@webjsdev/cli 0.10.55 → 0.10.56
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/lib/create.js +35 -0
- package/lib/doctor/codes.js +66 -0
- package/lib/doctor/manifest.js +161 -0
- package/lib/doctor/policy.js +124 -0
- package/lib/doctor/probes/elision.js +111 -0
- package/lib/doctor/probes/env.js +53 -0
- package/lib/doctor/probes/framework-resolves.js +84 -0
- package/lib/doctor/probes/git-hook.js +58 -0
- package/lib/doctor/probes/importmap-coherence.js +158 -0
- package/lib/doctor/probes/node.js +37 -0
- package/lib/doctor/probes/static-asset-freshness.js +58 -0
- package/lib/doctor/probes/tsconfig.js +55 -0
- package/lib/doctor/probes/unmarked-asset-links.js +199 -0
- package/lib/doctor/probes/vendor-gitignore.js +84 -0
- package/lib/doctor/probes/vendor-pin.js +77 -0
- package/lib/doctor/probes/webjs-versions.js +85 -0
- package/lib/doctor/route-modules.js +100 -0
- package/lib/doctor/runner.js +71 -0
- package/lib/doctor/util.js +160 -0
- package/lib/doctor.js +5 -1634
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +41 -2
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +20 -2
- package/templates/.agents/skills/webjs/references/components.md +41 -1
- package/templates/.agents/skills/webjs/references/module-structure.md +229 -0
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +36 -2
- package/templates/.agents/skills/webjs/references/runtime.md +5 -0
- package/templates/gallery/app/features/boundaries/page.ts +11 -0
- package/templates/gallery/app/features/client-router/page.ts +8 -1
- package/templates/gallery/modules/client-router/components/router-controls.ts +16 -2
- package/templates/gallery/modules/gallery/nav.ts +35 -26
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CHECK 6 (optional), git pre-commit hook installed + executable. WARN when the
|
|
10
|
+
* repo is a git checkout but `.git/hooks/pre-commit` is absent or
|
|
11
|
+
* non-executable, since the test-gate / changelog hook would not fire. PASS when
|
|
12
|
+
* present + executable, or skip (PASS) when this is not a git checkout at all
|
|
13
|
+
* (an exported tarball, a non-repo dir). Respects a configured `core.hooksPath`
|
|
14
|
+
* is OUT of scope here: the common scaffold installs into `.git/hooks`, so this
|
|
15
|
+
* checks the default location and a configured path is the user's own concern.
|
|
16
|
+
* @param {string} appDir
|
|
17
|
+
* @returns {DoctorResult}
|
|
18
|
+
*/
|
|
19
|
+
export function checkGitHook(appDir) {
|
|
20
|
+
const gitDir = join(appDir, '.git');
|
|
21
|
+
if (!existsSync(gitDir)) {
|
|
22
|
+
return {
|
|
23
|
+
name: 'git-hook',
|
|
24
|
+
status: 'pass',
|
|
25
|
+
message: 'Not a git checkout; no pre-commit hook expected.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const hook = join(gitDir, 'hooks', 'pre-commit');
|
|
29
|
+
if (!existsSync(hook)) {
|
|
30
|
+
return {
|
|
31
|
+
name: 'git-hook',
|
|
32
|
+
status: 'warn',
|
|
33
|
+
message: 'No .git/hooks/pre-commit hook installed.',
|
|
34
|
+
fix: 'Install the project hooks (e.g. `npm install` runs the prepare step that wires them).',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
let executable = false;
|
|
38
|
+
try {
|
|
39
|
+
// Owner-execute bit. On a checkout without exec bits (some Windows / CI
|
|
40
|
+
// setups) the hook will not run, so flag it.
|
|
41
|
+
executable = (statSync(hook).mode & 0o100) !== 0;
|
|
42
|
+
} catch {
|
|
43
|
+
executable = false;
|
|
44
|
+
}
|
|
45
|
+
if (!executable) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'git-hook',
|
|
48
|
+
status: 'warn',
|
|
49
|
+
message: '.git/hooks/pre-commit exists but is not executable.',
|
|
50
|
+
fix: 'chmod +x .git/hooks/pre-commit',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
name: 'git-hook',
|
|
55
|
+
status: 'pass',
|
|
56
|
+
message: '.git/hooks/pre-commit is installed and executable.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { formatConflicts, makeInstalledManifestReader } from '../manifest.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CHECK 7, importmap coherence (issue #450). Defense-in-depth that catches an
|
|
9
|
+
* INCOHERENT client dependency graph in the produced importmap, regardless of
|
|
10
|
+
* how the incoherence arose (a hand-edited pin file, a partial vendor pin, or
|
|
11
|
+
* the #446 resolution skew). For each resolved package, it checks that the
|
|
12
|
+
* version actually pinned for every OTHER resolved package it depends on
|
|
13
|
+
* satisfies the declared range; a miss warns naming both packages, the range,
|
|
14
|
+
* and the pinned version.
|
|
15
|
+
*
|
|
16
|
+
* Runs the SAME check over BOTH inputs and produces the same verdict for the
|
|
17
|
+
* same dep set (the parity invariant): the live importmap (resolved the way the
|
|
18
|
+
* server resolves it at runtime) AND the vendored `.webjs/vendor/importmap.json`.
|
|
19
|
+
* A vendored importmap is a freeze of the runtime-resolved graph, so a coherent
|
|
20
|
+
* runtime graph that gets vendored stays coherent.
|
|
21
|
+
*
|
|
22
|
+
* WARN-only and BEST-EFFORT: it never hard-fails (a runtime incoherence is the
|
|
23
|
+
* app's concern, not a broken toolchain), and it degrades to a soft
|
|
24
|
+
* "could not verify" whenever metadata or a live resolve is unavailable rather
|
|
25
|
+
* than failing closed. Dependency metadata is read from the already-installed
|
|
26
|
+
* `node_modules` manifests, no network call of its own; the only network touch
|
|
27
|
+
* is the live importmap resolve, which is wrapped so any failure degrades.
|
|
28
|
+
*
|
|
29
|
+
* The vendor functions + manifest reader are injectable via `opts.coherence`
|
|
30
|
+
* so a test can drive every branch without a network call.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} appDir
|
|
33
|
+
* @param {{ coherence?: {
|
|
34
|
+
* liveImports?: () => Promise<Record<string,string> | null>,
|
|
35
|
+
* vendoredImports?: () => Promise<Record<string,string> | null>,
|
|
36
|
+
* getManifest?: (pkg: string, version: string) => Promise<any>,
|
|
37
|
+
* check?: (imports: Record<string,string>, o: { getManifest: any }) => Promise<{ conflicts: any[], unverified: any[], checked: number }>,
|
|
38
|
+
* } }} opts
|
|
39
|
+
* @returns {Promise<DoctorResult>}
|
|
40
|
+
*/
|
|
41
|
+
export async function checkImportmapCoherence(appDir, opts) {
|
|
42
|
+
let inj = opts.coherence;
|
|
43
|
+
// Resolve the real vendor toolchain unless a test injected stubs. Both the
|
|
44
|
+
// importmap sources and the coherence-check function come from
|
|
45
|
+
// @webjsdev/server, so a missing install degrades to a WARN, never a throw.
|
|
46
|
+
if (!inj || !inj.check || !inj.liveImports || !inj.vendoredImports || !inj.getManifest) {
|
|
47
|
+
let mod;
|
|
48
|
+
try {
|
|
49
|
+
mod = await import('@webjsdev/server');
|
|
50
|
+
} catch {
|
|
51
|
+
return {
|
|
52
|
+
name: 'importmap-coherence',
|
|
53
|
+
status: 'warn',
|
|
54
|
+
bestEffort: true,
|
|
55
|
+
message: 'Could not load the vendor toolchain to check importmap coherence.',
|
|
56
|
+
fix: 'Run `npm install` so @webjsdev/server is available, then re-run `webjs doctor`.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const real = {
|
|
60
|
+
check: mod.checkImportmapCoherence,
|
|
61
|
+
// Hoist-aware manifest read from the already-installed node_modules (no
|
|
62
|
+
// network of its own), so a monorepo-hoisted dep still resolves. Falls
|
|
63
|
+
// back to the local app/node_modules read if the server build predates
|
|
64
|
+
// getPackageManifest.
|
|
65
|
+
getManifest: typeof mod.getPackageManifest === 'function'
|
|
66
|
+
? (pkg) => mod.getPackageManifest(pkg, appDir)
|
|
67
|
+
: makeInstalledManifestReader(appDir),
|
|
68
|
+
// Live importmap: resolve vendor imports the way the server does on the
|
|
69
|
+
// first request (prefers the pin file, else a live jspm.io resolve).
|
|
70
|
+
liveImports: async () => {
|
|
71
|
+
try {
|
|
72
|
+
const resolved = await mod.resolveVendorImports(appDir, () => mod.scanBareImports(appDir));
|
|
73
|
+
return resolved && resolved.imports ? resolved.imports : {};
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
// Vendored importmap: the committed pin file, no network.
|
|
79
|
+
vendoredImports: async () => {
|
|
80
|
+
try {
|
|
81
|
+
const pin = await mod.readPinFile(appDir);
|
|
82
|
+
return pin && pin.imports ? pin.imports : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
inj = { ...real, ...(inj || {}) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Gather both importmaps. Either may be absent (no pin file, or a live
|
|
92
|
+
// resolve that failed / found no vendor imports); the check runs over
|
|
93
|
+
// whichever exist, identically.
|
|
94
|
+
let live = null;
|
|
95
|
+
let vendored = null;
|
|
96
|
+
try { live = await inj.liveImports(); } catch { live = null; }
|
|
97
|
+
try { vendored = await inj.vendoredImports(); } catch { vendored = null; }
|
|
98
|
+
|
|
99
|
+
const liveHas = live && Object.keys(live).length > 0;
|
|
100
|
+
const vendoredHas = vendored && Object.keys(vendored).length > 0;
|
|
101
|
+
if (!liveHas && !vendoredHas) {
|
|
102
|
+
return {
|
|
103
|
+
name: 'importmap-coherence',
|
|
104
|
+
status: 'pass',
|
|
105
|
+
message: 'No vendor importmap to check (the app imports no npm packages on the client).',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Run the IDENTICAL check over each available importmap. The function is
|
|
110
|
+
// pure in (imports, getManifest), so the same pinned dep set produces the
|
|
111
|
+
// same verdict whichever input it came from (the runtime-vs-vendored parity
|
|
112
|
+
// invariant). Aggregate the conflicts; dedupe identical ones so a package
|
|
113
|
+
// pinned the same way in both maps is reported once.
|
|
114
|
+
/** @type {Map<string, any>} */
|
|
115
|
+
const conflictsByKey = new Map();
|
|
116
|
+
let anyChecked = 0;
|
|
117
|
+
let anyUnverified = 0;
|
|
118
|
+
for (const imports of [liveHas ? live : null, vendoredHas ? vendored : null]) {
|
|
119
|
+
if (!imports) continue;
|
|
120
|
+
let report;
|
|
121
|
+
try {
|
|
122
|
+
report = await inj.check(imports, { getManifest: inj.getManifest });
|
|
123
|
+
} catch {
|
|
124
|
+
// A check that threw is a "could not verify", never a doctor crash.
|
|
125
|
+
anyUnverified++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
anyChecked += report.checked || 0;
|
|
129
|
+
anyUnverified += (report.unverified || []).length;
|
|
130
|
+
for (const c of report.conflicts || []) {
|
|
131
|
+
conflictsByKey.set(`${c.pkg}@${c.version}->${c.dependsOn}@${c.pinnedVersion}`, c);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const conflicts = [...conflictsByKey.values()];
|
|
136
|
+
if (conflicts.length > 0) {
|
|
137
|
+
return {
|
|
138
|
+
name: 'importmap-coherence',
|
|
139
|
+
status: 'warn',
|
|
140
|
+
message: `Incoherent client dependency graph in the importmap: ${formatConflicts(conflicts)}.`,
|
|
141
|
+
fix: 'Align the pinned versions: re-run `webjs vendor pin` to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version satisfying every dependent.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (anyChecked === 0 && anyUnverified > 0) {
|
|
145
|
+
return {
|
|
146
|
+
name: 'importmap-coherence',
|
|
147
|
+
status: 'warn',
|
|
148
|
+
bestEffort: true,
|
|
149
|
+
message: 'Could not verify importmap coherence (dependency metadata for the pinned packages was unavailable).',
|
|
150
|
+
fix: 'Run `npm install` so the pinned packages are present in node_modules, then re-run `webjs doctor`.',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
name: 'importmap-coherence',
|
|
155
|
+
status: 'pass',
|
|
156
|
+
message: 'The importmap dependency graph is coherent (every pinned package satisfies its dependents\' declared ranges).',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { checkNodeInline } from '../../node-preflight.js';
|
|
2
|
+
import { readEngines } from '../util.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CHECK 1, Node version. HARD-FAIL when the running major is below the required
|
|
10
|
+
* major (the strip-types + recursive fs.watch floor). `opts.nodeVersion` lets a
|
|
11
|
+
* test inject the running version so the fail case is assertable without being
|
|
12
|
+
* on old Node.
|
|
13
|
+
* @param {string} cliDir
|
|
14
|
+
* @param {{ nodeVersion?: string }} opts
|
|
15
|
+
* @returns {Promise<DoctorResult>}
|
|
16
|
+
*/
|
|
17
|
+
export async function checkNode(cliDir, opts) {
|
|
18
|
+
const engines = await readEngines(cliDir);
|
|
19
|
+
const current = opts.nodeVersion || process.versions.node;
|
|
20
|
+
const r = checkNodeInline(current, engines);
|
|
21
|
+
if (r.ok) {
|
|
22
|
+
return {
|
|
23
|
+
name: 'node-version',
|
|
24
|
+
status: 'pass',
|
|
25
|
+
message: `Node ${r.current} satisfies the required Node ${r.requiredMajor}+.`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
name: 'node-version',
|
|
30
|
+
status: 'fail',
|
|
31
|
+
message:
|
|
32
|
+
`Node ${r.current} is below the required Node ${r.requiredMajor}+. ` +
|
|
33
|
+
`webjs is buildless and relies on Node ${r.requiredMajor}'s built-in TypeScript ` +
|
|
34
|
+
`strip and recursive fs.watch.`,
|
|
35
|
+
fix: `Upgrade to Node ${r.requiredMajor}+ (see https://nodejs.org).`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { newestMtimeMs } from '../util.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ADVISORY: a declared `webjs.dev.regenerate` output is STALE on disk (a source
|
|
11
|
+
* is newer than the committed/built output). In DEV the framework recompiles it
|
|
12
|
+
* on request (#967), so this never bites locally, but the check is the explicit
|
|
13
|
+
* dev/prod PARITY backstop: it catches a stale `public/tailwind.css` that would
|
|
14
|
+
* be served as-is by `webjs start` (prod does NOT recompile on request) or
|
|
15
|
+
* committed into the repo. WARN-level: the fix is a one-line rebuild, and a
|
|
16
|
+
* missing output (a fresh clone before the first `css:build`) is not this app's
|
|
17
|
+
* bug to hard-fail on.
|
|
18
|
+
* @param {string} appDir
|
|
19
|
+
* @returns {Promise<DoctorResult>}
|
|
20
|
+
*/
|
|
21
|
+
export async function checkStaticAssetFreshness(appDir) {
|
|
22
|
+
const name = 'Static build outputs (dev.regenerate freshness)';
|
|
23
|
+
let pkg;
|
|
24
|
+
try {
|
|
25
|
+
pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return { name, status: 'pass', message: 'no package.json to analyse' };
|
|
28
|
+
}
|
|
29
|
+
const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null;
|
|
30
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
31
|
+
return { name, status: 'pass', message: 'no webjs.dev.regenerate rules declared' };
|
|
32
|
+
}
|
|
33
|
+
const stale = [];
|
|
34
|
+
for (const rule of rules) {
|
|
35
|
+
if (!rule || typeof rule.output !== 'string') continue;
|
|
36
|
+
const output = rule.output.replace(/^\/+/, '');
|
|
37
|
+
const outMtime = newestMtimeMs(join(appDir, output));
|
|
38
|
+
if (outMtime === 0) continue; // missing output: not a staleness fail (built on first boot)
|
|
39
|
+
let newestSrc = 0;
|
|
40
|
+
for (const inp of Array.isArray(rule.inputs) ? rule.inputs : []) {
|
|
41
|
+
const m = newestMtimeMs(join(appDir, inp));
|
|
42
|
+
if (m > newestSrc) newestSrc = m;
|
|
43
|
+
}
|
|
44
|
+
if (newestSrc > outMtime) stale.push({ output, command: rule.command });
|
|
45
|
+
}
|
|
46
|
+
if (stale.length === 0) {
|
|
47
|
+
return { name, status: 'pass', message: 'every declared build output is up to date with its sources' };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
status: 'warn',
|
|
52
|
+
message:
|
|
53
|
+
`${stale.length} static build output(s) are older than a source file:\n` +
|
|
54
|
+
stale.map((s) => ` ${s.output} (rebuild: ${s.command})`).join('\n') +
|
|
55
|
+
'\n In dev the framework recompiles these on request, so this only bites a `webjs start` (prod) or a committed stale file.',
|
|
56
|
+
fix: 'Rebuild the output(s) with the command shown (e.g. `npm run css:build`) before deploying or committing. `webjs dev` regenerates them on request automatically.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { stripJsonc } from '../util.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CHECK 2, tsconfig erasableSyntaxOnly. PASS when `true`; WARN when no tsconfig
|
|
12
|
+
* (a JS-only app legitimately has none) or the file is unparseable; HARD-FAIL
|
|
13
|
+
* when the file EXISTS but the flag is missing/false (non-erasable TS 500s at
|
|
14
|
+
* strip time).
|
|
15
|
+
* @param {string} appDir
|
|
16
|
+
* @returns {Promise<DoctorResult>}
|
|
17
|
+
*/
|
|
18
|
+
export async function checkTsconfig(appDir) {
|
|
19
|
+
const path = join(appDir, 'tsconfig.json');
|
|
20
|
+
if (!existsSync(path)) {
|
|
21
|
+
return {
|
|
22
|
+
name: 'tsconfig-erasable',
|
|
23
|
+
status: 'warn',
|
|
24
|
+
message: 'No tsconfig.json found. A JS-only app needs none; a TypeScript app requires one.',
|
|
25
|
+
fix: 'If this app uses TypeScript, add a tsconfig.json with "erasableSyntaxOnly": true.',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(stripJsonc(await readFile(path, 'utf8')));
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
name: 'tsconfig-erasable',
|
|
34
|
+
status: 'warn',
|
|
35
|
+
message: 'tsconfig.json could not be parsed (even after stripping comments + trailing commas).',
|
|
36
|
+
fix: 'Fix the tsconfig.json syntax, then ensure "compilerOptions.erasableSyntaxOnly": true.',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const flag = parsed?.compilerOptions?.erasableSyntaxOnly;
|
|
40
|
+
if (flag === true) {
|
|
41
|
+
return {
|
|
42
|
+
name: 'tsconfig-erasable',
|
|
43
|
+
status: 'pass',
|
|
44
|
+
message: 'tsconfig.json sets "erasableSyntaxOnly": true.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
name: 'tsconfig-erasable',
|
|
49
|
+
status: 'fail',
|
|
50
|
+
message:
|
|
51
|
+
'tsconfig.json is missing "compilerOptions.erasableSyntaxOnly": true. ' +
|
|
52
|
+
'Non-erasable TypeScript (enum, namespace, parameter properties, ...) 500s at strip time.',
|
|
53
|
+
fix: 'Set "compilerOptions": { "erasableSyntaxOnly": true } in tsconfig.json.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { isCommentedOut } from '../util.js';
|
|
5
|
+
import { collectRouteModules, readAppBasePath } from '../route-modules.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One whole `<link …>` tag. QUOTE-AWARE (`(?:[^>"']|"[^"]*"|'[^']*')*`), the
|
|
13
|
+
* same shape `ssr.js`'s hoist scanner uses, so a `>` inside a quoted attribute
|
|
14
|
+
* value cannot terminate the tag early.
|
|
15
|
+
* @type {RegExp}
|
|
16
|
+
*/
|
|
17
|
+
const LINK_TAG_RE = /<link\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One attribute inside a tag: a name, then optionally `=` and a double-quoted,
|
|
21
|
+
* single-quoted, or unquoted value. Matching attributes as WHOLE units is what
|
|
22
|
+
* makes the scan correct, because each quoted value is consumed in one step and
|
|
23
|
+
* can therefore never be re-scanned as if it contained an attribute of its own.
|
|
24
|
+
* @type {RegExp}
|
|
25
|
+
*/
|
|
26
|
+
const ATTR_RE = /([a-zA-Z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a tag's attributes into a lowercased-name map. The value is `null` for a
|
|
30
|
+
* valueless attribute and carries a `quoted` flag, since this check treats an
|
|
31
|
+
* UNQUOTED href (a template hole) as undecidable rather than as a path.
|
|
32
|
+
* @param {string} tag
|
|
33
|
+
* @returns {Map<string, { value: string | null, quoted: boolean }>}
|
|
34
|
+
*/
|
|
35
|
+
function parseTagAttrs(tag) {
|
|
36
|
+
/** @type {Map<string, { value: string | null, quoted: boolean }>} */
|
|
37
|
+
const attrs = new Map();
|
|
38
|
+
// Skip the tag name itself so `link` is not read as an attribute.
|
|
39
|
+
const body = tag.replace(/^<[a-zA-Z_:][-\w:.]*/, '');
|
|
40
|
+
ATTR_RE.lastIndex = 0;
|
|
41
|
+
for (const m of body.matchAll(ATTR_RE)) {
|
|
42
|
+
const name = m[1].toLowerCase();
|
|
43
|
+
if (attrs.has(name)) continue; // first wins, as in HTML parsing
|
|
44
|
+
const quoted = m[2] !== undefined || m[3] !== undefined;
|
|
45
|
+
const value = m[2] ?? m[3] ?? m[4] ?? null;
|
|
46
|
+
attrs.set(name, { value, quoted });
|
|
47
|
+
}
|
|
48
|
+
return attrs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a parsed `<link>` is an unmarked stylesheet, and if so its href.
|
|
53
|
+
*
|
|
54
|
+
* Attribute PARSING rather than a lookahead over the raw tag is load-bearing,
|
|
55
|
+
* not tidiness. A scan that merely looks ahead for `rel=…stylesheet` anywhere in
|
|
56
|
+
* the tag matches the string inside ANOTHER attribute's value, which flags the
|
|
57
|
+
* two shapes this check most needs to leave alone: the canonical async-CSS
|
|
58
|
+
* `<link rel="preload" as="style" href="/public/app.css" onload="this.rel='stylesheet'">`
|
|
59
|
+
* (where the advised `asset()` fix would actively BREAK the preload, since the
|
|
60
|
+
* versioned hint could then never match the unversioned request), and a
|
|
61
|
+
* `data-rel="stylesheet"` sitting on a `rel="icon"`. Reading real attributes
|
|
62
|
+
* makes `rel` mean the `rel` attribute and nothing else.
|
|
63
|
+
*
|
|
64
|
+
* Returns the href only when every condition holds:
|
|
65
|
+
* - `rel` is a token list CONTAINING `stylesheet` (so `rel="preload"` with an
|
|
66
|
+
* onload swap, and `rel="icon"`, are both out).
|
|
67
|
+
* - `href` is QUOTED. An unquoted value is a template hole
|
|
68
|
+
* (`href=${asset('/public/app.css')}`), undecidable from source, and is
|
|
69
|
+
* exactly the shape the marked form uses.
|
|
70
|
+
* - the path is under `/public/` (after the app's `webjs.basePath` is
|
|
71
|
+
* stripped, since under a sub-path deploy the author writes the prefix
|
|
72
|
+
* themselves and `resolveAssetUrl` strips it before its own `public/` gate).
|
|
73
|
+
* - `resolveAssetUrl` would actually fingerprint it. It returns a path
|
|
74
|
+
* carrying a QUERY or a `..` unchanged, so wrapping one in `asset()` is a
|
|
75
|
+
* runtime NO-OP: the author does the work and the url they ship is
|
|
76
|
+
* byte-identical. Advising it would be advising a change that buys nothing.
|
|
77
|
+
* A hand-rolled `?v=` cache-buster is exactly what an author who has not
|
|
78
|
+
* adopted `asset()` is most likely to have written, so this is the common
|
|
79
|
+
* case, not a corner. (The warning itself would clear, since this check
|
|
80
|
+
* reads the SOURCE shape and a wrapped href is an unquoted hole. Clearing a
|
|
81
|
+
* warning without improving the caching is the outcome to avoid.)
|
|
82
|
+
*
|
|
83
|
+
* @param {string} tag
|
|
84
|
+
* @param {string} basePath the app's normalized `webjs.basePath` (`''` at root)
|
|
85
|
+
* @returns {string | null}
|
|
86
|
+
*/
|
|
87
|
+
function unmarkedStylesheetHref(tag, basePath = '') {
|
|
88
|
+
const attrs = parseTagAttrs(tag);
|
|
89
|
+
const rel = attrs.get('rel');
|
|
90
|
+
if (!rel || !rel.value) return null;
|
|
91
|
+
if (!rel.value.toLowerCase().split(/\s+/).includes('stylesheet')) return null;
|
|
92
|
+
const href = attrs.get('href');
|
|
93
|
+
if (!href || !href.quoted || !href.value) return null;
|
|
94
|
+
const url = href.value;
|
|
95
|
+
if (url[0] !== '/' || url[1] === '/') return null;
|
|
96
|
+
// Mirror `resolveAssetUrl`'s refusals IN ITS ORDER, so every flagged href is
|
|
97
|
+
// one `asset()` can actually fingerprint. It strips the base path, cuts at
|
|
98
|
+
// `?` / `#`, DECODES, and only then tests `..` and the `public/` prefix.
|
|
99
|
+
// Testing the raw value instead disagrees at both ends: `/public/%2e%2e/x`
|
|
100
|
+
// would be flagged although wrapping it changes nothing, and
|
|
101
|
+
// `/%70ublic/app.css` would be skipped although `asset()` fingerprints it.
|
|
102
|
+
let probe = url;
|
|
103
|
+
if (basePath && probe.startsWith(basePath + '/')) probe = probe.slice(basePath.length);
|
|
104
|
+
const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
|
|
105
|
+
let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
|
|
106
|
+
try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
|
|
107
|
+
if (decoded.includes('..') || !decoded.startsWith('/public/')) return null;
|
|
108
|
+
// A query is refused outright (an author query may carry meaning we do not
|
|
109
|
+
// own, so `resolveAssetUrl` returns the url untouched); a `#fragment` is not,
|
|
110
|
+
// since it is split off and preserved.
|
|
111
|
+
const beforeFragment = url.indexOf('#') === -1 ? url : url.slice(0, url.indexOf('#'));
|
|
112
|
+
if (beforeFragment.includes('?')) return null;
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ADVISORY (#1095): a route module hand-writes a `<link rel="stylesheet"
|
|
118
|
+
* href="/public/…">` without `asset()`, so the url is un-versioned and a deploy
|
|
119
|
+
* cannot bust a CDN's copy of it.
|
|
120
|
+
*
|
|
121
|
+
* The failure this names was caught in production on webjs.dev: the edge served
|
|
122
|
+
* a `public/tailwind.css` built BEFORE the deploy (`cf-cache-status: HIT`,
|
|
123
|
+
* `max-age=14400`) against post-deploy HTML, so the new page rendered with its
|
|
124
|
+
* content edge to edge and its grid collapsed, because the cached css was
|
|
125
|
+
* missing the arbitrary-value utilities that page introduced. It is invisible
|
|
126
|
+
* while a deploy only restyles existing classes and maximally visible the moment
|
|
127
|
+
* one adds a page using new utilities.
|
|
128
|
+
*
|
|
129
|
+
* Why this is an ADVISORY over the author's SOURCE rather than a rewrite of the
|
|
130
|
+
* framework's OUTPUT. The first attempt at the automatic form (#1196) matched
|
|
131
|
+
* urls in the assembled HTML, and two deep-review rounds found six major
|
|
132
|
+
* defects, five of them one bug: at that layer framework output and author data
|
|
133
|
+
* are indistinguishable, so the matcher kept editing things it did not own. That
|
|
134
|
+
* is why `asset()` (#1194) is opt-in, and it is what Rails (a
|
|
135
|
+
* `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url
|
|
136
|
+
* from the build graph, surfaced through `links()`) both do: take the
|
|
137
|
+
* fingerprint from an authoritative source at the point the url is PRODUCED, and
|
|
138
|
+
* never rewrite a rendered document. The gap `asset()` leaves is purely
|
|
139
|
+
* ergonomic. In Rails the helper is the only idiomatic way to write the tag, so
|
|
140
|
+
* forgetting it is nearly impossible; in WebJs the `<link>` is hand-written HTML,
|
|
141
|
+
* so it is easy to omit. This check closes exactly that gap, at authoring time,
|
|
142
|
+
* where the author's meaning is unambiguous and nothing is rewritten.
|
|
143
|
+
*
|
|
144
|
+
* Scoped to `rel="stylesheet"` on purpose. An icon is a legitimate deliberate
|
|
145
|
+
* NON-mark (the website leaves its favicons bare so the SEO repo-health tests
|
|
146
|
+
* can parse the hrefs literally), and a `rel="preload"` must NOT be marked at
|
|
147
|
+
* all, since its versioned hint could never match the unversioned request a CSS
|
|
148
|
+
* `url()` actually makes. Flagging either would nag about a correct choice.
|
|
149
|
+
*
|
|
150
|
+
* WARN only: an un-versioned stylesheet still SERVES correctly, it just caches
|
|
151
|
+
* badly, and an app fronted by no CDN may not care.
|
|
152
|
+
* @param {string} appDir
|
|
153
|
+
* @returns {Promise<DoctorResult>}
|
|
154
|
+
*/
|
|
155
|
+
export async function checkUnmarkedAssetLinks(appDir) {
|
|
156
|
+
const name = 'Asset urls (unmarked stylesheet links)';
|
|
157
|
+
const routeDir = join(appDir, 'app');
|
|
158
|
+
if (!existsSync(routeDir)) {
|
|
159
|
+
return { name, status: 'pass', message: 'no app/ directory to analyse' };
|
|
160
|
+
}
|
|
161
|
+
const basePath = await readAppBasePath(appDir);
|
|
162
|
+
const findings = [];
|
|
163
|
+
for (const file of collectRouteModules(routeDir)) {
|
|
164
|
+
let src;
|
|
165
|
+
try { src = await readFile(file, 'utf8'); } catch { continue; }
|
|
166
|
+
// Cheap bail before any tag scanning. Case-INSENSITIVE to match the tag
|
|
167
|
+
// regex: a file whose only link tag is written `<LINK …>` must still be
|
|
168
|
+
// scanned, or the scanner's own case-insensitivity is unreachable exactly
|
|
169
|
+
// where it is needed.
|
|
170
|
+
if (!/<link/i.test(src)) continue;
|
|
171
|
+
LINK_TAG_RE.lastIndex = 0;
|
|
172
|
+
for (const m of src.matchAll(LINK_TAG_RE)) {
|
|
173
|
+
const href = unmarkedStylesheetHref(m[0], basePath);
|
|
174
|
+
if (!href) continue;
|
|
175
|
+
// A commented-out tag emits nothing, so advising on it is advice about
|
|
176
|
+
// dead markup.
|
|
177
|
+
if (isCommentedOut(src, /** @type {number} */ (m.index))) continue;
|
|
178
|
+
// 1-indexed line of the match, for a jump-to reference.
|
|
179
|
+
const line = src.slice(0, m.index).split('\n').length;
|
|
180
|
+
findings.push({ file, line, href });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (findings.length === 0) {
|
|
184
|
+
return { name, status: 'pass', message: 'every route-module stylesheet link is content-hashed (or has none)' };
|
|
185
|
+
}
|
|
186
|
+
const rel = (f) => relative(appDir, f) || f;
|
|
187
|
+
return {
|
|
188
|
+
name,
|
|
189
|
+
status: 'warn',
|
|
190
|
+
message:
|
|
191
|
+
`${findings.length} stylesheet link(s) are served at an un-versioned url, so a deploy cannot bust a cached copy:\n` +
|
|
192
|
+
findings.map((f) => ` ${rel(f.file)}:${f.line} href="${f.href}"`).join('\n'),
|
|
193
|
+
fix:
|
|
194
|
+
"Wrap the path in asset(): `import { asset } from '@webjsdev/core'` then "
|
|
195
|
+
+ '`<link rel="stylesheet" href=${asset(\'/public/app.css\')}>`. It appends a content hash in prod '
|
|
196
|
+
+ '(the framework then serves that url immutable for a year) and is a no-op in dev and in the browser. '
|
|
197
|
+
+ 'Call it inside the render function, not at module scope.',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {import('../codes.js').DoctorResult} DoctorResult
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CHECK: the `.gitignore` does not swallow the committed vendor pin. The pattern
|
|
10
|
+
* for `.webjs/vendor/` is subtle: a bare `.webjs/` line excludes the directory
|
|
11
|
+
* entirely and git cannot re-include children of an excluded parent, so a
|
|
12
|
+
* `!.webjs/vendor/` exception silently does nothing and `webjs vendor pin`
|
|
13
|
+
* output never gets committed. The correct pattern is the depth-robust
|
|
14
|
+
* contents-glob form (see the fix text below / VENDOR_GITIGNORE_LINES in
|
|
15
|
+
* vendor.js): a globstar-prefixed `.webjs/*` plus the matching vendor
|
|
16
|
+
* negations, which ignores transient `.webjs` output at any depth while
|
|
17
|
+
* keeping the committed vendor pin tracked.
|
|
18
|
+
*
|
|
19
|
+
* This was a `webjs check` rule, but inspecting `.gitignore` is a project-config
|
|
20
|
+
* concern (like `tsconfig-erasable`), not source-code correctness, and vendoring
|
|
21
|
+
* is optional, so a doctor WARN fits the domain and severity better than a CI
|
|
22
|
+
* hard-fail (#461). It lives next to `vendor-pin` (same family).
|
|
23
|
+
*
|
|
24
|
+
* PASS/skip when the dir is not a git repo or has no `.gitignore` (the user has
|
|
25
|
+
* not opted into version control yet). Probes two representative paths via
|
|
26
|
+
* `git check-ignore` with the inherited GIT_* env stripped so `cwd` is the sole
|
|
27
|
+
* authority on which repo + .gitignore stack is consulted (a pre-commit hook
|
|
28
|
+
* from a linked worktree exports GIT_WORK_TREE, which would otherwise override
|
|
29
|
+
* cwd-based discovery).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} appDir
|
|
32
|
+
* @returns {Promise<DoctorResult>}
|
|
33
|
+
*/
|
|
34
|
+
export async function checkVendorGitignore(appDir) {
|
|
35
|
+
const hasGit = existsSync(join(appDir, '.git'));
|
|
36
|
+
const hasGitignore = existsSync(join(appDir, '.gitignore'));
|
|
37
|
+
if (!hasGit || !hasGitignore) {
|
|
38
|
+
return {
|
|
39
|
+
name: 'vendor-gitignore',
|
|
40
|
+
status: 'pass',
|
|
41
|
+
message: 'Not a git checkout with a .gitignore; nothing to verify.',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const { spawnSync } = await import('node:child_process');
|
|
45
|
+
const {
|
|
46
|
+
GIT_DIR: _gd, GIT_WORK_TREE: _gwt, GIT_INDEX_FILE: _gif, GIT_PREFIX: _gp,
|
|
47
|
+
...gitEnv
|
|
48
|
+
} = process.env;
|
|
49
|
+
// Check two representative paths: the pin manifest AND a sample downloaded
|
|
50
|
+
// bundle. A `.gitignore` that allows the manifest but blocks bundles (e.g.
|
|
51
|
+
// `*.js` higher up) would still break `webjs vendor pin --download`.
|
|
52
|
+
// `git check-ignore -q` exits 0 when the path is ignored, 1 when not.
|
|
53
|
+
const probes = [
|
|
54
|
+
'.webjs/vendor/importmap.json',
|
|
55
|
+
'.webjs/vendor/sample-pkg@1.0.0.js',
|
|
56
|
+
];
|
|
57
|
+
for (const probe of probes) {
|
|
58
|
+
const result = spawnSync('git', ['check-ignore', '-q', probe], {
|
|
59
|
+
cwd: appDir,
|
|
60
|
+
stdio: 'pipe',
|
|
61
|
+
env: gitEnv,
|
|
62
|
+
});
|
|
63
|
+
if (result.status === 0) {
|
|
64
|
+
return {
|
|
65
|
+
name: 'vendor-gitignore',
|
|
66
|
+
status: 'warn',
|
|
67
|
+
message:
|
|
68
|
+
`${probe} is gitignored, but \`webjs vendor pin\` writes files under .webjs/vendor/ that MUST be committed for a production deploy to use the pin (instead of calling api.jspm.io on every cold start). The most common cause: a \`.webjs/\` line that excludes the parent directory before the \`!.webjs/vendor/\` exception can take effect (git semantics: a parent exclusion blocks child negations). A second cause is a broader rule (e.g. \`*.js\` at root) hiding bundle files added by \`webjs vendor pin --download\`.`,
|
|
69
|
+
fix:
|
|
70
|
+
'Replace `.webjs/` in your .gitignore with this three-line pattern:\n' +
|
|
71
|
+
' **/.webjs/*\n' +
|
|
72
|
+
' !**/.webjs/vendor/\n' +
|
|
73
|
+
' !**/.webjs/vendor/**\n' +
|
|
74
|
+
'The `**/` prefix ignores `.webjs/` at any depth (so a nested / monorepo app does not leak its generated `.webjs/routes.d.ts`) while still re-including the committed vendor pin. ' +
|
|
75
|
+
'Verify with `git check-ignore -q .webjs/vendor/importmap.json` (exit 1 means correctly un-ignored).',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name: 'vendor-gitignore',
|
|
81
|
+
status: 'pass',
|
|
82
|
+
message: 'The .gitignore keeps .webjs/vendor/ committable.',
|
|
83
|
+
};
|
|
84
|
+
}
|