Skip to content

fix(website): fold JS escapes out of the llms corpus - #1358

Merged
vivek7405 merged 4 commits into
mainfrom
fix/llms-fence-escapes
Aug 9, 2026
Merged

fix(website): fold JS escapes out of the llms corpus#1358
vivek7405 merged 4 commits into
mainfrom
fix/llms-fence-escapes

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #1344

The llms corpus builder copied text straight out of page source with only entity decoding applied. A docs page body is a JS template literal, so a backtick in it is written \` and a literal hole \${, and the corpus carried that debris: 853 backslashes across 29 of 44 pages, including 5 form bindings that taught <form action=\${createPost}> where the rendered page shows <form action=${createPost}>. The corpus is the surface whose only reader is an LLM, and invariant 12 governs exactly that shape.

#1331 had already taught the two prose HOLE passes to fold, so this finishes the job on the other two paths: fenced samples, which is what #1344 asks for, and ordinary prose, which review turned up as the same defect from the same cause and which is fixed here rather than documented as a limit.

What changed

  1. website/lib/docs-llms.server.ts, the fenced-capture site: run the module's existing unescapeJs before decodeEntities.
  2. website/lib/docs-llms.server.ts, prose: one fold pass over the body. It runs AFTER the two hole passes because those are what tell a literal \${x} from a render-time ${x}, so folding first would erase the distinction and the dynamic-hole pass would drop the literal. It runs BEFORE the hole restore, so text those passes already folded cannot fold twice. The single entity decode still comes last, so the module's "strip at every stage, decode exactly once, at the end" rule holds verbatim.
  3. website/lib/docs-llms.server.ts, page title and description: the fold moves inside plainText, which both description sites already go through, and comes off the metadata call site so neither folds twice. A second fold is destructive, since it eats a backslash an author deliberately wrote as \\. This is the path review caught: only 8 of 44 pages declare metadata.description, so 36 fall back to their first <p>, and that fallback never folded, which meant a paragraph could reach /llms-full.txt cooked while the same paragraph reached /llms.txt and the search index raw. No live page trips it, so the corpus is byte-identical and a fixture is what holds the rule.
  4. website/app/docs/backend-only/page.ts and website/app/docs/websockets/page.ts authored a regex with a single backslash, so the LIVE page already rendered replace(/s+/g, '-') and /;s*/. The fold is faithful, so without this it would ship those broken samples to the corpus verbatim. website/app/docs/api-routes/page.ts:296 was already correct and is the precedent. Both live pages were re-rendered through renderToString and now show \s.
  5. website/test/ssr/docs-llms.test.ts: the corpus walk's mirror of the extractor gains the same fold, plus four fixtures and an authoring guard.
  6. website/AGENTS.md: the docs-llms.server.ts inventory entry records the fold, its ordering, and where a backslash may still legitimately appear.

Why escapes fold before entities decode

That is the order the browser applies them: JS cooks the template literal first, and the HTML parser only ever sees cooked text. packages/core/src/html.js stores the strings array as handed to it and never touches .raw, exactly as lit-html does, so the cooked array is what the renderer sees.

Both orders were prototyped over the whole real corpus and produce byte-identical output, so the corpus cannot settle it. They differ on exactly one shape, an escape splitting an entity: `&am\p;` cooks to &amp;, which the parser then shows as &, while decoding first would ship &amp;. No docs page carries that shape, which is why a fixture holds the rule. The reverse order can never help either, because nothing in the entity table yields a backslash, so decoding cannot manufacture an escape for the fold to eat.

Accounted before-and-after

renderLlmsFull() at main versus at this branch's head.

value
line count 16,083 both, so no line added or removed
changed lines 480: 457 from the fenced fold, 23 from the prose fold
bytes 998,990 -> 998,157
backslashes in the corpus 853 -> 20
lines whose change is not purely backslash removal 0
lines that gained a backslash 0
grep -cF 'form action=\${' 5 -> 0
grep -cF 'form action=${' 24 -> 29
fence markers, balanced 1,234 both, balanced both
triple-backtick inside a fence 0 both
sentinel leakage (U+E000 / U+E001) 0 both

Every one of the 20 surviving backslashes is one an author wrote as \\ and meant: two curl line continuations, three regexes (the two fixed here plus api-routes), a '\n' in a string, a <\/p> in a regex, and 13 in the repo-root skill markdown that renderLlmsFull folds in verbatim without passing it through bodyToMarkdown, which is out of this change's reach by construction. Nothing in the docs-page half of the corpus carries escape debris any more.

The 833 removed backslashes account to the escape table (512 \`, 265 \$, 5 \\, 2 \s and 1 \} inside fences, plus 50 in prose), minus the 2 the regex fixes put back. Those two regex lines read identically before and after, which is why they do not appear in the changed set: \s+ in the old source and \\s+ in the new one both reach the corpus as \s+, the difference being that only the new one also renders correctly on the live page.

Test plan

  • website/test/ssr/docs-llms.test.ts: 24 pass. The corpus walk still compares 348 samples with 0 mangled, mirror extended rather than assertion weakened, so it stays well above its > 300 gate.
  • Counterfactuals, all three run at 723073cb with the change committed first and reverted through git, never by editing a sentinel into the source:
    • revert the fenced-capture fold: 2 fixtures red AND the corpus walk reds with exactly 27 mangled samples. That second one is worth noting, since extending the mirror does not merely keep the test passing, it turns the corpus walk itself into a counterfactual. 27 of the 348 compared samples contain a backslash, which is exactly the set that reds if mirror and extractor disagree in either direction.
    • revert the prose fold: the prose fixture reds.
    • revert either page's regex fix: the authoring guard reds with 1 offender. Checked on both pages.
    • revert the plainText fold: the description fixture reds.
    • restore the fold at the metadata description call site, so that value folds twice: the call-site guard reds. This is the one the fixtures could not see, because a double fold happens across two functions while a fixture drives plainText alone.
  • cd website && npm test: 469 node tests pass, 84 browser tests pass across 8 files.
  • cd website && npm run typecheck: clean.
  • npx webjs check: all checks pass. npx webjs doctor: 11 passed, 2 warnings, 0 failed (both warnings pre-existing).
  • node --test test/docs/llms.test.mjs: 11 pass, unchanged, it asserts nothing about escaping.
  • website/test/lib/doc-headings.test.ts and website/test/ssr/docs-search.test.ts: 11 pass with no edit. They consume this markdown and pin the fence predicate, and fence parity is unchanged.

Repo-root npm test: 4,143 pass, 5 fail. Those 5 are the documented linked-worktree failures (the two test/bun/listener* tests and three elision assertions), not this change. Proven rather than asserted: a detached worktree at origin/main, linked the same way, fails the identical 5 by name. They pass in the primary checkout and in CI.

Layers that do not apply: browser and e2e, because this is a .server.ts module whose output is served as text/plain, so nothing hydrates and the unit layer holds the exact bytes. Bun parity does not apply, checked against the hook rather than asserted: .claude/hooks/require-bun-parity-with-runtime-src.sh scopes its runtime-sensitive match to ^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, which nothing under website/ can match, and on the merits this is a pure string-to-string transform with no listener, serializer, crypto or stream surface. Smoke covers the example apps, not the marketing site. Dogfood: the website IS the app under change, and its own suite plus webjs check and webjs doctor are reported above; examples/blog is untouched by a website/lib change.

Docs surfaces

  • website/AGENTS.md updated, as above. That is the doc surface for this change.
  • Everything else N/A: this is website-internal server code exporting no public API, so no @webjsdev/* export, CLI flag, webjs config key, html hole prefix, lifecycle hook or app-author convention moved. Root AGENTS.md, the skill at .agents/skills/webjs/, README.md, CONVENTIONS.md and the scaffold templates all stay untouched. The two docs-page edits are content corrections rather than doc-surface sync.

Deliberately not in this change

One thing #1344 scoped out and I left alone, worth your call rather than mine: the fenced path copies a ${'...'} string-literal hole verbatim, so website/app/docs/data-fetching/page.ts:36 reaches the corpus as .fallback=${'${html`<p>Loading section...</p>`}'} where the rendered page shows .fallback=${html`<p>Loading section...</p>`}. About 40 fence lines carry that shape. It is a real and separate defect, it is not an escape problem, and fixing it would collide with a fenced sample keeps the interpolation holes and indentation the prose pipeline would eat, which depends on a genuine ${children} surviving a fence verbatim.

@vivek7405 vivek7405 self-assigned this Aug 9, 2026

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole diff. The fold itself is right, and the browser-order argument holds up: I enumerated every backslash escape that occurs inside a code-block across all 44 docs pages and the set is exactly \``, $, \and one}, none of which is a shape where JS cooking and a naive (.)` fold disagree. The accounted before-and-after does what it claims, and extending the walk mirror rather than weakening its assertion is the right instinct, since it turns the corpus walk itself into a second counterfactual.

Two things I want handled before this goes in, both about what the change leaves behind rather than what it does.

The first is that the two docs-page content fixes ship with nothing that would notice if they regressed. That matters more here than it normally would, because the whole argument for this PR is that the extractor now copies an authoring error into the corpus verbatim as a teaching sample. The next page that writes /\s+/g reproduces the exact defect being fixed, silently, on the live page and in the corpus.

The second is the residue. The table says 853 to 70 and stops there, and the AGENTS.md addition states an escape rule without saying where it stops applying. 50 of those 70 are the same defect in prose code spans, so the corpus still disagrees with the page it was generated from on 8 pages.

Comment thread website/app/docs/backend-only/page.ts
Comment thread website/AGENTS.md Outdated
@vivek7405 vivek7405 changed the title fix(website): fold JS escapes out of fenced llms corpus samples fix(website): fold JS escapes out of the llms corpus Aug 9, 2026

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-read the prose fold and traced what it touches, and it turned up the thing the first pass missed: the fold now claims to be universal and is not.

extractPage has a FOURTH path that copies out of page source, the fallback description, and it is the majority path rather than an edge: only 8 of 44 pages declare metadata.description, so 36 fall back to their first <p>, and that one never folded. A paragraph would reach /llms-full.txt cooked while the same paragraph reached /llms.txt and the search index with its escape debris intact, which is the exact self-disagreement this PR says it removed. Nothing trips it today, so it is latent rather than a live regression, but it was left open while the docs asserted it closed.

The other two are the claims themselves. The prose count is off by unit, and the AGENTS.md entry reads as if no backslash survives anywhere, which is not true of the half of the corpus that never passes through this extractor at all.

Comment thread website/lib/docs-llms.server.ts
Comment thread website/lib/docs-llms.server.ts

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the description fold and what it claims. The behaviour is right and the numbers all check out, but the commit wrote a rule and left it unenforced, and it made the doc surface disagree with the file it documents.

The rule is "fold exactly once per path". I restored the fold at the metadata call site on top of this head, so that value folds twice, and all 23 tests stayed green. The fixture the comment credits for guarding it drives plainText alone, so it can only see a double fold inside one function, and a double fold here happens across two. No docs page carries a backslash in its description either, so the corpus walks have nothing to compare.

The other one is smaller but it is in the file this PR designates as its doc surface, and it contradicts a code comment the same commit wrote.

Comment thread website/test/ssr/docs-llms.test.ts
Comment thread website/AGENTS.md
@vivek7405
vivek7405 force-pushed the fix/llms-fence-escapes branch from 62fbdff to 07de54d Compare August 9, 2026 09:08
@vivek7405
vivek7405 marked this pull request as ready for review August 9, 2026 09:08
The llms corpus builder unescaped prose template holes but copied fenced
samples straight out of page source with only entity decoding applied. A
docs page body is a JS template literal, so a sample's backticks are
written \` and a literal hole \${, and the corpus carried that debris:
853 backslashes across 29 of 44 pages, including 5 form bindings that
taught `<form action=\${createPost}>` where the rendered page shows
`<form action=${createPost}>`. The corpus is the surface whose only
reader is an LLM, and invariant 12 governs exactly that shape.

The fold runs before the entity decode because that is the order a
browser applies them: JS cooks the literal first and the HTML parser
only ever sees cooked text. The two orders agree on every sample in the
repo and differ only where an escape splits an entity.

Two docs pages authored a regex with a single backslash, so the live
page already rendered `replace(/s+/g, '-')` and `/;s*/`. The fold is
faithful, so it would have shipped those broken samples to the corpus
verbatim; both are corrected here.
The fenced-sample fold left prose alone, so the corpus still taught
`html\`...\`` on 50 lines across 8 pages where the rendered page shows a
plain backtick. Prose is copied out of the same template literal as a
sample or a hole, so it folds the same way, and the module now has one
rule instead of three paths that disagree.

