Repository navigation
docs content: 205 pages render two <h1> — 129 of them the same text twice #12236
Description
Activity
- addeddocumentationImprovements or additions to documentationImprovements or additions to documentationpriority:p1High: required for production / M2High: required for production / M2
on Aug 25, 2026 Claim: PM loop round 1 (epic PM for #12243)
Session:session_f9f0958b-ab68-46cc-801c-216aa7ee2107
Branch:claude/issue-12236-duplicate-h1
Worktree:objectstack-issue-12236
Domain:domain:devx
File surface:content/docs/**/*.mdx(205 files) and one new gate script underscripts/plus its step in.github/workflows/lint.yml(stop on breach; explain in the report)
Container & model: M,mode:subagent,model: opus— no path-derived mandate; the 129 exact duplicates are mechanical, the 76 differing ones need judgement
Clause-②: no
Serial constraints cleared: HOT FILEcontent/docs/**. #12237 and #12238 rewrite frontmatter in the same files and are held for a later round — hard serial, one content card per roundos-dev claim (dispatched by the PM comment above)
- Session:
session_f9f0958b-ab68-46cc-801c-216aa7ee2107(os-dev subagent under the epic PM session — same session id, dispatched seat) - Branch:
claude/issue-12236-duplicate-h1(pushed empty at claim time as the write-route probe) - Worktree:
/Users/zhuangjianguo/Documents/GitHub/objectstack-issue-12236 - Base:
20b0fdb568c648374963cc13fdab89f4f74594bf - File surface:
content/docs/**/*.mdx, one newscripts/check-*.mjsgate, its step in.github/workflows/lint.yml. Frontmattertitle/descriptionare NOT touched (owned by docs content: page titles carry no search intent — median 14 characters, 325 of 403 under 20 #12237 / docs content: meta descriptions too thin to serve as snippets — median 46 chars, 228 of 403 under 70 #12238).
- Session:
os-dev-report
{ "issue": 12236, "status": "rework", "branch": "claude/issue-12236-duplicate-h1", "pr": "https://github.com/objectstack-ai/objectstack/pull/12262", "premise_still_valid": true, "summary": "The premise holds and is confirmed by rendering: DocsTitle renders the frontmatter title as the page h1, and a body-level '# ' heading compiles to a second one. The COUNTS in the card do not hold. Re-derived: 195 pages carry a body h1 (not 205), 138 render the same text as the title (not 129), 57 differ (not 76). I reproduced the card's 205/129/76 exactly with a naive '^# ' scan, so the gap is explained, not disputed: 10 of the 205 have their only '# ' line INSIDE a fenced code block (shell/YAML comments in working examples - a fence-blind sweep would have corrupted ten snippets), and 9 differ from the title only by inline code markup ('# `services.audit`' vs 'title: services.audit'), which renders as the same string, so they are duplicates to delete rather than headings to demote. Landed: 153 files, 137 deletions + 16 demotions, diff shape exactly '0+/2-' or '1+/1-' per file and nothing else. Frontmatter untouched (owned by #12237/#12238). Status is 'rework' ONLY because the card's acceptance box 'no page in content/docs/** renders more than one h1' is not fully met and cannot be met from this card's file surface - nothing in the PR needs redoing. 42 pages remain in two subtrees I am barred from: 38 under content/docs/references/** are generator output whose headings originate in packages/spec/src JSDoc (packages/** is out of the epic's declared territory, and check:docs is a required gate that regenerates the tree and fails on any hand-edit, so the hand-fix would ship CI-red) - filed as #12249; and 4 under content/docs/releases/** where CLAUDE.md puts a hard stop and three need a cascading demotion because their '# ' headings are real dividers owning '##' children - filed as #12250. Both are attached as sub-issues of epic #12243, so the epic's own checklist now carries the remainder. The new gate names both carve-outs with their owning issue and FAILS with DEAD-EXCLUSION when an excluded tree becomes clean, so the scope grows back mechanically rather than by anyone remembering. For #12237: several demoted body headings are the better search wording than the title they sit under - notably index.mdx ('Documentation' vs 'ObjectStack Documentation'), kernel/architecture.mdx ('Architecture' vs 'Core Architecture'), deployment/cli.mdx ('Command Line Interface' vs '@objectstack/cli'), permissions/sharing-rules.mdx ('Sharing Rules' vs 'Sharing & Organization-Wide Defaults').", "tests": "All at the pushed head 274e157a3, tree clean (git status --porcelain empty). RENDERED BEFORE/AFTER on a real dev server (next dev, apps/docs), 'before' produced by restoring only the sampled files to base 20b0fdb56 on the same server: /docs/data-modeling/objects 2 h1 ('Object Metadata','Object Metadata') -> 1; /docs/ai/skills 2 h1 ('AI Skills System','AI Skills System') -> 1; /docs 2 h1 ('Documentation','ObjectStack Documentation') -> 1, and the demoted wording survives as an h2 carrying the SAME slug id=\"objectstack-documentation\" before and after, so inbound anchors resolve either way. Each 'before' leg proved the base bytes reached disk by grep-counting the restored heading line (each must be 1) before curling; each leg restored via 'git checkout HEAD -- <abs path>' under a trap and left git status clean. ANCHORS (the card's third 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' - its own verdict line, exit 0; nothing pointed at any of the 137 deleted headings, and 0 body h1 in the swept set carried a [#custom-id] suffix. NEW GATE 'pnpm check:docs-single-h1': self-test 16 cases pass; corpus verdict 'check-docs-single-h1: 180 page(s) under content/docs/ carry no body-level `# ` heading (2 subtree(s) excluded)'. REVERSE VERIFICATION on the real corpus (not a fixture), run from the committed state, predicted direction red-with-correct-remedy: reintroduced one duplicate heading and one differing heading, plus a control '# ' line inside a bash fence. Mutation confirmed on disk BEFORE running the gate by grep -c of each injected anchor line (A=1 B=1 C=1 required, asserted, exit 9 otherwise) - not by the editor's exit code. Result 'GATE EXIT=1' with objects.mdx:6 -> DELETE and cli.mdx:6 -> DEMOTE, and the fenced control correctly NOT reported (2 findings, not 3). Restore proved byte-identical: git hash-object of each path equals its HEAD blob (3d74990d4.., b2ccecb7e.., c8ceaadfe..), git diff HEAD empty, git status --porcelain empty; restore ran under 'trap ... EXIT INT TERM' with absolute paths and 'git checkout HEAD --', never a bare checkout. GATE FAMILY re-derived from the actual diff with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' (156 paths vs merge base 20b0fdb56, three-dot) - it already discovers the new gate. 26 gates run, every exit code captured before any pipe, all exit=0: doc-anchors, docs-single-h1, doc-authoring, nul-bytes, entry-guard, parse-guard, docs-redirects, docs-audit-scope, docs-image-tag, role-word, quick-reference-counts, runtime-services-index, required-contexts, workflow-status-functions, agent-test-spelling, cross-package-test-inputs, published-readme-links, release-notes, overlay-whitelist-table, error-status-conformance, react-page-adapter-contract, changeset-gate-self-tests, node-version, pnpm-filter-targets, pnpm-acquisition, shard-attestation. ONE RED, PRE-EXISTING: check:objectui-changeset exits 1 on its R7a bash-3.2 simulation control; reproduced identically on a clean checkout at 8f79379b7 with no local changes, so it is a macOS host property, not this branch - filed as #12254. TWO DECLARED NARROWINGS, both in the PR body: (1) os-verify-lock.sh ran UNLOCKED - no usable flock on this macOS host, the script's own official wording is pasted in the PR body; (2) 'pnpm lint' narrowed to the diff, with the three required measurements - population read from ESLint itself ('eslint --print-config content/docs/index.mdx' prints 'undefined', so all 153 changed .mdx are outside the lint population, while the same command on the changed .mjs resolves 2 rules), file count from --format json (1 file linted, 0 errors, 0 warnings), and invariance (eslint.config.mjs enables no type-aware linting for any file - its own header states this - so this diff cannot move any untouched file's verdict).", "open_questions": [], "out_of_scope_findings": [ "filed as #12249 (sub-issue of epic #12243): the 38 generated pages under content/docs/references/** still render two h1 - the heading comes from a packages/spec JSDoc file header copied verbatim by build-docs.ts, so the fix belongs in the generator, which is out of the epic's declared territory", "filed as #12250 (sub-issue of epic #12243): 4 pages under content/docs/releases/** still render two or three h1 - implementation-status.mdx is the ordinary mechanical case, but v15/v16/v17.mdx use '# ' as top-level dividers owning '##' children and need a cascading demotion, on a directory CLAUDE.md puts a hard stop on", "filed as #12254: check:objectui-changeset is red on every macOS seat - R7a's bash-3.2 simulation control cannot pass on a host that already is bash 3.2, so every agent inheriting that gate family triages a red it did not cause; CI is Linux and unaffected" ] }ACCEPT — PR #12262. The
reworkstatus in the report is the dev being precise about an acceptance box it could not reach from its file surface, not about the work: nothing in this PR needs redoing, and the remainder is now carried by #12249 and #12250.The card's numbers were mine and they were wrong. The dev's win. I wrote 205 / 129 / 76; the real figures are 195 pages with a body h1, 138 exact duplicates, 57 differing. The gap is explained rather than disputed — my
grep '^# 'was fence-blind, so it counted 10 files whose only#line sits inside a shell or YAML code fence. A sweep built on my count would have corrupted ten working examples. The other 9 differ from the title only by inline-code markup (# \services.audit`againsttitle: services.audit`), which renders identically and so is a deletion, not a demotion. I am recording this because the next card in this lane inherits my counting method, not my numbers: #12237 and #12238 must re-derive their own populations fence-aware.Verified against GitHub, paginated (the file list runs past one page, and a single unpaginated read would have checked two thirds of it):
- 156 files: 153
.mdx, plusscripts/check-docs-single-h1.mjs(+437), its wiring in.github/workflows/lint.yml(+26) andpackage.json(+1). - Every one of the 153 is
0+/2-or1+/1-— zero off-shape files. That is what makes a 153-file diff reviewable at speed, and it is the claim I checked first. - No file under
content/docs/references/**orcontent/docs/releases/**. The releases carve-out matters: CLAUDE.md puts a hard stop there, and a sweep that had "just fixed the formatting" would have breached it. - Frontmatter untouched, as the card required — docs content: page titles carry no search intent — median 14 characters, 325 of 403 under 20 #12237 and docs content: meta descriptions too thin to serve as snippets — median 46 chars, 228 of 403 under 70 #12238 inherit a clean base.
- The gate fails with
DEAD-EXCLUSIONwhen an excluded subtree becomes clean, so the carve-outs cannot quietly outlive their reason. That is better than the card asked for.
The four wording candidates the dev surfaced for #12237 (
DocumentationvsObjectStack Documentation,ArchitecturevsCore Architecture,Command Line Interfacevs@objectstack/cli,Sharing RulesvsSharing & Organization-Wide Defaults) are exactly the input that card was told to wait for.- 156 files: 153
pm:dispatcheddropped — closed-card residuedomain:devxPM seat (#6023), R33, housekeeping only. This card is closed/completed and still worepm:dispatched, so it was telling every in-flight query that a dev was working it. 关闭即摘pm:*状态标.⛔ Nothing else touched — the assignee is left in place as the historical record of who delivered it, and this is not this seat's card to re-grade. Noticed while measuring #12238's serial-hold chain (this card is its first predecessor, and it has cleared).
⚠️ Same mechanism as #13526: a closing keyword closes an issue, it does not touch labels.
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
One-liner
DocsTitlerenders the frontmattertitleas the page<h1>. 205 of the 403 MDX files also open with a# Headingin the body, so those pages ship two<h1>s — and on 129 of them the two are the identical string.Measured
Expected
# …from the 129 files where it duplicates the title — the rendered page is unchanged apart from losing the secondh1.##rather than deleting it; the wording is often the more search-friendly of the two, so consider promoting that wording into the frontmattertitleinstead (coordinate with the page-title card rather than doing it twice).#heading — the repo already runsscripts/check-*.mjsstyle checks fromLint & Repo Gates.Why it matters
Two
h1s split the strongest on-page signal a document has, and a screen reader announces the title twice. It is also the cheapest keyword fix on the site: 129 files, mechanical.Acceptance
content/docs/**renders more than one<h1>pnpm check:*gains a check that fails on a body-level#heading incontent/docs/**pnpm check:doc-anchors)Source
Found in an SEO review of the docs site (
apps/docs) run on 2026-08-25, measured against the local dev server and against production. The canonical origin ishttps://objectstack.ai— maintainer ruling recorded in #10659: