How the Arbitrum docs portal works, and why it is built the way it is. For how to work on the docs, see README.md. For the path from a first edit to an open PR, see CONTRIBUTE.md. For the prose rules, see STYLE-GUIDE.md.
- What Fumadocs is
- Coming from Docusaurus
- The pipeline
sourceis a choke point- The frontmatter contract
- Last modified dates
- The sidebar and its roots
- Page metadata
- Partials
- Global variables
- Glossary and inline references
- Custom MDX components
- Page weight and what loads late
- Theme
- Redirects
- Routing and
proxy.ts - Static routing at the site root
- Analytics
- Scripts are TypeScript, run by Node
- The gates
- What nothing catches
- Contributor agent setups
Fumadocs is a set of libraries on top of a Next.js App Router app that we own. There is no
fumadocs build, no plugin system and no theme to eject from. pnpm dev runs pnpm install, then
pnpm clean (deletes .next/ and .source/), then next dev: the same sequence as master's
yarn start, so the first page compiles cold on every start. Both collections are dynamic
(see The pipeline), so that first page compiles its own MDX and the glossary
entries it cites, not every file under content/. Every route under app/ is ordinary
Next code. We get full control, and in exchange we own the pieces a
monolithic docs framework would supply. Most of this document describes those pieces.
| Package | Responsible for |
|---|---|
fumadocs-core |
The headless engine: the loader, the page tree, search, TOC, MDX plugins |
fumadocs-mdx |
The content source: compiles MDX into typed collections in .source/ |
fumadocs-ui |
The theme: the notebook docs layout, tabs, accordions, callouts, images |
Fumadocs' own reference is https://www.fumadocs.dev/llms.txt. Read it rather than guessing at an API.
Four concepts carry the site:
- Collections. A typed set of content files, declared in
source.config.ts. Each has adirand a Zod schema every file's frontmatter must satisfy. This repo declares two:docs(routed) andglossary(reference entries). - The loader.
loader()turns a compiled collection into thesourceobject the rest of the app queries:getPage(slug),getPages(), the page tree, and URLs derived frombaseUrl. - The page tree. The structure behind the sidebar, breadcrumbs and previous/next links, built
from the directory layout and the
meta.jsonin each directory. - The catch-all route.
app/(docs)/[...slug]/page.tsxrenders every docs article. Adding an.mdxfile creates a route with no wiring.
A slug is the file path minus the extension, with a trailing index dropped:
content/docs/stylus/quickstart.mdx serves at /stylus/quickstart. The content/docs directory
stores content; its name contributes no URL prefix.
The action row under a page's title holds the copy-markdown button, the view-options menu (with the
edit link) and RequestUpdateLink, which opens a prefilled GitHub issue.
Most of the team knows the Docusaurus site this one replaced. These are the differences that cause mistakes:
| Docusaurus | Here |
|---|---|
docusaurus.config.js, presets, plugins |
next.config.ts and source.config.ts; no plugin system |
sidebars.js, one global file |
A meta.json per directory |
sidebar_position, displayed_sidebar frontmatter |
The pages array in meta.json, and the root: true folder the file sits in |
user_story, target_audience, last_reviewed frontmatter |
Dropped; the schema strips unknown keys silently |
| Swizzling a theme component | Edit the component; it is our code |
onBrokenLinks: 'throw' |
Nothing built in, so pnpm check-links supplies it |
02-foo/bar serves at /foo/bar |
The numeric prefix stays in the slug |
:::note, :::caution, :::info admonitions |
<Callout type="info|warn|error|idea|success"> on its own lines (docusaurus-directive) |
<a data-quicklook-from="id">text</a> |
<Term id="id">text</Term> (quicklook-anchor) |
@@varName@@ preprocessing |
<Var name="…" /> in prose, {var:name} in a URL, see Global variables |
import Partial from '@site/docs/partials/_x.mdx' and <Partial /> |
<include cwd>content/partials/_x.mdx</include> (esm-in-page) |
import Tabs from '@theme/Tabs' and <TabItem value label> |
<Tabs items={[…]}> and <Tab value>; nothing is imported |
<details> |
<Accordions><Accordion title="…"> |
className="img-600px" on an image |
One prose cap (600px) in app/global.css, with a data-wide escape, see Image sizing |
Client-redirects plugin plus vercel.json |
Next redirects() only, see Redirects |
Read source.config.ts, lib/source.ts and app/(docs)/[...slug]/page.tsx together. Nothing else
reads the docs collection. The glossary collection has one reader, lib/references.ts.
fumadocs-mdxscanscontent/docs/**andcontent/glossary/**and emits the.source/collections. Both collections setdynamic: true, so.source/dynamic.tslists each file with its parsed frontmatter and nothing imports the MDX: a page compiles on first request, in the server, through the samebuildMDXand plugins as the bundler path (fumadocs-mdx/runtime/dynamic.js). Undernext devthe first page therefore waits for its own MDX, not for every file;next buildstill prerenders every page. The frontmatter schema (arbitrumPageSchemainlib/page-schema.ts, applied by thedocscollection insource.config.ts) runs when the index is generated.pnpm lintruns the same schema offline (ESLint ruledocs/frontmatter-schema,eslint/rules/frontmatter-schema.ts).lib/source.tsimportsdocsfromcollections/dynamicand runsloader()over it with the Lucide icons plugin and exportssource.- Route handlers read
source.page.dataholds the frontmatter;body,tocandlastModifiedcome fromawait page.data.load(), which compiles once per process and caches. The docs page renders pages; thellms.txt,llms-full.txt,llms.mdx/,og/and sitemap routes derive from the same object.getText('processed')loads the same way.
Dynamic mode supports neither a top-level import or export in an MDX page nor a relative
image path (./image.png); components come from the registry in components/mdx.tsx and images
from public/. Nothing in the build rejects an import: the compiled body runs as a function, so
an import becomes a runtime import() from the page's directory. A relative one fails at request
time with "Cannot find module"; a package import resolves through the repo's node_modules and
renders, untraced by the bundler. pnpm lint rule docs/esm-in-page is the gate.
Because .source/dynamic.ts imports source.config.ts, the plugins in lib/mdx-options.ts are
bundled into the server. Two consequences in lib/var-links.ts: VARS_PATH is derived from
import.meta.url as a string (Turbopack rewrites new URL('…', import.meta.url) into a static
asset), and the read in readVars carries turbopackIgnore (a variable path otherwise makes
Turbopack trace the whole project, public/ included, into every server function). The server
never reads the file in production: every page and mirror is prerendered.
Hot reload under next dev follows the same cache. An edit to a page changes its hash in
.source/dynamic.ts, Turbopack re-evaluates that module, and every page recompiles on its next
request. An edit to a partial or to content/vars.json changes no hash, and the runtime compile
records no dependencies (compilerOf(file) is undefined on that path), so a page already
compiled keeps the old include and the old {var:…} link destinations until a page under
content/docs/ changes or the server restarts. <Var> in prose still updates: the component
reads @/content/vars at render time.
.source/ is generated by postinstall, types:check and the build. Never hand-edit it.
MDX options live in lib/mdx-options.ts, shared by the site and by check-links, so anchor
validation sees the transforms the reader gets. The remark plugins are remark-math (with
rehype-katex), remarkVarLinks (see Global variables),
remarkStripMdxComments, which removes {/* … */} comments, and remarkLlmsMarkdown, which
turns the site's components into markdown for the mirrors (see
Routing and proxy.ts). Comments never reach the HTML, but the markdown
mirrors are sliced from the authored source, which prints a removed node unless the plugin also
records the removal (see source is a choke point). A comment inside
a fence or an inline code span is not an expression node and is served as written. Generators that
depend on a marker comment read the raw .mdx file from disk.
Seven files under app/ import source: the docs page and layout, the llms.txt,
llms-full.txt, llms.mdx and og routes, and the sitemap. The rules around it:
docs.toFumadocsSource()is the only adapter for.source/. Never build a second read path.baseUrlis an argument to the singleloader()call. A second loader would restate it and let page URLs drift.- Helpers are typed
(typeof source)['$inferPage'], so a schema edit re-types every consumer. - URL derivation lives next to
source(getPageImage,getPageMarkdownUrl,getLLMText), not in routes. postprocess.includeProcessedMarkdownon the collection is what makespage.data.getText('processed')work. Remove it and thellms-full.txtandllms.mdxroutes break.llms.txtonly lists pages and does not read their text. Behind the option, Fumadocs'remarkLLMsruns after the plugins inlib/mdx-options.tsand slices the authored source by node position. A plugin that only mutates or removes a node leaves the mirror unchanged, soremarkLlmsMarkdown(lib/llms-markdown.ts),remarkVarLinksandremarkStripMdxCommentsrecord their edits withreplaceSource()fromfumadocs-core/mdx-plugins/stringifier.- Server only. Never import
lib/source,lib/references, or a module that imports either, from a client component. They reachcollections/dynamic, which reads and compiles files on the server;scripts/lib/client-imports.test.tsfails on such a chain.
arbitrumPageSchema in lib/page-schema.ts extends the Fumadocs page schema, and the docs
collection in source.config.ts applies it (the schema has its own module because
source.config.ts may only export collections):
| Field | Rule |
|---|---|
title |
Required, trimmed, not empty |
description |
Required, trimmed, not empty |
sidebar_label |
Optional; replaces the title as the page's sidebar name |
content_type |
Optional; one of how-to, concept, quickstart, tutorial, reference, troubleshooting, faq |
author, sme |
Optional strings |
third_party_content_owner |
Optional string; GitHub username of the designated third-party content maintainer |
target_audience |
Optional string describing the intended readers and their assumed knowledge |
user_story |
Optional string describing the reader's goal and why it matters |
A missing title or description, or a content_type outside the enum, fails pnpm lint (rule
docs/frontmatter-schema) and the build. types:check does not validate every page's frontmatter. Nothing renders
content_type; it is an editorial label kept to one enum so values stay
comparable. Partials and glossary entries do not carry this contract.
target_audience and user_story are authoring metadata, preserved in parsed page data for
writers and reviewers. They do not render on the page and are not required for a valid page.
The lastModified option on the docs collection makes fumadocs-mdx read each file's date from
git. The docs page prints it as "Last updated on …", the sitemap emits it as <lastmod>, and page
metadata emits it as article:modified_time. It is not frontmatter, so never add a date field to a
page. It is resolved when the page compiles, so it sits behind await page.data.load(); the
sitemap loads every page to read it.
The option is gated by hasFullGitHistory() in source.config.ts. A shallow clone reports its
oldest commit as having added every file, which would stamp most pages with one wrong date, so a
shallow checkout resolves no dates at all. Two consequences:
- Vercel clones shallow, so production shows no dates until
VERCEL_DEEP_CLONE=trueis set in the project's environment variables. - CI checks out shallow, so the HTTP smoke suite never sees a date and asserts only that the metadata tag and the visible line agree.
Never express the fallback as lastModified: false. The generated type carries the field only
while the option is truthy, so false deletes it and the docs page fails types:check. The guard
chooses between true and a resolver that returns undefined.
Dates are formatted in UTC, so a prerendered page reads the same wherever it was built.
The sidebar is the directory tree, ordered by meta.json. Nothing else decides it.
- Each of the nine sections is a top-level directory under
content/docswhosemeta.jsonsets"root": true. The navbar (lib/layout.shared.tsx) links into each section. Two entries do not point at a landing page: Stylus goes tostylus/quickstart, and the Build menu carries an extra entry forbuild-decentralized-apps/machine-payments-protocol. tabs={false}inapp/(docs)/layout.tsxturns off the root switcher. Fumadocs resolves the current URL to its node in the tree and renders the nearestroot: truefolder above it. A page outside every root folder gets the whole tree.- A subdirectory is a collapsible group titled by its own
meta.json. Itsindex.mdxopens when a reader clicks the group name. - The
pagesarray orders entries. It takes basenames, subdirectory names, paths into other directories such as"../chain-info"(which place a page without moving it),"...name"to inline a subdirectory,"..."for everything not yet listed,"---Heading---"separators andexternal:[Label](https://…)links. CONTRIBUTE.md has the writer's version. - A page's sidebar name is its
sidebar_label, else itstitle. The one page-tree transformer inlib/source.tsappliessidebar_label. - The footer under every section tree pins Chain info, Glossary and Contribute
(
components/sidebar-resource-links.tsx, links fromsidebarResourceLinksinlib/shared.ts). It sits outside the page tree, so it cannot claim a page for another section.
Never write a [Label](/section/page) link entry for a page in this repo. It puts the page on a
second node, and the reader lands in whichever section Fumadocs finds first. Use a path entry.
scripts/sidebar.test.ts builds the real tree with Fumadocs' loader and fails when a page is on no
node, on more than one, or outside every root folder. The root overview (content/docs/index.mdx)
is excluded with !index: it supplies /index.md and the LLM overview, while the homepage owns
HTML at /. It runs under pnpm test.
generateMetadata in the docs page emits:
- the title and description from frontmatter;
alternates.canonical, the absolute page URL;- Open Graph:
type: article,siteNamefromappNameinlib/shared.ts,urlequal to the canonical, the page image from theog/route, andmodifiedTimewhen a date is known; - a
summary_large_imageTwitter card withsite: @arbitrum.
og:type is article for every docs article, the section landing pages included. The site root
publishes its own title, description and card from app/(home)/page.tsx, built on siteTitle and
siteDescription in lib/shared.ts. There is one HTML homepage and no separate /docs index.
The root's card is
app/(home)/opengraph-image.tsx, Next's file convention. Both cards come from renderOgImage in
lib/og.tsx, so they cannot drift.
Do not add a title.template to app/layout.tsx. Keep page titles as written in frontmatter,
without an automatic suffix.
Every absolute URL in metadata, the sitemap and robots.txt comes from getSiteUrl() in
lib/shared.ts. The rule itself is resolveSiteUrl in lib/site-url.ts:
NEXT_PUBLIC_SITE_URLwhen set, and it must parse as an absolute URL or the call throws;- otherwise
http://localhost:3000; - except in a production build (
VERCEL_ENVorNEXT_PUBLIC_VERCEL_ENVisproduction), where a missing value throws.
NEXT_PUBLIC_* is inlined at build time, so without the throw a misconfigured production build
would ship every canonical pointing at localhost. next.config.ts imports the same module and calls
it first, which makes it the check that always fires. app/layout.tsx also calls it at module scope
for metadataBase. lib/shared.ts imports nothing heavier than that module, so the sitemap and
robots routes can use it without pulling in lib/source. scripts/lib/site-url.test.ts covers the
rule.
RequestUpdateLink is the one exception. It reads NEXT_PUBLIC_SITE_URL directly and falls back
to the site-relative path, because its URL goes into a GitHub issue a human reads.
Both are Next metadata routes. app/sitemap.ts emits one entry per page from source.getPages(),
plus /, with <lastmod> when a date is known. Partials and the glossary are outside the docs
collection, so there is nothing to exclude. app/robots.ts allows everything and adds
Content-Signal: search=yes, ai-input=yes, ai-train=no through a rule's other field.
Reusable _-prefixed fragments live in content/partials/, outside the docs collection, so they
can never be routed. They carry no frontmatter that matters: <include> strips it.
A page includes one with the root-anchored form, which survives the page moving:
<include cwd>content/partials/_hardware-requirements.mdx</include>Inside a partial, write the include file-relative (<include>../_x.mdx</include>), so the
fragment resolves from its own location. _reference-chain-parameters.mdx includes the generated
_reference-gas-target-partial.mdx this way.
The include is spliced in at build time, so its headings join the page's table of contents and
check-links validates its #anchors per including page. Root-absolute links (/section/page) written
inside a partial or a glossary entry are checked against the page index too, and reported against
the partial's own file and line; a relative link inside a partial is not, so write partial links
root-absolute. A missing include is reported as a finding with the including page and the line of
the <include>, and the other findings for that page still print. Writers find partials by
browsing content/partials/; there is no catalog.
Three partials are generated and carry a do-not-edit comment: content/partials/precompile-tables/
(pnpm precompiles:generate), _reference-arbitrum-contract-addresses-partial.mdx
(pnpm contracts:generate, from the @arbitrum/sdk network registry plus
scripts/data/contract-addresses.data.ts) and _reference-gas-target-partial.mdx
(pnpm gas-target:generate, from the snapshot scripts/data/gas-target.json that
pnpm gas-target:fetch reads from the ArbGasInfo precompile). Edit the generator or its data,
never the output. Every address is EIP-55 checksummed, because <AEL> throws on a bad checksum.
Values that move on a release cadence live in content/vars.json. content/vars.ts re-exports
the JSON with a VarKey type, so adding a key needs no code change. <Var name="…" /> renders a
value inline. A few keys are read by code, not MDX, and nothing complains about them.
pnpm lint (rule docs/unknown-var) fails when a <Var name> or {var:name} in content/ names
no key, because MDX is not type-checked and a missing key renders the string undefined.
<Var> does not evaluate inside a fenced block or an inline code span. The reader sees the
literal tag. Rule docs/var-in-code fails on it. When a command must be copy-pasteable,
hardcode the value in the code and put <Var> in the prose beside it. For latestNitroNodeImage,
pnpm nitro:check-release --to <tag> keeps those copies current, but only in files that carry the
marker {/* sync-with-var: latestNitroNodeImage */}. Never add the marker to a page that states a
Nitro version historically, such as an ArbOS release page, or a bump rewrites history.
A CommonMark link destination may not contain a space, so [text](…<Var name="x" />…) never
parses as a link. Write {var:name} in the destination instead:
[Interface](https://github.com/OffchainLabs/{var:nitroRepositorySlug}/blob/{var:nitroVersionTag}/precompiles/ArbSys.go)remarkVarLinks in lib/var-links.ts expands placeholders in link and image URLs, link titles,
definitions, and JSX href, to and src attributes. check-links expands them before it
resolves an internal /<slug> link. Two limits:
- A placeholder in prose fails the build, because MDX reads the braces as an expression.
- A local image path is imported before the plugin runs, so write it in full.
Rule docs/var-in-link fails on a <Var> in a destination or URL attribute, and on a placeholder
whose name is not an identifier. The plugin rereads vars.json on each compile and registers it
as a dependency of the page, so pnpm dev hot-reloads a placeholder after an edit.
docsRepositoryUrl and docsRepositoryBranch in content/vars.json are this repo's GitHub
identity. gitConfig in lib/shared.ts reads them for the edit link and the "Request an update"
link, and the contribute partials write their links home as
{var:docsRepositoryUrl}/blob/{var:docsRepositoryBranch}/…. lib/shared.ts imports the JSON with
with { type: 'json' } because tests import it under node --test.
scripts/lib/contribute-repo-links.test.ts asserts that the links in
_contribute-docs-partial.mdx and in the PR template match gitConfig, and that no .mdx file
writes a docs-repository URL in full. _know-more-tools-box-partial.mdx also uses the placeholders
and is covered only by that last check.
.github/pull_request_template.md stays hardcoded because GitHub renders it, not this site.
The canonical documentation branch is master, including after the Fumadocs migration.
Keep docsRepositoryBranch and the PR template's file links aimed at master; development
branches do not change this value.
The bar above the navbar is Fumadocs' Banner, rendered in app/layout.tsx before {children}.
Five vars.json keys drive it: announcementEnabled, announcementText, announcementLinkText,
announcementLinkHref and announcementId. Three constraints:
vars:checkrequiresannouncementLinkHrefto be anhttpsURL or a root-absolute path to a real page orpublic/file (scripts/lib/announcement-link.ts).check-linksreads MDX only.vars:checkrequiresannouncementIdto start with a letter and hold only letters, digits,-and_, since it becomes an element id and a selector. It is also the dismissal key inlocalStorage, so a new message needs a new id.- The bar's height is fixed (
3rem,4rembelow 640px) because it feeds the layout's sticky offsets. No gate couples it to the text; the budget is in README.
Paths into the Nitro repository live as nitroPathTo* keys in content/vars.json. The prefix is
what registers them: nitro:check-release verifies each one, plus every content link under
github.com/OffchainLabs/<nitroRepositorySlug>/blob/<nitroVersionTag>/, against the GitHub
contents API at the target tag before it writes anything (scripts/lib/nitro-upstream-paths.ts).
The NodeInterface pins for nitro-contracts stay in scripts/generate-precompile-tables.ts
because they point at a fixed commit.
When a release moves a directory:
pnpm nitro:check-release --to <tag>, locally or innitro-bump.yml, fails naming the pin or link that no longer resolves at the new tag.- Find the new path in the Nitro release diff.
- Edit the pin in
content/vars.json, or the link when a file moved but its directory did not. - Rerun
pnpm nitro:check-release --to <tag>when the move came with a release bump, or plainpnpm nitro:check-releasewhen it did not, thenpnpm precompiles:generateandpnpm cli:generate. - Open the PR by hand and run the gates locally, since the automated branch gets no CI run.
check-links skips external URLs, so this check is the only thing that sees these links, and it
runs only here, not in CI, because the gates stay offline.
content/glossary/*.mdx is a reference collection ({ id, title, sortAs? }, body is the
definition). lib/references.ts is the registry; the collection is dynamic, so an entry's body
comes from await entry.load() and Term and ReferenceList are async server components. <Term id="…">text</Term> renders the text with
the definition in a popover (components/HoverPopover, a non-modal dialog that opens on hover,
focus or click, so the links inside a definition are reachable by keyboard), rendered on the server
so each page ships only the definitions it cites. <ReferenceList collection="glossary" /> renders the whole
collection on the glossary page.
pnpm lint (rule docs/unknown-term) fails on an unknown id or an unknown
<Reference collection>; pnpm references:check fails on a duplicate id or an entry with no id
frontmatter. A <Term> inside a partial is allowed: the include is
spliced into the page at build time, so the server component renders in the page like any other.
Rule R3 now only forbids ESM-importing (import X from '…/_x.mdx') a partial that holds one,
because an imported partial can end up in a client module where a server component is illegal; no
component imports a partial today. (R3 used to forbid the tag in every partial, which cost thirteen
hovers on the run-a-node and launch-chain pages.) A new term is a
new file in content/glossary/. A new reference type is a new collection in source.config.ts
plus one registry entry.
components/mdx.tsx is the registry and the source of truth. It spreads Fumadocs' defaults (which
include Callout, Card, Cards and code blocks) and adds:
- Fumadocs'
Accordions,Tab,Tabs,Step,StepsandImageZoom. Markdown images render throughImageZoomtoo. Accordionfromcomponents/mdx/Accordion.tsx, Fumadocs' own with the panel force-mounted. Fumadocs unmounts a closed accordion panel and an inactive tab, so their text never reaches the server HTML that search engines index. The registry force-mounts both (using the localFindableTab.tsxwrapper for tabs).use-findable-panel.tssetshidden="until-found"after hydration and synchronously activates the panel on the browser'sbeforematchevent. Search reveals skip the accordion animation so the browser can immediately highlight and scroll to the match. Let the browser applycontent-visibilitythroughhidden="until-found"; an explicit closed-statecontent-visibility: hiddencan prevent Chromium from discovering the text when the hook adds the attribute. Browser tests exercise native text search and text-fragment reveal.Calloutis wrapped too: it getsrole="note"and, when it has no title, a visually hidden label naming its type, so the type is not conveyed by colour alone.AEL, an address explorer link (components/mdx/AddressExplorerLink.tsx).Term,ReferenceListandVarfromcomponents/mdx/.- Three widgets under
components/widgets/, each used by one page and each behind anext/dynamicboundary in itsindex.tsx:EdgeChallengeFlow(BoLD, client only),VendingMachine(the cupcake demo, which pulls in viem) and the node troubleshooting set (TroubleshootingChecklist,ChecklistItem,ConfigGuidance,TroubleshootingConfig,TroubleshootingReport).
Callouts are <Callout type="info|warn|error|idea|success" title="…">. Fumadocs also accepts
warning as a spelling of warn; write warn. A Docusaurus ::: line renders as text, and
pnpm lint fails on one.
The registry also overrides a: an internal link ending in .pdf renders as a plain anchor so
Next's <Link> does not prefetch the file. Every other link goes through Fumadocs' relative-link
resolver.
An internal link that source.getPageByHref resolves is wrapped in components/LinkPreview, a
hover card in the style of Wikipedia's page previews. The card opens on mouse hover after 400 ms
and closes on leave or Esc. It is one anchor that opens the previewed page, with its #hash, in a
new tab. Touch and (hover: none) devices get no card. A link that wraps a <Term> keeps the Term
popover and gets no card, and a link inside a glossary definition gets none either (the component
checks for the data-hover-popover and data-hover-popover-panel attributes that
components/HoverPopover sets). The two cards share a panel but differ on purpose: the Term card
has a left accent and a "Glossary" kicker and is a reading surface; the preview card has a
"Preview" kicker with an arrow, its title in the link colour, and a hover tint, because the whole
card is a link. The card shows the page title, its frontmatter description and the lead, muted.
For a #section link it shows the section heading and its text, with the page title as a caption.
The data is the structuredData export, read through await page.data.load() because the
collection is dynamic, which remarkStructureOptions in lib/mdx-options.ts limits to headings
and paragraphs. lib/preview-text.ts runs before Fumadocs' remarkStructure
and hands it the plain text of each one: a paragraph through the node.data.structuredData hook, a
heading through 'search'-namespaced replaceSource edits on its children, so a <Var> reads as
its value and markers and <Term> wrappers are gone. lib/link-preview.ts turns it into one JSON
per page: description (from frontmatter), lead (the text before the first heading) and
sections (the text under each heading id), the last two clipped to 350 characters.
app/preview/[...slug]/route.ts prerenders it at /preview/<slug>/preview.json; any other path
404s. The component fetches the JSON on the first hover and caches the promise in memory for the
session. A 404 or a failed fetch caches as no card. A page that opens with a heading has no
lead text, so its card shows the title and description only. Tables and code never contribute
text, so an excerpt can end on a colon.
Fumadocs' ImageZoom has no caption prop: its props are the image props plus zoomInProps and
rmiz. A caption is a sibling <figcaption> inside a <figure>, with the markdown image on its
own line between blank lines so MDX parses it as markdown rather than as JSX text:
<figure>

<figcaption>Nitro support windows</figcaption>
</figure>Nothing in the registry maps figure or figcaption; both pass through as HTML, and Fumadocs'
prose styles size and color the caption.
app/global.css caps an image inside .prose at 600px wide (max-width: min(100%, 600px)),
which restores the common case the Docusaurus img-600px class covered on 55 of the 64 sized
images without editing a page. An image marked data-wide escapes the cap and takes the full
column. There is no per-image width and no img-*px utility: the presets were dropped in
3a5cb1e0d on the claim that no stylesheet defined them, which was wrong (master's custom.css
did), and the cap is the replacement rather than a restoration, so that a writer never has to pick
a number. CONTRIBUTE has the writer's version.
EdgeChallengeFlow renders a committed snapshot, public/data/edge-challenge-flow.json, refreshed
by hand with pnpm edge-challenge:fetch from Arbitrum Sepolia. It has no --check mode because a
live source is never stale, only older.
lib/mdx-options.ts sets remarkImageOptions: { external: false }, so the build never requests a
third-party image. A local /img/… src is measured from public/, and a missing raster file fails
the build. A missing .svg is skipped silently by the remark image plugin, so check SVG paths by
eye. A markdown image with a remote src reaches Next's image component with no dimensions and
renders broken. Commit the file under public/img/, or wrap a plain <img>:
<ImageZoom>
<img src="https://…" alt="…" />
</ImageZoom>Rule docs/remote-image blocks the markdown form.
components/mdx.tsx is a server module that every docs page imports, so Next cannot split what it
references. Every client component it reaches statically lands in every docs page, and any plain
.css file one of them imports becomes a render-blocking stylesheet on all of them. That is why
each widget sits behind next/dynamic, and why shared component styles live in app/global.css
or as Tailwind utilities.
LinkPreview is a small client module that every docs page carries. It reuses @floating-ui/react,
which HoverPopover already ships. The preview JSON loads on hover only and is cached, so page
weight does not grow with the number of links.
A docs page loads three stylesheets. Check after any component change:
curl -s http://localhost:3000/stylus | grep -o '<link rel="stylesheet"' | wc -lUse grep -o … | wc -l, not grep -c: every stylesheet link sits on the document's first line.
scripts/static-docs-http.test.ts asserts the same count against the built site, so a fourth
stylesheet fails the build job.
Other things not to undo:
- The search dialog mounts on first open.
app/layout.tsxpassespreload: falseto Fumadocs' search options, socomponents/inkeep/inkeep-search.tsxloads the Inkeep bundle only when the dialog opens. Fumadocs defaults topreload: true, which would fetch the bundle on every page load. WhenNEXT_PUBLIC_INKEEP_API_KEYis unset, search is disabled; this site has no/api/searchfallback route. - The AI chat panel mounts in
components/ai/layout.tsx, whichapp/(docs)/layout.tsxloads throughnext/dynamiconly whenNEXT_PUBLIC_AI_CHAT_ENABLEDistrue. With the flag unset the layout and bundle have no chat panel code. The panel posts toapp/api/chat/route.ts, which returns 503 withoutINKEEP_API_KEY. Add a Vercel WAF rate-limit rule onPOST /api/chatbefore settingINKEEP_API_KEY: the route is live whenever the key is set, with or without the flag.lib/ai/chat-request.tschecks each request before it reaches Inkeep: origin,Content-Type, body size, message count and the text length of each user message. The origin check stops browsers only. The panel sends the last 10 messages. The search dialog's "Ask AI" closes the dialog and opens the panel with the query throughlib/ai/bridge.ts. With the panel mounted, the dialog hides cxkit's "Ask AI" card and remounts cxkit after the hand-off, so the next open shows search. The layout holds only the open state, theCtrl + /andEscapekeys, the "Ask AI" controls and the queue for a search query (components/ai/chat/open.tsx). It loads the chat (AIChatLazyPanelincomponents/ai/search.tsx, with AI SDK and the markdown renderer) throughnext/dynamicon the first open, so a page load downloads none of it. A query from search waits in the queue until the chat loads and sends it once. The files undercomponents/ai/chat/are vendored from@fumadocs/cli@1.7.3; update them by running that CLI again. Local edits to re-apply after an update:open.tsxis not vendored, andindex.tsximportsuseAIChatfrom it and no longer has the open state, the keyboard listener,useAIChatorAIChatTrigger. - Fonts are self-hosted under
public/fonts/and loaded withnext/font/localinapp/layout.tsx. Never addnext/font/google: it makes the build fetch from Google. Only the two upright Aeonik faces preload. The italic is its own declaration so it can skip preloading, and theem, i, …rule inapp/global.csspoints italic elements at it. JetBrains Mono (fenced code) is the latin subset with its licence beside it, and keeps a variable100 800weight so bold tokens stay bold. lucide-reactstays on the versionfumadocs-uiresolves (pnpm why lucide-react), or two copies ship.- The home hero image sets
preload,fetchPriority="high"andloading="eager"together on purpose.
@inkeep/cxkit-primitives would request the Inter face from Google Fonts at runtime once its chunk
loads, sending every visitor's IP to Google. The Inkeep config sets
theme: { disableLoadingDefaultFont: true }, so it does not; the widget renders in the site's own
font. The report-only Content Security Policy (see Response headers) carries
no Google Fonts entry, so if the option is ever dropped the request shows up as a CSP report.
app/global.css is the whole theme: it imports Tailwind, Fumadocs' neutral.css and
preset.css, then sets the tokens in a @theme block. There is no other stylesheet on a plain docs
page apart from the two Fumadocs ships, and PostCSS is configured in package.json (a
postcss.config.ts is ignored silently and Tailwind stops compiling).
- Two token families.
--color-arbitrum-*(navy, blue, cyan, stone, gray, dark-gray, grey) is the raw arbitrum.io palette and is the same in both themes; never remap one in.dark.--color-fd-*is the Fumadocs theme built on it, and everyfdtoken set in@themeis set again in.dark. Components usefdtokens, not palette tokens, so a colour change lands in both themes.--ifm-*is Docusaurus and does not exist here. - The sidebar override. Fumadocs'
neutral.csssets--color-fd-muted,--color-fd-secondaryand--color-fd-muted-foregroundon.dark #nd-sidebar, which outranks.dark.global.csscarries a.dark #nd-sidebarblock that maps those three back to the brand values; without it the dark sidebar is Fumadocs grey and a.darkedit never reaches it. - Breakpoints. Components use Tailwind's
sm,md,lg,xland theirmax-*forms, and so doesglobal.css(max-md:for the prose type scale,max-xl:for the navbar). The one custom variant isnav-sm(max-width: 1104px), the width at which the navbar links stop fitting on one line; Tailwind has no step there. - Utilities.
bg-repeating-linesdraws the hairlines on the home and 404 pages;hex-clipis the hexagon bullet the navbar links carry.small-tableis not a utility but a class the.proseblock styles (<table className="small-table">), for dense tables such as the node troubleshooting page. - Tokens beyond colour.
--shadow-button-glow,--shadow-card-hoverand--drop-shadow-hex-gloware the three shadows;--font-displayis FK Screamer for the home hero. The status pair--color-fd-successand--color-fd-danger, and the hues the BoLD widget uses, live in@themetoo, so a widget never carries a colour literal. - Sidebar contrast. In light mode the active sidebar item carries a 5% primary tint so its label clears 4.5:1; the dark side needs none.
- Prose. The
.proseblock applies the arbitrum.io type scale, list markers, blockquote, table and inline-code styles on top of Fumadocs' typography plugin. It is unlayered so it beats the plugin, and every selector is wrapped in:where()so its specificity stays at one class and a page can still override it. Image sizing is covered under Image sizing. A heading inside anot-proseisland (the accordion trigger is anh3) drops the scale. - Focus. A global
:focus-visiblerule draws the ring in--color-fd-ring(blue in light, cyan in dark, both over 3:1 on their background). Never remove an outline without a replacement. - The navbar selectors are structural.
:is(#nd-subnav, #nd-nav) [data-header-body]and the> div:first-child,[data-search-full]and> div:last-child > div:first-childrules under it restyle Fumadocs' notebook header by its DOM shape (node_modules/fumadocs-ui/dist/layouts/notebook/slots/header.js), andcomponents/home-header.tsxreproduces that shape so the home page gets the same navbar. Afumadocs-uirelease that changes the header DOM breaks the layout silently;fumadocs-uiis pinned to an exact version for that reason, and the navbar is the first thing to check after a bump, at 1280px, 1104px and phone width.
Every redirect lives in redirects.config.ts, consumed by next.config.ts. Next compiles them into
the routes manifest Vercel reads, so there is no vercel.json. Adding one would shadow this file,
because Vercel applies vercel.json routes first. Nothing generates the list at build time: it is
a hand-maintained array, and move-doc is the only tool that writes to it.
The file has two blocks:
- Between the
AUTO-GENERATEDmarkers, one entry per moved page, appended bypnpm move-doc. Never hand-edit between the markers. - After them, hand-maintained entries for genuine legacy aliases, retired pages and assets.
Unchanged paths serve directly, with no redirect merely to add a
/docsprefix.
Permanence. An entry is permanent: true (308) when its source was a canonical page route on
docs.arbitrum.io (one of the page routes in scripts/data/master-routes.json), a file master
published at that URL, or a URL this site itself used to serve (every move-doc entry): that URL is
in search indexes and inbound links and will never serve a page again. /welcome/get-started
also keeps the 308 master gave it. Every other legacy entry is
permanent: false (307): those URLs were already redirects on master, and a temporary answer keeps
the mapping free to change. The header comment in redirects.config.ts states the rule; keep to it
when adding an entry.
The .md twins. next.config.ts derives a .md twin for each alias whose destination is a
documentation page (lib/markdown-redirects.ts): a second redirect from <source>.md to
<destination>.md. External destinations and public assets get no twin, nor do pattern sources and
sources that already end in .md. The root overview is served directly at /index.md. The twins
are derived in redirects(), never written to the file, and the redirect test checks the combined
list. Markdown requests still run the tracking proxy once before the rewrite.
Route compaction. After deriving the twins, next.config.ts calls
lib/compact-redirects.ts to group literal aliases with the same destination and permanence into
one exact regex alternative per group. This reduces the custom route count that Next scans on
each request, while preserving every HTML and markdown URL, fragment and status code. Pattern
routes stay in place, and groups are split before reaching Next's compiled regex length limit.
Keep editing the individual entries in redirects.config.ts; the grouping happens in memory.
pnpm move-doc <from> <to> rewrites every internal link that resolves to the page (keeping each
link's written form), moves the file with git mv, re-bases its relative links, updates
meta.json, and appends the redirect. --dry-run prints the changes. It touches no other redirect.
When an older entry pointed at the old URL, or a page moves back to a URL an earlier move
redirected away, scripts/lib/redirects-config.test.ts fails and names the entry to fix by hand.
That test asserts that every internal destination names a page under content/docs
(case-sensitively), a file under public/ or a markdown mirror, that no source is a live page, that
nothing chains or loops, and that no source is listed twice; it runs over the hand-written list and
the derived .md twins together, and every pattern entry (today, the audit-report PDFs) needs a
dedicated test of its own. Never hand-write a .md entry: the twin is derived. The 19 legacy
features/ sources point at the chain-config/** and extend-the-protocol/** pages that replaced
the retired decision pages; two point at the Arbitrum Foundation's AEP fee calculator, as master's
own redirects did.
scripts/lib/master-routes.test.ts covers the entries that are missing.
scripts/data/master-routes.json is a frozen list of the 294 page routes docs.arbitrum.io served
before the cutover, and the test fails when one of them is neither a page under content/docs nor
matched by a redirect source. Never regenerate the list from a later master; if master publishes a
page before the cutover, add its route by hand, as was done for
/notices/glamsterdam-sepolia-notice.
Choosing a legacy destination, in order:
- A destination verified by hand, by comparing the old page's title with the candidates.
- If the same path names a live page, no redirect is needed.
- A whole-section rename, such as
/run-arbitrum-nodeto/run-a-node. - The one local page with the same title.
- The one local page with the same basename, when the basename was unique upstream too.
- The nearest section landing, for a page that was never ported.
One entry falls under rule 6 and carries a comment: upstream's "Sequencer" page. Porting that page means retargeting its entry by hand. A redirect to a wrong page that exists is worse than a 404, and no test can see it, so decline rather than guess. Never add a source that names a live route: Next's redirects run before routes.
Deleting a page is not a move. Write its redirect by hand in the same commit. A page that the
Docusaurus site served is in scripts/data/master-routes.json, and pnpm test fails until its URL
is a page or a redirect source again.
URLs the Docusaurus site served that this site does not, with the reason, and the redirect where one exists. The list comes from the September 2026 review's sweep of 1,609 master-era URLs.
- Root
.mdmirrors (/<slug>.md, about 300 URLs master told readers to fetch). Served directly:next.config.tsrewrites/<slug>.mdand/index.mdonto the mirror route, and derives a.mdtwin for every alias inredirects.config.tswhose destination is a page, so the "append.md" habit still works. Routing uses rewrites and redirects; the tracking proxy still runs once for the markdown request and records a PostHog event in production. - Audit-report PDFs at
/assets/files/<name>-<hash>.pdf(47 files). Redirected by one pattern entry to/audit-reports/<name>.pdfunderpublic/. /category/best-practices,/category/troubleshooting, Docusaurus generated-index pages for the Stylus section, and/blog, an empty listing. Redirected./img/haw-transaction-lifecycle.png. The diagram is an SVG now; redirected to it./glossary.json. It fed the Docusaurus Quicklooks component. The glossary is rendered on the server here (<Term>,<ReferenceList>), so there is no derived file and no redirect. Nothing in this repo reads it; whether anything outside did is not known.- Six
/*-faqs.jsonfiles (bridging,building,building-orbit,building-stylus,get-started,node-running). They fed the FAQ structured-data component, whicha429f7004removed with its gate. No redirect. - 234 partial routes such as
/arbitrum-essentials/bridging/partials/_token-compatibility. Docusaurus routed every partial because master'sexcludereplaced the default underscore exclusion, and master itself classed them as non-canonical and kept them out of its sitemap andllms.txt. Partials live outside the docs collection here and cannot be routed. No redirect. - The three
.otfand.wofffonts under/fonts/. Renamed to the.woff2files inpublic/fonts/; no page links a font file. /.nojekyll(a GitHub Pages artefact) and/img/devdocs.png(only ever thetwitter:imageof the old site, pointing atdeveloper.arbitrum.io). Not carried over./img/haw-token-gateway.svg, a 6.5 MB raster wrapped in SVG that no page referenced. Not carried over.- The "Request an update" auto-close. The old issue template promised that a non-docs issue
would be closed with a templated reply; the
check-undocumented-issues.ymlworkflow that did it was not carried over, and the template no longer says so. /llms.txtchanged shape. It carries a title, a summary and one.mdlink per page, rather than master's list. Consumers that parsed the old shape need updating.
There is one locale and no [lang] segment. Pages live under content/docs/ and serve at
/<slug>, preserving existing root-level article URLs. app/(docs)/[...slug]/page.tsx uses a
URL-neutral route group and a non-optional catch-all, leaving / to app/(home)/page.tsx.
docsRoute is an empty prefix for links; the loader uses / as its baseUrl. Avoid joining /
with a path that already starts with /, which would create a protocol-relative URL.
Markdown mirrors. app/llms.mdx/docs/[[...slug]]/route.ts serves every page as markdown at
/llms.mdx/docs/<slug>/content.md, as text/markdown; charset=utf-8. next.config.ts rewrites
/<slug>.md (and /index.md) onto it after checking static routes and public files. There is no
Accept header negotiation: an article URL always serves HTML. content/docs/index.mdx provides
the root markdown overview; it is excluded from the sidebar and the article route's static
parameters. The sitemap lists / only once. /og/docs and /llms.mdx/docs remain machine
endpoints. /llms.txt lists every page with its title, summary and .md link, and
/llms-full.txt concatenates them; lib/llms-index.ts shapes the index (master's title and
summary, one .md link per page). The text the mirrors serve is the authored source, sliced by
Fumadocs' remarkLLMs (through includeProcessedMarkdown in source.config.ts) with the edits the
site's plugins record through replaceSource. remarkLlmsMarkdown in lib/llms-markdown.ts
records one for each component: <Var> becomes its value, <Term> and the other wrappers become
their children, <Callout> a blockquote opening with its title or type, <Accordion> and <Tab>
a level-4 heading and body, <Cards> a link list, <AEL> an explorer link, <ImageZoom> a
markdown image, and a self-closing widget nothing. remarkVarLinks records the expanded link
destination and remarkStripMdxComments the removed comment, because a plugin that only mutates
or removes a node leaves the mirror as authored. Code is never touched, an unknown <Var> keeps
its tag so the typo stays visible, and the rendered page is unaffected because the edits live
beside the tree. The HTTP smoke suite asserts that no component tag or {var:} placeholder reaches
/llms-full.txt or a mirror outside code.
/preview/<slug>/preview.json is a third machine endpoint. It is static, proxy.ts does not track
it, and lib/http-headers.ts gives it no CORS header.
headers() in next.config.ts is the one place response headers are set, and poweredByHeader
is off.
- Every route:
X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy(camera, microphone and geolocation off;clipboard-writestays at its default because the Copy Markdown button needs it),X-Frame-Options: DENY, and aContent-Security-Policy-Report-Only. The rules live inlib/http-headers.ts, which imports nothing so the tests can read it. The CSP is report-only because pages are prerendered, so a nonce is impossible andscript-srcandstyle-srcneed'unsafe-inline'; before enforcing it, add a report endpoint and watch a week of reports. HSTS is left to Vercel, which already sends it on the custom domain. /: aLinkheader advertising/llms.txt(rel="service-doc") and the sitemap, as the Docusaurus site did./llms.txt,/llms-full.txtand the markdown routes:Access-Control-Allow-Origin: *, so a browser-side agent can fetch them, as it could from the Docusaurus site.
/.well-known/ holds the MCP discovery card, public/.well-known/mcp/server-card.json. It
advertises the Inkeep MCP server over these docs, the same service behind search. Nothing
regenerates it; the HTTP smoke suite checks that it is served.
proxy.ts records a PostHog llms_file_fetched event for /llms.txt, /llms-full.txt,
/<slug>.md, /index.md and the /llms.mdx/ mirror. Its config.matcher selects markdown and
LLM requests; HTML navigation does not run the proxy.
- Production only: it checks
VERCEL_ENV === 'production', so nothing fires locally or on a preview, and no key is needed to run the app. - Both markdown shapes count as
/<slug>.md(or/index.mdfor the root), so one page is one series. The matcher sees the request before the rewrite, and a rewrite does not re-enter the proxy, so each request counts once. - Each event gets a random
distinct_idand$process_person_profile: false. The series counts fetches and bot categories, not readers. No IP address is read, anduser-agentandrefererare truncated to 512 characters before they are forwarded, since a client controls both. $current_urlusesgetSiteUrl(), so the*.vercel.appalias does not split a page into two series.- The capture goes through
event.waitUntil()on theNextFetchEvent, so the response is never delayed. Do not swap inwaitUntilfrom@vercel/functions, which reads a request context Next 16 does not set and drops the promise silently. A non-OK PostHog response is logged, sincefetchresolves on HTTP errors.
Classification lives in lib/llms-tracking.ts, which imports only lib/shared.ts, so
scripts/lib/llms-tracking.test.ts tests the real module.
app/(docs)/[...slug]/page.tsx exports generateStaticParams() over the nonempty slugs from
source.generateParams() and dynamicParams = false. Every article is prerendered at build,
and any other slug gets
the prerendered 404 page from app/not-found.tsx with status 404, without rendering the docs page.
The og/ and llms.mdx/ routes prerender one entry per page as well, and both export
dynamicParams = false, so an unknown slug answers 404 without rendering and without writing an
entry to the ISR cache. The OG route also answers 404 unless the last segment is image.png.
Without those two lines any junk URL under either route rendered (a fresh Satori PNG on the OG
route) and was cached for a year, 404s included.
Docs pages serve a Cache-Control header carrying an s-maxage directive; the value is Next's
own, and the smoke suite asserts the directive, not the number. The 404 keeps Next's no-store
directives.
Two consequences to accept:
- A page added without a rebuild answers 404. A failed build keeps new pages offline while existing pages keep serving the last good build.
- Anything that makes the route dynamic, such as reading
searchParamsin the page, breaks the 404. Next would then rendernotFound()mid-stream and replace the body with an empty error shell that still returns 404. Enabling Cache Components removesdynamicParamsand has the same effect.
To check the 404 by hand:
pnpm build && pnpm start
curl -sS -D - -o body.html http://localhost:3000/does-not-existStrip <script> blocks before grepping the body: a 200 page also carries the 404 copy in its
flight payload. scripts/static-docs-http.test.ts asserts this against a running build.
Five paths send events to one PostHog project. They share only the project token and the ingest host.
| Path | Where | Runs |
|---|---|---|
Page feedback (docs_feedback) |
lib/posthog.ts, a server action |
everywhere |
Web analytics ($pageview) |
components/analytics/posthog-provider.tsx, client |
production only |
Inkeep search (inkeep_*) |
lib/inkeep.ts, through the same client |
production only |
AI chat panel (inkeep_*) |
lib/ai/events.ts, through the same client |
production only |
Markdown fetches (llms_file_fetched) |
proxy.ts, server |
production only |
The client gate is NEXT_PUBLIC_VERCEL_ENV === 'production' plus a key; Vercel sets that
variable. The client runs cookieless and in memory, with no session recording or feature flags, and
a pinned defaults date so an SDK upgrade cannot change what is captured. The 404 page reports 404_error through
components/analytics/not-found-tracker.tsx, which retries until the client is ready and snapshots
the URL at mount. Those events show inbound URLs the redirect map misses.
The AI chat panel sends inkeep_user_message_submitted, inkeep_assistant_message_received and
inkeep_assistant_source_item_clicked from lib/ai/events.ts. The two message events set
component_type to ChatButton or SearchBar. A source click always sets ChatButton and adds
source_link. It never sends message text.
NEXT_PUBLIC_POSTHOG_KEY is the publishable, write-only phc_ token. Page feedback posts
server-side, so it works locally with the key set.
Every script, test, data table and root config is TypeScript that Node 22.18 or later runs
directly (node scripts/x.ts). There is no tsx, ts-node, .mjs or .js.
package.jsonhas"type": "module".tsconfig.jsonsetserasableSyntaxOnly(no enums, namespaces or parameter properties),verbatimModuleSyntax(a type-only import saysimport type) andallowImportingTsExtensions.- A relative import in any file Node runs carries its
.tsextension. tsconfig.jsonincludes**/*.ts, sotypes:checktype-checks every script.- PostCSS is configured in the
postcsskey ofpackage.json. Next ignores apostcss.config.tssilently, and Tailwind would stop compiling.
Node 22 is stated in three places that must agree: engines.node, the node-version key in each
workflow that sets up Node (ci.yml, upstream-refresh.yml and nitro-bump.yml), and the Vercel
project's Node.js Version setting, which is set by hand.
Never bypass engines.
Dependencies in package.json take caret ranges; pnpm-lock.yaml decides what installs, and CI
installs with --frozen-lockfile. fumadocs-ui and fumadocs-core stay exact, because the navbar
CSS depends on Fumadocs' header DOM (see Theme).
.github/workflows/ci.yml runs on pushes and PRs to main, master and fumadocs, in two jobs.
Gates, in order:
| Step | What it proves |
|---|---|
audit |
No advisory in pnpm-lock.yaml beyond those listed under auditConfig.ignoreGhsas in pnpm-workspace.yaml |
types:check |
.source/ regenerates, generated Next types, and tsc over the project. Not the frontmatter |
test |
Every scripts/**/*.test.ts and eslint/**/*.test.ts suite: the content rules on their fixtures, the sidebar and redirect tests, the frozen master-route fixture (scripts/data/master-routes.json), no 'use client' module reaching lib/source, no next/font/google import |
vars:check |
The announcement banner keys in content/vars.json are valid |
references:check |
Every glossary entry has an id, ids are unique, and no partial holding a <Term> is ESM-imported |
contracts:check |
The contract-address partial matches @arbitrum/sdk and its data file |
gas-target:check |
The gas-target partial matches its scripts/data/gas-target.json snapshot; offline, CI never calls an RPC |
faq:check |
The six FAQ partials match their content/faq/*.json snapshots |
check-links |
Internal links and their #fragments resolve in pages, partials and glossary entries, using the real MDX transforms; a missing include is a finding with a line |
lint |
ESLint over content/: MDX that compiles but renders wrong, every <Var> and <Term> resolving, every page under content/docs satisfying the page schema in lib/page-schema.ts (below) |
format:check |
Prettier |
audit asks the npm registry, so an advisory published today fails every PR, including one that
touched no dependency. First bump the direct dependency that brings the package in. If a parent
still pins a vulnerable version, add an overrides entry in pnpm-workspace.yaml and name that
parent in its comment. If no patched release exists, add the GHSA id to auditConfig.ignoreGhsas
with the reason. Drop an override once pnpm why <package> shows every parent allows the fix.
Build and HTTP smoke tests runs pnpm build (which runs format:check and check-links
first), starts the server, and runs scripts/static-docs-http.test.ts. That suite checks what only
a built site shows: prerendered pages, markdown URLs, the 404 shape, mirrors free of MDX comments
and of <Var>, <Term> and <Callout> tags, the stylesheet count on a plain docs page, the MCP
card, home and docs metadata, and the contribute guide's links home. The build also catches a page
that compiles but throws at prerender. Nothing in the build reaches the network after
pnpm install.
The same job installs Chromium and runs pnpm test:browser against the server at localhost.
tests/browser/findable-panels.test.ts checks that native text search discovers inactive tab
and closed accordion content, then uses text fragments to trigger trusted beforematch events
and verifies that the matched content becomes visible. It also simulates repeated beforematch
events and checks manual tab and accordion controls. It exercises the built components and
styles, without automating the browser's native Find UI. See Commands for local setup.
merge-controlled.yml fails a PR while it carries the merge-controlled label. Which checks block
a merge is decided by branch rules on GitHub, not by these files: until an admin makes it a
required status check, a red run is advisory. sbom-export.yaml generates a CycloneDX SBOM with
cdxgen from pnpm-lock.yaml on pushes to master and weekly, and uploads it to the
organisation's Dependency-Track; it gates nothing, it skips itself when the Dependency-Track
secrets are absent, and it is the Docusaurus repo's workflow restored for pnpm.
There is no pre-commit hook. Run the gates yourself before you push.
pnpm lint is ESLint 10 with one local plugin, docs (eslint/plugin.ts), configured in
eslint.config.ts for every .mdx and .md file under content/. There is no parser package in
between: eslint/language.ts is an ESLint language plugin, built the way @eslint/markdown builds
its own, that parses a file with the MDX, GFM, math and frontmatter micromark extensions, the same
tree @mdx-js/mdx gives the build. A rule is a file under eslint/rules/ that visits mdast node
types; eslint/shared.ts gives the text rules the source with every fence, inline code span and MDX
comment blanked, so an example inside code is never reported, and gives the component rules every
JSX element with its paragraph and heading context, real JSX inside {expressions} included. The
one rule that looks inside code is var-in-code. A file that does not parse is one fatal finding
with its line. The editor's ESLint integration reports the same findings while you type. There is
no inline disable comment for these rules: fix the finding.
Per-file rules live here. What needs the whole repository stays a script: check-links (link
targets and compiled anchors), references:check (ids present and unique, no imported partial with
a term), vars:check (the banner keys in JSON) and the sidebar test. ESLint lints one file at a
time, and the anchor check compiles every page.
| Rule | Catches |
|---|---|
frontmatter-schema |
A page under content/docs whose frontmatter breaks lib/page-schema.ts; one finding per field |
unknown-var |
A <Var name> or {var:name} that names no key in content/vars.json, a <Var> split across lines included; a <Var> with no static name |
unknown-term |
A <Term id> with no glossary entry, or a <Reference> to an unregistered collection |
docusaurus-directive |
A :::note line, which renders as literal colons |
docusaurus-var-token |
A Docusaurus @@name@@ or @@name=value@@ token outside code, which renders as the literal token |
quicklook-anchor |
A Docusaurus <a data-quicklook-from="…"> anchor, which renders with no href and no hover; write <Term> |
esm-in-page |
A top-level import or export; pages compile on demand, where Fumadocs supports neither (a @site/ or @theme/ alias gets a hint) |
unknown-component |
A capitalised JSX tag that is neither in components/mdx.tsx nor a Fumadocs default, which throws at render |
callout-type |
A <Callout type> outside info, warn, error, idea, success |
markdown-in-title |
Markdown (**, backticks, a link) in a <Callout title>, where JSX attributes print it literally |
block-component-in-paragraph |
A one-line <Callout> glued to the paragraph after it, which renders <div> inside <p> and breaks hydration |
tabs-null-default |
defaultValue={null} on <Tabs>, which selects no tab so every panel is hidden on load |
var-in-code |
<Var> inside a fence or inline code, which renders as a literal tag |
var-in-link |
<Var> in a link destination or URL attribute, or a malformed {var:…} placeholder |
link-in-heading |
A link or bare URL in a heading, which nests <a> in <a> and breaks hydration |
tr-in-table |
<tr> directly in <table>, where the browser inserts a <tbody> and hydration breaks |
remote-image |
A markdown image with a remote src, which renders broken |
scripts/lib/strip-code.ts is the "ignore code" scanner for the scripts that read MDX as text
(check-links, move-doc); the ESLint rules take the same regions from the parsed tree.
Only faq:check and gas-target:check are also CI gates. Four workflows run some of them (below):
| Command | Does |
|---|---|
pnpm move-doc <from> <to> |
Moves a page, see Redirects |
pnpm nitro:check-release [--to <tag>] |
Reports a newer Nitro release and a stale go-ethereum submodule pin, verifies every nitroPathTo* pin and Nitro source link at the pinned tag, writes nothing; --to <tag> is the only writer: it bumps the pinned release, the node image and the marked image tags to that tag, or repairs the submodule pin at the pinned tag |
pnpm precompiles:generate / :check |
Precompile tables from the pinned Nitro refs (fetches from GitHub); upstream doc comments, signatures and event names are escaped, never interpolated as MDX |
pnpm contracts:generate |
The contract-address partial |
pnpm cli:generate / :check |
The Nitro CLI flags page from the pinned tag's Go source |
pnpm stylus:generate / :check |
The Stylus by Example pages from offchainlabs/stylus-by-example at the commit pinned as repoRef in scripts/data/stylus-examples.data.ts (fetches from GitHub, or reads --source-path) |
pnpm edge-challenge:fetch |
The BoLD challenge snapshot from Arbitrum Sepolia |
pnpm faq:fetch |
The FAQ snapshots in content/faq/ from the Notion "FAQ CMS" database (needs NOTION_TOKEN) |
pnpm faq:generate / :check |
The six _troubleshooting-*-partial.mdx partials from the snapshots; offline |
pnpm gas-target:fetch |
The gas-target snapshot scripts/data/gas-target.json from the ArbGasInfo precompile on Arbitrum One, Nova and Sepolia (three public RPCs, no credentials) |
pnpm gas-target:generate / :check |
The _reference-gas-target-partial.mdx partial from the snapshot; offline |
upstream-refresh.yml runs nitro:check-release and precompiles:generate every Monday at 08:00
UTC. It never writes content/vars.json. It opens automated/upstream-refresh as a maintenance
PR when the tables changed, and one "Nitro vX.Y.Z is available" issue per newer release, with the
bump checklist. A stale submodule pin is only reported in the job log; a human repairs it with
pnpm nitro:check-release --to <pinned tag>. nitro-bump.yml is a workflow_dispatch that takes
the agreed version, runs nitro:check-release --to <tag> plus both generators, and opens
automated/nitro-bump as a PR. A PR opened with GITHUB_TOKEN triggers no CI run, so review
either PR's diff and run the gates locally.
faq-refresh.yml runs faq:fetch and faq:generate every Monday at 08:00 UTC with the
NOTION_TOKEN repository secret, and opens automated/faq-refresh as a PR when a snapshot or a
partial changed. It is the only place the token is used; faq:check in CI is offline.
gas-target-refresh.yml runs gas-target:fetch and gas-target:generate every Monday at
08:00 UTC against the three public RPCs, and opens automated/gas-target-refresh as a PR when the
snapshot or the partial changed. It is the only place an RPC is called; gas-target:check in CI
is offline.
content/docs/run-a-node/nitro/cli-flags-reference.mdxis written bypnpm cli:generatefrom the Nitro source atnitroVersionTag, including its go-ethereum submodule. Only the region between{/* GENERATED:START */}and{/* GENERATED:END */}is replaced, so frontmatter and surrounding prose survive. Flags whose default is not a static value are declared inscripts/data/nitro-cli-reference.data.ts; anything else the reader cannot evaluate fails the run, as does a default holding a backslash before a pipe, which has no safe spelling in a table code cell. The fix for either is a hand-declared default indefaultOverridesin that file. Flag names must use dotted alphanumeric, hyphen or underscore segments. The generated region must passassertInertMdxbefore formatting and before writing, so upstream names cannot become expressions in headings or JSX attributes. The Nitro ref must be a release tag.content/docs/stylus/stylus-by-example/is republished whole, frontmatter included, bypnpm stylus:generate. Each page carries a do-not-edit comment, so fix those pages upstream. The published set is the allowlist inscripts/data/stylus-examples.data.ts, whose order is the sidebar order and follows upstream's teaching sequence. The parentmeta.jsonis hand-owned. Upstream is fetched at the commit pinned asrepoRefin that data file, bumped by a reviewed one-line diff. Upstream'smetadataexport is parsed, never evaluated. The rest of each page is published as MDX, which the build runs, so after building each page the generator parses it with@mdx-js/mdx(assertInertMdxinscripts/lib/generated-partial.ts) and refuses a body holding anyimport,export,{…}expression or JSX element other than its own<include>, naming the page and line; comment-only{/* */}expressions pass, and a symlinked upstream file is refused. Without that check an upstreamexportwould run at build with the Vercel environment and in every reader's browser. A relative link to a slug this site does not publish stops the run.stylus:checkfetches upstream by default;--source-pathuses a local clone containing the pinned commit. It is not a CI gate.- The precompile tables escape upstream Solidity and Go text (signatures, doc comments, event
names) before it lands in JSX, and each generated partial must pass the same
assertInertMdx: no executable expression, and no element other than the table elements. content/docs/run-a-node/nitro/cli-flags-reference.mdx's code cells escape|and refuse a\|in a flag default with a named error, since that sequence would end the code span and put upstream text into live MDX.content/partials/_troubleshooting-{users,nodes,building,bridging,arbitrum-chain,stylus}-partial.mdxare written bypnpm faq:generatefromcontent/faq/<key>.json, whichpnpm faq:fetchreads from the Notion "FAQ CMS" database: rows that arePublishableand4 - Continuously publishing, routed byTarget document slugsand ordered byFAQ order index. The mapping islib/faq-pages.ts. Edit a question in Notion, never in the partial;faq:checkfails on a hand edit. Answers carry literal values, no<Var>, and no<Term>. A block the renderer does not support, a Notion link, an unsupported link protocol, or a link to no page fails the fetch and names the Notion page. Inline-code delimiters are longer than embedded backtick runs, and link destinations encode Markdown and MDX delimiters. A table code value containing a backslash directly before a pipe is rejected during the fetch, including when Notion splits the value across adjacent code items: GFM cannot preserve that sequence in a table code span. Put that example outside the table. Before formatting and again before writing or checking a partial,assertInertMdxrejects executable MDX and permits only the renderer's<Callout type="info">elements with that single literal attribute. Rejected content leaves the destination partial untouched; fix the answer in Notion or put the example in code. The same snapshots feed theFAQPageJSON-LD thatlib/faq.tsemits on those six pages.
Generated meta.json files are not formatted: .prettierignore excludes content/**/meta.json,
and the generators write them with format: false.
- Rendering.
types:checkchecks TypeScript;pnpm lintvalidates page metadata and MDX shape. Neither proves the render. Open changed pages onhttp://localhost:3000, not127.0.0.1, where React does not hydrate. - Runtime anchors.
check-linksvalidates compiled heading ids but runs no components. Click changed anchors and confirm the target is visible under the sticky header. - A redirect to the wrong page that exists.
- A page added since the cutover and deleted without a redirect.
master-routes.test.tsknows only the routes master published; for a newer page, every gate passes while its URL starts to 404. - A remote image that has rotted. Nothing requests third-party images.
Three gaps earlier versions of this list named are now tests: a 'use client' module reaching
lib/source through its imports, a next/font/google import, and a fourth stylesheet on a docs
page (the HTTP smoke suite counts them).
Contributors can use Claude, Codex, or both. Shared agent files live under .claude/,
.agents/, and .codex/. Their paths can also be referenced by contributor-local
configuration that is outside this repository.
Keep existing skill, hook, asset, and environment files when updating agent guidance. An obsolete path or a missing tracked consumer does not establish that a file is unused. Retiring a file requires an explicit migration for the local setups that use it. Keeping a Claude implementation does not replace a Codex hook invocation, or vice versa.
The duplicated writer and brand skill instructions should stay in sync across .claude/skills/
and .agents/skills/. The retained Codex hook configuration and scripts still contain legacy
paths; contributors must check those paths against their local integration. CI does not run
these agent hooks, and the repository does not establish which local agent loads them.
The retained hook commands invoke installed node_modules/.bin tools directly; missing tools fail
without fetching a package from the registry. The image-debt scanner keeps filenames separate from
validated numeric sizes. The brand SVG importer validates scene fields before producing XML, and
the contrast checker uses a private temporary directory. The PGA example uses a fresh directory
by default and rejects symlinked output files and directories before writing or removing frames.