Skip to content

feat(WIP): migrate from docusaurus to fumadocs - #61

Draft
fionnachan wants to merge 148 commits into
OffchainLabs:mainfrom
fionnachan:main
Draft

fionnachan wants to merge 148 commits into
OffchainLabs:mainfrom
fionnachan:main

Conversation

@fionnachan

@fionnachan fionnachan commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

fionnachan and others added 30 commits September 11, 2026 16:32
Context: the Docusaurus site injected a "Request an update" badge into every
doc page through a swizzled DocItem/Content (src/components/HeaderBadges). The
Fumadocs docs page has no equivalent, so readers have no one-click path from a
wrong page to a filed issue.

Why: keep the reporting path readers already know, and keep it server-rendered.
Upstream read window.location inside <BrowserOnly>, which forced the badge to be
client-side. Here page.url is known on the server, so the link ships as plain
HTML with no extra JavaScript and no hydration on any docs page.

What:
- components/RequestUpdateLink.tsx builds the prefilled GitHub new-issue URL
  from gitConfig (OffchainLabs/Fumadocs-test), the page path in the title and
  the absolute page URL in the body. NEXT_PUBLIC_SITE_URL supplies the origin,
  matching app/layout.tsx; without it the body falls back to the site-relative
  path. Styling reuses Fumadocs buttonVariants (secondary, sm) so it matches
  MarkdownCopyButton, with a lucide PencilLine icon.
- app/docs/[[...slug]]/page.tsx renders it in the action row after
  ViewOptionsPopover, and the row gains flex-wrap so three actions plus the
  version switcher wrap instead of overflowing on a narrow viewport.
- INTERNALS.md describes the action row next to the catch-all route.

References: plan M-20 in
.claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md;
upstream src/components/HeaderBadges/index.tsx.

Refs FS-2659

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context: content/vars.json had 38 keys covering chain parameters, node
images, and Nitro repo pins. Upstream arbitrum-docs/src/resources/globalVars.js
has grown to 69 keys since this migration branched, so several values
Stylus and reference pages hardcode locally are now available as
upstream variables instead.

Why: FS-2657 asks for a diff against upstream and, for every upstream
key that a page actually references via @@key@@, adding it here plus
replacing the matching hardcoded value with <Var name="..." /> so it
stays in sync automatically instead of drifting again.

What:
- Diffed vars.json (38 keys) against globalVars.js (69 keys) with a
  small node script. Every key present in both already had the same
  value, so no existing value needed to change (including the pinned
  nitroVersionTag, which matches upstream and was left alone).
- Added 15 upstream-only keys to both content/vars.json and the
  varsSchema in content/vars.ts (l1SlotTimeSeconds, l2BlockTimeMs,
  maxCodeSizeBytes, gasTargetSpeedLimit, maxDataSizeL2, maxDataSizeL3,
  dasMaxStoreChunkBytes, timeboostRoundSeconds,
  timeboostAuctionClosingSeconds, timeboostNonExpressDelayMs,
  stylusRustToolchain, stylusRustToolchainFull, cargoStylusVersion,
  stylusSdkVersion, aepRevenueSharePercent). Each is referenced by at
  least one upstream page via @@key@@.
- Skipped 19 upstream-only keys that no upstream page references:
  l1AvgBlockTimeSeconds, the arbOne/arbNova/arbGoerli snapshot URLs
  that duplicate the L2/classic archive snapshots already covered,
  the nitro-contracts repo/commit/path trio,
  nitroPrecompilesPathToInterfaces, and the Goerli-specific gas floor,
  dispute window, force-include period, and block gas limit keys
  (Goerli is a deprecated testnet with no local page referencing it).
- Replaced hardcoded values with <Var name="..." /> in the 9 ported
  Stylus pages where the value sits in prose, not inside a fenced code
  block, inline code span, or frontmatter (none of which the MDX
  pipeline runs through the component renderer): quickstart.mdx,
  fundamentals/testing-contracts.mdx, cli-tools/overview.mdx,
  cli-tools/commands-reference.mdx, how-tos/using-constructors.mdx,
  how-tos/trait-based-composition.mdx, how-tos/importing-interfaces.mdx,
  reference/overview.mdx, concepts/vm-differences.mdx.
