Skip to content

docs(content): one h1 per docs page — 137 deletions, 16 demotions, and a gate - #12262

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-12236-duplicate-h1
Aug 25, 2026
Merged

docs(content): one h1 per docs page — 137 deletions, 16 demotions, and a gate#12262
os-zhuang merged 2 commits into
mainfrom
claude/issue-12236-duplicate-h1

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12236

DocsTitle renders the frontmatter title as every doc page's heading-one
(apps/docs/app/[lang]/docs/[[...slug]]/page.tsx), so a body-level # heading
compiles to a second one inside DocsBody. This removes the second heading
from every hand-written page and adds a gate so it cannot come back.

The counts are not the card's counts — re-derived, and mine are smaller

The card measured 205 / 129 / 76. Those come from a ^# scan that does not strip
fenced code and compares raw markdown byte-for-byte. Reproduced exactly, then
re-derived properly:

cardre-derivedwhy
pages with a body # heading20519510 pages' only # line is a shell or YAML comment inside a fenced code block — it renders as code, never as a heading
body heading renders the title1291389 pages differ only by inline code markup: title: services.audit vs # `services.audit` — rendered, the same string
body heading says something else7657the remainder

The ten fence hits are the load-bearing correction. A fence-blind sweep does not
merely over-report by ten, it deletes or demotes a line inside a working
example
— ten corrupted copy-pasteable snippets to satisfy a check about HTML
those lines never produced. They are:

content/docs/getting-started/index.mdx # Install pnpm globally
content/docs/protocol/kernel/index.mdx # 47-step deployment runbook
content/docs/protocol/objectql/index.mdx # customer.object.yml
content/docs/protocol/objectql/schema.mdx # YAML (Recommended for readability)
content/docs/protocol/objectql/types.mdx # Encodes business meaning
content/docs/protocol/objectui/actions.mdx # (an imperative-React contrast)
content/docs/protocol/objectui/concept.mdx # customer_edit.view.yml
content/docs/protocol/objectui/index.mdx # customer.formview.yml (15 lines)
content/docs/protocol/objectui/layout-dsl.mdx # page_template: wizard
content/docs/upgrading.mdx # docker-compose.yml, or your ...

The nine inline-code pages are classified as duplicates and their headings deleted.
Demoting them would have left a ## services.audit sitting directly under a
heading-one reading services.audit — the duplicate this card is about, preserved
one level down.

What changed: 153 files, exactly two diff shapes

137 files 0 insertions(+), 2 deletions(-) the heading line + one blank line
16 files 1 insertion(+), 1 deletion(-) `# ` -> `## `
---
153 files changed, 16 insertions(+), 290 deletions(-)

Every one of the 153 removed non-blank lines is a # heading; all 16 added lines
begin ## . Nothing else rides along. Per the ruling, a heading whose wording
differs from the title is demoted, not deleted — the wording survives, and so
does the slug, so no inbound anchor moves.

The 16 demotions, reviewed by hand:

filefrontmatter titlebody heading, now ##
automation/hook-bodies.mdxHook & Action Bodies (L1 / L2)Hook & Action Bodies
concepts/architecture.mdxProtocol ArchitectureThe Protocol Stack
concepts/metadata-driven.mdxMetadata-Driven DevelopmentWhat is Metadata-Driven Development?
data-modeling/formulas.mdxExpressions (CEL)Expressions
deployment/cli.mdxCommand Line Interface@objectstack/cli
getting-started/common-patterns.mdxCommon PatternsCommon Patterns Guide
index.mdxDocumentationObjectStack Documentation
kernel/architecture.mdxArchitectureCore Architecture
permissions/authentication.mdxAuthenticationAuthentication Guide
permissions/profiles.mdxProfiles (removed)Profiles — removed (ADR-0090 D2)
permissions/sharing-rules.mdxSharing RulesSharing & Organization-Wide Defaults
plugins/development.mdxPlugin DevelopmentPlugin Development Tutorial
protocol/kernel/i18n-standard.mdxInternationalization StandardInternationalization (i18n) Standard
protocol/objectql/security.mdxSecurity & Access ControlSecurity Protocol
protocol/objectql/state-machine.mdxState Machine (Lifecycle)State Machine Protocol
ui/forms.mdxForms (Public + Internal)Forms

Frontmatter title and description are untouched in this PR — #12237 and
#12238 own that surface. Several of those 16 body headings are the better search
wording (ObjectStack Documentation, Core Architecture, Authentication Guide);
that observation is routed to the title card rather than acted on here.

The gate: pnpm check:docs-single-h1

New scripts/check-docs-single-h1.mjs, wired into Lint & Repo Gates. Its header
is the authority on the rule; the three things worth knowing here:

  • Fenced code is stripped before anything looks for a heading. That is the
    whole difference between a gate and a hazard, per the table above.
  • Nothing re-models Markdown.stripFrontmatter and flattenHeadingText are
    imported from check-doc-anchors.mjs, stripFencedBlocks from
    check-adr-links.mjs. flattenHeadingText was already diffed against the real
    fumadocs pipeline over the whole corpus, and it is what makes the delete/demote
    call right on the nine inline-code pages. A private second model would have got
    those nine wrong, and a gate that disagrees with the renderer about what a page
    says is no better than no gate.
  • Two subtrees are excluded, and the exclusions retire themselves. A carve-out
    whose tree becomes clean is a DEAD-EXCLUSIONfailure, so it cannot outlive
    its reason. A run that judges zero pages fails too.
ℹ EXCLUDED content/docs/references/** — 38 page(s) still carry a body `# ` heading, owned by #12249
ℹ EXCLUDED content/docs/releases/** — 4 page(s) still carry a body `# ` heading, owned by #12250
✓ check-docs-single-h1: 180 page(s) under content/docs/ carry no body-level `# ` heading (2 subtree(s) excluded, see --list).

Those two trees are not finished by this PR, and both are filed:

Neither #12249 nor #12250 is closed by this PR, and neither is #12254 below.

Verification, at 274e157a3

Rendered pages, before and after, same dev server. The card's own page plus two
more; the "before" leg restores just those files to the base commit so only the
content differs:

pagebeforeafter
/docs/data-modeling/objects2 headings — Object Metadata, Object Metadata1Object Metadata
/docs/ai/skills2 headings — AI Skills System, AI Skills System1AI Skills System
/docs (a demotion)2 headings — Documentation, ObjectStack Documentation1Documentation

On /docs the demoted wording is still on the page as a level-two heading and
still carries the same slug
, id="objectstack-documentation", before and after —
so an inbound anchor to it resolves either way.

Anchors — the card's third acceptance box, and the PM's stated risk.

✅ check-doc-anchors: 278 internal #fragment link(s) across 408 source file(s) all resolve to a real heading

Nothing pointed at any of the 137 deleted headings. No page in the swept set used a
[#custom-id] suffix on its body heading (measured: 0).

The gate can go red — reverse-verified on the real corpus from the committed
state, not on a fixture. Reintroducing one heading of each kind, plus a control:

GATE EXIT=1
content/docs/data-modeling/objects.mdx:6 → DELETE: renders the same text as the frontmatter title
content/docs/deployment/cli.mdx:6 → DEMOTE: says something the title does not

The third mutation — a # line added inside a bash fence in
getting-started/index.mdx — was correctly not reported: 2 findings, not 3.
Each file was then restored with git checkout HEAD -- <path> and proved
byte-identical to its HEAD blob via git hash-object, with git diff HEAD empty.
The self-test carries 16 cases, both fence spellings included.

Gates run locally, all green at 274e157a3:check:doc-anchors,
check:docs-single-h1, check:doc-authoring, check:nul-bytes,
check:entry-guard, check:parse-guard, check:docs-redirects,
check:docs-audit-scope, check:docs-image-tag, check:role-word,
check:quick-reference-counts, check:runtime-services-index,
check:required-contexts, check:workflow-status-functions,
check:agent-test-spelling, check:cross-package-test-inputs,
check:published-readme-links, check:release-notes,
check:overlay-whitelist-table, check:error-status-conformance,
check:react-page-adapter-contract, check:changeset-gate-self-tests,
check:node-version, check:pnpm-filter-targets, check:pnpm-acquisition,
check:shard-attestation — the family scripts/pm/dispatch-gates.mjs derives for
this diff, which already discovers the new gate.

One gate in that family, check:objectui-changeset, is red — and was red before
this branch existed
. Reproduced on a clean checkout at 8f79379b7 with no local
changes: its R7a leg simulates a bash-3.2 shell to prove R7b is not vacuous, and on
macOS the host shell already is bash 3.2, so the control cannot pass. Filed as
#12254 with the measurement; CI is Linux and unaffected.

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 command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.

bash rungates.sh check:doc-anchors check:docs-single-h1 ... check:shard-attestation

Declared narrowing — pnpm lint was run over the diff, not the repo. Three
measurements, not an assertion:

  1. Population, read from ESLint's own config, not from my guess.
    eslint --print-config content/docs/index.mdx prints undefined — no flat-config
    block matches .mdx, so all 153 changed content files are outside the lint
    population entirely. The same command on scripts/check-docs-single-h1.mjs
    resolves 2 rules, so the one linted file in this diff really is linted.
  2. File count, read from --format json. 1 file linted, 0 errors, 0 warnings.
  3. Invariance for untouched files.eslint.config.mjs never enables type-aware
    linting — its own header states this and the config carries no
    parserOptions.project and no typed rules for any file — so nothing in this diff
    can move the verdict on a file it did not touch.

Landing

Docs content plus one gate: nothing is published, so there is no changeset and the
PR carries skip-changeset. Draft, and expected to stay open for review.

Generated by Claude Code

os-zhuangand others added 2 commits August 25, 2026 23:02
DocsTitle renders the frontmatter title as the page h1, so a body-level
'# ' heading makes the page ship a second one. 137 pages repeated the
title verbatim (heading deleted); 16 said something different (demoted
to '## ', which keeps the wording and keeps the slug).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fences are stripped before anything looks for a heading: a naive scan
reports 205 files, ten of which are shell comments inside working
examples. The frontmatter and heading-text models are imported from
check-doc-anchors rather than retyped, so the gate cannot disagree with
the renderer about what a heading says.
Two subtrees are excluded with named owners, and an exclusion whose tree
is clean is a FAILURE, so the carve-outs retire themselves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filedocumentationImprovements or additions to documentationsize/lskip-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 content: 205 pages render two <h1> — 129 of them the same text twice

1 participant

@os-zhuang