Skip to content

fix(website): a stray decoded angle bracket eats code samples from llms.txt #1261

Description

@vivek7405

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 &lt; 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.

  1. oneLine() at L121 strips tags FIRST and decodes entities SECOND:

    /** Collapse a fragment to a single trimmed line of plain text. */
    function oneLine(s: string): string {
      return s
        .replace(/<[^>]+>/g, ' ')        // L123  tag strip
        .replace(/&amp;/g, '&')          // L124  decode chain starts
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&#123;/g, '{')
        .replace(/&#125;/g, '}')
        .replace(/&#39;|&apos;/g, "'")
        .replace(/&quot;/g, '"')         // L130  decode chain ends
        .replace(/\s+/g, ' ')            // L131
        .trim();                         // L132
    }
  2. 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.

  3. 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>&amp;</code> or <code>&lt;</code> cannot break the document).
    

    oneLine strips the <code> wrappers, then decodes &lt; to a bare < with no matching > in the same paragraph.

  4. 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 &lt;title&gt; 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 ... content reads, <a> links navigate, <form> + server actions submit
    Browser 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 } ... 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 &amp;lt;div&amp;gt; arrives as &lt;div&gt; instead of <div>." Correction: false. decodeEntities() at L257 is itself a sequential chain whose &amp; replacement runs before its &lt; replacement, so &amp;lt;div&amp;gt; collapses to <div> inside a SINGLE call. Measured on a fixture: today <p>write &amp;lt;div&amp;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.

Consumer What changes
website/app/llms-full.txt/route.ts (/llms-full.txt) The headline surface. 34 of 43 page bodies gain text, /docs/metadata-routes gains 5 fenced samples plus the paragraphs among them.
website/app/docs/[topic]/llms.txt/route.ts (/docs/<topic>/llms.txt) Same page bodies, served one page at a time.
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 &lt;a&gt; 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:

export function plainText(s: string): string {
  return decodeEntities(oneLine(s)).replace(/\s+/g, ' ').trim();
}

The whitespace collapse is re-run after decoding because &nbsp; and &hellip; 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>&lt;form action=${"${createPost}"}&gt;</code>, which the strip reduces to &lt;form action="}&gt;. 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:

cd website
node --input-type=module -e "
import { readFile } from 'node:fs/promises';
const { getDocPages } = await import('./lib/docs-llms.server.ts');
let total = 0;
for (const p of await getDocPages()) {
  const src = await readFile('./app' + p.path + '/page.ts', 'utf8');
  const authored = (src.match(/<code-block(?=[\s>])/g) ?? []).length;
  const fenced = (p.markdown.match(/^\`\`\`/gm) ?? []).length / 2;
  total += fenced;
  if (authored !== fenced) console.log(p.path + ': ' + authored + ' authored, ' + fenced + ' fenced');
}
console.log('total fenced samples: ' + total);
"

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. */
function oneLine(s: string): string {
  return s
    .replace(/<[^>]+>/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&#123;/g, '{')
    .replace(/&#125;/g, '}')
    .replace(/&#39;|&apos;/g, "'")
    .replace(/&quot;/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 `&lt;` in one paragraph to a decoded `&lt;title&gt;` sixty
 * lines further down. Decoding belongs after every strip, never before one.
 */
function oneLine(s: string): string {
  return s
    .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 `&nbsp;`
 * and `&hellip;` only become whitespace-relevant once decoded.
 */
export function plainText(s: string): string {
  return decodeEntities(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

Today, at L290 and L294:

    description = oneLine(decodeEntities(unescapeJs(descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? '')));
    if (pMatch) description = oneLine(decodeEntities(pMatch[1]));

After:

    description = plainText(unescapeJs(descMatch[1] ?? descMatch[2] ?? descMatch[3] ?? ''));
    if (pMatch) description = plainText(pMatch[1]);

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 `&lt;` 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

cd website
node --input-type=module -e "const m = await import('./lib/docs-llms.server.ts'); process.stdout.write(await m.renderLlmsFull());" > /tmp/llms-after.txt
diff -u /tmp/llms-before.txt /tmp/llms-after.txt | less
wc -cl /tmp/llms-before.txt /tmp/llms-after.txt     # 916859 bytes / 15501 lines -> 924500 / 15584

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 '&lt;\|&gt;\|&amp;\|&quot;' /tmp/llms-after.txt must print nothing, exactly as it does today. Ordinary prose about markup has to read as <div>, not &lt;div&gt;, 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.

  1. 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 &lt;code&gt; does NOT reproduce the bug, because it decodes into a complete <code> tag that the strip removes locally. The trigger is a LONE &lt; 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 `&lt;` in one
      // paragraph, a sample, then a later paragraph whose own escaped tag
      // supplies the `>` that closes the runaway match.
      const md = bodyToMarkdown(
        'html`<p>a value with <code>&lt;</code> cannot break the document</p>' +
          '<code-block>const x = 1;</code-block>' +
          '<p>injects <code>&lt;title&gt;</code> tags</p>`'
      );
      assert.match(md, /a value with < cannot break the document/);
      assert.match(md, /```\nconst x = 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.

  2. 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 &lt;code&gt; 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.
      const md = bodyToMarkdown('html`<p>a value with &lt;code&gt; here</p>`');
      assert.equal(md, 'a value with <code> here');
    });

    Reverting Step 1 yields 'a value with here'.

  3. The description path strips before it decodes.

    test('plainText strips tags before it decodes entities', () => {
      assert.equal(plainText('a value with &lt;code&gt; here'), 'a value with <code> here');
      // A real page description: /docs/client-router loses both tags today.
      assert.match(plainText('intercepts same-origin &lt;a&gt; 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.

  4. 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.
      const page = await getDocPage('client-router');
      assert.ok(page, 'the client-router page is in the corpus');
      assert.match(page.description, /same-origin <a> clicks and <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 &amp;lt;div&amp;gt; reaches the corpus as &lt;div&gt;. It reaches it as <div>, because decodeEntities (L257) chains &amp; before &lt; 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, fast
cd 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 test
cd website && npx webjs check                              # convention validator
cd 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 '&lt;\|&gt;\|&amp;\|&quot;' /tmp/llms-after.txt prints nothing, so prose about markup reads as <div> and not &lt;div&gt;
  • 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

Out of scope

  • fix(website): docs search indexes shell comments as headings #1262, the docs search fence tracking. fix(website): docs search indexes shell comments as headings #1262 edits website/app/api/search/route.ts to 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 restored metadata-routes samples 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.
  • 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions