From 6a0cff242aac2ec2ee4161c236824e0045577674 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 22:51:23 +0530 Subject: [PATCH 01/15] docs: title-case the two auth sidebar labels The docs sidebar is Title Case in 42 of its 44 entries and in all 6 section titles. These two were the only exceptions, and each page contradicted itself as well: the metadata.title was already Title Case while the nav label and the h1 were sentence case, so one page carried both conventions. #1103 retitled the pair so they stopped sharing a metadata.title. That rename was the point and stays. The casing was never considered, and this is the pass that settles it. Title Case is where the docs already sit and where the comparable docs sites sit. Next.js title-cases 88% of its multi-word nav labels and Rails all 83 of its guide names, against Tailwind and Svelte, whose sidebars are mostly literal identifiers and are sentence case throughout. createAuth keeps its own casing inside the label, which every framework surveyed does with an identifier and which is why the label reads Auth Providers (createAuth) rather than CreateAuth. No metadata.title value moves. Both chosen strings already ARE the existing title minus the WebJs suffix, so the no-duplicate-title guard and every derived surface (llms.txt, the sitemap, the search index) are untouched. The seven inbound anchors that name these pages move with them, since nothing tests anchor text. --- website/app/docs/auth/page.ts | 4 ++-- website/app/docs/authentication/page.ts | 4 ++-- website/app/docs/cache/page.ts | 2 +- website/app/docs/layout.ts | 4 ++-- website/app/docs/rate-limiting/page.ts | 2 +- website/app/docs/security/page.ts | 2 +- website/app/docs/sessions/page.ts | 2 +- website/app/docs/typescript/page.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/website/app/docs/auth/page.ts b/website/app/docs/auth/page.ts index c11e4c8c0..3f8b6cc26 100644 --- a/website/app/docs/auth/page.ts +++ b/website/app/docs/auth/page.ts @@ -8,9 +8,9 @@ export const metadata = { export default function Auth() { return html` -

Auth providers (createAuth)

+

Auth Providers (createAuth)

WebJs ships createAuth(), a NextAuth-style auth surface with OAuth providers, credentials login, and JWT sessions. Reach for this page when you want providers and sessions handled for you, and no external auth library in the dependency tree.

-

It is not the only route. If you want to own the session format, the password hashing, and the route-protection rules yourself, build on the framework primitives instead, which is what Build your own authentication covers and what the blog example does. Pick createAuth() for OAuth and a batteries-included setup, pick the primitives when you want full control of the session.

+

It is not the only route. If you want to own the session format, the password hashing, and the route-protection rules yourself, build on the framework primitives instead, which is what Build Your Own Authentication covers and what the blog example does. Pick createAuth() for OAuth and a batteries-included setup, pick the primitives when you want full control of the session.

Setup

// lib/auth.server.ts: create once diff --git a/website/app/docs/authentication/page.ts b/website/app/docs/authentication/page.ts index 538aa2edc..81f4acc74 100644 --- a/website/app/docs/authentication/page.ts +++ b/website/app/docs/authentication/page.ts @@ -8,9 +8,9 @@ export const metadata = { export default function Authentication() { return html` -

Build your own authentication

+

Build Your Own Authentication

This page is the hand-rolled route: session-based auth built on the framework primitives, where you own the session format, the password hashing, and the route-protection rules. The blog example is a complete implementation of it, using scrypt password hashing, session tokens in cookies, and middleware-based route protection.

-

WebJs does ship a batteries-included option, so this is a choice rather than a necessity. Auth providers (createAuth) gives you OAuth providers, credentials login, and JWT sessions out of the box. Take that page if you want OAuth or do not want to own the session. Stay here if you want full control over how a session is minted, stored, and checked.

+

WebJs does ship a batteries-included option, so this is a choice rather than a necessity. Auth Providers (createAuth) gives you OAuth providers, credentials login, and JWT sessions out of the box. Take that page if you want OAuth or do not want to own the session. Stay here if you want full control over how a session is minted, stored, and checked.

Architecture

lib/ diff --git a/website/app/docs/cache/page.ts b/website/app/docs/cache/page.ts index c45fc0aa0..38da3d532 100644 --- a/website/app/docs/cache/page.ts +++ b/website/app/docs/cache/page.ts @@ -199,7 +199,7 @@ setStore(redisStore({ url: process.env.REDIS_URL }));

Next Steps

