Skip to content

Commit 8122bb2

Browse files
committed
fix: scope global-* to the app root and stop // in a url blanking a line
Two behaviour defects and four stale claims. The widened module set added global-error and global-not-found, but router.js registers both only at the app root (dir === '.'), so a nested one is never routed and warning on it is advice about dead code, the same class the _private skip exists to avoid. The // comment blanking was a flat regex guarded only against https://. It fired on a protocol-relative href="//cdn/x.css" and blanked the rest of the line, so a layout mixing a CDN sheet with a local one went silently inert: exactly the shape this check targets. Comment blanking is a quote-aware walk now, because the two comment families live in opposite contexts. An HTML comment must be blanked INSIDE the template literal holding the tags, a JS comment only OUTSIDE a string. The recorded rationale for skipping a query or .. href was also wrong. It claimed asset() could never clear the warning, but this check reads the SOURCE shape, so a wrapped href is an unquoted hole and does clear it. The real reason is that the wrap is a runtime no-op there, so it would clear the warning without improving the caching. Remaining edits are the scope wording that widening left stale: the user-facing pass message, three in-file contract lines, the docs-site configuration page, and a built-ins sentence that still said this was a convention rather than something a tool checks.
1 parent 2267711 commit 8122bb2

4 files changed

Lines changed: 116 additions & 34 deletions

File tree

.agents/skills/webjs/references/built-ins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ import { html, asset } from '@webjsdev/core';
9191
html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
9292
```
9393

94-
That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, which is why this is a convention rather than a check rule. Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
94+
That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
9595

9696
Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `<link rel="stylesheet" href="/public/app.css">` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document.
9797

packages/cli/lib/doctor.js

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,7 @@ async function checkStaticAssetFreshness(appDir) {
969969
};
970970
}
971971

972-
// Directories the page/layout walk never descends into (deps, VCS, framework
972+
// Directories the route-module walk never descends into (deps, VCS, framework
973973
// and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can
974974
// change its exclusions without silently moving the other.
975975
const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
@@ -985,7 +985,16 @@ const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.n
985985
* @type {RegExp}
986986
*/
987987
const ROUTE_MODULE_RE =
988-
/^(?:page|layout|error|not-found|global-error|global-not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/;
988+
/^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/;
989+
990+
/**
991+
* The two boundary stems `router.js` registers ONLY at the app root (both are
992+
* guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never
993+
* in the route table and never renders, so scanning one would advise on dead
994+
* code, the same defect the `_private` skip exists to avoid.
995+
* @type {RegExp}
996+
*/
997+
const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/;
989998

990999
/**
9911000
* One whole `<link …>` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the
@@ -1050,12 +1059,14 @@ function parseTagAttrs(tag) {
10501059
* stripped, since under a sub-path deploy the author writes the prefix
10511060
* themselves and `resolveAssetUrl` strips it before its own `public/` gate).
10521061
* - `resolveAssetUrl` would actually fingerprint it. It returns a path
1053-
* carrying a QUERY or a `..` unchanged, so flagging one would raise a
1054-
* warning that `asset()` cannot clear: the author wraps it, nothing
1055-
* changes, the warning stays, and `doctor --strict` can never go green. A
1056-
* hand-rolled `?v=` cache-buster is exactly what an author who has not
1062+
* carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a
1063+
* runtime NO-OP: the author does the work and the url they ship is
1064+
* byte-identical. Advising it would be advising a change that buys nothing.
1065+
* A hand-rolled `?v=` cache-buster is exactly what an author who has not
10571066
* adopted `asset()` is most likely to have written, so this is the common
1058-
* case, not a corner. Only advise a fix that works.
1067+
* case, not a corner. (The warning itself would clear, since this check
1068+
* reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a
1069+
* warning without improving the caching is the outcome to avoid.)
10591070
*
10601071
* @param {string} tag
10611072
* @param {string} basePath the app's normalized `webjs.basePath` (`''` at root)
@@ -1074,9 +1085,8 @@ function unmarkedStylesheetHref(tag, basePath = '') {
10741085
// one `asset()` can actually fingerprint. It strips the base path, cuts at
10751086
// `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix.
10761087
// Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x`
1077-
// would be flagged although `asset()` refuses it (an unclearable warning,
1078-
// the very thing this check must never raise), and `/%70ublic/app.css` would
1079-
// be skipped although `asset()` fingerprints it.
1088+
// would be flagged although wrapping it changes nothing, and
1089+
// `/%70ublic/app.css` would be skipped although `asset()` fingerprints it.
10801090
let probe = url;
10811091
if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length);
10821092
const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
@@ -1129,29 +1139,73 @@ async function readAppBasePath(appDir) {
11291139
}
11301140

