@webjsdev/cli 0.10.51 → 0.10.53
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/bin/webjs.js +307 -5
- package/lib/app-name.js +73 -0
- package/lib/create.js +15 -4
- package/lib/doctor.js +168 -28
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +6 -3
- package/templates/.agents/skills/webjs/references/built-ins.md +1 -1
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +10 -0
- package/templates/.agents/skills/webjs/references/components.md +140 -3
- package/templates/.agents/skills/webjs/references/data-and-actions.md +53 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +28 -8
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +3 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +27 -0
- package/templates/.agents/skills/webjs/references/typescript.md +17 -3
- package/templates/.claude/hooks/block-prose-punctuation.sh +66 -24
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/directives/components/directive-demo.ts +4 -1
- package/templates/gallery/modules/gallery/components/gallery-nav.ts +5 -0
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +5 -3
- package/templates/test/hello/e2e/hello.test.ts +26 -1
package/bin/webjs.js
CHANGED
|
@@ -49,6 +49,40 @@ if (cmd !== 'help' && cmd !== undefined && !wantsHelp && !wantsVersion) {
|
|
|
49
49
|
// .agents/rules/workflow.md / .github/copilot-instructions.md mirror it.
|
|
50
50
|
const TEMPLATES = ['full-stack', 'api'];
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The one thing `webjs elision --verify` must say about its own boundary, in
|
|
54
|
+
* the command's own output rather than only in the docs. The differential masks
|
|
55
|
+
* the JS-loaded set by construction, so it can only ever see the DANGEROUS
|
|
56
|
+
* direction (elision changed the served bytes); a wrongly dropped module shows
|
|
57
|
+
* up as a dead click, which is bytes-identical.
|
|
58
|
+
*/
|
|
59
|
+
const VERIFY_CAVEAT = `
|
|
60
|
+
This proves elision did not change the bytes your app serves. It does NOT prove
|
|
61
|
+
post-hydration behaviour: a wrongly dropped module shows up as a dead click, not
|
|
62
|
+
as different bytes. Run your browser or e2e suite twice to cover that half:
|
|
63
|
+
WEBJS_ELIDE=1 <your e2e command>
|
|
64
|
+
WEBJS_ELIDE=0 <your e2e command>`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `--routes /a,/b` or `--routes=/a,/b` -> ['/a', '/b']. Every value is
|
|
68
|
+
* normalized to a leading slash; empties drop.
|
|
69
|
+
* @param {string[]} argv
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function parseRoutesFlag(argv) {
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const raw = [];
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
if (argv[i] === '--routes') { if (argv[i + 1]) raw.push(argv[i + 1]); i++; }
|
|
77
|
+
else if (argv[i].startsWith('--routes=')) raw.push(argv[i].slice('--routes='.length));
|
|
78
|
+
}
|
|
79
|
+
return raw
|
|
80
|
+
.flatMap((v) => v.split(','))
|
|
81
|
+
.map((v) => v.trim())
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((v) => (v.startsWith('/') ? v : '/' + v));
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
const USAGE = `webjs commands:
|
|
53
87
|
webjs dev [--port 8080] [--no-hot] Start dev server with live reload
|
|
54
88
|
(--no-hot: run in-process, no hot-reload supervisor)
|
|
@@ -56,8 +90,10 @@ const USAGE = `webjs commands:
|
|
|
56
90
|
webjs test [--server|--browser] Run server + browser tests
|
|
57
91
|
webjs check [--json] Run correctness checks (--json emits structured violations)
|
|
58
92
|
webjs routes [--json|--table] [--no-headers] Print the route table (path / owner file / methods). Default tree; --json matches the MCP list_routes shape; --no-headers drops the --table header
|
|
59
|
-
webjs
|
|
60
|
-
|
|
93
|
+
webjs elision [--json] [--verify] Report which component modules are elided and why each shipped one ships;
|
|
94
|
+
--verify diffs SSR output with elision on vs off (exits non-zero on a divergence)
|
|
95
|
+
webjs mcp Start the read-only MCP server (routes / actions / components / elision / check)
|
|
96
|
+
webjs doctor [--json] [--strict] Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision, component elision, un-versioned stylesheet links).
|
|
61
97
|
--json emits the structured results (with stable codes). --strict additionally fails on every remaining warning.
|
|
62
98
|
Per-check severity is CONFIG: map a code to off/warn/error under "webjs": { "doctor": { "gate": {...} } }
|
|
63
99
|
in package.json, so CI gates on a chosen subset without every warning becoming fatal
|
|
@@ -144,6 +180,24 @@ const HELP = {
|
|
|
144
180
|
],
|
|
145
181
|
examples: ['webjs routes', 'webjs routes --table', 'webjs routes --table --no-headers', 'webjs routes --json'],
|
|
146
182
|
},
|
|
183
|
+
elision: {
|
|
184
|
+
usage: 'webjs elision [--json] [--verify] [--routes <paths>]',
|
|
185
|
+
summary:
|
|
186
|
+
'Report the elision verdict: which component modules the browser never downloads, and why each one that ships does.',
|
|
187
|
+
options: [
|
|
188
|
+
{ flag: '--json', description: 'Emit the verdict as JSON (byte-identical to the MCP list_elision tool).' },
|
|
189
|
+
{ flag: '--verify', description: 'Render every static page route with elision on and off and diff the observable SSR bytes. Exits non-zero on a divergence.' },
|
|
190
|
+
{ flag: '--routes <paths>', description: 'Comma-separated URL paths to add to the --verify corpus (the only way to cover a dynamic route).' },
|
|
191
|
+
],
|
|
192
|
+
notesTitle: 'What --verify proves',
|
|
193
|
+
notes: [
|
|
194
|
+
'--verify proves elision did not change the bytes your app serves. It does NOT',
|
|
195
|
+
'prove post-hydration behaviour: a wrongly dropped module shows up as a dead',
|
|
196
|
+
'click, not as different bytes. Run your browser or e2e suite twice',
|
|
197
|
+
'(WEBJS_ELIDE=1 then WEBJS_ELIDE=0) to cover that half.',
|
|
198
|
+
],
|
|
199
|
+
examples: ['webjs elision', 'webjs elision --json', 'webjs elision --verify', 'webjs elision --verify --routes /,/blog/hello'],
|
|
200
|
+
},
|
|
147
201
|
doctor: {
|
|
148
202
|
usage: 'webjs doctor [--json] [--strict]',
|
|
149
203
|
summary: 'Verify project health. Each result carries a stable code so an agent branches on the failure kind.',
|
|
@@ -215,7 +269,7 @@ const HELP = {
|
|
|
215
269
|
},
|
|
216
270
|
mcp: {
|
|
217
271
|
usage: 'webjs mcp',
|
|
218
|
-
summary: 'Start the read-only MCP server (routes / actions / components / check + a docs/source knowledge layer).',
|
|
272
|
+
summary: 'Start the read-only MCP server (routes / actions / components / elision / check + a docs/source knowledge layer).',
|
|
219
273
|
examples: ['webjs mcp'],
|
|
220
274
|
},
|
|
221
275
|
version: {
|
|
@@ -255,9 +309,12 @@ function printCommandHelp(name) {
|
|
|
255
309
|
console.log('Options:');
|
|
256
310
|
for (const o of options) console.log(` ${o.flag.padEnd(width)} ${o.description}`);
|
|
257
311
|
// Optional per-command prose for surface a flag table cannot carry (doctor's
|
|
258
|
-
// package.json severity gate is the one that needs it).
|
|
312
|
+
// package.json severity gate is the one that needs it). The heading defaults
|
|
313
|
+
// to `Config:` because that is what doctor's block is and what its help test
|
|
314
|
+
// pins; a command whose prose is not configuration names its own heading
|
|
315
|
+
// (`webjs elision`'s is a caveat about what --verify proves).
|
|
259
316
|
if (h.notes) {
|
|
260
|
-
console.log('
|
|
317
|
+
console.log(`\n${h.notesTitle || 'Config'}:`);
|
|
261
318
|
for (const line of h.notes) console.log(` ${line}`);
|
|
262
319
|
}
|
|
263
320
|
console.log('\nExamples:');
|
|
@@ -887,6 +944,251 @@ async function main() {
|
|
|
887
944
|
}
|
|
888
945
|
break;
|
|
889
946
|
}
|
|
947
|
+
case 'elision': {
|
|
948
|
+
// Report the elision verdict for the app in cwd (#1308): which component
|
|
949
|
+
// modules the browser never downloads, why each one that ships does,
|
|
950
|
+
// which page/layout is inert / import-only / ships whole, and any orphan
|
|
951
|
+
// class that gets no verdict at all. Reuses the ONE analysis pass
|
|
952
|
+
// (`analyzeAppElision`, the same reporting layer `webjs doctor` and
|
|
953
|
+
// the MCP `list_elision` tool call), so `--json` is byte-identical to
|
|
954
|
+
// that tool. Read-only: no autofix, and nothing is written to disk.
|
|
955
|
+
const appDir = process.cwd();
|
|
956
|
+
|
|
957
|
+
// --verify: the app-level differential. Renders every static page route
|
|
958
|
+
// with elision ON and OFF in this one process and diffs the observable
|
|
959
|
+
// SSR bytes, which is literally the framework's own guard
|
|
960
|
+
// (`packages/server/test/elision/differential-elision.test.js`) pointed
|
|
961
|
+
// at an arbitrary app's route table.
|
|
962
|
+
if (rest.includes('--verify')) {
|
|
963
|
+
const { createRequestHandler, buildRouteTable, maskJsSet, staticPageRoutes } =
|
|
964
|
+
await import('@webjsdev/server');
|
|
965
|
+
|
|
966
|
+
const extra = parseRoutesFlag(rest);
|
|
967
|
+
let table;
|
|
968
|
+
try {
|
|
969
|
+
table = await buildRouteTable(appDir);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
console.error(`webjs elision --verify: cannot read the route table here (${err && err.message}).`);
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
const staticRoutes = staticPageRoutes(table);
|
|
975
|
+
const dynamic = (table.pages || [])
|
|
976
|
+
.filter((r) => r.paramNames && r.paramNames.length)
|
|
977
|
+
.map((r) => (r.routeDir && r.routeDir !== '.' ? '/' + r.routeDir : '/'))
|
|
978
|
+
.sort();
|
|
979
|
+
// An extra route the author named explicitly is REQUIRED to render; a
|
|
980
|
+
// static one that does not is merely reported (a page may legitimately
|
|
981
|
+
// throw notFound() for every visitor).
|
|
982
|
+
const required = new Set(extra);
|
|
983
|
+
const routes = [...new Set([...staticRoutes, ...extra])];
|
|
984
|
+
|
|
985
|
+
// The app's access log would drown the verdict, and a verification
|
|
986
|
+
// run is not a server: keep errors (a real boot failure must surface)
|
|
987
|
+
// and drop the per-request info lines.
|
|
988
|
+
const quiet = { info: () => {}, warn: () => {}, debug: () => {}, error: (...a) => console.error(...a) };
|
|
989
|
+
|
|
990
|
+
const capture = async (h, r) => {
|
|
991
|
+
const resp = await h.handle(new Request('http://localhost' + r));
|
|
992
|
+
return { status: resp.status, html: await resp.text() };
|
|
993
|
+
};
|
|
994
|
+
// The module URLs a response preloads, with the content-hash query
|
|
995
|
+
// stripped. Comparing the two sides' sets is how the run reports what
|
|
996
|
+
// elision actually DROPPED, which is the only thing that distinguishes
|
|
997
|
+
// a real pass from two identical renders.
|
|
998
|
+
const preloadSet = (html) => new Set(
|
|
999
|
+
[...html.matchAll(/<link rel="modulepreload" href="([^"]+)"/g)].map((m) => m[1].split('?')[0]),
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
const ORIG = process.env.WEBJS_ELIDE;
|
|
1003
|
+
/** @type {Record<string, {status:number, html:string}>} */
|
|
1004
|
+
const onA = {}, onB = {}, off = {};
|
|
1005
|
+
try {
|
|
1006
|
+
// Elision ON, FORCED. Deleting the override would only fall back to
|
|
1007
|
+
// `webjs.elide`, so on an app that opts out this side would run with
|
|
1008
|
+
// elision OFF too and the command would compare two identical renders
|
|
1009
|
+
// and report them "identical with elision on vs off", which is false
|
|
1010
|
+
// about a run where elision was never on. The env override wins over
|
|
1011
|
+
// the config key, which is exactly what makes it the right seam here.
|
|
1012
|
+
// Warm fully so the memoized verdict is locked before the env flips
|
|
1013
|
+
// for the second handler.
|
|
1014
|
+
process.env.WEBJS_ELIDE = '1';
|
|
1015
|
+
const hOn = await createRequestHandler({ appDir, dev: false, logger: quiet });
|
|
1016
|
+
if (hOn.warmup) await hOn.warmup();
|
|
1017
|
+
for (const r of routes) onA[r] = await capture(hOn, r);
|
|
1018
|
+
// A SECOND capture through the same warm handler. A route whose two
|
|
1019
|
+
// ON captures already differ is nondeterministic (live data, a random
|
|
1020
|
+
// id, a clock the masker does not normalize), so a differential over
|
|
1021
|
+
// it proves nothing and reporting it as a failure would be a red the
|
|
1022
|
+
// author cannot act on. The framework's own test needs no such pass
|
|
1023
|
+
// because its corpus is fixed and known; an arbitrary app's is not.
|
|
1024
|
+
for (const r of routes) onB[r] = await capture(hOn, r);
|
|
1025
|
+
// Elision OFF via the env override; a fresh handler reads it on its
|
|
1026
|
+
// own first warm.
|
|
1027
|
+
process.env.WEBJS_ELIDE = '0';
|
|
1028
|
+
const hOff = await createRequestHandler({ appDir, dev: false, logger: quiet });
|
|
1029
|
+
if (hOff.warmup) await hOff.warmup();
|
|
1030
|
+
for (const r of routes) off[r] = await capture(hOff, r);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
console.error(`webjs elision --verify: could not boot the app (${err && err.message}).`);
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
} finally {
|
|
1035
|
+
if (ORIG === undefined) delete process.env.WEBJS_ELIDE;
|
|
1036
|
+
else process.env.WEBJS_ELIDE = ORIG;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const unrenderable = [], nondeterministic = [], diverged = [];
|
|
1040
|
+
/** @type {Set<string>} modules the OFF side preloads and the ON side does not */
|
|
1041
|
+
const dropped = new Set();
|
|
1042
|
+
let compared = 0;
|
|
1043
|
+
for (const r of routes) {
|
|
1044
|
+
if (onA[r].status >= 400) { unrenderable.push(`${r} (${onA[r].status})`); continue; }
|
|
1045
|
+
if (maskJsSet(onA[r].html) !== maskJsSet(onB[r].html)) { nondeterministic.push(r); continue; }
|
|
1046
|
+
compared++;
|
|
1047
|
+
// What elision removed on this route. A pass over a corpus where this
|
|
1048
|
+
// stays empty is TRUE but trivially so, and the author needs to see
|
|
1049
|
+
// that rather than read it as proof elision was exercised.
|
|
1050
|
+
const onSet = preloadSet(onA[r].html);
|
|
1051
|
+
for (const u of preloadSet(off[r].html)) if (!onSet.has(u)) dropped.add(u);
|
|
1052
|
+
const a = maskJsSet(onA[r].html);
|
|
1053
|
+
const b = maskJsSet(off[r].html);
|
|
1054
|
+
if (onA[r].status !== off[r].status) {
|
|
1055
|
+
diverged.push({ route: r, kind: 'status', a: String(onA[r].status), b: String(off[r].status), at: 0 });
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (a !== b) {
|
|
1059
|
+
let i = 0;
|
|
1060
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
1061
|
+
diverged.push({
|
|
1062
|
+
route: r, kind: 'body', at: i,
|
|
1063
|
+
a: a.slice(Math.max(0, i - 40), i + 40),
|
|
1064
|
+
b: b.slice(Math.max(0, i - 40), i + 40),
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
for (const d of diverged) {
|
|
1070
|
+
console.error(
|
|
1071
|
+
d.kind === 'status'
|
|
1072
|
+
? `FAIL ${d.route}: status differs with elision on vs off (on=${d.a}, off=${d.b})`
|
|
1073
|
+
: `FAIL ${d.route}: elision changed observable output near offset ${d.at}\n` +
|
|
1074
|
+
` ON : ...${JSON.stringify(d.a)}\n OFF: ...${JSON.stringify(d.b)}`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const badRequired = unrenderable.filter((u) => required.has(u.split(' ')[0]));
|
|
1078
|
+
for (const u of badRequired) console.error(`FAIL ${u}: a --routes path must render`);
|
|
1079
|
+
|
|
1080
|
+
const skips = [
|
|
1081
|
+
dynamic.length ? `${dynamic.length} skipped (dynamic: ${dynamic.join(', ')})` : '0 skipped (dynamic)',
|
|
1082
|
+
`${nondeterministic.length} skipped (nondeterministic${nondeterministic.length ? ': ' + nondeterministic.join(', ') : ''})`,
|
|
1083
|
+
];
|
|
1084
|
+
if (unrenderable.length) skips.push(`${unrenderable.length} skipped (did not render: ${unrenderable.join(', ')})`);
|
|
1085
|
+
|
|
1086
|
+
if (diverged.length || badRequired.length) {
|
|
1087
|
+
console.error(`\nwebjs elision --verify: ${diverged.length} route(s) diverged out of ${compared} compared.`);
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
if (compared === 0) {
|
|
1091
|
+
// A vacuous pass is a failure, the same posture scripts/run-bun-tests.js
|
|
1092
|
+
// takes for a run that executed zero tests.
|
|
1093
|
+
console.error(
|
|
1094
|
+
`webjs elision --verify: nothing was compared (${skips.join(', ')}).\n` +
|
|
1095
|
+
'Pass --routes /a,/b to name renderable paths, or add a static page route.',
|
|
1096
|
+
);
|
|
1097
|
+
process.exit(1);
|
|
1098
|
+
}
|
|
1099
|
+
console.log(
|
|
1100
|
+
`webjs elision --verify: ${compared} route(s) identical with elision on vs off, ${skips.join(', ')}.\n` +
|
|
1101
|
+
(dropped.size
|
|
1102
|
+
? `Elision dropped ${dropped.size} module(s) across that corpus, so the comparison was a real one.`
|
|
1103
|
+
: 'Elision dropped NO modules across that corpus: nothing on these routes was elidable, so '
|
|
1104
|
+
+ 'there was nothing for elision to change. The comparison holds, it just had no work to '
|
|
1105
|
+
+ 'do. Run `WEBJS_ELIDE=1 webjs elision` to see why every module here ships (the same '
|
|
1106
|
+
+ 'override this run used, so the verdict matches even in an app that opts out).'),
|
|
1107
|
+
);
|
|
1108
|
+
console.log(VERIFY_CAVEAT);
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
1113
|
+
const report = await analyzeAppElision(appDir);
|
|
1114
|
+
|
|
1115
|
+
// --json: the machine contract, identical to the MCP `list_elision` shape.
|
|
1116
|
+
if (rest.includes('--json')) {
|
|
1117
|
+
console.log(JSON.stringify(report));
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (!report.analysed) {
|
|
1122
|
+
const why = {
|
|
1123
|
+
'no-app': 'no app/ directory here, so there is nothing to analyse',
|
|
1124
|
+
'elide-off': 'elision is disabled (webjs.elide false, or WEBJS_ELIDE), so every module ships',
|
|
1125
|
+
unanalysable: 'the app could not be analysed (`webjs check` and `webjs dev` name the real problem)',
|
|
1126
|
+
}[report.skipped] || 'nothing was analysed';
|
|
1127
|
+
console.log(`webjs elision: ${why}.`);
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const s = report.summary;
|
|
1132
|
+
console.log(
|
|
1133
|
+
`webjs elision: ${s.components} component module(s), ${s.elided} elided, ${s.shipped} shipped. ` +
|
|
1134
|
+
`${s.routeModules} route module(s): ${s.inert} inert, ${s.importOnly} import-only, ${s.shippedWhole} ship whole.\n`,
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
const elided = report.components.filter((c) => c.verdict === 'elided');
|
|
1138
|
+
const shipped = report.components.filter((c) => c.verdict === 'shipped');
|
|
1139
|
+
// Column width, capped so one outlier does not push every other row off
|
|
1140
|
+
// the terminal. An over-long cell overflows its own row rather than
|
|
1141
|
+
// widening the table. The FILE column gets the generous cap because a
|
|
1142
|
+
// module path is what a reader scans by; the tag column gets the tight
|
|
1143
|
+
// one because a file registering five tags is the rare case.
|
|
1144
|
+
const pad = (rows, col, cap) => Math.min(cap, Math.max(0, ...rows.map((r) => r[col].length)));
|
|
1145
|
+
const FILE_W = 58, TAG_W = 30;
|
|
1146
|
+
|
|
1147
|
+
if (elided.length) {
|
|
1148
|
+
console.log('Elided components (the browser never downloads these)');
|
|
1149
|
+
const rows = elided.map((c) => [c.file, c.tags.join(', ')]);
|
|
1150
|
+
const w = pad(rows, 0, FILE_W);
|
|
1151
|
+
for (const [file, tags] of rows) console.log(` ${file.padEnd(w)} ${tags}`.trimEnd());
|
|
1152
|
+
console.log();
|
|
1153
|
+
}
|
|
1154
|
+
if (shipped.length) {
|
|
1155
|
+
console.log('Shipped components (and the evidence that forced each one)');
|
|
1156
|
+
const rows = shipped.map((c) => [c.file, c.tags.join(', '), `${c.evidence || 'unknown'}: ${c.reason || 'no evidence recorded'}`]);
|
|
1157
|
+
const w0 = pad(rows, 0, FILE_W), w1 = pad(rows, 1, TAG_W);
|
|
1158
|
+
for (const [file, tags, why] of rows) console.log(` ${file.padEnd(w0)} ${tags.padEnd(w1)} ${why}`.trimEnd());
|
|
1159
|
+
console.log();
|
|
1160
|
+
}
|
|
1161
|
+
if (report.routeModules.length) {
|
|
1162
|
+
console.log('Route modules');
|
|
1163
|
+
const rows = report.routeModules.map((r) => [
|
|
1164
|
+
r.verdict, r.file,
|
|
1165
|
+
r.verdict === 'import-only' ? `emits ${r.emits.join(', ') || '(nothing)'}`
|
|
1166
|
+
: r.verdict === 'shipped' ? (r.blocker ? `blocked by ${r.blocker}, which ${r.reason}` : `it ${r.reason}`)
|
|
1167
|
+
: '',
|
|
1168
|
+
]);
|
|
1169
|
+
const w0 = pad(rows, 0, 12), w1 = pad(rows, 1, FILE_W);
|
|
1170
|
+
for (const [verdict, file, note] of rows) {
|
|
1171
|
+
console.log(` ${verdict.padEnd(w0)} ${file.padEnd(w1)}${note ? ' ' + note : ''}`.trimEnd());
|
|
1172
|
+
}
|
|
1173
|
+
console.log();
|
|
1174
|
+
}
|
|
1175
|
+
if (report.orphans.length) {
|
|
1176
|
+
console.log('Orphan components (no elision verdict; `static interactive = true` cannot rescue these)');
|
|
1177
|
+
for (const o of report.orphans) {
|
|
1178
|
+
console.log(` ${o.className} in ${o.file} is never registered with a literal tag`);
|
|
1179
|
+
}
|
|
1180
|
+
console.log(' Either there is no registration call at all, or the tag is computed. The scanner matches');
|
|
1181
|
+
console.log(' only a literal tag, so either way the module gets no verdict, no registry entry, and no');
|
|
1182
|
+
console.log(' preload hint. With no registration call the element never upgrades at all; with a computed');
|
|
1183
|
+
console.log(' tag it upgrades only while its module still reaches the browser through a shipping importer.');
|
|
1184
|
+
console.log(' Fix: register it with a literal tag, Class.register(\'my-tag\'), or delete the class.');
|
|
1185
|
+
console.log();
|
|
1186
|
+
}
|
|
1187
|
+
if (!report.components.length && !report.routeModules.length) {
|
|
1188
|
+
console.log(' Nothing to report. Add a component or a page under app/.');
|
|
1189
|
+
}
|
|
1190
|
+
break;
|
|
1191
|
+
}
|
|
890
1192
|
case 'types': {
|
|
891
1193
|
// Generate `.webjs/routes.d.ts` from the app's `app/` routes (#258),
|
|
892
1194
|
// narrowing the @webjsdev/core `Route` href union + per-route `params`.
|
package/lib/app-name.js
CHANGED
|
@@ -206,3 +206,76 @@ export function assertValidAppName(name) {
|
|
|
206
206
|
}
|
|
207
207
|
return /** @type {string} */ (name);
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* PostgreSQL's hard cap on an identifier, `NAMEDATALEN - 1` bytes. An
|
|
212
|
+
* over-length `CREATE DATABASE` name is silently truncated to this with only a
|
|
213
|
+
* NOTICE, which would reproduce the same name mismatch in a new guise, so the
|
|
214
|
+
* derivation caps it here instead.
|
|
215
|
+
*/
|
|
216
|
+
export const DB_NAME_MAX_LENGTH = 63;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Derive the PostgreSQL database name the scaffold writes into the generated
|
|
220
|
+
* `.env.example` `DATABASE_URL`. The APP NAME itself is never touched by this:
|
|
221
|
+
* the directory, the `package.json` `name`, the `{{APP_NAME}}` substitution and
|
|
222
|
+
* `metadata.title` all keep the name exactly as typed. Only the database
|
|
223
|
+
* segment of that one URL is normalized.
|
|
224
|
+
*
|
|
225
|
+
* The point is a QUOTING-INVARIANT name. A result in `[a-z_][a-z0-9_]*` under
|
|
226
|
+
* 63 bytes folds to itself under `CREATE DATABASE <name>;` AND is passed
|
|
227
|
+
* through unquoted by `createdb` (which builds its statement through `fmtId`),
|
|
228
|
+
* so the emitted URL names the same database whichever route the user takes.
|
|
229
|
+
* A case-preserving name does not have that property: `CREATE DATABASE MyApp;`
|
|
230
|
+
* creates `myapp` while `createdb MyApp` creates `MyApp`.
|
|
231
|
+
*
|
|
232
|
+
* One qualification on that property. A name that folds to a PostgreSQL
|
|
233
|
+
* KEYWORD (`order`, `user`, `table`, `group`, `check`, `window`, `limit`) is
|
|
234
|
+
* still not quoting-invariant: `CREATE DATABASE order;` is a syntax error
|
|
235
|
+
* rather than a fold, while `createdb order` succeeds because `fmtId` quotes a
|
|
236
|
+
* keyword. That is deliberately not detected here. The keyword list is
|
|
237
|
+
* version-dependent and roughly 470 entries, which is disproportionate in a
|
|
238
|
+
* helper whose whole point is being pure and dependency-free, and the failure
|
|
239
|
+
* is a loud syntax error on a placeholder line the user is editing anyway,
|
|
240
|
+
* not the silent wrong-database mismatch this function exists to remove.
|
|
241
|
+
*
|
|
242
|
+
* Three sub-rules, in this order, and the order is load-bearing:
|
|
243
|
+
*
|
|
244
|
+
* 1. Fold with `toLowerCase()`, NOT `toLocaleLowerCase()`. The latter is
|
|
245
|
+
* locale-dependent, so a Turkish-locale machine would fold `I` to the
|
|
246
|
+
* dotless `ı`, which the class below then turns into `_`, making the
|
|
247
|
+
* generated file machine-dependent.
|
|
248
|
+
* 2. Map every remaining character outside `[a-z0-9_]` to `_`, one for one.
|
|
249
|
+
* No run-collapsing, no trimming: both are legal identifier characters,
|
|
250
|
+
* and a 1:1 fold is one a reader can apply by eye. `checkAppName` already
|
|
251
|
+
* restricts the input to `[A-Za-z0-9._-]`, so in practice this only ever
|
|
252
|
+
* rewrites `.` and `-`.
|
|
253
|
+
* 3. Prefix a single `_` when the first character is a digit, BEFORE the
|
|
254
|
+
* slice so the cap governs the final string. `ALLOWED_FIRST_CHAR` admits
|
|
255
|
+
* a leading digit, and an unquoted PostgreSQL identifier may not start
|
|
256
|
+
* with one.
|
|
257
|
+
*
|
|
258
|
+
* Slicing LAST is what makes the byte cap correct: after the fold every
|
|
259
|
+
* surviving character is one ASCII byte, so a code-unit slice is a byte slice,
|
|
260
|
+
* and there is no surrogate pair or percent-escape for it to bisect (every
|
|
261
|
+
* character is unreserved under RFC 3986, so the segment needs no encoding).
|
|
262
|
+
*
|
|
263
|
+
* Precondition: `name` has passed `checkAppName`. `scaffoldApp` asserts that
|
|
264
|
+
* before any file is written. That is what guarantees a non-empty result (a
|
|
265
|
+
* validated name's first character is `[A-Za-z0-9]`, which folds into the
|
|
266
|
+
* class and survives), so there is no empty-result fallback branch here.
|
|
267
|
+
*
|
|
268
|
+
* Collisions are accepted and NOT detected. `My-App` and `my_app` both fold to
|
|
269
|
+
* `my_app`. This is a placeholder in `.env.example`, not a provisioned
|
|
270
|
+
* resource: the scaffold contacts no server and cannot know what exists, and a
|
|
271
|
+
* uniquifying suffix would make the name untraceable to the app name. Rails
|
|
272
|
+
* takes the same position in `railties/lib/rails/generators/app_name.rb`.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} name an app name that has passed `checkAppName`
|
|
275
|
+
* @returns {string} a fold-stable, unquoted-safe PostgreSQL database name
|
|
276
|
+
*/
|
|
277
|
+
export function toDatabaseName(name) {
|
|
278
|
+
const folded = name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
279
|
+
const prefixed = /^[0-9]/.test(folded) ? `_${folded}` : folded;
|
|
280
|
+
return prefixed.slice(0, DB_NAME_MAX_LENGTH);
|
|
281
|
+
}
|
package/lib/create.js
CHANGED
|
@@ -18,7 +18,7 @@ import { existsSync } from 'node:fs';
|
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
19
|
import { spawnSync } from 'node:child_process';
|
|
20
20
|
import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
|
|
21
|
-
import { assertValidAppName } from './app-name.js';
|
|
21
|
+
import { assertValidAppName, toDatabaseName } from './app-name.js';
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Detect which package manager invoked us. Reads `npm_config_user_agent`,
|
|
@@ -541,6 +541,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
541
541
|
{ name: '@webjsdev/intellisense' },
|
|
542
542
|
],
|
|
543
543
|
},
|
|
544
|
+
// `test/**/*` is in so `webjs typecheck` reads the tests you write, the
|
|
545
|
+
// same way Next / Remix / Astro's generated configs do (#1299). A type
|
|
546
|
+
// error in a test is then a gate failure rather than something a reviewer
|
|
547
|
+
// has to catch by eye, and it needs no second config to remember to run.
|
|
548
|
+
//
|
|
544
549
|
// `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
|
|
545
550
|
// run `webjs types` (or `webjs dev`, which emits it) to narrow the
|
|
546
551
|
// @webjsdev/core `Route` href union + per-route `params`. Listed in
|
|
@@ -552,6 +557,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
552
557
|
'components/**/*',
|
|
553
558
|
'modules/**/*',
|
|
554
559
|
'lib/**/*',
|
|
560
|
+
'test/**/*',
|
|
555
561
|
'middleware.js',
|
|
556
562
|
'middleware.ts',
|
|
557
563
|
'.webjs/routes.d.ts',
|
|
@@ -877,9 +883,14 @@ export default defineConfig({
|
|
|
877
883
|
`);
|
|
878
884
|
|
|
879
885
|
// Env vars: append DATABASE_URL to the .env.example the template already
|
|
880
|
-
// copied (if present), idempotently.
|
|
886
|
+
// copied (if present), idempotently. The database segment is the app name
|
|
887
|
+
// normalized to a fold-stable PostgreSQL identifier, so the emitted URL
|
|
888
|
+
// names the same database whether the user runs `createdb` or types
|
|
889
|
+
// `CREATE DATABASE`. Hoisted because the post-scaffold guidance below names
|
|
890
|
+
// the same value, and the two must not be able to drift.
|
|
891
|
+
const dbName = toDatabaseName(name);
|
|
881
892
|
const dbUrlLine = dialect === 'postgres'
|
|
882
|
-
? 'DATABASE_URL=postgres://user:password@localhost:5432/' +
|
|
893
|
+
? 'DATABASE_URL=postgres://user:password@localhost:5432/' + dbName
|
|
883
894
|
: 'DATABASE_URL=file:./db/dev.db';
|
|
884
895
|
const envExample = join(appDir, '.env.example');
|
|
885
896
|
if (existsSync(envExample)) {
|
|
@@ -1596,7 +1607,7 @@ ThemeToggle.register('theme-toggle');
|
|
|
1596
1607
|
// local file with no .env). Point it at a running database; `dev` / `start`
|
|
1597
1608
|
// then apply pending migrations via webjs.*.before.
|
|
1598
1609
|
const pgNote = dialect === 'postgres'
|
|
1599
|
-
? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\n`
|
|
1610
|
+
? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\nThe example URL names the database \`${dbName}\`. Create that database or edit the URL.\n`
|
|
1600
1611
|
: '';
|
|
1601
1612
|
// Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
|
|
1602
1613
|
// `webjs` npm name is owned by an unrelated package; `npx webjs
|