@webjsdev/cli 0.10.50 → 0.10.52
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.
- package/README.md +3 -1
- package/bin/webjs.js +437 -32
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +566 -21
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +26 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
- package/templates/.agents/skills/webjs/references/components.md +59 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +80 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- 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),
|
|
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,23 +31,53 @@
|
|
|
30
31
|
* missing/non-executable git hook.
|
|
31
32
|
* - 'pass' is the green path.
|
|
32
33
|
*
|
|
33
|
-
* Every NETWORK touch (
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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 {
|
|
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
|
|
48
69
|
*/
|
|
49
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[]}
|
|
78
|
+
*/
|
|
79
|
+
export const DOCTOR_SEVERITIES = ['off', 'warn', 'error'];
|
|
80
|
+
|
|
50
81
|
/**
|
|
51
82
|
* Stable machine-readable code per check (#975), so an agent consuming
|
|
52
83
|
* `webjs doctor --json` branches on the failure KIND, not the human message
|
|
@@ -72,7 +103,9 @@ export const DOCTOR_CODES = {
|
|
|
72
103
|
'importmap-coherence': 'IMPORTMAP_COHERENCE',
|
|
73
104
|
'git-hook': 'GIT_HOOK',
|
|
74
105
|
'Page/layout elision (carrier hygiene)': 'ELISION_CARRIERS',
|
|
106
|
+
'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
|
|
75
107
|
'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
|
|
108
|
+
'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
|
|
76
109
|
};
|
|
77
110
|
|
|
78
111
|
/**
|
|
@@ -86,6 +119,124 @@ export function codeForName(name) {
|
|
|
86
119
|
return DOCTOR_CODES[name] || name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
87
120
|
}
|
|
88
121
|
|
|
122
|
+
/**
|
|
123
|
+
* @typedef {{ gate: Record<string, DoctorSeverity>, unknownCodes: string[], badSeverities: Array<{ code: string, value: unknown }>, malformed: Array<{ path: string, value: unknown }>, unknownKeys: string[] }} DoctorPolicy
|
|
124
|
+
*/
|
|
125
|
+
|
|
126
|
+
/** A plain JSON object (not null, not an array), the only shape the gate accepts. */
|
|
127
|
+
function isPlainObject(v) {
|
|
128
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read the app's per-check severity policy out of `package.json`
|
|
133
|
+
* `webjs.doctor.gate` (#1257). PURE: it reads one file and returns data, and
|
|
134
|
+
* the caller (the CLI) decides what to do about a problem.
|
|
135
|
+
*
|
|
136
|
+
* `gate` keeps only WELL-FORMED entries, so a caller can fold it over the
|
|
137
|
+
* results without re-validating. Everything rejected is reported separately:
|
|
138
|
+
* a key that is not a value of `DOCTOR_CODES` lands in `unknownCodes`, a value
|
|
139
|
+
* outside `DOCTOR_SEVERITIES` in `badSeverities`, a wrong SHAPE (a non-object
|
|
140
|
+
* `doctor` or `gate`) in `malformed`, and a misspelled sibling of `gate` such
|
|
141
|
+
* as `gates` in `unknownKeys`. All four are surfaced as a hard error by the
|
|
142
|
+
* CLI rather than skipped.
|
|
143
|
+
*
|
|
144
|
+
* The shape check matters as much as the per-entry one, and is the easier half
|
|
145
|
+
* to leave out. A gate that FAILS OPEN is the one outcome this mechanism cannot
|
|
146
|
+
* afford: `"gate": "error"` or a misspelled `"gates": {...}` would leave CI
|
|
147
|
+
* un-gated while the package.json looks gated, which is strictly worse than
|
|
148
|
+
* having no gate at all, since nobody goes looking. The JSON Schema catches
|
|
149
|
+
* these in an editor, but it is editor-only, so it can never be the enforcement.
|
|
150
|
+
*
|
|
151
|
+
* A missing package.json, a missing block, or unparseable JSON is an EMPTY
|
|
152
|
+
* policy with no problems: an app that declares nothing behaves exactly as it
|
|
153
|
+
* did before the gate existed. Unparseable JSON in particular is deliberately
|
|
154
|
+
* not an error here, since `checkWebjsVersions` already reports that condition
|
|
155
|
+
* and doctor must never crash on a broken app file.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} appDir
|
|
158
|
+
* @returns {DoctorPolicy}
|
|
159
|
+
*/
|
|
160
|
+
export function readDoctorPolicy(appDir) {
|
|
161
|
+
/** @type {DoctorPolicy} */
|
|
162
|
+
const empty = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
|
|
163
|
+
let raw;
|
|
164
|
+
try {
|
|
165
|
+
raw = readFileSync(join(appDir, 'package.json'), 'utf8');
|
|
166
|
+
} catch {
|
|
167
|
+
return empty;
|
|
168
|
+
}
|
|
169
|
+
let pkg;
|
|
170
|
+
try {
|
|
171
|
+
pkg = JSON.parse(raw);
|
|
172
|
+
} catch {
|
|
173
|
+
return empty;
|
|
174
|
+
}
|
|
175
|
+
const doctor = pkg?.webjs?.doctor;
|
|
176
|
+
if (doctor === undefined) return empty;
|
|
177
|
+
if (!isPlainObject(doctor)) return { ...empty, malformed: [{ path: 'webjs.doctor', value: doctor }] };
|
|
178
|
+
|
|
179
|
+
/** @type {DoctorPolicy} */
|
|
180
|
+
const policy = { gate: {}, unknownCodes: [], badSeverities: [], malformed: [], unknownKeys: [] };
|
|
181
|
+
// A misspelled sibling (`gates`) would otherwise be dropped in silence, which
|
|
182
|
+
// is the fail-open case. `gate` is the only key this block accepts.
|
|
183
|
+
for (const key of Object.keys(doctor)) {
|
|
184
|
+
if (key !== 'gate') policy.unknownKeys.push(`webjs.doctor.${key}`);
|
|
185
|
+
}
|
|
186
|
+
const declared = doctor.gate;
|
|
187
|
+
if (declared !== undefined && !isPlainObject(declared)) {
|
|
188
|
+
policy.malformed.push({ path: 'webjs.doctor.gate', value: declared });
|
|
189
|
+
}
|
|
190
|
+
if (!isPlainObject(declared)) return policy;
|
|
191
|
+
|
|
192
|
+
const known = new Set(Object.values(DOCTOR_CODES));
|
|
193
|
+
for (const [code, value] of Object.entries(declared)) {
|
|
194
|
+
if (!known.has(code)) {
|
|
195
|
+
policy.unknownCodes.push(code);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (typeof value !== 'string' || !DOCTOR_SEVERITIES.includes(/** @type {DoctorSeverity} */ (value))) {
|
|
199
|
+
policy.badSeverities.push({ code, value });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
policy.gate[code] = /** @type {DoctorSeverity} */ (value);
|
|
203
|
+
}
|
|
204
|
+
return policy;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fold a severity `gate` over check results, returning a NEW array whose
|
|
209
|
+
* results each carry the EFFECTIVE level they contribute (#1257). PURE: the
|
|
210
|
+
* input array and its results are never mutated.
|
|
211
|
+
*
|
|
212
|
+
* `severity` is the effective level, not the declared one, which is why a
|
|
213
|
+
* PASSING check reports `'pass'` even when its code is gated `error`. A rule
|
|
214
|
+
* that did not fire contributes nothing, the same way ESLint puts severity on a
|
|
215
|
+
* message rather than on a rule that stayed quiet. It also keeps the obvious
|
|
216
|
+
* one-liner honest: `results.some((r) => r.severity === 'error')` is exactly
|
|
217
|
+
* "something fatal was found", with no passing-check false positive.
|
|
218
|
+
*
|
|
219
|
+
* The gate's one hard limit is `bestEffort`: a result that could not check
|
|
220
|
+
* (a toolchain that would not load, a network that was unreachable) is CLAMPED
|
|
221
|
+
* to `warn` however loudly the gate declares its code. That is what lets this
|
|
222
|
+
* repo's required CI job run a check whose live resolve touches jspm without
|
|
223
|
+
* an outage there ever redding an unrelated pull request.
|
|
224
|
+
*
|
|
225
|
+
* @param {DoctorResult[]} results
|
|
226
|
+
* @param {Record<string, DoctorSeverity>} [gate] well-formed entries only (see readDoctorPolicy)
|
|
227
|
+
* @returns {DoctorResult[]}
|
|
228
|
+
*/
|
|
229
|
+
export function applyDoctorPolicy(results, gate = {}) {
|
|
230
|
+
return results.map((r) => {
|
|
231
|
+
if (r.status === 'pass') return { ...r, severity: /** @type {DoctorLevel} */ ('pass') };
|
|
232
|
+
const declared = gate[r.code];
|
|
233
|
+
const fallback = r.status === 'fail' ? 'error' : 'warn';
|
|
234
|
+
let severity = /** @type {DoctorSeverity} */ (declared || fallback);
|
|
235
|
+
if (r.bestEffort && severity === 'error') severity = 'warn';
|
|
236
|
+
return { ...r, severity };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
89
240
|
/**
|
|
90
241
|
* Read the CLI package's own `engines.node` so the required Node major lives in
|
|
91
242
|
* one place (mirrors how `bin/webjs.js` sources it). Falls back to `>=24.0.0`.
|
|
@@ -321,6 +472,8 @@ async function checkVendorPin(appDir, opts) {
|
|
|
321
472
|
return {
|
|
322
473
|
name: 'vendor-pin',
|
|
323
474
|
status: 'warn',
|
|
475
|
+
// "Could not check", not a finding: never escalatable by a gate.
|
|
476
|
+
bestEffort: true,
|
|
324
477
|
message: 'Could not load the vendor toolchain to check pin freshness.',
|
|
325
478
|
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
326
479
|
};
|
|
@@ -348,6 +501,7 @@ async function checkVendorPin(appDir, opts) {
|
|
|
348
501
|
return {
|
|
349
502
|
name: 'vendor-pin',
|
|
350
503
|
status: 'warn',
|
|
504
|
+
bestEffort: true,
|
|
351
505
|
message: 'Could not check pin freshness (network unreachable or registry error).',
|
|
352
506
|
fix: 'Re-run `webjs doctor` when connectivity is back, or run `webjs vendor outdated`.',
|
|
353
507
|
};
|
|
@@ -577,6 +731,7 @@ async function checkImportmapCoherence(appDir, opts) {
|
|
|
577
731
|
return {
|
|
578
732
|
name: 'importmap-coherence',
|
|
579
733
|
status: 'warn',
|
|
734
|
+
bestEffort: true,
|
|
580
735
|
message: 'Could not load the vendor toolchain to check importmap coherence.',
|
|
581
736
|
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
582
737
|
};
|
|
@@ -670,6 +825,7 @@ async function checkImportmapCoherence(appDir, opts) {
|
|
|
670
825
|
return {
|
|
671
826
|
name: 'importmap-coherence',
|
|
672
827
|
status: 'warn',
|
|
828
|
+
bestEffort: true,
|
|
673
829
|
message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
|
|
674
830
|
fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
|
|
675
831
|
};
|
|
@@ -847,43 +1003,104 @@ function checkGitHook(appDir) {
|
|
|
847
1003
|
* named line. WARN only: a page legitimately MAY ship, and the analyser is
|
|
848
1004
|
* biased toward shipping by design (server AGENTS invariant 7), so this is a
|
|
849
1005
|
* "you may not have intended this" hint, never a hard fail.
|
|
850
|
-
* @param {
|
|
1006
|
+
* @param {Promise<any|null>} elisionPromise the ONE shared report (#1308)
|
|
851
1007
|
* @returns {Promise<DoctorResult>}
|
|
852
1008
|
*/
|
|
853
|
-
async function checkElisionCarriers(
|
|
1009
|
+
async function checkElisionCarriers(elisionPromise) {
|
|
854
1010
|
const name = 'Page/layout elision (carrier hygiene)';
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
858
|
-
report = await analyzeAppElision(appDir);
|
|
859
|
-
} catch {
|
|
1011
|
+
const report = await elisionPromise;
|
|
1012
|
+
if (!report) {
|
|
860
1013
|
// Analysis unavailable (no app, malformed, server import failed): no advice.
|
|
861
1014
|
return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
|
|
862
1015
|
}
|
|
863
1016
|
if (!report.analysed) {
|
|
864
1017
|
return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
|
|
865
1018
|
}
|
|
866
|
-
|
|
1019
|
+
// Paths and reasons arrive app-relative from `analyzeAppElision` (#1308).
|
|
1020
|
+
const shipped = report.routeModules.filter((r) => r.verdict === 'shipped');
|
|
1021
|
+
if (shipped.length === 0) {
|
|
867
1022
|
return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
|
|
868
1023
|
}
|
|
869
|
-
const rel = (f) => relative(appDir, f) || f;
|
|
870
1024
|
// Name the FIRST client-effecting blocker (there may be more than one; the
|
|
871
1025
|
// module stays shipped until every such blocker is moved out).
|
|
872
|
-
const lines =
|
|
1026
|
+
const lines = shipped.map(({ file, blocker, reason }) =>
|
|
873
1027
|
blocker
|
|
874
|
-
? `${
|
|
875
|
-
: `${
|
|
1028
|
+
? `${file} ships whole. Its first client-effecting blocker is ${blocker}, which ${reason} and is not a component`
|
|
1029
|
+
: `${file} ships whole because it ${reason}`,
|
|
876
1030
|
);
|
|
877
1031
|
return {
|
|
878
1032
|
name,
|
|
879
1033
|
status: 'warn',
|
|
880
1034
|
message:
|
|
881
|
-
`${
|
|
1035
|
+
`${shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
|
|
882
1036
|
lines.map((l) => ` ${l}`).join('\n'),
|
|
883
1037
|
fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See references/components.md in the skill.',
|
|
884
1038
|
};
|
|
885
1039
|
}
|
|
886
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* The OTHER direction of the elision verdict (#1308): which COMPONENT modules
|
|
1043
|
+
* the browser never downloads. `checkElisionCarriers` above reports the benign
|
|
1044
|
+
* over-ship direction; this one reports what was DROPPED, which is where a
|
|
1045
|
+
* wrong verdict silently costs an app its interactivity.
|
|
1046
|
+
*
|
|
1047
|
+
* Pass-only except for orphans, deliberately. An elided component is the
|
|
1048
|
+
* DESIRED outcome, so warning on one would fire on every healthy app and train
|
|
1049
|
+
* the reader to skip doctor output. The passing message carries the elided
|
|
1050
|
+
* inventory instead, which makes it the discovery surface, while `webjs
|
|
1051
|
+
* elision` is the detail surface. The one always-wrong condition is an ORPHAN:
|
|
1052
|
+
* a `class X extends WebComponent` with no literal-tag registration is
|
|
1053
|
+
* invisible to the scanner, so it gets no verdict at all and `static
|
|
1054
|
+
* interactive = true` cannot rescue it (nothing consults the component
|
|
1055
|
+
* analyser for a component the scanner never saw). Never `fail`:
|
|
1056
|
+
* an app that wants an orphan to break CI gates `ELISION_COMPONENTS` to
|
|
1057
|
+
* `error` via `webjs.doctor.gate`.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {Promise<any|null>} elisionPromise the ONE shared report
|
|
1060
|
+
* @returns {Promise<DoctorResult>}
|
|
1061
|
+
*/
|
|
1062
|
+
async function checkElisionComponents(elisionPromise) {
|
|
1063
|
+
const name = 'Component elision (what the browser drops)';
|
|
1064
|
+
const report = await elisionPromise;
|
|
1065
|
+
const notAnalysed = { name, status: /** @type {const} */ ('pass'), message: 'not analysed (no routable app or analysis unavailable)' };
|
|
1066
|
+
if (!report) return notAnalysed;
|
|
1067
|
+
if (!report.analysed) {
|
|
1068
|
+
return report.skipped === 'elide-off'
|
|
1069
|
+
? { name, status: 'pass', message: 'elision is disabled (webjs.elide false or WEBJS_ELIDE), so every component module ships' }
|
|
1070
|
+
: notAnalysed;
|
|
1071
|
+
}
|
|
1072
|
+
if (report.orphans.length > 0) {
|
|
1073
|
+
const lines = report.orphans.map(({ file, className }) =>
|
|
1074
|
+
`${className} in ${file} is never registered with a literal tag`,
|
|
1075
|
+
);
|
|
1076
|
+
return {
|
|
1077
|
+
name,
|
|
1078
|
+
status: 'warn',
|
|
1079
|
+
message:
|
|
1080
|
+
`${report.orphans.length} component class(es) get NO elision verdict:\n` +
|
|
1081
|
+
lines.map((l) => ` ${l}`).join('\n') +
|
|
1082
|
+
'\n Either it has no registration call at all, or it registers a computed tag. The component '
|
|
1083
|
+
+ 'scanner matches only a literal tag, so either way it never sees the class: no elision verdict, no '
|
|
1084
|
+
+ 'registry entry, no preload hint, and `static interactive = true` cannot rescue it. With no '
|
|
1085
|
+
+ 'registration call the element never upgrades at all; with a computed tag it upgrades only while '
|
|
1086
|
+
+ 'its module still reaches the browser through an importer that ships.',
|
|
1087
|
+
fix: 'Register it with a literal tag, Class.register(\'my-tag\') (invariant 3 already requires one), or delete the class if nothing uses it.',
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
const elided = report.components.filter((c) => c.verdict === 'elided');
|
|
1091
|
+
const tags = elided.flatMap((c) => c.tags);
|
|
1092
|
+
const shown = tags.slice(0, 8).join(', ');
|
|
1093
|
+
const tail = tags.length > 8 ? `, +${tags.length - 8} more` : '';
|
|
1094
|
+
return {
|
|
1095
|
+
name,
|
|
1096
|
+
status: 'pass',
|
|
1097
|
+
message:
|
|
1098
|
+
`${report.summary.elided} of ${report.summary.components} component module(s) are elided (never downloaded)` +
|
|
1099
|
+
(tags.length ? `: ${shown}${tail}` : '') +
|
|
1100
|
+
'. Run `webjs elision` for the full verdict.',
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
887
1104
|
// Directories never worth walking for the CSS-freshness advisory (mirrors
|
|
888
1105
|
// dev-regenerate's IGNORE_DIRS): build output, deps, VCS + framework caches.
|
|
889
1106
|
const FRESHNESS_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
@@ -967,6 +1184,322 @@ async function checkStaticAssetFreshness(appDir) {
|
|
|
967
1184
|
};
|
|
968
1185
|
}
|
|
969
1186
|
|
|
1187
|
+
// Directories the route-module walk never descends into (deps, VCS, framework
|
|
1188
|
+
// and build caches). Mirrors FRESHNESS_IGNORE; kept separate so either walk can
|
|
1189
|
+
// change its exclusions without silently moving the other.
|
|
1190
|
+
const ROUTE_WALK_IGNORE = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* A route module that renders markup on the server, which is where `asset()`
|
|
1194
|
+
* belongs. Page and layout are the common case, but the BOUNDARY modules matter
|
|
1195
|
+
* too and are easy to miss: `error` / `not-found` / `forbidden` / `unauthorized`
|
|
1196
|
+
* / `loading` are always shipped and never elided, and `global-error` renders
|
|
1197
|
+
* its OWN `<!doctype><html><head>` and is returned verbatim with no framework
|
|
1198
|
+
* head splice, which makes it the likeliest place outside the root layout for
|
|
1199
|
+
* an author to hand-write a stylesheet link.
|
|
1200
|
+
* @type {RegExp}
|
|
1201
|
+
*/
|
|
1202
|
+
const ROUTE_MODULE_RE =
|
|
1203
|
+
/^(?:page|layout|error|not-found|forbidden|unauthorized|loading)\.(?:js|ts|mjs|mts)$/;
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* The two boundary stems `router.js` registers ONLY at the app root (both are
|
|
1207
|
+
* guarded by `dir === '.'` there). A nested `app/admin/global-error.ts` is never
|
|
1208
|
+
* in the route table and never renders, so scanning one would advise on dead
|
|
1209
|
+
* code, the same defect the `_private` skip exists to avoid.
|
|
1210
|
+
* @type {RegExp}
|
|
1211
|
+
*/
|
|
1212
|
+
const ROOT_ONLY_MODULE_RE = /^(?:global-error|global-not-found)\.(?:js|ts|mjs|mts)$/;
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* One whole `<link …>` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the
|
|
1216
|
+
* same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute
|
|
1217
|
+
* value cannot terminate the tag early.
|
|
1218
|
+
* @type {RegExp}
|
|
1219
|
+
*/
|
|
1220
|
+
const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* One attribute inside a tag: a name, then optionally `=` and a double-quoted,
|
|
1224
|
+
* single-quoted, or unquoted value. Matching attributes as WHOLE units is what
|
|
1225
|
+
* makes the scan correct, because each quoted value is consumed in one step and
|
|
1226
|
+
* can therefore never be re-scanned as if it contained an attribute of its own.
|
|
1227
|
+
* @type {RegExp}
|
|
1228
|
+
*/
|
|
1229
|
+
const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Parse a tag's attributes into a lowercased-name map. The value is `null` for a
|
|
1233
|
+
* valueless attribute and carries a `quoted` flag, since this check treats an
|
|
1234
|
+
* UNQUOTED href (a template hole) as undecidable rather than as a path.
|
|
1235
|
+
* @param {string} tag
|
|
1236
|
+
* @returns {Map<string, { value: string | null, quoted: boolean }>}
|
|
1237
|
+
*/
|
|
1238
|
+
function parseTagAttrs(tag) {
|
|
1239
|
+
/** @type {Map<string, { value: string | null, quoted: boolean }>} */
|
|
1240
|
+
const attrs = new Map();
|
|
1241
|
+
// Skip the tag name itself so `link` is not read as an attribute.
|
|
1242
|
+
const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, '');
|
|
1243
|
+
ATTR_RE.lastIndex = 0;
|
|
1244
|
+
for (const m of body.matchAll(ATTR_RE)) {
|
|
1245
|
+
const name = m[1].toLowerCase();
|
|
1246
|
+
if (attrs.has(name)) continue; // first wins, as in HTML parsing
|
|
1247
|
+
const quoted = m[2] !== undefined || m[3] !== undefined;
|
|
1248
|
+
const value = m[2] ?? m[3] ?? m[4] ?? null;
|
|
1249
|
+
attrs.set(name, { value, quoted });
|
|
1250
|
+
}
|
|
1251
|
+
return attrs;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Whether a parsed `<link>` is an unmarked stylesheet, and if so its href.
|
|
1256
|
+
*
|
|
1257
|
+
* Attribute PARSING rather than a lookahead over the raw tag is load-bearing,
|
|
1258
|
+
* not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in
|
|
1259
|
+
* the tag matches the string inside ANOTHER attribute's value, which flags the
|
|
1260
|
+
* two shapes this check most needs to leave alone: the canonical async-CSS
|
|
1261
|
+
* `<link rel="preload" as="style" href="/public/app.css" onload="this.rel='stylesheet'">`
|
|
1262
|
+
* (where the advised `asset()` fix would actively BREAK the preload, since the
|
|
1263
|
+
* versioned hint could then never match the unversioned request), and a
|
|
1264
|
+
* `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes
|
|
1265
|
+
* makes `rel` mean the `rel` attribute and nothing else.
|
|
1266
|
+
*
|
|
1267
|
+
* Returns the href only when every condition holds:
|
|
1268
|
+
* - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an
|
|
1269
|
+
* onload swap, and `rel="icon"`, are both out).
|
|
1270
|
+
* - `href` is QUOTED. An unquoted value is a template hole
|
|
1271
|
+
* (`href=${asset('/public/app.css')}`), undecidable from source, and is
|
|
1272
|
+
* exactly the shape the marked form uses.
|
|
1273
|
+
* - the path is under `/public/` (after the app's `webjs.basePath` is
|
|
1274
|
+
* stripped, since under a sub-path deploy the author writes the prefix
|
|
1275
|
+
* themselves and `resolveAssetUrl` strips it before its own `public/` gate).
|
|
1276
|
+
* - `resolveAssetUrl` would actually fingerprint it. It returns a path
|
|
1277
|
+
* carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a
|
|
1278
|
+
* runtime NO-OP: the author does the work and the url they ship is
|
|
1279
|
+
* byte-identical. Advising it would be advising a change that buys nothing.
|
|
1280
|
+
* A hand-rolled `?v=` cache-buster is exactly what an author who has not
|
|
1281
|
+
* adopted `asset()` is most likely to have written, so this is the common
|
|
1282
|
+
* case, not a corner. (The warning itself would clear, since this check
|
|
1283
|
+
* reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a
|
|
1284
|
+
* warning without improving the caching is the outcome to avoid.)
|
|
1285
|
+
*
|
|
1286
|
+
* @param {string} tag
|
|
1287
|
+
* @param {string} basePath the app's normalized `webjs.basePath` (`''` at root)
|
|
1288
|
+
* @returns {string | null}
|
|
1289
|
+
*/
|
|
1290
|
+
function unmarkedStylesheetHref(tag, basePath = '') {
|
|
1291
|
+
const attrs = parseTagAttrs(tag);
|
|
1292
|
+
const rel = attrs.get('rel');
|
|
1293
|
+
if (!rel || !rel.value) return null;
|
|
1294
|
+
if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null;
|
|
1295
|
+
const href = attrs.get('href');
|
|
1296
|
+
if (!href || !href.quoted || !href.value) return null;
|
|
1297
|
+
const url = href.value;
|
|
1298
|
+
if (url[0] !== '/' || url[1] === '/') return null;
|
|
1299
|
+
// Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is
|
|
1300
|
+
// one `asset()` can actually fingerprint. It strips the base path, cuts at
|
|
1301
|
+
// `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix.
|
|
1302
|
+
// Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x`
|
|
1303
|
+
// would be flagged although wrapping it changes nothing, and
|
|
1304
|
+
// `/%70ublic/app.css` would be skipped although `asset()` fingerprints it.
|
|
1305
|
+
let probe = url;
|
|
1306
|
+
if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length);
|
|
1307
|
+
const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
|
|
1308
|
+
let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
|
|
1309
|
+
try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
|
|
1310
|
+
if (decoded.includes('..') || !decoded.startsWith('/public/')) return null;
|
|
1311
|
+
// A query is refused outright (an author query may carry meaning we do not
|
|
1312
|
+
// own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not,
|
|
1313
|
+
// since it is split off and preserved.
|
|
1314
|
+
const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#'));
|
|
1315
|
+
if (beforeFragment.includes('?')) return null;
|
|
1316
|
+
return url;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* The app's `webjs.basePath`, normalized to `''` (root mount) or `/segment…`.
|
|
1321
|
+
*
|
|
1322
|
+
* A faithful port of `normalizeBasePath` (`packages/server/src/base-path.js`),
|
|
1323
|
+
* which is the source of truth: it trims, PREPENDS the leading slash (so the
|
|
1324
|
+
* documented `"myapp"`, `"/myapp"` and `"/myapp/"` all normalize alike), and
|
|
1325
|
+
* fails safe to `''` on a value that is not a plain same-origin prefix. Reading
|
|
1326
|
+
* only `startsWith('/')` would leave this check inert for an app configured
|
|
1327
|
+
* `"myapp"`, which is exactly the silently-inert case it exists to close.
|
|
1328
|
+
*
|
|
1329
|
+
* Ported rather than imported because that helper is not on `@webjsdev/server`'s
|
|
1330
|
+
* public surface, and because doctor must stay usable when the framework does
|
|
1331
|
+
* not resolve from the app dir at all (the #954 fresh-worktree case this same
|
|
1332
|
+
* command exists to diagnose). `test/cli/doctor.test.mjs` pins the forms.
|
|
1333
|
+
* @param {string} appDir
|
|
1334
|
+
* @returns {Promise<string>}
|
|
1335
|
+
*/
|
|
1336
|
+
async function readAppBasePath(appDir) {
|
|
1337
|
+
let raw;
|
|
1338
|
+
try {
|
|
1339
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
1340
|
+
raw = pkg?.webjs?.basePath;
|
|
1341
|
+
} catch {
|
|
1342
|
+
return '';
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof raw !== 'string') return '';
|
|
1345
|
+
let v = raw.trim();
|
|
1346
|
+
if (v === '' || v === '/') return '';
|
|
1347
|
+
// Not a plain same-origin path prefix: fail safe to no base path.
|
|
1348
|
+
if (v.includes('..') || v.includes('://') || v.includes('\\') || /\s/.test(v)) return '';
|
|
1349
|
+
// A network-path reference (`//host`) is rejected BEFORE leading slashes are
|
|
1350
|
+
// collapsed, since collapsing would turn an origin escape into `/host`.
|
|
1351
|
+
if (v.startsWith('//')) return '';
|
|
1352
|
+
v = ('/' + v.replace(/^\/+/, '')).replace(/\/+$/, '');
|
|
1353
|
+
return v === '' || v === '/' ? '' : v;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Whether the `<link>` tag at `idx` is commented out, so dead markup is never
|
|
1358
|
+
* reported as a live finding.
|
|
1359
|
+
*
|
|
1360
|
+
* A DELIMITED comment is decided by an unclosed opener behind the tag. Neither
|
|
1361
|
+
* `<!--` nor `/*` nests, so "nearest opener beats nearest closer" is exact, and
|
|
1362
|
+
* it covers a multi-line block whose interior lines carry no marker of their
|
|
1363
|
+
* own (what an editor's toggle-block-comment writes). A `//` has no closer, so
|
|
1364
|
+
* it is decided from the tag's own line: a `//` inside an href later in the
|
|
1365
|
+
* line cannot match, because the line does not START with it.
|
|
1366
|
+
*
|
|
1367
|
+
* Do NOT replace this with a lexer. Two attempts did, and both shipped bugs a
|
|
1368
|
+
* stateless test cannot have: a line-blanking regex killed any line holding a
|
|
1369
|
+
* protocol-relative url, and a quote-tracking walk inverted string/code
|
|
1370
|
+
* polarity on a nested ``html`...` `` inside a `${}` hole (one quote char
|
|
1371
|
+
* cannot model nesting), so an unbalanced apostrophe in template text
|
|
1372
|
+
* desynchronized the rest of the file. This check does not need to lex
|
|
1373
|
+
* JavaScript. If it ever genuinely does, export `redactStringsAndTemplates`
|
|
1374
|
+
* from `@webjsdev/server` (`src/js-scan.js`, fuzz-tested differentially against
|
|
1375
|
+
* a real TypeScript parse) rather than growing a third one here.
|
|
1376
|
+
*
|
|
1377
|
+
* Residual gap: a tag behind a `//` that trails real code on the same line
|
|
1378
|
+
* stays reported. Rare, and it fails toward reporting rather than toward the
|
|
1379
|
+
* silent inertness both lexers produced.
|
|
1380
|
+
*
|
|
1381
|
+
* @param {string} src
|
|
1382
|
+
* @param {number} idx index of the tag's `<`
|
|
1383
|
+
* @returns {boolean}
|
|
1384
|
+
*/
|
|
1385
|
+
function isCommentedOut(src, idx) {
|
|
1386
|
+
const before = src.slice(0, idx);
|
|
1387
|
+
if (before.lastIndexOf('<!--') > before.lastIndexOf('-->')) return true;
|
|
1388
|
+
if (before.lastIndexOf('/*') > before.lastIndexOf('*/')) return true;
|
|
1389
|
+
const lineStart = before.lastIndexOf('\n') + 1;
|
|
1390
|
+
return before.slice(lineStart).trimStart().startsWith('//');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Collect every `app/**` route module that renders markup, depth-first.
|
|
1395
|
+
* Best-effort: an unreadable directory contributes nothing rather than throwing.
|
|
1396
|
+
* @param {string} dir
|
|
1397
|
+
* @param {string[]} [out]
|
|
1398
|
+
* @returns {string[]}
|
|
1399
|
+
*/
|
|
1400
|
+
function collectRouteModules(dir, root = dir, out = []) {
|
|
1401
|
+
let entries;
|
|
1402
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
1403
|
+
for (const e of entries) {
|
|
1404
|
+
if (e.name.startsWith('.') || ROUTE_WALK_IGNORE.has(e.name)) continue;
|
|
1405
|
+
if (e.isSymbolicLink()) continue; // never follow: can cycle or escape into deps
|
|
1406
|
+
// `_`-prefixed folders are PRIVATE: `router.js` drops any route whose
|
|
1407
|
+
// directory has such a segment, so markup under one is never routed and
|
|
1408
|
+
// never rendered. Advising on it would be advice about dead code.
|
|
1409
|
+
if (e.isDirectory() && e.name.startsWith('_')) continue;
|
|
1410
|
+
const abs = join(dir, e.name);
|
|
1411
|
+
if (e.isDirectory()) collectRouteModules(abs, root, out);
|
|
1412
|
+
else if (ROUTE_MODULE_RE.test(e.name)) out.push(abs);
|
|
1413
|
+
else if (dir === root && ROOT_ONLY_MODULE_RE.test(e.name)) out.push(abs);
|
|
1414
|
+
}
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* ADVISORY (#1095): a route module hand-writes a `<link rel="stylesheet"
|
|
1420
|
+
* href="/public/…">` without `asset()`, so the url is un-versioned and a deploy
|
|
1421
|
+
* cannot bust a CDN's copy of it.
|
|
1422
|
+
*
|
|
1423
|
+
* The failure this names was caught in production on webjs.dev: the edge served
|
|
1424
|
+
* a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`,
|
|
1425
|
+
* `max-age=14400`) against post-deploy HTML, so the new page rendered with its
|
|
1426
|
+
* content edge to edge and its grid collapsed, because the cached css was
|
|
1427
|
+
* missing the arbitrary-value utilities that page introduced. It is invisible
|
|
1428
|
+
* while a deploy only restyles existing classes and maximally visible the moment
|
|
1429
|
+
* one adds a page using new utilities.
|
|
1430
|
+
*
|
|
1431
|
+
* Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the
|
|
1432
|
+
* framework's OUTPUT. The first attempt at the automatic form (#1196) matched
|
|
1433
|
+
* urls in the assembled HTML, and two deep-review rounds found six major
|
|
1434
|
+
* defects, five of them one bug: at that layer framework output and author data
|
|
1435
|
+
* are indistinguishable, so the matcher kept editing things it did not own. That
|
|
1436
|
+
* is why `asset()` (#1194) is opt-in, and it is what Rails (a
|
|
1437
|
+
* `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url
|
|
1438
|
+
* from the build graph, surfaced through `links()`) both do: take the
|
|
1439
|
+
* fingerprint from an authoritative source at the point the url is PRODUCED, and
|
|
1440
|
+
* never rewrite a rendered document. The gap `asset()` leaves is purely
|
|
1441
|
+
* ergonomic. In Rails the helper is the only idiomatic way to write the tag, so
|
|
1442
|
+
* forgetting it is nearly impossible; in WebJs the `<link>` is hand-written HTML,
|
|
1443
|
+
* so it is easy to omit. This check closes exactly that gap, at authoring time,
|
|
1444
|
+
* where the author's meaning is unambiguous and nothing is rewritten.
|
|
1445
|
+
*
|
|
1446
|
+
* Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate
|
|
1447
|
+
* NON-mark (the website leaves its favicons bare so the SEO repo-health tests
|
|
1448
|
+
* can parse the hrefs literally), and a `rel="preload"` must NOT be marked at
|
|
1449
|
+
* all, since its versioned hint could never match the unversioned request a CSS
|
|
1450
|
+
* `url()` actually makes. Flagging either would nag about a correct choice.
|
|
1451
|
+
*
|
|
1452
|
+
* WARN only: an un-versioned stylesheet still SERVES correctly, it just caches
|
|
1453
|
+
* badly, and an app fronted by no CDN may not care.
|
|
1454
|
+
* @param {string} appDir
|
|
1455
|
+
* @returns {Promise<DoctorResult>}
|
|
1456
|
+
*/
|
|
1457
|
+
async function checkUnmarkedAssetLinks(appDir) {
|
|
1458
|
+
const name = 'Asset urls (unmarked stylesheet links)';
|
|
1459
|
+
const routeDir = join(appDir, 'app');
|
|
1460
|
+
if (!existsSync(routeDir)) {
|
|
1461
|
+
return { name, status: 'pass', message: 'no app/ directory to analyse' };
|
|
1462
|
+
}
|
|
1463
|
+
const basePath = await readAppBasePath(appDir);
|
|
1464
|
+
const findings = [];
|
|
1465
|
+
for (const file of collectRouteModules(routeDir)) {
|
|
1466
|
+
let src;
|
|
1467
|
+
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
1468
|
+
// Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag
|
|
1469
|
+
// regex: a file whose only link tag is written `<LINK …>` must still be
|
|
1470
|
+
// scanned, or the scanner's own case-insensitivity is unreachable exactly
|
|
1471
|
+
// where it is needed.
|
|
1472
|
+
if (!/<link/i.test(src)) continue;
|
|
1473
|
+
LINK_TAG_RE.lastIndex = 0;
|
|
1474
|
+
for (const m of src.matchAll(LINK_TAG_RE)) {
|
|
1475
|
+
const href = unmarkedStylesheetHref(m[0], basePath);
|
|
1476
|
+
if (!href) continue;
|
|
1477
|
+
// A commented-out tag emits nothing, so advising on it is advice about
|
|
1478
|
+
// dead markup.
|
|
1479
|
+
if (isCommentedOut(src, /** @type {number} */ (m.index))) continue;
|
|
1480
|
+
// 1-indexed line of the match, for a jump-to reference.
|
|
1481
|
+
const line = src.slice(0, m.index).split('\n').length;
|
|
1482
|
+
findings.push({ file, line, href });
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (findings.length === 0) {
|
|
1486
|
+
return { name, status: 'pass', message: 'every route-module stylesheet link is content-hashed (or has none)' };
|
|
1487
|
+
}
|
|
1488
|
+
const rel = (f) => relative(appDir, f) || f;
|
|
1489
|
+
return {
|
|
1490
|
+
name,
|
|
1491
|
+
status: 'warn',
|
|
1492
|
+
message:
|
|
1493
|
+
`${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` +
|
|
1494
|
+
findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'),
|
|
1495
|
+
fix:
|
|
1496
|
+
"Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then "
|
|
1497
|
+
+ '`<link rel="stylesheet" href=${asset(\'/public/app.css\')}>`. It appends a content hash in prod '
|
|
1498
|
+
+ '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. '
|
|
1499
|
+
+ 'Call it inside the render function, not at module scope.',
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
970
1503
|
/**
|
|
971
1504
|
* Probe whether `@webjsdev/core` resolves from `appDir`. Node resolution is
|
|
972
1505
|
* directory-relative, so this must probe FROM the app (not the CLI's own
|
|
@@ -1046,6 +1579,16 @@ export function checkFrameworkResolves(appDir) {
|
|
|
1046
1579
|
|
|
1047
1580
|
export async function runDoctorChecks(appDir, opts = {}) {
|
|
1048
1581
|
const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
|
|
1582
|
+
// ONE elision report for BOTH elision checks (#1308). Started before the
|
|
1583
|
+
// batch and awaited inside each check, so the module graph is built once per
|
|
1584
|
+
// doctor run and the two checks still run in parallel with everything else.
|
|
1585
|
+
// Fails soft to null, exactly as the carrier check's own try/catch did.
|
|
1586
|
+
const elision = (async () => {
|
|
1587
|
+
try {
|
|
1588
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1589
|
+
return await analyzeAppElision(appDir);
|
|
1590
|
+
} catch { return null; }
|
|
1591
|
+
})();
|
|
1049
1592
|
const results = await Promise.all([
|
|
1050
1593
|
checkNode(cliDir, opts),
|
|
1051
1594
|
checkTsconfig(appDir),
|
|
@@ -1056,8 +1599,10 @@ export async function runDoctorChecks(appDir, opts = {}) {
|
|
|
1056
1599
|
Promise.resolve(checkFrameworkResolves(appDir)),
|
|
1057
1600
|
checkImportmapCoherence(appDir, opts),
|
|
1058
1601
|
Promise.resolve(checkGitHook(appDir)),
|
|
1059
|
-
checkElisionCarriers(
|
|
1602
|
+
checkElisionCarriers(elision),
|
|
1603
|
+
checkElisionComponents(elision),
|
|
1060
1604
|
checkStaticAssetFreshness(appDir),
|
|
1605
|
+
checkUnmarkedAssetLinks(appDir),
|
|
1061
1606
|
]);
|
|
1062
1607
|
// Attach the stable machine code to every result (#975). Centralized here so
|
|
1063
1608
|
// each check function stays free of the code-contract concern.
|