Skip to content

fix: decode llms.txt entities once, after every tag strip - #1331

Merged
vivek7405 merged 6 commits into
mainfrom
fix/llms-single-decode
Aug 8, 2026
Merged

fix: decode llms.txt entities once, after every tag strip#1331
vivek7405 merged 6 commits into
mainfrom
fix/llms-single-decode

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Closes #1261

Summary

The llms corpus builder decoded HTML entities inside oneLine() and then ran a generic tag strip over the decoded text. A &lt; that a docs page authored as prose became a bare <, and the later strip matched from it to the next > anywhere in the document, deleting everything between the two. On /docs/metadata-routes that was a single 935-character match that swallowed 5 of the page's 9 code samples plus the paragraphs among them. Site-wide it deleted an escaped tag out of 253 prose lines, and where the match crossed a newline it merged separate list items into one run-on line.

Tags are now stripped at every stage and entities decoded exactly once, at the end, so no stage ever sees text an earlier stage decoded. The pipeline becomes correct by removal rather than by reordering.

What changed

website/lib/docs-llms.server.ts:

  • oneLine() no longer decodes entities. It is a tag strip plus a whitespace collapse and a trim. The single decodeEntities(body) already sits after the generic strip, so it does all prose decoding.

  • New exported plainText() applies the safe order (strip, then decode). Both extractPage description sites called oneLine(decodeEntities(...)), which is the same inversion spelled the other way round, and both now route through it.

  • Prose template holes now keep what a reader actually sees. A hole has three shapes and only one is dynamic: \${x} is ESCAPED, so the page renders the literal ${x} and there is no interpolation at all; ${"lit"} interpolates a string literal the reader sees; only a bare ${x} is resolved at render time and has nothing to contribute. The first two are preserved (parked behind a sentinel so the dynamic strip cannot eat them), the third is still dropped.

    This was not in the original plan and is the one place the PR grew. Fixing the decode ordering RESTORES fragments main had deleted outright, and those fragments are mostly sentences about form bindings, so the corpus went from zero <form action=\> lines to 12. The corpus is the surface whose only reader is an LLM, and root AGENTS.md invariant 12 is precisely about which shapes a bound form may take, so shipping that would have traded one defect for another. It now renders 24 correct <form action=${importedAction}> bindings and zero broken ones.

  • The stale comment block describing this loss as live is replaced by the invariant the file now holds.

website/test/ssr/docs-llms.test.ts: the KNOWN_TRUNCATED pin, its explanatory comment, and the the truncation exemption still describes reality test are deleted, and the walk every sample a page authors reaches the corpus now covers all 44 pages with no exemption. The pin asserted the broken counts in both directions precisely so a correct fix would red it. Ten tests added: one per behaviour the decode fix restores, and one per hole shape plus the two failure modes preserving them introduced.

website/AGENTS.md: records both invariants the module now holds, the strip-then-decode ordering and the three hole shapes.

Measured, before and after

The issue's absolute figures were taken when the corpus had 43 pages; it now has 44, so the numbers below are re-measured on this branch against current main. Every relative shape the issue predicted holds exactly.

before after
pages 44 44
fenced samples reaching the corpus 480 485
pages where authored <code-block> count differs from fenced count 1 0
/docs/metadata-routes 9 authored, 4 fenced 9 authored, 9 fenced, 8838 chars
/llms-full.txt 964128 bytes, 15838 lines 972347 bytes, 15921 lines
prose <form action=${...}> bindings clause deleted entirely restored, 9 more than main
broken bindings (<form action=\> etc.) 0 0 (would be 12 with the decode fix alone)

The diff accounts for itself completely: 278 evenly-paired changed lines, every one of them longer and none shorter, plus four structural hunks (2 lines to 7, 1 to 76, 1 to 3, 2 to 3) for a net +83 lines, which are the merged list items un-merging on /docs/no-build, /docs/metadata-routes, /docs/editor-setup, and /docs/deployment. No corpus line loses a character. Diagnostics : is its own bullet again and <script type="importmap"> is back in the no-build list. The /docs/client-router index description now reads intercepts same-origin <a> clicks and <form> submissions.

Checks on the output, all clean: no private-use sentinel leak (U+E000 or U+E001), no residual entity (&lt; &gt; &amp; &quot; all absent, so prose about markup reads as <div>), no action="} debris, no broken binding (<form action=\>, <form action=>, <button formaction=\> and @submit=\ are each 0),. The backslash-backtick count is identical to main (371), so the escape debris that remains is all inside fenced samples, on the code-block path this PR does not touch.