- Left the remaining hardcoded sites for the newly-added keys untouched
  (mostly "250ms" block-time prose and Timeboost/AEP parameters).
  They live under content/docs/launch-arbitrum-chain/** and
  content/docs/how-arbitrum-works/**, and
  content/docs/run-a-node/nitro/cli-flags-reference.mdx is
  machine-generated by M-41, so all are outside this ticket's conflict
  domain per the migration plan's file-ownership table.

Refs FS-2657

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
FS-2658 (plan M-17). `<FAQStructuredDataJsonLd faqsId>` is used on six pages
in content/docs (bridging, building, building-orbit, building-stylus,
get-started, node-running), but only building-orbit-faqs.json existed under
components/mdx/FAQStructuredData/data/. For the other five ids, the
component's FAQ_MAP lookup missed, hit `if (!faqs)`, logged a console.warn,
and returned null: no runtime error, no build error, just a silently
missing JSON-LD block and no visible FAQ content on those five pages.

Why:
Search engines need the FAQPage JSON-LD to show FAQ rich results, so a
silent no-op is a real regression from the upstream Docusaurus site (whose
component resolves the same six ids via `require` against static/). Nothing
in the existing gates (types:check, build) renders MDX content, so this gap
had no signal until now.

What:
- Copied bridging-faqs.json, building-faqs.json, building-stylus-faqs.json,
  get-started-faqs.json, and node-running-faqs.json from upstream
  arbitrum-docs static/ into components/mdx/FAQStructuredData/data/,
  unchanged. Diffed the existing building-orbit-faqs.json against upstream
  too; it is byte-identical, so left as is. All six files parse as arrays of
  { question, answer, key } string triples, matching FAQ in types.ts as is.
- Replaced the single-entry FAQ_MAP in index.tsx with all six ids, each a
  static import (no dynamic require/import-by-template-string, so the
  bundler can tree-shake and the map stays statically analyzable).
- Added a FaqsId string-literal union to types.ts and typed FAQ_MAP as
  Record<FaqsId, FAQ[]>, so a future consumer written outside MDX gets
  compile-time exhaustiveness checking.
- Changed the unknown-id branch from `console.warn` + `return null` to
  `throw`. MDX is not type-checked against FaqsId, so a bad id can still
  reach the component at runtime; throwing turns that into a loud render
  error instead of a silently missing JSON-LD block.
- Added scripts/lib/faq-data.mjs (extractFaqsIdUsages, listMdxFiles,
  validateFaqEntries, checkFaqData) plus scripts/faq-data-check.mjs (a thin
  CLI wrapper, human and --json output, matching the vars-check/nav-check
  convention) and scripts/faq-data-check.test.mjs (12 pure-fixture
  node:test cases, picked up by `pnpm test`'s `scripts/**/*.test.mjs` glob).
  The check cross-references every faqsId used in content/docs against the
  data directory and validates each data file's shape; verified it fails
  correctly by temporarily removing bridging-faqs.json and confirming the
  gap and exit code 1.

Verification:
- pnpm types:check, pnpm test (68/68), pnpm check-links, pnpm vars:check,
  pnpm nav:check, pnpm partials:check, pnpm references:check: all pass.
- node scripts/faq-data-check.mjs: "every faqsId has a well-formed data
  file."
- Dev server on port 3107: curled all six consumer pages
  (launch-arbitrum-chain/troubleshooting-building-arbitrum-chain,
  stylus/troubleshooting-building-stylus, run-a-node/faq,
  arbitrum-bridge/troubleshooting, build-decentralized-apps/
  troubleshooting-building, get-started/faq) and confirmed each returns 200
  with a `<script type="application/ld+json">` block, `@type: "FAQPage"`,
  and mainEntity length matching each JSON file's entry count (31, 15, 14,
  6, 28, 21 respectively).

References:
Refs FS-2658

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Context
The contract-address reference partial rendered on the chain-info and
contract-addresses pages was imported as a static file. Upstream keeps it
honest with scripts/generate-contract-addresses.ts, which reads the
@arbitrum/sdk network registry; here nothing did, so a protocol upgrade or an
SDK bump would leave the published addresses silently wrong.

Why
An address in this partial is the kind of value a reader copies straight into a
transaction, so "stale and plausible" is the worst failure mode available. The
SDK ships the registry as data, so deriving the partial costs one devDependency
and no network access, and the weekly upstream-refresh PR then surfaces any
change for review instead of waiting for a bug report.

What
- scripts/generate-contract-addresses.mjs: the runner. Reads the SDK for the
  protocol-core and token-bridge addresses, the data file for what the SDK does
  not expose, and writes the partial through the shared writeOrCheck helper.
  --check exits 1 and prints a line-level diff so a reviewer can tell an address
  change from a formatting one.
- scripts/lib/contract-addresses.mjs: the pure rendering half, so the markdown
  shape can be tested against fixture networks rather than against whatever the
  SDK ships today. Every address is normalised to its EIP-55 checksum, which
  <AddressExplorerLink> requires and the SDK does not always return.
- scripts/data/contract-addresses.data.mjs: the hand-maintained addresses (core
  proxy admin, fraud proofs, resource constraint manager, canonical factories,
  precompiles) and the chain column order.
- scripts/generate-contract-addresses.test.mjs: 13 cases over checksum
  normalisation, missing and invalid addresses, table shape, and one rendered
  network block.
- @arbitrum/sdk pinned at 4.1.0 as a devDependency, the same pin upstream uses
  and the newest 4.x exposing the network objects the script reads.
- package.json: contracts:generate and contracts:check.
- upstream-refresh.yml: a contracts:generate step after precompiles:generate,
  so an SDK bump lands in the same weekly PR.
- The first run reproduced the committed partial byte for byte. The only line
  that changed is the do-not-edit marker, which now names the pnpm scripts and
  the new data-file path instead of the yarn command and the upstream .ts path.
- INTERNALS.md and CLAUDE.md: contracts:check added to the run-by-hand list, the
  workflow description updated, and a note in Partials that this file and the
  precompile tables are generated. The frontmatter that partials:check warns
  about (R3) is reproduced deliberately, because CATALOG.md reads its title and
  summary from those keys; removing it is a catalog change for its own commit.

References
Refs FS-2670
Plan M-40, .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md
Upstream: scripts/generate-contract-addresses.ts, scripts/contract-addresses.data.ts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context: the Solidity quickstart renders the "free cupcakes" vending machine
three times (web2, web3 on a local chain, web3 on Arbitrum Sepolia); the
contrast between them is the lesson of the page. All three rendered as a
PendingWidget placeholder here, so the page taught nothing it could show.

Why: viem rather than the upstream ethers v6, per the plan's open decision 3 —
it is the OffchainLabs default and adds no second wallet stack. Reads go through
a public client and writes through a wallet client, both over the injected
EIP-1193 provider, so the demo follows whatever network the reader selects.
No chain or contract address is hardcoded, because readers deploy their own
instance from Remix, exactly as upstream assumed.

What:
- components/mdx/VendingMachine/VendingMachine.tsx is the client component:
  web2 mode keeps balances in tab memory with the quickstart's five-second rule,
  web3 mode reads the balance, sends giveCupcakeTo, waits for the receipt and
  re-reads to confirm. A missing wallet renders a notice instead of throwing,
  and failures surface as text under the widget rather than window.alert.
- abi.ts transcribes the two functions of the compiled VendingMachine.json as a
  TypeScript `as const`; the artifact's bytecode was never used, and the const
  gives viem its argument and return types.
- index.tsx is a 'use client' wrapper that next/dynamic-loads the implementation,
  keeping viem out of every other docs page's bundle while leaving server
  rendering on.
- components/mdx.tsx registers VendingMachine in place of the PendingWidget alias.
- viem 2.56.3 pinned in package.json; INTERNALS.md documents the widget and the
  lazy-wrapper pattern.

References: plan M-21 in
.claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md;
upstream src/components/VendingMachine/.

Refs FS-2660

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
`pnpm drift` listed `how-arbitrum-works/bold/bold-faq.mdx` (upstream, added
2026-08-26) and `run-arbitrum-node/arbos-releases/arbos61.mdx` (added
2026-07-16) as absent from this repo. Upstream created `bold-faq.mdx` by
splitting the FAQ out of `bold/gentle-introduction.mdx`; locally that FAQ still
lived inside the overview page.

Why:
Content parity with upstream is a cutover requirement, and ArbOS 61 activates on
Arbitrum One and Nova on 2026-08-20, so the release page has to be live. Copying
the FAQ onto a new page without removing it from the overview would have served
the same 168 lines at two URLs.

What:
- Add `content/docs/how-arbitrum-works/bold/bold-faq.mdx` from upstream, with
  `content_type: faq` (upstream's `gentle-introduction` value is not in the
  local enum), quicklook anchors converted to `<Term>`, `@@var@@` converted to
  `<Var>` for `arbOneDisputeWindowDays` and `arbOneForceIncludePeriodHours`, and
  every internal link rewritten to an absolute `/docs/...` path that resolves
  here.
- Remove the duplicated FAQ section from `bold/gentle-introduction.mdx` and link
  the new page from its Related reading list, mirroring the upstream split.
- Add `content/docs/run-a-node/arbos-releases/arbos61.mdx` from upstream, with
  the `docs.arbitrum.io` links repointed at local pages and the Trail of Bits
  ArbOS 60/61 report pointed at the copy already in `public/audit-reports/`.
- Register both pages in their `meta.json`, and reorder the ArbOS release list
  newest first to match upstream's sidebar.

References:
- Upstream: OffchainLabs/arbitrum-docs `docs/how-arbitrum-works/bold/bold-faq.mdx`,
  `docs/run-arbitrum-node/arbos-releases/arbos61.mdx`
- Plan M-12 in .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md

Refs FS-2653

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context: how-bold-bisection-works showed a PendingWidget placeholder where the
Docusaurus site replays a real Arbitrum Sepolia challenge: a d3 tree per
challenge level, stepped through event by event, with a node inspector and an
event timeline. The page's explanation of bisection depends on it.

Why: the logic, the layout and the d3 drawing code are carried over close to
verbatim, because they encode how a BoLD challenge actually unfolds and a
rewrite would risk changing what the diagram claims. What did change is
everything Docusaurus-specific: BrowserOnly and useBaseUrl are gone, and the
SCSS with its two hand-maintained palettes became one stylesheet reading
Fumadocs tokens.

What:
- components/mdx/EdgeChallengeFlow/ holds the ten upstream modules (control bar,
  d3 tree, flow steps, node details, event timeline, state hook, logic, types,
  constants) plus edge-challenge-flow.css and a lazy index.
- Colours: every surface, border and text colour reads a --color-fd-* token, so
  the diagram follows the site theme. Only the four status hues (active,
  bisected, has-rival, OSP confirmed) stay bespoke, declared once per theme as
  CSS variables the d3 code never sees, since the legend refers to them.
- The 236 KB event log lives at public/data/edge-challenge-flow.json and is
  fetched on mount rather than bundled. proxy.ts passes that path through as-is,
  so no bypass-list entry is needed.
- index.tsx dynamic-imports the diagram with ssr disabled: it measures its
  container and draws with d3, so there is nothing to render without a DOM, and
  d3 stays out of every other docs page's bundle.
- components/mdx.tsx registers EdgeChallengeFlow in place of the PendingWidget
  alias; d3 7.9.0 and @types/d3 7.4.3 pinned; INTERNALS.md documents the widget.

References: plan M-22 in
.claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md;
upstream src/components/InteractiveDiagrams/Bold/EdgeChallengeFlow/ and
static/data/edge-challenge-flow.json.

Refs FS-2661

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
Upstream arbitrum-docs sets `showLastUpdateTime: true` in docusaurus.config.js
and injects twitter:card, twitter:site, twitter:title, twitter:description and
twitter:image from src/theme/Layout.tsx. Neither survived the Fumadocs port, and
docs pages also emitted no canonical URL, so every page competed with its own
`?v=` and `.md` variants in search results.

Why:
Readers use the date to judge whether a runbook is still current, and social
cards are how docs links render in chat and on X. Both are cutover blockers in
the migration plan (M-35). The date needs a guard: fumadocs-mdx reads it from
`git log --name-only`, and in a shallow clone git grafts the oldest commit as a
parentless root, so it reports that commit as adding every file beneath it.
Measured here at --depth=10, Vercel's default: 430 of ~450 pages came back
stamped with one boundary commit that in full history touched no content at all.
A wrong date on every page is worse than no date, so the feature disables itself
unless the checkout has complete history.

What:
- source.config.ts: enable `lastModified` on the `docs` and `docsVersions`
  collections, gated behind a `hasFullGitHistory()` probe
  (`git rev-parse --is-shallow-repository`) that also covers a missing git.
- app/docs/[[...slug]]/page.tsx: render "Last updated on <date>" under the
  description as a muted `<time dateTime>` line, omitted entirely when the date
  is unknown. An archived `?v=` view shows the archive file's own date.
  Formatting is pinned to en-US and UTC so output does not depend on the
  rendering machine.
- app/docs/[[...slug]]/page.tsx: add `alternates.canonical` (from `page.url`, so
  `?v=` views canonicalize to the live page) and `twitter` with
  summary_large_image, site @Arbitrum, and the existing OG image, all resolved
  against `metadataBase` in app/layout.tsx.
- lib/versions.ts: carry the optional `lastModified` field on `VersionedEntry`,
  which is hand-declared rather than inferred from the generated collection.
- INTERNALS.md, CLAUDE.md: document both, including that a reviewer must set
  `VERCEL_DEEP_CLONE=true` in the Vercel project for dates to appear, and that
  an unset NEXT_PUBLIC_SITE_URL points every canonical at localhost.

`.github/workflows/ci.yml` is deliberately untouched: no gate reads the dates,
so raising actions/checkout fetch-depth would cost clone time for nothing.

References:
- Refs FS-2668
- Plan M-35, .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md
- https://www.fumadocs.dev/docs/mdx/last-modified
- Upstream: docusaurus.config.js:104, src/theme/Layout.tsx

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
`pnpm drift` reported `how-arbitrum-works/deep-dives/batchposter.mdx`,
`stf.mdx`, `arbos.mdx`, and `sequencer.mdx` as gutted against upstream. Reading
each page against upstream showed the drift tool had paired three of the four
with the wrong upstream file: it matched by filename across sections, so
`deep-dives/batchposter.mdx` was compared against the operator how-to
`launch-arbitrum-chain/run-a-node/batch-poster.mdx`, and `stf.mdx` / `arbos.mdx`
against `launch-arbitrum-chain/extend-the-protocol/{stf,arbos}.mdx`. Upstream has
its own `how-arbitrum-works/deep-dives/` versions of all three, and those are the
real counterparts.

Why:
Restoring an operator how-to into a concept page would have put chain-owner
configuration into `how-arbitrum-works` and duplicated pages that already exist
here. Measured against the correct upstream files, only the Sequencer page had
lost real material.

What:
- `sequencer.mdx`: restore the six "Sequencing and broadcasting" subsections, the
  "Batching and compression" breakdown with the Brotli compression levels, the
  "Submitting to the Sequencer Inbox contract" blobs-vs-calldata detail with
  "Authority and finality", and the "Finality" section. Add the page-contents
  list. Keep the local-only sections (the five-stage transaction path, the
  Sequencer coordinator, the Timeboost express lane) and the local `<Var>` and
  `/docs/...` link conversions.
- `batchposter.mdx`: add the "Related resources" list, switch the diagram to the
  upstream `batchposter-path.svg` with its descriptive alt text, and adopt
  upstream's parent-chain/child-chain wording in place of L1/L2.
- `stf.mdx`: restore the conceptual opening upstream keeps in
  `01-stf-gentle-intro.mdx` (what an STF is, offchain batched execution, fraud
  proofs, Nitro's STF) and the Stylus lead-in, ahead of the local six-step input
  pipeline.
- `arbos.mdx`: add the cross-references upstream introduced (ArbOS state
  reference, gas and fees, precompile architecture and reference, child-to-parent
  messaging), the gas pricing paragraph, and the corrected wording for the Geth
  sandwich and the "start block" system transaction.

The five `haw-*.svg` Sequencer diagrams the 2026-08-13 gap spec listed as
orphaned are already referenced by the local page; no diagram in `public/img` is
orphaned by these four pages after this change.

References:
- Upstream: OffchainLabs/arbitrum-docs `docs/how-arbitrum-works/deep-dives/`
  `sequencer.mdx`, `batchposter.mdx`, `arbos.mdx`, `01-stf-gentle-intro.mdx`
- Plan M-13 in .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md
- .claude/docs/superpowers/specs/2026-08-13-arbitrum-docs-content-gap.md

Refs FS-2654

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context
content/docs/run-a-node/nitro/cli-flags-reference.mdx lists every flag the
Nitro node binary accepts. It was imported as a static page. Upstream generates
it from scripts/data/nitro-cli-flags.json, but nothing regenerates that JSON
either, so on both sides the page is only as fresh as the last person who
remembered to refresh it by hand.

Why
Node operators copy flags and defaults off this page into production configs, so
a stale default is a real operational hazard, and 784 rows is far too many to
review by eye. Tying the page to the Nitro tag already pinned in vars.json means
the weekly upstream-refresh PR surfaces every change with a diff a reviewer can
actually read.

What
- scripts/generate-cli-reference.mjs: materialises the Nitro tree at
  `nitroVersionTag` (shallow clone, or `git archive` from a local checkout given
  --nitro-path) plus its pinned go-ethereum submodule, extracts the flags, and
  writes the page. --check exits 1 with a line-level diff.
- scripts/lib/go-source.mjs: a small Go reader (comments, delimiters, composite
  literals, var/const declarations, pflag registration functions), indexed by
  package directory so the several `package main` directories under cmd/ cannot
  answer each other's lookups.
- scripts/lib/nitro-cli-flags.mjs: walks the registration tree from
  NodeConfigAddOptions, composing each dotted name from the prefix its caller
  passes, and resolves defaults back to the struct literal they point at.
  Handles the shapes Nitro actually uses: defaults passed as parameters, local
  aliases, closures that copy and tweak a base struct, durations, shifts,
  append, fmt.Sprintf usage strings, and pflag's own type-name shortening.
- scripts/lib/cli-reference-page.mjs: renders the page and splices it between
  `{/* GENERATED:START */}` and `{/* GENERATED:END */}`, so the frontmatter and
  any prose a writer adds outside the markers survive a regeneration. Upstream
  rewrote the whole file and had no way to keep a local edit.
- scripts/data/nitro-cli-reference.data.mjs: namespace guide links rewritten for
  this site's /docs routes, the exclusion rules ported verbatim, and the two
  flag classes the reader cannot evaluate.
- scripts/generate-cli-reference.test.mjs: 29 cases over a miniature Go fixture
  tree plus the renderer and the marker splice.
- package.json: cli:generate and cli:check.
- upstream-refresh.yml: a cli:generate step after the Nitro pin bump. The job
  still installs nothing but Node, which is why the generator parses Go source
  instead of running `nitro --help` (that needs a Go toolchain and the Rust
  arbitrator artifacts).

Reading the source rather than a --help dump
Anything the reader cannot evaluate stops the run rather than rendering a blank
cell. Two classes are declared in the data file instead: five flags default to
util.GoMaxProcs(), decided at process start, and two are registered with f.Var
and a custom pflag.Value. The go-ethereum submodule is mandatory, not optional:
the whole execution.rpc.* namespace is registered there, and a missing submodule
would drop 19 flags with no error.

First run against v3.11.3, against the committed page
- 61 flags added, all real at v3.11.3 and absent from the older upstream dump:
  the AnyTrust storage backends (local-file, S3, Google Cloud, Redis cache,
  key), the staker blob-tx data-poster knobs, and transaction filtering.
- 1 removed: execution.transaction-filtering.address-filter.enable, renamed
  upstream to execution.transaction-filtering.enable.
- Of the 713 flags present in both, 701 are byte-identical. Twelve differ:
  - 2 genuine Nitro changes: file-logging.max-backups 20 to 40, and
    node.transaction-streamer.shutdown-on-blockhash-mismatch now defaulting to
    true with a rewritten description.
  - 5 GOMAXPROCS flags, now naming the symbol instead of publishing the core
    count of whoever ran --help.
  - 4 rows where upstream's --help scrape misfiled text: "account to use
    (default is first account in keystore)" had its parenthetical parsed as the
    Default column on three wallet flags, and max-fee-cap-formula had pflag's
    "(default ...)" swallowed into the description.
  - 1 f.Var flag, compression-levels, whose default was "null".

References
Refs FS-2671
Plan M-41, .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md
Upstream: scripts/generate-cli-reference.ts, scripts/data/nitro-cli-flags.json

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
The previous commit gated the `lastModified` collection option behind a
`hasFullGitHistory()` probe, passing `false` when the checkout is shallow. That
passed types:check locally and failed the Gates job in CI.

Why:
`lastModified` is part of the collection's type contract, not only its
behaviour. fumadocs-mdx adds `lastModified?: Date` to the generated DocData only
while the option is truthy, so `false` deletes the field from the type and
app/docs/[[...slug]]/page.tsx then fails with TS2339 "Property 'lastModified'
does not exist". CI checks out shallow (actions/checkout defaults to
fetch-depth: 1), so the gate passed or failed according to how the repository
happened to be cloned. A type must not depend on the runtime environment.

The guard itself still has to exist, because in a shallow clone git reports the
grafted boundary commit as adding every file beneath it, which stamped 430 of
~450 pages with one wrong date. So the fix keeps the guard and changes only how
"no dates" is expressed: both branches are now truthy, and the difference is the
data they resolve, not the type they generate. It stays a build-time decision
because pages render on demand in a serverless runtime that has neither git nor
the repository.

What:
- source.config.ts: `lastModified` is now `true` when history is complete, and
  otherwise an async resolver returning undefined for every file, so the field
  is always present on the generated type and simply carries no value. Comments
  record why it must never be `false`.
- INTERNALS.md, CLAUDE.md: document the constraint so the next person does not
  reintroduce `false`.

Verified against a simulated CI checkout: `git clone --depth 1` of this branch
fails types:check with TS2339 on the parent commit and passes on this one, and
the generated DocData carries `lastModified?: Date` in both the shallow and the
full checkout. Full gates re-run in the worktree: types:check, test (58),
check-links, vars:check, nav:check, partials:check, references:check, prettier.

References:
- Refs FS-2668
- Fixes the Gates failure on #6

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
Five docs pages under content/docs/launch-arbitrum-chain/configuration/
data-availability/** used <Var name="latestNitroNodeImage" /> inside a
fenced shell code block or an inline code span, expecting it to render
the current Nitro Docker image tag. MDX does not evaluate components
inside code, so readers saw the literal tag text instead, both in the
prerequisite bullet lists and in two copy-pasteable `docker run`
commands. This was found while restoring content in FS-2655 and no
existing gate (types:check, vars:check) can see it, since MDX is never
type-checked and vars:check only proves the key resolves, not where
it's used.

Why:
The fix has two parts per occurrence: hardcode the current value from
content/vars.json inside the code so the command stays runnable, and
keep (or add) a live <Var name="latestNitroNodeImage" /> reference in
the adjacent prose so a reader, or a future automated diff, can check
the hardcoded value against the source of truth. A lint rule is added
so this class of defect cannot silently reappear; it follows the
existing A1..A5 rule numbering scheme in scripts/lib/content-lint.mjs
rather than introducing a new registration mechanism.

What:
- das-docker-deployment.mdx: fix one inline-code bullet and two
  `docker run ... keygen` fenced blocks (BLS and ECDSA keypair steps),
  hardcoding offchainlabs/nitro-node:v3.11.3-beb2108 and adding/adjusting
  the adjacent sentence to carry the live <Var>.
- deploy-das.mdx, deploy-mirror-das.mdx: fix the same inline-code bullet
  pattern.
- scripts/lib/content-lint.mjs: add rule A6, flagging any <Var> found
  inside a fenced code block or an inline code span. Implemented by
  walking source directly (not the code-stripped `text` used by the
  other rules) with the same fence/inline-span regexes stripCode uses,
  so a fence's own backticks are never mistaken for an inline-code
  delimiter.
- scripts/content-lint.mjs: register the A6 title for the human report.
- scripts/lib/content-lint.test.mjs: four new tests, a Var in prose
  passes, a Var in a fenced block fails, a Var in an inline span fails,
  and the rule applies the same way to partial-shaped content with no
  frontmatter.
- README.md ("Use a variable") and INTERNALS.md ("Global variables"):
  one paragraph each documenting that variables do not work inside
  code and that content-lint rule A6 enforces it.

Verified pnpm content:lint reports zero A6 findings after the fix, and
the pre-existing 13 findings from other rules (A2: 7, A5: 6) are
unchanged. Confirmed in a browser (temporary, uncommitted
remarkImageOptions.external: false toggle for the unrelated FS-2681
dead-image bug) that none of the three fixed pages render a literal
<Var> tag.

References:
- Linear FS-2682
- Plan: .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md (M-04)
- scripts/lib/content-lint.mjs (existing A1..A5 rules and stripCode)
Refs FS-2682

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Context: the pre-review of the components stack flagged three added lines
carrying an em dash (U+2014), which the house style does not use. One of them is
the INTERNALS.md paragraph describing this widget.

What: the sentence now uses a colon and a comma instead, and the paragraph is
rewrapped to the file's width. No behaviour or code changes.

References: pre-review comments on the components stack.

Refs FS-2660

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fs-2661-port-the-bold-edgechallengeflow-diagram
Context: the pre-review raised two points on this widget. The built chunk was
288 KB because the tree imported `d3` wholesale, which drags in d3-geo and the
rest of the toolkit for a diagram that needs four things. And the render effect
had no cancellation, so while the dynamic import was in flight a second render
could start, both could pass the `svg.empty()` check, and each would append its
own <svg>.

What:
- D3EdgeTree imports only the five functions it calls, from d3-selection,
  d3-hierarchy, d3-shape and d3-zoom, loaded together in one Promise.all. The
  `d3` and `@types/d3` dependencies are replaced by those four packages and
  their types, all pinned.
- The effect passes a cancellation check into renderTree, which returns as soon
  as the import resolves if a newer render has superseded it; the cleanup flips
  the flag.

Result: the single 288 KB chunk is gone. The tree now sits in a 24 KB component
chunk, with the d3 pieces split across on-demand chunks of 32 KB (shape), 36 KB
(zoom), 16 KB (hierarchy) and 16 KB (selection). No built chunk contains d3-geo
any more. The page still draws 81 nodes across exactly three SVGs after
"Show All", so nothing double-appends.

References: pre-review comments on the components stack.

Refs FS-2661

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
Pre-review of PR #6 flagged that `alternates.canonical` resolves against
metadataBase, which falls back to http://localhost:3000, and that NEXT_PUBLIC_*
values are inlined at build time. A production build with the variable unset
would therefore ship every docs page with a canonical and social image URL
pointing at localhost.

Why:
That failure is silent in the worst way. The pages render correctly, nothing
errors, and the damage is visible only to crawlers, which are told the canonical
copy of every page lives on a machine they cannot reach. A wrong canonical is
worse than no canonical, so the site URL is now read through one helper that
refuses to guess it.

Where the guard has to live is the surprising part. The natural home is the
`getSiteUrl()` call at module scope in app/layout.tsx, and it does throw there,
but that never runs during a build: `pnpm build` uses
`--experimental-build-mode=compile` and `generateStaticParams` returns `[]`, so
no page or layout module is evaluated at build time. Measured: with the helper
in place, a build with VERCEL_ENV=production and no NEXT_PUBLIC_SITE_URL
completed successfully. The misconfiguration would have surfaced only as a
site-wide 500 on the first request after promotion to production. next.config.mjs
is the earliest thing a build does evaluate, so the enforcing check lives there
and the helper's throw is the backstop for anything that does evaluate it.

What:
- lib/shared.ts: add `getSiteUrl()`. Returns NEXT_PUBLIC_SITE_URL, falls back to
  http://localhost:3000 outside production, throws when VERCEL_ENV or
  NEXT_PUBLIC_VERCEL_ENV is `production` and the variable is unset. No imports,
  so app/sitemap.ts and app/robots.ts can adopt it without pulling lib/source
  toward a client bundle.
- next.config.mjs: fail the build on the same condition, with the same message.
- app/layout.tsx: build metadataBase from the helper instead of reading the
  environment inline.
- app/docs/[[...slug]]/page.tsx: build the canonical absolutely from the helper
  rather than relying on metadataBase resolution.
- scripts/lib/site-url.test.mjs: six cases covering configured, unset outside
  production, both production flags, and empty string. Each runs in a subprocess
  with --experimental-strip-types, because `node --test` cannot import
  TypeScript; they skip with a reason on a Node 22 older than 22.6.
- INTERNALS.md, CLAUDE.md: document the helper, the throw, and why the build-time
  check is duplicated in next.config.mjs.

Verified end to end with the dead third-party images temporarily ignored so the
build could reach a verdict: VERCEL_ENV=production with no NEXT_PUBLIC_SITE_URL
now fails with the intended message, the same build with the variable set
compiles, and a dev render with it set emits
`<link rel="canonical" href="https://docs.arbitrum.io/docs/stylus/quickstart">`
and matching og/twitter image URLs. That toggle is not in this commit.

References:
- Refs FS-2668
- Pre-review of #6

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context
The repo was pinned to fumadocs-core/ui 16.15.1, fumadocs-mdx 15.3.1,
fumadocs-twoslash 3.3.0 and next 16.3.2. Wave 0 of the production migration plan
requires the site to run on the current Fumadocs release before the content and
component waves start branching off main.

Why
Staying on the older pins means every Wave 1 stack would inherit an upgrade of its
own later, and lockfile conflicts would multiply across twelve parallel branches.
Doing the bump first, alone, keeps the breaking change surface in one reviewable
diff.

What
- fumadocs-core and fumadocs-ui 16.15.1 to 16.15.9.
- fumadocs-mdx 15.3.1 to 15.4.0.
- fumadocs-twoslash 3.3.0 to 3.3.1. Version 4.0.0 exists but depends on
  typescript ~7.0.2 while this repo is on typescript ^6.0.3, so it is deliberately
  left for a separate ticket.
- next 16.3.2 to 16.3.4.
- app/llms.txt/route.ts: `llms(source).index()` returns a promise as of
  fumadocs-core 16.15.9 and was synchronous in 16.15.1. The handler is now async
  and awaits it. This was the only type error the bump introduced.
- INTERNALS.md: the installed-package table now states the new versions.

`generateStaticParams` in app/docs/[[...slug]]/page.tsx is untouched; the Next
prerender crash it works around was not retested here.

References
Plan: .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md (M-01)

Refs FS-2649
* Fix drift resolution and pin the Node runtime

Context
`pnpm drift` failed on every checkout but one with "legacy tree not found at
/Users/allup/OCL/arbitrum-docs/docs", even though scripts/data/upstream.config.json
declares `repo: "../arbitrum-docs"` and three `probePaths`. The config's own
$comment described a resolution order implemented in scripts/lib/upstream-tree.mjs,
a module that did not exist: nothing read the config at all.

Forced to run with --tree-a, it then reported 13 absent and 12 gutted, of which nine
findings were wrong. Pages were paired one at a time, so a bare-filename fallback could
claim a local file that a directory-qualified match had already taken.

Separately, `engines` pinned Node 22 while Vercel's default runtime has moved ahead
of it and nothing in the repo told a fresh machine or a fresh build which major to use.

Why
A drift report that needs a machine-specific flag to run is a report nobody runs, and
one where a third of the findings are wrong is a report nobody believes. The plan's
cutover criteria require drift to reach zero, so the number has to mean something first.

What
- scripts/lib/upstream-tree.mjs, new: resolves the upstream checkout in the order the
  config always claimed. `--tree-a` (the docs tree, relative to the cwd), then
  UPSTREAM_DOCS_REPO (the repo root, relative to the cwd), then `repo`, then
  `probePaths` (both relative to this repo's root, so a git worktree resolves the same
  as the main checkout). A leading `~` is expanded. Resolution is pure apart from an
  injected existence probe, so the order is testable without a second checkout.
- scripts/upstream-drift.mjs: uses the resolver, and the hardcoded absolute path is
  gone. On failure it prints every location it looked at instead of one path. Tree B is
  now anchored at the repo root too, so `pnpm drift` works from any directory rather
  than crashing with a readdir stack trace. `addedDate` derives its pathspec from the
  resolved paths instead of assuming the docs tree is `docs/`.
- scripts/lib/tree-compare.mjs: `pairTrees` replaces per-file resolution. Two passes over
  the whole tree: directory-qualified matches first, each claiming its Tree B file, then
  the bare-slug fallback for the rest, never onto an already-claimed file. Upstream keeps
  a concept page and a how-to page under the same basename (arbos, stf, and
  batchposter versus batch-poster), so the how-to was stealing the concept page's local
  counterpart. That produced three GUTTED findings at 0.12, 0.20 and 0.48 that only
  measured a how-to against a concept page, and hid three unported how-tos behind them:
  one mispairing giving two wrong answers in opposite directions. A fourth case was
  quieter still, upstream chain-config/costs/gas-optimization.mdx paired against the
  unrelated Stylus best-practices/gas-optimization.mdx, whose line count cleared the 70%
  ratio, so it produced no finding at all.
- All four turned out to be plain renames, now in RENAME_MAP with their local targets
  verified on disk, along with 01-stf-gentle-intro.mdx, which is a rename to
  deep-dives/stf.mdx at ratio 1.74 rather than the absorption it was assumed to be. It
  moved out of absentAllowlist as a result: a rename keeps comparing the two pages on
  every run, an exemption stops looking. Two further renames found during FS-2651,
  da-api-guide and precompiles, are in the same map.
- scripts/data/upstream.config.json: two allowlists, kept separate on purpose.
  `absentAllowlist` now holds only sequencer-content-map.mdx, replaced by index.mdx plus
  meta.json by design. `guttedAllowlist` exempts four pages that WERE ported at content
  parity where the ratio only counts Docusaurus import lines and inline grid boilerplate:
  chain-info.mdx, get-started/overview.mdx, oracles-content-map.mdx and
  overview/introduction.mdx, the last being a restructure into comparison tables that the
  FS-2652 agent verified block by block. Merging the lists would let an exemption earned
  for one reason quietly cover the other. An absent-exempt page is still compared for
  GUTTED, so an exemption can never hide content loss in the page that absorbed it.
- scripts/upstream-drift.test.mjs: 22 new cases covering the resolution order and its
  precedence, `~` expansion, refusing to guess when nothing exists, both allowlists and
  their separation, whole-tree pairing including order independence and the
  one-file-one-claim rule, and every rename target. Several assert against the checked-in
  config, and one asserts every RENAME_MAP target and allowlist `local` path is a file
  that exists, so an entry that rots into a no-op fails the suite instead of quietly
  regrowing a false positive.
- .node-version containing `22`, matching upstream arbitrum-docs. Vercel, nvm, fnm and
  asdf all read it. `engines` is unchanged at >=22 <23.
- INTERNALS.md: new "The Node runtime" and "Upstream drift" sections. CLAUDE.md mirrors
  both. Both record that the Vercel project's Node.js Version must be set to 22.x by
  hand, because .node-version pins the build while the project setting is what the
  deployed functions run on, and a mismatch is reported nowhere.

Verified: `node scripts/upstream-drift.mjs` with no flags, from this worktree, now
reports 9 absent and 5 gutted and names the checkout it used.

References
Plan: .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md (M-02,
and the Node 22 LTS decision recorded at the end of the plan)

Refs FS-2650

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Expire drift allowlist entries and enforce one local file per upstream page

Context
Pre-review of PR #1 accepted the four guttedAllowlist entries, since the FS-2655 and
FS-2652 agents each read those pages side by side and deliberately chose not to restore
them, but raised that a permanent exemption cannot be allowed to silence future upstream
changes. It also asked for the one-file-one-claim rule to hold in pairing pass 1, not
only in the fallback pass.

Why
An exemption is a judgement about one version of a page. Upstream keeps committing, so
without an expiry the surest way to hide a real future gap is to have already allowlisted
the page it lands in. The claim rule has the same shape of hole: it was enforced in the
bare-slug fallback but not in the directory match, and two renames can point at one local
file, so the second would silently re-pair onto it and be measured against content that
is not its own.

What
- Allowlist entries now carry `reviewedUpstreamSha`, the git blob hash of the upstream
  page when the exemption was granted. `isAllowlistStale` compares it to the file's
  current hash, and drift re-flags the pair as STALE-ALLOWLIST, failing the run, once
  upstream edits the page. A missing hash counts as stale, so an entry added without one
  demands a review instead of being trusted. `gitBlobHash` computes git's blob hash in
  process rather than shelling out; a test pins it against two known git hashes, and it
  was cross-checked against `git hash-object` on a real upstream file.
- All five checked-in entries record their current hash, and a test requires a
  40-character hash on every entry in both lists.
- `pairTrees` enforces the claim in pass 1 as well as pass 2. The one exception is a
  deliberate two-into-one port, declared with `merge: true` on both upstream entries.
  RENAME_MAP values accept `{ to, merge: true }` alongside the plain string form, and the
  two entries pointing at batch-posting-assertion-control.mdx are marked. Both passes now
  iterate in sorted order, so which page wins a contested file never depends on readdir.
- A test asserts that any RENAME_MAP target shared by two upstream paths has `merge: true`
  on every one of them, so a future accidental collision fails the suite rather than
  quietly mispairing.
- Removed `allowlistedAbsent` and `allowlistedGutted`, superseded by `allowlistEntries`,
  which keeps the whole entry so the recorded hash stays reachable. They had no callers
  left outside their own tests, and an exported accessor that production does not use is
  the same trap that `resolveTreeBMatch` was.
- CLAUDE.md records that the freshness guard refuses to run against a clone last fetched
  more than 24 hours ago, and how to clear it. INTERNALS.md documents the merge flag and
  the exemption expiry alongside it.

Counts are unchanged at 9 absent, 5 gutted, 5 allowlisted, 0 stale. The merge flag is
load-bearing for that: without it one half of the batch-poster merge would report ABSENT.

References
Pre-review: #1 (comment)

Refs FS-2650

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Context: the Docusaurus site loaded posthog-docusaurus behind a
VERCEL_ENV === 'production' gate and captured pageviews from the browser.
This app had only the server-side feedback action in lib/posthog.ts, so no
pageviews reached PostHog and the Inkeep event bridge in lib/inkeep.ts
no-oped because nothing ever set window.posthog.

Why: cutover parity needs readership data on the new site from day one, and
the Inkeep search and chat events are already wired but silent. Neither may
start firing from a preview deployment or a writer's laptop, or production
numbers stop meaning anything.

What: adds posthog-js pinned at 1.430.2 and a client component,
components/analytics/posthog-provider.tsx, mounted inside RootProvider in
app/layout.tsx. It renders no DOM and returns null unless
NEXT_PUBLIC_VERCEL_ENV is 'production' and NEXT_PUBLIC_POSTHOG_KEY is set,
which keeps useSearchParams out of the tree everywhere else. The SDK is
loaded through a dynamic import, so it compiles to its own async chunk that
the browser never requests when the gate is off. On init it assigns the
client to window.posthog, which is the contract lib/inkeep.ts was written
against, and captures $pageview by hand on every pathname or query change
because the App Router does no full page loads. Capture settings mirror the
Docusaurus config: memory persistence, session replay off, autocapture off,
remote config disabled. README documents the environment variables and
INTERNALS gains an Analytics section.

References: Refs FS-2665, plan M-32. Upstream docusaurus.config.js
posthog-docusaurus block and src/components/PostHogProvider.tsx.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Add sitemap.xml and robots.txt metadata routes

Context
-------
The Docusaurus site at docs.arbitrum.io serves a generated sitemap and a
hand-written static/robots.txt. This Fumadocs port serves neither, so a
production cutover today would ship a site with no crawl surface and no
stated content-usage policy.

Why
---
Both are prerequisites for cutover, not nice-to-haves. Without a sitemap,
crawlers have to discover 339 pages by link-walking from the home page.
Without robots.txt, the Content-Signal declaration that permits search
indexing and AI-assistant input while withholding training consent is lost
in the migration.

Deriving the sitemap from source.getPages() rather than from a file list
keeps it on the existing content choke point: adding a page to
content/docs/ puts it in the sitemap with no further change, the same way
llms.txt already works.

What
----
- app/sitemap.ts: one entry per page from source.getPages(), plus the home
  page `/`, which is not part of the doc collection. Absolute URLs from
  NEXT_PUBLIC_SITE_URL with the same http://localhost:3000 fallback
  metadataBase in app/layout.tsx uses. `lastModified` is read defensively
  and emitted only when present, so enabling `lastModified: true` on the
  docs collection later (FS-2668) needs no change here.
- Upstream's nonCanonicalRoutePatterns ignore list has no equivalent.
  Docusaurus routed partials and auto-generated category pages; here
  partials, content/_versions/ and the glossary all live outside the doc
  collection dir, so source.getPages() cannot return them.
- app/robots.ts: User-agent *, Allow /, and the non-RFC-9309
  Content-Signal directive emitted through the rule's `other` field, which
  Next 16.3.0 added for exactly this. No separate route handler needed.
  Upstream's Disallow lines for /category/ and /hosted-pdfs/ are dropped;
  neither route exists here and disallowing 404s is noise.
- proxy.ts: /sitemap.xml and /robots.txt added to the bypass list, by the
  documented convention for new top-level routes. Neither rewrite pattern
  is reachable from these paths today; both are anchored at /docs.
- INTERNALS.md and CLAUDE.md: document both routes under routing.

Verification
------------
types:check, test, vars:check, nav:check, partials:check,
references:check and check-links all pass. `pnpm build` lists both routes.

On a dev server, /robots.txt returns 200 text/plain containing the
Content-Signal line, and /sitemap.xml returns 200 application/xml whose
URL set is exactly the 339 unique doc URLs in /llms.txt plus `/`, with no
duplicates. `.md` suffix and Accept: text/markdown negotiation still
rewrite correctly.

Pre-existing and unrelated: two third-party-docs pages reference remote
images that now 403 and 404, which fails `pnpm build` and 500s every route
reading `source`, /llms.txt included. Verified against those two URLs
locally substituted; unchanged in the commit.

References
----------
- Plan M-30, .claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md
- Upstream static/robots.txt and docusaurus.config.js in OffchainLabs/arbitrum-docs
- node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/01-metadata/

Refs FS-2663

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Address pre-review on the sitemap and robots routes

Context
-------
Pre-review of PR #17 raised two changes and one item to record for a
follow-up ticket.

Why
---
`export const revalidate = false` restated the default. A metadata route
with no request-time input is already cached at build time, so the line
implied a decision had been made where none was needed, and invited the
reader to wonder what it was guarding against.

The `NEXT_PUBLIC_SITE_URL` fallback is a genuine production hazard worth
recording where the next reader will find it. The variable is inlined at
build time, so if it is unset in the Vercel build environment the site
serves a sitemap and a robots.txt pointing at localhost, and nothing
fails. That is a silent, total loss of both files' usefulness.

What
----
- Drop `export const revalidate = false` from app/sitemap.ts and
  app/robots.ts, replacing it with one line in each file's doc comment
  saying why there is no such export, so the next person does not add it
  back.
- Record the NEXT_PUBLIC_SITE_URL build-time inlining hazard in INTERNALS
  and CLAUDE.md, naming FS-2668's `getSiteUrl()` helper as the fix both
  routes should adopt once it lands. Not changed here: adopting it before
  that helper exists would fork the behaviour.
- Remove twelve em dashes from the prose added by this branch, per the
  repo's writing convention.

No behaviour change. Verified both routes still serve identically:
/robots.txt returns 200 text/plain with the Content-Signal line, and
/sitemap.xml returns 200 application/xml with the same 340 entries.

Verification
------------
types:check, test, vars:check, nav:check, partials:check,
references:check, check-links and prettier all pass. `pnpm build` exits 0
and still lists both routes. (Build and dev were run with external
remark-image resolution disabled locally to work around FS-2681; that
toggle is not committed.)

References
----------
- Pre-review comments on PR #17
- FS-2668 (getSiteUrl helper), FS-2681 (dead third-party images)

Refs FS-2663

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Context: the Docusaurus site injected a "Request an update" badge into every
doc page through a swizzled DocItem/Content (src/components/HeaderBadges). The
Fumadocs docs page has no equivalent, so readers have no one-click path from a
wrong page to a filed issue.

Why: keep the reporting path readers already know, and keep it server-rendered.
Upstream read window.location inside <BrowserOnly>, which forced the badge to be
client-side. Here page.url is known on the server, so the link ships as plain
HTML with no extra JavaScript and no hydration on any docs page.

What:
- components/RequestUpdateLink.tsx builds the prefilled GitHub new-issue URL
  from gitConfig (OffchainLabs/Fumadocs-test), the page path in the title and
  the absolute page URL in the body. NEXT_PUBLIC_SITE_URL supplies the origin,
  matching app/layout.tsx; without it the body falls back to the site-relative
  path. Styling reuses Fumadocs buttonVariants (secondary, sm) so it matches
  MarkdownCopyButton, with a lucide PencilLine icon.
- app/docs/[[...slug]]/page.tsx renders it in the action row after
  ViewOptionsPopover, and the row gains flex-wrap so three actions plus the
  version switcher wrap instead of overflowing on a narrow viewport.
- INTERNALS.md describes the action row next to the catch-all route.

References: plan M-20 in
.claude/docs/superpowers/specs/2026-09-11-production-migration-plan.md;
upstream src/components/HeaderBadges/index.tsx.

Refs FS-2659
…gmachine-interactive-demo

Resolved pnpm-lock.yaml by taking main's lockfile and re-running pnpm
install, so viem is added on top of main's dependency updates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ct-addresses-generator

Conflicts: CLAUDE.md, pnpm-lock.yaml.

CLAUDE.md keeps everything main added (the drift upstream-checkout bullet,
the sitemap and robots bypass-list entries) and re-applies this branch's
contract-addresses notes: contracts:check in the run-by-hand list and
contracts:generate in the upstream-refresh sequence. The Commands block and
the generated-partials note merged without conflict, so CLAUDE.md still
mirrors INTERNALS.md.

pnpm-lock.yaml was regenerated by pnpm install from the merged package.json
rather than hand-resolved, so main's Fumadocs, Next and posthog-js bumps and
this branch's @arbitrum/sdk devDependency both land.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…teractive-demo' into fs-2661-port-the-bold-edgechallengeflow-diagram

Resolved package.json by keeping this branch's d3 dependencies alongside
the fumadocs version bumps the base branch carries from main. Reset
pnpm-lock.yaml to the base branch's lockfile and re-ran pnpm install, so
the d3 tree is added on top of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed-time-and-complete-social-metadata

Conflict in app/docs/[[...slug]]/page.tsx, resolved by keeping both features.
Main added a "Request an update" action to the row under the title; this branch
added a "Last updated on" line and rewrote generateMetadata. The merged page
keeps the date line between the description and the action row, keeps main's
flex-wrap on the row alongside RequestUpdateLink, and keeps this branch's
canonical and twitter metadata.

Two files auto-merged into a state that contradicted the branch, so they were
reconciled by hand. app/sitemap.ts and app/robots.ts each inlined their own
NEXT_PUBLIC_SITE_URL fallback, which silently ships localhost URLs from a
misconfigured production build; both now read getSiteUrl(), the helper this
branch adds for exactly that reason and which app/layout.tsx and the docs page
canonical already use. The sitemap also reads page.data.lastModified directly
now that the field is always in the generated type, and emits <lastmod>.

CLAUDE.md and INTERNALS.md said the site URL helper and the lastModified option
were still pending; both have landed on this branch, so those sentences were
rewritten rather than left describing a state that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s-generator' into fs-2671-port-the-nitro-cli-flags-reference-generator

Conflict: CLAUDE.md.

The base picked up main, which rewrote the run-by-hand and upstream-refresh
bullets and added a bullet on how drift locates the upstream checkout. The
resolution keeps the base's wording and the new drift bullet, then re-applies
this branch's additions: cli:check in the run-by-hand list, and the rewritten
upstream-refresh sentence naming cli:generate and the reason the job installs
no Go or Rust toolchain. The Commands block entries and the Generated pages
paragraph merged without conflict, so CLAUDE.md still mirrors INTERNALS.md.

package.json merged cleanly; this branch adds only cli:generate and cli:check
and no dependency, so pnpm-lock.yaml was taken from the base and pnpm install
left it unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context: unmatched URLs fell through to the Next default 404, a bare
"This page could not be found" with no navbar, no search, and no way back
into the docs. The Docusaurus site swizzled theme/NotFound to show site
chrome and to report every miss to PostHog.

Why: cutover moves every upstream URL, so inbound 404s are expected for a
while. A reader who lands on one needs search and the section landings in
front of them, and we need the misses recorded: the plan's post-cutover
monitoring maps real inbound 404s into the legacy redirect map, and that
needs an event to count.

What: adds app/not-found.tsx, which Next serves for both an unmatched URL
and any notFound() thrown in a segment, so it covers /anything and
/docs/anything from one file. It renders inside HomeLayout with the same
baseOptions the home route group uses, so the navbar, theme switch, and
search come back. Content is a 404 heading, one line of explanation,
Fumadocs' FullSearchTrigger wired to the same Inkeep dialog as the navbar,
and cards for the eight section landings. components/analytics/
not-found-tracker.tsx captures 404_error with the fields the Docusaurus
swizzle sent; it reads window.posthog and no-ops when the SDK is absent, so
nothing fires outside production. The page body is a div rather than a
second <main>, since HomeLayout's container already is one.

Verification: curl shows /does-not-exist and /docs/does-not-exist returning
404 with the heading, /docs/does-not-exist.md still returning 404 with an
empty body and no HTML, and /docs/get-started.md still returning 200
text/markdown. Checked in headless Chrome at 400px with no horizontal
overflow, in light and dark mode, with the 404_error payload asserted
against a stubbed window.posthog.

References: Refs FS-2666, plan M-33. Upstream
src/theme/NotFound/Content/index.tsx.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context: NotFoundTracker looked for window.posthog, and if the SDK had not
initialised yet it tried again a single time a second later and then gave up.

Why: pre-review pointed out that one retry is not enough. The SDK arrives in
a ~290 kB async chunk fetched from an effect of its own, and no ordering
between that effect and this one is guaranteed, so a slow connection loses
the event. Those are exactly the 404s worth counting, and the whole point of
the event is to find inbound URLs the redirect map misses.

What: replaces the single retry with a backoff schedule of 0, 250ms, 500ms,
1s, 2s, 4s and 8s, roughly a sixteen-second window, after which it stops
rather than leave a timer alive for the life of the page. The location is
now snapshotted when the effect runs rather than read at capture time, so a
late attempt still reports the URL the reader landed on. INTERNALS is
updated to match.

Verified in headless Chrome: with window.posthog stubbed in only after five
seconds, the 404_error event still lands, at about 8.4 seconds, carrying the
original pathname, search and hash. The previous version dropped it.

References: Refs FS-2666. Pre-review of PR #24.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Context:
`contracts:check` prints a summary of how the committed partial differs
from what the generator would write. Its whole purpose, per its own
header comment, is to let a reviewer of the weekly upstream-refresh PR
tell an address change from a formatting one. It compared the two texts
by line index, so an insertion or deletion shifted every following line
and reported all of them as changed. Measured on the committed
112-line partial: inserting two lines reported 53 changed lines and 104
diff lines, every one past the insertion point an artifact of the
shift.

Why:
Positional comparison defeats the output's only reason to exist. The
alternative of keeping it and noting in the header that the comparison
is positional was rejected: telling a reviewer that the 53 lines in
front of them are probably artifacts still does not say which one is
the address that moved. A longest common subsequence over lines is
about 40 lines of code, needs no dependency, and runs instantly on a
file this size (roughly 12k table cells).

What:
- scripts/lib/line-diff.mjs: new. `lineDiff` aligns the two sides over
  an LCS and reports only the lines that moved in or out; `diffSummary`
  renders that as the printed block. Kept pure and file-agnostic so it
  can be tested directly and reused by the other generator later.
- scripts/generate-contract-addresses.mjs: drops its local `diffSummary`
  and the now-unused `prettier` import.
- scripts/lib/generated-partial.mjs: `StaleFileError` carries the
  Prettier-formatted text that `writeOrCheck` already computed, so the
  failure path no longer formats the same content a second time to get
  it. The argument is optional and the two other callers never
  construct the error, so they are unaffected.
- scripts/generate-contract-addresses.test.mjs: 11 cases. `lineDiff`
  over identical input, insertion, deletion, substitution, an empty
  side, and an address change amid formatting churn; `diffSummary`'s
  header; and `writeOrCheck` in both directions against a temp file,
  which is where check mode and the stale path actually live. The suite
  previously touched only the pure renderer, leaving all of that
  uncovered.

Verified: the same two-line insertion now reports 2 lines, both the
insertion. A single changed address prints as one `-` and one `+` line,
adjacent. Suite goes from 101 to 112 cases.

Left out: running the generator's own `main` from a test, which would
pull `@arbitrum/sdk` and a repo-root cwd into a suite that is otherwise
pure, offline and fixture-driven.

References:
- Review of PR #18, findings 2 and 3 and the second nit
- 702312e Port the contract-addresses generator from arbitrum-docs
Context:
`classicOutboxRow` sorts the `{ address: version }` map by version
descending and leaves equal versions in the SDK's own key order. That
tie-break is what makes the rendered block byte-stable across runs,
which `contracts:check` depends on, but nothing said so.

Why:
A future reader could reasonably take the tie order as incidental and
"tidy" it into something unstable, which would make the check fail on
an unchanged SDK. Worth one comment rather than a test, since the
behavior is the language's, not ours.

What:
Expand the comment above `classicOutboxRow`. Records that stability is
guaranteed by ES2019, which requires `Array.prototype.sort` to be
stable, rather than being a V8 implementation detail as the review
described it.

References:
- Review of PR #18, first nit
* FS-2724: Emit og:site_name, og:type and og:url on docs pages

Context:
After FS-2713, the site root (/) emits a full Open Graph set including
og:site_name ("Arbitrum docs"), og:type ("website") and og:url. Docs
pages, whose metadata comes from generateMetadata in
app/docs/[[...slug]]/page.tsx, emitted og:title, og:description and
og:image and nothing else. They had no og:url either: Next fills that
tag only from an explicit openGraph.url and synthesizes nothing from
alternates.canonical (resolve-opengraph.js sets resolved.url to null
when openGraph.url is absent), so the canonical the page already
computed reached no Open Graph consumer.

Why:
og:site_name is sourced from appName in lib/shared.ts, the same
constant the home page and its OG card already use, so the three
cannot drift apart.

og:url reuses the exact string alternates.canonical carries, computed
once into one canonical constant and spent on both. They are one claim
addressed to two readers and must never disagree, and it means an
archive names its live page in both places, the same deduplication the
canonical was already doing.

og:type is 'article', not the 'website' the root uses, and it is
applied uniformly to everything the catch-all serves, /docs and the
section landing pages included. The distinction being drawn is root
versus docs, not index versus document: nothing in either collection
marks a page as an index, so singling out the landing pages would take
a hand-kept list of URLs that goes stale the moment a section is
added, and og:type drives no crawler behaviour that would pay for it.
The alternative, special-casing an empty slug array, would still leave
every section landing typed 'article', so it would not make the
narrower rule true, only look like it did.

'article' also unlocks article:modified_time, which this repo can
populate for free: page.data.lastModified / archive.entry.lastModified
already exist (git-derived, gated on hasFullGitHistory() in
source.config.ts) and the docs page already surfaces the same date to
readers as "Last updated on ...". article:published_time is
deliberately not set: nothing in the frontmatter or either collection
records when a page was first published, only git's last-touched date,
which is exactly what modifiedTime already is, so a fabricated
publishedTime would be worse than none.

Archived versions (/docs/<slug>/<id>, noindex, follow) get the same
og:site_name/og:type/og:url/modifiedTime as live pages. noindex
controls crawling, not what kind of object the URL is, and an
archive's own lastModified is a real per-document date, not the live
page's.

What:
- app/docs/[[...slug]]/page.tsx: add openGraph.siteName,
  openGraph.type: 'article' and openGraph.url to generateMetadata,
  plus openGraph.modifiedTime when lastModified is defined (computed
  the same way the page body already computes it for the "Last updated
  on ..." line). The canonical URL is hoisted into one constant now
  that alternates.canonical and openGraph.url both need it.
- scripts/static-docs-http.test.mjs: add a "docs page open graph tags"
  test covering a live page, its archive, and /docs. Moved the
  head/tag/title/meta helpers the existing FS-2713 "home page
  metadata" test defined inline up to module scope so both tests share
  one implementation. og:site_name is asserted against appName
  imported from lib/shared.ts, not a restated literal, the way
  scripts/lib/shared.test.mjs already imports that module under
  node --test.
- scripts/static-docs-http.test.mjs: article:modified_time is asserted
  to be present if and only if the body carries the "Last updated on"
  <time dateTime>, and to carry the same instant. The obvious shape,
  skipping the assertion when the tag is absent, would never execute:
  the one place this suite runs automatically is ci.yml's Build job,
  whose actions/checkout sets no fetch-depth and so clones at depth 1,
  which is exactly the case hasFullGitHistory() answers false for. The
  invariant holds in shallow and deep checkouts alike because both
  values come from one lastModified in one render.
- INTERNALS.md, CLAUDE.md: extend the existing "Page metadata"
  paragraph with the same explanation (this repo edits INTERNALS.md
  and mirrors into CLAUDE.md rather than keeping the two in two
  different places), including why the type is uniform and why the
  test asserts an equivalence rather than a presence.

Left out: lib/og.tsx (the OG PNG generator) is untouched, confirmed by
an empty diff against fork/main on that file and app/og/. This ticket
is meta tags only, not the rendered image. The plan document is kept
out of the tree: this repo's plans live in
.claude/docs/superpowers/specs/, and fork/main carries no PLAN-*.md at
the root.

Verified types:check, test (275 top-level subtests, 355 including
nested, 351 pass, 4 skipped for want of a server, 0 fail), build, and
prettier over every tracked file, all green. Served the build on 3184
and ran the extended HTTP suite against it: 22/22 pass, 0 skipped.
Curled the head of /, /docs, a live docs page and its archive: / keeps
og:type=website, the three docs URLs carry og:type=article with
og:site_name="Arbitrum docs", og:url equal to their canonical (the
archive's naming its live page), and article:modified_time equal to
the instant the body renders. prerender-manifest.json route count
unchanged at 1061.

References:
- Linear FS-2724
- code-review-fs-2724-docs-og-site-name.md (1 Medium, 4 Low, all
  confirmed and fixed)
- 6b28802 FS-2715: Cut the docs payload that was holding LCP at four seconds
- e9f76c6 FS-2713: Give the site root a title, description and social card

* docs(metadata): say why an archive's og:url and modified_time describe two documents

Context: FS-2724 gives archives the same og:site_name, og:type and og:url as
live pages, and dates them with their own article:modified_time.

Why: the round 2 review noticed the two halves describe different documents,
the URL the live page and the date the archive, and that they agree today only
because one commit last touched both files. Nothing said whether that was
intended.

What: one sentence in the Page metadata paragraph of CLAUDE.md and
INTERNALS.md stating the mismatch and why it is accepted: og:url is the OG
object's canonical, which for an archive is its live page, while the date
describes the document actually served.

References: FS-2724; code-review-fs-2724-docs-og-site-name.md, round 2 new Low.
* FS-2725: Make a global variable usable inside a link destination

Context:
The precompiles reference table built its Interface and Implementation
links as markdown links whose destination held `<Var name="..." />`.
That never produced a link. CommonMark reads an unbracketed link
destination as one raw token that may not contain a space, and the tag
holds two, so the resource never parses and the reader is served the
literal `[Interface](...)` brackets, with only the bare URL prefix
before the first `<Var>` autolinked by GFM. It was a class, not one
table: 73 links across five pages, measured in the rendered HTML.

No gate saw it. `vars:check` only proves the key exists, `check-links`
skips an external destination, and `content:lint` rule A6 reads code
fences and spans, not destinations.

Why:
A destination takes a `{var:name}` placeholder instead, because that
holds no space and so parses as an ordinary raw destination, leaving
the braces verbatim in the mdast link node's url (confirmed by parsing
the real pattern with the repo's own remark-parse, remark-gfm and
remark-mdx). A remark plugin then expands it.

The plugin lives in `lib/mdx-options.mjs` so the site and `check-links`
resolve the same URL, and the markdown mirror at `/docs/....md` serves
the expanded one too. A component that built the href itself was
rejected on the repo's own evidence: rule A7 exists precisely because
no gate can see a URL inside a component prop, and that option would
have put 73 more URLs there. Hardcoding plus the `sync-with-var` marker
was rejected because the marker only drives `latestNitroNodeImage`, so
every other variable in these URLs would go stale by hand.

The `var:` prefix is what lets the gate be strict. A bare `{name}` is
indistinguishable from a URL documenting a path template
(`.../{chainId}/...`), so `vars:check` would have to choose between
letting a mistyped name ship silently and failing on a real template.

An unknown name is left in place rather than thrown on, matching what
`<Var>` does with one: the defect reaches the page and the gate fails.
Throwing inside a plugin that loads before any page is rendered would
take the whole site down for a single typo.

What:
- `lib/var-links.mjs` (new): `remarkVarLinks` plus the pure helpers the
  gates and tests share. Rewrites `link` and `definition` urls and the
  `href`, `to` and `src` attributes of a JSX element. Not markdown
  image nodes: fumadocs' own remark-image owns those and this plugin
  has no pinned position relative to it.
- `lib/mdx-options.mjs`: wire the plugin into `remarkPlugins`.
- Five content pages: 73 destinations, 185 placeholders. Rendered
  broken-link count goes 58/6/7/1/1 to zero, and resolved GitHub
  anchors across the five pages go from 426 to 499.
- `content:lint` rule A11, blocking and in the default set: a `<Var>`
  in a markdown link destination, a `<Var>` in an `href=`/`to=` value,
  and a placeholder whose name is not an identifier, which is the one
  shape that reaches the reader as literal braces.
- `scripts/lib/vars-audit.mjs`: read both syntaxes, so a variable used
  only in a destination still counts as referenced and an unresolvable
  name in one still fails `vars:check`.
- Tests: `scripts/lib/var-links.test.mjs`, nine A11 cases, four
  placeholder cases in the vars-audit tests.
- Docs: the A11 row in the three rule listings, a new INTERNALS
  subsection on the mechanism, and the writer-facing syntax in
  README.md and CONTRIBUTE.md.

Kept as one commit because the three halves are only correct together:
A11 reports 73 findings until the content moves, and the content is
broken until the plugin lands. The doc edits interleave both halves
inside the same paragraphs.

Verified: all thirteen blocking gates, `pnpm build`, and the rendered
before/after counts on a production server. `.next/prerender-manifest`
holds 1061 routes, unchanged.

Left out of scope: the three hardcoded `Fumadocs-test` repo URLs in
`content/partials/_contribute-docs-partial.mdx`, which want the repo
identity unified with `gitConfig` in `lib/shared.ts` first. Written up
as a proposed follow-up in PR-FS-2725.md.

References:
- FS-2725
- 88d4a02 FS-2714: Fix the invalid HTML nesting that breaks hydration
  on eighteen pages (the A8 to A10 rules this one follows)

* FS-2725: Point two of the revived links at paths that exist

Context:
The seventy-three destinations this branch brought back to life were
rewritten mechanically, placeholder for component, so every path error
already written into them survived the change. Two did. Both were inert
literal text before, so nobody could have followed them to a 404, and
check-links treats a github.com destination as external and never
fetches it, so no gate sees them either.

Why:
The branch claims seventy-three links arriving as working anchors. All
seventy-three expanded destinations were fetched to check that claim
(forty-one distinct URLs, one request each, two seconds apart), and two
came back 404. Fixing them here rather than filing them keeps the claim
true and costs two lines.

What:
- how-to-estimate-gas.mdx named nodeInterface/NodeInterface.go, which
  does not exist at the pinned Nitro tag. The file is at
  execution/nodeinterface/node_interface.go, and
  EstimateRetryableTicket, which the old #L120 was reaching for and
  which the surrounding prose names, is at line 128 there.
- customize-arbos.mdx wrote blob/v3.1.0/arbos/{var:nitroPathToArbosState}
  and that variable is already arbos/arbosState, so the path doubled.
  Dropped the redundant segment. Line 253 at that tag is
  UpgradeArbosVersion, which the prose names, so the anchor stands.

Both corrected URLs were refetched and return 200. All forty-one
distinct destinations now resolve.

References:
- code-review-fs-2725-var-in-link-destination.md, first Medium finding
- verify-fs-2725-var-in-link-destination-round1.md, the sweep table

* FS-2725: Resolve a placeholder wherever a URL is read, not just in the site

Context:
The plugin expands a {var:name} placeholder in every link destination,
internal ones included, but check-links has two halves and only one of
them shared the site's transforms. doc-anchors.mjs imports mdxOptions,
so a fragment was validated against the expanded URL; doc-links.mjs
reads destinations out of the raw MDX with regexes and never expanded
anything, so /docs/x/{var:y} was reported broken while the built page
carried a working URL. Reproduced on a scratch page before changing
anything. The plugin also skipped image destinations and link titles,
and rule A11 probed href and to but not src, though the plugin rewrites
src.

Why:
Making the checker expand is preferred over forbidding the syntax in an
internal destination, because the syntax works: the page renders a
correct link, only the gate disagreed. doc-links.mjs cannot simply
import mdxOptions the way doc-anchors.mjs does, because it has to
rewrite destinations in place for move-doc and therefore needs the
written text and its offsets. It calls the plugin's own
expandVarPlaceholders instead, so there is one expansion function with
two callers rather than two implementations that can drift.

Extending the plugin to image nodes is the smaller half of a fix. It
only reaches a remote src: fumadocs inserts user plugins after its own
remark-image, which has already turned a local src into an import of
the written path, so a placeholder there fails the build on a missing
file. Moving the plugin ahead of remark-image would fix that too and
was rejected as a larger change to plugin ordering than the finding
warrants. Nothing silent survives either way.

What:
- doc-links.mjs exports expandRefUrl and calls it in resolveRefToFile
  and findBrokenLinks, the two places that resolve a destination.
  Extraction is untouched, so the ranges move-doc rewrites still point
  at the written text.
- move-doc.mjs resolves such a link but never rewrites it. renderRef
  writes a literal path, which would bake the variable's current value
  into the file, so a destination holding {var: is passed over the way
  a cwd include is.
- var-links.mjs also rewrites image nodes and the title of a link,
  image or definition, so everything inside one link's parentheses
  behaves the same way. Module comment corrected: only node:fs is
  imported, and doc-links.mjs is now a second importer.
- content-lint.mjs rule A11 probes src alongside href and to.
- Tests: expandRefUrl and placeholder resolution against a built index
  in check-links.test.mjs, image and title cases in var-links.test.mjs,
  src and image destination cases in content-lint.test.mjs.
- Docs. INTERNALS no longer claims the shared options object covers
  path resolution, and records move-doc, the image boundary and prose.
  README and CONTRIBUTE gain the writer-facing boundary: internal
  destinations and titles work, prose fails the build with an acorn
  parse error, a local image path cannot use a placeholder. All four
  files, CLAUDE included, say that a vars.json edit does not reach a
  placeholder until pnpm dev restarts, which was measured on a dev
  server with one page holding both forms: the prose Var moved on
  reload and the link did not.
- Em dashes removed from every line this branch adds.

References:
- code-review-fs-2725-var-in-link-destination.md, both Medium findings
  and the four Low ones
- verify-fs-2725-var-in-link-destination-round1.md

* fix(gates): reject placeholder hrefs in the banner and report them in move-doc

Context: FS-2725 made the docs link resolver expand `{var:name}` placeholders,
so `check-links` and `move-doc` see the same URL the build renders.

Why: two consumers of that resolver were not written with placeholders in
mind. `checkAnnouncementLink` validates the raw `announcementLinkHref` and then
resolves it, so an href holding a placeholder would pass the gate whenever the
expansion named a real page, while `app/layout.tsx` renders the string as-is
and the reader would see literal braces. And `move-doc` set a placeholder
ref's target to null to keep it from being rewritten, which also kept it out
of the "resolve to the move but can't be auto-rewritten" warning, so the
operator learned about it from a later gate instead of the command that
caused it.

What: `checkAnnouncementLink` rejects any href containing `{var:` before
resolving, with a test. `move-doc` resolves placeholder refs like any other,
marks them, and routes an inbound one to the unrenderable list (the same
treatment an expression include gets) while still never rewriting it, inbound
or outbound.

References: FS-2725; code-review-fs-2725-var-in-link-destination.md, round 2 new Lows 1 and 2.

* test(move-doc): pin that a placeholder link to the moved page is warned about, never rewritten

Context: the previous commit made move-doc resolve `{var:name}` placeholder
links so an inbound one pointing at the moved page lands in the "can't be
auto-rewritten" warning instead of being passed over in silence.

Why: that commit changed planning logic with no test; the review's
confirmation pass named the gap.

What: an end-to-end case in the existing fixture-repo style. It borrows the
real `nitroRepositorySlug` key (readVars reads the repo's vars.json), moves
`content/docs/nitro/old-name.mdx`, and asserts the placeholder link is named
in the warning under the form the writer typed, is left byte-identical, while
the plain link beside it is rewritten.

References: FS-2725; code-review-fs-2725-var-in-link-destination.md, confirmation pass.

* test(move-doc): derive the placeholder fixture from vars.json and cover the outbound guard

Context: the previous test commit pinned that an inbound placeholder link to
the moved page is warned about and never rewritten.

Why: the confirmation pass mutated the code and showed the test saw only half
of the change. Removing the outbound `!rec.placeholder` guard left the suite
green, because the fixture's placeholder link lived in another file and the
outbound branch only fires for links inside the moved page; without that guard
a relative `.mdx` placeholder link is re-based with the variable's current
value written into it. The fixture also spelled out "nitro" five times while
its comment said the value comes from `nitroRepositorySlug`, so a change to
that value would have failed the test with nothing pointing at vars.json.

What: derive every fixture path from `readVars().nitroRepositorySlug`, the way
the check-links fixture does; give the moved page its own relative `.mdx`
links, one through a placeholder and one plain, to a sibling that stays behind;
move it to another directory and assert the placeholder link is byte-identical
while the plain one is re-based. Measured on scratch copies: removing either
guard now fails the test, and the unmodified code passes.

References: FS-2725; code-review-fs-2725-var-in-link-destination.md, confirmation pass.
…root switcher (#73)

Context:
The sidebar was built straight from the content directories: each
`meta.json` ordered its folder, twelve folders declared `"root": true`,
and Fumadocs rendered a root switcher above the tree listing all twelve.
That put Oracles, Third-party docs and a synthetic Resources folder next
to the real sections, scattered the old site's learning sequences across
file locations the migration had settled on, and let a reader hop
between main-menu sections from inside the sidebar, which the navbar
already does. Cross-section shortcuts had to live in the footer because
a `[Title](/docs/x)` entry in a meta.json becomes a page node and steals
that page's sidebar (FS-2716).

Why:
The original Arbitrum docs had nine section menus with their own labels,
order and nested categories, defined once in `sidebars.js`. Reproducing
that hierarchy from directory layout alone means moving files and
adding redirects for every editorial decision. A manifest applied as a
page-tree transformer keeps the one source loader and the real page
nodes (URLs, icons, metadata) and only supplies order, groups and
labels, so a menu change never touches an MDX file or a URL. The
switcher goes because it duplicated the navbar's section list, named
sections the navbar leaves out, and disagreed with it about what the
sections are; the navbar chooses the section and the sidebar shows that
section's tree, as the Docusaurus site behaved.

What:
- `lib/docs-navigation.json`: nine sections mirroring the referenced
  `sidebars.js` revision (provenance only; nothing reads that repo).
  Entries are `page`, `href`, `children` or `folder` (with `flatten`);
  `sourceFolders` assigns local content to a section so unlisted pages
  land under an "Additional guides" group instead of vanishing. Three
  PGA/Fast Feed pages absent locally link to the live site.
- `lib/docs-navigation.ts`: the transformer. `file` honours a page's
  `sidebar_label`; `root` rebuilds the tree from the manifest and throws
  on a missing page, folder, section or `/docs` reference rather than
  dropping the item. Cross-section `href` entries become display-only
  separator nodes carrying a URL, so they cannot claim a sidebar root.
- `lib/source.ts`: register the transformer on the single loader.
- `components/sidebar-navigation-reference.tsx`: renders those reference
  nodes as ordinary sidebar links; wired as the notebook layout's
  `Separator` component in `app/docs/layout.tsx`.
- `app/docs/layout.tsx`: `tabs={false}`. The tab transform that kept
  the switcher on landing pages goes with it.
- `lib/shared.ts`: the footer pins Chain info, Glossary and Contribute
  (Glossary replaces Audit reports) to match the original section menus.
- `scripts/docs-navigation.test.mjs`: loads the real content through
  Fumadocs and checks every page is navigable with exactly one owner,
  landing pages sit on their section, the Get started, Stylus and chain
  configuration sequences, that shortcuts never steal a sidebar, and
  that a broken manifest entry throws.
- INTERNALS.md and README.md describe the manifest model and why there
  is no switcher.

Not done here: CLAUDE.md's sidebar paragraph and CONTRIBUTE.md line 49
still describe the meta.json-only model and the switcher; `nav:check`
still validates the underlying meta.json tree. `"root": true` remains in
the twelve meta.json files, since the transformer reads the folders it
builds from.

Verified: types:check, nav:check, format:check, check-links, vars:check
and the test suite (437) pass; headless Chromium on six docs URLs shows
no switcher and each page's own section tree.

References:
- FS-2716 (rootless pages, footer links), review Low 6 on the
  twelve-entry switcher
- FS-2731 (sidebar_label read by nothing): the `file` transformer here
  is the "honour it" answer
- https://github.com/OffchainLabs/arbitrum-docs/blob/1023c40f157f09a2bf72ebe2de71239b470c6e3d/sidebars.js
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 21, 2026
content/_versions/ is a snapshot collection: each page records how the
docs read at one point in time. pinned-image-check walks all of
content/, so a snapshot naming the image current when it was taken
reads as a contradiction of today's pin.

Nothing under it carries a tag today, so no current result changes. The
archive grows by hand-registration, and the first page archived while
carrying a hardcoded tag would redden the gate with only two ways out:
falsify the snapshot, or file a permanent EXCEPTIONS entry for something
exempt as a category.

Ported from OffchainLabs/Fumadocs-test#61 (f60049e2), which found the
same hole in that branch's rewriter. Partials stay covered, and the test
asserts both halves.
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 22, 2026
Three data-availability pages wrapped <Var name="latestNitroNodeImage" />
in backticks. MDX does not evaluate a component inside an inline code
span, so the prerequisite bullet showed readers the literal text
<Var name="latestNitroNodeImage" /> where the image string belonged.
These are prose bullets, not commands, so the fix is to drop the
backticks rather than hardcode the tag.

No gate could see this: types:check never type-checks MDX, and
vars:check proves a key resolves, not where it is used. Adds
content-lint rule A6 for both placements — fenced block and inline span
— following the existing A1..A5 numbering. A6 reports zero after the
three fixes.

Ported from OffchainLabs/Fumadocs-test#61 (78df9ee1).
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 22, 2026
VanillaAdmonition implements note|tip|info|warning|danger. type="caution"
is a Docusaurus-era value that survived the migration: it indexes both
the style map and the icon map as undefined, so all eight callouts
rendered as unstyled boxes with no icon. Upstream's own component does
not implement it either, so this was inherited, not introduced.

warning is the closest implemented type and is what the Fumadocs-test
branch settled on for the same eight callouts.

Also repoints the gas-price-floor glossary link, which kept a Docusaurus
path and an .mdx suffix (for-devs/dev-tools-and-resources/chain-info.mdx)
at a tree that does not exist here, so it 404s. The target is
/docs/chain-info#chain-parameters.

content-lint now reports no structural defects.

Ported from OffchainLabs/Fumadocs-test#61 (4d7e372).
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 22, 2026
public/img/batchposter-path.png (1.52 MB) is referenced nowhere.
batchposter.mdx uses batchposter-path.svg, which is 7,973 bytes and
200x smaller, so the PNG is the pre-SVG original left behind.

vm-differences.mdx paired <Var name="maxCodeSizeBytes" /> with a
hardcoded "(24 KB)" beside it. The point of the substitution is that
the line stays correct when the value changes; a duplicate literal next
to it makes a future change self-contradicting rather than merely stale.

Ported from OffchainLabs/Fumadocs-test#61 (e3b6c915, ca79b743). That
first commit also demotes two sequencer.mdx headings to ###; not carried,
because upstream keeps all three at ## and this tree matches upstream.
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 22, 2026
Both exist upstream and have no counterpart here:

- stylus/cli-tools/overview.mdx: "Deploying contracts larger than 24 KB"
  (upstream overview.mdx:84), on how cargo stylus fragments an oversized
  WASM, what the root contract address means, and what activation costs.
  This tree went straight from the commands reference to How-tos.
- run-a-node/high-availability-sequencer-docs.mdx: "Redis priority
  registration" and its "Verifying registration" subsection (upstream
  high-availability-sequencer.mdx:204). Setting redis-url and my-url does
  not make a sequencer eligible; it must also appear in
  coordinator.priorities, which only the SQM writes. Nothing here said so.

Taken from the Fumadocs-test branch's current state rather than its
restoring commit: that commit wrote both callouts as type="caution",
which the component does not implement, and the branch corrected them to
warning afterwards. Both cross-references resolve here
(cli-flags-reference#node, run-sequencer-coordination-manager).

Ported from OffchainLabs/Fumadocs-test#61 (861dc30, as corrected by
4d7e372).
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 22, 2026
fumadocs' remark-image fetches every image to read its intrinsic size,
which for an https:// src is a network request made during compilation.
onError: 'ignore' was set on 2026-09-17 after seven dead googleusercontent
images took every docs route to 500. That stops the failure but keeps the
request: the compiler still depends on third-party hosts being up, and
now discards whatever they answer.

external: false removes the request instead, so compilation is
deterministic and offline. onError returns to its default; that part
catches nothing new, because a missing local image is already reported by
check-links and by module resolution, neither of which onError governs.

Safe here because no page uses markdown image syntax with a remote src —
that is the one case external: false would send to next/image without a
width. Every remote image in this tree is an <ImageZoom> or a plain <img>,
which the option does not touch. Verified with a full build.

Ported from OffchainLabs/Fumadocs-test#61 (e22cea8), config change only:
that commit's remote-image gate polices markdown remote images, which
this tree has none of.
anegg0 added a commit to OffchainLabs/arbitrum-docs that referenced this pull request Sep 22, 2026
Fumadocs wraps each heading's content in its own anchor, so a heading
that already holds a link renders an anchor inside an anchor. No HTML
parser can represent that, so the client tree differs from the server
tree and React discards and re-renders the subtree — error #418. The
page still returns 200 and passes every gate, which is why nothing here
caught it: 67 headings across 11 files.

The fix is editorial, because the nesting comes from the content and not
the component. Each heading loses its link markup and the URL moves into
the prose under it, or is dropped where that section already links it.

Heading text is preserved everywhere except three places, so slugs and
inbound fragments do not move:
- arbos40: the trailing (#2998) moves into the prose
- stylus/cli-tools/overview: two entries under "Additional resources"
  become list items, which is what they always were
- venly: "Request Endpoint: reference" becomes "Request Endpoint"
None of the four affected anchors is referenced anywhere in content/ or
in either redirect map.

Verified: no heading link remains, no URL was lost from any page, no
heading was deleted except the two converted to list items, and all ten
gates plus a full build pass.

Ported from OffchainLabs/Fumadocs-test#61 (88d4a02), applied per heading
rather than by taking that branch's files: its geth.mdx is missing a
RevertedTxHook paragraph this tree has, and its arbos51.mdx points a gas
floor link at a different page.
…ne (#75)

* refactor(FS-2730): make the precompile-table generator testable offline

Context:
scripts/generate-precompile-tables.mjs called runScript(main) at module
scope and exported nothing, and every path to writeOrCheck ran through
fetchSource, so nothing could import it for a unit test. Its only
end-to-end guard was pnpm precompiles:check, which fetches about 30
Solidity/Go sources from raw.githubusercontent.com per run and is
therefore continue-on-error in CI's Network checks job. FS-2728 worked
around this by asserting that every committed partial under
content/partials/precompile-tables/ opens with the do-not-edit marker,
which proves the marker is on disk, not that the generator put it
there.

Why:
Split the generator the way scripts/generate-contract-addresses.mjs and
scripts/lib/contract-addresses.mjs are already split: pure assembly in
a lib module, fetch plus write in the CLI script. That lets a fixture
test exercise the real parsing and rendering logic without touching the
network, and closes the gap FS-2728's doc comment called out.

What:
- Add scripts/lib/precompile-tables.mjs: every pure function moved out
  of generate-precompile-tables.mjs unchanged (toRawUrl,
  extractDocComment, lowercaseKeys, assertResolved,
  renderMethodsInTable, renderEventsInTable, DEPRECATION_NOTICE), plus
  PRECOMPILE_MARKER and NODE_INTERFACE_MARKER (moved here so the
  on-disk test and the render step read the same constant instead of a
  hand-copied regex), plus two new composition functions,
  renderPrecompilePartial and renderNodeInterfacePartial, mirroring
  generatePrecompile/generateNodeInterface minus the fetch and the
  write.
- Slim scripts/generate-precompile-tables.mjs down to I/O: vars.json
  read, URL construction, fetchSource, and calls into the new render
  functions before writeOrCheck. No parsing logic left in this file.
- Add scripts/lib/precompile-tables.test.mjs (25 tests): exercises the
  render step offline against inline fixture Solidity/Go source
  (following the fixture convention already used by
  generate-cli-reference.test.mjs and generate-stylus-examples.test.mjs,
  not a separate fixtures/ directory). Covers a single-line signature,
  a signature whose parameter list spans several lines, a deprecated
  override, an availableSinceArbOS override, an emitted event resolved
  to its con.<Event>( call site, an event declared but never emitted
  falling back to its first mention, and assertResolved throwing when a
  method or event never resolves a Go line.
- One of those tests deliberately preserves existing behavior rather
  than introducing something new: a method with no doc comment renders
  a genuinely empty description cell. Two committed partials
  (_ArbOwner.mdx, _ArbOwnerPublic.mdx) already ship exactly that shape,
  and precompiles:generate output for the current pins had to stay
  byte-identical across this refactor, so no placeholder copy was
  added for that case.
- Update scripts/lib/generated-partial.test.mjs's on-disk describe
  block to assert exact equality against the imported
  PRECOMPILE_MARKER / NODE_INTERFACE_MARKER instead of a hand-copied
  regex. Kept this block rather than folding it into the new file
  because it proves something the fixture test cannot: that the 16
  actual committed files on disk, not a fixture, still carry the
  marker and that the count still matches, i.e. nobody hand-edited a
  partial after generation.
- No behavior change. Verified with pnpm precompiles:generate: git
  diff against content/partials/precompile-tables/ is empty. Ran pnpm
  precompiles:check against the network once; it passed on the first
  try, confirming byte-identical output before and after the refactor.

References:
- FS-2730 (this ticket)
- FS-2728 (PR #68), whose marker-on-disk test this extends
- scripts/generate-contract-addresses.mjs / scripts/lib/contract-addresses.mjs,
  the existing split this follows

* test(FS-2730): fail loudly when a composition function throws, pin the source URLs

Context: f50cb41 split the precompile-table generator into a pure library and an I/O
runner so the render path could be tested offline. Review of that commit found the
two new composition suites called renderPrecompilePartial and
renderNodeInterfacePartial in the describe body, and that the four GitHub base URLs
every href in the sixteen partials derives from stayed in the runner, untested.

Why: node --test drops a suite whose construction throws from the count and exits 0.
Measured on a scratch copy: a throw at the top of renderPrecompilePartial gave 23
tests, 0 failures, exit 0, so pnpm test passed while the two tests vanished. That is
the one class of regression the new offline gate could not see. The URL builder is
pure apart from one vars.json read, and check-links skips external URLs, so a wrong
commit or path there was visible only to the network-bound precompiles:check.

What:
- Both composition suites call a render() helper inside each it. The same throw now
  gives 28 tests, 2 failures.
- buildSourceUrls(vars, pins) is exported from scripts/lib/precompile-tables.mjs and
  the runner destructures its four results. Three tests pin the two base URL shapes,
  the empty interfaces-subpath branch and the raw-URL conversion. precompiles:check
  run once after the move reports up to date.
- Five newly written comment lines that carried an em dash reworded.

References: FS-2730; code-review-fs-2730-precompile-generator-offline.md (Medium, Low,
em-dash Nit).
…ours it (#76)

* fix(nav): drop dead sidebar_label overrides, shorten redundant Supra ones

Context:
FS-2731 asked us to audit the 111 content/docs pages carrying
sidebar_label frontmatter now that PR #73's lib/docs-navigation.ts
transformer honours the field as a page's sidebar name, but only when
that page's entry in lib/docs-navigation.json gives it no explicit
name of its own.

Why:
Cross-referencing all 79 pages whose sidebar_label differs from title
against lib/docs-navigation.json showed 52 of them have an explicit
manifest name that always wins, so those sidebar_label values never
render anywhere. Confirmed on a local dev server by diffing rendered
HTML for a sample (e.g. state-growth.mdx's "Manage state growth"
label never appears; the manifest's "State growth" does). Left as
dead frontmatter, a future editor could reasonably believe changing
one of these values changes the sidebar, and it would not.

Two more pages do render their sidebar_label but repeat their
enclosing folder redundantly and add nothing: oracles/supra's two
pages sit under a "Supra" folder yet their labels were "How to use
Supra price feed oracle" and "How to use Supra VRF", longer than the
page titles and repeating the folder name a reader already sees one
level up.

Why: reasoned about, not changed:
The remaining 25 rendering sidebar_label values were reviewed and
left alone. Most are third-party vendor pages (Codex, Covalent,
MetaMask, and so on) where the label shortens a long marketing title
to just the vendor name, matching the sibling LayerZero page whose
title is already just "LayerZero" with no override needed. A few
non-vendor labels (bold-faq.mdx's "FAQ", sequencer-config-reference's
"Configuration reference") trim a title that already repeats its
parent folder's name and were left as accurate, appropriately short.

What:
- Deleted the sidebar_label line from 52 files where a
  lib/docs-navigation.json manifest entry already sets an explicit
  name for that page.
- Shortened sidebar_label on
  content/docs/oracles/supra/use-supras-price-feed-oracle.mdx and
  use-supras-vrf.mdx to "Price feed oracle" and "VRF".

References:
FS-2731 (Linear). PR #73 (lib/docs-navigation.ts, the transformer
this audit is downstream of).

* docs(nav): document sidebar_label precedence, close stale backlog item

Context:
FS-2731 also asked us to document how sidebar_label now behaves (it
was "read by nothing" when the ticket was filed) and its precedence
against the lib/docs-navigation.json manifest's own name field, and to
close out a stale backlog item that predates that behavior.

Why:
INTERNALS.md is canonical for this repo. It already had one sentence
on this in "The sidebar and its roots" (added by PR #73, the change
that made sidebar_label load-bearing), but "The frontmatter contract"
section did not mention it, and CLAUDE.md, CONTRIBUTE.md and README.md
did not mention it in their own frontmatter-contract sentences either.

Precedence, worked out by reading lib/docs-navigation.ts: an explicit
name on a page's lib/docs-navigation.json manifest entry always wins
over that page's sidebar_label. docsNavigationTransformer's file()
hook renames every page node to its sidebar_label first (it runs
before the root transformer), and buildDocsNavigation's page() helper
only replaces that name when the manifest entry passes one explicitly
(`name ?? original.name`). This is the right order: the manifest is a
curated, section-aware editorial hierarchy that can rename a page
differently depending on where it appears (see the cross-section
shortcut handling elsewhere in that file), while sidebar_label is the
page's own, context-free opinion, so it should only apply as a
fallback when the manifest has nothing to say.

Separately, .claude/docs/code-review-backlog.md recommended "set
sidebar_label on one" of the two Oracles pages to fix a duplicate
"Oracles" sidebar title. FS-2727 already resolved that duplicate by
retitling content/docs/oracles/overview-oracles.mdx to "How oracles
work" instead, so the backlog line was stale.

What:
- INTERNALS.md: tightened "The sidebar and its roots" to name the
  file() hook and its interaction with buildDocsNavigation's page(),
  and added a new sentence to "The frontmatter contract" stating the
  same precedence with a cross-reference.
- CLAUDE.md, CONTRIBUTE.md, README.md: added one sentence each to
  their sidebar_label mentions, stating it is honoured by the
  navigation transformer as the sidebar name only when the manifest
  gives the page no explicit name, and that a manifest name always
  wins.
- .claude/docs/code-review-backlog.md: struck the resolved Oracles
  bullet with a note pointing to FS-2727.

Left out of scope: CLAUDE.md's separate "Sidebar ordering" paragraph
(around the meta.json root discussion) still describes a root switcher
that PR #73 removed and does not mention lib/docs-navigation.json at
all. That is a larger staleness issue predating this ticket and is not
about sidebar_label; noted as a follow-up rather than fixed here.

References:
FS-2731, FS-2727 (Linear). PR #73, "feat(nav): drive the sidebar from
an editorial manifest and drop the root switcher" (lib/docs-navigation.ts,
the transformer this documents).

* test(nav): pin manifest-name-vs-sidebar_label precedence

Context:
The preceding commit documents that an explicit lib/docs-navigation.json
manifest name always wins over a page's own sidebar_label. That
precedence was not covered by any existing test in
scripts/docs-navigation.test.mjs, and after the frontmatter cleanup
in this branch, no real page in content/docs carries both a
sidebar_label and an explicit manifest name at the same time to
exercise it incidentally (the previous 52 such pages had their
sidebar_label deleted precisely because it was dead).

Why:
Pin the precedence behaviorally, not just in prose, so a future
refactor of lib/docs-navigation.ts (for example, reordering the
file() and root() transformer hooks, or changing page()'s
`name ?? original.name` fallback) cannot silently invert it without
a test failing.

What:
Added a demoSource() helper and two node --test cases to
scripts/docs-navigation.test.mjs, built on a small synthetic
fumadocs-core loader() source rather than the real content tree, so
the case is exercised independently of what content/docs happens to
contain:
- a manifest entry's explicit name wins over the page's own
  sidebar_label
- sidebar_label applies when the manifest entry gives the page no
  name

Both pass alongside the seven pre-existing tests in that file
(9/9 total).

References:
FS-2731 (Linear).

* fix(nav): keep the vendor name in the Supra labels, say when sidebar_label applies

Context: 118ac89 shortened the two Supra oracle labels to "Price feed oracle" and
"VRF" on the belief that they render under a "Supra" folder. Review of the branch
showed the manifest lists both pages flat, as siblings of API3, Chainlink, Chronicle,
DIA, ORA and Trellor, so the sidebar showed two entries with no vendor name in a
list where every other entry is one. The same review found three of the new docs
sentences said sidebar_label applies only when the page's manifest entry gives no
name, which presupposes an entry; a page the manifest never names (an unnamed entry,
a folder entry's child, or an Additional guides fallback) renders its label too, and
three pages do so today.

Why: the Supra change was the branch's only reader-visible edit and it made the
Oracles list harder to scan. STYLE-GUIDE.md asks for scannable names and expanded
acronyms; a bare "VRF" is neither. The docs wording matters because a writer could
conclude a page absent from the manifest has no working sidebar_label, which is
backwards.

What:
- Supra labels become "Supra price feed oracle" and "Supra VRF": still shorter than
  the originals, still dropping "How to use", vendor kept.
- INTERNALS.md, CLAUDE.md and README.md align on CONTRIBUTE.md's phrasing ("only if
  the page has no explicit name in lib/docs-navigation.json") and name the
  never-named case.
- One em dash in the README sentence replaced.

References: FS-2731; code-review-fs-2731-sidebar-label.md (Medium 1, Low 3, Nit 1).
The out-of-scope Medium (four manifest URLs listed twice) is FS-2740.

* docs(nav): tell contributors a label works on a page the manifest never names

Context: 5b8c01c gave INTERNALS.md, CLAUDE.md and README.md the positive clause
(a page the manifest never names renders its sidebar_label); CONTRIBUTE.md kept only
the corrected "only if" phrasing.

Why: CONTRIBUTE.md is the document an outside contributor reads, and knowing that a
label works on an unlisted page is most useful there. All four documents now say the
same thing.

What: one clause appended to the sidebar_label sentence in CONTRIBUTE.md.

References: FS-2731; code-review-fs-2731-sidebar-label.md round 2 Nit.
* fix(FS-2732): strip MDX comments from the markdown mirrors

Context:
`{/* … */}` renders as nothing, so it has never appeared in the HTML,
but it survived into the processed markdown served at /docs/<slug>.md,
/llms.mdx/docs/<slug>/content.md, the archive equivalents and
/llms-full.txt. Measured on the built site before this change: 100
occurrences in llms-full.txt, three in /docs/contribute.md, two in the
/docs/run-a-node/start-here/v1 archive mirror, zero in any HTML. The
leaked text is maintainer-facing: do-not-edit banners naming a pnpm
script, `todo:` notes recording open questions about the page, and
warnings about hardcoded URLs in the source file.

Why:
A comment is not content, and the HTML output already agrees. The
mirror exists to give a reader the same document in another format,
and every one of these comments is addressed to somebody editing the
.mdx file, which is the one file the mirror's reader does not have.
The do-not-edit banners are the only arguable case and the argument
fails: no generator reads a marker back out of a mirror (cli:generate,
stylus:generate, precompiles:generate and nitro:check-release all read
the raw file from disk), and "run pnpm stylus:generate" is not
actionable for a reader with no checkout.

The plugin goes in lib/mdx-options.mjs rather than in
postprocess.includeProcessedMarkdown. fumadocs-mdx composes one
processor per collection as [remarkInclude, ...mdxOptions.remarkPlugins,
[remarkPostprocess, …]] and remarkPostprocess stringifies the same
mdast the page compile then turns into JSX, so a plugin there sees
includes already spliced in, reaches the mirror, and cannot tell which
output it is feeding. Scoping to the markdown output alone would mean
passing a custom stringifier through postprocess, restated once per
collection, to buy nothing: deleting these nodes is a no-op for the
page compile. Sitting in that module also means check-links compiles
the same tree the site does, and both doc collections are covered at
once.

What:
- lib/mdx-comments.mjs: remarkStripMdxComments removes every
  mdxFlowExpression / mdxTextExpression whose parsed program has no
  statements and at least one comment, wherever it sits, including
  inside a JSX element's children. A JSX attribute expression is not a
  child and is never visited. The visitor revisits the spliced index,
  or a run of consecutive comments would keep every second one. A
  source-text fallback covers a tree parsed with no estree attached.
- lib/mdx-options.mjs: one import and one entry in remarkPlugins.
- scripts/lib/mdx-comments.test.mjs: 12 cases through the real
  @mdx-js/mdx processor and the real remarkLLMs, so the assertions are
  on the markdown string a reader gets. Includes the un-stripped
  control case, a comment inside <Tabs>, and proof that a comment
  inside a fenced block or an inline code span is served as written.
- scripts/static-docs-http.test.mjs: a page mirror, an archive mirror
  and llms-full.txt each come back with no `{/*` and with their prose
  intact. llms-full.txt is asserted because it spans ~80 pages, so it
  cannot go vacuous when a single page is edited.
- INTERNALS.md and CLAUDE.md record the decision and the mechanism.

Left out: whitespace is not normalized around a removed inline
comment, so `word {/* … */} word` leaves two spaces in the mirror.
That would mean editing text rather than deleting a node, and no page
in content/ writes a comment mid-line; all 99 start their line.

After this change all four counts above are zero, with the HTML, the
13 blocking gates and `pnpm build` unchanged.

References:
- FS-2732 (MDX comments leak into the markdown mirrors and
  llms-full.txt)
- node_modules/fumadocs-mdx/dist/build-default-gg93ZIqY.js, buildJSMDX
  and remarkPostprocess
- 6c233ac FS-2725, the precedent for a small remark plugin in lib/ with
  a test under scripts/lib/

* docs(FS-2732): say what stripping a comment changes in the HTML, not "nothing"

Context: 6b9e069 removes comment-only MDX expression nodes from the shared remark
chain so they stop reaching the markdown mirrors, and described the removal as a
no-op for the page compile. Review measured it: on the 44 pages that carry a comment
the compiled output loses an empty JSX expression plus one "\n" string child per
comment, which the prerendered HTML did contain (events.html carried 182 such nodes).

Why: invisible whitespace is the right outcome, but a comment and a canonical doc
that claim strict identity will mislead the next person who diffs the HTML and finds
a change. The HTTP test's comment also misattributed the contribute page's three
comments to its own body when all three arrive through the included partial.

What:
- lib/mdx-comments.mjs and INTERNALS.md describe the measured effect (an invisible
  newline per comment) instead of claiming a no-op.
- scripts/static-docs-http.test.mjs comment corrected.

References: FS-2732; code-review-fs-2732-mdx-comments-mirrors.md (Low 1, Nit 2).
…ne-based scanner (#79)

* refactor(FS-2729): converge the four code-stripping helpers onto one scanner

Context

Four independent implementations of "ignore code when scanning MDX" had grown
up, one per content gate: the regex stripCode in scripts/lib/strip-code.mjs
(partials tooling and content-lint), maskRegions in scripts/lib/doc-links.mjs
(check-links and move-doc), stripCodeFences plus stripInlineCode in
scripts/lib/remote-images.mjs (images:check), and a fourth copy of stripCode's
two fence regexes inlined in content-lint rule A6.

Why

Each carried its own edge cases, and the FS-2723 round 2 review showed how
cheaply one of them regresses a blocking gate: a one-line regex change produced
two Medium regressions, one of which silently hid 6,914 characters of prose from
content:lint. The review also documented two limits it could not fix inside a
regex. A run of three or more backticks closed an opener of any length, so a
three-backtick line closed a four-backtick fence, and two content pages open
four-backtick fences. An unbalanced backtick inside a single-line MDX comment
that straddled the comment's own closer left that comment unblanked, because no
fixed order of two passes can get that case and backticked comment delimiters
written as prose both right.

What

scripts/lib/strip-code.mjs now holds one line-based scanner and every consumer
imports it. The scanner direction was chosen so strip-code.mjs keeps importing
nothing: doc-links.mjs already owns node:fs and node:path, and the reverse would
drag a filesystem walk into both linters.

Two phases, matching the order a markdown parser works in. Phase 1 is block
level: frontmatter, then a line-by-line fence scan where the closer repeats the
opener's character at least as many times, is indented no more than three
columns past the opener, and holds nothing else, and an unclosed fence runs to
end of file. Phase 2 is one left-to-right scan over what phase 1 left, where
whichever delimiter opens first wins, and a code span closes on a run of exactly
the same length within the same block. First-opener-wins is what fixes the
straddling comment without losing backticked delimiters in prose.

Every fence and span rule was checked against mdast-util-from-markdown rather
than reasoned about, including the two that moved: a four-space closer under an
unindented opener does not close, and a closer carrying trailing text does not
close either.

Region kinds are per consumer, so no gate's coverage moves by accident.
content-lint and the partials tooling keep frontmatter visible, check-links and
move-doc keep MDX comments visible, images:check keeps all three visible. A6 now
asks for the code regions themselves instead of carrying its own regexes, which
deletes the fourth implementation.

New shared suite in scripts/lib/strip-code.test.mjs: 45 cases carrying FS-2723's
29-case matrix, both documented limits as cases that now pass, and the cases the
replaced helpers owned, each asserting the length and line-count contract.

Tree-wide sweep over all 596 tracked content files: includes, partial imports
and extracted remote images are unchanged everywhere, and extractRefs gains four
links in one file that a spurious cross-paragraph code span had been hiding.
content:lint, partials:check, check-links and images:presence output is byte
identical to fork/main.

References

FS-2729. Follows FS-2723 (PR #70) and its round 2 and round 3 reviews.

* fix(FS-2729): bound a code span to its own paragraph

Context: converging the four code-stripping helpers gave `stripCode` the
multi-line inline span that `maskRegions` already had, where the old regex
stopped at end of line. The review of `172482b` found that the only bound on
that reach is a blank line, and a paragraph ends in more places than that.

Why: the direction is silent coverage loss in a blocking gate. Proven through
the real `lintSource`, on a page whose only defect is an internal link keeping
a `.md` suffix so rule A5 should report it: put a heading, a list item, a
blockquote, an HTML block or a thematic break straight after the paragraph with
no blank line, add a stray backtick either side, and `content:lint` reports
nothing where `fork/main` reports one finding. A CRLF blank line and the end of
frontmatter do the same. That is the FS-2723 round 2 failure again, where one
regex line hid 6,914 characters of prose while every gate stayed green.

What: `closingRun` now stops at the end of the paragraph the run opened in
rather than at a blank line, and `endsParagraph` decides that line by line. A
line ends the paragraph if it is blank, counting a CR as whitespace, or if it
opens a block: an ATX heading, a blockquote marker, a thematic break, a bullet
or ordered list marker, a fence opener, or an HTML or JSX tag. Frontmatter needs
no case of its own, because both of its delimiters are `---` lines and a `---`
line is a thematic break. `INLINE_ELEMENT` exempts a tag that opens and closes
the same element on one line, which is an inline element in MDX and interrupts
nothing, unlike the self-closing and unclosed forms beside it. All three rules
were measured against the MDX parser this repo compiles with, not reasoned
about.

Fourteen cases added, seven of which fail against `172482b`, and every clause of
the new rule is caught by a mutation the suite fails on. One more case pins the
exact-run rule for closing a span, which survived mutation to `>=` before. Three
"Known limits" corrected: the claim that a span may pair across a blanked fence
is false and is deleted, and a backtick in a fence info string, a fence inside a
blockquote and the container blindness in the new indentation cap are added.

Nothing moves in `content/`: `stripCode`, `maskRegions` and the images mask are
byte-identical to `172482b` on all 596 files, so the sweep against `fork/main`
is unchanged, and `content:lint`, `content:lint --all`, `partials:check`,
`check-links` and `images:presence` are still byte-identical to base. A
differential against the parser over 4,000 generated inputs takes head-only
divergences from 17 to 0 under MDX ground truth.

References: FS-2729, review round 1; FS-2723 rounds 2 and 3 for the history.

* fix(strip-code): bound a span at a setext underline and on CRLF thematic breaks

Context: 4381143 confined the multi-line code-span search to the paragraph the run
opened in, deciding "end of paragraph" line by line. Round 2 review probed the new
rule with fresh inputs and found two lines it missed: a setext heading underline of
"=" (not in the block-start set), and a thematic break on CRLF input (the alternative
ended in $ with no \r allowed, and frontmatter rides on that same clause).

Why: the setext case is the round 1 Medium surviving in one shape and in the
over-masking direction, where content-lint goes blind to a real finding; zero such
underlines exist in content today and the house style is ATX, hence Low. The CRLF
case cannot reach content because Prettier rejects CRLF and format:check blocks, but
the scanner should not depend on another gate for its own correctness. The review
also found two heuristics with no test pinning them.

What:
- BLOCK_START gains an "=+" alternative and both end-anchored alternatives allow a
  trailing \r; the comment above it says so.
- Four tests: setext underline bounds; CRLF thematic break and CRLF frontmatter
  bound; "#hashtag" and "#5" do not; "<Foo>x</Bar>" fails INLINE_ELEMENT's
  backreference and opens a block. The first two fail against 4381143.
- All three masks byte-identical to 4381143 on all 596 content files.

References: FS-2729; code-review-fs-2729-strip-code-convergence.md round 2 (Low 6,
Low 7, Nits on unpinned heuristics).

* fix(strip-code): a two-hyphen setext underline bounds a span too

Context: 94cdbe2 added a setext "=" underline to the paragraph bound. Round 3 review
found the hyphen form covered at the ends of the range and not the middle: one hyphen
is a bullet marker, three or more a thematic break, exactly two matched neither, and
both parsers read "--" under a paragraph as an h2 setext heading. Base lintSource
reported the A5 finding on that shape and head reported none.

Why: same over-masking direction as the "=" case, where content-lint goes blind to a
real finding. No line of one or two hyphens follows a non-blank line anywhere in
content today, hence Low, but the module's premise is paying down exactly these
undocumented edge cases.

What:
- The setext alternative is (?:=+|-+), not [=-]+: a mixed run such as "--=" is
  neither an underline nor a break and must let the span run. Tested both ways, and
  the backreference of the thematic-break group still resolves (verified
  behaviourally on ---, ***, - - -, -*-, -_-).
- The endsParagraph comment no longer claims the "-" form is a thematic break; the
  parser calls any hyphen run under a paragraph a setext heading.
- The <Foo>x</Bar> pin's comment now states that MDX falls back to paragraph text on
  the mismatched tag and why the scanner's stricter reading is pinned anyway.
- All three masks byte-identical to 94cdbe2 on 596 content files.

References: FS-2729; code-review-fs-2729-strip-code-convergence.md round 3 (Low 8,
Nit 8).
* fix(content-lint): judge an A5 destination after {var:} expansion

Context:
Rule A5 flags an internal link target that keeps its .md or .mdx
suffix, and decides "internal" by looking for a scheme at the start of
the written destination. FS-2725 introduced the {var:name} placeholder,
which may hold an absolute URL, so a destination like
{var:docsRepositoryUrl}/blob/main/CONTRIBUTE.md has no scheme as
written and reads as a relative path with a .md suffix.

Why:
A destination that expands to a file in a git repository is supposed to
keep its .md suffix, so the finding tells the writer to break a working
link. check-links already resolves a destination after expansion
(expandRefUrl in scripts/lib/doc-links.mjs), so the two checkers now
agree about what a destination means. Special-casing a leading "{var:"
as external was rejected: a placeholder also appears inside internal
/docs/... destinations, which must keep being judged.

What:
- Expand placeholders with lib/var-links.mjs before the internal test
  and the suffix test, in both the markdown and the href/to forms. The
  finding still quotes the destination as written, so the message names
  the string the writer has to find in the file.
- Read content/vars.json once, lazily, so the unit tests that never
  reach A5 do not pay for a file read on import.
- Cover both directions in content-lint.test.mjs.

References:
- FS-2733 (one owner for the repository's own GitHub URL), which is
  where this shape first appears in content/
- 6c233ac FS-2725: Make a global variable usable inside a link
  destination

* FS-2733: give the repository's own GitHub URL one owner

Context:
content/partials/_contribute-docs-partial.mdx, rendered at
/docs/contribute, hardcoded six links back into this repository, and
lib/shared.ts held the same owner, repo and branch as gitConfig for the
edit link and the "Request an update" issue link. FS-2722 covered the
six with a comment asking a human to retarget them by hand, because
FS-2725 had declined to move them onto a variable while a second copy
of the string lived in TypeScript. Nothing enforced the comment:
check-links skips every external destination before resolving it, so a
rename would have left six dead links with no gate turning red. The
repository is expected to take over the OffchainLabs/arbitrum-docs name
at cutover, so the rename is scheduled, not hypothetical.

Why:
content/vars.json is the single writer-facing source of values already,
{var:name} already expands in a link destination, and vars:check
already fails on a mistyped name, so putting the identity there costs
nothing new. The alternative (keeping the value in TypeScript and
merging code-owned values into lib/var-links.mjs) was rejected: that
module is plain JavaScript imported by check-links under bare Node, so
it cannot import a .ts file, and a .mjs sibling holding the value would
be a third home for a string that already had two.

The JSON is imported with an explicit `with { type: 'json' }`
attribute, and content/vars.ts is deliberately not imported instead.
Three test files import lib/shared.ts as .ts under node --test, where
Node 22 strips the types but still rejects a bare JSON import with
ERR_IMPORT_ATTRIBUTE_MISSING; and content/vars.ts pulls in Zod, which
this module has to keep away from the client bundle, since
components/sidebar-resource-links.tsx imports from it. Measured with a
full build before and after: client chunks are byte identical
(16,618,175 bytes over 379 files, the chunk carrying
SidebarResourceLinks still 2,530 bytes) and no vars.json value appears
in any of them, because both keys are read in server components only.

What:
- content/vars.json and content/vars.ts gain docsRepositoryUrl and
  docsRepositoryBranch. docsRepositoryUrl is the one value that flips at
  cutover and takes the code and the content with it.
- gitConfig in lib/shared.ts reads both, and is now { url, branch }
  rather than { user, repo, branch }. Both call sites joined the first
  two immediately, so the split only offered a way for the halves to
  disagree. Call sites updated in app/docs/[[...slug]]/page.tsx and
  components/RequestUpdateLink.tsx.
- The six destinations in the contribute partial become
  {var:docsRepositoryUrl}/blob/{var:docsRepositoryBranch}/... links, and
  both drift-warning comments are deleted, since the mechanism replaces
  them. The "fork the Arbitrum docs repo" link and its exception comment
  are untouched: it names the cutover destination on purpose, and the
  other five files do not exist in that repository yet.
- scripts/lib/contribute-repo-links.test.mjs asserts, with no server,
  that every GitHub URL the partial renders belongs to the repository
  gitConfig names, and that the URL is never written out in full. It
  imports lib/shared.ts, so it also pins the code value to the JSON
  value. scripts/static-docs-http.test.mjs asserts the same rule over
  the rendered /docs/contribute, which is what proves the placeholders
  expanded rather than shipping as literal braces.
- INTERNALS.md gains a Global variables subsection naming the owner and
  the cutover flip; CLAUDE.md, CONTRIBUTE.md and README.md mirror it.

Left out: the two URLs in .github/pull_request_template.md stay
hardcoded, since GitHub renders that file and nothing here reaches it.
Flip them by hand at cutover. package.json's "name" is the npm package
name, not a URL, and is untouched.

Verified: all thirteen blocking gates green, plus a production build and
the HTTP suite against next start on port 3205, where /docs/contribute
and /docs/contribute.md both serve the six expanded URLs.

References:
- 8dde6e2 FS-2722: Remove the remaining Docusaurus-era instructions from
  the contribute guide
- 6c233ac FS-2725: Make a global variable usable inside a link
  destination
- a705499 fix(content-lint): judge an A5 destination after {var:}
  expansion

* FS-2733: put the second issue link on the variable and widen the tripwire

Context:
Round 1 review of the branch that gave this repository's own GitHub URL one
owner in content/vars.json. The review found a second reader-facing "file an
issue about these docs" link that the branch's sweep could not have seen,
because that sweep grepped for the literal string Fumadocs-test, which cannot
match a URL naming the other repository.

Why:
On the four pages carrying the "know more tools?" box, a reader was offered two
different issue trackers for the same task, one paragraph apart: the box named
OffchainLabs/arbitrum-docs while the "Request an update" button beside it named
this repository. Nothing was dead, so nothing complained. The offline tripwire
could not have caught it either, since it asserted over one hardcoded path, and
the argument for the check is that check-links skips every external destination,
which is true of every content file rather than one.

What:
- content/partials/_know-more-tools-box-partial.mdx now writes
  {var:docsRepositoryUrl}/issues/new, the seventh link on the variable.
- scripts/lib/contribute-repo-links.test.mjs keeps its two contribute-guide
  assertions and gains a third that walks every .mdx under content/, covering
  docs, partials, _versions and the glossary: no content file may write a
  docs-repository URL out in full, judged against both the current name and the
  arbitrum-docs name this repository takes over, with the fork step as its one
  documented exception. Other OffchainLabs repositories are out of scope, being
  separate projects the cutover does not move. Reverting the link above makes it
  fail, naming the file and the URL.
- scripts/static-docs-http.test.mjs asserts the new link expanded on a page
  carrying the box, which /docs/contribute cannot see.
- content/vars.ts constrains docsRepositoryBranch to z.string().min(1), the way
  announcementId carries a pattern. An empty branch renders a /blob// URL that
  GitHub answers 404 and no gate would see. A trailing slash on the URL is left
  alone, since the doubled slash it produces is answered 200.
- INTERNALS.md and CLAUDE.md record all of the above, restate the client-chunk
  measurement as key names rather than every value, which produces four
  coincidental hits on short version strings, and add the traced proxy closure
  as the second consumer of lib/shared.ts. Measured: nothing from vars.json
  reaches it, because gitConfig goes unused there and is dropped.

References:
- Linear FS-2733
- code-review-fs-2733-repo-url-owner.md, findings 1, 2, 3, 5 and 6

* docs(FS-2733): state the proxy measurement exactly, complete the cutover checklist

Context: 3c4db05 documented that the traced proxy closure carries no vars.json key
name "or either new value". Review measured docsRepositoryBranch's value, the word
"main", in 16 of the 98 traced files, all as ordinary code, which is the same
coincidence the client-chunk paragraph two paragraphs above had just been corrected
for. The same review found the cutover checklist in INTERNALS.md and CLAUDE.md names
the docsRepositoryUrl flip and the pull-request template but not the fork-step
exception.

Why: after the flip gitConfig.url equals the arbitrum-docs URL, so the exception in
scripts/lib/contribute-repo-links.test.mjs would let a hardcoded copy of the live URL
sit unchallenged in the one file the mechanism otherwise owns, and nothing else would
object. The full checklist existed only in the untracked PR note.

What:
- INTERNALS.md: the proxy sentence now claims key names and docsRepositoryUrl's value
  only, and names the "main" coincidence; the pull-request-template paragraph becomes
  the full three-step cutover checklist, including how to retire the exception.
- CLAUDE.md: the same checklist in one sentence, linked to the INTERNALS section.

References: FS-2733; code-review-fs-2733-repo-url-owner.md round 2 (Nit, Low).

* change target repo name

* update

* update pr template
)

Context:
FS-2731 (PR #76) made lib/docs-navigation.json the source of truth for
sidebar naming and deleted every sidebar_label that a manifest name
already overrode. What that pass did not touch is a second class of
dead frontmatter: 32 pages under content/docs whose sidebar_label is
byte-identical to their own title. The navigation transformer renames
a page node to sidebar_label when present, so on these pages it
produces exactly the string the loader's title default already
produces. The field has no effect and invites a future editor to
believe it does something.

Why:
A frontmatter value that never changes rendered output is worse than
no value: it suggests sidebar_label is doing work on that page, and it
is one more place a future edit can silently drift from what actually
renders. Deleting it where it is provably a no-op removes that trap
without touching any rendered sidebar text.

What:
- Deleted the sidebar_label line from the 32 content/docs pages where
  it matched title exactly (confirmed by parsing real frontmatter with
  fumadocs-core/content/md/frontmatter and comparing against title).
- Also deleted it from content/_versions/v1/run-a-node/run-batch-poster.mdx,
  an archived page with the same identical value. sidebar_label is only
  ever read through docsNavigationTransformer, which is wired solely
  into the live docs collection's page tree; docsVersions never passes
  through it, so the field had zero effect on this archive regardless
  of its value.
- content/docs/run-a-node/nitro/cli-flags-reference.mdx is generated by
  pnpm cli:generate, but only the GENERATED:START/END region is
  rewritten; frontmatter outside the markers is read from the existing
  file and preserved as-is, so deleting the line from the committed
  page is safe without regenerating. Also dropped
  sidebar_label: 'CLI flags reference' from SCAFFOLD_FRONTMATTER in
  scripts/lib/cli-reference-page.mjs (used only if the file is ever
  deleted and rebuilt from scratch) so the redundant value cannot come
  back, and updated the file's doc comment to match. pnpm cli:generate
  was not run; only the scaffold template and the doc comment changed.
- Swept every sidebar_label and title under content/ (including
  stylus-by-example, content/_versions, content/glossary,
  content/partials) for a leading/trailing space or internal double
  space. Found none; the one known past example was already fixed by
  FS-2731. No whitespace-fix commit.
- Considered adding a trim/min-length guard to title and sidebar_label
  in source.config.ts. Declined: nothing live triggers the defect it
  would catch, and schema hardening with no current instance to fix is
  outside this cleanup's scope. Left as a follow-up suggestion.

Verified by building the real Fumadocs page tree through
docsNavigationTransformer plus lib/docs-navigation.json, with real
frontmatter, once before and once after the 32 content/docs deletions:
480 nodes both times, zero differing nodes. Spot-checked six pages
across five sections on a local dev server; all render their expected
title as the sidebar name.

References:
- FS-2731 (PR #76), which established the manifest-name-over-
  sidebar_label precedence and left this second, identical-value class
  unaddressed.
- code-review-fs-2731-sidebar-label.md (read-only reference), for the
  page-tree-dump verification method reused here.
…er cannot see (#83)

* fix(FS-2743): repair four fence and title defects no gate can see

Context:
The FS-2729 tree-wide sweep and the FS-2731 label audit turned up four
one-line content defects. Each compiles, type-checks and returns HTTP
200, so every gate stays green while the reader sees something wrong.
All four were confirmed against a running dev server and, where the
question was how the parser reads the source, against the real MDX
parser this repo resolves.

Why:
Three of the four are reader-visible and their fixes are obvious once
the render is seen. The fourth, the four-space fence closer, turned out
not to be what it looked like, and that is worth recording.

This repo's MDX parser closes a fence at any indentation, because
remark-mdx turns off indented code blocks and with them the three-column
cap CommonMark puts on a closing fence. Measured on one input across
closer indents 0 to 6: mdast-util-from-markdown stops closing at 4,
remark-parse plus remark-mdx keeps closing at 4, 5 and 6. So the
da-api page has always rendered correctly and the ticket's prediction
of one swallowed code block is what CommonMark would do, not this site.

The defect there is a blind spot rather than a bad render.
scripts/lib/strip-code.mjs models the CommonMark column, so it treats
lines 919 to 936 as fence body and blanks them. Every gate that reads
MDX through that scanner (content:lint A1 to A11, check-links,
partials:check, images:presence) is therefore blind over eighteen
lines, two of which are Tab element tags. Dedenting the closer changes
no pixel and hands those eighteen lines back to the gates.

What:
- bold-technical-deep-dive.mdx:195. Drop one of two trailing backticks.
  The opening run was one backtick and the closing run two, so nothing
  paired and the span shipped as literal text, backticks included. Now
  renders as a <code> element.
- da-api-integration-guide.mdx:926. Dedent the closing fence from four
  spaces to column 0. The MDX AST for lines 916 to 951 is byte for byte
  what it was: three Tab elements with one code node each.
- trait-based-composition.mdx:236. Delete a stray trailing fence. It was
  the last line of the file and opened a block nothing closed, which
  rendered an empty shiki code box with a copy button under the closing
  prose list. Deleted rather than closed: lines 226 to 235 are two prose
  lists about method search order, and the fence above them at 182
  already closes at 213.
- reown.mdx:2. Close the parenthesis in the frontmatter title. The
  unclosed form reached the reader in four places: <title>, og:title,
  twitter:title and the page <h1>. sidebar_label is untouched, so the
  sidebar does not change.

Left out: the scanner itself. Making strip-code.mjs model MDX rather
than CommonMark would change masking across the whole tree and could
regress any gate that reads it, so it wants its own ticket. A lint rule
blocking both shapes lands in the next commit instead.

References:
- FS-2743
- FS-2729 (the converged scanner these four were found with)
- FS-2731 (the label audit that found the Reown title)
- PLAN-FS-2743.md, which carries the before and after renders

* feat(FS-2743): add content-lint rules A12 and A13 for fence closers

Context:
The two fence defects fixed in b88eada were both invisible. A stray
trailing fence rendered an empty code box with a copy button under the
last prose list of a page, and a four-space closer left eighteen lines
of another page masked as fence body by every gate that reads MDX
through strip-code.mjs. Both shipped with all thirteen gates green, and
both were found by hand during the FS-2729 sweep rather than by a check.

Why:
A defect a writer cannot see and no gate can see is the case this linter
exists for, and the shared scanner already knows where every fence opens
and how it closed, so neither rule needs a new parser.

Two ids rather than one, because the report groups by id and the two
fixes are opposite: A12's is to delete a stray line or write a missing
closer, A13's is to dedent one. A single "fence defect" id would print
one count covering two unrelated edits, which is the reasoning INTERNALS
already records for keeping A8, A9 and A10 apart.

Both are in the default rule set. A tree-wide probe before the content
fixes found exactly one instance of each, and both were the two files
b88eada repaired, so both report zero on content/ today. A7 stays the
only rule outside the default set.

A13 is worth stating plainly: it fires on source that renders correctly.
remark-mdx turns off indented code blocks and with them CommonMark's
three-column cap on a closing fence, so the MDX parser closes a fence at
any indentation while strip-code.mjs, which models CommonMark, does not.
Measured across closer indents 0 to 6 on one input:
mdast-util-from-markdown stops closing at 4, remark-parse plus remark-mdx
keeps closing at 4, 5 and 6. Where they disagree the reader sees a
correct page and A1 to A11, check-links, partials:check and
images:presence all stop looking at the lines between. Writing the closer
at the opener's indentation is the one form both parsers read alike.

What:
- strip-code.mjs: pull the fence loop out of codeRegions into scanFences,
  one generator, and yield both readings of the closer per fence.
  codeRegions consumes start and end from it and is otherwise untouched,
  so masking behaviour is unchanged. New export fenceDefects reads the
  same generator for the two rules. No second fence regex anywhere.
- content-lint.mjs: A12 and A13 off fenceDefects. A13 reports the
  closer's line, not the opener's, because that is where the fix goes.
- content-lint.mjs (CLI): titles for both, so the grouped report names
  them. The numeric ruleOrder added for A10 already sorts A12 and A13.
- Tests: nine cases in strip-code.test.mjs covering the three-column
  allowance, a fence legitimately nested four columns deep in a list
  item, a marker mismatch, a fence documented inside a longer fence, and
  the agreement between fenceDefects and codeRegions; ten in
  content-lint.test.mjs, including the one that pins an over-indented
  closer at end of file as A13 rather than A12.

Left out: making strip-code.mjs model MDX rather than CommonMark, which
would remove the disagreement at its root but change masking across the
whole tree and could regress any gate that reads it. Recorded as a
follow-up. With A13 in the default set, no file in content/ relies on
the difference meanwhile.

References:
- FS-2743
- FS-2729 (the one-scanner convergence these rules extend)
- FS-2714 (A8, A9 and A10, the precedent for splitting one family of
  defects across several ids)
- b88eada fix(FS-2743): repair four fence and title defects no gate can see

* docs(FS-2743): record content-lint rules A12 and A13

Context:
7eace82 added two rules to the default content:lint set. Three places
state the rule range or enumerate the rules, and CLAUDE.md's own header
says a change in one has to be checked against the others.

Why:
A13 in particular is not self-explanatory from its message. It fires on
source that renders correctly, so a writer who opens the page will find
nothing wrong and needs the reason written down: this repo has two
markdown parsers, remark-mdx lifts CommonMark's three-column cap on a
closing fence, and where they disagree every gate that masks code goes
blind over the lines between. Without that written down the obvious
reading of a green page and a red gate is that the gate is wrong.

What:
- INTERNALS.md: two rows in the rule table, and a new subsection under
  the content-lint rules carrying the measured parser comparison across
  closer indents 0 to 6, one entry per rule in the shape the A8/A9/A10
  entries use, the reason the masking deliberately stays at the
  CommonMark reading, and the note that neither rule brings a parser of
  its own. Also corrects the sentence on strip-code.mjs's consumers,
  which listed the masking and region callers but not the new one.
- INTERNALS.md: the content:lint row in the gate table now reads A1
  through A13 except A7.
- CLAUDE.md: the content:lint rule sentence in the Gates bullet, and the
  pre-commit hook's statement of the default set. Both said A1 to A11.

Left out: CONTRIBUTE.md and README.md. Both name rule A11 for a specific
`<Var>` defect rather than stating a range, so both stay accurate.
CONTRIBUTE's list of companion rules sits inside a prose-style bullet
about links in headings, where a fence-mechanics rule would be off
topic. Nothing in either file is now wrong.

References:
- FS-2743
- 7eace82 feat(FS-2743): add content-lint rules A12 and A13 for fence closers
- FS-2714 (the A8, A9 and A10 entries these are written to match)

* fix(FS-2743): close the review findings on the fence rules

Context:
Round 1 review of this branch confirmed one Medium, three Lows and two
nits. The Medium is that the Reown title fix closed the parenthesis on
the page but not on the section index card that links to it, so the site
showed two spellings of the same title one click apart. The Lows are a
missing test, a missed CLAUDE.md mirror, and an A13 message that asserts
two things which are false on two of the shapes the rule fires on.

Why:
The defect class this ticket exists to close is "a reader sees something
wrong and every gate is green", and the index card was one instance of it
left on the shortest path to the page that was fixed.

A13's message told the writer the page renders correctly and to dedent
the closer. Measured through this repo's processor, neither holds when
the fence was meant to stay open past that line: a fence whose own closer
is missing, followed later by an unrelated over-indented closer, has MDX
ending it at that later line and swallowing the prose between, and an
outer fence documenting an indented inner fence ends at the inner closer,
early. On both, dedenting does not fix the page and on the second it
makes it worse. Reclassifying those shapes would be a logic change with
more than one reasonable resolution, so the message is made true instead:
it now names what the scanner saw, says which parser ends the fence
there, and sends the writer to the rendered page before dedenting.

The A13 test gap was real. Every existing A13 case has no conforming
closer, so the strict offset is -1 and the line arithmetic on it lands on
the asserted line by coincidence; mutating the reported field to the
strict closer left all 79 cases green.

What:
- Close the parenthesis on the Reown card in the third-party-docs index.
  A tree-wide sweep of all 596 tracked content files, over every
  frontmatter title, sidebar_label and description and all 229 <Card>
  tags, returns this one hit and no other unbalanced parenthesis.
- Reword A13's message, its rule comment and its INTERNALS entry so every
  claim holds on every shape the rule fires on, and record the two shapes
  that falsify the old wording. CLAUDE.md's Gates bullet mirrors it.
- Add an A13 case with an over-indented closer and a conforming closer
  further down, asserting the over-indented line is reported. It is the
  shape that occurred in content/ and the only input that distinguishes
  the two readings. Proved by mutation on a scratch copy: with
  closerStart taking the strict offset, this case alone fails.
- Mirror the strip-code consumer sentence into CLAUDE.md, which still
  enumerated only the masking and region callers.
- Split the INTERNALS run-on that the new A12/A13 clause created, and
  name images:presence rather than images:check in A13's gate list, which
  is the blocking gate and the name the rule message already uses.

Left out: Nit 7, codeRegions ignoring bodyStart with no test covering it.
It is pre-existing, indistinguishable on all 596 content files, and the
base carries the identical structure.

strip-code.mjs is untouched, so the reviewer's behavior-preserving sweep
over the scanner needs no rerun.

References:
- code-review-fs-2743-content-fence-defects.md (Medium 1, Low 2, Low 3,
  Nit 4, Nit 5, Low 8)
- 8772946 docs(FS-2743): record content-lint rules A12 and A13
- 7eace82 feat(FS-2743): add content-lint rules A12 and A13 for fence
  closers

* fix(content-lint): A13 names the opener too when an outer fence documents an inner one

Context: round 2 review of FS-2743 measured the outer-fence shape: lengthening only
the closer leaves the page broken, the opener has to grow as well.

Why: the message told the writer to look for a too-short closer, which on that shape
sends them to the wrong delimiter.

What: one clause in A13's message. Tests unchanged, 79 passing.

References: FS-2743; code-review-fs-2743-content-fence-defects.md round 2 Nit 9.
… a `nav:check` rule (#81)

* fix(FS-2740): point each duplicated manifest entry at the page it names

Context: `lib/docs-navigation.json` claimed four `page` URLs twice, under
eight entries. `page()` in `buildDocsNavigation` throws only on a URL that
does not exist, and every one of these index URLs exists, so nothing caught
it. The page each second entry was meant to name fell through into its
section's "Additional guides" group instead, where it rendered its own
`sidebar_label`. The most visible symptom was "Elara (ArbOS 61)" opening the
ArbOS releases overview while the real page sat under Additional guides as
"ArbOS 61 Elara".

Why: the manifest was built alongside `redirects.legacy.mjs`, and every one
of the six mis-targeted entries has a legacy redirect resolving the same
upstream URL to the same section landing rather than to the real page. The
manifest inherited that resolution. Six real pages therefore had no named
place in the sidebar.

What: seven entries retargeted and one deleted.

- Sequencing > "Sequencer configuration reference" now opens
  `configuration/sequencer/sequencer-config-reference`, whose title it
  already carried.
- Costs > "Parent chain data fee pricing" and "Priority fees" now open
  `costs/parent-chain-data-fee-pricing` and `costs/priority-fees`, whose
  `sidebar_label` each already carried.
- Operate > "Error index" and "Sequencer troubleshooting" now open
  `operate/error-index` and `operate/sequencer-troubleshooting`.
- ArbOS software releases > "Elara (ArbOS 61)" now opens
  `arbos-releases/arbos61`, matching its five siblings.
- Batch poster > "Batch poster" is deleted. It named upstream's
  `chain-config/batch-poster/config-batch-poster`, a page this repository
  never ported, and borrowed the sequencer landing page's URL the same way
  its legacy redirect does. Every other batch-poster page is already claimed
  once and correctly, so there was no page for it to name, and leaving it
  would keep a sidebar item labelled "Batch poster" that opens a page titled
  "Sequencer configuration".

Six pages leave Additional guides. Three folder landing pages move into it,
because nothing names them any more: the `configuration/costs`,
`configuration/sequencer` and `operate` indexes. That is where four sibling
landing pages already sit, and each one's body is a card grid duplicating
links the sidebar lists individually.

Verified by dumping the real Fumadocs page tree before and after through
`buildDocsNavigation` with real frontmatter: 513 nodes to 508, and every
changed line is one of the above. Checked in a browser on all four sections.

References: FS-2740.

* feat(FS-2740): reject a navigation manifest that claims a page URL twice

Context: the four duplicate `page` URLs the previous commit fixed were
invisible to every gate. `types:check` and `build` never read the manifest,
`nav:check` read only the `meta.json` tree, and `buildDocsNavigation` throws
on a URL that does not exist, which a duplicate is not. The defect was found
by hand during a code review.

Why: a `page` entry is the canonical claim on a URL, the node that gives the
destination its sidebar root. Two entries claiming one URL therefore always
means one of them opens a page it does not name, and the page it was meant
to name is exiled to Additional guides. There is no legitimate reason to
write it, so it can be rejected outright.

What: `duplicateManifestPages` in the new `lib/docs-navigation-rules.mjs`,
plain JavaScript with no imports, following `lib/site-url.mjs`. Two callers
apply the same copy:

- `buildDocsNavigation` validates the sections up front and throws, so
  `pnpm dev` fails loudly rather than rendering a wrong sidebar. It checks
  the manifest rather than counting claims inside `page()`, which is also
  reached from `copyFolder` and `remaining`, where a repeat is legitimate.
- `checkManifest` in `scripts/lib/nav.mjs`, reported by `scripts/nav-check.mjs`
  as a fourth rule, so `pnpm nav:check` exits 1 and names each URL with both
  entries that claim it.

`.mjs` rather than importing `lib/docs-navigation.ts` directly, because
Node's type stripping prints a `MODULE_TYPELESS_PACKAGE_JSON` warning onto
the gate's stderr for every such import.

A repeated `href` is deliberately exempt: it builds a display-only separator
node that claims nothing, which is the documented way to pin a cross-section
shortcut, and the manifest does it for sixteen URLs. A repeated `folder` is
left alone too, because a folder expands against the real content tree and a
static read of the manifest cannot say what a repeat would duplicate.

Tests cover the rule, the `href` exemption, a claim nested in a `children`
group and one used as a group index, the transformer throw, and the six
pages that now have a named home. Run against `fork/main`'s manifest, the
rule reports exactly the four URLs and all eight entry names, and the
transformer throws at load.

References: FS-2740.

* docs(FS-2740): record the manifest duplicate rule in the sidebar docs

Context: `pnpm nav:check` gained a fourth rule and `buildDocsNavigation`
gained a second throw. Both are described in INTERNALS.md's sidebar section
and mirrored into CLAUDE.md, which agents read in session.

Why: the existing wording said missing pages, references and folders throw,
which a reader could take to mean the transformer catches every broken
manifest entry. A duplicate names a page that exists, so it did not throw,
and that gap is exactly what this ticket closes.

What: one sentence added to each of INTERNALS.md's two relevant paragraphs,
plus the gate table row. In CLAUDE.md, the `nav:check` line in Commands and
the `nav:check` sentence in the Sidebar ordering paragraph. Nothing else in
that paragraph is touched; FS-2744 owns the larger rewrite of it.

References: FS-2740, FS-2744.

* docs(nav): name the rule's remaining gap, drop the six labels the manifest now overrides

Context: dac25d0 retargeted eight manifest entries and added the duplicate-URL rule.
Review found INTERNALS.md calling the duplicate "the one broken shape" the missing-page
check cannot see, while the same branch's PR note records a second one (a children
claim on a section's derived landing page, which the rule's walk never visits). The
six pages the fix freed from Additional guides also carry a sidebar_label that their
new manifest name overrides, four of them byte-identical to it: the dead-label class
FS-2731 removed 52 of one day earlier.

Why: a canonical doc that says "the one" gap invites the next reader to stop looking.
A sidebar_label the manifest always beats misleads the writer who edits it and
changes nothing.

What:
- INTERNALS.md: "one broken shape", plus the sentence naming the section-landing gap.
- scripts/nav-check.mjs: one em dash carried from the replaced header line reworded.
- Six content files: sidebar_label line deleted; the manifest name wins, so the
  rendered sidebar is unchanged.

References: FS-2740; code-review-fs-2740-manifest-duplicate-urls.md round 1 (Low 1,
Low 2, Nit 3).
* ci(FS-2746): make the Build job blocking

Context: ci.yml ran three jobs and only Gates blocked. The Build job carried
continue-on-error: true because the MDX image pipeline fetched remote images at
build time, so a dead third-party URL reddened it for reasons unrelated to the
change under review. FS-2681 set remarkImageOptions: { external: false }, and
the build stopped touching the network for images.

Why: everything only that job sees reported and passed anyway. MDX compile
errors types:check cannot reach, the 404-shape and prerendered-route assertions
of FS-2688 and FS-2698, redirects:check, FS-2724's og:* tags, FS-2732's "no MDX
comment in the mirrors" assertions, and FS-2733's rendered link back into this
repository could all land on main red.

What:

- Drop continue-on-error from the build job and rename it from
  "Build (non-blocking)" to "Build". It stays a job of its own rather than
  folding into Gates, so it keeps running in parallel and promoting it costs no
  pull-request latency. The comment now records what it blocks on, the one
  network dependency that is left (JetBrains Mono through next/font/google, read
  from the installed loader: three retries, local fallback only in dev, so a
  production build rethrows), and the fact that "required" is a branch-protection
  setting this file cannot express.
- Rewrite the Network checks comment, which cited the build as the precedent for
  staying advisory. It is now the only job here that does not block, and the
  reason is its own: about thirty raw.githubusercontent requests with no retry.
- upstream-refresh.yml's stylus job runs ci.yml's blocking set itself, because a
  pull request opened with GITHUB_TOKEN triggers no workflow runs. That list now
  gains pnpm build and the same server step, so the two do not drift. It is the
  half that payload most needs: an MDX compile error is the defect no other step
  catches, and nineteen pages of unpinned third-party prose is where one comes
  from. The HTTP suite asserts nothing content-dependent, so a regenerated Stylus
  tree cannot redden it on its own.

Verified: pnpm build succeeds in 52.9 s locally, and redirects:check plus the
thirty-test HTTP suite pass against next start on that build. A scratch MDX page
referencing an unregistered component passes all thirteen Gates steps and fails
pnpm build, which is the failure mode this promotion catches.

References: FS-2746, FS-2681, FS-2688, FS-2698, FS-2724, FS-2732, FS-2733.

* docs(FS-2746): record that the Build job now blocks

Context: four files described CI as three jobs of which only the first blocks,
and several sentences turned on the Build job being advisory.

Why: the workflow change is invisible unless the prose that explains the tiers
follows it. CLAUDE.md, INTERNALS.md and CONTRIBUTE.md each state the gate list,
so all three drift together if one is left behind.

What, INTERNALS.md first and mirrored outward:

- "The gates": two of the three jobs block now. Added a paragraph saying what
  the workflow file can and cannot express, since a job without
  continue-on-error fails its run but what holds a merge is the branch
  protection rule on main, which names checks by job name. Rewrote the Build
  paragraph to list what only that job sees, why it stays a separate job, and
  the one network dependency left in it (JetBrains Mono through
  next/font/google, with the loader's retry and dev-only fallback read from the
  installed package). Rewrote the Network checks paragraph, which used the build
  as its precedent. Corrected the "run by hand only" paragraph, which said
  redirects:check is not in the blocking set.
- "Redirects": the CI paragraph said that check is non-blocking only because its
  job is.
- "Remote images are never fetched at build": external: false let the job become
  blocking, which has now happened.
- "Stylus by Example": the sync-the-two-lists bullet names the build.
- CLAUDE.md: the three-tier bullet list, the redirects:check paragraph and the
  upstream-refresh bullet.
- CONTRIBUTE.md: a paragraph telling a contributor that a second job blocks and
  that pnpm build is worth running locally after an MDX change with components
  or JSX in it, since it is the only thing in CI that compiles their page the
  way the site does.
- README.md: the one-line summary of what CI runs.

References: FS-2746, FS-2681.

* docs(ci): say no check holds a merge yet, and name what the build alone catches

Context: aab3b6e promoted the Build job and told the maintainer that both Gates and
Build "have to be in" the branch protection rule's required checks. Review measured
the rule through the GitHub API: neither repository has one. The fork's main has no
protection and no rulesets; OffchainLabs/Fumadocs-test has one ruleset requiring only
an integration's merge-controlled context. So no CI job holds a merge anywhere today,
Gates included, and the prose left a reader believing otherwise. The same review
reproduced both failure classes on a scratch page: an undefined component passes all
thirteen gates and fails the build, but a plain MDX syntax error already fails
check-links, which compiles every page to validate anchors.

Why: a maintainer following the old note would look for a list to edit that does not
exist. "MDX compile errors types:check cannot see" was true but overstated what the
promotion newly blocks; the newly covered class is render-time failure.

What:
- INTERNALS.md and CLAUDE.md: the branch-protection paragraph states the measured
  absence, how to create the rule or ruleset, and why the retired job name must not
  be listed; the "what only this job sees" sentence names render-time failure and
  credits check-links with the parse errors; the stylus job's exposure to a Google
  Fonts outage is recorded as an accepted trade; the build time is the measured
  53 s cold and 37 s warm.
- CONTRIBUTE.md: the same two corrections in contributor terms.

References: FS-2746; code-review-fs-2746-build-job-blocking.md round 1 (Medium 1,
Low 1, Low 2, Nit 1).

* docs(ci): correct the origin protection claim and finish the render-time wording

Context: 9efdc02 corrected the branch-protection note from the reviewer's round 1
measurement and reworded "MDX compile errors types:check cannot see". Round 2 found
both half done. The origin repository reports main as protected (a field readable
without admin) by a classic rule whose contents the API refuses to this token; the
effective-rules endpoint that produced "merge-controlled only" reports ruleset rules
alone, so "neither repository has one" was wrong for origin. The render-time wording
reached the CLAUDE.md mirror, CONTRIBUTE.md and one INTERNALS sentence but not the
canonical INTERNALS paragraph or the ci.yml comment. And "nobody is told" about a
failed Monday stylus run is false: GitHub emails whoever last edited the cron schedule.

Why: the file of record disagreed with its mirror in the wrong direction, and a
maintainer told that nothing protects origin would look in the wrong place.

What:
- INTERNALS.md and CLAUDE.md: the fork has no protection; origin's main is protected
  by a classic rule that must be read in Settings, Branches; either way list both
  Gates and Build. The canonical "what only this job sees" paragraph now names
  render-time failure and credits check-links with syntax errors. The stylus-job
  sentence names the cron-editor email as the only signal and notes the job could
  already fail on any gate.
- .github/workflows/ci.yml: the build job's comment carries the same render-time
  wording. Comment only; the parsed workflow is unchanged.

References: FS-2746; code-review-fs-2746-build-job-blocking.md round 2 (Medium 1,
Medium 2, Low 1).

* docs(ci): origin's protection is a ruleset with no CI check, not a classic rule

Context: dc4bd2c told the maintainer origin's main is protected by a classic rule the
API would not show. Round 3 review read the branch object's protection sub-object,
which push access can see: protection.enabled is false with zero required contexts,
so classic protection is off, and the protected flag comes from the active "Merge
control" ruleset, whose only required check is the integration's merge-controlled
context. Re-measured by the orchestrator before writing this.

Why: the previous text sent a maintainer to Settings, Branches, the classic page,
which is empty on origin; the mechanism in force lives under Settings, Rules. And the
round 2 conclusion that no CI job holds a merge on either repository was right after
all and had been walked back.

What: INTERNALS.md and CLAUDE.md state the measured picture for both repositories
and name the ruleset to edit on origin and the rule or ruleset to create on the fork,
each listing both Gates and Build.

References: FS-2746; code-review-fs-2746-build-job-blocking.md round 3.
…and fix two stale counts (#84)

* fix(FS-2740): point each duplicated manifest entry at the page it names

Context: `lib/docs-navigation.json` claimed four `page` URLs twice, under
eight entries. `page()` in `buildDocsNavigation` throws only on a URL that
does not exist, and every one of these index URLs exists, so nothing caught
it. The page each second entry was meant to name fell through into its
section's "Additional guides" group instead, where it rendered its own
`sidebar_label`. The most visible symptom was "Elara (ArbOS 61)" opening the
ArbOS releases overview while the real page sat under Additional guides as
"ArbOS 61 Elara".

Why: the manifest was built alongside `redirects.legacy.mjs`, and every one
of the six mis-targeted entries has a legacy redirect resolving the same
upstream URL to the same section landing rather than to the real page. The
manifest inherited that resolution. Six real pages therefore had no named
place in the sidebar.

What: seven entries retargeted and one deleted.

- Sequencing > "Sequencer configuration reference" now opens
  `configuration/sequencer/sequencer-config-reference`, whose title it
  already carried.
- Costs > "Parent chain data fee pricing" and "Priority fees" now open
  `costs/parent-chain-data-fee-pricing` and `costs/priority-fees`, whose
  `sidebar_label` each already carried.
- Operate > "Error index" and "Sequencer troubleshooting" now open
  `operate/error-index` and `operate/sequencer-troubleshooting`.
- ArbOS software releases > "Elara (ArbOS 61)" now opens
  `arbos-releases/arbos61`, matching its five siblings.
- Batch poster > "Batch poster" is deleted. It named upstream's
  `chain-config/batch-poster/config-batch-poster`, a page this repository
  never ported, and borrowed the sequencer landing page's URL the same way
  its legacy redirect does. Every other batch-poster page is already claimed
  once and correctly, so there was no page for it to name, and leaving it
  would keep a sidebar item labelled "Batch poster" that opens a page titled
  "Sequencer configuration".

Six pages leave Additional guides. Three folder landing pages move into it,
because nothing names them any more: the `configuration/costs`,
`configuration/sequencer` and `operate` indexes. That is where four sibling
landing pages already sit, and each one's body is a card grid duplicating
links the sidebar lists individually.

Verified by dumping the real Fumadocs page tree before and after through
`buildDocsNavigation` with real frontmatter: 513 nodes to 508, and every
changed line is one of the above. Checked in a browser on all four sections.

References: FS-2740.

* feat(FS-2740): reject a navigation manifest that claims a page URL twice

Context: the four duplicate `page` URLs the previous commit fixed were
invisible to every gate. `types:check` and `build` never read the manifest,
`nav:check` read only the `meta.json` tree, and `buildDocsNavigation` throws
on a URL that does not exist, which a duplicate is not. The defect was found
by hand during a code review.

Why: a `page` entry is the canonical claim on a URL, the node that gives the
destination its sidebar root. Two entries claiming one URL therefore always
means one of them opens a page it does not name, and the page it was meant
to name is exiled to Additional guides. There is no legitimate reason to
write it, so it can be rejected outright.

What: `duplicateManifestPages` in the new `lib/docs-navigation-rules.mjs`,
plain JavaScript with no imports, following `lib/site-url.mjs`. Two callers
apply the same copy:

- `buildDocsNavigation` validates the sections up front and throws, so
  `pnpm dev` fails loudly rather than rendering a wrong sidebar. It checks
  the manifest rather than counting claims inside `page()`, which is also
  reached from `copyFolder` and `remaining`, where a repeat is legitimate.
- `checkManifest` in `scripts/lib/nav.mjs`, reported by `scripts/nav-check.mjs`
  as a fourth rule, so `pnpm nav:check` exits 1 and names each URL with both
  entries that claim it.

`.mjs` rather than importing `lib/docs-navigation.ts` directly, because
Node's type stripping prints a `MODULE_TYPELESS_PACKAGE_JSON` warning onto
the gate's stderr for every such import.

A repeated `href` is deliberately exempt: it builds a display-only separator
node that claims nothing, which is the documented way to pin a cross-section
shortcut, and the manifest does it for sixteen URLs. A repeated `folder` is
left alone too, because a folder expands against the real content tree and a
static read of the manifest cannot say what a repeat would duplicate.

Tests cover the rule, the `href` exemption, a claim nested in a `children`
group and one used as a group index, the transformer throw, and the six
pages that now have a named home. Run against `fork/main`'s manifest, the
rule reports exactly the four URLs and all eight entry names, and the
transformer throws at load.

References: FS-2740.

* docs(FS-2740): record the manifest duplicate rule in the sidebar docs

Context: `pnpm nav:check` gained a fourth rule and `buildDocsNavigation`
gained a second throw. Both are described in INTERNALS.md's sidebar section
and mirrored into CLAUDE.md, which agents read in session.

Why: the existing wording said missing pages, references and folders throw,
which a reader could take to mean the transformer catches every broken
manifest entry. A duplicate names a page that exists, so it did not throw,
and that gap is exactly what this ticket closes.

What: one sentence added to each of INTERNALS.md's two relevant paragraphs,
plus the gate table row. In CLAUDE.md, the `nav:check` line in Commands and
the `nav:check` sentence in the Sidebar ordering paragraph. Nothing else in
that paragraph is touched; FS-2744 owns the larger rewrite of it.

References: FS-2740, FS-2744.

* docs(nav): name the rule's remaining gap, drop the six labels the manifest now overrides

Context: dac25d0 retargeted eight manifest entries and added the duplicate-URL rule.
Review found INTERNALS.md calling the duplicate "the one broken shape" the missing-page
check cannot see, while the same branch's PR note records a second one (a children
claim on a section's derived landing page, which the rule's walk never visits). The
six pages the fix freed from Additional guides also carry a sidebar_label that their
new manifest name overrides, four of them byte-identical to it: the dead-label class
FS-2731 removed 52 of one day earlier.

Why: a canonical doc that says "the one" gap invites the next reader to stop looking.
A sidebar_label the manifest always beats misleads the writer who edits it and
changes nothing.

What:
- INTERNALS.md: "one broken shape", plus the sentence naming the section-landing gap.
- scripts/nav-check.mjs: one em dash carried from the replaced header line reworded.
- Six content files: sidebar_label line deleted; the manifest name wins, so the
  rendered sidebar is unchanged.

References: FS-2740; code-review-fs-2740-manifest-duplicate-urls.md round 1 (Low 1,
Low 2, Nit 3).

* docs(FS-2744): describe the navigation manifest end to end in INTERNALS

Context: PR #73 replaced the meta.json-only sidebar model with the editorial
manifest lib/docs-navigation.json, applied by the page-tree transformer in
lib/docs-navigation.ts, and turned the root switcher off with tabs={false} in
app/docs/layout.tsx. "The sidebar and its roots" had been amended for the
manifest but still left the reader without the mechanism: what each of the two
files decides, what the manifest's four entry shapes build, where an unlisted
page goes, what _fallback is, and what "root": true is still worth.

Why: this is the canonical section for humans, and CLAUDE.md and CONTRIBUTE.md
both point at it. A reader arriving after a wrong-looking sidebar entry needs to
know which failures a gate catches and which two it does not, and the old prose
implied the coverage was complete.

What: rewrote the section under eight subheadings, every claim measured against
the built tree at head on 2026-09-22.

- Separated what meta.json decides (the content tree) from what the manifest
  decides (the rendered hierarchy), and updated "The page tree" earlier in the
  file to match.
- Documented the nine sections, the twelve sourceFolders that cover every
  top-level content directory, and the four entry shapes with their counts: 239
  page, 41 href, 44 children, 2 folder, one flatten, no defaultOpen.
- Stated the naming order (manifest name, sidebar_label, title) with a
  cross-reference to the frontmatter contract rather than a second copy of it.
- Documented the Additional guides fallback: seven of nine sections have a
  group, 58 pages in total, 43 of them under Run an Arbitrum chain. A page in a
  directory no sourceFolders array covers renders above the sections with no
  section root, and no gate reports it.
- Documented _fallback as fumadocs-core's second tree pass, whose guard never
  fires here because no i18n is configured.
- Corrected the root claim. Rebuilding the tree with all twelve "root": true
  flags deleted gives a structurally identical tree, because the transformer
  overwrites root on everything it emits. The flags now feed nav:check alone:
  removing stylus's makes it report 61 uncovered pages, removing all twelve
  makes it report 348 of 349.
- Kept the FS-2716 lesson and restated the two failures that survive the
  transformer, both measured: a link entry overwrites an unnamed page's sidebar
  label, and pulls that page into the linking section when that section is
  processed first.
- Listed nav:check's four rules and the two shapes that get past them, both
  recorded in FS-2749.

References: FS-2744. Builds on FS-2740 (the duplicate rule) and #73.

* docs(FS-2744): rewrite the sidebar prose for agents and contributors

Context: CLAUDE.md's Sidebar ordering paragraph and CONTRIBUTE.md's two sidebar
paragraphs both still described the pre-#73 world: a root switcher above the
sidebar naming the last root folder on the page's tree path, "root": true as
what makes the rendered sidebar show one section, and a link entry as something
that steals a page's sidebar root. Neither file mentioned lib/docs-navigation.json,
the transformer, the Additional guides fallback, or href separators.

Why: CLAUDE.md is the file an agent reads before touching navigation, and
CONTRIBUTE.md is what an outside contributor reads before placing a new page.
Both were steering readers at meta.json alone, which no longer decides what the
sidebar shows.

What, mirroring the INTERNALS rewrite in the previous commit:

- CLAUDE.md: replaced the Sidebar ordering paragraph with the two-file split,
  the four entry shapes and their counts, the naming order (cross-referenced to
  the frontmatter paragraph rather than restated), the Additional guides
  fallback and the uncovered-directory case, the no-switcher decision, the
  measured fact that "root": true no longer reaches the rendered tree and is now
  read only by nav:check, the two link-entry failures that survive the
  transformer, and nav:check's rules with the two FS-2749 gaps.
- CONTRIBUTE.md: replaced both paragraphs with a "Place your page in the
  sidebar" subsection that names the file to edit, shows a manifest entry, says
  what happens if the contributor does nothing (the page lands in Additional
  guides under its sidebar_label), states that a new top-level section needs
  both a root flag and a sourceFolders entry, and ends with the check to run and
  a link to INTERNALS.
- Two stale counts, both re-measured today: pnpm test runs 30 test files under
  scripts/, not 20; and the nav:check comment in both files now names all three
  rule families, including the manifest duplicates FS-2740 added.

Kept from the old prose, still true: the content/docs/resources/ reference-only
shape, own() first-come arbitration, the three link-entry forms LINK_ENTRY
matches, the sidebar footer for links pinned under every section, and
ROOTLESS_BY_DESIGN exempting content/docs/index.mdx.

References: FS-2744, FS-2749, FS-2740, FS-2716, #73.

* docs(FS-2744): restore the sidebar-resolution rule and name the roots gap

Context:
The round 1 review of this branch found three Medium, seven Low and
three Nit defects in the rewritten sidebar prose. All thirteen were
re-verified against the code before anything was changed here; the three
that matter are a mechanism deleted by mistake, two true sentences lost
with it, and a justification that closes on itself.

Why:
The `path.findLast` rule is the premise of everything else in the
section. A `page` entry is a claim only because Fumadocs resolves a URL
to the first matching page node and then renders the last root folder
above it; without that, the duplicate rule, the `href` design and the
link-entry warnings are conclusions with no argument under them. The
rewrite dropped it while the PR note claimed it was kept, so an auditor
would have stopped looking. Separately, the prose kept the twelve
`"root": true` flags on the strength of a gate whose only input is those
same flags, while the question that now decides where a top-level
directory's pages land goes unchecked.

What:
- INTERNALS.md gains "How a page gets its sidebar": `searchPath` is
  depth-first and stops at the first page node with the URL, then
  `path.findLast` takes the last `root: true` folder on that chain.
  Cited from the link-entry section and from `nav:check` rules 3 and 4.
- INTERNALS.md records again that three `href` entries point at
  `docs.arbitrum.io` for PGA and Fast Feed pages this site never
  carried, with the reason from commit 4317cf9, and that the synced
  Stylus examples are local under Build apps with Stylus > Reference.
- The roots subsection and its CLAUDE.md mirror now say plainly that the
  flags change nothing a reader sees, that the coverage rule proves only
  that they exist, that no gate reads `sourceFolders`, and that FS-2751
  owns the choice between deleting flags plus rule and replacing the
  rule. Measured: a top-level directory with the flag and no
  `sourceFolders` entry passes the gate and still renders above the nine
  sections. No flag and no script changed here.
- Four precision fixes, each re-measured: the transformer leaves 51 of
  117 folder nodes with no `root` key rather than overwriting all of
  them; `transformerFallback` skips its second build when every file was
  reached, which is not an i18n condition, and it is that branch, not
  the `root()` hook, that never runs; the label leak and the relocation
  turn on two different orderings and can land together; and nine
  sections are reached through ten navbar links, not one per entry.
- CONTRIBUTE.md: the sample entry no longer duplicates an existing
  manifest claim, and the duplicate rule is no longer described as
  cross-section only. `flatten` is described as applying to any entry
  that builds a folder, in both files that mention it.

References:
- FS-2744, round 1 review fixes.
- FS-2751 for the root flags, their coverage rule, and the stale
  "root switcher" wording in the gate's failure message.
- FS-2749 for the two shapes that get past the duplicate rule.
…, and gate the class (#85)

* fix(FS-2748): point nine legacy redirects at the page, not its section

Context:
`redirects.legacy.mjs` answers 853 legacy docs.arbitrum.io URLs. Where
upstream had a page this site had not ported, the URL was sent to the
nearest section landing instead, recorded in `SECTION_LANDINGS` in
`scripts/lib/legacy-redirects.mjs`. That map documented itself as
self-correcting: rule 2 (self-URL) beats rule 6 (`SECTION_LANDINGS`), so
re-resolving the URL once the page landed would retire the entry.

The re-resolution never happened. Nine of the eleven entries were already
wrong in the commit that introduced them. All nine pages were ported on
2026-09-11 (a36b096, 3ea315c) and all nine are present in the tree at
27f7660, the 2026-09-15 commit that seeded `redirects.legacy.mjs` from
output computed against an older tree. So from day one, a reader asking
for "Common error messages" was answered with a list of links to the
Operate section. FS-2706 then deleted the generator, which makes that
state permanent: no committed destination is recomputed ever again.

No gate could see it. `redirects:check` asks only whether a destination
resolves, and a section landing resolves. The existing tripwire in
`legacy-redirects.test.mjs` asks the same question. This is the case the
docs already name: a redirect to the wrong-but-existing page is worse
than a 404, precisely because nothing catches it. These are also the six
pages FS-2740 fixed on the navigation manifest side; the manifest had
copied this map's mistake and only the manifest was corrected.

Why:
The nine move into `MANUAL_DESTINATIONS` rather than being deleted or
left in place. `SECTION_LANDINGS` is defined as pages this site has not
ported, which these no longer are, and `MANUAL_DESTINATIONS` is defined
as hand-verified against the upstream frontmatter title, which is exactly
what they are. Rules 2 and 3 would reach the same nine destinations
unaided, so the entries are redundant as routing, but being in a map is
what puts a page in front of `pnpm move-doc`, which retargets both maps
and prints the note that `redirects.legacy.mjs` names those pages too.
Deleting them would delete that signal for nine pages.

The upstream title becomes data (`UPSTREAM_TITLES`) rather than staying
in the comment beside each entry, because a test cannot read a comment
without parsing its own source file. It is a separate `new Map([...])`
literal under its own export name, so `legacy-destinations.mjs`, which
scopes its textual rewrite by export name, cannot reach it and `move-doc`
needs no change. Making the destination a tuple was rejected for the
opposite reason: it breaks that rewrite's value test silently.

What:
- `scripts/lib/legacy-redirects.mjs`: nine entries moved from
  `SECTION_LANDINGS` to `MANUAL_DESTINATIONS`, retargeted at the exact
  page. New `UPSTREAM_TITLES` export (the verified upstream title per
  legacy source, covering the two remaining landings and the fourteen
  `MANUAL_DESTINATIONS` entries whose comments recorded one). New
  `collectPagesByTitle` export, built on `collectLocalPages`, which is
  exported again so titles and URLs come from one content walk. Titles
  are read with `splitFrontmatter`, the repo's one frontmatter-title
  reader, since this tree quotes `title:` values about half the time.
  The self-correction claim is corrected in the module header and in the
  `SECTION_LANDINGS` comment, which now states the two-file edit that
  porting such a page requires.
- `redirects.legacy.mjs`: the nine mirrored entries retargeted by hand.
  `move-doc` never touches this file, and these were not moves.
- `scripts/lib/legacy-redirects.test.mjs`: two tests. One asserts rule 4
  continuously, so a destination has to name the page carrying the
  recorded upstream title when exactly one page carries it; it is skipped
  when no page carries the title (which is why the two survivors pass
  today and fail the moment their page is ported) and when two do, which
  is where rule 4 declines as well. The other rejects a recorded title
  whose entry has been deleted.
- `INTERNALS.md`, then `CLAUDE.md`: rule 6 no longer claims to expire on
  its own, with the history that disproves it, and the maps paragraph
  now covers all three tests. `CONTRIBUTE.md` gains a note under "Add or
  edit a page", since a contributor porting one of the two remaining
  pages is who meets the new failure.

One commit, not two: the docs describe `UPSTREAM_TITLES` and the test
asserts the retargeted destinations, so either half alone is wrong.

Left out: `/stylus/overview` and `/for-devs/oracles/oracles-content-map`
in `MANUAL_DESTINATIONS`, and "Batch Poster" and "Sequencer" in
`SECTION_LANDINGS`. No page carries those titles or basenames, so each
already points where the upstream page's own content lives. A sweep of
all 853 legacy entries for the same shape found nothing else.

References:
- FS-2748
- 27f7660 FS-2669: Seed the legacy redirect map from every upstream
  canonical URL (#9), the commit the nine bad entries arrived in
- a36b096 Port seven absent launch-arbitrum-chain pages from upstream
- 3ea315c Port the BoLD FAQ and the ArbOS 61 Elara release page
- 3064177 FS-2706: Decommission the upstream Docusaurus coupling (#58),
  which deleted the generator
- 8914700 FS-2740: Fix four navigation-manifest page URLs listed twice

* docs(FS-2748): name both fixes when the upstream-title gate fires

Context:
The gate added in 5ee140c asserts rule 4 of the legacy redirect
resolution order: when exactly one page carries a recorded upstream
frontmatter title, that entry's destination has to be that page. Review
showed the assertion message read as an unhedged instruction to retarget,
and two of the recorded titles are short generic nouns, "Batch Poster"
and "Sequencer". Reproduced: a page titled "Sequencer" anywhere in the
tree makes the test tell the author to point
`/node-running/sequencer-content-map` at it, which would be the
plausible-but-wrong redirect the resolution order exists to prevent, and
which `redirects:check` cannot catch because the destination exists.

Why:
A title match is evidence, not proof, so the failure has two correct
answers and only one was written down. The second was already supported
and undocumented: deleting that source's `UPSTREAM_TITLES` entry while
leaving both destinations alone passes the suite, because no test
requires a map entry to carry a recorded title. Verified before relying
on it, and verified again through the new message. Naming only one fix in
a failure message gets that fix, so the message is where this belongs,
with the docs agreeing rather than restating.

What:
- `scripts/lib/legacy-redirects.test.mjs`: the message now branches on
  whether the page is the port, and names the `UPSTREAM_TITLES` entry to
  delete otherwise. Added the `isAbsolute` guard the sibling destination
  test already has, so an external destination added later fails on its
  own merits rather than on this rule; no entry in either map is external
  today, so this changes no result.
- `scripts/lib/legacy-redirects.mjs`: the same fork in the
  `UPSTREAM_TITLES` doc comment. The `SECTION_LANDINGS` header said both
  survivors were added upstream after the port window closed, which
  contradicted the per-entry comment recording `sequencer-content-map` as
  a standing non-item; it now distinguishes the two. The adjacent claim
  that every entry comment records an upstream date was wrong the same
  way, since that entry has none, and is softened.
- `CONTRIBUTE.md`: says the trigger is the new page's frontmatter `title`
  matching the upstream title exactly, and splits the fix into the two
  cases, so a contributor is not told to retarget on a collision.
- `INTERNALS.md`, then `CLAUDE.md`: the same, one paragraph each.

No behaviour change to any redirect. Every destination is byte-identical
to 5ee140c; this commit is the failure message, the guard and the prose.

Left out, at the reviewer's recommendation: the gate covers 23 of the 75
entries across the two maps, because only verified upstream titles are
recorded and the archived repo cannot be read; and two pre-existing
legacy entries (`bold-adoption-for-arbitrum-chains`, `layer-leap`) have a
related wrong-but-existing shape outside the landing pattern. Both are
written up as follow-ups in the PR note for separate tickets.

References:
- FS-2748 review round 1, findings F1, F4, F5 and F6
- 5ee140c fix(FS-2748): point nine legacy redirects at the page, not its
  section
* fix(FS-2747): let versioned-docs-check see a committed change in CI

Context:
scripts/versioned-docs-check.mjs, the advisory that warns when a page
under content/_versions/ or a live page registered in VERSIONED
changes, ran `git diff --name-only HEAD -- <pinned docs>`: working
tree plus staged changes against HEAD. CI's actions/checkout step
takes no fetch-depth, so it defaults to a depth-1 checkout with a
clean working tree, and that diff is empty there by construction. The
check has run in the blocking Gates job since it was added and could
never fire there. Locally it only fires before a git commit. Once an
edit to a versioned doc is committed, nothing warns again, in CI or
locally. FS-2745 committed a one-line frontmatter deletion to an
archived page and this advisory said nothing about it, which is how
the gap surfaced.

Reproduced on a throwaway local branch (reverted, never part of this
branch's history): committed a one-character body edit to
content/_versions/v1/run-a-node/run-batch-poster.mdx, ran the old
script, and it printed nothing, while `git diff HEAD^ HEAD --name-only
-- content/_versions` plainly listed the file.

Why:
The guard exists to make a versioned-doc edit deliberate, not to
forbid it (FS-2745's edit was reviewed and accepted), so it stays
advisory and always exits 0. It needs a comparison that can see a
change that is already committed, which "working tree vs HEAD" can
never do. A merge-base diff was considered and rejected: a depth-1 CI
checkout has no shared history with a separately fetched base branch,
so `git merge-base` has nothing to find. A direct two-tree diff needs
no shared history, and HEAD on a pull_request-triggered run is already
a synthetic merge of the PR head into the current base, so diffing the
base branch's tip against HEAD reports exactly what the PR changed.

What:
- scripts/versioned-docs-check.mjs: when GITHUB_ACTIONS=true and
  GITHUB_BASE_REF is set (set by GitHub Actions on a
  pull_request-triggered run, and only then), fetch the PR base
  branch's tip at depth 1 into refs/remotes/origin/<base> and diff it
  directly against HEAD. Local behavior (working tree + staged vs
  HEAD) is unchanged, and is also the fallback when no base ref is
  available or the fetch fails, which includes a direct push to main
  (post-merge, no GITHUB_BASE_REF): accepted as a residual gap on the
  reasoning that a merge to main goes through a PR first, and that
  PR's own pull_request run should already have warned. The script now
  always prints which comparison it ran, so a quiet Gates step can be
  told apart from one with nothing to report.
- Verified the fix the same way as the repro: with GITHUB_ACTIONS=true
  and GITHUB_BASE_REF pointing at a real base branch, the script
  fetched the base fresh and correctly reported the committed archive
  edit the old code missed.

Also from the same ticket (folded in rather than split off, since the
three offenders and the new rule are each a handful of lines and the
INTERNALS.md/CLAUDE.md prose for both topics ended up interleaved in
the same paragraphs, making a clean file-level split not worth doing):

- Added content:lint rule A14: a title/sidebar_label/description
  frontmatter value with leading/trailing whitespace or a doubled
  internal space. Both ship verbatim into the <title> tag, the
  OG/Twitter description, or the sidebar label. A Zod .trim() in
  source.config.ts would fix the leading/trailing case silently and
  never catch a doubled space; this rule reports both instead, on the
  theory that a generated page's whitespace defect belongs fixed at
  its generator rather than papered over at read time. The rule reads
  the raw frontmatter block from `source`, not the code-stripped
  `text` most rules use, because stripCode's inline-code masking has
  no notion of YAML string quoting and was blanking a backtick pair
  inside a frontmatter value as if it were a real inline code span,
  producing false "doubled space" findings on two files that had none
  (bytes_in_bytes_out.mdx, hello_world.mdx) until the rule was
  switched to read from `source`. Covered by new tests in
  scripts/lib/content-lint.test.mjs, including that regression.
- Fixed the three existing offenders: trailing spaces in the
  description of deploying-an-arbitrum-chain.mdx and
  deploying-token-bridge.mdx, fixed by hand. A doubled space in
  stylus-by-example/basic_examples/variables.mdx's description, fixed
  at its generator (parseMetadata in scripts/lib/stylus-examples.mjs
  now collapses whitespace runs and trims title/description after
  reading them) rather than in the committed .mdx, since a hand-edit
  there would be overwritten by the next weekly stylus job. The
  doubled space was in upstream's own metadata string verbatim, so
  this is a whitespace fix, not the kind of wording change that has to
  be made upstream. Regenerated via `pnpm stylus:generate`, confirmed
  via `pnpm stylus:check` beforehand that this was the only diff.
- INTERNALS.md updated first (content-lint rule table, a new "A14"
  subsection, and a new paragraph under "The gates" on
  versioned-docs-check.mjs's comparison logic), then mirrored into
  CLAUDE.md (the Gates bullet and the Husky bullet's rule count).

Gates: types:check, test (592 pass), vars:check, nav:check,
partials:check, versioned-docs-check.mjs, references:check,
faq:check, images:presence, check-links, contracts:check,
format:check, content:lint, and `pnpm build` all pass.

References:
- Linear FS-2747
- FS-2745 (the archive edit this advisory should have flagged)
- FS-2743 (A12/A13, the template this A14 rule followed)
- 9c4708b docs(FS-2744): rewrite the sidebar docs for the navigation
  manifest, and fix two stale counts (#84)

* fix(FS-2747): read the PR base from the merge commit instead of fetching it

Context:
The first pass at this ticket taught versioned-docs-check.mjs to see a
committed change in CI by fetching the pull request's base branch at
depth 1 and diffing it against HEAD. Review round 1 raised three
Mediums against that shape. Every actions/checkout step in ci.yml
passes persist-credentials: false, so the fetch was anonymous and
worked only while both repositories stayed public; any failure of it,
transient or permanent, silently returned the gate to reporting
nothing, which is the same invisible green the ticket exists to
remove; and pnpm build runs this script first, so the blocking Build
job gained a network call while CLAUDE.md and INTERNALS.md both still
said nothing else in that job reaches the network. There was also no
test over the decision that had been quietly doing nothing for months.

Why:
actions/checkout on a pull_request event checks out the synthetic
merge commit GitHub builds for the PR, whose first parent is the base
branch's tip and whose second is the PR head. So HEAD^1 vs HEAD is
already the PR's own change set, and the only thing missing was the
parent. fetch-depth: 2 keeps it, needs no network, no credentials and
no origin remote, and leaves the "nothing else reaches the network"
claim true rather than needing it rewritten. Proved in a scratch clone
reproducing the action's documented refspec: at depth 1 both HEAD^1
and HEAD^2 fail to resolve, at depth 2 both resolve and
git diff --name-only HEAD^1 HEAD names the archive edit and nothing
else. The same clone confirms rev-parse --is-shallow-repository still
answers true at depth 2, so hasFullGitHistory() in source.config.ts
keeps lastModified off in CI, which is the constraint that rules out
any deeper fetch.

Two signals now pick the comparison and neither substitutes for the
other. GITHUB_BASE_REF says the run has a base at all. The
merge-commit probe says the checkout actually holds that shape, so a
checkout pinned to the PR head, where HEAD^1 is merely the previous
commit on the branch, is not diffed against the wrong tree. Keying on
GITHUB_BASE_REF rather than on the merge shape alone is also what
keeps upstream-refresh.yml's scheduled stylus job quiet: it has no
base, its working tree is genuinely dirty after stylus:generate, and
the local comparison is the meaningful one there.

What:
- scripts/lib/versioned-docs-comparison.mjs (new): pickComparison, the
  decision on its own, pure, taking the environment and the two git
  probe results as booleans. Split out so it can be tested without a
  git fixture.
- scripts/lib/versioned-docs-comparison.test.mjs (new): all five
  shapes, including the depth-1 regression this ticket closes.
- scripts/versioned-docs-check.mjs: drop fetchPrBaseRef, probe HEAD^1
  and HEAD^2 instead, raise a ::warning:: annotation when a pull
  request run lacks the merge shape (so a checkout that loses
  fetch-depth: 2 reaches the run summary, not just a collapsed log),
  print a plain note otherwise. Word-wrap the warning box instead of
  truncating its second line at 72 characters, which cut the
  comparison label mid-word in all three modes. Add a 30s timeout to
  the git helper.
- .github/workflows/ci.yml: fetch-depth: 2 on the Gates and Build
  checkouts, with the reasoning and the lastModified constraint.
- scripts/lib/content-lint.mjs (A14): judge the value YAML actually
  parses. Line-trailing whitespace comes off before the quoted test,
  so a clean quoted value followed by spaces is no longer reported as
  both leading-or-trailing whitespace and a doubled internal space; it
  is reported as its own line-noise problem, since nothing else in the
  toolchain removes it (measured: js-yaml drops it, Prettier does
  not). The doubled-space probe reads the trimmed value for the same
  naming reason. A folded or literal block scalar is now skipped
  explicitly rather than having its indicator read as the value. Drop
  a slice that restated its own input.
- scripts/lib/content-lint.test.mjs: fixtures for the quote case, both
  block scalars, and a file with no frontmatter (the path every
  partial takes); the unquoted trailing-space test now pins its
  message.
- INTERNALS.md then CLAUDE.md: the new comparison and why the fetch
  was rejected, the fetch-depth constraint recorded in both the gates
  and the last-modified sections, and A14's two deliberate limits
  (block scalars, and that it covers content/_versions like any other
  path).

Left out: the two "nothing else in the job reaches the network"
sentences need no edit after all, because the network call is gone.
The push-to-main gap is still open and still accepted.

Verified: A14 still reports exactly the three real offenders on
fork/main, with the same messages, and nothing on the two backtick
pages that drove the source-not-text decision. pnpm test 601 pass,
types:check, content:lint, check-links, nav:check, vars:check,
partials:check, references:check, faq:check, images:presence,
contracts:check all pass. format:check reports only untracked scratch
files.

References:
- code-review-fs-2747-versioned-docs-check.md (review round 1)
- 9bdf478 fix(FS-2747): let versioned-docs-check see a committed change in CI
- FS-2750 (removing the last network dependency from the Build job)

* docs(FS-2747): name the depth-2 coupling in CLAUDE.md's last-modified paragraph

Context: `ci.yml`'s `Gates` and `Build` checkouts now set `fetch-depth: 2` so
`versioned-docs-check.mjs` can diff the PR merge commit against its first parent.
`hasFullGitHistory()` in `source.config.ts` keys on `is-shallow-repository`, so depth 2
keeps `lastModified` off in CI, and INTERNALS records the two as one decision.

Why: CLAUDE.md stated the coupling only inside the Gates bullet. An agent editing the
checkout depth while reading the last-modified paragraph alone would miss that raising
it changes whether dates render (round-2 review nit on this branch).

What: one sentence added to the last-modified paragraph, pointing at the two workflow
lines and the guard.

References: FS-2747.
… a coverage rule with a real input (#87)

* refactor(FS-2751): delete the twelve "root": true flags, and give nav:check a coverage rule with a real input

Context:
Since PR #73 the sidebar has been built by lib/docs-navigation.ts from
lib/docs-navigation.json. The twelve `"root": true` flags in
content/docs/**/meta.json stopped reaching the rendered tree then,
because the transformer sets `root` itself on every folder node it
emits. Their only consumer left was nav:check's root-coverage rule,
whose only input was those same flags, so the rule proved the flags
were declared and nothing more.

The question that now decides where a top-level directory's pages land,
whether the directory is named in some section's `sourceFolders`, was
read by no gate. Reproduced: a scratch top-level directory declaring the
flag and named in no section passed nav:check, while its page rendered
above the nine sections with no section sidebar.

Why:
Option 1 of the two the ticket offered. Keeping the flags as editorial
markers would leave a setting on disk that reads as operative and is
not, and a contributor who declares it on a new directory and stops
there still gets a page above the sections. Deleting them makes the
manifest the single place a section is declared, which is where every
other sidebar decision already lives.

Measured before deciding, through Fumadocs' own loader() with the real
transformer: 391 leaf keys with the flags and 391 without, 0 differing,
identical order, where a leaf key carries each node's URL, its name and
the chain of folder names above it. root.fallback, the one branch that
would return an untransformed tree, is undefined on the real content.
Nothing in app/, lib/, components/ or scripts/ reads a node's `root`
besides the transformer and this rule. The one thing metadata.root
changes inside fumadocs-core is that a root folder's index.mdx is not
auto-attached as node.index, and buildDocsNavigation already falls back
to pages.get('/docs/<section id>') for that case.

The shadow-link half of the old rule stays, because it still bites:
adding "[Stolen](/docs/launch-arbitrum-chain/third-party-integrations)"
to oracles/meta.json moved that page out of Run an Arbitrum chain into
Get started's Additional guides and renamed it "Stolen". The URL lands
on one node now, so FS-2716's exact failure is gone, but the link node
overwrites the real node in the transformer's URL index and the linking
section claims the page first.

What:
- content/docs/*/meta.json (12 files): delete the `"root": true` line.
  Nothing else moves. content/docs/resources/meta.json keeps the rest:
  it is still what claims the four loose pages at the top of
  content/docs, and that claim is now what puts them in a section.
- scripts/lib/nav.mjs: checkRoots becomes checkSections({ dirs, pages,
  sections }). Same ownership model (fumadocs-core's own()), new
  predicate: coverage is "the chain of claims reaches a directory some
  section names in sourceFolders", the set buildDocsNavigation sweeps
  for leftovers. Reports a sourceFolders entry naming no directory, a
  directory two sections claim, a top-level directory no section
  covers, a page no section covers (a loose top-level .mdx in
  practice), and shadowing link entries as before. A page inside an
  already-reported directory is left out of the page list, so one root
  cause gives one message. ROOTLESS_BY_DESIGN becomes
  SECTIONLESS_BY_DESIGN, still ['index']. checkManifest becomes
  readSections, so the gate reads the manifest once.
- scripts/nav-check.mjs: report blocks for the four new lists. The old
  failure text named the sidebar root switcher, which has not existed
  since PR #73; that wording is gone from the gate and from the comment
  above the rule.
- scripts/nav-check.test.mjs: eight checkRoots tests become ten
  checkSections tests, plus a new test asserting all four coverage
  lists are empty on the real content tree and that the set of
  top-level directories equals the set of sourceFolders entries. 21
  tests to 23.
- INTERNALS.md, CLAUDE.md, CONTRIBUTE.md, README.md: the subsection
  that recorded this as an open decision now records the decision and
  the measurements. CONTRIBUTE.md said a new top-level section needs
  two things; it now says one, and says not to copy the flag. It also
  gains the loose top-level page case, which had no instructions.

Each new report was reproduced against the gate and the probe reverted:
a scratch-zone directory, a loose-probe.mdx, a "gone-missing"
sourceFolders entry (which makes the transformer throw), and "notices"
listed by a second section. The current tree reports zero.

Left out: the 58 pages in Additional guides, and any model of manifest
`folder` claims. The new rule reads sourceFolders only, which cannot
produce a false positive today because every folder entry points inside
a directory that is already a source folder. FS-2749 builds a precise
claim counter over the built tree, and this rule should read that once
it exists.

References:
- FS-2751
- FS-2716 (link entries stealing a sidebar root)
- FS-2740 (the manifest duplicate rule this sits beside)
- 9c4708b docs(FS-2744): rewrite the sidebar docs for the navigation manifest
- INTERNALS.md, "The sidebar and its roots"

* fix(FS-2751): test sourceFolders for a folder node, and correct three doc claims

Context:
Review round 1 on 77b713f passed with no Medium or above. Three Low and
three Nit findings, all verified from the code here before acting.

Why:
The one with teeth is the `missingFolders` rule. It tested whether the
directory exists on disk, but what `buildDocsNavigation` looks up is a
folder node, and fumadocs-core's `buildFolder` returns nothing when
`storage.readDir` finds no file. That storage holds only `.mdx` pages
and `meta.json`, so a directory of images, or one left empty mid-edit,
has neither. Measured both halves: a `content/docs/empty-zone/` holding
one `.txt` and named in a `sourceFolders` array made the gate report
"no navigation defects", while the transformer on the same input threw
`Navigation source folder does not exist: empty-zone`. The gate was
naming a different defect from the one that breaks the build.

The two documentation Lows were both wrong as written rather than merely
stale. The gate table in INTERNALS still named a rule this branch
deleted, and the paragraph on deleting `content/docs/resources/` claimed
an outcome that does not happen. Reproduced: dropping that directory
alone throws `Navigation source folder does not exist: resources`,
because `"resources"` is still in Get started's `sourceFolders`.
Dropping both, the four loose pages do land in Get started, which is
what the sentence intended.

What:
- scripts/lib/nav.mjs: `missingFolders` now asks whether a directory
  produces a folder node, meaning it has a `meta.json` of its own, a
  page below it, or a `meta.json` below it, rather than whether it
  exists. `sharedFolders` carries distinct section ids plus a `count`,
  so one section listing a folder twice no longer reads as two sections.
- scripts/nav-check.mjs: both report blocks reworded to match, with a
  fix line saying a directory of images alone builds no folder node, and
  a per-item line that prints "named by: a, b" for two sections and
  "named 2 times by: a" for one.
- scripts/nav-check.test.mjs: three tests. The content-free directory is
  reported; the two shapes that do build a node are not (a meta-less
  directory with a page below it, and a directory holding only a
  meta.json, which is what `resources` is); and a within-section repeat
  reports one section with a count of 2. The real-repo test's set
  equality now carries a comment saying it is a deliberate pin stricter
  than the rule, since the rule also accepts coverage inherited through
  a cross-directory `pages` claim, and what to do if a content change
  takes that shape.
- INTERNALS.md: the blocking-gates table says "section coverage"; the
  `resources` paragraph says deleting it takes two edits and states what
  each produces; rule 3's description covers the folder-node test and
  the reworded shared-folder report.
- CLAUDE.md: the same two corrections mirrored.

The probes were reverted and the gate reports zero on the current tree.
The remaining Nit, that the `--json` shape changed, needed no action:
no consumer exists in the repo, `.github/workflows/` or `package.json`.

Gates: types:check, test, vars:check, nav:check, partials:check,
versioned-docs-check, references:check, faq:check, images:presence,
check-links, contracts:check, content:lint and build all pass.
`pnpm format:check` passes on every tracked file; it reads untracked
notes in this worktree that never reach CI.
`scripts/nav-check.test.mjs` goes from 23 to 26 tests.

References:
- FS-2751
- code-review-fs-2751-root-flags-coverage-rule.md (untracked, worktree root)
- 77b713f refactor(FS-2751): delete the twelve "root": true flags
…nding pages stay in Additional guides (#89)

* fix(FS-2749): count navigation claims in the built tree, not in the manifest

Context:
FS-2740 added a rule that fails when two `page` entries claim one URL,
because `searchPath` stops at the first page node carrying a URL and the
folder above the second node never gives that page its section. The rule
reads each section's `children`, and two things put a page in the tree
from outside `children`, so it saw neither: the section landing, which
`buildDocsNavigation` derives from the source folder's index, and a
`folder` entry, which expands through `copyFolder` and claims every page
in the subtree.

One collision existed. Get started's first `children` entry claimed
`/docs/get-started`, the same URL the section landing derives, so the
tree carried 350 page nodes over 349 URLs and the rule reported nothing.
Reproduced for the folder shape too: a `page` entry for
`/docs/stylus/stylus-by-example/basic_examples/hello_world`, a page the
Stylus Reference group already reaches through its `folder` entry,
repeated nothing in the manifest, returned zero from the rule, built
without complaint, and landed on two nodes.

Why:
The check belongs where the tree is. `scripts/nav-check.mjs` is plain
Node and importing `lib/docs-navigation.ts` prints a
MODULE_TYPELESS_PACKAGE_JSON warning onto its stderr, so the ticket
suggested a pure .mjs claim counter over the manifest plus the content
tree, modelling `copyFolder` and the derived landing, with a test
asserting it agrees with the transformer. That model has to reimplement
`copyFolder`, fumadocs-core's `own()` arbitration, rest globs,
`!exclude` and flattened paths, and needs an agreement test against the
real transformer to be trusted. The agreement test costs the same as
running the real transformer, and the model can drift between runs of
it. So the exhaustive check runs in `buildDocsNavigation`, and
`scripts/docs-navigation.test.mjs` exercises it on the real content
through the real transformer under `pnpm test`, a blocking gate. `pnpm
build` and `pnpm dev` fail on it too.

`nav:check` still gets the one hole it can see exactly with no content
tree, the section landing, which is where the live collision came from.
That rule is exact because the landing node exists whenever
`/docs/<section id>` exists, and if the URL does not exist `page()`
throws on the entry instead.

Get started's entry becomes an `href` rather than being deleted. An
href builds a display-only separator claiming nothing, so the sidebar
keeps the row a reader clicks while the page keeps one node. Deleting it
would have removed the row, since the notebook sidebar does not render a
root folder's index as an item.

What:
- lib/docs-navigation.ts: `assertOneNodePerUrl` walks the finished tree
  and throws when any URL sits on more than one page node, naming each
  position. The two static manifest reads ahead of the build stay,
  because they can name the offending entries and a finished tree
  cannot.
- lib/docs-navigation-rules.mjs: new `sectionLandingClaims`, beside
  `duplicateManifestPages`, imported by the transformer and the gate.
- lib/docs-navigation.json: Get started's landing entry is now an
  `href`. Entry counts move with it: 239 `page` to 238, 41 `href` to 42,
  17 distinct internal href URLs to 18.
- components/sidebar-navigation-reference.tsx: a reference claims no
  page node, so nothing in the tree marks it as the current page and
  `SidebarItem` left `active` false. Right for a reference into another
  section, where the reader never sees this sidebar on that page, but
  Get started's landing row points inside its own section and would have
  been the one row that stays unlit under the reader's feet. The
  component now compares the URL to the pathname and passes `active`,
  with the notebook layout's own active classes.
- scripts/nav-check.mjs: a sixth rule for the section-landing shape,
  with a fix line naming `href`.
- scripts/docs-navigation.test.mjs: three tests. The real tree puts
  every URL on exactly one node and the node count equals the page
  count; a `children` entry claiming its own section landing throws; a
  `page` entry naming a page a `folder` entry already copied throws,
  with the same section building cleanly without it. 11 tests to 14.
- scripts/nav-check.test.mjs: four unit tests for
  `sectionLandingClaims`, including that an href to the landing is left
  alone, and the real-manifest test now asserts both rules empty. 23
  tests to 27.
- INTERNALS.md, CLAUDE.md, CONTRIBUTE.md: a new "One URL, one page node"
  subsection; the nav:check rule list goes from five to six; the
  paragraph naming two shapes that get past the duplicate rule now names
  one, with evidence; and part 2's decision, below.

Part 2, landing pages in Additional guides: they stay, and that is now
written down. 27 of the 58 pages in those groups are a folder's
index.mdx, 19 of them under Run an Arbitrum chain, and every one is a
Cards grid with at most a sentence of prose. Excluding them from the
fallback group is the tempting fix and it is wrong: measured over every
.mdx under content/, counting only a link whose destination is exactly
that URL, 16 of the 27 have no incoming link from any page that is not
itself one of these landings. Excluding them would leave those 16
reachable by typing the URL while they stay in the sitemap and in
llms.txt, which is worse than a hub page behind a collapsed toggle. The
fix that works, for a landing that belongs in the reading order, is to
give its manifest group a `page` of its own, which the transformer
already attaches as that group's index. That is editorial work, not a
rule, because the manifest's groups deliberately do not mirror the
folder tree.

Left out: three manifest entries point at a folder index while the page
they name sits in Additional guides ("Test chain configuration", "Token
bridge troubleshooting", "FAQ"). Every URL is real and on one node, so
no rule here sees them, and retargeting a sidebar entry is an editorial
call. The ticket's claim that no entry names a folder index, zero of
239, is wrong: four do, and Get started's was the fourth.

Browser-checked at localhost:3302. The Get started sidebar renders the
same rows in the same order as before, and its landing row reports
data-active="true" on /docs/get-started and false on
/docs/get-started/arbitrum-introduction, as it did when it was a page
entry.

References:
- FS-2749
- FS-2740 (the duplicate rule this extends)
- 77b713f refactor(FS-2751): delete the twelve "root": true flags
- INTERNALS.md, "One URL, one page node" and "Pages the manifest never lists"

* fix(FS-2749): check every section landing against every section, and correct two counts

Context:
Review round 1 on 9ece140 passed with no Medium or above. Three Low and
three Nit findings, all reproduced here with the reviewer's probe at
node_modules/.fs2749-probe/probe.mjs before acting.

Why:
The one with teeth is the section-landing rule. It looped over sections
and, for each, walked only that section's `children` looking for
`page === "/docs/" + section.id`. A `page` entry in one section naming
another section's landing builds the identical two-node defect and was
invisible to it. Reproduced with the probe's `cross` mode, a `page`
entry for `/docs/get-started` in the Notices section: both static rules
returned empty and `pnpm nav:check` reported no defects, while the
transformer threw `Navigation page on more than one node:
/docs/get-started (Get started > (index) | Notices)`. Nothing could ship
broken, since a blocking gate still caught it, but a contributor running
the gate the docs point them at got a clean bill of health and met the
defect later as a stack trace with no fix line.

The rule stays exact for the same reason it was exact before: a landing
node exists whenever `/docs/<section id>` exists, because the
transformer falls back from the folder's own `index` to that URL. What
changed is only which children it compares that set against.

Both count corrections were measured rather than taken. Over the two
manifests, resolving each claimed URL to its file: base 77b713f has 239
`page` claims of which 4 name a folder index, head has 238 of which 3,
so calling Get started's landing "the fourth of the 238" counted an
`href` among the page claims. And the Additional guides population is 58
entries, of which 27 are attached as a folder's index node and 29 are an
`index.mdx` by file; the two extras, `/docs/oracles` and
`/docs/launch-arbitrum-chain/migrate`, render as plain rows, and by file
the Run an Arbitrum chain figure is 20 rather than 19. I re-ran the
incoming-link measurement over all 29 rather than assuming the extras
did not move it: both have one incoming link from a non-landing page, so
the load-bearing sixteen is unchanged and the part 2 decision stands.

What:
- lib/docs-navigation-rules.mjs: `sectionLandingClaims` collects every
  section's landing URL first, then walks every section's `children`
  against that set. The result carries the claiming section beside the
  entry name, so the message says where the entry sits. Its docstring no
  longer claims `page()` throws first on a nonexistent landing URL,
  which it cannot, since this rule runs ahead of the build; it now says
  the rule fires first and the message is the weaker of the two.
- lib/docs-navigation.ts, scripts/nav-check.mjs: the throw and the
  report name the entry and its section. "claimed by its own children"
  becomes "claimed by a page entry", which is what the rule now means.
- components/sidebar-navigation-reference.tsx: the active comparison
  strips a trailing slash from both sides, which is what Fumadocs'
  `normalizeUrl` does before `searchPath` matches. Exact equality agreed
  today only because `trailingSlash` is unset, and under
  `trailingSlash: true` this row alone would have stopped lighting up
  while every real page node kept working. Adds
  `data-[active=true]:hover:transition-colors`, the third class the
  notebook layout applies to its own active rows.
- scripts/nav-check.test.mjs: three tests, for the cross-section case,
  the same-section case, and one landing claimed from two places at
  once. 27 tests to 29.
- scripts/docs-navigation.test.mjs: a two-section synthetic source
  asserting the transformer throws on the cross-section shape. 14 tests
  to 15.
- INTERNALS.md, CLAUDE.md, CONTRIBUTE.md: three of 238 rather than four;
  the Additional guides population stated by file with the tree-side
  reading beside it and the two plain rows named; the landing rule
  described as covering every section; and the `page()` fallback claim
  removed.

After the fix the probe's `cross` and `selfclaim` modes both fail at the
static rule with the entry named, and `clean` still builds 349 page
nodes over 349 URLs.

Gates: types:check, test, nav:check, format:check, check-links,
content:lint, vars:check, partials:check, versioned-docs-check,
references:check, faq:check, images:presence, contracts:check and build
all pass. `pnpm format:check` now passes over the whole worktree, the
untracked notes included. Browser-checked again at localhost:3302: the
Get started landing row reports data-active="true" on /docs/get-started
and false on the introduction page.

References:
- FS-2749
- code-review-fs-2749-manifest-built-tree-claims.md (untracked, worktree root)
- 9ece140 fix(FS-2749): count navigation claims in the built tree, not in the manifest

* docs(FS-2749): state the section-landing rule as broadly as it now checks

Context: the round-1 fix made `sectionLandingClaims` check every section's landing URL
against every section's `children`, and CLAUDE.md and CONTRIBUTE.md describe that broad
rule.

Why: two summaries still carried the narrow pre-fix wording, "a `children` entry claiming
its own section's landing URL": the header rule list in `scripts/nav-check.mjs` and the
lead sentence of rule 6 in INTERNALS.md, which contradicted its own next sentence. The
three documents disagreed on one point (round-2 review nit).

What: both sentences now say a `page` entry claiming any section's landing URL, its own
or another's.

References: FS-2749.
…quest (#88)

* build(FS-2750): self-host JetBrains Mono so the build makes no network request

Context:
app/layout.tsx declared --font-code with JetBrains_Mono from
next/font/google, so every next build fetched that face's CSS from
fonts.googleapis.com and six unicode-range woff2 slices (84.3 KiB) from
fonts.gstatic.com, then self-hosted them into .next/static/media. The
Google loader retries three times and falls back to a local face only in
dev, so in a production build an outage throws. CI caches only the pnpm
store, so every run refetched. That was the last network dependency in
the Build job, blocking since FS-2746 (3d0dc68), and it reached the
stylus job in upstream-refresh.yml too, where a Monday outage fails the
run before its PR opens and the only signal is a red run emailed to
whoever last edited the cron schedule.

Why:
The four Aeonik faces and FK Screamer are already committed under
public/fonts/, so this is the shape the repo had already chosen. Google
serves the face as six unicode-range slices and a browser fetches only
the ones the rendered text needs, so committing one file would normally
change what every reader downloads. It does not here: --font-code is
spent by exactly one rule, "pre, pre code", and a scan of every fenced
block and inline code span under content/ found no character any of the
five non-latin slices covers. Confirmed at runtime as well, from
Lighthouse's network-requests audit on cli-flags-reference, the most
code-dense page on the site: both builds fetch one JetBrains file at
40,780 bytes on the wire, and the other five were never requested.

The committed file is the one Google served, not a rebuild of it. A curl
of the gstatic URL in the generated CSS and the file the previous build
wrote into .next/static/media share sha256 1e06740a, and the built asset
keeps its content hash, so no reader's download moved by a byte.

Proved rather than assumed, with HTTPS_PROXY and HTTP_PROXY on a closed
port, which the Google loader honours through get-proxy-agent.js: on
9c4708b pnpm build exits 1 with "Failed to fetch JetBrains Mono from
Google Fonts" as its only error, and on this commit it exits 0.

What:
- public/fonts/jetbrains-mono-latin.woff2, Google's latin slice of the
  variable face, plus jetbrains-mono-OFL.txt (SIL Open Font License 1.1,
  from the JetBrains/JetBrainsMono repository).
- app/layout.tsx: JetBrains_Mono becomes localFont over that file. Same
  variable, display and preload: false. weight is pinned to the variable
  range 100 800 rather than a single value, because font-synthesis: none
  on body would otherwise render a bold code token at 400 against a
  static face. unicode-range is carried over verbatim through the
  declarations option, without which the one file would claim every
  character and an out-of-range one would render as .notdef instead of
  falling back. No fallback option, because the Google declaration had
  none either, so the emitted variable keeps its shape.
- app/global.css: comment only, the pre rule pointed at
  app/[lang]/layout.tsx, a path gone since i18n was removed.
- INTERNALS.md, CLAUDE.md and .github/workflows/ci.yml: the three places
  that recorded the dependency as accepted now record that it is gone,
  each telling the next person not to add a next/font/google declaration
  back. The Network checks paragraph's comparison against the build's
  network profile is corrected too.

Left out: adding a slice back is not a second src entry, since one
localFont call emits one unicode-range across all of them. It would take
a second call and a second CSS variable chained in font-family. Not done,
because nothing needs it.

Two things measured and deliberately accepted. The metric-adjusted
fallback face moved from Google's family-level metadata (ascent-override
75.79%, size-adjust 134.59%) to metrics the local loader computes from
the file itself (77.57%, 131.49%); the local numbers describe the file
actually being served. And Lighthouse moved nothing: 9 interleaved runs
per build put / at 87 against 88 and cli-flags-reference at 79 against
79, which is the expected result when the served bytes are identical.

References:
- FS-2750
- 3d0dc68 ci(FS-2746): make the Build job blocking
- INTERNALS.md "Page weight and what loads late"

* test(FS-2750): gate the absent font import, and fix five wrong claims about it

Context:
Round 1 review of 60f274c passed with no Medium or above, and every
measurement reproduced independently, including a stronger version of
the subset claim: 23 distinct code points appear in code regions outside
the latin slice, 528 occurrences, and all 528 fall outside all six
Google slices, so box drawing was never covered by any of them and
nothing regressed. What the review found instead was five Low findings
and three Nits, all of them documentation accuracy plus one missing
tripwire.

Why:
Two of the wrong claims mattered enough to be worth a commit on their
own. The first said every face is self-hosted, which a network trace
disproves in one page load: @inkeep/cxkit-primitives hardcodes a Google
Fonts URL for Inter, so the chat chunk still fetches one at runtime. It
is pre-existing and identical on fork/main, it lives in the chunk rather
than the HTML, and that is exactly why the original grep for googleapis
came back clean. Left as written, the next person to check would find a
counterexample and not know whether it was a regression.

The second said the unicode-range entry is what makes an out-of-range
character fall back, and that without it a Cyrillic one would render as
.notdef. The reviewer disproved it with CSS.getPlatformFontsForNode:
Cyrillic, Greek and box drawing render in the fallback face with the
entry and without it, and the woff2 downloads either way, because CSS
font matching runs per character. The option is still right to keep, for
a reason the comment did not give, and a reader who discovered the
stated reason was false could reasonably drop it as decoration. That
would be a real change to what the emitted CSS means.

The tripwire is the repository's own convention for an invariant of this
shape. The whole value of the parent commit is the absence of one
import, an absence no other gate can see, and three files carried an
instruction not to write it back with nothing enforcing it.
scripts/lib/contribute-repo-links.test.mjs walks the content tree for
the same reason.

What:
- scripts/lib/fonts.test.mjs asserts no file under app/, components/ or
  lib/ imports next/font/google, picked up by pnpm test's
  scripts/**/*.test.mjs glob so it lands in the blocking Gates job. It
  matches the specifier only where quoted after from, import or require,
  because a plain substring search flags the comment explaining why the
  import is gone. Two further assertions keep it honest: that the walk
  really reached app/layout.tsx and more than fifty files, so a renamed
  directory cannot turn it into a silent no-op, and that the committed
  face and its licence are non-empty. Verified both ways: it passes as
  committed and fails when the import is put back, and the probe was
  reverted. The suite goes from 587 tests to 590.
- app/layout.tsx, INTERNALS.md and CLAUDE.md: the .notdef mechanism is
  replaced by the fidelity argument in all three, each warning against
  reading the option as decoration. The claim that every face is
  self-hosted is narrowed to every face this repo declares, and
  INTERNALS names the Inkeep request as the one runtime exception.
- app/layout.tsx also stops claiming that a character outside the
  committed range resolves as a character outside all six already did.
  That is false for the five dropped ranges, whose characters used to be
  set in JetBrains Mono. The comment now splits the two cases and names
  the second as the cost this change accepts.
- INTERNALS.md records the one uncovered invariant beside the sha256:
  the unicode-range was transcribed by hand from the CSS Google served
  on the day, so a replacement slice must bring its own range, and
  nothing checks it. Kept as a recorded limit rather than a
  font-parsing test.
- ci.yml, INTERNALS.md and CLAUDE.md said the job reaches the network
  nowhere, which the checkout and pnpm install contradict. All three now
  scope the claim to the build step.
- Two INTERNALS.md lines ran to 199 and 105 characters against
  printWidth 100, which format:check cannot see because Prettier's
  markdown proseWrap default is preserve. Rewrapped. The first attempt
  put "0." at the start of a line, which is an ordered list item, and
  Prettier rejoined it; the sentence is reworded so the break lands
  somewhere harmless.

Left out: a test reading the woff2 cmap and asserting every code point
falls inside the declared range. It would need a tolerance for the six
combining and Vietnamese marks Google's own latin slice carries outside
its range, and the obligation is better placed where someone replacing
the file is already reading.

References:
- FS-2750
- code-review-fs-2750-self-host-jetbrains-mono.md, round 1
- 60f274c build(FS-2750): self-host JetBrains Mono so the build makes no network request
- scripts/lib/contribute-repo-links.test.mjs, the model for the tripwire
* build: run scripts and config as TypeScript under Node's type stripping

Context:
Every script, test, data file and root config in this repo was `.mjs`,
92 files at 8d37f11, while app code is TypeScript. The two halves met at
four `.ts` imports of `.mjs` modules (lib/shared.ts, proxy.ts,
source.config.ts, lib/docs-navigation.ts) that relied on `allowJs` and
JSDoc for their types, and six tests already imported `lib/*.ts` under
Node's native type stripping. Fionna asked for no `.mjs` left at all.

Why:
Node 22.18 and later run a `.ts` file directly, stripping types and
compiling nothing else, so the scripts need no build step and no runner
(tsx, ts-node, jiti). That decides the setup:
- `engines.node` rises to 22.18.0; `.node-version` stays `22`, which
  every version manager, actions/setup-node and Vercel resolve to the
  newest 22.x.
- `"type": "module"` so Node stops sniffing each `.ts` file's format and
  printing MODULE_TYPELESS_PACKAGE_JSON. No `.js` or `.cjs` exists
  outside node_modules, so nothing changes meaning.
- `erasableSyntaxOnly` rejects at `tsc` what Node cannot strip (enums,
  namespaces, parameter properties); `verbatimModuleSyntax` makes a
  type-only import say `import type`, since Node keeps the import and
  throws on a missing runtime export; `allowImportingTsExtensions`
  allows the `./x.ts` specifiers Node needs (legal with `noEmit`).
  Turbopack resolves those specifiers in app code too. One app file
  (VanillaAdmonition) was importing a type as a value and is fixed.
- Config loaders were checked in node_modules, not assumed: Next
  transpiles next.config.ts and the .ts files it imports; Prettier
  3.9.6 and lint-staged 17.5.1 both search for a `.ts` config; svgo
  loads an explicit `--config` path with dynamic import. Next's PostCSS
  loader accepts only .json/.js/.mjs/.cjs and reads package.json's
  `postcss` key first, so that config moves there; a postcss.config.ts
  would have been ignored silently and Tailwind would have stopped.

What:
- tsconfig.json: the three options above, plus `.lintstagedrc.ts` in
  `include`, because `**/*.ts` never matches a dotfile. `allowJs` stays
  until the last `.mjs` is gone.
- package.json: `type`, `engines`, the `postcss` key, and a test glob
  that runs both `*.test.mjs` and `*.test.ts` during the transition.
- next.config.mjs, prettier.config.mjs, .lintstagedrc.mjs,
  svgo.config.mjs, redirects.config.mjs, redirects.legacy.mjs,
  lib/site-url.mjs and its test become `.ts` with real types; the
  redirect arrays are typed by one exported `Redirect` shape.
- types/offchainlabs-prettier-config.d.ts declares the untyped preset.
- lib/site-url.ts drops its "why this is .mjs" header, which no longer
  holds; the test drops its Node-version skip, which cannot fire on a
  Node that runs the test file itself.
- The svgo hook and skill name the .ts config.

Left for the following commits: the 78 files under scripts/, the other
four lib/*.mjs, the package.json script entries, the workflows, the
generated markers and the four docs, all listed in the plan document.

References:
- .claude/docs/superpowers/specs/2026-09-24-mjs-to-typescript-plan.md
- 8d37f11 FS-2750: Self-host JetBrains Mono (base commit)

* refactor(scripts): convert content-lint, remote-images and vars-check to TypeScript

Cluster B of the mjs-to-TypeScript conversion. Eleven files renamed with
git mv and typed; imports of other clusters' files keep their .mjs
specifiers until the orchestrator flips them.

Type decisions that were not obvious:

- content-lint exports a RuleId union, and the CLI's title table is a
  Record<RuleId, string>, so a rule added to the engine without a title
  fails types:check. The --rule= filter stays plain strings, because an
  unknown id has always matched nothing.
- announcement-link types its index as the third parameter of
  resolveRefToFile rather than restating the docs index shape, so it
  follows whatever doc-links declares once that file is converted.
- remote-images-check models a probe as a union of an HTTP answer and a
  failure. The impossible "URL was never probed" lookup now throws a named
  error where the untyped code threw a TypeError.
- vars-check narrows the parsed vars.json to an object before the `in`
  test, and keeps reading it with fs rather than a JSON import, so a
  missing or malformed file is still the audit's report and not a
  module-load crash.
- vars-audit's comment on why it regex-parses content/vars.ts now gives
  the current reason: that module imports JSON without the attribute Node
  needs and parses at load.

* refactor(scripts): convert cluster A1 to TypeScript

Convert strip-code, partials, lib/var-links, lib/mdx-comments and
lib/mdx-options, their tests, fonts.test, contribute-repo-links.test,
partials-check, generate-partials-catalog and references-check from
.mjs to .ts, run by Node's own type stripping. source.config.ts now
imports lib/mdx-options.ts. CATALOG.md is regenerated because its
do-not-edit marker names the generator.

Non-obvious type decisions:

- mdast, mdast-util-mdx-jsx and mdast-util-mdx-expression types are not
  resolvable from the repo root, so var-links and mdx-comments declare
  small local structural node types (VarLinksNode, MdxCommentsParent).
- mdxOptions is annotated with fumadocs-mdx's DefaultMDXOptions, the
  type defineConfig and applyMdxPreset accept.
- JSON.parse results are narrowed: readVars() returns
  Record<string, unknown> and throws if vars.json is not an object;
  loadRegistry() validates each registry.json entry's field types and
  throws on a wrong type. A non-neutral/localized scope string still
  passes, so partials-check R4 keeps reporting it.
- isMdxComment() takes unknown and narrows, since tests and visitors
  hand it anything including undefined.
- The mdx-comments test calls processor.process() instead of
  parse() + run(), because @mdx-js/mdx types run() as taking the estree
  Program rather than the mdast Root it is actually handed.

* refactor(scripts): convert the navigation, versioning and FAQ cluster to TypeScript

Renames the thirteen E1 files from .mjs to .ts and types them for Node's
native type stripping. Runtime behavior is unchanged: nav-check,
faq-data-check and versioned-docs-check print byte-identical output
before and after, including the --json modes, the CI annotation path and
the modified-document warning box.

Type decisions that were not obvious:
- NavigationEntry and NavigationSection move from lib/docs-navigation.ts
  into lib/docs-navigation-rules.ts, which imports nothing, and
  lib/docs-navigation.ts imports them from there. The rules file stays
  free of fumadocs-core, so nav-check loads no more than before, and the
  import between the two runs one way only.
- The two manifest rules take Partial<NavigationSection>, since they
  read only id and children and tolerate either missing.
- readSections validates the parsed manifest with a structural guard
  instead of returning JSON.parse's value untyped; it throws, naming the
  file, when "sections" has the wrong shape.
- meta.json is typed as MetaJson with pages left unknown, because
  classifyEntry already reports non-string entries. A meta.json that
  parses to a non-object is read as {}, which every rule answered the
  same way before.
- docs-navigation.test types its virtual files as fumadocs-core
  VirtualFile with a sidebar_label page field, and checks each meta.json
  against MetaData with a guard.

* refactor(scripts): convert cluster D (CLI reference and Stylus generators) to TypeScript

Renames the Go source reader, the Nitro flag extractor, the CLI reference page renderer, the
Stylus-by-Example transform, both generators, their tests and their data files from .mjs to .ts,
run directly by Node's type stripping. Imports of files outside the cluster (generated-partial,
line-diff, doc-links) keep their .mjs specifiers until the merge pass.

Type decisions that were not obvious:

- go-source: the index is typed as GoTree (per-directory GoPackage of vars, consts and flag
  functions, plus per-file GoImports alias maps). GoRoot now declares `absDir`, which the old
  JSDoc omitted although indexGoTree reads it.
- nitro-cli-flags: evaluated expressions are a GoValue discriminated union on `kind` (nil, zero,
  string, bool, number as number|bigint, duration, slice). Parameter and local bindings are a
  recursive Scope map. List evaluation narrows through a small allEvaluated helper instead of
  `.some(null)` followed by an unchecked spread. The resolver's lazy `_importCache` became an
  eagerly initialised private field, since parameter properties are not erasable syntax.
- stylus-examples: parseObjectLiteral returns a JsonValue. parseMetadata returns the parsed
  object with title and description normalised in place, typed JsonObject & StylusMetadata.
  `fail` is a function declaration returning never, so its calls narrow.
- generate-stylus-examples restates writeOrCheck's options as a local interface, because the
  still-JavaScript generated-partial.mjs has no JSDoc on that parameter.

Path strings naming this cluster's own files (problem messages, error text, comments) now say
.ts. Neither generator writes a script path into its output, so no content changes.

* refactor(scripts): convert cluster E2 (redirects, legacy maps, HTTP suites) to TypeScript

Renames the redirects-check lib, CLI and test, legacy-redirects and its
test, legacy-destinations and its test, and the shared, llms-tracking and
static-docs-http tests from .mjs to .ts, run by Node's type stripping.

Non-obvious type decisions:
- MANUAL_DESTINATIONS, SECTION_LANDINGS and UPSTREAM_TITLES keep their
  unannotated `export const X = new Map([` lines. legacy-destinations
  finds each literal with a regex over exactly that line, so an
  annotation would make every rewrite miss. Inference already gives
  Map<string, string>. The reason is recorded on mapRange.
- assertLegacyDestinationsRewrite types the dynamic import as unknown and
  narrows it with a small destinationValues guard instead of indexing an
  untyped module.
- DESTINATION_MAPS is `as const`, so the per-map change counts are keyed
  by the two map names.

Path constants: LEGACY_REDIRECTS_PATH now names
scripts/lib/legacy-redirects.ts, and REDIRECTS_CONFIG_PATH now names
redirects.config.ts. The second fixes a break from the base commit, which
renamed redirects.config.mjs to .ts but left this constant naming the .mjs
file. So `pnpm move-doc` would have written a fresh redirects.config.mjs
beside the real config, which Next never reads, and findChainedAutoRedirects
would silently find nothing. The same base rename had also broken
scripts/redirects-check, whose import of ../redirects.config.mjs no longer
resolved. The CLI notes naming these files, and their test regexes, follow.

* chore: temporary .mjs re-export shims for the merge

scripts/move-doc.mjs (another cluster) still imports ./lib/legacy-destinations.mjs. This one-line re-export keeps that import resolving until phase 3 flips the specifier and deletes the shim.

* chore: temporary .mjs re-export shims for the merge

Files outside cluster A1 still import strip-code.mjs, partials.mjs,
lib/var-links.mjs and lib/mdx-options.mjs by their old specifiers
(content-lint, doc-links, doc-anchors, remote-images, vars-audit,
nitro-node-image, legacy-redirects, and the check-links, move-doc and
remote-images-check tests). Those are converted by other clusters, so
their specifiers stay as they are until the orchestrator flips them.

Each shim is one `export *` line from the new .ts module, so the old
importers keep resolving at runtime and `pnpm test` keeps passing in
this worktree. Delete all four once no importer names a .mjs path.

* refactor(scripts): convert cluster C (generated partials) to TypeScript

Convert the generated-partial helpers, the contract-address and
precompile-table generators with their data files, the Nitro release
bump and its image sync, and the edge-challenge fetch from .mjs to .ts,
run by Node's own type stripping. Imports of partials.mjs (cluster A1)
keep their .mjs specifier until the merge.

Regenerated the 17 generated partials. Only the do-not-edit marker line
changed, because it names the generator or data file by path; both
--check modes exit 0 afterwards.

Type decisions that were not obvious:
- contract-addresses: buildContent is generic over the chain key, and
  the renderer reads a NetworkAddresses Pick of the SDK's
  ArbitrumNetwork, so test fixtures with a "demo" key and a partial
  network still type-check. ChainKey now lives in the data module.
- precompile-tables: parsed entries are strict until overrides are
  merged, then the same object is widened to all-optional fields,
  because an override for an undeclared name produces an entry with no
  line numbers that assertResolved rejects.
- writeOrCheck exports WriteOrCheckOptions; overrides is prettier's
  Options, so callers annotate their MDX_FORMAT constants.
- JSON.parse and API responses are narrowed with small type guards.
  check-nitro-release and generate-precompile-tables now throw by name
  when a required vars.json key is not a string, where they previously
  rendered or compared "undefined".

* chore: temporary .mjs re-export shims for the merge

scripts/generate-cli-reference.mjs and scripts/generate-stylus-examples.mjs
(cluster D) still import generated-partial.mjs and line-diff.mjs. Each shim
re-exports its .ts module so types:check and pnpm test stay green until
phase 3 flips those specifiers and deletes the shims.

* refactor(scripts): convert cluster A2 (doc links, anchors, move-doc) to TypeScript

Renames doc-links, doc-anchors, check-links, inventory-links, move-doc,
restructure and their tests to .ts, run directly by Node's type stripping.

Type decisions worth knowing:
- doc-links exports DocIndex, DocFile, FileMeta, LinkRef, LinkStyle,
  RefSurface, Rewrite, BrokenLink and MetaFile for its importers. LinkRef is a
  union on `range` (tuple vs null), so checking `ref.range` narrows the ref.
- doc-anchors walks the mdast and hast trees through a local structural
  TreeNode, since mdast/hast/unist are not direct dependencies. Its one cast
  hands the parsed mdast root to `run`, which @mdx-js/mdx types as taking an
  estree Program. The anchor results are typed by augmenting vfile's DataMap.
- move-doc and restructure path strings now name the .ts scripts, and the
  redirects label reads REDIRECTS_CONFIG_PATH instead of repeating it.

* chore(scripts): add a temporary doc-links.mjs shim for other clusters

Four files outside cluster A2 still import ./doc-links.mjs. The shim
re-exports doc-links.ts so they keep running until the specifiers flip;
delete it then.

* refactor: finish the .mjs to TypeScript conversion, no .mjs left

Context:
56e8e08 set up Node's type stripping and converted the root config and
redirect tables. Seven parallel branches (mjs-a1, a2, b, c, d, e1, e2)
then converted the 83 files under scripts/ and lib/, each in its own
worktree, and are merged just before this commit. Because a file
renamed on one branch was still imported by its old name from another,
each branch left a one-line `export * from './x.ts'` shim at the old
.mjs path so its own gates stayed green; those shims were the only
.mjs files left after the merge. Import specifiers, package.json
aliases, the pre-commit config, both workflows, generated markers and
the four docs all still named the .mjs paths.

Why:
The request was no .mjs at all, so the shims go, every specifier and
path string flips to .ts, and `allowJs` comes out of tsconfig.json so
nothing can quietly reintroduce JavaScript. Three cross-branch type
mismatches surfaced once the real modules met: `resolveRefToFile` is
now typed against the three index lookups it reads (and accepts a null
origin, which the announcement banner has always passed) rather than
the full `DocIndex`, so the announcement-link test's stand-in index
type-checks; the CLI-reference and Stylus generators annotate their
Prettier options as `Options`, since `writeOrCheck` now types its
overrides; and the Stylus generator imports `WriteOrCheckOptions`
instead of restating it. A relative link with a null origin now returns
null instead of throwing inside `path.dirname`; no caller reaches that.

What:
- Delete the eight shims (lib/mdx-options, lib/var-links, and six
  under scripts/lib/).
- Flip every remaining .mjs import specifier and path string in
  scripts/, lib/, app/, components/ and the root .ts files. Three
  historical mentions stay: an upstream file name, a deleted
  generator's test name, and the extension list fonts.test.ts scans.
- tsconfig.json: drop `allowJs`.
- package.json: every script alias and the test glob name .ts;
  .lintstagedrc.ts runs content-lint.ts; ci.yml and
  upstream-refresh.yml run versioned-docs-check.ts and the HTTP suite
  by their new names.
- README, CONTRIBUTE, INTERNALS and CLAUDE.md: every repo path renamed;
  the Node floor stated as 22.18; the passages that explained why
  site-url and nav-check were plain JavaScript rewritten; a new
  INTERNALS section "Scripts are TypeScript, run by Node" recording the
  four tsconfig/package settings, the PostCSS package.json key, and
  that every config loader was checked in node_modules; the same
  mirrored as one Conventions bullet in CLAUDE.md.
- lib/shared.ts comment on getSiteUrl updated for the same reason.

Verified on this commit: types:check (0 errors), test (628 tests, 622
pass, 6 skipped, unchanged from 8d37f11), format:check, vars:check,
nav:check, partials:check, versioned-docs-check, references:check,
faq:check, images:presence, check-links, contracts:check, content:lint,
precompiles:check (network), pnpm build, then next start with
redirects:check and static-docs-http.test.ts (30/30) against it.
`find . -name '*.mjs'` outside node_modules, .next and .source is empty.

Left out: the Nitro pin is stale upstream (v3.11.4 since 2026-09-16);
the check-nitro-release run that found it was reverted, since a version
bump is not this change.

References:
- .claude/docs/superpowers/specs/2026-09-24-mjs-to-typescript-plan.md
- 56e8e08 build: run scripts and config as TypeScript under Node's
  type stripping

* refactor(scripts): let renderRef and findBrokenLinks ask for the index fields they read

Context:
82699f1 typed resolveRefToFile against the three lookups it reads so a
test can pass a stand-in index. Two sibling functions in doc-links.ts
still took the whole DocIndex. Cluster A2 narrowed them the same way in
a rewrite of its branch that landed after the merge, so this carries
that part over by hand; the rest of that rewrite was already in.

What:
- renderRef takes Pick<DocIndex, 'docsRoot' | 'urlByAbs'>.
- findBrokenLinks takes Pick<DocIndex, 'files' | 'repoRoot' | 'byAbs'
  | 'urlByAbs' | 'byUrl'>.
No caller changes; types:check and pnpm test are unchanged.
* refactor(redirects): fold every redirect into redirects.config.mjs

Context:
Redirects lived in two files. `redirects.config.mjs` held the entries
`pnpm move-doc` writes between its AUTO-GENERATED markers and spread in
`redirects.legacy.mjs`, which held the 853 legacy docs.arbitrum.io
entries. A third layer under `scripts/lib/` (`legacy-redirects.mjs`,
`legacy-destinations.mjs`, two test files, about 1,600 lines) kept the
four hand-written maps the legacy file had been generated from. The
generator was deleted in FS-2706, so nothing at runtime read those maps
any more; `move-doc` still rewrote them on every page move and the
tests kept them consistent with a content tree they no longer affected.
Moving a page therefore printed two notes telling the mover which
entries in which file were now one hop stale, and left them stale.

Why:
Fionna asked for every redirect to be handled in `redirects.config.mjs`,
as part of shrinking the tooling to what a writer-only workflow needs.
With one file there is nothing left for a move to leave stale, so the
map layer and both CLI notes can go, and `move-doc` can simply retarget
every entry whose destination was the moved URL. The served redirect
set does not change: the loaded `redirects` array is byte-identical
before and after (857 entries), checked by a JSON comparison of the
imported module.

What:
- `redirects.config.mjs`: the legacy entries now sit after the
  AUTO-GENERATED block under a comment, in their original order. The
  two entries that still point at a section landing because the page
  was never ported (`config-batch-poster`, `sequencer-content-map`)
  carry a comment saying to retarget them when it is.
- Deleted `redirects.legacy.mjs`, `scripts/lib/legacy-redirects.mjs`,
  `scripts/lib/legacy-destinations.mjs` and their two test files.
- New `scripts/lib/redirects-config.mjs`: the marker constants (kept in
  a module because `move-doc.mjs` runs `main()` on import) and
  `retargetRedirects`, which rewrites `destination: '<old>'` with an
  optional `#anchor` and nothing else. A `source:` cannot match because
  the pattern starts with the `destination:` key; a child page cannot
  match because the closing quote must follow the URL immediately.
- `scripts/move-doc.mjs`: step 5 calls `retargetRedirects` after the
  new entry is appended, so that entry (destination = new URL) cannot
  match itself. Header comment rewritten; the `pnpm restructure` hint
  dropped from the closing line.
- New `scripts/lib/redirects-config.test.mjs`: offline tripwire that
  every internal destination names a page under `content/docs`
  (case-sensitive, since Next routes are) and that no source is listed
  twice, plus unit tests for the retarget. This replaces the "every
  hand-written destination still resolves" assertion of the deleted
  test, over the file that is actually served.
- `scripts/move-doc.test.mjs`: fixture now writes a
  `redirects.config.mjs` with an earlier move's entry and two legacy
  entries (one anchored) pointing at the page; tests cover a real run,
  a dry run, and a move of a page only one entry names. The
  double-quote abort test went with the textual cross-check it tested.
- `scripts/lib/shared.test.mjs`: uses `buildIndex` from `doc-links.mjs`
  instead of the deleted `collectValidUrls`/`resolveUrl`.
- INTERNALS.md (Redirects section), README.md and CONTRIBUTE.md (move a
  page), CLAUDE.md (Redirects bullet): describe the single file, the
  retarget, and keep the six-rule resolution order as guidance for a
  new legacy entry.

Given up: the `UPSTREAM_TITLES` test, which failed the suite when a
page carrying one of two recorded upstream titles was ported. Replaced
by the comments on those two entries and a paragraph in CONTRIBUTE.md;
nothing automated reminds anyone now.

Not in this commit: Tier 1 of the script audit (`restructure.mjs`,
`inventory-links.mjs`, `svgo.config.mjs`) is untouched.

Verified on Node 22: `pnpm test` 593 pass / 0 fail, `pnpm types:check`,
`pnpm format:check`, and `pnpm redirects:check --base-url` against
`pnpm dev` (857 redirects, every destination resolves, nothing
shadowed).

References:
- .claude/docs/superpowers/specs/2026-09-24-single-redirects-file-plan.md
- .claude/docs/superpowers/specs/2026-09-24-script-deletion-audit.md
- a385260 FS-2748: Point nine legacy redirects at the page the reader
  asked for, and gate the class
- FS-2706 (deleted the legacy redirect generator)
- FS-2697 (added the move-doc map retarget this removes)

* fix(move-doc): drop the redirect that would shadow a page moved back

Context:
Review of 82f7cd8 (code-review-single-redirects-file.md) reproduced an
out-and-back move with the real CLI: `X -> Y` on file from an earlier
move, then the page moved from Y back to X. The append writes `Y -> X`
and the new retarget rewrites `X -> Y` into `X -> X`, a loop that
Next's `redirects()` serves before the route, so the restored page is
unreachable while `move-doc` reports success. The base had the same
defect as a two-entry loop (`X -> Y`, `Y -> X`), noted as a follow-up
in the FS-2697 review loop, so this branch automated it rather than
introducing it. The offline test could not see it, because X exists
as a page and no source was duplicated.

The same review found that a retarget can leave the file failing
`format:check` (the deleted module used to run Prettier on its output;
the new one did not), that the "runs last so it cannot match itself"
rationale guarded an invariant that does not exist, that the
Prettier-wrapped destination shape was no longer pinned by a test, and
an em dash in the new module's header.

Fixing the first of those in a fixture repo also showed the textual
rewrite missing double-quoted entries once Prettier ran with default
settings, which is the fragility the deleted cross-check existed for.

Why:
An entry whose source is the new URL cannot be right, since a page
lives there now, so deleting it is the only correct resolution and is
confined to the same module. Accepting either quote style in both
regexes removes the fragility instead of guarding it, and formatting
before the write restores the behaviour the deleted module had.

What:
- `scripts/lib/redirects-config.mjs`: new `removeEntriesFrom` deletes
  a whole `{ source, destination, permanent }` entry by source, on one
  line or wrapped. `retargetRedirects` runs it before the retarget (so
  the note names the entry as it was on file), is now async, and
  writes through `prettier.format` with the file's resolved config.
  Both regexes accept single or double quotes. Header em dash replaced.
- `scripts/move-doc.mjs`: `await` restored on both call sites; the
  ordering comment now says the order is for reporting only.
- `scripts/lib/redirects-config.test.mjs`: new offline assertion that
  no source is a live page and none redirects to itself (passes on the
  current file); unit tests for the removal, the wrapped shape, the
  double-quoted shape, and the two no-op early returns.
- `scripts/move-doc.test.mjs`: fixture writes the repo's Prettier
  settings; an `entry()` helper tolerates wrapping; new end-to-end
  out-and-back test asserting nothing redirects away from the restored
  page and the CLI names the removed entry.
- INTERNALS.md, README.md, CONTRIBUTE.md, CLAUDE.md: describe the
  deletion and the out-and-back case, correct the ordering rationale,
  state the new test coverage; CLAUDE.md test-file count 30 to 31.

Verified on Node 22: `pnpm test` 598 pass / 0 fail (604 tests, 6
skipped), Prettier clean on every changed file.

References:
- code-review-single-redirects-file.md (round 1)
- 82f7cd8 refactor(redirects): fold every redirect into
  redirects.config.mjs

* fix(move-doc): format redirects.config.mjs after an append-only move

Context:
Round 2 review (code-review-single-redirects-file-round2.md) found
that `retargetRedirects` returned early when no entry needed
rewriting, so a move that only appended its own redirect left the
one-line entry `appendRedirect` writes unformatted, and a long URL
made `format:check` fail. CLAUDE.md said the file is formatted before
every write. It also found INTERNALS.md still counting two offline
tests where there are three, and the config file's header not
mentioning the shadow-entry deletion.

Why:
Formatting on every real run is three lines and makes the documented
claim true; the pre-commit hook would have masked it on a normal
commit, but a `HUSKY=0` commit would fail the blocking gate.

What:
- `scripts/lib/redirects-config.mjs`: drop the early return; a real
  run always formats and writes. Notes are still empty when nothing
  was rewritten.
- INTERNALS.md: "Three offline tests", naming the shadow/loop check.
- `redirects.config.mjs` header: mention the deletion and the tests.

Verified on Node 22: `pnpm test` 598 pass / 0 fail; an append-only
move of a long URL in a fixture leaves a file `prettier --check`
accepts.

References:
- code-review-single-redirects-file-round2.md
- 4356b42 fix(move-doc): drop the redirect that would shadow a page
  moved back

This branch was successfully deployed

1 active (outdated) deployment
Preview — 38a39b7b Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant