You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every other entry in NAV_SECTIONS is Title Case: API Routes, Backend-Only Mode, Metadata Routes, Rate Limiting, Server-Side Rendering, Streaming & Suspense, Task (Async Data), and so on.
The same two pages are ALSO internally inconsistent about it. Their metadata.title is Title Case ('Build Your Own Authentication | WebJs', 'Auth Providers (createAuth) | WebJs') while their <h1> and nav label are sentence case, so one page carries both conventions.
This came from #1103, which retitled the pair to stop them sharing a metadata.title and contradicting each other. The rename itself was the point and is correct. The casing was not considered.
The reason it is worth a ticket rather than a drive-by fix: website/test/ssr/docs-links.test.ts:176 (a doc page h1 matches its sidebar label) asserts the <h1> and the nav label are byte-equal for these two slugs. So the two strings can no longer drift apart, but they also cannot be changed independently. Whichever way this is resolved, all four strings move together or the test reds.
Decision
The two labels become Auth Providers (createAuth) (for /docs/auth) and Build Your Own Authentication (for /docs/authentication), and the convention written down is Title Case for every docs nav label and section title, with code identifiers kept verbatim.
Two independent lines of evidence settled it, both measured rather than assumed.
Internal survey. Across all 44 entries of NAV_SECTIONS (website/app/docs/layout.ts:27-107), the list is not mixed. It is Title Case with exactly two exceptions. 24 multi-word entries are unambiguously Title Case (AI-First Development, No-Build Model, Runtime (Node & Bun), Lifecycle Hooks, Data Fetching, Server-Side Rendering, Progressive Enhancement, Streaming & Suspense, Loading States, Error Handling, Client Router, Server Actions, API Routes, Database (Drizzle), Backend-Only Mode, File Storage, Rate Limiting, Metadata Routes, Reactive Controllers, Context Protocol, Task (Async Data), Lazy Loading, Editor Setup (Neovim, VS Code), Conventions & AI Workflow), 16 are single words and casing-neutral, Migrating from Next.js reads identically under either convention, @webjsdev/ui (AI-first) is a package name and exempt, and the remaining 2 are the auth pair. All 6 section titles are Title Case as well (Getting Started, Core Concepts, Data & Backend, Infrastructure, Component Library, Advanced). Since only two entries deviate, the scope is those two entries, not a list-wide normalization, and there is nothing else in the list to normalize.
External survey, 11 framework docs sites. Title Case leads roughly 8 to 2. Title Case: Next.js (88% of multi-word nav_title values, 56 of 63), Rails (100% of 83 guide names), Vite, Remix v2, Remix v3, Nuxt, TanStack Router and Start, Bun, shadcn. Sentence case: Tailwind (100% of 196 entries, zero Title Case) and Svelte. Qwik has no written rule and ships both API Reference and API reference in one sidebar, which is the cautionary case for writing the rule down rather than fixing the strings alone.
The split is not arbitrary. Title Case clusters in guide-heavy sidebars; sentence case clusters in reference-heavy sidebars that are mostly literal identifiers (Tailwind's is 185 CSS property names out of 196, Svelte's is $state / bind: / <svelte:window>). WebJs's sidebar is guide-heavy prose, so it belongs with Next.js and Rails, which is also where 42 of its 44 entries already sit.
Next.js confirms the sub-rule directly. Its nav_title frontmatter key exists partly to UNDO Title Case and restore a real identifier (turbopackFileSystemCache, cacheHandler, webpack, public, 'use client'), and 70% of its pages pair a sentence-case prose title with a Title-Cased nav label, so the nav label is a separate register from the page title. All 11 projects keep code identifiers verbatim, with zero counterexamples. That is why createAuth stays lowercase inside its label, the same way Database (Drizzle) keeps its dependency's own casing.
Both chosen strings are already exactly the metadata.title minus the | WebJs suffix, so no metadata value changes and the no-duplicate-title guard is untouched.
Rejected:
Sentence case across the whole sidebar. 26 label edits plus 6 section titles, it moves WebJs onto Tailwind's convention while the docs are structured like Next.js, and it gains the reader nothing.
Leaving the two entries in sentence case. They are the only two exceptions in a 44-entry list and nothing about the two pages distinguishes them from the rest.
Changing only the nav labels and leaving the <h1> values alone.website/test/ssr/docs-links.test.ts:176 pins the <h1> byte-equal to the label for exactly these two slugs, so this reds, correctly.
Sentence-casing the two metadata.title values instead, to converge downward. They would then disagree with every other page title on the site and with the shared | WebJs title format.
Writing CreateAuth to make the label uniformly capitalized. It is the name of a real export, and every framework surveyed keeps identifiers verbatim.
Leaving the convention unwritten and fixing only the strings. This is the second pass over these labels. Without a written rule and a guard, a new page picks a casing by coin flip, which is exactly how Qwik's sidebar ended up carrying both spellings of the same phrase.
Regression surface (mapped before implementing)
Every consumer of the nav label and the page <h1> was traced. The finding is that the blast radius is smaller than it looks.
NAV_SECTIONS is module-local and never exported. It is read in exactly one place, docsShell({ nav: NAV_SECTIONS }) at layout.ts:160, and label becomes user-visible at exactly one line, website/lib/ui/docs-shell.ts:307 (<a href=${it.href}>${it.label}</a>). It is not used for aria text, active state, data-*, or anchor ids.
website/lib/docs-llms.server.ts reads the layout SOURCE for hrefs only (/href:\s*'\/docs\/([^']+)'/g, docs-llms.server.ts:69-78). It never captures label. Page titles come from metadata.title, and the <h1> is STRIPPED from the markdown body (docs-llms.server.ts:298-300), so it reaches no corpus.
Therefore /llms.txt, /llms-full.txt, /docs/<topic>/llms.txt, /sitemap.xml, and the /api/search index change zero bytes. Docs pages emit no JSON-LD, there is no RSS route, and the docs OG title is the hardcoded DOCS_OG_TITLE (layout.ts:130), so no social card moves either.
No redirect map, link-checker allowlist, or sitemap priority table keys off label text. All are href-keyed or slug-keyed.
Outside website/app/docs/, the two phrases appear nowhere in the repo. The bare createAuth identifier appears widely (packages/server/src/auth.js:210, the scaffold gallery, README.md:319, .agents/skills/webjs/references/auth-and-sessions.md) but never as part of either phrase, so no framework surface is touched.
The only rendered change is sidebar anchor text, two headings, and seven prose anchors.
Two corrections to the earlier draft of this issue, both found during that trace: the enumerator lives at website/lib/docs-llms.server.ts, not website/modules/**/docs-llms.server.ts; and the rate-limiting anchor is at rate-limiting/page.ts:116, not :114.
Steps
website/app/docs/layout.ts:64, change the label to 'Build Your Own Authentication'.
website/app/docs/layout.ts:74, change the label to 'Auth Providers (createAuth)'.
website/app/docs/authentication/page.ts:11, change the <h1> to Build Your Own Authentication. Leave metadata.title at L4 alone, it is already 'Build Your Own Authentication | WebJs'. Confirm rather than assume.
website/app/docs/auth/page.ts:11, change the <h1> to Auth Providers (createAuth). Leave metadata.title at L4 alone, it is already 'Auth Providers (createAuth) | WebJs'. Confirm rather than assume.
Update the two cross-links the pair carries for each other, so anchor text keeps matching the destination heading: website/app/docs/auth/page.ts:13 (anchor to /docs/authentication) becomes Build Your Own Authentication, and website/app/docs/authentication/page.ts:13 (anchor to /docs/auth) becomes Auth Providers (createAuth).
Update the five inbound cross-links from other doc pages, same reason. To /docs/auth, now reading Auth Providers (createAuth): website/app/docs/cache/page.ts:202, website/app/docs/sessions/page.ts:155, website/app/docs/security/page.ts:76, website/app/docs/typescript/page.ts:204. To /docs/authentication, now reading Build Your Own Authentication: website/app/docs/rate-limiting/page.ts:116.
Add the casing guard to website/test/ssr/docs-links.test.ts (see Tests below), so this cannot drift a third time.
Write the convention into website/AGENTS.md, appending it to the docs/ entry of the layout tree at L38-L41.
Verify nothing is left behind: grep -rn "Build your own authentication\|Auth providers (createAuth)" website/ must return zero hits.
Steps 1 through 6 are one mechanical pass over website/app/docs/, replacing each sentence-case phrase with its Title Case form. Because metadata.title is already Title Case, that pass cannot touch it.
Tests
The new test is every docs sidebar label and section title is Title Case in website/test/ssr/docs-links.test.ts, placed after the existing h1-matches-label test. It parses app/docs/layout.ts as source text, the same regex-over-source approach the sibling tests already use, so no import of the layout module is needed. Each design choice below is tied to the specific failure it prevents, and the whole algorithm was executed against the real file (offenders = 0, and the counterfactual reports exactly your, own, authentication, providers).
Slice to the NAV_SECTIONS literal first, indexOf('const NAV_SECTIONS') to indexOf('\n];') (measured: the only such terminator in the file). This is load-bearing, not defensive. Outside the literal sit the docs-scoped metadata block's own title: keys and the shell call's menuLabel: 'Documentation menu', which is legitimately sentence case and would otherwise be a false positive. Assert the slice did not overrun (!nav.includes('generateMetadata')).
Read label: on its own rather than anchored to a preceding href:, and cross-check labels.length === hrefs.length. The anchored form yields NOTHING for an entry written { label: '...', href: '...' }, so a key reorder silently drops entries from the check. With only a count floor, 14 of 44 could vanish before anything fired, which is precisely the vacuous pass the test exists to prevent. (\blabel: does not match menuLabel:, measured.) Keep a > 40 floor as well, matching the sibling check at L209 in the same file.
Normalize wrapping punctuation before the identifier lookup, and store the bare identifier (createAuth, not (createAuth)). Storing the parenthesized form reds CI on a legitimate edit such as Auth Providers (createAuth API). The same strip handles Runtime (Node & Bun), Task (Async Data), and Editor Setup (Neovim, VS Code).
Skip tokens containing no letters, which is the bare & in Streaming & Suspense and Data & Backend.
Keep the identifier exemption as a named list, not a structural rule. Shape detection catches @webjsdev/ui and createAuth but cannot catch a future webjs check, which is two ordinary lowercase words and byte-indistinguishable from the slip being hunted. A structural rule would cover part of the ground and still need the list, so it would be a second mechanism to maintain. The friction is the feature: adding an entry is the deliberate act that records "identifier, not prose".
A generous minor-word set. A thin list would false-fire on plausible future labels (Deploying on Railway, WebJs vs Next.js, Config by Convention, Setup at a Glance). Use the standard short-word set so the rule PERMITS both Deploying With Docker and Deploying with Docker. Only Migrating from Next.js exercises it today; the rest are there so the next ordinary label does not red CI. Reachable only at position > 0, since every scheme capitalizes a leading minor word.
Assert each word STARTS with a capital rather than parsing true Title Case. This is a floor, deliberately. A stricter rule is one people delete the first time it fires on something legitimate.
The failure message names all three remedies (recase the word, add a genuine identifier to the list, or add an unknown minor word), with the offenders interpolated into the message string, since assert.deepEqual's message replaces the diff rather than accompanying it.
Scoped to app/docs/layout.ts only, NEVER app/ui/layout.ts, whose labels are component names from the live registry (website/app/ui/layout.ts:76) and are identifiers by construction. Applying this there would be 32 false positives. Say so in a comment so nobody widens it later.
Existing tests, all unchanged:
a doc page h1 matches its sidebar label (L176), which is what forces steps 1 through 4 to move together.
no two doc pages declare the same metadata title, unaffected because no metadata.title value moves.
every internal /docs link the docs publish resolves, which boots the app and fetches every doc page, so it covers the render check for both pages.
Counterfactuals, run after committing so a git checkout restores the fix rather than discarding it. Revert either label in layout.ts alone and the new test fails naming that entry and the offending word. Revert either <h1> alone and a doc page h1 matches its sidebar label fails naming the slug.
Browser, e2e, and Bun parity: N/A, because this changes copy in a docs app and touches no framework source, no runtime-sensitive surface, and no client behaviour. The whole change is server-rendered strings, which the SSR test file already covers. The four-app dogfood boot check is likewise not triggered, since nothing under packages/ moves, though website is one of those apps and step 1 boots it.
Run: node --test website/test/ssr/docs-links.test.ts from the repo root (8 tests after this change, up from 7). Then cd website && npm test for the app's full suite, and webjs check on website.
Doc surfaces
website/AGENTS.md L38-L41, the docs/ entry in the layout tree, which today ends at "layout.ts holds the nav tree + docs-scoped metadata; the shell itself is shared, see lib/ui/docs-shell.ts." Append the convention: docs nav labels and section titles are Title Case (the Next.js and Rails convention, against Tailwind's sentence case), a code identifier keeps its own casing inside a label, and a doc page's <h1> reads exactly as its nav label. Name both guard tests so a reader knows it is enforced rather than merely suggested.
website/app/docs/layout.ts L64 and L74, the labels themselves.
website/app/docs/auth/page.ts L11 and L13, and website/app/docs/authentication/page.ts L11 and L13, the headings plus the two cross-links the pair carries for each other.
website/app/docs/cache/page.ts L202, website/app/docs/sessions/page.ts L155, website/app/docs/security/page.ts L76, website/app/docs/typescript/page.ts L204, and website/app/docs/rate-limiting/page.ts L116, the five inbound anchors whose text names one of the two pages. Nothing tests anchor TEXT, only that the href resolves, so verify these by grep.
/llms.txt, /llms-full.txt, and /sitemap.xml need no edit, per the regression map above. Confirm rather than assume by fetching /llms.txt once.
No surface outside website/ names these labels, so no framework doc surface, scaffold template, or skill reference is in scope.
Implementation notes (for the implementing agent)
Where to edit (all four core strings move together, or the test reds):
website/app/docs/layout.ts L64 and L74, the two label values in NAV_SECTIONS.
website/app/docs/authentication/page.ts L11, the <h1>.
website/app/docs/auth/page.ts L11, the <h1>.
The metadata.title on both pages (L4 in each) is ALREADY the chosen Title Case string plus | WebJs, so it needs no change. Confirm rather than assume.
Context worth reading first: the labels landed in commit befd1d8e, "docs: split the two auth pages by what they actually teach (#1243)", which closed #1103. git show befd1d8e explains what each page now teaches, which is why the labels are long and descriptive rather than one word each. The length is deliberate and stays; only the casing moves.
Landmines:
website/test/ssr/docs-links.test.ts:176 asserts <h1> === sidebar label for exactly these two slugs. Change one without the other and it fails, which is the intended behaviour, not a bug in the test.
Both page files are .ts returning an html template, so invariant 9 applies: no backtick anywhere in the template body, including inside comments. A backtick there closes the literal at parse time and 500s the page. This bit docs: make the canonical optimistic() reducer snippets pure #1241 on website/app/docs/client-router/page.ts.
The label regex in the existing L176 test is href:\s*'<href>',\s*label:\s*'([^']+)'. Its \s* spans newlines, so a multi-line entry is fine, but a KEY REORDER (label before href) breaks it, and single quotes are required. The new test deliberately does not repeat that anchoring, for the reason given under Tests.
The new casing test parses source with a regex too. Runtime (Node & Bun) and Editor Setup (Neovim, VS Code) contain a comma and an ampersand, and Streaming & Suspense an ampersand, so split on whitespace rather than on punctuation and strip wrapping parentheses before the uppercase check.
const offenders = [] infers never[] under strict, so annotate const offenders: string[] = [] to match every sibling in the file. website/test/ is outside the tsconfig include, so nothing would catch it.
Invariants to respect: AGENTS.md invariants 9 and 11. Invariant 11 applies to the website/AGENTS.md sentence being added, so no em-dash and no spaced hyphen or semicolon as pause punctuation there, and write the brand as WebJs.
Acceptance criteria
/docs/authentication reads Build Your Own Authentication and /docs/auth reads Auth Providers (createAuth), in the sidebar label and in the page <h1>
The two sidebar labels follow the same casing convention as the other 42 entries and all 6 section titles
Each page's <h1>, sidebar label, and metadata.title agree with each other on casing, and no metadata.title value was edited to achieve it
All seven inbound anchors (the five cross-page ones plus the two the pair carries for each other) use the new label text, verified by grep -rn "Build your own authentication\|Auth providers (createAuth)" website/ returning nothing
website/test/ssr/docs-links.test.ts passes, including the h1-matches-label and no-duplicate-title assertions
The new test fails when either label is reverted to sentence case, and the failure message names the offending entry and word
The new test does not false-fire on Runtime (Node & Bun), Migrating from Next.js, Streaming & Suspense, Editor Setup (Neovim, VS Code), or @webjsdev/ui (AI-first), and it fails loudly rather than silently if a nav entry is written in a shape it cannot parse
/docs/auth and /docs/authentication render (200, non-empty) and webjs check passes on website
The convention is written down in website/AGENTS.md so the next page does not have to guess, and it names the guard tests
Problem
Two docs sidebar entries are sentence case among 42 Title Case ones, and the inconsistency is now pinned in place by a test.
website/app/docs/layout.ts:{ href: '/docs/authentication', label: 'Build your own authentication' }{ href: '/docs/auth', label: 'Auth providers (createAuth)' }Every other entry in
NAV_SECTIONSis Title Case:API Routes,Backend-Only Mode,Metadata Routes,Rate Limiting,Server-Side Rendering,Streaming & Suspense,Task (Async Data), and so on.The same two pages are ALSO internally inconsistent about it. Their
metadata.titleis Title Case ('Build Your Own Authentication | WebJs','Auth Providers (createAuth) | WebJs') while their<h1>and nav label are sentence case, so one page carries both conventions.This came from #1103, which retitled the pair to stop them sharing a
metadata.titleand contradicting each other. The rename itself was the point and is correct. The casing was not considered.The reason it is worth a ticket rather than a drive-by fix:
website/test/ssr/docs-links.test.ts:176(a doc page h1 matches its sidebar label) asserts the<h1>and the nav label are byte-equal for these two slugs. So the two strings can no longer drift apart, but they also cannot be changed independently. Whichever way this is resolved, all four strings move together or the test reds.Decision
The two labels become
Auth Providers (createAuth)(for/docs/auth) andBuild Your Own Authentication(for/docs/authentication), and the convention written down is Title Case for every docs nav label and section title, with code identifiers kept verbatim.Two independent lines of evidence settled it, both measured rather than assumed.
Internal survey. Across all 44 entries of
NAV_SECTIONS(website/app/docs/layout.ts:27-107), the list is not mixed. It is Title Case with exactly two exceptions. 24 multi-word entries are unambiguously Title Case (AI-First Development,No-Build Model,Runtime (Node & Bun),Lifecycle Hooks,Data Fetching,Server-Side Rendering,Progressive Enhancement,Streaming & Suspense,Loading States,Error Handling,Client Router,Server Actions,API Routes,Database (Drizzle),Backend-Only Mode,File Storage,Rate Limiting,Metadata Routes,Reactive Controllers,Context Protocol,Task (Async Data),Lazy Loading,Editor Setup (Neovim, VS Code),Conventions & AI Workflow), 16 are single words and casing-neutral,Migrating from Next.jsreads identically under either convention,@webjsdev/ui (AI-first)is a package name and exempt, and the remaining 2 are the auth pair. All 6 section titles are Title Case as well (Getting Started,Core Concepts,Data & Backend,Infrastructure,Component Library,Advanced). Since only two entries deviate, the scope is those two entries, not a list-wide normalization, and there is nothing else in the list to normalize.External survey, 11 framework docs sites. Title Case leads roughly 8 to 2. Title Case: Next.js (88% of multi-word
nav_titlevalues, 56 of 63), Rails (100% of 83 guide names), Vite, Remix v2, Remix v3, Nuxt, TanStack Router and Start, Bun, shadcn. Sentence case: Tailwind (100% of 196 entries, zero Title Case) and Svelte. Qwik has no written rule and ships bothAPI ReferenceandAPI referencein one sidebar, which is the cautionary case for writing the rule down rather than fixing the strings alone.The split is not arbitrary. Title Case clusters in guide-heavy sidebars; sentence case clusters in reference-heavy sidebars that are mostly literal identifiers (Tailwind's is 185 CSS property names out of 196, Svelte's is
$state/bind:/<svelte:window>). WebJs's sidebar is guide-heavy prose, so it belongs with Next.js and Rails, which is also where 42 of its 44 entries already sit.Next.js confirms the sub-rule directly. Its
nav_titlefrontmatter key exists partly to UNDO Title Case and restore a real identifier (turbopackFileSystemCache,cacheHandler,webpack,public,'use client'), and 70% of its pages pair a sentence-case prosetitlewith a Title-Cased nav label, so the nav label is a separate register from the page title. All 11 projects keep code identifiers verbatim, with zero counterexamples. That is whycreateAuthstays lowercase inside its label, the same wayDatabase (Drizzle)keeps its dependency's own casing.Both chosen strings are already exactly the
metadata.titleminus the| WebJssuffix, so no metadata value changes and the no-duplicate-title guard is untouched.Rejected:
<h1>values alone.website/test/ssr/docs-links.test.ts:176pins the<h1>byte-equal to the label for exactly these two slugs, so this reds, correctly.metadata.titlevalues instead, to converge downward. They would then disagree with every other page title on the site and with the shared| WebJstitle format.CreateAuthto make the label uniformly capitalized. It is the name of a real export, and every framework surveyed keeps identifiers verbatim.Regression surface (mapped before implementing)
Every consumer of the nav
labeland the page<h1>was traced. The finding is that the blast radius is smaller than it looks.NAV_SECTIONSis module-local and never exported. It is read in exactly one place,docsShell({ nav: NAV_SECTIONS })atlayout.ts:160, andlabelbecomes user-visible at exactly one line,website/lib/ui/docs-shell.ts:307(<a href=${it.href}>${it.label}</a>). It is not used for aria text, active state,data-*, or anchor ids.website/lib/docs-llms.server.tsreads the layout SOURCE for hrefs only (/href:\s*'\/docs\/([^']+)'/g,docs-llms.server.ts:69-78). It never captureslabel. Page titles come frommetadata.title, and the<h1>is STRIPPED from the markdown body (docs-llms.server.ts:298-300), so it reaches no corpus./llms.txt,/llms-full.txt,/docs/<topic>/llms.txt,/sitemap.xml, and the/api/searchindex change zero bytes. Docs pages emit no JSON-LD, there is no RSS route, and the docs OG title is the hardcodedDOCS_OG_TITLE(layout.ts:130), so no social card moves either.website/app/docs/, the two phrases appear nowhere in the repo. The barecreateAuthidentifier appears widely (packages/server/src/auth.js:210, the scaffold gallery,README.md:319,.agents/skills/webjs/references/auth-and-sessions.md) but never as part of either phrase, so no framework surface is touched.The only rendered change is sidebar anchor text, two headings, and seven prose anchors.
Two corrections to the earlier draft of this issue, both found during that trace: the enumerator lives at
website/lib/docs-llms.server.ts, notwebsite/modules/**/docs-llms.server.ts; and the rate-limiting anchor is atrate-limiting/page.ts:116, not:114.Steps
website/app/docs/layout.ts:64, change the label to'Build Your Own Authentication'.website/app/docs/layout.ts:74, change the label to'Auth Providers (createAuth)'.website/app/docs/authentication/page.ts:11, change the<h1>toBuild Your Own Authentication. Leavemetadata.titleat L4 alone, it is already'Build Your Own Authentication | WebJs'. Confirm rather than assume.website/app/docs/auth/page.ts:11, change the<h1>toAuth Providers (createAuth). Leavemetadata.titleat L4 alone, it is already'Auth Providers (createAuth) | WebJs'. Confirm rather than assume.website/app/docs/auth/page.ts:13(anchor to/docs/authentication) becomesBuild Your Own Authentication, andwebsite/app/docs/authentication/page.ts:13(anchor to/docs/auth) becomesAuth Providers (createAuth)./docs/auth, now readingAuth Providers (createAuth):website/app/docs/cache/page.ts:202,website/app/docs/sessions/page.ts:155,website/app/docs/security/page.ts:76,website/app/docs/typescript/page.ts:204. To/docs/authentication, now readingBuild Your Own Authentication:website/app/docs/rate-limiting/page.ts:116.website/test/ssr/docs-links.test.ts(see Tests below), so this cannot drift a third time.website/AGENTS.md, appending it to thedocs/entry of the layout tree at L38-L41.grep -rn "Build your own authentication\|Auth providers (createAuth)" website/must return zero hits.Steps 1 through 6 are one mechanical pass over
website/app/docs/, replacing each sentence-case phrase with its Title Case form. Becausemetadata.titleis already Title Case, that pass cannot touch it.Tests
The new test is
every docs sidebar label and section title is Title Caseinwebsite/test/ssr/docs-links.test.ts, placed after the existing h1-matches-label test. It parsesapp/docs/layout.tsas source text, the same regex-over-source approach the sibling tests already use, so no import of the layout module is needed. Each design choice below is tied to the specific failure it prevents, and the whole algorithm was executed against the real file (offenders = 0, and the counterfactual reports exactlyyour,own,authentication,providers).NAV_SECTIONSliteral first,indexOf('const NAV_SECTIONS')toindexOf('\n];')(measured: the only such terminator in the file). This is load-bearing, not defensive. Outside the literal sit the docs-scoped metadata block's owntitle:keys and the shell call'smenuLabel: 'Documentation menu', which is legitimately sentence case and would otherwise be a false positive. Assert the slice did not overrun (!nav.includes('generateMetadata')).label:on its own rather than anchored to a precedinghref:, and cross-checklabels.length === hrefs.length. The anchored form yields NOTHING for an entry written{ label: '...', href: '...' }, so a key reorder silently drops entries from the check. With only a count floor, 14 of 44 could vanish before anything fired, which is precisely the vacuous pass the test exists to prevent. (\blabel:does not matchmenuLabel:, measured.) Keep a> 40floor as well, matching the sibling check at L209 in the same file.createAuth, not(createAuth)). Storing the parenthesized form reds CI on a legitimate edit such asAuth Providers (createAuth API). The same strip handlesRuntime (Node & Bun),Task (Async Data), andEditor Setup (Neovim, VS Code).&inStreaming & SuspenseandData & Backend.@webjsdev/uiandcreateAuthbut cannot catch a futurewebjs check, which is two ordinary lowercase words and byte-indistinguishable from the slip being hunted. A structural rule would cover part of the ground and still need the list, so it would be a second mechanism to maintain. The friction is the feature: adding an entry is the deliberate act that records "identifier, not prose".Deploying on Railway,WebJs vs Next.js,Config by Convention,Setup at a Glance). Use the standard short-word set so the rule PERMITS bothDeploying With DockerandDeploying with Docker. OnlyMigrating from Next.jsexercises it today; the rest are there so the next ordinary label does not red CI. Reachable only at position > 0, since every scheme capitalizes a leading minor word.assert.deepEqual's message replaces the diff rather than accompanying it.app/docs/layout.tsonly, NEVERapp/ui/layout.ts, whose labels are component names from the live registry (website/app/ui/layout.ts:76) and are identifiers by construction. Applying this there would be 32 false positives. Say so in a comment so nobody widens it later.Existing tests, all unchanged:
a doc page h1 matches its sidebar label(L176), which is what forces steps 1 through 4 to move together.no two doc pages declare the same metadata title, unaffected because nometadata.titlevalue moves.every internal /docs link the docs publish resolves, which boots the app and fetches every doc page, so it covers the render check for both pages.Counterfactuals, run after committing so a
git checkoutrestores the fix rather than discarding it. Revert either label inlayout.tsalone and the new test fails naming that entry and the offending word. Revert either<h1>alone anda doc page h1 matches its sidebar labelfails naming the slug.Browser, e2e, and Bun parity: N/A, because this changes copy in a docs app and touches no framework source, no runtime-sensitive surface, and no client behaviour. The whole change is server-rendered strings, which the SSR test file already covers. The four-app dogfood boot check is likewise not triggered, since nothing under
packages/moves, thoughwebsiteis one of those apps and step 1 boots it.Run:
node --test website/test/ssr/docs-links.test.tsfrom the repo root (8 tests after this change, up from 7). Thencd website && npm testfor the app's full suite, andwebjs checkonwebsite.Doc surfaces
website/AGENTS.mdL38-L41, thedocs/entry in the layout tree, which today ends at "layout.ts holds the nav tree + docs-scoped metadata; the shell itself is shared, see lib/ui/docs-shell.ts." Append the convention: docs nav labels and section titles are Title Case (the Next.js and Rails convention, against Tailwind's sentence case), a code identifier keeps its own casing inside a label, and a doc page's<h1>reads exactly as its nav label. Name both guard tests so a reader knows it is enforced rather than merely suggested.website/app/docs/layout.tsL64 and L74, the labels themselves.website/app/docs/auth/page.tsL11 and L13, andwebsite/app/docs/authentication/page.tsL11 and L13, the headings plus the two cross-links the pair carries for each other.website/app/docs/cache/page.tsL202,website/app/docs/sessions/page.tsL155,website/app/docs/security/page.tsL76,website/app/docs/typescript/page.tsL204, andwebsite/app/docs/rate-limiting/page.tsL116, the five inbound anchors whose text names one of the two pages. Nothing tests anchor TEXT, only that the href resolves, so verify these by grep./llms.txt,/llms-full.txt, and/sitemap.xmlneed no edit, per the regression map above. Confirm rather than assume by fetching/llms.txtonce.website/names these labels, so no framework doc surface, scaffold template, or skill reference is in scope.Implementation notes (for the implementing agent)
Where to edit (all four core strings move together, or the test reds):
website/app/docs/layout.tsL64 and L74, the twolabelvalues inNAV_SECTIONS.website/app/docs/authentication/page.tsL11, the<h1>.website/app/docs/auth/page.tsL11, the<h1>.metadata.titleon both pages (L4 in each) is ALREADY the chosen Title Case string plus| WebJs, so it needs no change. Confirm rather than assume.Context worth reading first: the labels landed in commit
befd1d8e, "docs: split the two auth pages by what they actually teach (#1243)", which closed #1103.git show befd1d8eexplains what each page now teaches, which is why the labels are long and descriptive rather than one word each. The length is deliberate and stays; only the casing moves.Landmines:
website/test/ssr/docs-links.test.ts:176asserts<h1> === sidebar labelfor exactly these two slugs. Change one without the other and it fails, which is the intended behaviour, not a bug in the test.metadata.title. Do not "simplify" the two titles into anything that collides; that duplication is what docs: /docs/auth and /docs/authentication duplicate and contradict each other #1103 removed..tsreturning anhtmltemplate, so invariant 9 applies: no backtick anywhere in the template body, including inside comments. A backtick there closes the literal at parse time and 500s the page. This bit docs: make the canonical optimistic() reducer snippets pure #1241 onwebsite/app/docs/client-router/page.ts.href:\s*'<href>',\s*label:\s*'([^']+)'. Its\s*spans newlines, so a multi-line entry is fine, but a KEY REORDER (labelbeforehref) breaks it, and single quotes are required. The new test deliberately does not repeat that anchoring, for the reason given under Tests.Runtime (Node & Bun)andEditor Setup (Neovim, VS Code)contain a comma and an ampersand, andStreaming & Suspensean ampersand, so split on whitespace rather than on punctuation and strip wrapping parentheses before the uppercase check.const offenders = []infersnever[]understrict, so annotateconst offenders: string[] = []to match every sibling in the file.website/test/is outside the tsconfiginclude, so nothing would catch it.node_modules. Symlink the primary checkout's root andwebsite/copies before running anything, per AGENTS.md (dogfood: a fresh git worktree can't resolve @webjsdev/* (no node_modules) #954).Invariants to respect: AGENTS.md invariants 9 and 11. Invariant 11 applies to the
website/AGENTS.mdsentence being added, so no em-dash and no spaced hyphen or semicolon as pause punctuation there, and write the brand as WebJs.Acceptance criteria
/docs/authenticationreadsBuild Your Own Authenticationand/docs/authreadsAuth Providers (createAuth), in the sidebar label and in the page<h1><h1>, sidebar label, andmetadata.titleagree with each other on casing, and nometadata.titlevalue was edited to achieve itgrep -rn "Build your own authentication\|Auth providers (createAuth)" website/returning nothingwebsite/test/ssr/docs-links.test.tspasses, including the h1-matches-label and no-duplicate-title assertionsRuntime (Node & Bun),Migrating from Next.js,Streaming & Suspense,Editor Setup (Neovim, VS Code), or@webjsdev/ui (AI-first), and it fails loudly rather than silently if a nav entry is written in a shape it cannot parse/docs/authand/docs/authenticationrender (200, non-empty) andwebjs checkpasses onwebsitewebsite/AGENTS.mdso the next page does not have to guess, and it names the guard tests