11311141
/**
1132-
* Commented-out regions, so dead markup is never read as live. Covers the three
1133-
* forms a route module can carry: an HTML comment inside a template, and the JS
1134-
* block and line comments around it. The JS LINE form is the one that actually
1135-
* bites, since commenting out a tag in a `.ts` page is done with `//`, and a
1136-
* warning the author can only clear by deleting a comment is exactly the
1137-
* un-clearable advice this check must never give.
1142+
* Blank commented-out regions to spaces so dead markup is never read as live,
1143+
* LENGTH-PRESERVING (newlines kept) so reported line numbers still point at the
1144+
* real source.
11381145
*
1139-
* The `//` matcher requires the slashes NOT be preceded by `:`, so a `https://`
1140-
* or protocol-relative url inside an href is not mistaken for a comment. Any
1141-
* residual mismatch blanks too much rather than too little, which costs a
1142-
* detection instead of inventing one.
1143-
* @type {RegExp}
1146+
* A quote-aware WALK rather than a regex, because the two comment families live
1147+
* in opposite contexts and a flat regex cannot tell them apart. The `<link>`
1148+
* tags sit inside a template literal, so an HTML comment must be blanked INSIDE
1149+
* a string, while a JS `//` or block comment is only a comment OUTSIDE one. A
1150+
* plain `(?<!:)//` matcher gets that second half wrong in the way that matters
1151+
* most here: it fires on a protocol-relative `href="//cdn/x.css"` and blanks the
1152+
* rest of the line, so a layout mixing a CDN sheet with a local one, the exact
1153+
* shape this check targets, goes silently inert.
1154+
*
1155+
* Backtick, single and double quotes all count as string context, so a `//`
1156+
* inside a `${}` hole is not treated as a comment. That fails toward scanning
1157+
* rather than toward blanking, which costs nothing but a redundant look.
1158+
*
1159+
* @param {string} src
1160+
* @returns {string}
11441161
*/
1145-
const COMMENT_RE = /<!--[\s\S]*?-->|\/\*[\s\S]*?\*\/|(?<!:)\/\/[^\n]*/g;
1162+
function blankComments(src) {
1163+
const blank = (t) => t.replace(/[^\n]/g, ' ');
1164+
let out = '';
1165+
let i = 0;
1166+
/** @type {string} */
1167+
let quote = '';
1168+
while (i < src.length) {
1169+
// An HTML comment is blanked in ANY context: it lives in the template.
1170+
if (src.startsWith('<!--', i)) {
1171+
const end = src.indexOf('-->', i + 4);
1172+
const stop = end === -1 ? src.length : end + 3;
1173+
out += blank(src.slice(i, stop));
1174+
i = stop;
1175+
continue;
1176+
}
1177+
const ch = src[i];
1178+
if (quote) {
1179+
if (ch === '\\') { out += src.slice(i, i + 2); i += 2; continue; }
1180+
if (ch === quote) quote = '';
1181+
out += ch; i++; continue;
1182+
}
1183+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; out += ch; i++; continue; }
1184+
if (ch === '/' && src[i + 1] === '/') {
1185+
const nl = src.indexOf('\n', i);
1186+
const stop = nl === -1 ? src.length : nl;
1187+
out += blank(src.slice(i, stop));
1188+
i = stop; continue;
1189+
}
1190+
if (ch === '/' && src[i + 1] === '*') {
1191+
const end = src.indexOf('*/', i + 2);
1192+
const stop = end === -1 ? src.length : end + 2;
1193+
out += blank(src.slice(i, stop));
1194+
i = stop; continue;
1195+
}
1196+
out += ch; i++;
1197+
}
1198+
return out;
1199+
}
11461200