`; diff --git a/website/app/docs/layout.ts b/website/app/docs/layout.ts index aa7f8576e..0e285aede 100644 --- a/website/app/docs/layout.ts +++ b/website/app/docs/layout.ts @@ -61,7 +61,7 @@ const NAV_SECTIONS = [ { href: '/docs/api-routes', label: 'API Routes' }, { href: '/docs/websockets', label: 'WebSockets' }, { href: '/docs/database', label: 'Database (Drizzle)' }, - { href: '/docs/authentication', label: 'Build your own authentication' }, + { href: '/docs/authentication', label: 'Build Your Own Authentication' }, { href: '/docs/backend-only', label: 'Backend-Only Mode' }, ], }, @@ -71,7 +71,7 @@ const NAV_SECTIONS = [ { href: '/docs/cache', label: 'Caching' }, { href: '/docs/file-storage', label: 'File Storage' }, { href: '/docs/sessions', label: 'Sessions' }, - { href: '/docs/auth', label: 'Auth providers (createAuth)' }, + { href: '/docs/auth', label: 'Auth Providers (createAuth)' }, { href: '/docs/rate-limiting', label: 'Rate Limiting' }, { href: '/docs/security', label: 'Security' }, { href: '/docs/metadata-routes', label: 'Metadata Routes' }, diff --git a/website/app/docs/rate-limiting/page.ts b/website/app/docs/rate-limiting/page.ts index 7c84b00cd..91ff701ea 100644 --- a/website/app/docs/rate-limiting/page.ts +++ b/website/app/docs/rate-limiting/page.ts @@ -113,7 +113,7 @@ REDIS_URL=redis://localhost:6379
`; } diff --git a/website/app/docs/security/page.ts b/website/app/docs/security/page.ts index 7ce66bfc5..a3d1b0cb6 100644 --- a/website/app/docs/security/page.ts +++ b/website/app/docs/security/page.ts @@ -73,7 +73,7 @@ export default function Security() {

Cross-origin vendor modules (resolved from jspm.io) carry a standard SRI integrity hash so a swapped or compromised CDN response cannot execute unverified. This now applies on both paths: a pinned app (webjs vendor pin) ships the hashes in its committed importmap, and an un-pinned app computes them live at warmup. SRI computation is fail-open: a CDN fetch failure during the live path skips that one hash with a warning rather than taking the app down. For reproducible hashes and zero warmup fetches, pin. See No-Build Model.

Sessions and secret management

-

Session cookies are signed, so set a strong AUTH_SECRET (and SESSION_SECRET where used), 32 or more random characters, in production. Keep all secrets server-only: any process.env name WITHOUT the WEBJS_PUBLIC_ prefix never reaches the browser (reading process.env.DATABASE_URL from a component returns undefined, the same as a typo). The prefix is fail-closed, so a secret cannot leak by accident. Prefer your platform's secret injection over a committed .env file. See Sessions and Auth providers (createAuth).

+

Session cookies are signed, so set a strong AUTH_SECRET (and SESSION_SECRET where used), 32 or more random characters, in production. Keep all secrets server-only: any process.env name WITHOUT the WEBJS_PUBLIC_ prefix never reaches the browser (reading process.env.DATABASE_URL from a component returns undefined, the same as a typo). The prefix is fail-closed, so a secret cannot leak by accident. Prefer your platform's secret injection over a committed .env file. See Sessions and Auth Providers (createAuth).

Rate limiting

Protect auth endpoints and other abuse-prone routes with the rateLimit({ window, max }) middleware, placed at any route level (it applies to that subtree). Behind a reverse proxy or CDN, set trustProxy: true so it keys on the forwarded client IP, make sure the proxy strips an inbound X-Forwarded-For before adding its own, and leave WEBJS_NO_TRUST_PROXY=1 unset, since that env var outranks the option and puts the limiter back on the stamped socket IP. See Rate Limiting.

diff --git a/website/app/docs/sessions/page.ts b/website/app/docs/sessions/page.ts index 4c44bf044..337e294e1 100644 --- a/website/app/docs/sessions/page.ts +++ b/website/app/docs/sessions/page.ts @@ -152,7 +152,7 @@ const message = s.get('success'); // 'Post published!', only this requestNext Steps `; diff --git a/website/app/docs/typescript/page.ts b/website/app/docs/typescript/page.ts index 5dc840865..4bd71b250 100644 --- a/website/app/docs/typescript/page.ts +++ b/website/app/docs/typescript/page.ts @@ -201,7 +201,7 @@ events[0].createdAt.toLocaleDateString(); // works const { auth } = createAuth<AppUser>({ secret, providers }); const session = await auth(); session?.user.role; // typed, no cast -

Un-augmented and un-parameterised, AuthUser is empty and resolves back to Record<string, unknown>, so pre-existing untyped code keeps compiling. The declared fields should mirror what the callbacks write onto session.user. See Auth providers (createAuth).

+

Un-augmented and un-parameterised, AuthUser is empty and resolves back to Record<string, unknown>, so pre-existing untyped code keeps compiling. The declared fields should mirror what the callbacks write onto session.user. See Auth Providers (createAuth).

JSDoc Alternative

If you prefer .js files, you can achieve the same type safety using JSDoc annotations with checkJs: true in your tsconfig:

From 76954b22f9cc7af6f4c92b3fbb133bf8fd00ebc2 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 22:53:01 +0530 Subject: [PATCH 02/15] docs: guard the docs sidebar casing convention Fixing the two strings does not stop the next page picking a casing by coin flip, which is how they drifted in the first place. Qwik's sidebar ships both "API Reference" and "API reference" for exactly that reason. So the rule is written into AGENTS.md and enforced by a test. The test is a floor, not a Title Case parser: it asserts each word starts with a capital, so a stricter rule cannot fire on something legitimate and get deleted. Three things it deliberately does NOT do. It does not anchor `label:` to a preceding `href:`, because that regex yields nothing for an entry written { label, href } and would silently stop checking it, so the href count is the cross-check instead. It does not detect identifiers structurally, because a future "webjs check" label is two ordinary lowercase words and no shape rule can tell it from the slip being hunted, so the exemption is a named list whose friction is the point. It does not cover the /ui sidebar, whose labels come from the live registry and would be 32 false positives. The NAV_SECTIONS slice is load-bearing rather than defensive: the metadata block below it has its own title: keys, and the shell call carries menuLabel: 'Documentation menu', which is legitimately sentence case and would otherwise be reported as an offender. --- website/AGENTS.md | 14 ++++- website/test/ssr/docs-links.test.ts | 94 +++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 78f07ef7e..ab32a3592 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -38,7 +38,19 @@ website/ docs/ /docs/, the reference documentation (#1098 moved it here from docs.webjs.dev). layout.ts holds the nav tree + docs-scoped metadata; the shell itself is shared, - see lib/ui/docs-shell.ts. + see lib/ui/docs-shell.ts. Nav labels and section titles + are Title Case, the convention the Next.js and Rails + docs follow, rather than Tailwind's sentence case. A + code identifier keeps its own casing inside a label + (createAuth, Drizzle), which is what every comparable + docs site does. A doc page's h1 reads exactly as its + nav label. Both rules are enforced, by the + test/ssr/docs-links.test.ts tests "every docs sidebar + label and section title is Title Case" and "a doc page + h1 matches its sidebar label". The /ui sidebar is + deliberately exempt: its labels are component names + from the live registry, so they are identifiers by + construction. ui/ /ui, the @webjsdev/ui component gallery (#1099 moved it here from ui.webjs.dev). page.ts is the introduction, [name]/page.ts one page per component, layout.ts the diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index d22e0a2e7..6f07bc1ac 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -194,6 +194,100 @@ test('a doc page h1 matches its sidebar label', async () => { } }); +test('every docs sidebar label and section title is Title Case', async () => { + // Two labels drifted to sentence case ('Build your own authentication' and + // 'Auth providers (createAuth)') against 42 Title Case siblings, and nothing + // caught it because casing is not a link, a title, or an order. The rule is + // the one comparable docs sites converge on: prose takes the project's + // convention, and a CODE IDENTIFIER is written verbatim and never recased. + // Qwik ships both 'API Reference' and 'API reference' in one sidebar because + // neither half was ever written down. + // + // This is a FLOOR, not a Title Case parser. It asserts each word STARTS with + // a capital, so 'Build YOUR Own Authentication' passes. That is deliberate: + // the drift it exists to catch is sentence case, and a stricter rule is one + // people delete the first time it fires on something legitimate. + + // Words a title-case scheme leaves lowercase after the first position. + // Generous on purpose, so this PERMITS both 'Deploying With Docker' and + // 'Deploying with Docker'. Only 'Migrating from Next.js' exercises it today. + // The rest are here so the first 'Deploying on Railway' does not red CI for a + // label that was never wrong. + const MINOR_WORDS = new Set([ + 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'if', 'in', 'into', + 'nor', 'of', 'off', 'on', 'onto', 'or', 'over', 'per', 'so', 'the', 'to', + 'up', 'via', 'vs', 'with', 'yet', + ]); + + // Identifiers are kept VERBATIM. No structural rule can carry this: shape + // detects '@webjsdev/ui' and 'createAuth', but a future 'webjs check' label + // is two ordinary lowercase words, byte-indistinguishable from the slip this + // test hunts. So the exemption is a named list, and adding to it is the + // deliberate act that records "identifier, not prose". Matched against the + // word with wrapping punctuation stripped, so it survives a rename to + // 'Auth Providers (createAuth API)'. + const IDENTIFIERS = new Set(['createAuth', '@webjsdev/ui']); + + const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); + // Slice to the NAV_SECTIONS literal. Outside it sit the docs-scoped metadata + // block (its own `title:` keys) and the shell call's aria labels + // ('Documentation', 'Documentation menu'), none of which are nav text and the + // last of which is legitimately sentence case. + const start = layout.indexOf('const NAV_SECTIONS'); + const end = layout.indexOf('\n];', start); + assert.ok(start !== -1 && end > start, 'could not locate the NAV_SECTIONS literal in layout.ts'); + const nav = layout.slice(start, end); + assert.ok(!nav.includes('generateMetadata'), 'the NAV_SECTIONS slice ran past the end of the literal'); + + // `label:` is read on its own rather than anchored to a preceding `href:`. + // The anchored form yields NOTHING for an entry written + // `{ label: '...', href: '...' }`, and a guard that quietly checks 30 of 44 + // labels is the exact failure mode it exists to prevent. The href count is + // the cross-check: every nav item has one of each. + const labels = [...nav.matchAll(/\blabel:\s*'([^']+)'/g)].map((m) => m[1]); + const titles = [...nav.matchAll(/\btitle:\s*'([^']+)'/g)].map((m) => m[1]); + const hrefs = [...nav.matchAll(/\bhref:\s*'([^']+)'/g)].map((m) => m[1]); + + assert.equal( + labels.length, + hrefs.length, + `parsed ${hrefs.length} hrefs but ${labels.length} labels: a nav entry is written in a shape this test cannot read, so it is silently not being checked`, + ); + // Floors matching the sibling checks in this file, so a regex that stops + // matching fails here instead of passing empty. + assert.ok(labels.length > 40, `sanity: expected the full sidebar, parsed ${labels.length} labels`); + assert.ok(titles.length > 3, `sanity: expected every section, parsed ${titles.length} titles`); + + const offenders: string[] = []; + for (const value of [...titles, ...labels]) { + value.split(/\s+/).forEach((token, i) => { + // 'Runtime (Node & Bun)', 'Editor Setup (Neovim, VS Code)', 'cache()' + const word = token.replace(/^\(+|[)(,.]+$/g, ''); + if (!/[A-Za-z]/.test(word)) return; // the bare & in 'Streaming & Suspense' + if (IDENTIFIERS.has(word)) return; + if (i > 0 && MINOR_WORDS.has(word.toLowerCase())) return; + if (!/^[A-Z]/.test(word)) offenders.push(`${value} -> '${word}'`); + }); + } + + assert.deepEqual( + offenders, + [], + 'these docs sidebar entries are not Title Case (entry -> the lowercase word):\n ' + + offenders.join('\n ') + + '\n\nThe docs sidebar is Title Case throughout, section titles included. Pick the fix that matches the word:\n' + + ' 1. ORDINARY WORD: recase it in app/docs/layout.ts. This is the fix nearly every\n' + + ' hit wants, and it is what the last two drifts needed.\n' + + " 2. CODE IDENTIFIER: add it VERBATIM to IDENTIFIERS at the top of this test\n" + + " (a package like '@webjsdev/ui', an export like 'createAuth', a command like\n" + + " 'webjs check', a filename like 'package.json'). Identifiers keep their own\n" + + ' casing and are never title-cased, so the label is correct and the test is\n' + + ' what needs updating. Add each word of a multi-word command separately.\n' + + " 3. LOWERCASE-IN-TITLE WORD this list does not know yet ('amid', 'until'): add it\n" + + ' to MINOR_WORDS instead.', + ); +}); + test('the llms.txt index follows the sidebar order', async () => { // The order used to be a hand-copied list that had already drifted from the // sidebar, so the AI-facing index put Runtime and Security in an From c91a1af12de2bdc8df01ace1eb613295e4ee7fa0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 22:56:19 +0530 Subject: [PATCH 03/15] docs: tighten the sidebar casing convention wording --- website/AGENTS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index ab32a3592..79304df77 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -44,10 +44,10 @@ website/ code identifier keeps its own casing inside a label (createAuth, Drizzle), which is what every comparable docs site does. A doc page's h1 reads exactly as its - nav label. Both rules are enforced, by the - test/ssr/docs-links.test.ts tests "every docs sidebar - label and section title is Title Case" and "a doc page - h1 matches its sidebar label". The /ui sidebar is + nav label. Two tests in test/ssr/docs-links.test.ts + enforce all of this, "every docs sidebar label and + section title is Title Case" and "a doc page h1 + matches its sidebar label". The /ui sidebar is deliberately exempt: its labels are component names from the live registry, so they are identifiers by construction. From ba0d58cdc04cb9df93ca313bbe411137b9267924 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:01:13 +0530 Subject: [PATCH 04/15] docs: scope the h1 rule to the pages it actually covers The convention paragraph claimed a doc page's h1 reads exactly as its nav label, and named a test as enforcing it. Both halves were wrong. Four pages contradict the rule today (Introduction over a "Getting Started" heading, "Runtime (Node & Bun)" over "Runtime", "Task (Async Data)" over "Task Controller", "Editor Setup (Neovim, VS Code)" over "Editor Setup for VS Code & Neovim"), and the named test iterates exactly two slugs, with a comment saying that scoping is deliberate because the rest of the docs use a shorter nav label than their heading. AGENTS.md is the agent contract, so the false rule was the harmful kind: an agent reading it would either rewrite those four headings or recase the four labels, and the existing test comment says both are wrong. It now says casing is the only thing pinned sidebar-wide, that a nav label may be shorter than the h1, and that the byte-equality pin is scoped to the two auth pages and must not be generalised. Also fixes the guard's own failure message, which told the reader to add an identifier VERBATIM while the lookup runs on the word AFTER wrapping punctuation is stripped, so a literal 'cache()' entry never matches and only 'cache' works. It now says to spell it exactly as the message printed it, which is the stripped form. And drops Drizzle as an example of the identifier rule, since it is Title Case anyway and demonstrates nothing. --- website/AGENTS.md | 27 ++++++++++++++++++--------- website/test/ssr/docs-links.test.ts | 13 ++++++++----- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 79304df77..d6fe6bd26 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -42,15 +42,24 @@ website/ are Title Case, the convention the Next.js and Rails docs follow, rather than Tailwind's sentence case. A code identifier keeps its own casing inside a label - (createAuth, Drizzle), which is what every comparable - docs site does. A doc page's h1 reads exactly as its - nav label. Two tests in test/ssr/docs-links.test.ts - enforce all of this, "every docs sidebar label and - section title is Title Case" and "a doc page h1 - matches its sidebar label". The /ui sidebar is - deliberately exempt: its labels are component names - from the live registry, so they are identifiers by - construction. + (createAuth, @webjsdev/ui), which is what every + comparable docs site does. That rule covers the whole + sidebar and is enforced by the test "every docs + sidebar label and section title is Title Case" in + test/ssr/docs-links.test.ts. + Casing is the only thing pinned. A nav label MAY be + shorter than the page's own h1, and usually is + (Introduction against a "Getting Started" heading, + "Runtime (Node & Bun)" against "Runtime"). The two + auth pages are the one exception: their labels and + h1 values are held byte-equal by the test "a doc page + h1 matches its sidebar label", because those two once + carried headings that matched neither their labels + nor each other. That pin is scoped to those two slugs + on purpose. Do NOT generalise it to other pages. + The /ui sidebar is exempt from all of the above. Its + labels are component names from the live registry, so + they are identifiers by construction. ui/ /ui, the @webjsdev/ui component gallery (#1099 moved it here from ui.webjs.dev). page.ts is the introduction, [name]/page.ts one page per component, layout.ts the diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 6f07bc1ac..7b0acc171 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -278,11 +278,14 @@ test('every docs sidebar label and section title is Title Case', async () => { '\n\nThe docs sidebar is Title Case throughout, section titles included. Pick the fix that matches the word:\n' + ' 1. ORDINARY WORD: recase it in app/docs/layout.ts. This is the fix nearly every\n' + ' hit wants, and it is what the last two drifts needed.\n' + - " 2. CODE IDENTIFIER: add it VERBATIM to IDENTIFIERS at the top of this test\n" + - " (a package like '@webjsdev/ui', an export like 'createAuth', a command like\n" + - " 'webjs check', a filename like 'package.json'). Identifiers keep their own\n" + - ' casing and are never title-cased, so the label is correct and the test is\n' + - ' what needs updating. Add each word of a multi-word command separately.\n' + + ' 2. CODE IDENTIFIER: add it to IDENTIFIERS at the top of this test, spelled\n' + + ' EXACTLY as this message printed it above. Wrapping brackets and trailing\n' + + " punctuation are stripped before the lookup, so a 'cache()' in a label is\n" + + " listed as 'cache'. What belongs there: a package like '@webjsdev/ui', an\n" + + " export like 'createAuth', a command like 'webjs check', a filename like\n" + + " 'package.json'. Identifiers keep their own casing and are never\n" + + ' title-cased, so the label is correct and the test is what needs updating.\n' + + ' Add each word of a multi-word command separately.\n' + " 3. LOWERCASE-IN-TITLE WORD this list does not know yet ('amid', 'until'): add it\n" + ' to MINOR_WORDS instead.', ); From f746f099cf6591a7dae7815bc8ef1f8a5056861c Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:08:23 +0530 Subject: [PATCH 05/15] docs: state the label-to-h1 relationship as it measures The previous pass replaced one false claim with another. It said a nav label may be shorter than the page's h1 "and usually is", when the measured norm is the opposite: 39 of the 43 doc entries read exactly the same in both places. Both examples it offered were wrong too, since "Runtime (Node & Bun)" over "Runtime" and "Task (Async Data)" over "Task Controller" are LONGER labels, so they argued against the sentence they illustrated. Only getting-started is genuinely shorter. The historical clause was also wrong on both halves. It said the two auth pages once carried headings matching neither their labels nor each other. At befd1d8e^ both rendered

Authentication

, so they matched each other exactly, and /docs/authentication was labelled 'Authentication', so that one matched its own label. Only /docs/auth, labelled 'Auth (Providers)', mismatched. Now says what the numbers say: equality is the norm but not a rule, 4 entries diverge deliberately and must not be "fixed", and the pin covers two slugs because the other 41 have never been audited against byte-equality and 4 would fail it. Corrects the same falsehood in the test's own comment, which is where the previous pass took it from. Leaving it there would have shipped a convention in AGENTS.md that the adjacent test comment contradicts. --- website/AGENTS.md | 29 +++++++++++++++++++---------- website/test/ssr/docs-links.test.ts | 18 +++++++++++++----- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index d6fe6bd26..afc4a743b 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -47,16 +47,25 @@ website/ sidebar and is enforced by the test "every docs sidebar label and section title is Title Case" in test/ssr/docs-links.test.ts. - Casing is the only thing pinned. A nav label MAY be - shorter than the page's own h1, and usually is - (Introduction against a "Getting Started" heading, - "Runtime (Node & Bun)" against "Runtime"). The two - auth pages are the one exception: their labels and - h1 values are held byte-equal by the test "a doc page - h1 matches its sidebar label", because those two once - carried headings that matched neither their labels - nor each other. That pin is scoped to those two slugs - on purpose. Do NOT generalise it to other pages. + Casing is the only thing pinned sidebar-wide. A nav + label usually reads exactly as the page's own h1 (39 + of the 43 doc entries do), but that is the norm, not + a rule, and 4 entries deliberately diverge: a label + may be SHORTER than the heading (Introduction against + a "Getting Started" h1), LONGER (Runtime (Node & Bun) + against "Runtime", Task (Async Data) against "Task + Controller"), or simply reworded (Editor Setup + (Neovim, VS Code) against "Editor Setup for VS Code & + Neovim"). Do not "fix" those four. + Only the two auth slugs are PINNED byte-equal, by the + test "a doc page h1 matches its sidebar label". They + earned the pin because both pages once rendered the + same h1 as each other under the same metadata title, + so a reader arriving on either could not tell which + page they had landed on (#1103). The pin is scoped to + those two on purpose, since the other 41 have never + been audited against it and 4 would fail it. Do NOT + generalise it to other pages. The /ui sidebar is exempt from all of the above. Its labels are component names from the live registry, so they are identifiers by construction. diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 7b0acc171..869eab958 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -174,11 +174,19 @@ test('no two doc pages declare the same metadata title', async () => { }); test('a doc page h1 matches its sidebar label', async () => { - // The pair above also disagreed with their own nav entries ('Auth - // (Providers)' and 'Authentication' over two

Authentication

s), so - // neither entry matched the heading a reader landed on. Scoped to the two - // pages the mismatch was found on rather than all of them, because the rest - // of the docs use a deliberately shorter nav label than their heading. + // The pair above also disagreed with their own nav entries: both rendered + //

Authentication

while their labels were 'Auth (Providers)' and + // 'Authentication', so one page's heading named no label at all and the + // other's named a heading its sibling rendered identically. + // + // Scoped to these two slugs rather than every page, and NOT because the + // rest diverge. 39 of the 43 doc entries already read the same in both + // places. It is scoped because the remaining 4 diverge deliberately + // (Introduction over a 'Getting Started' h1, 'Runtime (Node & Bun)' over + // 'Runtime', 'Task (Async Data)' over 'Task Controller', 'Editor Setup + // (Neovim, VS Code)' over 'Editor Setup for VS Code & Neovim'), and the + // other 41 have never been audited against a byte-equal pin. Widening this + // test would red on those 4, which are correct as they stand. const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); const labelFor = (href: string) => { const m = layout.match(new RegExp(`href:\\s*'${href}',\\s*label:\\s*'([^']+)'`)); From 351f0fcd33104e019b8b0a74b3b5c8bfa171006f Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:16:10 +0530 Subject: [PATCH 06/15] docs: count the label-to-h1 divergences on the byte basis The previous pass fixed the direction of the claim but counted on the wrong basis. It said 39 of 43 entries read the same and 4 diverge, which is the RENDERED census, then used it to reason about a pin that is byte-equal and implemented as a source-byte comparison. On that basis it is 38 and 5. The fifth is conventions, whose h1 escapes its ampersand as an entity while the label carries the raw character, so the two read identically and compare unequal. That is exactly the trap someone widening this test would hit, so both surfaces now name it. Also sharpens the pin's rationale. Saying the two pages once shared an h1 and a metadata title is true but does not justify a LABEL pin, and the duplicate title is already guarded by its own test. The real reason is narrower: /docs/auth rendered an h1 byte-identical to its SIBLING's label, so a reader clicking one nav entry landed on a heading naming the other page. The test comment said "the pair" disagreed with their nav entries, when /docs/authentication was labelled 'Authentication' and agreed with itself. Only one of the two ever mismatched. Corrects a stale 45 in two older comments in the same file, which the new 43 would otherwise contradict on the same page. The sidebar links 43 doc pages (44 href keys, one of which is /ui). --- website/AGENTS.md | 38 ++++++++++++++++------------- website/test/ssr/docs-links.test.ts | 29 ++++++++++++---------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index afc4a743b..6c21784de 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -48,24 +48,28 @@ website/ sidebar label and section title is Title Case" in test/ssr/docs-links.test.ts. Casing is the only thing pinned sidebar-wide. A nav - label usually reads exactly as the page's own h1 (39 - of the 43 doc entries do), but that is the norm, not - a rule, and 4 entries deliberately diverge: a label - may be SHORTER than the heading (Introduction against - a "Getting Started" h1), LONGER (Runtime (Node & Bun) - against "Runtime", Task (Async Data) against "Task - Controller"), or simply reworded (Editor Setup - (Neovim, VS Code) against "Editor Setup for VS Code & - Neovim"). Do not "fix" those four. + label usually reads exactly as the page's own h1, and + 38 of the 43 doc entries match it byte for byte, but + that is the norm, not a rule. Five diverge, four of + them deliberately: a label may be SHORTER than the + heading (Introduction against a "Getting Started" + h1), LONGER (Runtime (Node & Bun) against "Runtime", + Task (Async Data) against "Task Controller"), or + reworded (Editor Setup (Neovim, VS Code) against + "Editor Setup for VS Code & Neovim"). The fifth, + conventions, READS the same but is not byte-equal, + because its h1 escapes the ampersand as an HTML + entity where the label carries the raw character. + Do not "fix" any of the five. Only the two auth slugs are PINNED byte-equal, by the - test "a doc page h1 matches its sidebar label". They - earned the pin because both pages once rendered the - same h1 as each other under the same metadata title, - so a reader arriving on either could not tell which - page they had landed on (#1103). The pin is scoped to - those two on purpose, since the other 41 have never - been audited against it and 4 would fail it. Do NOT - generalise it to other pages. + test "a doc page h1 matches its sidebar label". The + pin exists because /docs/auth once rendered an h1 + naming its SIBLING's label rather than its own, so a + reader following the nav landed on a heading that + belonged to the other page (#1103). It is scoped to + those two because the other 41 have never been + audited against byte-equality and 5 would fail it + today. Do NOT generalise it to other pages. The /ui sidebar is exempt from all of the above. Its labels are component names from the live registry, so they are identifiers by construction. diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 869eab958..ceff9b502 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -37,7 +37,7 @@ before(async () => { * * Two sources, and the second is the one that matters most. Doc page prose * yields around 36 distinct slugs, but the SIDEBAR is the only surface that - * links all 45, and its hrefs are single-quoted object literals rendered + * links all 43, and its hrefs are single-quoted object literals rendered * through a template hole, so a walk that only reads `href="..."` in page * files cannot see them. A typo there would ship a 404 in the primary * navigation of every docs page with this test green. @@ -76,7 +76,7 @@ async function internalDocLinks(): Promise<{ from: string; href: string }[]> { test('every internal /docs link the docs publish resolves', async () => { const links = await internalDocLinks(); - // The sidebar alone contributes 45, so a floor well above that proves both + // The sidebar alone contributes 43, so a floor well above that proves both // sources were actually read rather than one silently yielding nothing. assert.ok(links.length > 60, `sanity: expected many internal links, found ${links.length}`); assert.ok( @@ -174,19 +174,22 @@ test('no two doc pages declare the same metadata title', async () => { }); test('a doc page h1 matches its sidebar label', async () => { - // The pair above also disagreed with their own nav entries: both rendered - //

