docs: settle the sidebar label casing for the two auth pages - #1295
Conversation
vivek7405
left a comment
There was a problem hiding this comment.
Read the whole diff: the two label renames, the seven inbound anchors, the new casing guard, and the AGENTS.md convention.
The rename half is right and the reasoning behind it holds up. Title Case is where 42 of the 44 entries already sat, metadata.title was already correct on both pages so no derived surface moves, and keeping createAuth lowercase inside the label is the call every comparable docs site makes.
The guard test is the part I'd keep an eye on, and it mostly earns its keep: reading label: unanchored with an href cross-check is the right instinct, because the obvious href-anchored version would silently stop checking any entry someone reordered. Two real problems though, and the worse one is in AGENTS.md rather than the code. The convention paragraph asserts a rule the docs do not follow and credits a test that does not enforce it, which is the kind of wrong that actively misleads an agent into "fixing" four correct pages. The other is the guard's own failure message contradicting the code path directly beneath it.
Comments inline.
vivek7405
left a comment
There was a problem hiding this comment.
Second pass, scoped to the fix commit and what it touches.
The failure-message correction holds up. The printed word is the same value the exemption set is looked up with, so "spelled exactly as this message printed it" is now literally true, and the cache() to cache example is right.
The AGENTS.md rewrite did not. It swapped one false claim for another, which is worse than the original because the whole point of that commit was to stop the file asserting things the docs do not do. I measured the sidebar: 39 of the 43 doc entries already read exactly the same in the label and the h1, so "a nav label may be shorter, and usually is" inverts the norm. Both examples it gave were longer labels, not shorter ones, so they argued against their own sentence. The historical clause was wrong twice over as well.
The root cause is that I took the claim from the test's own comment instead of measuring, and that comment has been wrong since it was written. So it is fixed in both places now, which is the only way the convention and the test stop contradicting each other.
vivek7405
left a comment
There was a problem hiding this comment.
Third pass over the same paragraphs, and it found the subtlest one yet.
The direction of the claim was right this time, but the basis was not. The census I quoted (39 match, 4 diverge) is what you get after decoding HTML entities, while the pin it is reasoning about is byte-equal and implemented as a source-byte comparison. On that basis it is 38 and 5. The missing fifth is /docs/conventions, whose h1 escapes its ampersand where the label carries the raw character, so it reads identically and compares unequal. That is precisely the surprise waiting for anyone who widens this test, so it is worth naming rather than rounding away.
Second thing, and it has survived two rounds: the pin's stated rationale did not justify the pin. Two pages sharing an h1 and a metadata title is a duplicate-title problem, and there is already a separate test for that. What justifies THIS test is narrower and sharper, that /docs/auth rendered a heading byte-identical to its sibling's label, so clicking one nav entry landed you on a heading naming the other page.
Also corrected a stale count of 45 in two older comments, since the 43 introduced here would otherwise contradict it a few lines away in the same file.
Comments inline.
vivek7405
left a comment
There was a problem hiding this comment.
Fourth pass, and this one is the useful one because it stopped looking at the sentences and looked at why they keep being wrong.
Every number in the previous commit checks out. The problem was structural. The same prose lived in two files with nothing keeping them in sync, so each fix had to land twice, and the two copies had already drifted apart on the one piece of arithmetic they both restated. That is four rounds of the same failure, and no amount of further wording care would have stopped a fifth.
There was also a contradiction the byte-basis correction created rather than removed. Stating that 38 of 43 entries are byte-equal IS an audit of all 43, so the sentence two paragraphs later claiming the other 41 had never been audited could not also be true. Both halves only collided once they moved onto the same basis.
So the counts, the examples and the taxonomy are out of AGENTS.md entirely and live in the test comment, next to the code that measures them and in front of the reader who would widen the pin. What stays in the contract is the part that cannot rot. The entry drops from 38 lines to 18, which matters in a block whose next-longest entry is 7.
I dropped the shorter/longer/reworded taxonomy instead of repairing it, because it did not survive checking: the reworded example is shorter than its heading and the Task entry filed under longer shares only a prefix, so length was never the axis. And I answered a question none of the four rounds had: why /docs/authentication is pinned at all.
vivek7405
left a comment
There was a problem hiding this comment.
Fifth pass, over the commit that pulled the counts out of the contract file.
That restructure held up. Everything left in the test comment measures correctly, the history checks out, and the removal did not strand anything an agent needs.
Two things it missed, both the same shape as the bug this PR fixes: a fact stated in one place and left stale in its neighbour. AGENTS.md described the pin as guarding a collision between the two headings, when the test compares each page's heading to its own label and never looks at the other page, so the sentence promised a guarantee the test does not make. And the stale page count I corrected twice in the test file was still sitting in two comments in layout.ts, which this PR edits anyway. That one got worse when the counts moved, since AGENTS.md now sends the reader to the test for numbers and layout.ts was the last place carrying a wrong one.
Comments inline.
vivek7405
left a comment
There was a problem hiding this comment.
Final read of the whole change rather than of any one commit.
The substance is right and I checked it rather than trusting the earlier passes: both metadata titles unchanged and already Title Case, all seven anchors moved with nothing missed repo-wide, the page counts, the five divergent slugs with conventions differing only by its ampersand escape, the #1103 history, and the /ui exemption.
Three things worth fixing, and the interesting one is that the guard's own comment and its failure message had each drifted from the code they sit next to.
The comment justified reading label: unanchored by saying a guard quietly checking 30 of 44 labels is the failure it prevents. The floor thirteen lines below demands more than 40, so 30 fails loudly and that scenario cannot happen. The real argument is narrower and still good: the floor only fires once four entries are missing, so the href cross-check is what makes a single dropped entry loud.
The failure message had no remedy for the one false-fire it will plausibly hit. A label like "Deploying on macOS" trips the capital check on a correctly spelled brand, and the message's first remedy, the one it calls the fix nearly every hit wants, tells the reader to recase it into MacOS. The exemption set already handles this mechanically, so I widened what it is documented to be for rather than adding anything.
One finding I am not taking: the report flagged the test-plan boxes as all unchecked. That was against a stale copy of the body, not the PR.
vivek7405
left a comment
There was a problem hiding this comment.
Check of the fix that widened the casing exemption to cover lowercase brands.
The mechanics were right. The floor arithmetic holds, and an exact-case lookup on the stripped token means adding macOS genuinely silences a label containing it. But the fix only reached the two places that DESCRIBE the exemption and missed both places that STATE the rule, so the test's header and the AGENTS.md line still said prose or code identifier, a dichotomy with no room for a brand. The enforcement permitted macOS while both statements of the convention forbade it, and AGENTS.md is the one an author actually follows.
The other half is a self-inflicted overclaim. I had written that the href cross-check makes even a single dropped entry loud. It does not: an entry written with double quotes on BOTH keys is invisible to both regexes at once, so the counts stay equal and the guard passes over it. I confirmed it by double-quoting one entry, which walked a sentence-case label straight past the guard. It is not a hole in the file, since the orphan test reds on that shape, but promising a guarantee the test does not make is exactly what the previous round pulled me up on, so the comment now names the gap and where it is really caught.
Comments inline.
vivek7405
left a comment
There was a problem hiding this comment.
Second check of the fix chain, and it found the gap I had documented was documented WRONG.
I had written that a double-quoted entry is caught by the sibling orphan test. That is true for a /docs entry and false for the one /ui cross-link, which is not a doc directory. Double-quoting both keys of it leaves labels and hrefs equal at 43, above the floor, with no orphan reported, so nothing in the file would have caught a sentence-cased relabel of the Component Library link. I had replaced an overclaim with a narrower overclaim.
So this stops describing the hole and closes it. The quote character is captured and back-referenced rather than hard-coded, which reads both styles and lets a label carry the other quote inside it. Both shapes that used to escape now fail the guard by name.
Also renamed the exemption set. It was still called IDENTIFIERS after being widened to hold brands, and macOS is not an identifier in any sense, so the symbol disagreed with the rule it encodes. That was the last surviving piece of the pre-widening wording.
I am not calling this converged. This is the second consecutive check to find something, and by the cycle's own rule that is the point to stop rather than push for a clean pass. These fixes are on the branch unreviewed.
vivek7405
left a comment
There was a problem hiding this comment.
Read the quote-agnostic parse and the FIXED_CASING rename, with the surrounding file for context. Nothing wrong with either.
Scope note so the trail is honest: this read the tree at 13364976. While it was running I found and pushed one more thing on top, so this clean result does NOT cover a14e056b. That commit is looked at separately.
The thing I found there is worth recording, because widening the parse to both quote styles changed more than the quote character. The old pattern required at least one character and the replacement matched zero, so an empty 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. It is back to requiring a character, which keeps both quote styles and restores the loud failure.
vivek7405
left a comment
There was a problem hiding this comment.
Check of the commit that made the parse require a non-empty value.
The change itself is right and I confirmed it: with the plus, an empty label leaves the counts uneven and the entry gets caught; the star let it match as an empty string and vanish at the has-a-letter test. Both quote styles, a value carrying the other quote, and a one-character value all still parse, and no legitimate nav value is empty, so there is no false failure.
What was wrong is the sentence I wrote around it, for the fourth time in this PR. It promised that an empty value stays unmatched and that the count cross-check is what fires on it. That is true for one shape out of four. In the other key order the label pattern swallows the rest of the line as its value, so the counts stay even and the entry surfaces as a garbage row in the offender list instead. An empty href behaves the same way, and an empty section title has no cross-check at all, only a floor it still clears.
The helper serves three keys, so any single sentence about what happens to an empty value was going to be wrong for two of them. I have stopped writing that kind of sentence here. The comment now says what the pattern does and calls the cross-check a signal rather than a guarantee. The assertion message also pointed the reader at the parser rather than the nav entry, which aimed them straight back at the star this commit had just reverted.
The pattern across this PR is mine and worth naming: the code has been right throughout, and every round has found a comment claiming more than it can deliver. The fix that finally stuck was writing less, not writing more carefully.
vivek7405
left a comment
There was a problem hiding this comment.
Final read of the whole change, and unlike the rounds before it this one found a hole in the GUARD rather than in the prose around it.
MINOR_WORDS is matched case-insensitively, so any entry in it shadows a fixed-casing word that lowercases to the same string, and the shadow wins because the minor-word branch runs on the lowercased token. 'vs' was in that list, so the VS of "Editor Setup (Neovim, VS Code)" never reached the capital check, and a drift to "vs Code" passed with zero offenders. That is the guard failing to guard, in the one label where a brand and a minor word collide. VS has moved to FIXED_CASING, 'vs' is out of MINOR_WORDS, and the list now carries a note so the next short word added gets checked against the labels first.
The parse also stopped at an escaped same-quote: "What's New" captured "What" and left the rest of the label unread, with no count mismatch and no offender, so the test passed green having checked almost none of it. The value now consumes an escape pair or any non-terminator.
Two smaller ones: the helper's parameter was an implicit any in a .ts file, which nothing catches because website/test sits outside the tsconfig include; and the PR body still quoted a line count from an earlier commit. I dropped the number rather than correcting it, since it had already rotted twice.
Worth recording what this run of reviews actually taught me. The code was right from the first commit and the churn was almost entirely my own claims about it, so the fixes that stuck were the ones that removed a claim rather than reworded it. This round is the exception that justifies the whole exercise: two of the four findings are real defects in shipped behaviour, and both were invisible to every test.
vivek7405
left a comment
There was a problem hiding this comment.
Delta round on the VS and escaped-quote commit.
The mechanics hold: the drift direction works, no other label changed verdict when 'vs' left MINOR_WORDS, the new capture handles both quote styles, the other quote inside a value, an escaped same-quote and a one-character value, and it still refuses an empty value.
Two problems, and the second is the one that mattered. My comment stated the mechanism backwards: FIXED_CASING is checked BEFORE MINOR_WORDS, so a minor word cannot swallow a correctly-cased VS. What a colliding minor word actually does is permit the DRIFTED spelling, since FIXED_CASING is exact-case and never matches 'vs'. The old wording described the code as it was before the fix.
And the 'VS' entry was documentation rather than protection. It changes no verdict on its own, and re-adding 'vs' to MINOR_WORDS reopens the hole silently. That path is not hypothetical, because 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 asserted now and the reopen reds with a message naming the collision.
Separately I closed the gap I had flagged rather than leaving it: section titles had no cross-check, only a floor, so an empty one dropped the count from 6 to 5, cleared "> 3" and passed silently. Sections are now counted independently by their items: key, the way hrefs count entries independently of labels.
All three verified by counterfactual, run after committing so the toggle restores the fix rather than discarding it.
d5b3498 to
68ff1f5
Compare
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.
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.
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.
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 befd1d8^ both rendered <h1>Authentication</h1>, 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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
68ff1f5 to
9d9bfde
Compare
Closes #1259
The docs sidebar was Title Case in 42 of its 44 entries and in all 6 section titles. The two auth pages were the only exceptions, and each contradicted itself as well: its
metadata.titlewas already Title Case while its nav label and<h1>were sentence case, so one page carried both conventions. This title-cases the pair, moves the seven inbound anchors that name them, and writes the convention down with a guard test so it cannot drift a third time.Why Title Case
Two independent lines of evidence, both measured.
Internal. Of the 44 entries, 24 multi-word ones are unambiguously Title Case, 16 are single words and casing-neutral,
Migrating from Next.jsreads the same either way,@webjsdev/ui (AI-first)is a package name, and the remaining 2 are this pair. All 6 section titles are Title Case. So the scope is those two entries, not a list-wide normalization, and Title Case is also the far cheaper direction (2 edits against 26 labels plus 6 section titles).External. Across 11 framework docs sites, Title Case leads roughly 8 to 2. Title Case: Next.js (88% of multi-word
nav_titlevalues), Rails (100% of 83), Vite, Remix v2, Remix v3, Nuxt, TanStack, Bun, shadcn. Sentence case: Tailwind (100% of 196) and Svelte. The split is not arbitrary: Title Case clusters in guide-heavy sidebars, sentence case in reference-heavy ones that are mostly literal identifiers (Tailwind's is 185 CSS property names out of 196). WebJs's sidebar is guide-heavy prose. Qwik, which never wrote a rule down, ships bothAPI ReferenceandAPI referencein one sidebar, which is why this adds a guard rather than only fixing the strings.All 11 keep code identifiers verbatim, with zero counterexamples. Next.js's
nav_titlekey exists partly to UNDO Title Case and restore one (turbopackFileSystemCache,webpack,'use client'). HenceAuth Providers (createAuth)and notCreateAuth.What changed
app/docs/layout.ts, the two labels.app/docs/{auth,authentication}/page.ts, the two<h1>values plus the cross-link each carries for the other. Nometadata.titlevalue moves: both chosen strings already ARE the existing title minus the| WebJssuffix.app/docs/{cache,sessions,security,typescript,rate-limiting}/page.ts, the five inbound anchors that name one of the pages. Nothing tests anchor text, so these were found and verified by grep.test/ssr/docs-links.test.ts, the new guard, plus corrections to two neighbouring comments (see below).AGENTS.md, the convention.Regression surface
Traced before implementing.
NAV_SECTIONSis module-local and never exported;labelreaches exactly one line,lib/ui/docs-shell.ts:307.lib/docs-llms.server.tsreads the layout for hrefs only and never captureslabel, page titles come frommetadata.title, and the<h1>is stripped from the markdown body. So/llms.txt,/llms-full.txt,/docs/<topic>/llms.txt,/sitemap.xml, and the search index change zero bytes, which I confirmed by fetching/llms.txtrather than assuming. Docs pages emit no JSON-LD, there is no RSS route, and the docs OG title is a hardcoded constant. No redirect map or priority table keys off label text. Outsideapp/docs/, the two phrases appear nowhere in the repo.The guard test
every docs sidebar label and section title is Title Case. Each choice is tied to a failure it prevents:NAV_SECTIONSliteral first. Load-bearing, not defensive: outside it sit the metadata block's owntitle:keys andmenuLabel: 'Documentation menu', which is legitimately sentence case and would be a false positive.labels.length === hrefs.length. Anhref:-anchored regex yields nothing for an entry written{ label, href }, so a key reorder would silently drop it, and the floor alone is too coarse to notice, firing only once four entries are missing. A single-quote-only regex would likewise 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 quote character is captured and back-referenced instead, which closes that.createAuth. The parenthesized form would red CI on a legitimate rename to(createAuth API).FIXED_CASING). It covers any word whose casing is fixed by something other than prose, meaning code tokens and brands that start lowercase (macOS,npm), since recasing either misspells it. Shape detection cannot carry this: a futurewebjs checklabel is two ordinary lowercase words. The friction is the feature: adding an entry records "this spelling is correct, not a slip".Deploying on Railwaydoes not red CI for a label that was never wrong.app/docs/layout.ts, neverapp/ui/layout.ts, whose labels are registry component names and would be 32 false positives.Where the convention lives, and why
The review rounds kept finding false claims in the same paragraphs because the same facts were stated in both
AGENTS.mdand the test comment, with nothing keeping them in sync, so every fix had to land twice and the copies drifted apart again.So
AGENTS.mdnow carries only what cannot rot: labels are Title Case, identifiers keep their casing, a label need not match the page's h1 and the ones that differ are correct, the pin covers two named slugs and must not be generalised,/uiis exempt. Every count, example and taxonomy lives in the test comment, beside the code that measures it and in front of whoever would widen the pin. That entry shed most of its bulk, which matters in a tree block whose next-longest entry is 7 lines.Two corrections to neighbouring pre-existing comments, both the same class of bug this PR is about (a fact repaired in one file and left stale in another):
a doc page h1 matches its sidebar labelclaimed the rest of the docs use a shorter nav label than their heading. Measured, 38 of the 43 doc entries are byte-equal, so it had the norm backwards. It also said both pages disagreed with their nav entries, when/docs/authenticationwas labelledAuthenticationand agreed with itself exactly.docs-links.test.ts(twice) andapp/docs/layout.ts(twice). There are 43.Test plan
node --test website/test/ssr/docs-links.test.ts, 8 tests (up from 7), all pass<h1>redsa doc page h1 matches its sidebar label, naming the slughref:-anchored regex would have dropped silentlyparsed 44 hrefs but 43 labels) instead of quietly checking one fewerRuntime (Node & Bun),Migrating from Next.js,Editor Setup (Neovim, VS Code),@webjsdev/ui (AI-first)) are in the checked setcd website && npm test, full suite green (443 server tests + 84 browser)webjs checkonwebsite, all checks passgrepfor the sentence-case strings across rendered source returns nothing/docs/authand/docs/authenticationrender 200 with the correct<h1>/llms.txtfetched: both entries already Title Case, zero bytes changedDoc surfaces
website/AGENTS.md(the convention, pointing attest/ssr/docs-links.test.tsfor the specifics), and the docs site itself, which is what this PR edits.AGENTS.md, the skill at.agents/skills/webjs/,packages/*/README.md, the scaffold templates and generators, the MCP server, the editor plugins,website/app/page.tsmarketing copy, and any changelog or version bump.packages/moves, no runtime-sensitive surface is touched, and no client behaviour changes. The whole change is server-rendered strings the SSR suite covers. The four-app dogfood check is not triggered for the same reason, thoughwebsiteis one of those apps and the SSR suite boots it throughcreateRequestHandlerin prod mode and fetches every doc page. CI runs all of these regardless and was green.