@webjsdev/cli 0.10.50 → 0.10.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +3 -1
  2. package/bin/webjs.js +133 -30
  3. package/lib/api-gallery.js +6 -7
  4. package/lib/app-name.js +208 -0
  5. package/lib/create.js +37 -9
  6. package/lib/doctor.js +479 -7
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +25 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +25 -6
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +6 -2
  13. package/templates/.agents/skills/webjs/references/components.md +9 -1
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +40 -7
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +54 -18
  16. package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
  17. package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
  18. package/templates/.agents/skills/webjs/references/runtime.md +5 -1
  19. package/templates/.agents/skills/webjs/references/styling.md +1 -1
  20. package/templates/.agents/skills/webjs/references/testing.md +61 -3
  21. package/templates/.agents/skills/webjs/references/typescript.md +71 -2
  22. package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
  23. package/templates/.github/pull_request_template.md +1 -0
  24. package/templates/.github/workflows/ci.yml +13 -0
  25. package/templates/AGENTS.md +31 -5
  26. package/templates/CONVENTIONS.md +4 -1
  27. package/templates/gallery/app/examples/layout.ts +2 -1
  28. package/templates/gallery/app/examples/todo/page.ts +3 -16
  29. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  30. package/templates/gallery/app/features/auth/signup/page.ts +4 -23
  31. package/templates/gallery/app/features/caching/page.ts +6 -6
  32. package/templates/gallery/app/features/file-storage/page.ts +8 -19
  33. package/templates/gallery/app/features/forms/page.ts +12 -38
  34. package/templates/gallery/app/features/layout.ts +6 -2
  35. package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
  36. package/templates/gallery/app/features/view-transitions/page.ts +1 -1
  37. package/templates/gallery/app/global-error.ts +7 -4
  38. package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
  39. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  40. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  41. package/templates/gallery/modules/gallery/nav.ts +1 -1
  42. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  43. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +31 -0
  44. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  45. package/templates/gallery/modules/todo/types.ts +15 -10
  46. package/templates/gallery/test/auth/auth.test.ts +31 -16
  47. package/templates/partials/agents-playbook-api.md +5 -0
  48. package/templates/partials/agents-playbook-fullstack.md +5 -0
  49. package/templates/scripts/clear-gallery.mjs +5 -4
  50. package/templates/test/hello/e2e/hello.test.ts +18 -1
package/lib/doctor.js CHANGED
@@ -6,7 +6,8 @@
6
6
  * Node 24+ strip-types floor, the `erasableSyntaxOnly` TS flag, importmap pin
7
7
  * freshness, env drift vs `.env.example`, `@webjsdev/*` version coherence,
