You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The llms corpus builder decodes HTML entities in the middle of its pipeline and then runs a tag strip over the decoded text. A < that a docs page authored as prose becomes a bare <, and the later tag strip matches from it to the next > anywhere in the document, deleting everything in between.
All line anchors below were verified against HEAD 5ac991ce (fix: keep a live jspm outage from redding the required CI job (#1297)), working tree clean. The file is website/lib/docs-llms.server.ts.
The mechanism, traced end to end.
oneLine() at L121 strips tags FIRST and decodes entities SECOND:
/** Collapse a fragment to a single trimmed line of plain text. */functiononeLine(s: string): string{returns.replace(/<[^>]+>/g,' ')// L123 tag strip.replace(/&/g,'&')// L124 decode chain starts.replace(/</g,'<').replace(/>/g,'>').replace(/{/g,'{').replace(/}/g,'}').replace(/'|'/g,"'").replace(/"/g,'"')// L130 decode chain ends.replace(/\s+/g,' ')// L131.trim();// L132}
bodyToMarkdown() (exported, L154) calls oneLine from the block-level rewrites at L211 through L219 (h1 to h4 at L211-L214, <li> at L216, <p> at L218, <blockquote> at L219) and splices the decoded result back into body.
website/app/docs/metadata-routes/page.ts:52 teaches XML escaping with a LONE escaped less-than sign:
The <code>url</code> is REQUIRED and XML-escaped (a value with <code>&</code> or <code><</code> cannot break the document).
oneLine strips the <code> wrappers, then decodes < to a bare < with no matching > in the same paragraph.
The generic tag strip at L223, .replace(/<[^>]+>/g, ' '), then matches from that stray <. Instrumented on the real page, this is one match, 935 characters long, starting at < cannot break the document). A malformed entry (no url ), an out-of-range priority , or a and ending at the > of <title>, which is itself a decoded <title> from page.ts:111. The match swallows 5 of the page's 9 CODE<n> sentinels plus every paragraph among them, so the samples never reach the sentinel restore at L230.
Measured on the live corpus (43 doc pages, read through the module's own getDocPages() and renderLlmsFull()).
today
with the fix
pages
43
43
fenced samples reaching the corpus
471
476
fence lines (```) in the page bodies
942
952
pages where authored <code-block> count differs from fenced count
1
0
/docs/metadata-routes
9 authored, 4 fenced, 6325 markdown chars
9 authored, 9 fenced, 8838 chars
/llms-full.txt size
916859 bytes, 15501 lines
924500 bytes, 15584 lines
pages whose markdown or description changes
(baseline)
34 of 43
The site-wide "authored-vs-fenced mismatch = 1" figure comes from the walk every sample a page authors reaches the corpus in website/test/ssr/docs-llms.test.ts:94, run with its KNOWN_TRUNCATED skip at L104 removed. That map (L79) currently pins /docs/metadata-routes at { authored: 9, fenced: 4 }.
Two claims in the previous version of this issue were wrong, and the corrected versions matter for the tests.
Stale claim: "Any docs page whose prose teaches an escaped < is exposed to this. /docs/metadata-routes is currently the only one that trips it." Correction:/docs/metadata-routes is the only page that loses CODE SAMPLES, but prose loss is site-wide. 34 of 43 pages change, and 253 corpus lines currently arrive with text deleted out of them. Every one of those grows under the fix. Examples of what readers get today versus what they should get:
today
with the fix
Light-DOM components serialize as plain children with a marker.
... with a <!--webjs-hydrate--> marker.
With JS disabled: content reads, links navigate, + server actions submit
an in-memory graph of file -> Set<imported files> .
Actions return ActionResult : a { success, data } ... envelope
Actions return ActionResult<T> : a { success, data } ... envelope
The loss is structural on four pages, not only textual. When the swallowed span crosses a newline it MERGES the surrounding list items into one. On /docs/no-build, a runaway match starting at the decoded <pkg> in node_modules/<pkg>/package.json eats forward through two more bullets, so three separate list items arrive as one run-on line with <script type="importmap"> missing from the middle of it. On /docs/editor-setup, the whole Diagnostics : bullet loses its label and is absorbed into the Completions : bullet above it. /docs/deployment and /docs/metadata-routes are affected the same way. That is why the corpus gains 83 lines rather than only widening existing ones.
Stale claim: "prose stops being double-decoded, so authored &lt;div&gt; arrives as <div> instead of <div>." Correction: false. decodeEntities() at L257 is itself a sequential chain whose & replacement runs before its < replacement, so &lt;div&gt; collapses to <div> inside a SINGLE call. Measured on a fixture: today <p>write &lt;div&gt; to escape</p> yields write to escape (the decoded tag is deleted by the generic strip); with the fix it yields write <div> to escape. There is no double-decode to remove and no fixture test should assert one.
Blast radius, every consumer of this module by path.
website/app/llms.txt/route.ts (/llms.txt, via renderDocsIndexSection)
Descriptions come from extractPage, which has the SAME inversion. Exactly 1 of the 43 index lines changes: /docs/client-router currently reads intercepts same-origin clicks and submissions and should read intercepts same-origin <a> clicks and <form> submissions.
website/app/api/search/route.ts (the docs search index)
Builds headings and text from page.markdown plus page.description, so the index gains the restored prose and the restored samples. No code change is needed there, and none belongs in this PR (see Out of scope).
website/app/sitemap.ts
Reads slug and path only. Unaffected.
Design / approach
Settled decision: strip tags at every stage, decode entities exactly ONCE, at the end.
Delete the entity-decode chain at L124-L130 from oneLine(), leaving it a tag strip plus a whitespace collapse and a trim. The single decodeEntities(body) at L227 then does all prose decoding, and it already sits AFTER the generic strip at L223, so the pipeline becomes correct by removal rather than by reordering. No stage ever sees text that an earlier stage decoded. Code samples are unaffected: they are captured at L204, decoded on their own path at L205, and restored at L230, all outside the prose pipeline.
Every caller of oneLine() was enumerated, because the fix only works if each one still ends up decoded.
Call site
Reaches the terminal decode at L227?
L211 <h1> rewrite
yes, result is spliced into body
L212 <h2> rewrite
yes
L213 <h3> rewrite
yes
L214 <h4> rewrite
yes
L216 <li> rewrite
yes
L218 <p> rewrite
yes
L219 <blockquote> rewrite
yes
L290 extractPage metadata description
NO
L294 extractPage first-paragraph description
NO
The two description sites are the reason a bare deletion is not enough. They read oneLine(decodeEntities(...)), which is the SAME inversion spelled the other way round: it decodes first and then strips, so <a> becomes <a> and the strip deletes it. That is the measured /docs/client-router defect above. Both are fixed by routing them through one exported helper that applies the correct order:
The whitespace collapse is re-run after decoding because and … only become whitespace-relevant once decoded. bodyToMarkdown gets the same treatment for free from its existing per-line normalization at L237-L249, which runs after the decode.
Settled: the generic tag strip is NOT hardened, and a lazy quantifier would be a no-op.
/<[^>]+>/g cannot be "made non-greedy" in any way that helps. [^>] cannot cross a >, so the match already ends at the first > after the <, and /<[^>]+?>/g produces byte-identical output (verified on x <code y>z<p>w>, both give "x z w>"). The damage is not greediness, it is that a < which is not the start of a tag reaches the strip at all. Once the ordering is fixed, the strip only ever sees tags the page SOURCE authored, which are well formed by construction because the same source has to render as an html template. The measurement is the proof: 43 of 43 pages reach authored-vs-fenced parity, and no corpus line loses a character. Nothing is left for a regex change to buy.
Rejected alternatives.
Run the generic strip BEFORE the per-block rewrites. Not implementable. The strip deletes every tag, including the <h2> / <p> / <li> boundaries the rewrites at L211-L219 match on, so a strip-first body arrives at those rewrites as one undifferentiated blob with no headings and no list items. The ordering constraint has exactly one solution, which is the one taken.
Make the \uE000 sentinels unmatchable by the generic strip. Bounds the damage to prose instead of removing the cause. The 253 prose lines measured above would still arrive with text deleted, and silently, which is the failure mode this file exists to avoid.
Escape or re-protect oneLine()'s decoded output before it rejoins the body. Adds an encode and decode round trip to hide an ordering bug that a deletion fixes outright.
Rewrite the prose at website/app/docs/metadata-routes/page.ts:52. The documentation is correct as written, and the next page teaching escaping trips the same bug.
A back-compat shim keeping oneLine's old behaviour behind a flag. WebJs has no users of this internal module beyond the five consumers listed above, all in this repo, so a clean change beats a shim.
One small same-file tweak is folded in, and it is not optional.
The template-hole strip at L225, .replace(/\$\{[^}]*\}/g, ''), stops at the FIRST }, so a nested hole leaves debris. website/app/docs/architecture/page.ts:122 authors <code><form action=${"${createPost}"}></code>, which the strip reduces to <form action="}>. Today that debris is invisible, because the swallow deletes the whole fragment. With the ordering fixed it becomes a reader-visible corrupt line in /llms-full.txt, so shipping the fix without this tweak trades one defect for another. The strip becomes brace-aware:
.replace(/\$\{(?:[^{}]|\{[^}]*\})*\}/g,'')
Measured: this changes exactly 2 lines of the 924KB corpus, on /docs/architecture and /docs/client-router, and both are improvements. <form action="}> becomes <form action=>, matching how every other template hole in prose is already treated, and @submit=\} becomes @submit=\. There is exactly one nested-hole site in the whole docs tree, confirmed by grep -rn '\${"' website/app/docs/*/page.ts.
Implementation plan
Work in a dedicated worktree cut from origin/main (git worktree add -b fix/llms-single-decode ../webjs-llms-single-decode origin/main), per the repo contract. Every path below is relative to the repo root.
Step 0. Capture the baseline BEFORE editing anything
cd website
node --input-type=module -e "const m = await import('./lib/docs-llms.server.ts'); process.stdout.write(await m.renderLlmsFull());"> /tmp/llms-before.txt
node --test test/ssr/docs-llms.test.ts # 9 tests, all passing on main
Node 24+ strips the types natively and #lib/env.ts resolves through website/package.json, so this needs no server, no build, and no install. Expect /tmp/llms-before.txt to be 916859 bytes.
Capture the parity table too, which is the number the fix moves:
On main this prints /docs/metadata-routes: 9 authored, 4 fenced and total fenced samples: 471. After the change it must print no mismatch line and total fenced samples: 476.
Step 1. website/lib/docs-llms.server.ts, oneLine() at L121-L133
Delete the seven entity replacements at L124-L130 and state the rule in the doc comment. As it exists today:
/** Collapse a fragment to a single trimmed line of plain text. */functiononeLine(s: string): string{returns.replace(/<[^>]+>/g,' ').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/{/g,'{').replace(/}/g,'}').replace(/'|'/g,"'").replace(/"/g,'"').replace(/\s+/g,' ').trim();}
How it should read after:
/** * Collapse a fragment to a single trimmed line of plain text. * * Deliberately does NOT decode entities. Entities are decoded exactly once, * at the END of the pipeline (`decodeEntities(body)`), because a decoded `<` * re-entering a later tag strip matches from there to the next `>` anywhere * in the document and deletes everything between the two. That is what cost * /docs/metadata-routes 5 of its 9 samples: one 935-character match that ran * from a decoded `<` in one paragraph to a decoded `<title>` sixty * lines further down. Decoding belongs after every strip, never before one. */functiononeLine(s: string): string{returns.replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim();}
Step 2. Same file, add plainText next to oneLine
Insert immediately after oneLine, above the truncate helper at L135:
/** * `oneLine` plus the decode, in the one order that is safe: strip, THEN * decode. Exported for the same reason `bodyToMarkdown` is, so a unit test * can drive it on a fixture instead of planting scaffolding in a real docs * page. The whitespace collapse is re-run after decoding because ` ` * and `…` only become whitespace-relevant once decoded. */exportfunctionplainText(s: string): string{returndecodeEntities(oneLine(s)).replace(/\s+/g,' ').trim();}
decodeEntities is a function declaration at L257, so it is hoisted and callable from here.
Step 3. Same file, extractPage() at L272, the two description sites
Step 4. Same file, the template-hole strip at L225
Today:
// Drop template-interpolation holes.replace(/\$\{[^}]*\}/g,'');
After:
// Drop template-interpolation holes. Brace-aware: `[^}]*` stops at the// FIRST `}`, so a nested hole like ${"${createPost}"} (docs/architecture// authors one) left `"}` behind as debris in the prose. Invisible until// the decode ordering above was fixed, and reader-visible after..replace(/\$\{(?:[^{}]|\{[^}]*\})*\}/g,'');
Step 5. Same file, the stale comment block at L193-L202
Delete it in full. It describes this loss as live and unfixed:
// A related loss is still live and is NOT fixed here: oneLine() below// decodes `<` to a bare `<` while rewriting a <p>, and the generic tag// strip further down then matches from that stray `<` to the next `>` and// swallows what lies between, including these sentinels. On// /docs/metadata-routes that costs 5 of its 9 samples and the paragraphs// among them. Not fixed here: the repair reorders this pipeline for every// page, and the decode is what makes prose about markup readable, so it// needs its own before-and-after across all 43. test/ssr/docs-llms.test.ts// pins that page at its exact counts, so it cannot decay further and a// repair fails the test rather than passing unnoticed.
Replace it with the invariant the file now holds:
// The pipeline invariant, and the reason the stages are ordered this way:// tags are stripped at EVERY stage, entities are decoded exactly ONCE, at// the end. A captured sample decodes on its own path below; prose decodes// at `decodeEntities(body)` after the generic strip. Decoding earlier puts// a bare `<` in front of a strip that then eats to the next `>`.
Step 6. website/test/ssr/docs-llms.test.ts, remove the exemption
Delete the comment block at L58-L78 explaining the exemption.
Delete const KNOWN_TRUNCATED = new Map([['/docs/metadata-routes', { authored: 9, fenced: 4 }]]); at L79.
Delete the whole test the truncation exemption still describes reality at L81-L92.
In every sample a page authors reaches the corpus at L94, change the guard at L104 from if (!authored || KNOWN_TRUNCATED.has(page.path)) continue; to if (!authored) continue;, and update the surrounding comment at L96-L98, which currently explains why a named exemption exists.
Deleting the pin is the intended response, not a weakening. It asserts the broken counts in BOTH directions precisely so a correct fix reds it and forces its removal.
Step 7. Same test file, add the fixtures (see Tests)
Step 8. Re-measure and account for every difference
What the diff must show, and nothing else. Every figure below was measured on a reference implementation of Steps 1 through 4:
254 evenly-paired changed lines, of which 253 are LONGER after, each gaining back an escaped tag the strip used to delete (<a>, <form>, <title>, <script type="module">, Set<imported files>, ActionResult<T>, and so on). Exactly one line is shorter, by a single character, and it is the @submit=\} debris the brace-aware hole strip removes. Any OTHER shorter line is a regression and blocks the change.
Four hunks that change the line count, one per structurally damaged page: /docs/no-build (2 lines become 7), /docs/metadata-routes (1 becomes 76, the 5 restored fenced samples plus the paragraphs among them), /docs/editor-setup (1 becomes 3), /docs/deployment (2 become 3). Net +83 lines. These are the merged list items un-merging.
Exactly 1 changed description, /docs/client-router, visible in the /llms.txt index line for that page.
No residual entity anywhere.grep -n '<\|>\|&\|"' /tmp/llms-after.txt must print nothing, exactly as it does today. Ordinary prose about markup has to read as <div>, not <div>, or the fix has traded one defect for another.
No "} debris.grep -n 'action="}' /tmp/llms-after.txt must print nothing.
Then re-run the parity command from Step 0 and confirm no mismatch line and total fenced samples: 476.
Tests
The home is website/test/ssr/docs-llms.test.ts, not test/docs/llms.test.mjs. Both exist and both are real, and the split is deliberate:
website/test/ssr/docs-llms.test.ts imports the module directly (import { bodyToMarkdown, getDocPage, getDocPages } from '#lib/docs-llms.server.ts'), already carries a fixture section at the bottom driving bodyToMarkdown on fixed inputs, and already holds the KNOWN_TRUNCATED pin this change deletes. bodyToMarkdown was exported specifically so a test could do this (fix(website): one code-block element owns the grammar and focus stop #1249), and plainText is exported for the same reason. This is a pure string-to-string transform, so a unit test on a fixture is the right instrument and the counterfactual is exact.
test/docs/llms.test.mjs boots the whole marketing app through createRequestHandler and asserts the three ROUTES serve correctly (status, content-type, the index link count equalling the page count). It is a transport test. It asserts nothing about entity handling and nothing there changes, so adding a decode fixture to it would put a string-transform assertion behind an HTTP boot for no benefit.
Unit, website/test/ssr/docs-llms.test.ts, in the fixture section at the bottom. Four tests, each verified against a reference implementation of the fix.
A paragraph teaching a lone escaped < does not eat the rest of the page. This is the counterfactual, and the fixture shape matters: a PAIRED <code> does NOT reproduce the bug, because it decodes into a complete <code> tag that the strip removes locally. The trigger is a LONE < plus a > somewhere later in the document, which is exactly what metadata-routes L52 and L111 form.
test('a paragraph teaching a lone escaped angle bracket does not eat the rest of the page',()=>{// The exact shape of /docs/metadata-routes: a lone `<` in one// paragraph, a sample, then a later paragraph whose own escaped tag// supplies the `>` that closes the runaway match.constmd=bodyToMarkdown('html`<p>a value with <code><</code> cannot break the document</p>'+'<code-block>const x = 1;</code-block>'+'<p>injects <code><title></code> tags</p>`');assert.match(md,/avaluewith<cannotbreakthedocument/);assert.match(md,/```\nconstx=1;\n```/);assert.match(md,/injects<title>tags/);});
Reverting Step 1 alone makes this fixture yield the string "a value with tags", so all three assertions fail. With the fix it yields "a value with < cannot break the document\n\n```\nconst x = 1;\n```\n\ninjects <title> tags". Both outputs were measured, not predicted.
An escaped tag in prose survives the generic strip. This pins the 253-line site-wide half of the bug, which the sample-count walks cannot see.
test('an escaped tag in prose survives to the corpus',()=>{// Today oneLine decodes <code> to a real <code> tag mid-pipeline// and the generic strip deletes it, so the sentence loses the very// thing it was written to show.constmd=bodyToMarkdown('html`<p>a value with <code> here</p>`');assert.equal(md,'a value with <code> here');});
Reverting Step 1 yields 'a value with here'.
The description path strips before it decodes.
test('plainText strips tags before it decodes entities',()=>{assert.equal(plainText('a value with <code> here'),'a value with <code> here');// A real page description: /docs/client-router loses both tags today.assert.match(plainText('intercepts same-origin <a> clicks'),/same-origin<a>clicks/);});
Add plainText to the import at L23. This one pins the helper itself, so reverting only Step 3 leaves it green. Test 4 is its counterfactual.
A real page description keeps the escaped tags it teaches. This is what reds when Step 3 is reverted, because it goes through extractPage.
test('a page description keeps the escaped tags it teaches',async()=>{// extractPage used to run oneLine(decodeEntities(...)), decoding first// and stripping second, so a description teaching a tag lost it.constpage=awaitgetDocPage('client-router');assert.ok(page,'the client-router page is in the corpus');assert.match(page.description,/same-origin<a>clicksand<form>submissions/);});
On main the description reads intercepts same-origin clicks and submissions, so this fails. The phrase sits about 100 characters in, well inside the 200-character truncate at L296, so the assertion is stable.
Do NOT write a fixture asserting that authored &lt;div&gt; reaches the corpus as <div>. It reaches it as <div>, because decodeEntities (L257) chains & before < inside one call. Measured both ways. A test asserting the escaped form fails on a correct implementation.
Corpus walks, already in the file, strengthened by deletion. With KNOWN_TRUNCATED gone, every sample a page authors reaches the corpus (L94) covers all 43 pages with no exemption and reports /docs/metadata-routes: 9 authored, 4 fenced the moment the fix is reverted. a sample that reaches the corpus reaches it whole (L113) compares more samples than before, since metadata-routes' 9 now qualify: measured compared rises from over 300 to 339, still 0 mangled.
The counterfactual, stated as one command. Revert website/lib/docs-llms.server.ts to origin/main while keeping the test file, then run cd website && node --test test/ssr/docs-llms.test.ts. It must red on four things: the two bodyToMarkdown fixtures (tests 1 and 2), the real-description test (test 4), and the now-unexempted every sample a page authors reaches the corpus, which reports /docs/metadata-routes: 9 authored, 4 fenced.
Full verification the implementer runs and reports:
cd website && node --test test/ssr/docs-llms.test.ts # the changed suite, fastcd website && npm test# the app suite CI runs (webjs test)cd website && npm run typecheck # tsc --noEmit, the same gate CI runs
node --test test/docs/llms.test.mjs # the route-level integration testcd website && npx webjs check # convention validatorcd website && npx webjs doctor # the required `conventions` CI job runs this over website
The CI apps job runs npm run typecheck --workspace=@webjsdev/website and npm test --workspace=@webjsdev/website, so those two are the gate.
Layers that do NOT apply, and why.
Browser (npm run test:browser). Nothing here hydrates. docs-llms.server.ts is a .server.ts module read by route handlers only, and its output is text/plain. No component, no DOM, no custom-element upgrade.
E2E (WEBJS_E2E=1, test/e2e/*.test.mjs). The route-serving behaviour is already covered by test/docs/llms.test.mjs through createRequestHandler, and no navigation, streaming, or network-probe behaviour changes.
Bun parity (test/bun/**). The Bun gate covers runtime-sensitive framework surfaces (the serializer, the listener and request path, SSR and action dispatch, streams, node:crypto, the TS stripper, auth and sessions, cors). This is a pure string transform in an application's own lib/, uses no runtime-specific API beyond node:fs/promises reads that already work on both, and touches no packages/*/src. The pre-commit hook require-bun-parity-with-runtime-src.sh scopes to packages/*/src, so it does not fire.
Smoke (test/examples/*/smoke/*). Those cover the example apps, not the marketing site.
Docs
The require-docs-with-src.sh commit gate does NOT fire. It matches staged paths under packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, and this change is entirely under website/. The same is true of require-tests-with-src.sh. Neither is a licence to skip docs, so the one surface that genuinely applies is updated:
website/AGENTS.md. Add one bullet in the lib/ inventory near the line that already names docs-llms.server.ts (lib/docs-llms.server.ts enumerates the doc pages on disk (sitemap, llms.txt)), recording the ordering invariant so a future edit cannot reintroduce the bug:
The llms extractor strips tags at every stage and decodes entities exactly once, at the end. Decoding earlier puts a bare < in front of a later tag strip, which then matches to the next > anywhere in the document and deletes everything between the two. That once cost /docs/metadata-routes 5 of its 9 code samples and deleted an escaped tag from 253 prose lines across the corpus.
Every other surface is N/A, and here is why for each. The module is website-internal server code. It exports no public API, adds no CLI flag, no webjs config key, no html hole prefix, no lifecycle hook. So the root AGENTS.md, the skill at .agents/skills/webjs/ (SKILL.md and every reference), the docs site pages under website/app/docs/, the marketing copy in website/app/page.ts, the scaffold templates under packages/cli/templates/, README.md, the MCP server, CONVENTIONS.md, and the per-package changelogs all stay untouched. No docs page's PROSE changes either: website/app/docs/metadata-routes/page.ts:52 is correct documentation and must NOT be rewritten to dodge the bug.
Acceptance criteria
oneLine() no longer decodes entities, and its doc comment states why
plainText() is exported and both extractPage description sites at L290 and L294 call it instead of oneLine(decodeEntities(...))
The template-hole strip at L225 is brace-aware, so ${"${createPost}"} leaves no "} debris
/docs/metadata-routes reaches the corpus with 9 of 9 samples, up from 4 of 9
The paragraphs between those samples are present too, not only the code blocks
Site-wide authored-vs-fenced mismatches go from 1 to 0 across all 43 pages
Total fenced samples in the page bodies go from 471 to 476 (942 to 952 fence lines), and no page loses one
/llms-full.txt grows from 916859 bytes / 15501 lines to 924500 / 15584, with 253 of 254 changed lines longer and the single shorter line being the one-character @submit=\} debris removal
The merged list items on /docs/no-build, /docs/editor-setup, and /docs/deployment are un-merged, so Diagnostics : is its own bullet again and <script type="importmap"> is back in the no-build list
The /docs/client-router index description reads intercepts same-origin <a> clicks and <form> submissions
grep -n '<\|>\|&\|"' /tmp/llms-after.txt prints nothing, so prose about markup reads as <div> and not <div>
The KNOWN_TRUNCATED map, its explanatory comment at L58-L78, the test the truncation exemption still describes reality, and the KNOWN_TRUNCATED.has(page.path) skip at L104 are all DELETED
a sample that reaches the corpus reaches it whole compares 339 samples with 0 mangled
Four tests are added (two bodyToMarkdown fixtures, one plainText fixture, one real-description assertion), and reverting website/lib/docs-llms.server.ts to origin/main reds three of them plus the now-unexempted corpus walk
The stale comment block at docs-llms.server.ts L193-L202 is replaced by the invariant statement
website/AGENTS.md records the strip-then-decode invariant
cd website && npm test, cd website && npm run typecheck, node --test test/docs/llms.test.mjs, npx webjs check, and npx webjs doctor all pass, and the results are reported
Rewriting website/app/docs/metadata-routes/page.ts:52 or any other docs prose. The documentation is correct; the extractor was not.
Replacing the regex pipeline with a real HTML parser. The module is deliberately regex-based and comment-documented as "perfection is not required, a clean-ish rendering is". A parser is a different design decision with its own before-and-after, not a bug fix.
The stray backslash left in /docs/client-router's rendered @submit=\ fragment. It comes from a \$ escape in the page source surviving into the corpus. Pre-existing, unrelated to entity ordering, unchanged by this fix in either direction.
Attribute values containing a literal >. A source-authored <a title="a > b"> would end the strip's match early and leave b"> as text. No docs page does this today, the fix neither causes nor cures it, and guarding it would need the parser rejected above.
Any change to website/app/sitemap.ts, website/app/llms.txt/route.ts, website/app/llms-full.txt/route.ts, or website/app/docs/[topic]/llms.txt/route.ts. They consume the module and need no edit; their OUTPUT changing is the point of the PR.
Problem
The llms corpus builder decodes HTML entities in the middle of its pipeline and then runs a tag strip over the decoded text. A
<that a docs page authored as prose becomes a bare<, and the later tag strip matches from it to the next>anywhere in the document, deleting everything in between.All line anchors below were verified against HEAD
5ac991ce(fix: keep a live jspm outage from redding the required CI job (#1297)), working tree clean. The file iswebsite/lib/docs-llms.server.ts.The mechanism, traced end to end.
oneLine()at L121 strips tags FIRST and decodes entities SECOND:bodyToMarkdown()(exported, L154) callsoneLinefrom the block-level rewrites at L211 through L219 (h1 to h4 at L211-L214,<li>at L216,<p>at L218,<blockquote>at L219) and splices the decoded result back intobody.website/app/docs/metadata-routes/page.ts:52teaches XML escaping with a LONE escaped less-than sign:oneLinestrips the<code>wrappers, then decodes<to a bare<with no matching>in the same paragraph.The generic tag strip at L223,
.replace(/<[^>]+>/g, ' '), then matches from that stray<. Instrumented on the real page, this is one match, 935 characters long, starting at< cannot break the document). A malformed entry (no url ), an out-of-range priority , or aand ending at the>of<title>, which is itself a decoded<title>frompage.ts:111. The match swallows 5 of the page's 9CODE<n>sentinels plus every paragraph among them, so the samples never reach the sentinel restore at L230.Measured on the live corpus (43 doc pages, read through the module's own
getDocPages()andrenderLlmsFull()).```) in the page bodies<code-block>count differs from fenced count/docs/metadata-routes/llms-full.txtsizeThe site-wide "authored-vs-fenced mismatch = 1" figure comes from the walk
every sample a page authors reaches the corpusinwebsite/test/ssr/docs-llms.test.ts:94, run with itsKNOWN_TRUNCATEDskip at L104 removed. That map (L79) currently pins/docs/metadata-routesat{ authored: 9, fenced: 4 }.Two claims in the previous version of this issue were wrong, and the corrected versions matter for the tests.
Stale claim: "Any docs page whose prose teaches an escaped
<is exposed to this./docs/metadata-routesis currently the only one that trips it." Correction:/docs/metadata-routesis the only page that loses CODE SAMPLES, but prose loss is site-wide. 34 of 43 pages change, and 253 corpus lines currently arrive with text deleted out of them. Every one of those grows under the fix. Examples of what readers get today versus what they should get:Light-DOM components serialize as plain children with a marker.... with a <!--webjs-hydrate--> marker.With JS disabled: content reads, links navigate, + server actions submit... content reads, <a> links navigate, <form> + server actions submitBrowser parses HTML, finds (or follows an import from one that was already loaded).... finds <script type="module" src="/app/page.ts"> (or follows ...)an in-memory graph of file -> Set .an in-memory graph of file -> Set<imported files> .Actions return ActionResult : a { success, data } ... envelopeActions return ActionResult<T> : a { success, data } ... envelopeThe loss is structural on four pages, not only textual. When the swallowed span crosses a newline it MERGES the surrounding list items into one. On
/docs/no-build, a runaway match starting at the decoded<pkg>innode_modules/<pkg>/package.jsoneats forward through two more bullets, so three separate list items arrive as one run-on line with<script type="importmap">missing from the middle of it. On/docs/editor-setup, the wholeDiagnostics :bullet loses its label and is absorbed into theCompletions :bullet above it./docs/deploymentand/docs/metadata-routesare affected the same way. That is why the corpus gains 83 lines rather than only widening existing ones.Stale claim: "prose stops being double-decoded, so authored
&lt;div&gt;arrives as<div>instead of<div>." Correction: false.decodeEntities()at L257 is itself a sequential chain whose&replacement runs before its<replacement, so&lt;div&gt;collapses to<div>inside a SINGLE call. Measured on a fixture: today<p>write &lt;div&gt; to escape</p>yieldswrite to escape(the decoded tag is deleted by the generic strip); with the fix it yieldswrite <div> to escape. There is no double-decode to remove and no fixture test should assert one.Blast radius, every consumer of this module by path.
website/app/llms-full.txt/route.ts(/llms-full.txt)/docs/metadata-routesgains 5 fenced samples plus the paragraphs among them.website/app/docs/[topic]/llms.txt/route.ts(/docs/<topic>/llms.txt)website/app/llms.txt/route.ts(/llms.txt, viarenderDocsIndexSection)extractPage, which has the SAME inversion. Exactly 1 of the 43 index lines changes:/docs/client-routercurrently readsintercepts same-origin clicks and submissionsand should readintercepts same-origin <a> clicks and <form> submissions.website/app/api/search/route.ts(the docs search index)headingsandtextfrompage.markdownpluspage.description, so the index gains the restored prose and the restored samples. No code change is needed there, and none belongs in this PR (see Out of scope).website/app/sitemap.tsslugandpathonly. Unaffected.Design / approach
Settled decision: strip tags at every stage, decode entities exactly ONCE, at the end.
Delete the entity-decode chain at L124-L130 from
oneLine(), leaving it a tag strip plus a whitespace collapse and a trim. The singledecodeEntities(body)at L227 then does all prose decoding, and it already sits AFTER the generic strip at L223, so the pipeline becomes correct by removal rather than by reordering. No stage ever sees text that an earlier stage decoded. Code samples are unaffected: they are captured at L204, decoded on their own path at L205, and restored at L230, all outside the prose pipeline.Every caller of
oneLine()was enumerated, because the fix only works if each one still ends up decoded.<h1>rewritebody<h2>rewrite<h3>rewrite<h4>rewrite<li>rewrite<p>rewrite<blockquote>rewriteextractPagemetadata descriptionextractPagefirst-paragraph descriptionThe two description sites are the reason a bare deletion is not enough. They read
oneLine(decodeEntities(...)), which is the SAME inversion spelled the other way round: it decodes first and then strips, so<a>becomes<a>and the strip deletes it. That is the measured/docs/client-routerdefect above. Both are fixed by routing them through one exported helper that applies the correct order:The whitespace collapse is re-run after decoding because
and…only become whitespace-relevant once decoded.bodyToMarkdowngets the same treatment for free from its existing per-line normalization at L237-L249, which runs after the decode.Settled: the generic tag strip is NOT hardened, and a lazy quantifier would be a no-op.
/<[^>]+>/gcannot be "made non-greedy" in any way that helps.[^>]cannot cross a>, so the match already ends at the first>after the<, and/<[^>]+?>/gproduces byte-identical output (verified onx <code y>z<p>w>, both give"x z w>"). The damage is not greediness, it is that a<which is not the start of a tag reaches the strip at all. Once the ordering is fixed, the strip only ever sees tags the page SOURCE authored, which are well formed by construction because the same source has to render as anhtmltemplate. The measurement is the proof: 43 of 43 pages reach authored-vs-fenced parity, and no corpus line loses a character. Nothing is left for a regex change to buy.Rejected alternatives.
<h2>/<p>/<li>boundaries the rewrites at L211-L219 match on, so a strip-first body arrives at those rewrites as one undifferentiated blob with no headings and no list items. The ordering constraint has exactly one solution, which is the one taken.\uE000sentinels unmatchable by the generic strip. Bounds the damage to prose instead of removing the cause. The 253 prose lines measured above would still arrive with text deleted, and silently, which is the failure mode this file exists to avoid.oneLine()'s decoded output before it rejoins the body. Adds an encode and decode round trip to hide an ordering bug that a deletion fixes outright.website/app/docs/metadata-routes/page.ts:52. The documentation is correct as written, and the next page teaching escaping trips the same bug.oneLine's old behaviour behind a flag. WebJs has no users of this internal module beyond the five consumers listed above, all in this repo, so a clean change beats a shim.One small same-file tweak is folded in, and it is not optional.
The template-hole strip at L225,
.replace(/\$\{[^}]*\}/g, ''), stops at the FIRST}, so a nested hole leaves debris.website/app/docs/architecture/page.ts:122authors<code><form action=${"${createPost}"}></code>, which the strip reduces to<form action="}>. Today that debris is invisible, because the swallow deletes the whole fragment. With the ordering fixed it becomes a reader-visible corrupt line in/llms-full.txt, so shipping the fix without this tweak trades one defect for another. The strip becomes brace-aware:Measured: this changes exactly 2 lines of the 924KB corpus, on
/docs/architectureand/docs/client-router, and both are improvements.<form action="}>becomes<form action=>, matching how every other template hole in prose is already treated, and@submit=\}becomes@submit=\. There is exactly one nested-hole site in the whole docs tree, confirmed bygrep -rn '\${"' website/app/docs/*/page.ts.Implementation plan
Work in a dedicated worktree cut from
origin/main(git worktree add -b fix/llms-single-decode ../webjs-llms-single-decode origin/main), per the repo contract. Every path below is relative to the repo root.Step 0. Capture the baseline BEFORE editing anything
Node 24+ strips the types natively and
#lib/env.tsresolves throughwebsite/package.json, so this needs no server, no build, and no install. Expect/tmp/llms-before.txtto be 916859 bytes.Capture the parity table too, which is the number the fix moves:
On main this prints
/docs/metadata-routes: 9 authored, 4 fencedandtotal fenced samples: 471. After the change it must print no mismatch line andtotal fenced samples: 476.Step 1.
website/lib/docs-llms.server.ts,oneLine()at L121-L133Delete the seven entity replacements at L124-L130 and state the rule in the doc comment. As it exists today:
How it should read after:
Step 2. Same file, add
plainTextnext tooneLineInsert immediately after
oneLine, above thetruncatehelper at L135:decodeEntitiesis a function declaration at L257, so it is hoisted and callable from here.Step 3. Same file,
extractPage()at L272, the two description sitesToday, at L290 and L294:
After:
Step 4. Same file, the template-hole strip at L225
Today:
After:
Step 5. Same file, the stale comment block at L193-L202
Delete it in full. It describes this loss as live and unfixed:
Replace it with the invariant the file now holds:
Step 6.
website/test/ssr/docs-llms.test.ts, remove the exemptionconst KNOWN_TRUNCATED = new Map([['/docs/metadata-routes', { authored: 9, fenced: 4 }]]);at L79.the truncation exemption still describes realityat L81-L92.every sample a page authors reaches the corpusat L94, change the guard at L104 fromif (!authored || KNOWN_TRUNCATED.has(page.path)) continue;toif (!authored) continue;, and update the surrounding comment at L96-L98, which currently explains why a named exemption exists.Deleting the pin is the intended response, not a weakening. It asserts the broken counts in BOTH directions precisely so a correct fix reds it and forces its removal.
Step 7. Same test file, add the fixtures (see Tests)
Step 8. Re-measure and account for every difference
What the diff must show, and nothing else. Every figure below was measured on a reference implementation of Steps 1 through 4:
<a>,<form>,<title>,<script type="module">,Set<imported files>,ActionResult<T>, and so on). Exactly one line is shorter, by a single character, and it is the@submit=\}debris the brace-aware hole strip removes. Any OTHER shorter line is a regression and blocks the change./docs/no-build(2 lines become 7),/docs/metadata-routes(1 becomes 76, the 5 restored fenced samples plus the paragraphs among them),/docs/editor-setup(1 becomes 3),/docs/deployment(2 become 3). Net +83 lines. These are the merged list items un-merging./docs/client-router, visible in the/llms.txtindex line for that page.grep -n '<\|>\|&\|"' /tmp/llms-after.txtmust print nothing, exactly as it does today. Ordinary prose about markup has to read as<div>, not<div>, or the fix has traded one defect for another."}debris.grep -n 'action="}' /tmp/llms-after.txtmust print nothing.Then re-run the parity command from Step 0 and confirm no mismatch line and
total fenced samples: 476.Tests
The home is
website/test/ssr/docs-llms.test.ts, nottest/docs/llms.test.mjs. Both exist and both are real, and the split is deliberate:website/test/ssr/docs-llms.test.tsimports the module directly (import { bodyToMarkdown, getDocPage, getDocPages } from '#lib/docs-llms.server.ts'), already carries a fixture section at the bottom drivingbodyToMarkdownon fixed inputs, and already holds theKNOWN_TRUNCATEDpin this change deletes.bodyToMarkdownwas exported specifically so a test could do this (fix(website): one code-block element owns the grammar and focus stop #1249), andplainTextis exported for the same reason. This is a pure string-to-string transform, so a unit test on a fixture is the right instrument and the counterfactual is exact.test/docs/llms.test.mjsboots the whole marketing app throughcreateRequestHandlerand asserts the three ROUTES serve correctly (status,content-type, the index link count equalling the page count). It is a transport test. It asserts nothing about entity handling and nothing there changes, so adding a decode fixture to it would put a string-transform assertion behind an HTTP boot for no benefit.Unit,
website/test/ssr/docs-llms.test.ts, in the fixture section at the bottom. Four tests, each verified against a reference implementation of the fix.A paragraph teaching a lone escaped
<does not eat the rest of the page. This is the counterfactual, and the fixture shape matters: a PAIRED<code>does NOT reproduce the bug, because it decodes into a complete<code>tag that the strip removes locally. The trigger is a LONE<plus a>somewhere later in the document, which is exactly whatmetadata-routesL52 and L111 form.Reverting Step 1 alone makes this fixture yield the string
"a value with tags", so all three assertions fail. With the fix it yields"a value with < cannot break the document\n\n```\nconst x = 1;\n```\n\ninjects <title> tags". Both outputs were measured, not predicted.An escaped tag in prose survives the generic strip. This pins the 253-line site-wide half of the bug, which the sample-count walks cannot see.
Reverting Step 1 yields
'a value with here'.The description path strips before it decodes.
Add
plainTextto the import at L23. This one pins the helper itself, so reverting only Step 3 leaves it green. Test 4 is its counterfactual.A real page description keeps the escaped tags it teaches. This is what reds when Step 3 is reverted, because it goes through
extractPage.On main the description reads
intercepts same-origin clicks and submissions, so this fails. The phrase sits about 100 characters in, well inside the 200-charactertruncateat L296, so the assertion is stable.Do NOT write a fixture asserting that authored
&lt;div&gt;reaches the corpus as<div>. It reaches it as<div>, becausedecodeEntities(L257) chains&before<inside one call. Measured both ways. A test asserting the escaped form fails on a correct implementation.Corpus walks, already in the file, strengthened by deletion. With
KNOWN_TRUNCATEDgone,every sample a page authors reaches the corpus(L94) covers all 43 pages with no exemption and reports/docs/metadata-routes: 9 authored, 4 fencedthe moment the fix is reverted.a sample that reaches the corpus reaches it whole(L113) compares more samples than before, since metadata-routes' 9 now qualify: measuredcomparedrises from over 300 to 339, still 0 mangled.The counterfactual, stated as one command. Revert
website/lib/docs-llms.server.tstoorigin/mainwhile keeping the test file, then runcd website && node --test test/ssr/docs-llms.test.ts. It must red on four things: the twobodyToMarkdownfixtures (tests 1 and 2), the real-description test (test 4), and the now-unexemptedevery sample a page authors reaches the corpus, which reports/docs/metadata-routes: 9 authored, 4 fenced.Full verification the implementer runs and reports:
The CI
appsjob runsnpm run typecheck --workspace=@webjsdev/websiteandnpm test --workspace=@webjsdev/website, so those two are the gate.Layers that do NOT apply, and why.
npm run test:browser). Nothing here hydrates.docs-llms.server.tsis a.server.tsmodule read by route handlers only, and its output istext/plain. No component, no DOM, no custom-element upgrade.WEBJS_E2E=1,test/e2e/*.test.mjs). The route-serving behaviour is already covered bytest/docs/llms.test.mjsthroughcreateRequestHandler, and no navigation, streaming, or network-probe behaviour changes.test/bun/**). The Bun gate covers runtime-sensitive framework surfaces (the serializer, the listener and request path, SSR and action dispatch, streams,node:crypto, the TS stripper, auth and sessions, cors). This is a pure string transform in an application's ownlib/, uses no runtime-specific API beyondnode:fs/promisesreads that already work on both, and touches nopackages/*/src. The pre-commit hookrequire-bun-parity-with-runtime-src.shscopes topackages/*/src, so it does not fire.test/examples/*/smoke/*). Those cover the example apps, not the marketing site.Docs
The
require-docs-with-src.shcommit gate does NOT fire. It matches staged paths underpackages/([^/]+/src|editors/[^/]+/src|cli/lib)/, and this change is entirely underwebsite/. The same is true ofrequire-tests-with-src.sh. Neither is a licence to skip docs, so the one surface that genuinely applies is updated:website/AGENTS.md. Add one bullet in thelib/inventory near the line that already namesdocs-llms.server.ts(lib/docs-llms.server.ts enumerates the doc pages on disk (sitemap, llms.txt)), recording the ordering invariant so a future edit cannot reintroduce the bug:Every other surface is N/A, and here is why for each. The module is website-internal server code. It exports no public API, adds no CLI flag, no
webjsconfig key, nohtmlhole prefix, no lifecycle hook. So the rootAGENTS.md, the skill at.agents/skills/webjs/(SKILL.md and every reference), the docs site pages underwebsite/app/docs/, the marketing copy inwebsite/app/page.ts, the scaffold templates underpackages/cli/templates/,README.md, the MCP server,CONVENTIONS.md, and the per-package changelogs all stay untouched. No docs page's PROSE changes either:website/app/docs/metadata-routes/page.ts:52is correct documentation and must NOT be rewritten to dodge the bug.Acceptance criteria
oneLine()no longer decodes entities, and its doc comment states whyplainText()is exported and bothextractPagedescription sites at L290 and L294 call it instead ofoneLine(decodeEntities(...))${"${createPost}"}leaves no"}debris/docs/metadata-routesreaches the corpus with 9 of 9 samples, up from 4 of 9/llms-full.txtgrows from 916859 bytes / 15501 lines to 924500 / 15584, with 253 of 254 changed lines longer and the single shorter line being the one-character@submit=\}debris removal/docs/no-build,/docs/editor-setup, and/docs/deploymentare un-merged, soDiagnostics :is its own bullet again and<script type="importmap">is back in the no-build list/docs/client-routerindex description readsintercepts same-origin <a> clicks and <form> submissionsgrep -n '<\|>\|&\|"' /tmp/llms-after.txtprints nothing, so prose about markup reads as<div>and not<div>KNOWN_TRUNCATEDmap, its explanatory comment at L58-L78, the testthe truncation exemption still describes reality, and theKNOWN_TRUNCATED.has(page.path)skip at L104 are all DELETEDa sample that reaches the corpus reaches it wholecompares 339 samples with 0 mangledbodyToMarkdownfixtures, oneplainTextfixture, one real-description assertion), and revertingwebsite/lib/docs-llms.server.tstoorigin/mainreds three of them plus the now-unexempted corpus walkdocs-llms.server.tsL193-L202 is replaced by the invariant statementwebsite/AGENTS.mdrecords the strip-then-decode invariantcd website && npm test,cd website && npm run typecheck,node --test test/docs/llms.test.mjs,npx webjs check, andnpx webjs doctorall pass, and the results are reportedOut of scope
website/app/api/search/route.tsto stop indexing shell comments inside fenced samples as headings. Do NOT touch that file here. fix(website): a stray decoded angle bracket eats code samples from llms.txt #1261 lands FIRST and fix(website): docs search indexes shell comments as headings #1262 rebases on it. The two do not conflict textually, but fix(website): docs search indexes shell comments as headings #1262's measurements were taken against today's corpus, so record what moves and what does not: the phantom heading count stays at 53 across 9 of 43 pages, unchanged by this fix, because the 5 restoredmetadata-routessamples open no line with#. What DOES move is the total, from 737 to 741 lines starting with#, of which outside-a-fence rises from 684 to 688. fix(website): docs search indexes shell comments as headings #1262 owns refreshing any expectation of its own that shifts, because it is the consumer and the later merge.website/app/docs/metadata-routes/page.ts:52or any other docs prose. The documentation is correct; the extractor was not./docs/client-router's rendered@submit=\fragment. It comes from a\$escape in the page source surviving into the corpus. Pre-existing, unrelated to entity ordering, unchanged by this fix in either direction.>. A source-authored<a title="a > b">would end the strip's match early and leaveb">as text. No docs page does this today, the fix neither causes nor cures it, and guarding it would need the parser rejected above.website/app/sitemap.ts,website/app/llms.txt/route.ts,website/app/llms-full.txt/route.ts, orwebsite/app/docs/[topic]/llms.txt/route.ts. They consume the module and need no edit; their OUTPUT changing is the point of the PR.