Skip to content

feat(docs): emit JSON-LD on the homepage, doc pages and blog posts - #12355

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12240-json-ld
Aug 25, 2026
Merged

feat(docs): emit JSON-LD on the homepage, doc pages and blog posts#12355
os-zhuang merged 2 commits into
mainfrom
claude/issue-12240-json-ld

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12240

Adds a schema.org @graph to the three served page shapes, built from the same page object generateMetadata() already reads, so the JSON-LD and the canonical/Open Graph layer cannot describe two different pages.

routeemits
/ (app/[lang]/page.tsx)Organization + SoftwareSourceCode
/docs, /docs/**Organization + TechArticle + BreadcrumbList
/blog/**Organization + BlogPosting
/blog (index)nothing — not asked for by the card, called out rather than left to be discovered

Every identifier and URL goes through absoluteUrl() from lib/site.ts.

Two decisions worth reviewing

SoftwareSourceCode, not SoftwareApplication. The card allows either. Google's Software App rich result requiresoffers, aggregateRating or review; ObjectStack is an Apache-2.0 runtime with no price, no store listing and no ratings, so a SoftwareApplication node would report missing-required-property errors in the very validator the acceptance criteria name, buying a rich result it can never be eligible for. SoftwareSourceCode has no Google rich-result feature and therefore no required properties, and codeRepository / programmingLanguage / runtimePlatform / license describe what this project is.

dateModified reuses the sitemap's own output rather than re-deriving it.lib/structured-data.tsx calls app/sitemap.ts's default export once per process and indexes the array. The JSON-LD value and the <lastmod> in /sitemap.xml are the same object serialised the same way, so the ruling on this card ("two different answers for the same page's date is worse than one absent date") is satisfied by construction, not by two derivations agreeing. Pages the sitemap ships without a <lastmod> get no dateModified here either. Verified over all 406 dated pages — see below.

Declared surface expansion — a fourth file

The card's file surface is the three page routes. This PR adds a fourth, apps/docs/lib/structured-data.tsx, for two reasons that are not style:

  1. Organization is the author/publisher of every doc page and every blog post. Three copies is three definitions of one company, free to drift.
  2. The JSON.stringify(...).replace(/</g, …) in JsonLd is the only thing between page frontmatter and a </script> injected into every page. Three copies is three places for that .replace() to go missing.

Contention checked before adding it, not assumed: at the time of writing no open PR in this repo touches apps/docs at all (enumerated over every open PR's file list). A new path cannot collide with #12242, which is queued behind this card on app/[lang]/page.tsx and the blog page.

The homepage's YOUTUBE_CHANNEL_URL moved into that module and is imported back, so the hero links and Organization.sameAs are one string.

⚠️ Breadcrumbs are complete on 231 of 403 doc pages, and that is a producer defect — filed as #12352

Measured on the rendered output of a local production build, not reasoned about:

docs URLs in sitemap: 403
complete trails: 231
short trails: 172

fumadocs-core's getBreadcrumbItems() links a folder crumb to folder.index?.url. Fumadocs attaches a folder's index.mdx as that index node only when the folder's meta.json does not list "index" in pages — and 17 of the 35 meta.json files under content/docs do list it. Those folders arrive with a title and no URL.

Confirmed causally by ablation: deleting the one line "index", from content/docs/data-modeling/meta.json, rebuilding, and re-reading the rendered page (then restoring the file — git diff HEAD empty, blob hash back to the HEAD blob):

before /docs/data-modeling/objects ObjectStack > Documentation > Object Metadata (3)
after /docs/data-modeling/objects ObjectStack > Documentation > Data Modeling(/docs/data-modeling) > Object Metadata (4)
control /docs/ai/agents ObjectStack > Documentation > AI Agents (3, unchanged)

Not worked around in this PR. Google requires item on every crumb but the last, so a name-only crumb is not an option; reconstructing the URL from the slug in the page component would make a content-config bug invisible and permanent. The un-linkable ancestor is dropped. When #12352 lands, these trails complete with no change to this code.

A hypothesis of mine that did not hold, recorded because I acted on it for a while: I expected those 17 folder-index pages to be navigation orphans too. Crawling all 408 sitemap URLs for inbound links found 4 orphans, none of them folder indexes — filed as #12353.

Emitted JSON-LD, verbatim

From next build && next start at 13791c71d, read out of the HTTP response. ⛔ Not from production: per #12333 the live site has served one unchanged deployment since ~16:55, so it is evidence about a build that predates this branch.

/

{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://objectstack.ai/#organization",
"name": "ObjectStack",
"url": "https://objectstack.ai/",
"logo": "https://objectstack.ai/logo.svg",
"sameAs": [
"https://github.com/objectstack-ai",
"https://www.youtube.com/@objectstack"
]
},
{
"@type": "SoftwareSourceCode",
"@id": "https://objectstack.ai/#software",
"name": "ObjectStack",
"url": "https://objectstack.ai/",
"headline": "Metadata framework for AI-written apps",
"description": "ObjectStack turns the whole app — data model, UI, workflows, permissions — into typed metadata: a complete CRM in under 150k tokens, one context window.",
"image": "https://objectstack.ai/hero-cover-dark.png",
"codeRepository": "https://github.com/objectstack-ai/framework",
"programmingLanguage": "TypeScript",
"runtimePlatform": "Node.js",
"license": "https://www.apache.org/licenses/LICENSE-2.0",
"author": {
"@id": "https://objectstack.ai/#organization"
},
"maintainer": {
"@id": "https://objectstack.ai/#organization"
}
}
]
}

/docs/data-modeling/objects

{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://objectstack.ai/#organization",
"name": "ObjectStack",
"url": "https://objectstack.ai/",
"logo": "https://objectstack.ai/logo.svg",
"sameAs": [
"https://github.com/objectstack-ai",
"https://www.youtube.com/@objectstack"
]
},
{
"@type": "TechArticle",
"@id": "https://objectstack.ai/docs/data-modeling/objects#article",
"headline": "Object Metadata",
"name": "Object Metadata",
"description": "Define business entities with ObjectSchema — the core building block of every ObjectStack application",
"url": "https://objectstack.ai/docs/data-modeling/objects",
"mainEntityOfPage": "https://objectstack.ai/docs/data-modeling/objects",
"inLanguage": "en",
"image": "https://objectstack.ai/og/docs/data-modeling/objects/image.png",
"dateModified": "2026-08-25T16:30:09.000Z",
"author": {
"@id": "https://objectstack.ai/#organization"
},
"publisher": {
"@id": "https://objectstack.ai/#organization"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://objectstack.ai/docs/data-modeling/objects#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "ObjectStack",
"item": "https://objectstack.ai/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Documentation",
"item": "https://objectstack.ai/docs"
},
{
"@type": "ListItem",
"position": 3,
"name": "Object Metadata",
"item": "https://objectstack.ai/docs/data-modeling/objects"
}
]
}
]
}

/blog/context-window-is-the-constraint

{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://objectstack.ai/#organization",
"name": "ObjectStack",
"url": "https://objectstack.ai/",
"logo": "https://objectstack.ai/logo.svg",
"sameAs": [
"https://github.com/objectstack-ai",
"https://www.youtube.com/@objectstack"
]
},
{
"@type": "BlogPosting",
"@id": "https://objectstack.ai/blog/context-window-is-the-constraint#article",
"headline": "The Constraint Isn't Typing Speed. It's the Context Window.",
"name": "The Constraint Isn't Typing Speed. It's the Context Window.",
"description": "AI can write an enterprise app. Whether it can maintain one depends on whether the whole system fits in its head — which is an argument about the target format, not the model.",
"url": "https://objectstack.ai/blog/context-window-is-the-constraint",
"mainEntityOfPage": "https://objectstack.ai/blog/context-window-is-the-constraint",
"inLanguage": "en",
"image": "https://objectstack.ai/hero-cover-dark.png",
"datePublished": "2026-07-17T00:00:00.000Z",
"dateModified": "2026-08-18T01:17:36.000Z",
"keywords": [
"ai",
"architecture",
"metadata",
"positioning"
],
"author": {
"@type": "Organization",
"name": "ObjectStack Team"
},
"publisher": {
"@id": "https://objectstack.ai/#organization"
}
}
]
}

How it was validated

Google's Rich Results Test was not run. It is a hosted tool that needs a public URL, and this branch is not deployed. The card's first acceptance box is therefore reported, not ticked — it can only close against a preview or production URL after deploys resume (#12333).

What was run instead: a structural check of every JSON-LD block the site serves, field by field, against schema.org's property ranges and Google's documented required/recommended properties. Over all 408 sitemap URLs:

{ "pages": 408, "withLd": 407,
"byType": { "Organization": 407, "SoftwareSourceCode": 1,
"TechArticle": 403, "BreadcrumbList": 403, "BlogPosting": 3 } }
failures: 0

The 408th is /blog, which emits none by design. Each block was asserted on:

  • exactly one ld+json script per page; payload parses as JSON; @context is https://schema.org
  • no raw < survives into the payload — the injection guard, checked on output rather than trusted
  • every URL-valued property is absolute and on https://objectstack.ai, except the three declared external ones (sameAs, license, codeRepository); no relative URL anywhere
  • every bare {"@id": …} reference resolves to a node defined in that same page's@graph (no cross-page dangling references)
  • Organization: name, url, logo, sameAs all present
  • TechArticle / BlogPosting: headline, image, author, publisher, mainEntityOfPage, url present; url equals the page's canonical; headline within Google's 110-character cap; datePublished / dateModified match ISO 8601
  • dateModified byte-equal to that page's <lastmod> in /sitemap.xml — the card's third acceptance box, checked on all 406 dated pages, e.g. /docs/data-modeling/objects carries 2026-08-25T16:30:09.000Z in both
  • BreadcrumbList: position contiguous from 1, every entry has a name and an absolute item, last entry is the page itself
  • SoftwareSourceCode.license is the Apache-2.0 URL (schema.org's license ranges over CreativeWork | URL, not Text, so the bare SPDX id Apache-2.0 is not a value it accepts)

Commands

Union re-run on the final commit 13791c71d, after the last push:

pnpm --filter '@objectstack/docs^...' build VERDICT command-exit 0
pnpm --filter @objectstack/docs typecheck VERDICT command-exit 0
npx next build (apps/docs) VERDICT command-exit 0
eslint . --no-inline-config (full repo, not narrowed) VERDICT command-exit 0
pnpm check:docs-locale-catch-all exit 0
pnpm check:page-declaration-shape exit 0
pnpm check:published-files exit 0
pnpm check:test-source-alias exit 0
pnpm check:type-source-resolution exit 0
pnpm check:nul-bytes exit 0

The five check:* families are what node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack derives for this diff; re-derived after the final commit and unchanged. check:nul-bytes added by standing rule, plus a direct control-byte scan of the four changed files (no hits).

⚠️ The repo-wide pnpm lintaborts with a V8 heap OOM on this host at its declared --stack-size=4000 with the default heap. It is not a narrowing and not a red: re-run as node --stack-size=4000 --max-old-space-size=8192 node_modules/eslint/bin/eslint.js . --no-inline-config, the full unnarrowed population passes clean in 46s.

⚠️ One red found and fixed inside this branch, worth naming because it is invisible in a diff: the second commit is comments-only, and it broke the build. A comment containing the glob content/docs/**/meta.json closes the block comment at */. tsc reported 18 syntax errors and eslint a parse error. Caught only because the gate union was re-run on the new head rather than skipped for "just comments".

Declared narrowing — verification ran UNLOCKED.scripts/pm/os-verify-lock.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not ship it), so the commands above were run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for those
runs, nor for any sibling agent in this container while they ran.

Out of scope, filed

No changeset: apps/docs is private and publishes nothing. skip-changeset applied.

Generated by Claude Code

hotlongand others added 2 commits August 26, 2026 03:31
Adds a schema.org @graph to the three served page shapes, built from the same
page data generateMetadata() already reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blog date comment asserted a YAML-timestamp coercion that the rendered
page falsifies, and the breadcrumb comment misattributed the missing ancestor
URL to the page not being listed. Both replaced with what was measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation labels Aug 25, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 20:00
@os-zhuang
os-zhuang added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 2491064Aug 25, 2026
29 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12240-json-ld branch August 25, 2026 20:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs site: no structured data (JSON-LD) anywhere

2 participants

@os-zhuang@hotlong