Skip to content

test(website): tag-balance guard only covers app/docs, not the rest of the site #1263

Description

@vivek7405

Problem

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:

const CONTAINERS = ['pre', 'code-block', 'div', 'ul', 'ol', 'table'];   // L48
...
for await (const p of glob('website/app/docs/**/page.{js,ts}', { cwd: ROOT })) {   // 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.js swapMarkerRange (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 (const n of finalNodes) {
    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:

    else if (e.isFile() && (e.name.endsWith('.test.js') || e.name.endsWith('.test.mjs'))) out.push(full);

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

Tag list: keep the existing six, add nothing

CONTAINERS stays 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, 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.

Live case, website/components/doc-search.ts L97:

                   @click=${(e: Event) => { e.preventDefault(); this.goTo(r.path); }}>

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:

  const tplStart = raw.indexOf('html`');
  ...
    if (lastTick > tplStart) body = raw.slice(tplStart + 'html`'.length, lastTick);

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

Step 1. Replace extractHtmlTemplates (currently L66-94)

Today:

/**
 * 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.
 */
async function extractHtmlTemplates(filePath) {
  const src = await readFile(filePath, 'utf8');
  const out = [];
  let i = 0;
  while (i < src.length) {
    const start = src.indexOf('html`', i);
    if (start < 0) break;
    let j = start + 'html`'.length;
    let depth = 0;
    while (j < src.length) {
      const ch = 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;
  }
  return out.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. */
function skipString(src, i) {
  const quote = src[i];
  i++;
  while (i < src.length) {
    if (src[i] === '\\') { i += 2; continue; }
    if (src[i] === quote) return i + 1;
    i++;
  }
  return i;
}

/**
 * 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.
 */
export function scanTemplate(src, i) {
  let text = '';
  while (i < src.length) {
    const ch = src[i];
    if (ch === '\\') { text += src.slice(i, i + 2); i += 2; continue; }
    if (ch === '`') return { text, end: i };
    if (ch === '$' && src[i + 1] === '{') {
      const hole = 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 `}`.
 */
function scanExpression(src, i) {
  let depth = 1;
  let text = '';
  while (i < src.length) {
    const ch = 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 === '`') {
      const nested = 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.
 */
export function extractHtmlTemplatesFrom(src) {
  const out = [];
  let i = 0;
  while (i < src.length) {
    const start = 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; }
    const tpl = scanTemplate(src, start + 'html`'.length);
    out.push(tpl.text);
    i = tpl.end + 1;
  }
  return out.join('\n');
}

async function extractHtmlTemplates(filePath) {
  return extractHtmlTemplatesFrom(await readFile(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. */
export function unbalancedContainers(body) {
  const bad = [];
  for (const tag of CONTAINERS) {
    const { open, close } = tagCounts(body, tag);
    if (open !== close) bad.push(`<${tag}> open=${open} close=${close}`);
  }
  return bad;
}

Step 2. Replace listDocsPages (currently L96-102) with an app table

Today:

async function listDocsPages() {
  const entries = [];
  for await (const p of glob('website/app/docs/**/page.{js,ts}', { cwd: ROOT })) {
    entries.push(resolve(ROOT, p));
  }
  return entries;
}

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.
 */
const APPS = [
  {
    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,
  },
];

async function listSources(app) {
  const entries = [];
  for await (const p of glob(app.pattern, { cwd: ROOT })) {
    if (app.exclude.some((re) => re.test(p))) continue;
    entries.push(resolve(ROOT, p));
  }
  return entries;
}

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 (const app of APPS) {
  test(`${app.name}: every source file has matching open/close counts for ${CONTAINERS.map((t) => `<${t}>`).join(', ')}`, async () => {
    const files = await listSources(app);
    assert.ok(
      files.length >= app.minFiles,
      `expected the full ${app.name} corpus, found ${files.length}: glob or exclusions wrong?`,
    );

    /** @type {string[]} */
    const failures = [];
    let withBody = 0;
    for (const file of files) {
      const body = await extractHtmlTemplates(file);
      if (!body) continue;          // metadata routes, route handlers, pure logic
      withBody++;
      for (const entry of unbalancedContainers(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.

Step 6. Move the file

git mv test/docs/docs-pages-well-formed.test.js test/repo-health/site-pages-well-formed.test.mjs

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', () => {
  const src = [
    'const t = html`',
    '  <div>',
    '    <a @click=${(e) => { e.preventDefault(); go(); }}>x</a>',
    '  </div>',
    '`;',
  ].join('\n');
  const body = 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", () => {
  const src = "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', () => {
  const src = 'const t = html`<style>.x { color: red }</style><div></div>`;\nconst after = "<div>";\n';
  const body = 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', () => {
  const body = 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.
  • docs/ and packages/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 in test/repo-health/site-seo-tags.test.mjs L34-41). Measured, each has 2 matching source files and zero html templates. There is nothing to guard.
  • 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.

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