11471201
/**
1148-
* Collect every `app/**` page + layout module path, depth-first.
1202+
* Collect every `app/**` route module that renders markup, depth-first.
11491203
* Best-effort: an unreadable directory contributes nothing rather than throwing.
11501204
* @param {string} dir
11511205
* @param {string[]} [out]
11521206
* @returns {string[]}
11531207
*/
1154-
function collectRouteModules(dir, out = []) {
1208+
function collectRouteModules(dir, root = dir, out = []) {
11551209
let entries;
11561210
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
11571211
for (const e of entries) {
@@ -1162,14 +1216,15 @@ function collectRouteModules(dir, out = []) {
11621216
// never rendered. Advising on it would be advice about dead code.
11631217
if (e.isDirectory() && e.name.startsWith('_')) continue;
11641218
const abs = join(dir, e.name);
1165-
if (e.isDirectory()) collectRouteModules(abs, out);
1219+
if (e.isDirectory()) collectRouteModules(abs, root, out);
11661220
else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs);
1221+
else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs);
11671222
}
11681223
return out;
11691224
}
11701225

11711226
/**
1172-
* ADVISORY (#1095): a page or layout hand-writes a `<link rel="stylesheet"
1227+
* ADVISORY (#1095): a route module hand-writes a `<link rel="stylesheet"
11731228
* href="/public/…">` without `asset()`, so the url is un-versioned and a deploy
11741229
* cannot bust a CDN's copy of it.
11751230
*
@@ -1223,11 +1278,9 @@ async function checkUnmarkedAssetLinks(appDir) {
12231278
// scanned, or the scanner's own case-insensitivity is unreachable exactly
12241279
// where it is needed.
12251280
if (!/<link/i.test(src)) continue;
1226-
// Blank commented-out regions to spaces, LENGTH-PRESERVING (a newline is
1227-
// kept as itself) so the reported line numbers still point at the real
1228-
// source. A commented-out tag emits nothing, so advising on it is advice
1229-
// about dead markup.
1230-
const scan = src.replace(COMMENT_RE, (c) => c.replace(/[^\n]/g, ' '));
1281+
// A commented-out tag emits nothing, so advising on it is advice about
1282+
// dead markup.
1283+
const scan = blankComments(src);
12311284
LINK_TAG_RE.lastIndex = 0;
12321285
for (const m of scan.matchAll(LINK_TAG_RE)) {
12331286
const href = unmarkedStylesheetHref(m[0], basePath);
@@ -1238,7 +1291,7 @@ async function checkUnmarkedAssetLinks(appDir) {
12381291
}
12391292
}
12401293
if (findings.length === 0) {
1241-
return { name, status: 'pass', message: 'every page/layout stylesheet link is content-hashed (or has none)' };
1294+
return { name, status: 'pass', message: 'every route-module stylesheet link is content-hashed (or has none)' };
12421295
}
12431296
const rel = (f) => relative(appDir, f) || f;
12441297
return {

test/cli/doctor.test.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,3 +1129,32 @@ test('asset-link advisory covers the boundary modules, not just page and layout'
11291129
assert.match(r.message, /ge\.css/);
11301130
assert.match(r.message, /nf\.css/);
11311131
});
1132+
1133+
test('asset-link advisory scans global-error only at the app root, as the router does', async () => {
1134+
const nested = tmpDir();
1135+
// router.js registers global-error / global-not-found only when dir === '.',
1136+
// so a nested one is never in the route table and never renders.
1137+
write(nested, 'app/admin/global-error.ts', 'export default () => `<link rel="stylesheet" href="/public/dead.css">`;');
1138+
assert.equal(byName(await runDoctorChecks(nested, baseOpts()), ASSET_LINK_CHECK).status, 'pass');
1139+
1140+
const root = tmpDir();
1141+
write(root, 'app/global-error.ts', 'export default () => `<link rel="stylesheet" href="/public/ge.css">`;');
1142+
assert.equal(byName(await runDoctorChecks(root, baseOpts()), ASSET_LINK_CHECK).status, 'warn');
1143+
});
1144+
1145+
test('asset-link advisory does not treat a // inside an attribute value as a comment', async () => {
1146+
// A protocol-relative CDN sheet beside a local one is the exact layout this
1147+
// check targets. A flat `//` comment matcher blanks the rest of that line and
1148+
// makes the scan silently inert there.
1149+
for (const [label, markup] of [
1150+
['protocol-relative sheet', '<link rel="stylesheet" href="//cdn.example/x.css"><link rel="stylesheet" href="/public/a.css">'],
1151+
['preconnect', '<link rel="preconnect" href="//fonts.example"><link rel="stylesheet" href="/public/a.css">'],
1152+
['// inside a data attribute', '<link data-x="a//b" rel="stylesheet" href="/public/a.css">'],
1153+
]) {
1154+
const dir = tmpDir();
1155+
write(dir, 'app/layout.ts', 'export default () => `' + markup + '`;');
1156+
const r = byName(await runDoctorChecks(dir, baseOpts()), ASSET_LINK_CHECK);
1157+
assert.equal(r.status, 'warn', label);
1158+
assert.match(r.message, /a\.css/, label);
1159+
}
1160+
});

website/app/docs/configuration/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ webjs routes --json # structured JSON (matches the MCP list_routes t
4747
<pre>webjs doctor # human-readable project-health checklist
4848
webjs doctor --json # structured results (each with a stable code) + a summary
4949
webjs doctor --strict # also fail the exit on warnings, not just hard failures</pre>
50-
<p>Verifies project health: the Node version floor, <code>erasableSyntaxOnly</code>, <code>.env</code> drift, vendor-pin freshness, importmap coherence, <code>@webjsdev/*</code> version coherence, framework resolvability, the git hook, a page/layout elision advisory, and a warning when a page or layout writes a <code>&lt;link rel="stylesheet"&gt;</code> without <code>asset()</code> (so its url is un-versioned and a deploy cannot bust a cached copy). Each result carries a stable machine <code>code</code> (for example <code>NODE_VERSION</code>, <code>TSCONFIG_ERASABLE</code>, <code>IMPORTMAP_COHERENCE</code>) so an agent branches on the failure kind, not the message text. The <code>--json</code> payload is an object <code>&#123; results, summary &#125;</code> (the <code>results</code> array holds the per-check objects, each with its <code>code</code>). By default the exit is non-zero only on a hard <em>toolchain</em> failure; <code>--strict</code> also fails on warnings, so it can gate a fully-clean fix loop the way <code>webjs check --json</code> does.</p>
50+
<p>Verifies project health: the Node version floor, <code>erasableSyntaxOnly</code>, <code>.env</code> drift, vendor-pin freshness, importmap coherence, <code>@webjsdev/*</code> version coherence, framework resolvability, the git hook, a page/layout elision advisory, and a warning when a route module writes a <code>&lt;link rel="stylesheet"&gt;</code> without <code>asset()</code> (so its url is un-versioned and a deploy cannot bust a cached copy). Each result carries a stable machine <code>code</code> (for example <code>NODE_VERSION</code>, <code>TSCONFIG_ERASABLE</code>, <code>IMPORTMAP_COHERENCE</code>) so an agent branches on the failure kind, not the message text. The <code>--json</code> payload is an object <code>&#123; results, summary &#125;</code> (the <code>results</code> array holds the per-check objects, each with its <code>code</code>). By default the exit is non-zero only on a hard <em>toolchain</em> failure; <code>--strict</code> also fails on warnings, so it can gate a fully-clean fix loop the way <code>webjs check --json</code> does.</p>
5151
5252
<h3>webjs version</h3>
5353
<pre>webjs version # print the installed @webjsdev/cli version

0 commit comments

Comments
 (0)