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 container-tag balance guard covers website/app/docs and nothing else, so most of the site's hand-authored markup is unchecked.
All line anchors below were re-verified at HEAD 5ac991ce.
test/docs/docs-pages-well-formed.test.js counts opens against closes for pre, code-block, div, ul, ol, table (L48) in every page's html template. Its glob is at L98:
It exists because of a real incident, recorded in its own header at L10-13. An unclosed <pre> in website/app/docs/components/page.ts pulled the <!--/wj:children--> layout marker inside it, after which every client-router navigation past that page threw NotFoundError.
The mechanism is still live at HEAD.packages/core/src/router-client.jsswapMarkerRange (L3340) takes liveParent from target.start.parentNode (L3347) and hands target.end to reconcileSiblings (L3371) as the insertion reference. reconcileSiblings (L3405) ends with:
// Insert final nodes in order before the end marker.for(constnoffinalNodes){parent.insertBefore(n,insertBefore);// L3445}
When the closing marker has been swallowed into an unclosed container, it is no longer a child of liveParent, and insertBefore throws NotFoundError. There is no same-parent gate on this path (swapMarkerRange checks only that target.start.parentNode exists, L3348), and the replace tier has the identical shape at L3301 and L3318. So an unbalanced container tag anywhere on the site still breaks client navigation exactly as it did.
Everything outside website/app/docs is unguarded. Measured at HEAD, the site holds 76 source files carrying an html template and the current glob reaches 43 of them. Among the 33 it never sees:
website/app/page.ts (5 hand-authored <pre> pairs), website/app/why-webjs/page.ts (4), website/app/what-is-webjs/page.ts (1), website/app/error.ts (1). Marketing pages hold their samples as JS strings and write their own <pre> around highlight(SAMPLE), which website/AGENTS.md L355-359 explicitly sanctions, so this is a growing surface, not a legacy one.
website/app/ui/page.ts and website/app/ui/[name]/page.ts.
The hub pages under app/articles/, app/blog/, app/compare/, app/changelog/, app/brand/, plus app/not-found.ts.
website/app/layout.ts, which wraps every page, so unbalanced markup there breaks the whole site rather than one route.
website/components/** (8 files), website/lib/ui/** (4 files), and four more markup-authoring modules outside both: website/lib/design/brand.ts, website/lib/links.ts, website/lib/utils/highlight.ts, website/modules/changelog/utils/pkg-badge.ts.
Nothing else in the repo checks tag balance. The failure is silent at author time, survives review (the page renders fine on a hard load), and only appears as a client-router crash on a later navigation, which is precisely why it was worth automating for the docs.
Two corrections to the previous statement of this issue. First, it claimed #1249 (bfa0eb49) DROPPED code-block from CONTAINERS. It did the opposite: the diff of that commit reads -const CONTAINERS = ['pre', 'div', 'ul', 'ol', 'table']; / +const CONTAINERS = ['pre', 'code-block', 'div', 'ul', 'ol', 'table'];. code-block is in the list at HEAD and must stay. Second, every line anchor in the previous version was roughly nine lines short (it cited CONTAINERS at L39 and the glob at L89). The anchors above replace them.
Design / approach
Corpus shape: one broad glob with a named exclusion list
The guard covers website/**/*.{js,ts} minus a short exclusion list, not an enumerated include list of directories.
The deciding property is which shape fails safe when someone adds a file. A broad glob covers a new page, a new component, and a new top-level directory by default. An include list covers them only when someone remembers to widen it. That is the exact failure this issue reports. The docs glob was written once and never revisited while the marketing pages grew eleven hand-authored <pre> blocks beside it.
This is not theoretical here. An app-plus-components include list, measured, misses four tracked files that really do author markup (website/lib/design/brand.ts, website/lib/links.ts, website/lib/utils/highlight.ts, website/modules/changelog/utils/pkg-badge.ts) and misses the whole tracked website/modules/changelog/ tree.
Exclusions, each with its reason:
Excluded
Why
website/node_modules/**
Dependency source, not ours to fix.
website/test/**
A test fixture may deliberately hold broken markup, and a guard that reds on its own fixtures is unusable.
website/scripts/**
Build tooling, no served markup.
website/modules/ui/components/**
Gitignored generated mirror of packages/ui/packages/registry/ (website/.gitignore L7), written by website/scripts/copy-registry.mjs. It does not exist until that script runs, so including it makes the corpus size depend on whether pretest ran, and a failure there is not fixable in website/. Exclude components/ only, never modules/ui/ wholesale.website/modules/ui/queries/ and website/modules/ui/utils/ hold five TRACKED files that must stay in the corpus.
website/lib/utils/cn.ts, website/lib/utils/dom.ts
The other two gitignored outputs of the same script (website/.gitignore L8-9).
website/components/ui/**
Gitignored webjs ui add target, tracked as empty (website/.gitignore L16).
website/.webjs/**
Generated route types and vendor state.
Metadata routes and route.{js,ts} handlers (app/sitemap.ts, app/robots.ts, app/llms.txt/route.ts, app/llms-full.txt/route.ts, app/api/search/route.ts, app/ui/registry/**/route.ts) need no exclusion rule. They emit XML, plain text, or JSON and hold no html template, so the extractor returns an empty body and the existing if (!body) continue skips them. Verified over the whole corpus.
Measured result on the widened corpus: 116 files matched, 76 carrying a template, ZERO violations. So this lands with no allowlist and no violation backlog. Reproduce it with the script quoted under Tests.
The zero holds only after the extractor is fixed. As written, extractHtmlTemplates reports two false positives inside the corpus (see the next section).
Rejected.
An enumerated include list of globs (website/app/**/{page,layout,error,...}.{js,ts} plus website/components/** plus website/lib/ui/**). Measured, it misses the four markup-authoring files named above and fails open for any new top-level directory. The broad glob's exclusion list is short, each entry has a durable reason, and a new directory is covered rather than forgotten.
An allowlist of known-unbalanced files that shrinks over time. The widened corpus is already clean, so the allowlist would be empty and would encode nothing. The repo's other health guards (test/repo-health/site-seo-tags.test.mjs, test/repo-health/gitignore-webjs-depth.test.mjs) assert their invariant outright with no per-file waiver.
Excluding *.server.ts to dodge the docs-llms.server.ts false positive. That treats a symptom of the extractor as a corpus rule, and it would silently drop any future server module that does author markup. Fix the extractor instead.
Rendering each page and checking the output DOM. The three apps have different dependency trees and rendering the corpus means booting the site. A source-text check is what caught the original bug and is what site-seo-tags.test.mjs chose for the same reason (its header, L22-25).
Splitting into two test files, one for the docs and one for the rest. One failure class, one extractor, one corpus. A split duplicates the extractor or invents a shared helper for two callers.
Leaving the file at test/docs/. The corpus is no longer docs-specific.
File name and location
test/docs/docs-pages-well-formed.test.js moves to test/repo-health/site-pages-well-formed.test.mjs, via git mv so history follows.
test/repo-health/ is where the cross-cutting source-level guards live, and site-seo-tags.test.mjs is the nearest sibling in kind (source-level, incident-derived, spanning surfaces, structured as a table of apps so adding one is a single row). Every file in that directory is .mjs.
scripts/run-node-tests.js walks test/ recursively and accepts both extensions (L32), so no runner config changes:
A repo-wide grep for the old path, excluding node_modules and .git, returns nothing. No CI workflow, no npm script, no comment, no doc names it. (.github/workflows/ci.yml L507 and framework-dev.md L98 mention test/docs/docs-host-redirect.test.mjs and test/docs/llms.test.mjs, which are different files and stay put.)
Extending is not blocked by existing violations. I evaluated 33 candidate tags (section, nav, main, article, aside, figure, blockquote, form, span, a, button, svg, details, header, footer, p, li, td, tr, th, tbody, thead, dl, dt, dd, option, select and the six current ones) over the widened corpus and the website is clean under every one of them today. The reason not to extend is cost against benefit, and one of the costs is measurable.
The counter is textual, so a tag NAME written inside an HTML comment in the template counts as an open. That is live, not hypothetical: examples/blog/app/layout.ts counts <details> open=3 close=1, and two of the three opens come from comments at L285 and L318 that write <details>/<summary> in prose. Every tag added widens that surface.
Against that, the added coverage is near zero. A <div> already brackets essentially every region of these pages, so an unclosed <section> inside one is caught by the enclosing <div> count on the same file.
The rule for anyone extending the list later:
Safe to count are elements whose start AND end tags are both REQUIRED and which are not void. div, pre, ul, ol, table qualify, and so does any custom element, which is why code-block belongs (it carries all 476 code samples under app/docs, the historical offender's modern shape).
Never safe are void elements (br, img, input, hr, meta, link). They have no end tag at all, so close is structurally zero.
Never safe are the elements HTML5 gives an OPTIONAL end tag: p, li, tr, td, th, thead, tbody, tfoot, option, dt, dd. An author may legitimately omit the close and the parser recovers correctly, so counting them flags correct markup.
Not worth it is svg. It is properly paired, but its subtree is foreign content full of self-closing children, an inline icon is normally pasted whole from a design tool, and any corruption inside it is already bracketed by the enclosing <div>.
Extractor: two independent bugs, both live in the widened corpus
extractHtmlTemplates (L73-94) is currently a flat ${ / } counter over the file. Two things go wrong, and each produces a false positive inside the new corpus.
Bug 1, the flat depth counter desynchronizes on any { that is not part of ${. An arrow function with a block body, or a bare object literal, closes with a } that decrements the counter without a matching increment. The scan then treats an earlier backtick as the end of the literal and silently drops the rest.
The arrow body's closing } drops the depth by one too many, so the scan terminates at the backtick in ` : ''} (L103) instead of the real closing backtick at L105, dropping the trailing </div>. The file reports <div> open=6 close=5 against markup that is correctly balanced.
**Bug 2, an html\`` inside a STRING literal is read as a template start.** website/lib/docs-llms.server.ts` parses page source and holds the literal at L160 and L163:
The extractor takes that as a template opening and scans the remainder of the file as markup, so the regex SOURCE at L204 (/<(?:pre|code-block)(?=[\s>])[^>]*>([\s\S]*?)<\/(?:pre|code-block)>/g) counts as tags. The file reports <pre> 2/0 and <code-block> 1/0. Fixing this is what makes the broad glob viable without carving *.server.ts out of the corpus.
The fix for bug 1 is two mutually recursive scanners, and it carries a second benefit worth naming. Because the hole scanner keeps only NESTED template bodies and discards the hole's own JS, a <div appearing inside a JS string in a hole stops being counted as markup. Such a string renders as escaped text, never as an element, so counting it was always wrong.
Rejected for bug 1: naive brace counting (treat every { as depth, not just ${). It fixes doc-search.ts, but a literal { in template TEXT (a CSS rule inside a <style>, an unescaped code sample) desynchronizes it the other way and can over-run past the literal's real end.
Known limitation of the bug-2 fix, accepted deliberately. The guard on the character before html catches an immediately-quoted occurrence, which is the shape that exists. A string containing html\`` after a space ('see html`x`') would still be read as a template start. The alternative, a stateful top-level scanner that skips strings, comments, and regex literals, fails the other way: a regex holding an unbalanced quote (/[^']/`) flips it into string mode and it silently SWALLOWS a later template, so the guard passes on nothing. For a guard, failing loud beats failing silent, so the stateless check wins. Both behaviours are pinned by fixtures.
Implementation plan
Cut a worktree first, per root AGENTS.md.
git worktree add -b test/site-tag-balance ../webjs-site-tag-balance origin/main
cd ../webjs-site-tag-balance && npm run worktree:link
/** * Read a page module source and extract every top-level `` html`...` `` * template literal. Returns the concatenated body of those literals. * Conservative: anything outside `` html`` `` (e.g. helper-fn fragments, * doc strings) is ignored, since only the rendered template lands in * the response HTML. */asyncfunctionextractHtmlTemplates(filePath){constsrc=awaitreadFile(filePath,'utf8');constout=[];leti=0;while(i<src.length){conststart=src.indexOf('html`',i);if(start<0)break;letj=start+'html`'.length;letdepth=0;while(j<src.length){constch=src[j];if(ch==='\\'){j+=2;continue;}if(ch==='`'&&depth===0)break;if(ch==='$'&&src[j+1]==='{'){depth++;j+=2;continue;}if(ch==='}'&&depth>0){depth--;j++;continue;}j++;}out.push(src.slice(start+'html`'.length,j));i=j+1;}returnout.join('\n');}
After. Split the file read from the scan so fixtures can drive the scanner on a string, which is the shape #1249 used when it exported bodyToMarkdown for the same reason.
/** Skip a `'...'` / `"..."` string. Returns the index just past the closer. */functionskipString(src,i){constquote=src[i];i++;while(i<src.length){if(src[i]==='\\'){i+=2;continue;}if(src[i]===quote)returni+1;i++;}returni;}/** * Scan template TEXT, starting just after an opening backtick. Keeps every * text character, recurses on `${`, and stops at its own closing backtick. * * @returns {{ text: string, end: number }} `end` indexes the closing backtick. */exportfunctionscanTemplate(src,i){lettext='';while(i<src.length){constch=src[i];if(ch==='\\'){text+=src.slice(i,i+2);i+=2;continue;}if(ch==='`')return{ text,end: i};if(ch==='$'&&src[i+1]==='{'){consthole=scanExpression(src,i+2);text+=hole.text;i=hole.end+1;continue;}text+=ch;i++;}return{ text,end: i};}/** * Scan a `${...}` HOLE, starting just after the `{`, at brace depth 1. * Skips strings and comments so their braces cannot move the depth, and * recurses into a nested backtick template, KEEPING that nested body (a * nested html`...` is real markup that reaches the response). The hole's * own JS is discarded: a `<div` inside a JS string renders as escaped * text, never as an element, so counting it was always wrong. * * @returns {{ text: string, end: number }} `end` indexes the closing `}`. */functionscanExpression(src,i){letdepth=1;lettext='';while(i<src.length){constch=src[i];if(ch==='\\'){i+=2;continue;}if(ch==="'"||ch==='"'){i=skipString(src,i);continue;}if(ch==='/'&&src[i+1]==='/'){while(i<src.length&&src[i]!=='\n')i++;continue;}if(ch==='/'&&src[i+1]==='*'){i+=2;while(i<src.length&&!(src[i]==='*'&&src[i+1]==='/'))i++;i+=2;continue;}if(ch==='`'){constnested=scanTemplate(src,i+1);text+=nested.text+'\n';i=nested.end+1;continue;}if(ch==='{'){depth++;i++;continue;}if(ch==='}'){depth--;if(depth===0)return{ text,end: i};i++;continue;}i++;}return{ text,end: i};}/** * Extract every `` html`...` `` template body from a module SOURCE and * return them concatenated. Exported so the fixtures below can drive it * without touching disk. */exportfunctionextractHtmlTemplatesFrom(src){constout=[];leti=0;while(i<src.length){conststart=src.indexOf('html`',i);if(start<0)break;// `html` must be a standalone tag. Reject the tail of an identifier// (`myHtml`), a member expression (`x.html`), and an occurrence inside// a string: lib/docs-llms.server.ts holds the literal 'html`' while// parsing page source, and reading that as a template start made the// whole rest of that file count as markup (<pre> 2/0).if(start>0&&/[A-Za-z0-9_$.'"`]/.test(src[start-1])){i=start+5;continue;}consttpl=scanTemplate(src,start+'html`'.length);out.push(tpl.text);i=tpl.end+1;}returnout.join('\n');}asyncfunctionextractHtmlTemplates(filePath){returnextractHtmlTemplatesFrom(awaitreadFile(filePath,'utf8'));}
Also export a pure checker so the fixtures can assert a failure without a corpus:
/** @returns {string[]} one entry per unbalanced container, empty when clean. */exportfunctionunbalancedContainers(body){constbad=[];for(consttagofCONTAINERS){const{ open, close }=tagCounts(body,tag);if(open!==close)bad.push(`<${tag}> open=${open} close=${close}`);}returnbad;}
Step 2. Replace listDocsPages (currently L96-102) with an app table
After. Structure it as a table of apps, mirroring APPS in test/repo-health/site-seo-tags.test.mjs (L42-44), so bringing another in-repo app under the guard later is one row rather than a refactor.
/** * Every in-repo app under this guard, one row each. A broad glob with a * named exclusion list, NOT an enumerated include list: a page, component, * or whole directory added tomorrow is covered by default, which is the * property the docs-only glob lacked while eleven hand-authored <pre> * blocks grew beside it. * * Metadata routes and route.{js,ts} handlers need no exclusion. They emit * XML, text, or JSON and hold no html template, so the extractor returns * an empty body and they are skipped. * * Floors are set a little under today's counts (116 files, 76 with a * template at 5ac991ce) so ordinary churn does not red the guard while a * glob that collapses back to the docs corpus (44) or to nothing does. */constAPPS=[{name: 'website (webjs.dev, incl. /docs and /ui)',pattern: 'website/**/*.{js,ts}',exclude: [/^website\/node_modules\//,// dependency source/^website\/test\//,// fixtures may hold broken markup on purpose/^website\/scripts\//,// build tooling, no served markup/^website\/modules\/ui\/components\//,// gitignored @webjsdev/ui mirror (.gitignore L7).// components/ ONLY: modules/ui/queries and// modules/ui/utils are tracked and stay in./^website\/lib\/utils\/(cn|dom)\.ts$/,// same script's other outputs (.gitignore L8-9)/^website\/components\/ui\//,// gitignored `webjs ui add` target (.gitignore L16)/^website\/\.webjs\//,// generated route types / vendor state],minFiles: 100,minWithBody: 65,},];asyncfunctionlistSources(app){constentries=[];forawait(constpofglob(app.pattern,{cwd: ROOT})){if(app.exclude.some((re)=>re.test(p)))continue;entries.push(resolve(ROOT,p));}returnentries;}
Step 3. Run the corpus check per row, with both floors inside the loop
The two floor assertions today sit at L115-118 (pages.length >= 40) and L139-142 (withBody >= 40). They are load-bearing and must survive the rewrite, not be dropped: they exist because this glob once pointed at a moved directory, matched a single redirect stub with no template, and every check below passed on an empty string. Move both INSIDE the per-app loop so a row can never collapse to zero unnoticed.
for(constappofAPPS){test(`${app.name}: every source file has matching open/close counts for ${CONTAINERS.map((t)=>`<${t}>`).join(', ')}`,async()=>{constfiles=awaitlistSources(app);assert.ok(files.length>=app.minFiles,`expected the full ${app.name} corpus, found ${files.length}: glob or exclusions wrong?`,);/** @type {string[]} */constfailures=[];letwithBody=0;for(constfileoffiles){constbody=awaitextractHtmlTemplates(file);if(!body)continue;// metadata routes, route handlers, pure logicwithBody++;for(constentryofunbalancedContainers(body)){failures.push(`${file.replace(ROOT+'/','')}: ${entry}`);}}// The file floor proves they were FOUND. This proves they were READ:// without it a regression in the extractor hands back empty strings and// every check passes on nothing, the same vacuum one layer down.assert.ok(withBody>=app.minWithBody,`only ${withBody} of ${files.length} files yielded a template: extraction broken?`,);assert.deepEqual(failures,[],/* keep the existing message, updated per step 5 */);});}
Step 4. Keep CONTAINERS (L48) byte-for-byte
No additions. Keep the test name derived from CONTAINERS rather than spelling the tags into the string, for the reason recorded at L105-107 (the name advertised <pre> and omitted code-block for exactly as long as the guard was inert).
Step 5. Rewrite the file header (L1-20) and the CONTAINERS comment (L31-47)
The header keeps the incident it was written against and adds why the corpus is now the whole site with a short exclusion list. The CONTAINERS comment at L44-46 currently ends with a sentence that this change makes false:
It is NOT cover for the marketing pages, which do author <pre> and which this guard's glob does not reach.
It does reach them now. Replace that sentence with the safe-to-count rule from the Design section (required-both-tags and non-void only, never a void element, never an optional-end-tag element), so the next person extending the list has the criterion rather than a list to copy.
Update the failure message at L143-154 too: it says "in doc pages" and points at <code-block> as the usual offender, which is right for /docs and wrong for a marketing page (whose samples are a hand-written <pre> around highlight(SAMPLE), per website/AGENTS.md L355-359). Name both shapes.
Nothing else needs updating for the move. There are no references to the old path anywhere in the repo, and scripts/run-node-tests.js picks up both extensions under test/.
Step 7. Documentation
Add the bullet to website/AGENTS.md under ## Style (L333, alongside the existing code-sample rules at L349-359). See the Docs section.
Tests
This change IS the test, so the counterfactuals are the deliverable. Add all four fixtures to the moved file, driving the exported extractHtmlTemplatesFrom and unbalancedContainers on inline strings so they need no disk and no corpus.
1. The widening actually fires. The corpus counterfactual: delete one </pre> from website/app/page.ts and the guard must red with website/app/page.ts: <pre> open=5 close=4. Under the old glob that file was never read, so the same mutation was invisible. Perform it by hand, record the output in the PR body, and revert it. Do not commit a broken page.
2. The extractor fix for the desynchronizing hole. Reds against the current flat counter.
test('a hole holding a block-bodied arrow does not truncate the literal',()=>{constsrc=['const t = html`',' <div>',' <a @click=${(e) => { e.preventDefault(); go(); }}>x</a>',' </div>','`;',].join('\n');constbody=extractHtmlTemplatesFrom(src);assert.match(body,/<\/div>/,'the scan reached the real closing backtick');assert.deepEqual(unbalancedContainers(body),[]);});
Add the object-literal twin (${cls({ variant: 'outline' })}) in the same shape, since that is the construct the previous statement of this issue named and it desynchronizes identically.
3. The extractor fix for a string that contains `html``. Reds against the current version, which returns the rest of the file.
test("the literal string 'html`' is not read as a template start",()=>{constsrc="const marker = 'html`';\nconst re = /<pre[^>]*>/g;\n";assert.equal(extractHtmlTemplatesFrom(src),'');});
4. The mirror case, a bare { in template TEXT. Guards against the rejected naive-brace-counting fix.
test('a bare { in template text does not over-run the closing backtick',()=>{constsrc='const t = html`<style>.x { color: red }</style><div></div>`;\nconst after = "<div>";\n';constbody=extractHtmlTemplatesFrom(src);assert.ok(!body.includes('const after'),'the scan stopped at the real closing backtick');assert.deepEqual(unbalancedContainers(body),[]);});
5. A deliberately unbalanced fixture, so the checker itself is proven to fail.
test('an unclosed container is reported',()=>{constbody=extractHtmlTemplatesFrom('const t = html`<div><span>x</span>`;');assert.deepEqual(unbalancedContainers(body),['<div> open=1 close=0']);});
How to run.
node --test test/repo-health/site-pages-well-formed.test.mjs # this file alone
npm test# the whole node suite
Reproduce the corpus measurement independently with a throwaway script (do not commit it), which is how the numbers in this issue were obtained:
node -e "…apply unbalancedContainers over the APPS glob and print the count…"
Layers. This is a static source check with no runtime, no DOM, and no runtime-sensitive surface, so the browser, e2e, and Bun-parity layers do not apply. It runs under npm test via scripts/run-node-tests.js.
Docs
website/AGENTS.md, ## Style (L333), one bullet. Add it after the code-sample rules that end at L359, since an author writing a <pre> there is exactly who needs it. It must state three things: that test/repo-health/site-pages-well-formed.test.mjs counts container-tag balance across all of website/, not just app/docs, that shared chrome under lib/ui/, components/, and lib/design/ is covered because pages render through it, and that the generated mirrors under modules/ui/components/ and components/ui/, plus test/ and scripts/, are deliberately outside it. Name the consequence in one line, that an unbalanced container swallows the client router's <!--/wj:children--> marker and throws NotFoundError on the next navigation.
No other surface applies, and this is a decision, not an omission. The change is website- and test-only with no public API, so the framework skill at .agents/skills/webjs/, the docs site under website/app/docs/, the marketing pages, the scaffold templates under packages/cli/templates/, the MCP surface, root AGENTS.md, README.md, and the changelog are all N/A. framework-dev.md needs no edit either: it does not enumerate the test/repo-health/ files, it names an individual test only where that test has a workflow of its own (check:git at L70), and this guard has none.
Acceptance criteria
The guard reads website/**/*.{js,ts} minus the six named exclusions, so a new page, component, or top-level directory is covered without anyone widening a list
The measured corpus is 116 files with 76 carrying a template, and the violation count on it is zero, with no allowlist and no per-file waiver
extractHtmlTemplatesFrom scans holes recursively, so a block-bodied arrow or a bare object literal no longer truncates the extracted body, and hole SOURCE is no longer counted as markup
extractHtmlTemplatesFrom rejects an html\`` preceded by a quote or an identifier character, so website/lib/docs-llms.server.tsyields no template instead of reporting
` 2/0
website/components/doc-search.ts reports <div> 6/5 with the old flat counter and is clean with the new scanner, demonstrated by the fixture in Tests item 2
Deleting one </pre> from website/app/page.ts reds the guard, and the same mutation was invisible under the old glob, with both outcomes recorded in the PR body
Per-app file and template floors sit inside the loop, so a row cannot collapse to zero unnoticed
CONTAINERS is unchanged at ['pre', 'code-block', 'div', 'ul', 'ol', 'table'], and the comment above it carries the safe-to-count rule rather than the now-false claim that the glob does not reach the marketing pages
The file lives at test/repo-health/site-pages-well-formed.test.mjs, moved with git mv so history follows, with no runner or CI change
website/AGENTS.md## Style states the guard's scope, its exclusions, and the client-router consequence
npm test is green, and webjs check plus webjs doctor are clean for website/
Out of scope
examples/blog. Measured at HEAD it is 112 source files with 54 carrying a template and zero violations under the current six tags, so bringing it in is one more APPS row and the table is structured for exactly that. It is not this issue: the issue scopes to webjs.dev, and the blog's markup habits differ enough to want their own read (its app/layout.ts counts <details> 3/1 purely from two HTML comments, which is the false-positive shape this corpus has none of). Do not add the row here, and do not file a follow-up issue for it.
Extending CONTAINERS. Settled above with the measurement and the safe-to-count rule.
website/modules/ui/components/**, website/components/ui/**, website/lib/utils/cn.ts, website/lib/utils/dom.ts. Gitignored outputs of website/scripts/copy-registry.mjs. A failure there is not fixable in website/, and they do not exist until that script runs. Exclude those paths only, since website/modules/ui/queries/ and website/modules/ui/utils/ are tracked and belong in the corpus.
Rendering pages to check balance. The guard stays a source-text check.
Any change to packages/core/src/router-client.js. Its insertBefore throw is the failure this guard prevents, not a bug to fix here.
website/app/docs/metadata-routes's known sample truncation in the llms.txt pipeline, pinned by exact counts in the #1249 tests. Unrelated pipeline, do not touch it.
Problem
The container-tag balance guard covers
website/app/docsand nothing else, so most of the site's hand-authored markup is unchecked.All line anchors below were re-verified at HEAD
5ac991ce.test/docs/docs-pages-well-formed.test.jscounts opens against closes forpre,code-block,div,ul,ol,table(L48) in every page'shtmltemplate. Its glob is at L98:It exists because of a real incident, recorded in its own header at L10-13. An unclosed
<pre>inwebsite/app/docs/components/page.tspulled the<!--/wj:children-->layout marker inside it, after which every client-router navigation past that page threwNotFoundError.The mechanism is still live at HEAD.
packages/core/src/router-client.jsswapMarkerRange(L3340) takesliveParentfromtarget.start.parentNode(L3347) and handstarget.endtoreconcileSiblings(L3371) as the insertion reference.reconcileSiblings(L3405) ends with:When the closing marker has been swallowed into an unclosed container, it is no longer a child of
liveParent, andinsertBeforethrowsNotFoundError. There is no same-parent gate on this path (swapMarkerRangechecks only thattarget.start.parentNodeexists, L3348), and the replace tier has the identical shape at L3301 and L3318. So an unbalanced container tag anywhere on the site still breaks client navigation exactly as it did.Everything outside
website/app/docsis unguarded. Measured at HEAD, the site holds 76 source files carrying anhtmltemplate and the current glob reaches 43 of them. Among the 33 it never sees:website/app/page.ts(5 hand-authored<pre>pairs),website/app/why-webjs/page.ts(4),website/app/what-is-webjs/page.ts(1),website/app/error.ts(1). Marketing pages hold their samples as JS strings and write their own<pre>aroundhighlight(SAMPLE), whichwebsite/AGENTS.mdL355-359 explicitly sanctions, so this is a growing surface, not a legacy one.website/app/ui/page.tsandwebsite/app/ui/[name]/page.ts.app/articles/,app/blog/,app/compare/,app/changelog/,app/brand/, plusapp/not-found.ts.website/app/layout.ts, which wraps every page, so unbalanced markup there breaks the whole site rather than one route.website/components/**(8 files),website/lib/ui/**(4 files), and four more markup-authoring modules outside both:website/lib/design/brand.ts,website/lib/links.ts,website/lib/utils/highlight.ts,website/modules/changelog/utils/pkg-badge.ts.Nothing else in the repo checks tag balance. The failure is silent at author time, survives review (the page renders fine on a hard load), and only appears as a client-router crash on a later navigation, which is precisely why it was worth automating for the docs.
Two corrections to the previous statement of this issue. First, it claimed #1249 (
bfa0eb49) DROPPEDcode-blockfromCONTAINERS. It did the opposite: the diff of that commit reads-const CONTAINERS = ['pre', 'div', 'ul', 'ol', 'table'];/+const CONTAINERS = ['pre', 'code-block', 'div', 'ul', 'ol', 'table'];.code-blockis in the list at HEAD and must stay. Second, every line anchor in the previous version was roughly nine lines short (it citedCONTAINERSat L39 and the glob at L89). The anchors above replace them.Design / approach
Corpus shape: one broad glob with a named exclusion list
The guard covers
website/**/*.{js,ts}minus a short exclusion list, not an enumerated include list of directories.The deciding property is which shape fails safe when someone adds a file. A broad glob covers a new page, a new component, and a new top-level directory by default. An include list covers them only when someone remembers to widen it. That is the exact failure this issue reports. The docs glob was written once and never revisited while the marketing pages grew eleven hand-authored
<pre>blocks beside it.This is not theoretical here. An app-plus-components include list, measured, misses four tracked files that really do author markup (
website/lib/design/brand.ts,website/lib/links.ts,website/lib/utils/highlight.ts,website/modules/changelog/utils/pkg-badge.ts) and misses the whole trackedwebsite/modules/changelog/tree.Exclusions, each with its reason:
website/node_modules/**website/test/**website/scripts/**website/modules/ui/components/**packages/ui/packages/registry/(website/.gitignoreL7), written bywebsite/scripts/copy-registry.mjs. It does not exist until that script runs, so including it makes the corpus size depend on whetherpretestran, and a failure there is not fixable inwebsite/. Excludecomponents/only, nevermodules/ui/wholesale.website/modules/ui/queries/andwebsite/modules/ui/utils/hold five TRACKED files that must stay in the corpus.website/lib/utils/cn.ts,website/lib/utils/dom.tswebsite/.gitignoreL8-9).website/components/ui/**webjs ui addtarget, tracked as empty (website/.gitignoreL16).website/.webjs/**Metadata routes and
route.{js,ts}handlers (app/sitemap.ts,app/robots.ts,app/llms.txt/route.ts,app/llms-full.txt/route.ts,app/api/search/route.ts,app/ui/registry/**/route.ts) need no exclusion rule. They emit XML, plain text, or JSON and hold nohtmltemplate, so the extractor returns an empty body and the existingif (!body) continueskips them. Verified over the whole corpus.Measured result on the widened corpus: 116 files matched, 76 carrying a template, ZERO violations. So this lands with no allowlist and no violation backlog. Reproduce it with the script quoted under Tests.
The zero holds only after the extractor is fixed. As written,
extractHtmlTemplatesreports two false positives inside the corpus (see the next section).Rejected.
website/app/**/{page,layout,error,...}.{js,ts}pluswebsite/components/**pluswebsite/lib/ui/**). Measured, it misses the four markup-authoring files named above and fails open for any new top-level directory. The broad glob's exclusion list is short, each entry has a durable reason, and a new directory is covered rather than forgotten.test/repo-health/site-seo-tags.test.mjs,test/repo-health/gitignore-webjs-depth.test.mjs) assert their invariant outright with no per-file waiver.*.server.tsto dodge thedocs-llms.server.tsfalse positive. That treats a symptom of the extractor as a corpus rule, and it would silently drop any future server module that does author markup. Fix the extractor instead.site-seo-tags.test.mjschose for the same reason (its header, L22-25).test/docs/. The corpus is no longer docs-specific.File name and location
test/docs/docs-pages-well-formed.test.jsmoves totest/repo-health/site-pages-well-formed.test.mjs, viagit mvso history follows.test/repo-health/is where the cross-cutting source-level guards live, andsite-seo-tags.test.mjsis the nearest sibling in kind (source-level, incident-derived, spanning surfaces, structured as a table of apps so adding one is a single row). Every file in that directory is.mjs.scripts/run-node-tests.jswalkstest/recursively and accepts both extensions (L32), so no runner config changes:A repo-wide grep for the old path, excluding
node_modulesand.git, returns nothing. No CI workflow, no npm script, no comment, no doc names it. (.github/workflows/ci.ymlL507 andframework-dev.mdL98 mentiontest/docs/docs-host-redirect.test.mjsandtest/docs/llms.test.mjs, which are different files and stay put.)Tag list: keep the existing six, add nothing
CONTAINERSstays exactly['pre', 'code-block', 'div', 'ul', 'ol', 'table'].Extending is not blocked by existing violations. I evaluated 33 candidate tags (
section,nav,main,article,aside,figure,blockquote,form,span,a,button,svg,details,header,footer,p,li,td,tr,th,tbody,thead,dl,dt,dd,option,selectand the six current ones) over the widened corpus and the website is clean under every one of them today. The reason not to extend is cost against benefit, and one of the costs is measurable.The counter is textual, so a tag NAME written inside an HTML comment in the template counts as an open. That is live, not hypothetical:
examples/blog/app/layout.tscounts<details>open=3 close=1, and two of the three opens come from comments at L285 and L318 that write<details>/<summary>in prose. Every tag added widens that surface.Against that, the added coverage is near zero. A
<div>already brackets essentially every region of these pages, so an unclosed<section>inside one is caught by the enclosing<div>count on the same file.The rule for anyone extending the list later:
div,pre,ul,ol,tablequalify, and so does any custom element, which is whycode-blockbelongs (it carries all 476 code samples underapp/docs, the historical offender's modern shape).br,img,input,hr,meta,link). They have no end tag at all, socloseis structurally zero.p,li,tr,td,th,thead,tbody,tfoot,option,dt,dd. An author may legitimately omit the close and the parser recovers correctly, so counting them flags correct markup.svg. It is properly paired, but its subtree is foreign content full of self-closing children, an inline icon is normally pasted whole from a design tool, and any corruption inside it is already bracketed by the enclosing<div>.Extractor: two independent bugs, both live in the widened corpus
extractHtmlTemplates(L73-94) is currently a flat${/}counter over the file. Two things go wrong, and each produces a false positive inside the new corpus.Bug 1, the flat depth counter desynchronizes on any
{that is not part of${. An arrow function with a block body, or a bare object literal, closes with a}that decrements the counter without a matching increment. The scan then treats an earlier backtick as the end of the literal and silently drops the rest.Live case,
website/components/doc-search.tsL97:The arrow body's closing
}drops the depth by one too many, so the scan terminates at the backtick in` : ''}(L103) instead of the real closing backtick at L105, dropping the trailing</div>. The file reports<div>open=6 close=5 against markup that is correctly balanced.**Bug 2, an
html\`` inside a STRING literal is read as a template start.**website/lib/docs-llms.server.ts` parses page source and holds the literal at L160 and L163:The extractor takes that as a template opening and scans the remainder of the file as markup, so the regex SOURCE at L204 (
/<(?:pre|code-block)(?=[\s>])[^>]*>([\s\S]*?)<\/(?:pre|code-block)>/g) counts as tags. The file reports<pre>2/0 and<code-block>1/0. Fixing this is what makes the broad glob viable without carving*.server.tsout of the corpus.The fix for bug 1 is two mutually recursive scanners, and it carries a second benefit worth naming. Because the hole scanner keeps only NESTED template bodies and discards the hole's own JS, a
<divappearing inside a JS string in a hole stops being counted as markup. Such a string renders as escaped text, never as an element, so counting it was always wrong.Rejected for bug 1: naive brace counting (treat every
{as depth, not just${). It fixesdoc-search.ts, but a literal{in template TEXT (a CSS rule inside a<style>, an unescaped code sample) desynchronizes it the other way and can over-run past the literal's real end.Known limitation of the bug-2 fix, accepted deliberately. The guard on the character before
htmlcatches an immediately-quoted occurrence, which is the shape that exists. A string containinghtml\`` after a space ('see html`x`') would still be read as a template start. The alternative, a stateful top-level scanner that skips strings, comments, and regex literals, fails the other way: a regex holding an unbalanced quote (/[^']/`) flips it into string mode and it silently SWALLOWS a later template, so the guard passes on nothing. For a guard, failing loud beats failing silent, so the stateless check wins. Both behaviours are pinned by fixtures.Implementation plan
Cut a worktree first, per root
AGENTS.md.Step 1. Replace
extractHtmlTemplates(currently L66-94)Today:
After. Split the file read from the scan so fixtures can drive the scanner on a string, which is the shape #1249 used when it exported
bodyToMarkdownfor the same reason.Also export a pure checker so the fixtures can assert a failure without a corpus:
Step 2. Replace
listDocsPages(currently L96-102) with an app tableToday:
After. Structure it as a table of apps, mirroring
APPSintest/repo-health/site-seo-tags.test.mjs(L42-44), so bringing another in-repo app under the guard later is one row rather than a refactor.Step 3. Run the corpus check per row, with both floors inside the loop
The two floor assertions today sit at L115-118 (
pages.length >= 40) and L139-142 (withBody >= 40). They are load-bearing and must survive the rewrite, not be dropped: they exist because this glob once pointed at a moved directory, matched a single redirect stub with no template, and every check below passed on an empty string. Move both INSIDE the per-app loop so a row can never collapse to zero unnoticed.Step 4. Keep
CONTAINERS(L48) byte-for-byteNo additions. Keep the test name derived from
CONTAINERSrather than spelling the tags into the string, for the reason recorded at L105-107 (the name advertised<pre>and omittedcode-blockfor exactly as long as the guard was inert).Step 5. Rewrite the file header (L1-20) and the
CONTAINERScomment (L31-47)The header keeps the incident it was written against and adds why the corpus is now the whole site with a short exclusion list. The
CONTAINERScomment at L44-46 currently ends with a sentence that this change makes false:It does reach them now. Replace that sentence with the safe-to-count rule from the Design section (required-both-tags and non-void only, never a void element, never an optional-end-tag element), so the next person extending the list has the criterion rather than a list to copy.
Update the failure message at L143-154 too: it says "in doc pages" and points at
<code-block>as the usual offender, which is right for/docsand wrong for a marketing page (whose samples are a hand-written<pre>aroundhighlight(SAMPLE), perwebsite/AGENTS.mdL355-359). Name both shapes.Step 6. Move the file
Nothing else needs updating for the move. There are no references to the old path anywhere in the repo, and
scripts/run-node-tests.jspicks up both extensions undertest/.Step 7. Documentation
Add the bullet to
website/AGENTS.mdunder## Style(L333, alongside the existing code-sample rules at L349-359). See the Docs section.Tests
This change IS the test, so the counterfactuals are the deliverable. Add all four fixtures to the moved file, driving the exported
extractHtmlTemplatesFromandunbalancedContainerson inline strings so they need no disk and no corpus.1. The widening actually fires. The corpus counterfactual: delete one
</pre>fromwebsite/app/page.tsand the guard must red withwebsite/app/page.ts: <pre> open=5 close=4. Under the old glob that file was never read, so the same mutation was invisible. Perform it by hand, record the output in the PR body, and revert it. Do not commit a broken page.2. The extractor fix for the desynchronizing hole. Reds against the current flat counter.
Add the object-literal twin (
${cls({ variant: 'outline' })}) in the same shape, since that is the construct the previous statement of this issue named and it desynchronizes identically.3. The extractor fix for a string that contains `html``. Reds against the current version, which returns the rest of the file.
4. The mirror case, a bare
{in template TEXT. Guards against the rejected naive-brace-counting fix.5. A deliberately unbalanced fixture, so the checker itself is proven to fail.
How to run.
Reproduce the corpus measurement independently with a throwaway script (do not commit it), which is how the numbers in this issue were obtained:
node -e "…apply unbalancedContainers over the APPS glob and print the count…"Layers. This is a static source check with no runtime, no DOM, and no runtime-sensitive surface, so the browser, e2e, and Bun-parity layers do not apply. It runs under
npm testviascripts/run-node-tests.js.Docs
website/AGENTS.md,## Style(L333), one bullet. Add it after the code-sample rules that end at L359, since an author writing a<pre>there is exactly who needs it. It must state three things: thattest/repo-health/site-pages-well-formed.test.mjscounts container-tag balance across all ofwebsite/, not justapp/docs, that shared chrome underlib/ui/,components/, andlib/design/is covered because pages render through it, and that the generated mirrors undermodules/ui/components/andcomponents/ui/, plustest/andscripts/, are deliberately outside it. Name the consequence in one line, that an unbalanced container swallows the client router's<!--/wj:children-->marker and throwsNotFoundErroron the next navigation.No other surface applies, and this is a decision, not an omission. The change is website- and test-only with no public API, so the framework skill at
.agents/skills/webjs/, the docs site underwebsite/app/docs/, the marketing pages, the scaffold templates underpackages/cli/templates/, the MCP surface, rootAGENTS.md,README.md, and the changelog are all N/A.framework-dev.mdneeds no edit either: it does not enumerate thetest/repo-health/files, it names an individual test only where that test has a workflow of its own (check:gitat L70), and this guard has none.Acceptance criteria
website/**/*.{js,ts}minus the six named exclusions, so a new page, component, or top-level directory is covered without anyone widening a listextractHtmlTemplatesFromscans holes recursively, so a block-bodied arrow or a bare object literal no longer truncates the extracted body, and hole SOURCE is no longer counted as markupextractHtmlTemplatesFromrejects anhtml\`` preceded by a quote or an identifier character, sowebsite/lib/docs-llms.server.tsyields no template instead of reportingwebsite/components/doc-search.tsreports<div>6/5 with the old flat counter and is clean with the new scanner, demonstrated by the fixture in Tests item 2</pre>fromwebsite/app/page.tsreds the guard, and the same mutation was invisible under the old glob, with both outcomes recorded in the PR bodyCONTAINERSis unchanged at['pre', 'code-block', 'div', 'ul', 'ol', 'table'], and the comment above it carries the safe-to-count rule rather than the now-false claim that the glob does not reach the marketing pagestest/repo-health/site-pages-well-formed.test.mjs, moved withgit mvso history follows, with no runner or CI changewebsite/AGENTS.md## Stylestates the guard's scope, its exclusions, and the client-router consequencenpm testis green, andwebjs checkpluswebjs doctorare clean forwebsite/Out of scope
examples/blog. Measured at HEAD it is 112 source files with 54 carrying a template and zero violations under the current six tags, so bringing it in is one moreAPPSrow and the table is structured for exactly that. It is not this issue: the issue scopes to webjs.dev, and the blog's markup habits differ enough to want their own read (itsapp/layout.tscounts<details>3/1 purely from two HTML comments, which is the false-positive shape this corpus has none of). Do not add the row here, and do not file a follow-up issue for it.docs/andpackages/ui/packages/website/. Both are redirect-only hosts (seo: serve the docs at webjs.dev/docs with the marketing site chrome #1098 and seo: serve the UI gallery at webjs.dev/ui with the marketing site chrome #1099, recorded intest/repo-health/site-seo-tags.test.mjsL34-41). Measured, each has 2 matching source files and zerohtmltemplates. There is nothing to guard.CONTAINERS. Settled above with the measurement and the safe-to-count rule.website/modules/ui/components/**,website/components/ui/**,website/lib/utils/cn.ts,website/lib/utils/dom.ts. Gitignored outputs ofwebsite/scripts/copy-registry.mjs. A failure there is not fixable inwebsite/, and they do not exist until that script runs. Exclude those paths only, sincewebsite/modules/ui/queries/andwebsite/modules/ui/utils/are tracked and belong in the corpus.packages/core/src/router-client.js. ItsinsertBeforethrow is the failure this guard prevents, not a bug to fix here.website/app/docs/metadata-routes's known sample truncation in the llms.txt pipeline, pinned by exact counts in the#1249tests. Unrelated pipeline, do not touch it.