The fold runs after the two hole passes because those are what tell a
literal `\${x}` from a render-time `${x}`, and before the restore so
text they already folded cannot fold twice. The single entity decode
still comes last.

The two page regex fixes had nothing that would notice a regression,
which matters because the fold's whole point is that it now copies an
authoring error into the corpus verbatim. A letter after a backslash is
never meaningful in a template literal, so a test rejects one across
every docs page.
extractPage had a fourth path that copies out of page source without
folding: the fallback description, which takes a page's first paragraph
when it declares no metadata.description. That is the majority path, 36
of 44 pages, so a paragraph could reach /llms-full.txt cooked while the
same paragraph reached /llms.txt and the search index with its escape
debris intact. No page trips it today, which is why it survived the
first pass; the docs claimed every path folded while three of four did.

The fold moves inside plainText, which both description sites already
go through, and comes off the metadata call site so neither folds
twice. A second fold is destructive, since it eats a backslash an
author wrote as an escaped one.

The prose claim said 50 lines where 50 is the backslash count and 23 is
the line count, in the two places a later reader would check it from.
The AGENTS.md entry also read as if no backslash survives anywhere,
when renderLlmsFull appends the repo-root skill markdown verbatim and
those files are not template literals, so their single backslash is
correct at source.
The commit that moved the description fold into plainText also wrote
the rule that no path folds twice, and left that rule with nothing
behind it: restoring the fold at the metadata call site keeps all
tests green. The fixture the comment credited drives plainText alone,
so it can only see a double fold inside one function, and no docs page
carries a backslash in its description, so the corpus walks have
nothing to compare either. extractPage is module-private and reads a
real file, which leaves the call sites as the only thing to assert on.

The AGENTS.md entry also lumped the title in with the description and
said both fold inside plainText before the single decode. The title
folds at its call site, never passes through plainText, and decodes
nothing at all, because it is read out of a quoted metadata string
rather than out of markup. The code comment beside it already said so,
so the doc surface contradicted the file it documents.
@vivek7405
vivek7405 force-pushed the fix/llms-fence-escapes branch from 07de54d to 430c4c9 Compare August 9, 2026 09:47
@vivek7405
vivek7405 merged commit e79969d into main Aug 9, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/llms-fence-escapes branch August 9, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(website): llms corpus leaves JS escapes in fenced code samples

1 participant