Test plan

  • Unit: cd website && node --test test/ssr/docs-llms.test.ts, 18 pass.

  • Counterfactuals, proven at cf0933c4 by toggling each source change independently and re-running. Every one of the three hunks has one:

    • Reverting the oneLine decode chain reds three tests: both bodyToMarkdown fixtures and the now-unexempted corpus walk, which reports /docs/metadata-routes: 9 authored, 4 fenced.
    • Reverting only the two extractPage description sites reds a page description keeps the escaped tags it teaches.
    • Reverting only the hole handling reds a hole whose value is a string literal keeps the value and an escaped hole is literal text, not an interpolation to drop, while a genuinely dynamic hole is still dropped holds the other side of that line so the two kept shapes cannot be over-generalised into keeping everything.

    Reverting the whole file at once proves nothing, so do not use it as the counterfactual: the import of plainText then fails and the suite never loads.

  • App suite + typecheck: npm test and npm run typecheck in website, the two the CI apps job runs.

  • Route-level integration: node --test test/docs/llms.test.mjs.

  • Conventions: npx webjs check and npx webjs doctor over website.

Layers that do not apply: browser (nothing hydrates, this is a .server.ts module serving text/plain), e2e (the routes are already covered by the integration test through createRequestHandler, and no navigation or streaming behaviour changes), Bun parity (a pure string transform in an app's own lib/, no runtime-specific API, no packages/*/src touched, so the parity hook does not fire), smoke (covers the example apps, not the marketing site).

Docs surfaces

  • Updated: website/AGENTS.md.
  • N/A: the module is website-internal server code exporting no public API, 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/, the docs site pages, the marketing copy, the scaffold templates, README.md, the MCP server, CONVENTIONS.md, and the changelogs all stay untouched. No docs page's prose changes either: website/app/docs/metadata-routes/page.ts:52 is correct documentation and rewriting it to dodge the bug would only defer it to the next page teaching escaping.

Out of scope

#1262 (the docs search fence tracking) owns website/app/api/search/route.ts and rebases on this. For its measurements: the phantom heading count stays at 53, unchanged by this fix, because the restored metadata-routes samples open no line with # . The totals move, so #1262 refreshes its own expectations as the later merge.

A note on the one deferral

The fenced-sample capture copies from page source without folding JS escapes, so a sample still reaches the corpus as ${html\...`} where the page shows `` ${html...} ``. It is the same question this PR answers for prose, but on a code path the PR does not touch, and it is **unchanged in both directions against main** (5 fenced form bindings before and after, backslash-backtick count identical at 371). Fixing it moves roughly 775 more corpus positions and needs a rethink of a sample that reaches the corpus reaches it whole`, which compares raw source text, so it is separate work rather than a tail on this one. Recorded on the review thread, awaiting a call on filing.

One environmental note for whoever runs the suites: test/bun/listener.test.mjs fails in a linked worktree (clientIp reads _anon_) and passes in a full checkout at the same commit. It is not affected by this branch: reverting all three changed files makes the tree byte-identical to main and it still fails.

@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.

Went through the whole diff. The ordering fix is right and the removal-not-reordering framing holds: once oneLine stops decoding, every one of its callers still sits upstream of the single decodeEntities(body), so nothing leaks an undecoded entity, and the description sites were the same inversion spelled backwards. The measurements back it up rather than just asserting it, which is what I wanted to see on a change whose whole risk is silent loss.

Two things below. The one that matters is the hole strip: it is a third independent source change riding along with the two decode fixes, and nothing tests it. I checked, and reverting it alone leaves the entire suite green.

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

@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.

Second pass, scoped to the fixture commit and its blast radius. The fixture does discriminate and the &nbsp; comment correction is right.

Two observations about what the hole strip leaves in the corpus, both on the same theme: a template hole in PROSE is dropped, so a sentence about a form binding can arrive with the binding gone. Worth writing down even though I do not think either is this PR's to fix, since the corpus is the surface an LLM reads.

Comment thread website/test/ssr/docs-llms.test.ts Outdated
Comment thread website/test/ssr/docs-llms.test.ts Outdated

@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.

Third pass, scoped to the hole-preservation commit. Three real problems, all in code that commit added. A fourth (the AGENTS.md entry) was already covered by a later commit than the one under review.

The sentinel one is the sharpest: the two keep passes run in sequence, so a string-literal hole can park text that already carries an escaped hole's sentinel, and a single-pass restore then emits the inner sentinel verbatim into a text/plain response. No docs page authors that shape today, so the corpus was clean and the mechanism was still wrong.

Comment thread website/lib/docs-llms.server.ts Outdated
Comment thread website/lib/docs-llms.server.ts Outdated
Comment thread website/test/ssr/docs-llms.test.ts
The llms corpus builder decoded HTML entities inside oneLine() and then ran
a generic tag strip over the decoded text. A `&lt;` a docs page authored as
prose became a bare `<`, and the later strip matched from it to the next
`>` anywhere in the document, deleting everything between the two.

On /docs/metadata-routes that was a single 935-character match that swallowed
5 of the page's 9 code samples plus the paragraphs among them. Site-wide it
deleted an escaped tag out of 253 prose lines, and where the match crossed a
newline it merged separate list items into one run-on line.

Tags are now stripped at every stage and entities decoded exactly once, at the
end. The two extractPage description sites spelled the same inversion the
other way round (decode then strip), so both route through a new plainText()
helper that applies the safe order. The template-hole strip is brace-aware
too: with the ordering fixed, the `"}` debris a nested hole leaves behind
would have become reader-visible instead of being swallowed.
The hole strip shipped with no counterfactual: reverting it alone left every
corpus walk and all four new fixtures green, because the only observable
difference is two corpus lines nothing asserted. Add the fixture, which reds
on the old regex.

Also correct the plainText doc comment. It named &hellip; as motivating the
second whitespace collapse, but that decodes to '...', which is not
whitespace. Only &nbsp; motivates it.
Restoring the swallowed fragments exposed what the hole strip does to prose
about form bindings. On main those clauses were deleted outright, so the
corpus had no <form action=\> line at all; with the fragments back it had
12, plus a stranded escape backslash on the client-router @submit line.

A prose hole has three shapes and only one is dynamic. \${x} is ESCAPED, so
the page renders the literal ${x} and there is no interpolation at all.
${"lit"} interpolates a string literal, so the reader sees that literal.
Only ${x} is resolved at render time and has nothing to contribute. The
first two are now kept (parked behind a sentinel so the dynamic strip cannot
eat them) and the third is still dropped.

The corpus is the surface an LLM reads, and root AGENTS.md invariant 12 is
about exactly which shapes a bound form may take, so shipping 12 lines that
teach <form action=\> would trade one defect for another. It now renders 24
correct <form action=${importedAction}> bindings, matching what a reader of
the rendered docs page sees.
The module now holds two invariants, not one. Record the second alongside
the strip-then-decode rule so a future edit cannot collapse the three hole
shapes back into one strip.
Two defects in the hole-preservation pass, plus the coverage it dropped.

The restore replaced once and did not rescan what it inserted, so a
string-literal hole parking an escaped hole's sentinel emitted that inner
sentinel verbatim. A private-use codepoint would have shipped in a
text/plain response and into the search index. It now repeats until none is
left, bounded by the parked count since a hole can only carry sentinels
parked before it.

The kept text is copied out of page SOURCE, which is a JS template literal,
so a backtick is written escaped there. Keeping it verbatim moved the escape
debris from in front of the hole to inside it: /docs/components and
/docs/lifecycle rendered .fallback=${html\`…\`} where the page shows
.fallback=${html`…`}, and /docs/troubleshooting mangled the very example
teaching invariant 9. Both keep passes now fold the escapes out.

Keeping the string-literal shape also stole the dynamic strip's only
counterfactual, since that fixture no longer reaches it. A dynamic hole
carrying a nested object literal pins its brace-awareness instead.
unescapeJs(lit) shipped with no counterfactual: no docs page carries an
escape inside a string-literal hole, so reverting it left every test green
and the corpus byte-identical. A fixture holds that half of the rule now.

The brace-aware comment also overclaimed. The regex handles ONE level of
nesting, so ${fn({a:{b:1}})} still leaves )} behind. No page authors that,
checked across all 44, and arbitrary depth is not a regex's job, so the
fixture asserts the limit instead of implying it does not exist.

Test-only: the rendered corpus is byte-identical before and after.
@vivek7405
vivek7405 force-pushed the fix/llms-single-decode branch from 128e29e to 3cd0670 Compare August 8, 2026 13:31

@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.

Fourth pass, scoped to the nested-restore commit. Two gaps worth closing and one I am leaving alone.

The one I am leaving is the fenced-sample path. It is the same escape-folding question, but the code-block capture is a different code path that this PR does not touch, and I measured it as unchanged in both directions against main (5 fenced form bindings before, 5 after, and the backslash-backtick count identical at 371). Folding escapes there moves roughly 775 more corpus positions and needs its own before-and-after, so it is separate work rather than something to bolt on here. Flagged for a call on filing it.

Comment thread website/lib/docs-llms.server.ts
Comment thread website/lib/docs-llms.server.ts
Comment thread website/test/ssr/docs-llms.test.ts
@vivek7405
vivek7405 marked this pull request as ready for review August 8, 2026 13:57
@vivek7405
vivek7405 merged commit 3db6e6e into main Aug 8, 2026
10 checks passed
@vivek7405
vivek7405 deleted the fix/llms-single-decode branch August 8, 2026 13:58
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): a stray decoded angle bracket eats code samples from llms.txt

1 participant