Skip to content

docs content: 205 pages render two <h1> — 129 of them the same text twice #12236

Description

@os-zhuang

One-liner

DocsTitle renders the frontmatter title as the page <h1>. 205 of the 403 MDX files also open with a # Heading in the body, so those pages ship two <h1>s — and on 129 of them the two are the identical string.

Measured

$ curl -s http://localhost:3999/docs/data-modeling/objects | grep -o '<h1[^>]*>.*</h1>'
 h1: Object Metadata
 h1: Object Metadata          ← the body's own `# Object Metadata`

files with a body H1:                       205
  body H1 identical to the frontmatter title: 129
  body H1 different from the title:            76

Expected

  • Strip the leading body # … from the 129 files where it duplicates the title — the rendered page is unchanged apart from losing the second h1.
  • For the 76 where the body H1 says something different, demote it to ## rather than deleting it; the wording is often the more search-friendly of the two, so consider promoting that wording into the frontmatter title instead (coordinate with the page-title card rather than doing it twice).
  • Add a gate so a new page cannot reintroduce a body # heading — the repo already runs scripts/check-*.mjs style checks from Lint & 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

  • no page in content/docs/** renders more than one <h1>
  • pnpm check:* gains a check that fails on a body-level # heading in content/docs/**
  • anchors/links that pointed at the removed heading still resolve (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 is https://objectstack.ai — maintainer ruling recorded in #10659:

这个仓的文档站规范 URL 是 https://objectstack.ai

Activity

  1. self-assigned this
    on Aug 25, 2026
  2. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    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 under scripts/ 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 FILE content/docs/**. #12237 and #12238 rewrite frontmatter in the same files and are held for a later round — hard serial, one content card per round

  3. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    os-dev claim (dispatched by the PM comment above)

  4. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    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"
      ]
    }
    
  5. os-zhuang commented on Aug 25, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — PR #12262. The rework status 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):

    The four wording candidates the dev surfaced for #12237 (Documentation vs ObjectStack Documentation, Architecture vs Core Architecture, Command Line Interface vs @objectstack/cli, Sharing Rules vs Sharing & Organization-Wide Defaults) are exactly the input that card was told to wait for.

  6. claude commented on Aug 31, 2026

    @claude
    Contributor

    pm:dispatched dropped — closed-card residue

    domain:devx PM seat (#6023), R33, housekeeping only. This card is closed/completed and still wore pm: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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions