Repository navigation
generateMetadata returns four fields: docs pages ship no SEO title, no Open Graph, and no structured data #166
Description
What is on main
apps/docs/app/[lang]/docs/[[...slug]]/page.tsx:67-74 — generateMetadata returns exactly title, description, alternates.canonical, alternates.languages. Three keyword-bearing surfaces are therefore absent from all 79 English pages and every translation of them.
1. There is no SEO title distinct from the H1. title is the raw frontmatter title, rendered through the root template %s | ObjectOS (apps/docs/app/layout.tsx:7-10). Measured over the English pages on this branch:
| frontmatter title length | pages |
|---|---|
| 1 word | 42 |
| 2 words | 25 |
| 3 words | 9 |
| 4+ words | 3 |
So 42 of 79 pages (53%) emit a one-word title tag and 67 of 79 (85%) emit two words or fewer: Views | ObjectOS, Actions | ObjectOS, Pages | ObjectOS, Forms | ObjectOS, Storage | ObjectOS. The title tag is the most heavily weighted on-page ranking signal and on this site it currently carries no query term at all.
The same measurement shows the descriptions are in good shape — 79 of 79 present, only 3 over 160 characters, and specific ("List, Form, Kanban, Calendar, Gantt and more — how every object surface is declared"). The keyword-rich language has already been written; it is sitting in the one field that does not rank. Most of the title work is derivable from the description already on the page rather than authored from scratch.
2. There is no Open Graph or Twitter metadata. Grep for openGraph and twitter across apps/docs returns nothing outside node_modules. Meanwhile apps/docs/lib/source.ts:13-20 exports getPageImage() and apps/docs/app/og/docs/[...slug]/route.tsx statically generates a 1200x630 card for every page at build time — and nothing references either one. The generator is built and deployed dead. Every share of a docs link on X, LinkedIn, Slack or Discord renders as a bare URL.
3. There is no structured data. No JSON-LD is emitted on any route. A docs site with a clean nested URL shape earns BreadcrumbList rich results and TechArticle signals cheaply, and neither is present.
One line is in scope alongside these: apps/docs/app/og/docs/[...slug]/route.tsx:19 passes site="ObjectStack Protocol" into the card. This site is ObjectOS, and the string is a third spelling of the brand (see the entity-consistency card linked below).
What to change
- Extend the page schema so a page may declare an SEO title separate from its H1 — an optional frontmatter field, so the sidebar and the on-page H1 keep the short navigational noun while the title tag carries the query terms. The schema comes from
pageSchemainsource.config.ts:6; extending it there is the expected landing spot, but verify against the installedfumadocs-mdx15.0.7 /fumadocs-core16.8.12 rather than assuming the shape. - Have
generateMetadataprefer that field and fall back totitle, so nothing regresses on pages that never declare one. Do not change whatDocsTitlerenders. - Wire
openGraphandtwitter(summary_large_image) using the existinggetPageImage()helper, with the per-locale canonical URL already computed at line 65-72. - Emit JSON-LD:
BreadcrumbListderived from the slug segments andTechArticleper docs page. Keep it to those two here; the entity-levelOrganization/SoftwareApplicationmarkup depends on the positioning decision and is deliberately out of scope for this card. - Fix the
site=string on the OG route.
This card is the mechanism only. Authoring the actual titles is a separate card so that the two do not collide in the same function.
Verification
- Build and confirm the rendered
<head>of at least one nested page and one index page carries a distinct title,og:imagepointing at the generated card, and both JSON-LD blocks. - Confirm a page with no SEO title declared still renders its old title tag byte-for-byte.
- The OG card route must still build for every page (
generateStaticParamsatroute.tsx:31-35).
Scope note
Do not touch content/docs/** in this PR — no frontmatter authoring here. File surface is apps/docs/** plus source.config.ts.
Activity
hotlong commentedon Aug 25, 2026
Repair note — one phrase in the Verification section above was eaten by the issue sanitizer (it was written with angle-bracket markup). It should read:
Nothing else in the card was affected.
Generated by Claude Code
hotlong commentedon Aug 25, 2026
Held to the next round — same-file serial constraint, not shelved.
Round 1 dispatched #168, #169 and #170. This card was deliberately left in the queue because it collides with #169: both edit
generateMetadatainapps/docs/app/[lang]/docs/[[...slug]]/page.tsx.Recording the known trap now so the next round does not rediscover it:
languageAlternates()inapps/docs/lib/seo.tsso it takes the set of locales a given path actually has, instead of always returning all seven.generateMetadatais one of its two call sites (page.tsx:70-73); the other isapp/sitemap.ts.languageAlternates()and the post-mergegenerateMetadatarather than the line numbers quoted in the card body above. The card was written against the pre-Sitemap and hreflang advertise seven locales for pages that exist only in English — 41 of 79 pages #169 tree.openGraph/twitter, JSON-LD, and thesite=string on the OG route — are unaffected either way.Re-pricing question for the #169 dev, to be answered in that card's final report: does your change make this card simpler, harder, unnecessary, or unaffected?
Generated by Claude Code
hotlong commentedon Aug 25, 2026
Claim: PM loop round 2
Session:
session_01G5zjYc2BoFV2NjKBBapC7CBranch:
claude/issue-166-metadata-layerWorktree:
objectos-issue-166Domain: n/a (objectos carries no
domain:*lanes)File surface:
apps/docs/app/[lang]/docs/[[...slug]]/page.tsx,apps/docs/lib/source.ts,apps/docs/app/og/docs/[...slug]/route.tsx,source.config.ts(stop on breach; explain in the report)Container & model: M,
mode:subagent,model: opus— PM judgement;scripts/pm/dispatch-gates.mjslives in the objectstack repo and has no counterpart here.Clause-②: no
Serial constraints cleared: the blocker is gone — #169 merged as
93a366c(PR #176), so the same-file hold recorded above is released. Currently in flight: #168 (two layouts + a newnot-found.tsx) and #177 (llms-full.txt/route.ts); neither intersects this surface. #174 is queued behind this card on the samegenerateMetadataand must not be done here.Note the earlier hold comment on this card is now historical:
languageAlternates()has reached its final two-required-argument shape andtranslatedLocales()exists inlib/seo.ts, so this card reads a settled surface rather than inheriting a moving one. Line numbers quoted in the card body are stale —page.tsxgrew four lines.Generated by Claude Code
hotlong commentedon Aug 25, 2026
Dev claim (separate from the PM claim above).
Session:
session_01G5zjYc2BoFV2NjKBBapC7CBranch:
claude/issue-166-metadata-layer(pushed, empty, at base9c29df6)Worktree:
/home/user/objectos-issue-166Base:
9c29df6=origin/mainafter #176 and #179 landed.File surface I will touch, per dispatch:
apps/docs/app/[lang]/docs/[[...slug]]/page.tsx,apps/docs/lib/source.ts,apps/docs/app/og/docs/[...slug]/route.tsx,source.config.ts.Card body verified against the sanitizer damage: the Verification bullet reads "the rendered `` of at least one nested page" — the angle-bracket word inside the backticks was eaten, and the repair comment supplies it ("document head"). No other gap found in the body; implementing against the repaired text.
Not doing here, per the card and the dispatch:
#174remains open and is not addressed by this branch, entity-levelOrganization/SoftwareApplicationmarkup (blocked on the positioning decision card), and anycontent/docs/**frontmatter authoring.Generated by Claude Code
Generated by Claude Code
hotlong commentedon Aug 25, 2026
os-dev-report
{ "issue": 166, "status": "done", "branch": "claude/issue-166-metadata-layer", "pr": "https://github.com/objectstack-ai/objectos/pull/186", "premise_still_valid": true, "head_sha": "2d8e409", "base": "9c29df6", "files_changed": [ "apps/docs/source.config.ts", "apps/docs/app/[lang]/docs/[[...slug]]/page.tsx", "apps/docs/lib/source.ts", "apps/docs/app/og/docs/[...slug]/route.tsx" ], "file_surface_breach": false, "gates": [ { "command": "pnpm --filter @objectos/docs run type-check", "verdict": "VERDICT command-exit 0", "at_sha": "2d8e409" }, { "command": "pnpm --filter @objectos/docs run build", "verdict": "VERDICT command-exit 0", "at_sha": "2d8e409" }, { "command": "pnpm turbo run test", "verdict": "VERDICT command-exit 0", "at_sha": "2d8e409" }, { "command": "node .github/scripts/check-node-floor.mjs", "verdict": "exit 0 — every declared floor clears what the dependency tree requires", "at_sha": "2d8e409" }, { "command": "node .github/scripts/check-translations.mjs", "verdict": "exit 0 — translations gate passed", "at_sha": "2d8e409" } ], "ci_status": "in_progress — reported at draft-PR time, not awaited", "summary": "generateMetadata now emits a distinct SEO title, Open Graph, Twitter summary_large_image and two JSON-LD blocks. Added an optional seoTitle frontmatter field via pageSchema.extend in source.config.ts; generateMetadata prefers it and falls back to title, so pages declaring nothing render unchanged. Wired openGraph/twitter onto the existing dead getPageImage() card and the per-locale canonical already computed there. Emitted BreadcrumbList and TechArticle only. Replaced the OG route's site=ObjectStack Protocol with a single exported SITE_NAME constant read by both use sites. DocsTitle, alternates.canonical, alternates.languages and languageAlternates() are untouched; no content/docs file is in the diff.", "tests": "All gates above run at final commit 2d8e409 and read from the gate's own printed verdict line, not a bare exit code. Rendered-HTML verification read from apps/docs/.next/server/app/**, not from source. Nested page en/docs/build/interface/views.html: title 'Views | ObjectOS', og:title Views, og:image https://docs.objectos.ai/og/docs/build/interface/views/image.png, og:url + canonical per-locale, og:type article, og:site_name ObjectOS, og:locale en, twitter:card summary_large_image; both JSON-LD blocks present and json.loads-valid, breadcrumb resolving 4 real page titles ObjectOS/Build/Interface/Views. Index pages verified too: /docs/build/interface (3-item trail, TechArticle headline 'Interface') and docs root /docs (1-item trail). Per-locale: ja emits og:locale ja and TechArticle inLanguage ja with the /ja/ URL; zh-Hans maps to zh_Hans. NO-REGRESSION MEASURED ACROSS THE WHOLE SURFACE: for all 79 built English pages the title tag equals frontmatter-title + ' | ObjectOS', 79 of 79 matched, 0 mismatched. DECLARED-PATH PROBE (temporary, reverted, never committed): added a seoTitle to one content page and rebuilt (build exit 0) — title tag, og:title, twitter:title and TechArticle headline all became the declared value while the rendered H1 and the last breadcrumb crumb stayed 'Views'. Mutation confirmed on disk before the run (blob hash moved, seoTitle count 0 to 1); file then restored and proven byte-identical to its HEAD blob, git status of content/docs empty. ABLATION of the schema wiring: removing the seoTitle extension from source.config.ts and re-running type-check goes RED, exit 2, error TS2339 'Property seoTitle does not exist on type DocCollectionEntry' printing the schema-derived frontmatter type without the field — direct evidence page.data.seoTitle resolves against the generated collection type and not a permissive any. No dist/build artifact is involved in this ablation: type-check regenerates .source from source.config.ts in-process, and the mutation was proven on disk by hash (a46822eb to 331520a7, seoTitle count 4 to 0) before the run, with restore verified against the HEAD blob and git diff HEAD empty after. Page helpers take the inferred InferPageType precisely so this stays measurable — an earlier hand-written structural annotation would have type-checked whether or not the extension reached the generated types, and was replaced for that reason. OG route still prerenders 79 paths.", "pm_mechanism_assumption": "CONFIRMED IN DIRECTION, FALSIFIED IN DETAIL. Extending pageSchema in source.config.ts is the right landing spot: pageSchema is a plain Zod 4 ZodObject, .extend() works, and the base schema was measured to SILENTLY STRIP an undeclared seoTitle, so extending is required rather than optional. But the obvious implementation is not available — zod is a PEER dependency of fumadocs-core and is not resolvable from apps/docs (require.resolve('zod') fails; it exists only in pnpm's hidden store, off the resolution path), and fumadocs-mdx/config re-exports no z. Importing zod would have meant adding a dependency to package.json plus the lockfile, outside the declared file surface. Built the field from the existing schema instance instead: seoTitle: pageSchema.shape.title.trim().min(1).optional(). No dependency added, surface intact.", "deviations": [ "JSON-LD renders in the document BODY, not the head. The card's Verification section asks for the head; the Next.js Metadata API has no channel for a script element (meta and link only), so the blocks are rendered from the page component. Google reads application/ld+json from either location and this is the placement Next.js documents. Both blocks are present and parse as valid JSON. Flagged in the PR body.", "seoTitle is schema-tightened with .trim().min(1) so an empty or whitespace-only value is rejected at build time. Contract-first: this is what lets the consumer use a plain ?? fallback rather than a tolerant one that would paper over the empty string.", "Touched apps/docs/lib/source.ts (inside the declared surface) to add the exported SITE_NAME constant. lib/seo.ts is the more natural home but is OUTSIDE the declared surface, and would also create a circular import since seo.ts already imports source.ts.", "Field name seoTitle was chosen by me — #167 specifies the mechanism but never names the field. It is the contract #167 will author against.", "Verification required one temporary content/docs mutation, since no page declares seoTitle and this card authors no content. It was proven on disk, reverted, and confirmed byte-identical to its HEAD blob. Nothing from content/docs is in the diff or the PR.", "Dedup scan before filing #185 used the MCP issue listing, not the REST list endpoint: REST is unavailable to this session (the token returns 'GitHub access is not enabled for this session. An org admin must connect the Claude GitHub App'). Declaring the fallback channel.", "No changeset and no skip-changeset label: this repo has no changeset flow at all (.changeset absent, no changeset gate in ci.yml). Deliberately did NOT create the label here — minting a repo label that does not exist would read as a real mechanism to the next agent." ], "out_of_scope_findings": [ "filed as #185: every locale's og:image resolves to the same English share card — getPageImage builds a locale-free URL and the OG route calls source.getPage without a language argument. Dormant until this PR wired og:image; same missing-language-argument family as #177 and #175." ], "open_questions": [] }Generated by Claude Code
Generated by Claude Code
hotlong commentedon Aug 25, 2026
ACCEPT — PM review of record. PR #186 at
2d8e409.main,Fixes #166✓changed_files: 4, exactly the declared surface ✓buildandNode floorbothcompleted/success✓apps/docs/**; no governed surface ✓content/docs/**absent from the diff;canonical/languages/languageAlternates()untouched; #174 not smuggled in ✓My assumption was right in direction and wrong in detail — third time this backlog
I said "extending
pageSchemainsource.config.tsis the expected landing spot, but verify." Both halves earned their keep:pageSchemais a Zod object,.extend()is the mechanism, and the base schema was measured to silently strip an undeclaredseoTitle— so extending is required, not optional. Good thing that was measured rather than assumed, because a silent strip would have looked like "the field doesn't work" with no error to chase.zodis a peer dependency of fumadocs-core and does not resolve fromapps/docsat all. Importing it would have meantpackage.jsonplus the lockfile — outside the declared surface. Building the field offpageSchema.shape.titleinstead keeps the surface intact and gets a better field than a hand-rolledz.string()would have.The ablation is what carries this
type-checkgoing red withTS2339naming the field — and printing the schema-derived frontmatter type without it — is direct evidence thatpage.data.seoTitleresolves against the generated collection type rather than a permissiveany. Most schema wiring is verified by "it built"; that proves nothing, because it would also build if the field wereany.The note that the page helpers take the inferred
InferPageTypespecifically so this stays measurable — and that an earlier hand-written structural annotation was replaced because it would have type-checked either way — is the part I want on the record. That is designing the code so the test can fail.Alongside it: 79 of 79 English pages unchanged, 0 mismatched, and a declared-path probe showing the title tag,
og:titleandTechArticleheadline follow the declared value while the H1 and the last breadcrumb crumb stayViews. That is exactly the separation this card exists to create, demonstrated in both directions.Three judgement calls, all correct
.trim().min(1)on the field. Rejecting an emptyseoTitleat build time is what earns the plain??fallback in the consumer. A tolerant consumer would have papered over the empty string and produced a bare| ObjectOStitle on some page months from now.TechArticlecarries nopublisher/author. Those are entity-level claims, and which entity this site speaks for is exactly what [Decision] What does docs.objectos.ai target — the root URL is a redirect, the index title is "ObjectOS | ObjectOS", and the brand is spelled three ways #171 has not decided. ATechArticlewithout them is valid; one asserting the wrong entity would have to be retracted. Correctly propagating an unresolved decision into the schema instead of guessing a value.SITE_NAMEinlib/source.tsrather thanlib/seo.ts.seo.tsis the more natural home but is outside the surface and would create a circular import, since it already importssource.ts. Right call, declared.Deviation accepted: JSON-LD in the body
The Metadata API emits meta and link only — it has no channel for a script element — so the blocks render from the page component. Google reads
application/ld+jsonfrom either location and this is the placement Next.js documents. My card's Verification wording said "document head"; that was me writing the convention rather than the constraint. The blocks are present, valid, and one level down from where that sentence reads. Card wording was wrong, not the implementation.Field name
seoTitlewas chosen by the dev because #166 specified the mechanism and never named the field. Confirming it as the contract: #167 authors againstseoTitle.#185
Correctly filed and correctly left alone. Note it is a different shape from #177's: that one opened a door onto an existing defect (net negative until closed, hence p0). This one connected a wire — every locale now gets an English share card where before there was no card at all. Net improvement with a known residual, so it triages as ordinary queue work, not a priority.
Proceeding to ready and merge.
Generated by Claude Code
fix(docs): stop advertising breadcrumb crumbs for paths that have no …
docs: SEO titles for the 67 English pages whose title tag was two wor…