Authentication

while their labels were 'Auth (Providers)' and - // 'Authentication', so one page's heading named no label at all and the - // other's named a heading its sibling rendered identically. + // One of the pair also disagreed with its own nav entry, and only one. + // Both rendered

Authentication

. /docs/authentication was labelled + // 'Authentication', so it agreed with itself. /docs/auth was labelled + // 'Auth (Providers)' and rendered a heading byte-identical to its SIBLING's + // label, so a reader who clicked one nav entry landed on a heading that + // named the other page. That is what this pin is for. // // Scoped to these two slugs rather than every page, and NOT because the - // rest diverge. 39 of the 43 doc entries already read the same in both - // places. It is scoped because the remaining 4 diverge deliberately - // (Introduction over a 'Getting Started' h1, 'Runtime (Node & Bun)' over - // 'Runtime', 'Task (Async Data)' over 'Task Controller', 'Editor Setup - // (Neovim, VS Code)' over 'Editor Setup for VS Code & Neovim'), and the - // other 41 have never been audited against a byte-equal pin. Widening this - // test would red on those 4, which are correct as they stand. + // rest diverge. 38 of the 43 doc entries are already byte-equal. It is + // scoped because 4 diverge deliberately (Introduction over a 'Getting + // Started' h1, 'Runtime (Node & Bun)' over 'Runtime', 'Task (Async Data)' + // over 'Task Controller', 'Editor Setup (Neovim, VS Code)' over 'Editor + // Setup for VS Code & Neovim'), a 5th (conventions) reads the same but + // escapes its ampersand in the h1 so it is not byte-equal, and the other + // 41 have never been audited against this pin. Widening the test would red + // on all five, every one of which is correct as it stands. const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); const labelFor = (href: string) => { const m = layout.match(new RegExp(`href:\\s*'${href}',\\s*label:\\s*'([^']+)'`)); From 6f411440d9cc2cba647b66a0121a3eb2b1d427f3 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:24:14 +0530 Subject: [PATCH 07/15] docs: keep the sidebar counts in the test, not the contract Four review rounds each found a false claim in the same paragraph, and the last one named why: the prose lived in two files at once, with nothing keeping them in sync, so every fix had to land twice and the two copies drifted apart again. Round 4 caught them already disagreeing on the one piece of arithmetic they both restated. The measurements were also self-contradicting. Saying 38 of 43 entries are byte-equal IS a byte-equality audit of all 43, so the next sentence claiming the other 41 had never been audited could not both be true. That collision appeared the moment both halves moved onto the byte basis, which is what the previous round did. So AGENTS.md keeps only what cannot rot: labels are Title Case, identifiers keep their casing, a label need not match the h1 and the ones that differ are correct, the pin covers two named slugs and must not be generalised, /ui is exempt. Every count, every example, and every taxonomy moves to the test comment, which sits beside the code that measures them and is the thing a reader consults before widening the pin. The docs/ entry goes from 38 lines to 18, back in proportion to a block whose next-longest entry is 7. Drops the shorter/longer/reworded taxonomy rather than repairing it. It did not survive contact: Task (Async Data) over "Task Controller" was filed as LONGER while sharing only a prefix, and the reworded example is in fact shorter than its heading, so length was not the axis separating the buckets. Also states why /docs/authentication is pinned at all, which no round had explained: the collision was between one page's h1 and the OTHER's label, so pinning half of it leaves the other half free to drift back. --- website/AGENTS.md | 48 +++++++++-------------------- website/test/ssr/docs-links.test.ts | 26 ++++++++-------- 2 files changed, 27 insertions(+), 47 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 6c21784de..1f206e22b 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -39,40 +39,20 @@ website/ it here from docs.webjs.dev). layout.ts holds the nav tree + docs-scoped metadata; the shell itself is shared, see lib/ui/docs-shell.ts. Nav labels and section titles - are Title Case, the convention the Next.js and Rails - docs follow, rather than Tailwind's sentence case. A - code identifier keeps its own casing inside a label - (createAuth, @webjsdev/ui), which is what every - comparable docs site does. That rule covers the whole - sidebar and is enforced by the test "every docs - sidebar label and section title is Title Case" in - test/ssr/docs-links.test.ts. - Casing is the only thing pinned sidebar-wide. A nav - label usually reads exactly as the page's own h1, and - 38 of the 43 doc entries match it byte for byte, but - that is the norm, not a rule. Five diverge, four of - them deliberately: a label may be SHORTER than the - heading (Introduction against a "Getting Started" - h1), LONGER (Runtime (Node & Bun) against "Runtime", - Task (Async Data) against "Task Controller"), or - reworded (Editor Setup (Neovim, VS Code) against - "Editor Setup for VS Code & Neovim"). The fifth, - conventions, READS the same but is not byte-equal, - because its h1 escapes the ampersand as an HTML - entity where the label carries the raw character. - Do not "fix" any of the five. - Only the two auth slugs are PINNED byte-equal, by the - test "a doc page h1 matches its sidebar label". The - pin exists because /docs/auth once rendered an h1 - naming its SIBLING's label rather than its own, so a - reader following the nav landed on a heading that - belonged to the other page (#1103). It is scoped to - those two because the other 41 have never been - audited against byte-equality and 5 would fail it - today. Do NOT generalise it to other pages. - The /ui sidebar is exempt from all of the above. Its - labels are component names from the live registry, so - they are identifiers by construction. + are Title Case (the Next.js and Rails convention, not + Tailwind's sentence case), and a code identifier keeps + its own casing inside a label (createAuth, + @webjsdev/ui). Casing is the only thing pinned + sidebar-wide: a nav label NEED NOT match the page's + own h1, several deliberately do not, and those are + correct as they stand. The one exception is the + /docs/auth and /docs/authentication pair, held + byte-equal after their headings once collided (#1103). + Do NOT generalise that pin to other pages. The /ui + sidebar is exempt from all of the above, since its + labels are component names from the live registry. + Both rules are enforced in test/ssr/docs-links.test.ts, + which carries the specifics and the current counts. ui/ /ui, the @webjsdev/ui component gallery (#1099 moved it here from ui.webjs.dev). page.ts is the introduction, [name]/page.ts one page per component, layout.ts the diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index ceff9b502..df2ea1f6e 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -174,22 +174,22 @@ test('no two doc pages declare the same metadata title', async () => { }); test('a doc page h1 matches its sidebar label', async () => { - // One of the pair also disagreed with its own nav entry, and only one. - // Both rendered

Authentication

. /docs/authentication was labelled + // Only one of the pair ever disagreed with its own nav entry. Both + // rendered

Authentication

. /docs/authentication was labelled // 'Authentication', so it agreed with itself. /docs/auth was labelled // 'Auth (Providers)' and rendered a heading byte-identical to its SIBLING's - // label, so a reader who clicked one nav entry landed on a heading that - // named the other page. That is what this pin is for. + // label, so clicking one nav entry landed the reader on a heading naming + // the other page. Both slugs are pinned rather than just /docs/auth because + // the collision was between one page's h1 and the OTHER's label, so pinning + // half of it would leave the other half free to drift back into it. // - // Scoped to these two slugs rather than every page, and NOT because the - // rest diverge. 38 of the 43 doc entries are already byte-equal. It is - // scoped because 4 diverge deliberately (Introduction over a 'Getting - // Started' h1, 'Runtime (Node & Bun)' over 'Runtime', 'Task (Async Data)' - // over 'Task Controller', 'Editor Setup (Neovim, VS Code)' over 'Editor - // Setup for VS Code & Neovim'), a 5th (conventions) reads the same but - // escapes its ampersand in the h1 so it is not byte-equal, and the other - // 41 have never been audited against this pin. Widening the test would red - // on all five, every one of which is correct as it stands. + // Scoped to these two rather than every page, and NOT because the rest + // diverge. Most doc entries already read the same in both places. It is + // scoped because five of the 41 unpinned pages would fail a byte-equal + // check: getting-started, runtime, task and editor-setup deliberately use + // a label that differs from their heading, and conventions reads the same + // but escapes its ampersand in the h1, so it compares unequal. All five + // are correct as they stand, so widening this test reds on working pages. const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); const labelFor = (href: string) => { const m = layout.match(new RegExp(`href:\\s*'${href}',\\s*label:\\s*'([^']+)'`)); From 4202f3e5b2a54fd867bad070b68f709cc88277cf Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:32:01 +0530 Subject: [PATCH 08/15] docs: describe the pin as what the test compares AGENTS.md said the auth pair is held byte-equal "after their headings once collided", which describes a heading-versus-heading problem. The test compares each page's h1 to its OWN label and never compares the two headings, so a reader taking that at face value would think the pin guards duplicate headings. It does not, and that guard is a separate test on metadata titles. Now says each h1 is pinned to its own label, after one page rendered a heading that named the other page. Also corrects the stale "45 pages" in two layout.ts comments. There are 43. This PR already fixed the identical number twice in the test file and edits layout.ts in the same breath, so leaving it here meant the same wrong count was repaired in one file and left in its neighbour. It matters more now that AGENTS.md delegates the counts to the test, which leaves layout.ts as the last place an agent editing the sidebar would read a false one. --- website/AGENTS.md | 6 ++++-- website/app/docs/layout.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index 1f206e22b..fb213cfe7 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -46,8 +46,10 @@ website/ sidebar-wide: a nav label NEED NOT match the page's own h1, several deliberately do not, and those are correct as they stand. The one exception is the - /docs/auth and /docs/authentication pair, held - byte-equal after their headings once collided (#1103). + /docs/auth and /docs/authentication pair, where each + page's h1 is held byte-equal to its OWN label after + one of them once rendered a heading that named the + other page (#1103). Do NOT generalise that pin to other pages. The /ui sidebar is exempt from all of the above, since its labels are component names from the live registry. diff --git a/website/app/docs/layout.ts b/website/app/docs/layout.ts index 0e285aede..cdc1fbfd0 100644 --- a/website/app/docs/layout.ts +++ b/website/app/docs/layout.ts @@ -22,7 +22,7 @@ import '#components/code-block.ts'; * * Doc page bodies are plain HTML with no component wrapper, so their * typography is styled through the shell's `.prose-docs` rules rather than - * per-element utility classes across 45 pages. + * per-element utility classes across 43 pages. */ const NAV_SECTIONS = [ { @@ -114,7 +114,7 @@ const NAV_SECTIONS = [ * one field each page sets. * * Without this the docs inherit the marketing pitch: the deleted docs root - * layout carried its own title and description, so dropping it left all 45 + * layout carried its own title and description, so dropping it left all 43 * pages advertising "the web framework for AI agents" as their search snippet * and social card. * From 63e37cccc27211823631ed180ac01970103f300f Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:41:59 +0530 Subject: [PATCH 09/15] test: give the casing guard a remedy for lowercase brands The guard flags any word not starting with a capital, so a label like "Deploying on macOS" fires on a correctly spelled brand. None of the three remedies fit it. The first, which the message calls the fix nearly every hit wants, yields MacOS and is actively wrong; the second was scoped in its own text to packages, exports, commands and filenames; the third would permit the word anywhere but the first position, which is not the rule for a brand. The exemption set already handles it mechanically, so this widens what the set is FOR rather than adding machinery: words whose casing is fixed by something other than prose, which is code tokens and brands that start lowercase. Recasing either one misspells it. Also corrects the comment above the label regex. It justified the unanchored read by saying a guard quietly checking 30 of 44 labels is the failure mode it prevents, but the floor beneath it demands more than 40, so 30 would fail loudly and that scenario cannot occur. The floor only fires once four entries are missing. The href cross-check is what makes a single dropped entry loud, which is the real reason it is there. --- website/test/ssr/docs-links.test.ts | 38 +++++++++++++++++------------ 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index df2ea1f6e..ef2586ca8 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -230,11 +230,15 @@ test('every docs sidebar label and section title is Title Case', async () => { 'up', 'via', 'vs', 'with', 'yet', ]); - // Identifiers are kept VERBATIM. No structural rule can carry this: shape - // detects '@webjsdev/ui' and 'createAuth', but a future 'webjs check' label - // is two ordinary lowercase words, byte-indistinguishable from the slip this - // test hunts. So the exemption is a named list, and adding to it is the - // deliberate act that records "identifier, not prose". Matched against the + // Words whose casing is fixed by something other than prose, so recasing + // them would be WRONG rather than a correction. Two kinds qualify: a code + // token ('@webjsdev/ui', 'createAuth', 'webjs check', 'package.json') and a + // brand that starts lowercase ('macOS', 'iOS', 'npm'). No structural rule + // can carry this: shape detects '@webjsdev/ui' and 'createAuth', but a + // future 'webjs check' label is two ordinary lowercase words, + // byte-indistinguishable from the slip this test hunts. So the exemption is + // a named list, and adding to it is the deliberate act that records "this + // spelling is correct, not a slip". Matched against the // word with wrapping punctuation stripped, so it survives a rename to // 'Auth Providers (createAuth API)'. const IDENTIFIERS = new Set(['createAuth', '@webjsdev/ui']); @@ -252,9 +256,10 @@ test('every docs sidebar label and section title is Title Case', async () => { // `label:` is read on its own rather than anchored to a preceding `href:`. // The anchored form yields NOTHING for an entry written - // `{ label: '...', href: '...' }`, and a guard that quietly checks 30 of 44 - // labels is the exact failure mode it exists to prevent. The href count is - // the cross-check: every nav item has one of each. + // `{ label: '...', href: '...' }`, so a key reorder drops that entry from + // the check. The floor below is too coarse to catch it on its own, since it + // only fires once four entries are missing. The href count is what makes + // even a single dropped entry loud: every nav item has one of each. const labels = [...nav.matchAll(/\blabel:\s*'([^']+)'/g)].map((m) => m[1]); const titles = [...nav.matchAll(/\btitle:\s*'([^']+)'/g)].map((m) => m[1]); const hrefs = [...nav.matchAll(/\bhref:\s*'([^']+)'/g)].map((m) => m[1]); @@ -289,14 +294,15 @@ test('every docs sidebar label and section title is Title Case', async () => { '\n\nThe docs sidebar is Title Case throughout, section titles included. Pick the fix that matches the word:\n' + ' 1. ORDINARY WORD: recase it in app/docs/layout.ts. This is the fix nearly every\n' + ' hit wants, and it is what the last two drifts needed.\n' + - ' 2. CODE IDENTIFIER: add it to IDENTIFIERS at the top of this test, spelled\n' + - ' EXACTLY as this message printed it above. Wrapping brackets and trailing\n' + - " punctuation are stripped before the lookup, so a 'cache()' in a label is\n" + - " listed as 'cache'. What belongs there: a package like '@webjsdev/ui', an\n" + - " export like 'createAuth', a command like 'webjs check', a filename like\n" + - " 'package.json'. Identifiers keep their own casing and are never\n" + - ' title-cased, so the label is correct and the test is what needs updating.\n' + - ' Add each word of a multi-word command separately.\n' + + ' 2. A WORD WHOSE CASING IS NOT PROSE: add it to IDENTIFIERS at the top of\n' + + ' this test, spelled EXACTLY as this message printed it above. Wrapping\n' + + " brackets and trailing punctuation are stripped before the lookup, so a\n" + + " 'cache()' in a label is listed as 'cache'. Two kinds belong there: a code\n" + + " token (a package like '@webjsdev/ui', an export like 'createAuth', a\n" + + " command like 'webjs check', a filename like 'package.json') and a brand\n" + + " that starts lowercase ('macOS', 'iOS', 'npm'). For both, recasing would\n" + + " MISSPELL the word, so the label is right and this test is what needs\n" + + ' updating. Add each word of a multi-word command separately.\n' + " 3. LOWERCASE-IN-TITLE WORD this list does not know yet ('amid', 'until'): add it\n" + ' to MINOR_WORDS instead.', ); From 6114b2088bb472040a62a0de709c3ae601739e87 Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:49:31 +0530 Subject: [PATCH 10/15] docs: widen the casing rule where it is defined, not just described The previous commit taught the exemption set and the failure message that a lowercase brand is legitimate, but left the two places that state the RULE saying otherwise. The test's header still said prose takes the convention and a code identifier is written verbatim, a dichotomy with no room for macOS, and AGENTS.md still said a code identifier keeps its casing, which is the sentence an author actually follows. So the enforcement allowed a brand while both statements of the rule forbade it. Both now say a word whose casing is fixed by something other than prose keeps it, covering code tokens and lowercase-initial brands alike. Also walks back an overclaim introduced by that same commit. It said the href cross-check makes even a single dropped entry loud, which is false: an entry written with double quotes on BOTH keys is invisible to both regexes at once, so the counts stay equal and this test passes over it. Confirmed by double-quoting one entry, which slipped a sentence-case label past the guard while reding 'every doc page on disk is reachable from the sidebar' instead, since that test reads single-quoted hrefs and reports the page as orphaned. The comment now names the gap and where it is actually caught, rather than promising a guarantee this test does not make. --- website/AGENTS.md | 8 +++++--- website/test/ssr/docs-links.test.ts | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/website/AGENTS.md b/website/AGENTS.md index fb213cfe7..97dbb9044 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -40,9 +40,11 @@ website/ tree + docs-scoped metadata; the shell itself is shared, see lib/ui/docs-shell.ts. Nav labels and section titles are Title Case (the Next.js and Rails convention, not - Tailwind's sentence case), and a code identifier keeps - its own casing inside a label (createAuth, - @webjsdev/ui). Casing is the only thing pinned + Tailwind's sentence case). A word whose casing is fixed + by something other than prose keeps it, which covers + code tokens (createAuth, @webjsdev/ui) and brands that + start lowercase (macOS, npm). Recasing either one + misspells it. Casing is the only thing pinned sidebar-wide: a nav label NEED NOT match the page's own h1, several deliberately do not, and those are correct as they stand. The one exception is the diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index ef2586ca8..51cc69843 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -209,8 +209,11 @@ test('every docs sidebar label and section title is Title Case', async () => { // Two labels drifted to sentence case ('Build your own authentication' and // 'Auth providers (createAuth)') against 42 Title Case siblings, and nothing // caught it because casing is not a link, a title, or an order. The rule is - // the one comparable docs sites converge on: prose takes the project's - // convention, and a CODE IDENTIFIER is written verbatim and never recased. + // the one comparable docs sites converge on: PROSE takes the project's + // convention, and a word whose casing is fixed by something else is written + // verbatim and never recased. That second half covers code tokens + // ('createAuth', '@webjsdev/ui') and brands that start lowercase ('macOS', + // 'npm') alike, since recasing either one misspells it. // Qwik ships both 'API Reference' and 'API reference' in one sidebar because // neither half was ever written down. // @@ -258,8 +261,15 @@ test('every docs sidebar label and section title is Title Case', async () => { // The anchored form yields NOTHING for an entry written // `{ label: '...', href: '...' }`, so a key reorder drops that entry from // the check. The floor below is too coarse to catch it on its own, since it - // only fires once four entries are missing. The href count is what makes - // even a single dropped entry loud: every nav item has one of each. + // only fires once four entries are missing, so the href count is the + // cross-check: every nav item has one of each. + // + // That is not total, and the gap is worth knowing. An entry written with + // DOUBLE quotes on both keys is invisible to both regexes at once, so the + // counts stay equal and this test passes over it. That shape is caught a few + // tests up instead, by 'every doc page on disk is reachable from the + // sidebar', which reads single-quoted hrefs and so reports the page as + // orphaned. Verified: double-quoting one entry reds that test, not this one. const labels = [...nav.matchAll(/\blabel:\s*'([^']+)'/g)].map((m) => m[1]); const titles = [...nav.matchAll(/\btitle:\s*'([^']+)'/g)].map((m) => m[1]); const hrefs = [...nav.matchAll(/\bhref:\s*'([^']+)'/g)].map((m) => m[1]); From ee060ce233520d27e52c51316c59fcb7f7bbe97a Mon Sep 17 00:00:00 2001 From: Vivek Date: Wed, 5 Aug 2026 23:56:37 +0530 Subject: [PATCH 11/15] test: read either quote style instead of documenting the gap The comment claimed a double-quoted entry is caught by the sibling orphan test. That holds for a /docs entry but not for the one /ui cross-link, which is not a doc directory, so nothing in the file would have caught a sentence-cased relabel of it. Verified: double-quoting both keys of the /ui entry left labels and hrefs equal at 43, above the floor, with no orphan reported. Rather than document a narrower version of the gap, this closes it. The quote character is captured and back-referenced instead of hard-coded, so both styles parse and a label may carry the other quote inside it. Both shapes that previously escaped now fail the guard by name. Renames IDENTIFIERS to FIXED_CASING. The set was widened to hold brands that start lowercase, and macOS is not an identifier in any sense, so the symbol an author greps for disagreed with the rule it encodes. That was the last place the pre-widening wording survived. --- website/test/ssr/docs-links.test.ts | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 51cc69843..6bb42f538 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -244,7 +244,7 @@ test('every docs sidebar label and section title is Title Case', async () => { // spelling is correct, not a slip". Matched against the // word with wrapping punctuation stripped, so it survives a rename to // 'Auth Providers (createAuth API)'. - const IDENTIFIERS = new Set(['createAuth', '@webjsdev/ui']); + const FIXED_CASING = new Set(['createAuth', '@webjsdev/ui']); const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); // Slice to the NAV_SECTIONS literal. Outside it sit the docs-scoped metadata @@ -257,22 +257,25 @@ test('every docs sidebar label and section title is Title Case', async () => { const nav = layout.slice(start, end); assert.ok(!nav.includes('generateMetadata'), 'the NAV_SECTIONS slice ran past the end of the literal'); - // `label:` is read on its own rather than anchored to a preceding `href:`. - // The anchored form yields NOTHING for an entry written - // `{ label: '...', href: '...' }`, so a key reorder drops that entry from - // the check. The floor below is too coarse to catch it on its own, since it - // only fires once four entries are missing, so the href count is the - // cross-check: every nav item has one of each. + // Two ways an entry can slip out of this check unnoticed, both closed here. // - // That is not total, and the gap is worth knowing. An entry written with - // DOUBLE quotes on both keys is invisible to both regexes at once, so the - // counts stay equal and this test passes over it. That shape is caught a few - // tests up instead, by 'every doc page on disk is reachable from the - // sidebar', which reads single-quoted hrefs and so reports the page as - // orphaned. Verified: double-quoting one entry reds that test, not this one. - const labels = [...nav.matchAll(/\blabel:\s*'([^']+)'/g)].map((m) => m[1]); - const titles = [...nav.matchAll(/\btitle:\s*'([^']+)'/g)].map((m) => m[1]); - const hrefs = [...nav.matchAll(/\bhref:\s*'([^']+)'/g)].map((m) => m[1]); + // Anchoring `label:` to a preceding `href:` yields NOTHING for an entry + // written `{ label: '...', href: '...' }`, so a key reorder would drop it. + // Each key is therefore read on its own, and the href count is the + // cross-check, since every nav item has one of each. + // + // Matching only single quotes would miss a double-quoted entry, and when + // BOTH its keys are double-quoted the counts stay equal, so the cross-check + // would not notice either. The sibling orphan test covers that for a + // `/docs/*` entry, but NOT for the one `/ui` cross-link, which is not a doc + // directory, so nothing in this file would have caught a sentence-cased + // relabel of it. The quote character is captured and back-referenced instead + // of hard-coded, which reads both styles and lets a label carry the other + // quote inside it. + const quoted = (key) => new RegExp(`\\b${key}:\\s*(['"])(.*?)\\1`, 'g'); + const labels = [...nav.matchAll(quoted('label'))].map((m) => m[2]); + const titles = [...nav.matchAll(quoted('title'))].map((m) => m[2]); + const hrefs = [...nav.matchAll(quoted('href'))].map((m) => m[2]); assert.equal( labels.length, @@ -290,7 +293,7 @@ test('every docs sidebar label and section title is Title Case', async () => { // 'Runtime (Node & Bun)', 'Editor Setup (Neovim, VS Code)', 'cache()' const word = token.replace(/^\(+|[)(,.]+$/g, ''); if (!/[A-Za-z]/.test(word)) return; // the bare & in 'Streaming & Suspense' - if (IDENTIFIERS.has(word)) return; + if (FIXED_CASING.has(word)) return; if (i > 0 && MINOR_WORDS.has(word.toLowerCase())) return; if (!/^[A-Z]/.test(word)) offenders.push(`${value} -> '${word}'`); }); @@ -304,7 +307,7 @@ test('every docs sidebar label and section title is Title Case', async () => { '\n\nThe docs sidebar is Title Case throughout, section titles included. Pick the fix that matches the word:\n' + ' 1. ORDINARY WORD: recase it in app/docs/layout.ts. This is the fix nearly every\n' + ' hit wants, and it is what the last two drifts needed.\n' + - ' 2. A WORD WHOSE CASING IS NOT PROSE: add it to IDENTIFIERS at the top of\n' + + ' 2. A WORD WHOSE CASING IS NOT PROSE: add it to FIXED_CASING at the top of\n' + ' this test, spelled EXACTLY as this message printed it above. Wrapping\n' + " brackets and trailing punctuation are stripped before the lookup, so a\n" + " 'cache()' in a label is listed as 'cache'. Two kinds belong there: a code\n" + From 84b018c6fa671bc08c8aa576d4952e8b2867c885 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 00:04:36 +0530 Subject: [PATCH 12/15] test: keep an empty nav label a loud failure, not a silent pass Widening the parse to both quote styles quietly swapped `[^']+` for `.*?`, and the difference is not just the quote character: the old pattern required at least one character while the new one matched zero. So `label: ''` went from unmatched, which made the label and href counts diverge and fired the cross-check, to matched-as-empty, which kept the counts equal and then fell through the has-a-letter test in silence. An empty label is a sidebar row with no visible text, so losing that signal is a real loss even though nothing in the tree has one today. Back to `+`, which keeps both quote styles and restores the loud failure. Verified: an empty label now reports 44 hrefs against 43 labels. --- website/test/ssr/docs-links.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 6bb42f538..4a88f4224 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -271,8 +271,12 @@ test('every docs sidebar label and section title is Title Case', async () => { // directory, so nothing in this file would have caught a sentence-cased // relabel of it. The quote character is captured and back-referenced instead // of hard-coded, which reads both styles and lets a label carry the other - // quote inside it. - const quoted = (key) => new RegExp(`\\b${key}:\\s*(['"])(.*?)\\1`, 'g'); + // quote inside it. It stays `+` rather than `*` so an EMPTY value does not + // match: an empty label is a nav row with no visible text, and leaving it + // unmatched is what makes the count cross-check fire on it. Matching it as + // an empty string would keep the counts equal and then skip it at the + // has-a-letter test below, which is silent. + const quoted = (key) => new RegExp(`\\b${key}:\\s*(['"])(.+?)\\1`, 'g'); const labels = [...nav.matchAll(quoted('label'))].map((m) => m[2]); const titles = [...nav.matchAll(quoted('title'))].map((m) => m[2]); const hrefs = [...nav.matchAll(quoted('href'))].map((m) => m[2]); From f9349277041a153746e1576fa59c829b808c1165 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 00:16:36 +0530 Subject: [PATCH 13/15] test: stop the parse comment promising which assertion catches what The comment said an empty value stays unmatched and that this is what makes the count cross-check fire. That is true for one shape out of four. Measured: an empty label in { href, label } order does diverge the counts, but in { label, href } order the label pattern swallows "', href: " as its value, so the counts stay equal at 44 and the entry surfaces as a garbage row in the offender list instead. An empty href keeps the counts equal for the same reason, and an empty section title takes titles from 6 to 5, which still clears its floor. The helper serves three keys, so a sentence about what happens to "an empty value" was always going to be wrong for two of them. It now states what the pattern DOES and says plainly that the cross-check is a signal rather than a guarantee, since a malformed entry can surface through the offender list instead, which is equally loud. The cross-check message also diagnosed the parser rather than the nav, telling the reader the test cannot read the entry, which points at widening the regex, back to the `*` the previous commit reverted. It now names an empty value as a usual cause and says to fix the entry first, widening only if the entry is legitimate. Both empty-label orderings verified to fail loudly, by different assertions, which is what the comment now claims and no more. --- website/test/ssr/docs-links.test.ts | 33 +++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 4a88f4224..2b483f04b 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -257,25 +257,22 @@ test('every docs sidebar label and section title is Title Case', async () => { const nav = layout.slice(start, end); assert.ok(!nav.includes('generateMetadata'), 'the NAV_SECTIONS slice ran past the end of the literal'); - // Two ways an entry can slip out of this check unnoticed, both closed here. + // Three deliberate choices in this one regex, each closing a way an entry + // could go unread. Stated as what the pattern DOES, not as a promise about + // which assertion catches a given malformed entry: the parse degrades + // differently depending on which key is malformed and in what order the keys + // are written, so any such promise would be true for some shapes only. // - // Anchoring `label:` to a preceding `href:` yields NOTHING for an entry - // written `{ label: '...', href: '...' }`, so a key reorder would drop it. - // Each key is therefore read on its own, and the href count is the - // cross-check, since every nav item has one of each. + // Each key is read on its own rather than anchored to a neighbour, since an + // `href:`-anchored pattern reads nothing from `{ label: '...', href: '...' }`. + // The quote character is captured and back-referenced rather than hard-coded, + // so both quote styles parse and a value may carry the other quote inside it. + // And it takes `+` rather than `*`, so an empty value does not quietly become + // an empty string that the has-a-letter test below then skips. // - // Matching only single quotes would miss a double-quoted entry, and when - // BOTH its keys are double-quoted the counts stay equal, so the cross-check - // would not notice either. The sibling orphan test covers that for a - // `/docs/*` entry, but NOT for the one `/ui` cross-link, which is not a doc - // directory, so nothing in this file would have caught a sentence-cased - // relabel of it. The quote character is captured and back-referenced instead - // of hard-coded, which reads both styles and lets a label carry the other - // quote inside it. It stays `+` rather than `*` so an EMPTY value does not - // match: an empty label is a nav row with no visible text, and leaving it - // unmatched is what makes the count cross-check fire on it. Matching it as - // an empty string would keep the counts equal and then skip it at the - // has-a-letter test below, which is silent. + // The href count is a cross-check on the label count. It is a useful signal, + // not a guarantee: a malformed entry can also surface as a garbage row in the + // offender list instead, which is equally loud and names the entry. const quoted = (key) => new RegExp(`\\b${key}:\\s*(['"])(.+?)\\1`, 'g'); const labels = [...nav.matchAll(quoted('label'))].map((m) => m[2]); const titles = [...nav.matchAll(quoted('title'))].map((m) => m[2]); @@ -284,7 +281,7 @@ test('every docs sidebar label and section title is Title Case', async () => { assert.equal( labels.length, hrefs.length, - `parsed ${hrefs.length} hrefs but ${labels.length} labels: a nav entry is written in a shape this test cannot read, so it is silently not being checked`, + `parsed ${hrefs.length} hrefs but ${labels.length} labels: a nav entry did not parse, so it is not being checked. Usual causes are an empty value or a quoting style this regex does not read. Fix the nav entry if it is malformed; widen the regex only if the entry is legitimate`, ); // Floors matching the sibling checks in this file, so a regex that stops // matching fails here instead of passing empty. From 07526c2be4a5eb21d0ab510d00cb6ac22e6128a9 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 10:30:09 +0530 Subject: [PATCH 14/15] test: stop a minor word shadowing the VS brand in a label MINOR_WORDS is matched case-insensitively, so an entry in it shadows any fixed-casing word that lowercases to the same string, and the shadow wins because the minor-word check runs on the lowercased token. 'vs' was in the list, so the VS of 'Editor Setup (Neovim, VS Code)' never reached the capital check at all, and a drift to 'vs Code' passed with zero offenders. Verified both ways: the drift now fails naming 'vs', and the real label still passes. VS moves to FIXED_CASING, where it belongs, and 'vs' comes out of MINOR_WORDS. Nothing uses it as a minor word today, and a future 'WebJs vs Next.js' will red once with a message that says where to put it. The list now carries a note about the shadowing, so the next person adding a short word checks first. Also makes the parse read an escaped same-quote. `(.+?)` with a back-reference stopped at the backslash in 'What\'s New' and captured 'What\', leaving the rest of the label unchecked with no count mismatch and no offender, so it passed green while reading almost none of it. The value now consumes an escape pair or any non-terminator, verified against a label written that way. And annotates the helper's parameter, which was an implicit any in a .ts file. website/test is outside the tsconfig include, so nothing would have caught it. --- website/test/ssr/docs-links.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index 2b483f04b..a2b4e85f2 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -227,16 +227,26 @@ test('every docs sidebar label and section title is Title Case', async () => { // 'Deploying with Docker'. Only 'Migrating from Next.js' exercises it today. // The rest are here so the first 'Deploying on Railway' does not red CI for a // label that was never wrong. + // + // This list is matched case-INSENSITIVELY, so an entry here SHADOWS any + // fixed-casing word that lowercases to it, and the shadow wins because a + // minor word is checked against the lowercased token. 'vs' is deliberately + // absent for that reason: it would swallow the VS of 'Editor Setup (Neovim, + // VS Code)', and a drift to 'vs Code' would then read as an ordinary minor + // word and pass. Before adding a word here, check no label depends on its + // capitalised form; if one does, it belongs in FIXED_CASING instead. const MINOR_WORDS = new Set([ 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'if', 'in', 'into', 'nor', 'of', 'off', 'on', 'onto', 'or', 'over', 'per', 'so', 'the', 'to', - 'up', 'via', 'vs', 'with', 'yet', + 'up', 'via', 'with', 'yet', ]); // Words whose casing is fixed by something other than prose, so recasing // them would be WRONG rather than a correction. Two kinds qualify: a code // token ('@webjsdev/ui', 'createAuth', 'webjs check', 'package.json') and a - // brand that starts lowercase ('macOS', 'iOS', 'npm'). No structural rule + // brand with non-prose capitalisation ('macOS', 'iOS', 'npm', and the VS of + // 'VS Code', which is here rather than left to MINOR_WORDS because 'vs' + // there would shadow it). No structural rule // can carry this: shape detects '@webjsdev/ui' and 'createAuth', but a // future 'webjs check' label is two ordinary lowercase words, // byte-indistinguishable from the slip this test hunts. So the exemption is @@ -244,7 +254,7 @@ test('every docs sidebar label and section title is Title Case', async () => { // spelling is correct, not a slip". Matched against the // word with wrapping punctuation stripped, so it survives a rename to // 'Auth Providers (createAuth API)'. - const FIXED_CASING = new Set(['createAuth', '@webjsdev/ui']); + const FIXED_CASING = new Set(['createAuth', '@webjsdev/ui', 'VS']); const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); // Slice to the NAV_SECTIONS literal. Outside it sit the docs-scoped metadata @@ -273,7 +283,7 @@ test('every docs sidebar label and section title is Title Case', async () => { // The href count is a cross-check on the label count. It is a useful signal, // not a guarantee: a malformed entry can also surface as a garbage row in the // offender list instead, which is equally loud and names the entry. - const quoted = (key) => new RegExp(`\\b${key}:\\s*(['"])(.+?)\\1`, 'g'); + const quoted = (key: string) => new RegExp(`\\b${key}:\\s*(['"])((?:\\\\.|(?!\\1).)+)\\1`, 'g'); const labels = [...nav.matchAll(quoted('label'))].map((m) => m[2]); const titles = [...nav.matchAll(quoted('title'))].map((m) => m[2]); const hrefs = [...nav.matchAll(quoted('href'))].map((m) => m[2]); From 9d9bfdea92d805e8c57e16825eaf19c7c93a25e4 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 11:06:59 +0530 Subject: [PATCH 15/15] test: enforce the set-collision rule instead of describing it Three things, all on the guard rather than around it. The comment I added with the VS fix stated the mechanism backwards. FIXED_CASING is checked before MINOR_WORDS, so a minor word cannot swallow a correctly-cased VS; what it actually does is permit the DRIFTED spelling, because FIXED_CASING is exact-case and never matches 'vs'. The old wording described the pre-fix code and told a reader the two checks run in the opposite order from the source. Worse, the 'VS' entry was documentation, not protection. It changes no verdict on its own, and if someone re-adds 'vs' to MINOR_WORDS the hole reopens silently. That path is not hypothetical: remedy 3 of this test's own failure message tells the next person to add a lowercase-in-title word to MINOR_WORDS. So the rule is now asserted, and re-adding 'vs' reds with a message naming the collision. Adds the cross-check section titles never had. Labels are counted against hrefs, but titles had only a floor, so an empty section title dropped the count from 6 to 5, cleared "> 3", and passed in silence. Sections are counted independently by their items: key, the way hrefs count entries independently of labels. --- website/test/ssr/docs-links.test.ts | 39 ++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/website/test/ssr/docs-links.test.ts b/website/test/ssr/docs-links.test.ts index a2b4e85f2..88ea18d7c 100644 --- a/website/test/ssr/docs-links.test.ts +++ b/website/test/ssr/docs-links.test.ts @@ -228,13 +228,15 @@ test('every docs sidebar label and section title is Title Case', async () => { // The rest are here so the first 'Deploying on Railway' does not red CI for a // label that was never wrong. // - // This list is matched case-INSENSITIVELY, so an entry here SHADOWS any - // fixed-casing word that lowercases to it, and the shadow wins because a - // minor word is checked against the lowercased token. 'vs' is deliberately - // absent for that reason: it would swallow the VS of 'Editor Setup (Neovim, - // VS Code)', and a drift to 'vs Code' would then read as an ordinary minor - // word and pass. Before adding a word here, check no label depends on its - // capitalised form; if one does, it belongs in FIXED_CASING instead. + // This list is matched case-INSENSITIVELY while FIXED_CASING is matched + // exact-case, so an entry here whose spelling collides with a fixed-casing + // word permits that word's DRIFTED form. The correctly-cased word is fine + // either way, since FIXED_CASING is checked first, but the drift is not: + // FIXED_CASING never matches 'vs', so with 'vs' in this list a slip from + // 'VS Code' to 'vs Code' reads as an ordinary minor word and passes. That + // is why 'vs' is absent. The assertion below enforces it rather than + // trusting this comment, because remedy 3 of the failure message sends the + // next person here to add exactly this kind of word. const MINOR_WORDS = new Set([ 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'if', 'in', 'into', 'nor', 'of', 'off', 'on', 'onto', 'or', 'over', 'per', 'so', 'the', 'to', @@ -245,8 +247,7 @@ test('every docs sidebar label and section title is Title Case', async () => { // them would be WRONG rather than a correction. Two kinds qualify: a code // token ('@webjsdev/ui', 'createAuth', 'webjs check', 'package.json') and a // brand with non-prose capitalisation ('macOS', 'iOS', 'npm', and the VS of - // 'VS Code', which is here rather than left to MINOR_WORDS because 'vs' - // there would shadow it). No structural rule + // 'VS Code'). No structural rule // can carry this: shape detects '@webjsdev/ui' and 'createAuth', but a // future 'webjs check' label is two ordinary lowercase words, // byte-indistinguishable from the slip this test hunts. So the exemption is @@ -256,6 +257,17 @@ test('every docs sidebar label and section title is Title Case', async () => { // 'Auth Providers (createAuth API)'. const FIXED_CASING = new Set(['createAuth', '@webjsdev/ui', 'VS']); + // The two sets must not collide, or the MINOR_WORDS entry permits the drifted + // spelling and the guard goes quiet on it. Asserted rather than left to the + // comment above, since 'vs' sat in MINOR_WORDS and let 'vs Code' pass until + // it was measured. + const collisions = [...FIXED_CASING].filter((w) => MINOR_WORDS.has(w.toLowerCase())); + assert.deepEqual( + collisions, + [], + `these words are in FIXED_CASING and also in MINOR_WORDS: ${collisions.join(', ')}. MINOR_WORDS is matched on the lowercased token, so it permits the drifted spelling of each one. Remove them from MINOR_WORDS`, + ); + const layout = await readFile(resolve(DOCS_ROOT, 'layout.ts'), 'utf8'); // Slice to the NAV_SECTIONS literal. Outside it sit the docs-scoped metadata // block (its own `title:` keys) and the shell call's aria labels @@ -287,12 +299,21 @@ test('every docs sidebar label and section title is Title Case', async () => { const labels = [...nav.matchAll(quoted('label'))].map((m) => m[2]); const titles = [...nav.matchAll(quoted('title'))].map((m) => m[2]); const hrefs = [...nav.matchAll(quoted('href'))].map((m) => m[2]); + // Every section object carries exactly one `items:`, so this counts sections + // independently of their titles, the way hrefs count entries independently + // of their labels. + const sections = [...nav.matchAll(/\bitems:/g)].length; assert.equal( labels.length, hrefs.length, `parsed ${hrefs.length} hrefs but ${labels.length} labels: a nav entry did not parse, so it is not being checked. Usual causes are an empty value or a quoting style this regex does not read. Fix the nav entry if it is malformed; widen the regex only if the entry is legitimate`, ); + assert.equal( + titles.length, + sections, + `parsed ${sections} sections but ${titles.length} section titles: a section title did not parse, so it is not being checked. Same causes as above. Without this a section title could go empty and only drop the count, which the floor below would still clear`, + ); // Floors matching the sibling checks in this file, so a regex that stops // matching fails here instead of passing empty. assert.ok(labels.length > 40, `sanity: expected the full sidebar, parsed ${labels.length} labels`);