8
8
  * whether the framework even resolves from the app dir (the fresh-git-worktree
9
- * trap, #954), and the git pre-commit hook activation. `webjs doctor` verifies
9
+ * trap, #954), whether a route-module stylesheet link is content-hashed (#1095),
10
+ * and the git pre-commit hook activation. `webjs doctor` verifies
10
11
  * each one up front and prints pass/warn/fail with an actionable fix line.
11
12
  *
12
13
  * This module is PURE: `runDoctorChecks(appDir, opts?)` reads files (and, for
@@ -30,22 +31,52 @@
30
31
  * missing/non-executable git hook.
31
32
  * - 'pass' is the green path.
32
33
  *
33
- * Every NETWORK touch (only the vendor-pin freshness check) is BEST-EFFORT: a
34
- * fetch failure is a WARN ("could not check, network"), never a hard fail and
35
- * never a throw that crashes the command. Network is flaky, and a doctor that
36
- * fails CI because npm was briefly unreachable is worse than useless.
34
+ * Every NETWORK touch (the vendor-pin freshness check, plus the live resolve in
35
+ * the importmap-coherence check) is BEST-EFFORT: a fetch failure is a WARN
36
+ * ("could not check, network"), never a hard fail and never a throw that
37
+ * crashes the command. Network is flaky, and a doctor that fails CI because npm
38
+ * was briefly unreachable is worse than useless. A result that reports "could
39
+ * not check" rather than a real finding carries `bestEffort: true`, and that
40
+ * flag is what the severity gate below reads to CLAMP it: an app may declare a
41
+ * code fatal, but an outage still cannot red its CI.
42
+ *
43
+ * SEVERITY POLICY (#1257) is CONFIG, not a flag, and lives one layer up. The
44
+ * checks below stay policy-unaware; `readDoctorPolicy(appDir)` reads the app's
45
+ * `webjs.doctor.gate` map out of package.json and `applyDoctorPolicy` folds it
46
+ * over the results, attaching the EFFECTIVE severity each one contributes. So a
47
+ * project declares which health signals it treats as fatal in ONE place that
48
+ * travels with the repo, and its CI workflow, its `npm run doctor`, and an
49
+ * agent's `--json` loop all read that one policy. `--strict` stays what it is:
50
+ * the blunt "every warning is fatal" switch, layered on top.
37
51
  */
38
52
 
39
- import { existsSync, statSync, readdirSync } from 'node:fs';
53
+ import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
40
54
  import { readFile } from 'node:fs/promises';
41
55
  import { join, relative } from 'node:path';
42
56
  import { createRequire } from 'node:module';
43
57
  import { checkNodeInline } from './node-preflight.js';
44
58
 
45
59
  /**
60
+ * `status` is what the CHECK found and never depends on config. `severity` is
61
+ * the EFFECTIVE level the result contributes after the app's gate is applied,
62
+ * attached by `applyDoctorPolicy` (the checks never set it). `bestEffort` marks
63
+ * a result that reports "could not check" rather than a real finding, which is
64
+ * the one thing a gate can never escalate.
46
65
  * @typedef {'pass' | 'warn' | 'fail'} DoctorStatus
47
- * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string }} DoctorResult
66
+ * @typedef {'off' | 'warn' | 'error'} DoctorSeverity a level a gate entry may DECLARE
67
+ * @typedef {'pass' | DoctorSeverity} DoctorLevel the EFFECTIVE level of a result
68
+ * @typedef {{ name: string, code: string, status: DoctorStatus, message: string, fix?: string, bestEffort?: boolean, severity?: DoctorLevel }} DoctorResult
69
+ */
70
+
71
+ /**
72
+ * The severity levels a `webjs.doctor.gate` entry may name, mirroring ESLint's
73
+ * three-level scale (its `off` / `warn` / `error`, which Next.js's
74
+ * `eslint-plugin-next` uses verbatim as a rule-id-keyed map). `off` is uniform:
75
+ * it silences ANY code, the two hard-fail checks included, exactly as ESLint
76
+ * lets any rule be turned off.
77
+ * @type {DoctorSeverity[]}
48
78
  */
79
+ export const DOCTOR_SEVERITIES = ['off', 'warn', 'error'];
49
80
 
50
81
  /**
51
82
  * Stable machine-readable code per check (#975), so an agent consuming
@@ -73,6 +104,7 @@ export const DOCTOR_CODES = {
73
104
  'git-hook': 'GIT_HOOK',
74
105
  'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
75
106
  'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
107
+ 'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
76
108
  };
77
109
 
78
110
  /**
@@ -86,6 +118,124 @@ export function codeForName(name) {
86
118
  return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
87
119
  }
88
120
 
121
+ /**
122
+ * @typedef {{ gate: Record<string, DoctorSeverity>, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy
123
+ */
124
+
125
+ /** A plain JSON object (not null, not an array), the only shape the gate accepts. */
126
+ function isPlainObject(v) {
127
+ return !!v && typeof v === 'object' && !Array.isArray(v);
128
+ }
129
+
130
+ /**
131
+ * Read the app's per-check severity policy out of `package.json`
132
+ * `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and
133
+ * the caller (the CLI) decides what to do about a problem.
134
+ *
135
+ * `gate` keeps only WELL-FORMED entries, so a caller can fold it over the
136
+ * results without re-validating. Everything rejected is reported separately:
137
+ * a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value
138
+ * outside `DOCTOR_SEVERITIES` in `badSeverities`, a wrong SHAPE (a non-object
139
+ * `doctor` or `gate`) in `malformed`, and a misspelled sibling of `gate` such
140
+ * as `gates` in `unknownKeys`. All four are surfaced as a hard error by the
141
+ * CLI rather than skipped.
142
+ *
143
+ * The shape check matters as much as the per-entry one, and is the easier half
144
+ * to leave out. A gate that FAILS OPEN is the one outcome this mechanism cannot
145
+ * afford: `"gate": "error"` or a misspelled `"gates": {...}` would leave CI
146
+ * un-gated while the package.json looks gated, which is strictly worse than
147
+ * having no gate at all, since nobody goes looking. The JSON Schema catches
148
+ * these in an editor, but it is editor-only, so it can never be the enforcement.
149
+ *
150
+ * A missing package.json, a missing block, or unparseable JSON is an EMPTY
151
+ * policy with no problems: an app that declares nothing behaves exactly as it
152
+ * did before the gate existed. Unparseable JSON in particular is deliberately
153
+ * not an error here, since `checkWebjsVersions` already reports that condition
154
+ * and doctor must never crash on a broken app file.
155
+ *
156
+ * @param {string} appDir
157
+ * @returns {DoctorPolicy}
158
+ */
159
+ export function readDoctorPolicy(appDir) {
160
+ /** @type {DoctorPolicy} */
161
+ const empty = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
162
+ let raw;
163
+ try {
164
+ raw = readFileSync(join(appDir, 'package.json'), 'utf8');
165
+ } catch {
166
+ return empty;
167
+ }
168
+ let pkg;
169
+ try {
170
+ pkg = JSON.parse(raw);
171
+ } catch {
172
+ return empty;
173
+ }
174
+ const doctor = pkg?.webjs?.doctor;
175
+ if (doctor === undefined) return empty;
176
+ if (!isPlainObject(doctor)) return { ...empty, malformed: [{ path: 'webjs.doctor', value: doctor }] };
177
+
178
+ /** @type {DoctorPolicy} */
179
+ const policy = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
180
+ // A misspelled sibling (`gates`) would otherwise be dropped in silence, which
181
+ // is the fail-open case. `gate` is the only key this block accepts.
182
+ for (const key of Object.keys(doctor)) {
183
+ if (key !== 'gate') policy.unknownKeys.push(`webjs.doctor.${key}`);
184
+ }
185
+ const declared = doctor.gate;
186
+ if (declared !== undefined && !isPlainObject(declared)) {
187
+ policy.malformed.push({ path: 'webjs.doctor.gate', value: declared });
188
+ }
189
+ if (!isPlainObject(declared)) return policy;
190
+
191
+ const known = new Set(Object.values(DOCTOR_CODES));
192
+ for (const [code, value] of Object.entries(declared)) {
193
+ if (!known.has(code)) {
194
+ policy.unknownCodes.push(code);
195
+ continue;
196
+ }
197
+ if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) {
198
+ policy.badSeverities.push({ code, value });
199
+ continue;
200
+ }
201
+ policy.gate[code] = /** @type {DoctorSeverity} */ (value);
202
+ }
203
+ return policy;
204
+ }
205
+
206
+ /**
207
+ * Fold a severity `gate` over check results, returning a NEW array whose
208
+ * results each carry the EFFECTIVE level they contribute (#1257). PURE: the
209
+ * input array and its results are never mutated.
210
+ *
211
+ * `severity` is the effective level, not the declared one, which is why a
212
+ * PASSING check reports `'pass'` even when its code is gated `error`. A rule
213
+ * that did not fire contributes nothing, the same way ESLint puts severity on a
214
+ * message rather than on a rule that stayed quiet. It also keeps the obvious
215
+ * one-liner honest: `results.some((r) => r.severity === 'error')` is exactly
216
+ * "something fatal was found", with no passing-check false positive.
217
+ *
218
+ * The gate's one hard limit is `bestEffort`: a result that could not check
219
+ * (a toolchain that would not load, a network that was unreachable) is CLAMPED
220
+ * to `warn` however loudly the gate declares its code. That is what lets this
221
+ * repo's required CI job run a check whose live resolve touches jspm without
222
+ * an outage there ever redding an unrelated pull request.
223
+ *
224
+ * @param {DoctorResult[]} results
225
+ * @param {Record<string, DoctorSeverity>} [gate] well-formed entries only (see readDoctorPolicy)
226
+ * @returns {DoctorResult[]}
227
+ */
228
+ export function applyDoctorPolicy(results, gate = {}) {
229
+ return results.map((r) => {
230
+ if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') };
231
+ const declared = gate[r.code];
232
+ const fallback = r.status === 'fail' ? 'error' : 'warn';
233
+ let severity = /** @type {DoctorSeverity} */ (declared || fallback);
234
+ if (r.bestEffort && severity === 'error') severity = 'warn';
235
+ return { ...r, severity };
236
+ });
237
+ }
238
+
89
239
  /**
90
240
  * Read the CLI package's own `engines.node` so the required Node major lives in
91
241
  * one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
@@ -321,6 +471,8 @@ async function checkVendorPin(appDir, opts) {
321
471
  return {
322
472
  name: 'vendor-pin',
323
473
  status: 'warn',
474
+ // "Could not check", not a finding: never escalatable by a gate.
475
+ bestEffort: true,
324
476
  message: 'Could not load the vendor toolchain to check pin freshness.',
325
477
  fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
326
478
  };
@@ -348,6 +500,7 @@ async function checkVendorPin(appDir, opts) {
348
500
  return {
349
501
  name: 'vendor-pin',
350
502
  status: 'warn',
503
+ bestEffort: true,
351
504
  message: 'Could not check pin freshness (network unreachable or registry error).',
352
505
  fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.',
353
506
  };
@@ -577,6 +730,7 @@ async function checkImportmapCoherence(appDir, opts) {
577
730
  return {
578
731
  name: 'importmap-coherence',
579
732
  status: 'warn',
733
+ bestEffort: true,
580
734
  message: 'Could not load the vendor toolchain to check importmap coherence.',
581
735
  fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
582
736
  };
@@ -670,6 +824,7 @@ async function checkImportmapCoherence(appDir, opts) {
670
824
  return {
671
825
  name: 'importmap-coherence',
672
826
  status: 'warn',
827
+ bestEffort: true,
673
828
  message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
674
829
  fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
675
830
  };
@@ -967,6 +1122,322 @@ async function checkStaticAssetFreshness(appDir) {
967
1122
  };
968
1123
  }
969
1124
 
1125
+ // Directories the route-module walk never descends into (deps, VCS, framework
1126
+ // and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can
1127
+ // change its exclusions without silently moving the other.
1128
+ const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
1129
+
1130
+ /**
1131
+ * A route module that renders markup on the server, which is where `asset()`
1132
+ * belongs. Page and layout are the common case, but the BOUNDARY modules matter
1133
+ * too and are easy to miss: `error` / `not-found` / `forbidden` / `unauthorized`
1134
+ * / `loading` are always shipped and never elided, and `global-error` renders
1135
+ * its OWN `<!doctype><html><head>` and is returned verbatim with no framework
1136
+ * head splice, which makes it the likeliest place outside the root layout for
1137
+ * an author to hand-write a stylesheet link.
1138
+ * @type {RegExp}
1139
+ */
1140
+ const ROUTE_MODULE_RE =
1141
+ /^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/;
1142
+
1143
+ /**
1144
+ * The two boundary stems `router.js` registers ONLY at the app root (both are
1145
+ * guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never
1146
+ * in the route table and never renders, so scanning one would advise on dead
1147
+ * code, the same defect the `_private` skip exists to avoid.
1148
+ * @type {RegExp}
1149
+ */
1150
+ const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/;
1151
+
1152
+ /**
1153
+ * One whole `<link …>` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the
1154
+ * same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute
1155
+ * value cannot terminate the tag early.
1156
+ * @type {RegExp}
1157
+ */
1158
+ const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
1159
+
1160
+ /**
1161
+ * One attribute inside a tag: a name, then optionally `=` and a double-quoted,
1162
+ * single-quoted, or unquoted value. Matching attributes as WHOLE units is what
1163
+ * makes the scan correct, because each quoted value is consumed in one step and
1164
+ * can therefore never be re-scanned as if it contained an attribute of its own.
1165
+ * @type {RegExp}
1166
+ */
1167
+ const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
1168
+
1169
+ /**
1170
+ * Parse a tag's attributes into a lowercased-name map. The value is `null` for a
1171
+ * valueless attribute and carries a `quoted` flag, since this check treats an
1172
+ * UNQUOTED href (a template hole) as undecidable rather than as a path.
1173
+ * @param {string} tag
1174
+ * @returns {Map<string, { value: string | null, quoted: boolean }>}
1175
+ */
1176
+ function parseTagAttrs(tag) {
1177
+ /** @type {Map<string, { value: string | null, quoted: boolean }>} */
1178
+ const attrs = new Map();
1179
+ // Skip the tag name itself so `link` is not read as an attribute.
1180
+ const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, '');
1181
+ ATTR_RE.lastIndex = 0;
1182
+ for (const m of body.matchAll(ATTR_RE)) {
1183
+ const name = m[1].toLowerCase();
1184
+ if (attrs.has(name)) continue; // first wins, as in HTML parsing
1185
+ const quoted = m[2] !== undefined || m[3] !== undefined;
1186
+ const value = m[2] ?? m[3] ?? m[4] ?? null;
1187
+ attrs.set(name, { value, quoted });
1188
+ }
1189
+ return attrs;
1190
+ }
1191
+
1192
+ /**
1193
+ * Whether a parsed `<link>` is an unmarked stylesheet, and if so its href.
1194
+ *
1195
+ * Attribute PARSING rather than a lookahead over the raw tag is load-bearing,
1196
+ * not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in
1197
+ * the tag matches the string inside ANOTHER attribute's value, which flags the
1198
+ * two shapes this check most needs to leave alone: the canonical async-CSS
1199
+ * `<link rel="preload" as="style" href="/public/app.css" onload="this.rel='stylesheet'">`
1200
+ * (where the advised `asset()` fix would actively BREAK the preload, since the
1201
+ * versioned hint could then never match the unversioned request), and a
1202
+ * `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes
1203
+ * makes `rel` mean the `rel` attribute and nothing else.
1204
+ *
1205
+ * Returns the href only when every condition holds:
1206
+ * - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an
1207
+ * onload swap, and `rel="icon"`, are both out).
1208
+ * - `href` is QUOTED. An unquoted value is a template hole
1209
+ * (`href=${asset('/public/app.css')}`), undecidable from source, and is
1210
+ * exactly the shape the marked form uses.
1211
+ * - the path is under `/public/` (after the app's `webjs.basePath` is
1212
+ * stripped, since under a sub-path deploy the author writes the prefix
1213
+ * themselves and `resolveAssetUrl` strips it before its own `public/` gate).
1214
+ * - `resolveAssetUrl` would actually fingerprint it. It returns a path
1215
+ * carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a
1216
+ * runtime NO-OP: the author does the work and the url they ship is
1217
+ * byte-identical. Advising it would be advising a change that buys nothing.
1218
+ * A hand-rolled `?v=` cache-buster is exactly what an author who has not
1219
+ * adopted `asset()` is most likely to have written, so this is the common
1220
+ * case, not a corner. (The warning itself would clear, since this check
1221
+ * reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a
1222
+ * warning without improving the caching is the outcome to avoid.)
1223
+ *
1224
+ * @param {string} tag
1225
+ * @param {string} basePath the app's normalized `webjs.basePath` (`''` at root)
1226
+ * @returns {string | null}
1227
+ */
1228
+ function unmarkedStylesheetHref(tag, basePath = '') {
1229
+ const attrs = parseTagAttrs(tag);
1230
+ const rel = attrs.get('rel');
1231
+ if (!rel || !rel.value) return null;
1232
+ if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null;
1233
+ const href = attrs.get('href');
1234
+ if (!href || !href.quoted || !href.value) return null;
1235
+ const url = href.value;
1236
+ if (url[0] !== '/' || url[1] === '/') return null;
1237
+ // Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is
1238
+ // one `asset()` can actually fingerprint. It strips the base path, cuts at
1239
+ // `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix.
1240
+ // Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x`
1241
+ // would be flagged although wrapping it changes nothing, and
1242
+ // `/%70ublic/app.css` would be skipped although `asset()` fingerprints it.
1243
+ let probe = url;
1244
+ if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length);
1245
+ const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
1246
+ let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
1247
+ try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
1248
+ if (decoded.includes('..') || !decoded.startsWith('/public/')) return null;
1249
+ // A query is refused outright (an author query may carry meaning we do not
1250
+ // own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not,
1251
+ // since it is split off and preserved.
1252
+ const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#'));
1253
+ if (beforeFragment.includes('?')) return null;
1254
+ return url;
1255
+ }
1256
+
1257
+ /**
1258
+ * The app's `webjs.basePath`, normalized to `''` (root mount) or `/segment…`.
1259
+ *
1260
+ * A faithful port of `normalizeBasePath` (`packages/server/src/base-path.js`),
1261
+ * which is the source of truth: it trims, PREPENDS the leading slash (so the
1262
+ * documented `"myapp"`, `"/myapp"` and `"/myapp/"` all normalize alike), and
1263
+ * fails safe to `''` on a value that is not a plain same-origin prefix. Reading
1264
+ * only `startsWith('/')` would leave this check inert for an app configured
1265
+ * `"myapp"`, which is exactly the silently-inert case it exists to close.
1266
+ *
1267
+ * Ported rather than imported because that helper is not on `@webjsdev/server`'s
1268
+ * public surface, and because doctor must stay usable when the framework does
1269
+ * not resolve from the app dir at all (the #954 fresh-worktree case this same
1270
+ * command exists to diagnose). `test/cli/doctor.test.mjs` pins the forms.
1271
+ * @param {string} appDir
1272
+ * @returns {Promise<string>}
1273
+ */
1274
+ async function readAppBasePath(appDir) {
1275
+ let raw;
1276
+ try {
1277
+ const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
1278
+ raw = pkg?.webjs?.basePath;
1279
+ } catch {
1280
+ return '';
1281
+ }
1282
+ if (typeof raw !== 'string') return '';
1283
+ let v = raw.trim();
1284
+ if (v === '' || v === '/') return '';
1285
+ // Not a plain same-origin path prefix: fail safe to no base path.
1286
+ if (v.includes('..') || v.includes('://') || v.includes('\\') || /\s/.test(v)) return '';
1287
+ // A network-path reference (`//host`) is rejected BEFORE leading slashes are
1288
+ // collapsed, since collapsing would turn an origin escape into `/host`.
1289
+ if (v.startsWith('//')) return '';
1290
+ v = ('/' + v.replace(/^\/+/, '')).replace(/\/+$/, '');
1291
+ return v === '' || v === '/' ? '' : v;
1292
+ }
1293
+
1294
+ /**
1295
+ * Whether the `<link>` tag at `idx` is commented out, so dead markup is never
1296
+ * reported as a live finding.
1297
+ *
1298
+ * A DELIMITED comment is decided by an unclosed opener behind the tag. Neither
1299
+ * `<!--` nor `/*` nests, so "nearest opener beats nearest closer" is exact, and
1300
+ * it covers a multi-line block whose interior lines carry no marker of their
1301
+ * own (what an editor's toggle-block-comment writes). A `//` has no closer, so
1302
+ * it is decided from the tag's own line: a `//` inside an href later in the
1303
+ * line cannot match, because the line does not START with it.
1304
+ *
1305
+ * Do NOT replace this with a lexer. Two attempts did, and both shipped bugs a
1306
+ * stateless test cannot have: a line-blanking regex killed any line holding a
1307
+ * protocol-relative url, and a quote-tracking walk inverted string/code
1308
+ * polarity on a nested ``html`...` `` inside a `${}` hole (one quote char
1309
+ * cannot model nesting), so an unbalanced apostrophe in template text
1310
+ * desynchronized the rest of the file. This check does not need to lex
1311
+ * JavaScript. If it ever genuinely does, export `redactStringsAndTemplates`
1312
+ * from `@webjsdev/server` (`src/js-scan.js`, fuzz-tested differentially against
1313
+ * a real TypeScript parse) rather than growing a third one here.
1314
+ *
1315
+ * Residual gap: a tag behind a `//` that trails real code on the same line
1316
+ * stays reported. Rare, and it fails toward reporting rather than toward the
1317
+ * silent inertness both lexers produced.
1318
+ *
1319
+ * @param {string} src
1320
+ * @param {number} idx index of the tag's `<`
1321
+ * @returns {boolean}
1322
+ */
1323
+ function isCommentedOut(src, idx) {
1324
+ const before = src.slice(0, idx);
1325
+ if (before.lastIndexOf('<!--') > before.lastIndexOf('-->')) return true;
1326
+ if (before.lastIndexOf('/*') > before.lastIndexOf('*/')) return true;
1327
+ const lineStart = before.lastIndexOf('\n') + 1;
1328
+ return before.slice(lineStart).trimStart().startsWith('//');
1329
+ }
1330
+
1331
+ /**
1332
+ * Collect every `app/**` route module that renders markup, depth-first.
1333
+ * Best-effort: an unreadable directory contributes nothing rather than throwing.
1334
+ * @param {string} dir
1335
+ * @param {string[]} [out]
1336
+ * @returns {string[]}
1337
+ */
1338
+ function collectRouteModules(dir, root = dir, out = []) {
1339
+ let entries;
1340
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
1341
+ for (const e of entries) {
1342
+ if (e.name.startsWith('.') || ROUTE_WALK_IGNORE.has(e.name)) continue;
1343
+ if (e.isSymbolicLink()) continue; // never follow: can cycle or escape into deps
1344
+ // `_`-prefixed folders are PRIVATE: `router.js` drops any route whose
1345
+ // directory has such a segment, so markup under one is never routed and
1346
+ // never rendered. Advising on it would be advice about dead code.
1347
+ if (e.isDirectory() && e.name.startsWith('_')) continue;
1348
+ const abs = join(dir, e.name);
1349
+ if (e.isDirectory()) collectRouteModules(abs, root, out);
1350
+ else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs);
1351
+ else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs);
1352
+ }
1353
+ return out;
1354
+ }
1355
+
1356
+ /**
1357
+ * ADVISORY (#1095): a route module hand-writes a `<link rel="stylesheet"
1358
+ * href="/public/…">` without `asset()`, so the url is un-versioned and a deploy
1359
+ * cannot bust a CDN's copy of it.
1360
+ *
1361
+ * The failure this names was caught in production on webjs.dev: the edge served
1362
+ * a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`,
1363
+ * `max-age=14400`) against post-deploy HTML, so the new page rendered with its
1364
+ * content edge to edge and its grid collapsed, because the cached css was
1365
+ * missing the arbitrary-value utilities that page introduced. It is invisible
1366
+ * while a deploy only restyles existing classes and maximally visible the moment
1367
+ * one adds a page using new utilities.
1368
+ *
1369
+ * Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the
1370
+ * framework's OUTPUT. The first attempt at the automatic form (#1196) matched
1371
+ * urls in the assembled HTML, and two deep-review rounds found six major
1372
+ * defects, five of them one bug: at that layer framework output and author data
1373
+ * are indistinguishable, so the matcher kept editing things it did not own. That
1374
+ * is why `asset()` (#1194) is opt-in, and it is what Rails (a
1375
+ * `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url
1376
+ * from the build graph, surfaced through `links()`) both do: take the
1377
+ * fingerprint from an authoritative source at the point the url is PRODUCED, and
1378
+ * never rewrite a rendered document. The gap `asset()` leaves is purely
1379
+ * ergonomic. In Rails the helper is the only idiomatic way to write the tag, so
1380
+ * forgetting it is nearly impossible; in WebJs the `<link>` is hand-written HTML,
1381
+ * so it is easy to omit. This check closes exactly that gap, at authoring time,
1382
+ * where the author's meaning is unambiguous and nothing is rewritten.
1383
+ *
1384
+ * Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate
1385
+ * NON-mark (the website leaves its favicons bare so the SEO repo-health tests
1386
+ * can parse the hrefs literally), and a `rel="preload"` must NOT be marked at
1387
+ * all, since its versioned hint could never match the unversioned request a CSS
1388
+ * `url()` actually makes. Flagging either would nag about a correct choice.
1389
+ *
1390
+ * WARN only: an un-versioned stylesheet still SERVES correctly, it just caches
1391
+ * badly, and an app fronted by no CDN may not care.
1392
+ * @param {string} appDir
1393
+ * @returns {Promise<DoctorResult>}
1394
+ */
1395
+ async function checkUnmarkedAssetLinks(appDir) {
1396
+ const name = 'Asset urls (unmarked stylesheet links)';
1397
+ const routeDir = join(appDir, 'app');
1398
+ if (!existsSync(routeDir)) {
1399
+ return { name, status: 'pass', message: 'no app/ directory to analyse' };
1400
+ }
1401
+ const basePath = await readAppBasePath(appDir);
1402
+ const findings = [];
1403
+ for (const file of collectRouteModules(routeDir)) {
1404
+ let src;
1405
+ try { src = await readFile(file, 'utf8'); } catch { continue; }
1406
+ // Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag
1407
+ // regex: a file whose only link tag is written `<LINK …>` must still be
1408
+ // scanned, or the scanner's own case-insensitivity is unreachable exactly
1409
+ // where it is needed.
1410
+ if (!/<link/i.test(src)) continue;
1411
+ LINK_TAG_RE.lastIndex = 0;
1412
+ for (const m of src.matchAll(LINK_TAG_RE)) {
1413
+ const href = unmarkedStylesheetHref(m[0], basePath);
1414
+ if (!href) continue;
1415
+ // A commented-out tag emits nothing, so advising on it is advice about
1416
+ // dead markup.
1417
+ if (isCommentedOut(src, /** @type {number} */ (m.index))) continue;
1418
+ // 1-indexed line of the match, for a jump-to reference.
1419
+ const line = src.slice(0, m.index).split('\n').length;
1420
+ findings.push({ file, line, href });
1421
+ }
1422
+ }
1423
+ if (findings.length === 0) {
1424
+ return { name, status: 'pass', message: 'every route-module stylesheet link is content-hashed (or has none)' };
1425
+ }
1426
+ const rel = (f) => relative(appDir, f) || f;
1427
+ return {
1428
+ name,
1429
+ status: 'warn',
1430
+ message:
1431
+ `${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` +
1432
+ findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'),
1433
+ fix:
1434
+ "Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then "
1435
+ + '`<link rel="stylesheet" href=${asset(\'/public/app.css\')}>`. It appends a content hash in prod '
1436
+ + '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. '
1437
+ + 'Call it inside the render function, not at module scope.',
1438
+ };
1439
+ }
1440
+
970
1441
  /**
971
1442
  * Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is
972
1443
  * directory-relative, so this must probe FROM the app (not the CLI's own
@@ -1058,6 +1529,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
1058
1529
  Promise.resolve(checkGitHook(appDir)),
1059
1530
  checkElisionCarriers(appDir),
1060
1531
  checkStaticAssetFreshness(appDir),
1532
+ checkUnmarkedAssetLinks(appDir),
1061
1533
  ]);
1062
1534
  // Attach the stable machine code to every result (#975). Centralized here so
1063
1535
  // each check function stays free of the code-contract concern.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.50",
3
+ "version": "0.10.51",
4
4
  "type": "module",
5
- "description": "webjs CLI - dev, start, create, db",
5
+ "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
7
7
  "webjs": "bin/webjs.js"
8
8
  },
@@ -50,7 +50,15 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
50
50
  2. Browser tests in `test/<feature>/browser/*.test.js` for hydration, DOM, slots,
51
51
  and the client router.
52
52
  3. Documentation stays in sync on the SAME PR as the code, never a follow-up.
53
- 4. `npm run check` must pass.
53
+ 4. `npm run check` must pass (correctness), and so must `npm run doctor`
54
+ (project health). CI runs both. Doctor fails on whatever your `package.json`
55
+ `webjs.doctor.gate` marks `error`, which starts as the un-versioned
56
+ stylesheet link check, plus the two hard toolchain checks that default to
57
+ `error` with no gate entry at all: `NODE_VERSION` (the Node floor) and
58
+ `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an existing
59
+ tsconfig), either of which would 500 the app at runtime. Everything else it
60
+ reports is a warning that cannot fail the build. Widen or narrow the gate in
61
+ `package.json` rather than in the workflow.
54
62
  5. Pre-merge self-review: before saying a PR is ready, run fresh-context review
55
63
  rounds until one round finds zero issues (minimum two rounds, rotate focus).
56
64
  Skip only for a one-line trivial change.