@jskit-ai/jskit-catalog 0.2.30 → 0.2.32
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/catalog/packages.json +260 -255
- package/package.json +3 -2
- package/scripts/jskit.mjs +22 -3
- package/scripts/material3-source.mjs +83 -0
- package/scripts/material3.mjs +61 -16
- package/scripts/navigation.mjs +297 -57
- package/scripts/package-routes.mjs +93 -0
- package/scripts/test-budget.mjs +19 -0
- package/scripts/test-command.mjs +116 -0
- package/scripts/test-runs.mjs +148 -0
- package/scripts/verify-packages.mjs +23 -1
package/scripts/navigation.mjs
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_IMPORT,
|
|
5
|
+
TEMPLATE_TAG,
|
|
6
|
+
VOID_TAGS,
|
|
7
|
+
balancedFrom,
|
|
8
|
+
blankHtmlComments,
|
|
9
|
+
blankJsComments,
|
|
10
|
+
enclosingObject,
|
|
11
|
+
kebab,
|
|
12
|
+
lineIndex,
|
|
13
|
+
localComponent,
|
|
14
|
+
splitVue
|
|
15
|
+
} from "./material3-source.mjs";
|
|
16
|
+
import { readPackageRoutes } from "./package-routes.mjs";
|
|
4
17
|
|
|
5
18
|
// The navigation rules of the JSKIT Material 3 guide (#241). The shell owns Up and the per-tab trail;
|
|
6
19
|
// an app declares each inner page's parent and links through JSKIT navigation, nothing more. Plain `jskit check`
|
|
@@ -13,7 +26,10 @@ const RULES = Object.freeze({
|
|
|
13
26
|
"nav/raw-link": "forbidden-in-app-code",
|
|
14
27
|
"nav/back-control": "forbidden-in-app-code",
|
|
15
28
|
"nav/missing-parent": "what-an-app-declares",
|
|
16
|
-
"nav/top-level-parent": "what-an-app-declares"
|
|
29
|
+
"nav/top-level-parent": "what-an-app-declares",
|
|
30
|
+
"nav/placement-data": "what-an-app-declares",
|
|
31
|
+
"nav/missing-shell-layout": "what-an-app-declares",
|
|
32
|
+
"nav/surface-parent": "what-an-app-declares"
|
|
17
33
|
});
|
|
18
34
|
|
|
19
35
|
const HISTORY_CALL = /(?<![\w$.])(?:(?:window|globalThis)\s*\.\s*)?history\s*\.\s*(?:back|go|forward|pushState|replaceState)\s*\(/gu;
|
|
@@ -60,6 +76,7 @@ const MAIN_NAV_TARGET = /\btarget\s*:\s*["']shell\.(?:primary|secondary)-nav["']
|
|
|
60
76
|
// draws its own bar renders shell-web's Up control in it.
|
|
61
77
|
const SHELL_LAYOUT_IMPORT = /\bfrom\s*["'](?:@\/components\/ShellLayout|@jskit-ai\/shell-web\/client\/components\/ShellLayout)(?:\.vue)?["']/u;
|
|
62
78
|
const SHELL_UP_TAG = /<\s*(?:ShellUpButton|shell-up-button)\b/u;
|
|
79
|
+
const SHELL_TITLE_TAG = /<\s*(?:ShellPageTitle|shell-page-title)\b/u;
|
|
63
80
|
const LOCAL_IMPORT = /\bfrom\s*["']((?:@\/|\.{1,2}\/)[^"']+)["']/gu;
|
|
64
81
|
const ROUTER_VIEW_TAG = /<\s*(?:RouterView|router-view)\b/u;
|
|
65
82
|
|
|
@@ -101,12 +118,17 @@ function isPlacementLink(code, offset) {
|
|
|
101
118
|
return Boolean(placement) && /\btarget\s*:\s*["'`][\w.-]+["'`]/u.test(placement.text) && /\bkind\s*:/u.test(placement.text);
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
// A back-named function that navigates the app
|
|
121
|
+
// A back-named function that navigates the app, and the import of a back component (ContextBackButton); a variable,
|
|
122
|
+
// a composable's import or an in-component step is not back logic.
|
|
105
123
|
function checkScriptBackNames(code, offset, report) {
|
|
106
124
|
for (const match of code.matchAll(BACK_DEFINITION)) {
|
|
107
125
|
if (!navigatesApp(definitionOf(code, match[1]), code)) continue;
|
|
108
126
|
report("nav/back-control", offset + match.index, `${match[1]} navigates back in the app. ${UP_OWNER}; delete it and declare the page's parent.`);
|
|
109
127
|
}
|
|
128
|
+
for (const match of code.matchAll(DEFAULT_IMPORT)) {
|
|
129
|
+
if (!/^[A-Z]/u.test(match[1]) || !isBackComponentName(match[1])) continue;
|
|
130
|
+
report("nav/back-control", offset + match.index, `${match[1]} is a back component. ${UP_OWNER}; remove it and declare the page's parent.`);
|
|
131
|
+
}
|
|
110
132
|
}
|
|
111
133
|
|
|
112
134
|
// The text of a local function's definition: function name(...) { ... }, or const name = ... to its end.
|
|
@@ -171,9 +193,13 @@ function visibleText(inner) {
|
|
|
171
193
|
return inner.replace(/<[^>]*>/gu, " ").replace(/\{\{[\s\S]*?\}\}/gu, " ").replace(/\s+/gu, " ").trim();
|
|
172
194
|
}
|
|
173
195
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
// A back component, in either case (tags are read in kebab case): ContextBackButton, <page-back-link>, GoBack,
|
|
197
|
+
// BackToList. Not Vuetify's own tags, a back office, or a back-to-top control, which scrolls the page.
|
|
198
|
+
const BACK_COMPONENT = /(?:^|-)back(?:$|-(?:action|arrow|btn|button|chevron|control|icon|link|nav|navigation)(?:-|$)|-to-(?!top(?:-|$)))/u;
|
|
199
|
+
|
|
200
|
+
function isBackComponentName(name) {
|
|
201
|
+
const tag = kebab(name);
|
|
202
|
+
return !tag.startsWith("v-") && BACK_COMPONENT.test(tag);
|
|
177
203
|
}
|
|
178
204
|
|
|
179
205
|
function looksLikeBack(tag) {
|
|
@@ -197,11 +223,12 @@ function checkBackControl(tag, report, script = "") {
|
|
|
197
223
|
report("nav/back-control", tag.offset, "Breadcrumbs navigate back up the hierarchy. The shell's Up arrow does that; the app bar title says where the person is.");
|
|
198
224
|
return;
|
|
199
225
|
}
|
|
200
|
-
|
|
201
|
-
if (
|
|
226
|
+
// A back component navigates by itself, so its tag needs no link or handler to be one.
|
|
227
|
+
if (isBackComponentName(tag.name)) {
|
|
202
228
|
report("nav/back-control", tag.offset, `<${tag.name}> is a back control. ${UP_OWNER}; remove it and declare the page's parent.`);
|
|
203
229
|
return;
|
|
204
230
|
}
|
|
231
|
+
if (!navigatesFromControl(tag, script)) return;
|
|
205
232
|
const handler = tag.attributes.find((entry) => entry.event && BACK_IDENTIFIER.test(entry.value));
|
|
206
233
|
if (handler) {
|
|
207
234
|
report("nav/back-control", handler.offset, `${handler.raw}="${handler.value.trim()}" is a back handler. ${UP_OWNER}; remove the control and declare the page's parent.`);
|
|
@@ -220,48 +247,53 @@ function checkLinkAttribute(tag, entry, report) {
|
|
|
220
247
|
}
|
|
221
248
|
}
|
|
222
249
|
|
|
223
|
-
// The value of the first balanced { ... } or [ ... ] starting at or after `start`.
|
|
224
|
-
function balancedFrom(text, start, open = "{", close = "}") {
|
|
225
|
-
const first = text.indexOf(open, start);
|
|
226
|
-
if (first < 0) return null;
|
|
227
|
-
let depth = 0;
|
|
228
|
-
for (let index = first; index < text.length; index += 1) {
|
|
229
|
-
if (text[index] === open) depth += 1;
|
|
230
|
-
else if (text[index] === close && --depth === 0) return { start: first, end: index + 1, text: text.slice(first, index + 1) };
|
|
231
|
-
}
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// The object literal that encloses `offset`, as { start, end, text }, or null.
|
|
236
|
-
function enclosingObject(text, offset) {
|
|
237
|
-
let depth = 0;
|
|
238
|
-
for (let index = offset; index >= 0; index -= 1) {
|
|
239
|
-
if (text[index] === "}") depth += 1;
|
|
240
|
-
else if (text[index] === "{") {
|
|
241
|
-
if (depth === 0) return balancedFrom(text, index);
|
|
242
|
-
depth -= 1;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
250
|
const stringValue = (text, key) => new RegExp(`\\b${key}\\s*:\\s*(["'\\u0060])([^"'\\u0060]*)\\1`, "u").exec(text)?.[2];
|
|
249
251
|
|
|
250
|
-
// Main-navigation destinations, by surface: every main-navigation placement's suffixes
|
|
252
|
+
// Main-navigation destinations, by surface: every main-navigation placement's suffixes, and its address when it has
|
|
253
|
+
// one (`to`). A link that names its surface (props.surface) leads to that surface's page wherever it shows, so it is a
|
|
254
|
+
// destination of that surface only; one that names none leads to the page on each surface it shows on. A placement is
|
|
255
|
+
// literal data; one whose suffixes, address or surface are computed (built by a helper, spread or interpolated) can't
|
|
256
|
+
// be read, and is returned as unreadable.
|
|
251
257
|
function readMainNavigation(sources) {
|
|
252
258
|
const destinations = [];
|
|
253
|
-
|
|
259
|
+
const unreadable = [];
|
|
260
|
+
for (const { file, source } of sources) {
|
|
254
261
|
for (const match of source.matchAll(MAIN_NAV_TARGET)) {
|
|
255
262
|
const placement = enclosingObject(source, match.index)?.text || "";
|
|
256
|
-
const
|
|
257
|
-
|
|
263
|
+
const computed = computedPlacementPart(placement);
|
|
264
|
+
if (computed) {
|
|
265
|
+
unreadable.push({ file, source, offset: match.index, computed });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const surface = stringValue(placement, "surface");
|
|
269
|
+
const surfaces = surface ? [surface] : (/\bsurfaces\s*:\s*\[([^\]]*)\]/u.exec(placement)?.[1] || "")
|
|
270
|
+
.split(",").map((entry) => entry.trim().replace(/^["'`]|["'`]$/gu, "")).filter(Boolean);
|
|
258
271
|
for (const key of ["scopedSuffix", "unscopedSuffix"]) {
|
|
259
272
|
const suffix = stringValue(placement, key);
|
|
260
273
|
if (suffix !== undefined) destinations.push({ suffix: normalisePath(suffix.split(/[?#]/u)[0]), surfaces });
|
|
261
274
|
}
|
|
275
|
+
// A link with an address of its own (`to: "/account"`, a package's global route) leads to that path.
|
|
276
|
+
const to = stringValue(placement, "to");
|
|
277
|
+
if (to?.startsWith("/")) destinations.push({ to: normalisePath(to.split(/[?#]/u)[0]), surfaces });
|
|
262
278
|
}
|
|
263
279
|
}
|
|
264
|
-
return destinations;
|
|
280
|
+
return { destinations, unreadable };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// What makes a placement computed rather than literal data, or "": a spread, props that aren't an object literal, or
|
|
284
|
+
// a link key that isn't a plain string (or a list of strings, for surfaces), such as a variable, a call or an
|
|
285
|
+
// interpolated template.
|
|
286
|
+
function computedPlacementPart(placement) {
|
|
287
|
+
if (/[{,]\s*\.\.\./u.test(placement)) return "it spreads another object";
|
|
288
|
+
if (/(?<=[{,]\s*)props\s*:(?!\s*\{)/u.test(placement)) return "its props are computed";
|
|
289
|
+
for (const key of ["scopedSuffix", "unscopedSuffix", "surface", "to"]) {
|
|
290
|
+
// The key as a property, `key: value` or shorthand `key`, whose value is not a quoted string without ${}.
|
|
291
|
+
const property = new RegExp(`(?<=[{,]\\s*)${key}\\s*(?::(?!\\s*(["'\\u0060])[^"'\\u0060$]*\\1)|(?=[,}]))`, "u");
|
|
292
|
+
if (property.test(placement)) return `its ${key} is computed`;
|
|
293
|
+
}
|
|
294
|
+
const surfaces = /(?<=[{,]\s*)surfaces\s*:\s*(\[[^\]]*\])?/u.exec(placement);
|
|
295
|
+
if (surfaces && !/^\[\s*(?:(["'`])[^"'`$]*\1\s*,?\s*)*\]$/u.test(surfaces[1] || "")) return "its surfaces are computed";
|
|
296
|
+
return "";
|
|
265
297
|
}
|
|
266
298
|
|
|
267
299
|
function normalisePath(value) {
|
|
@@ -328,20 +360,21 @@ function readPage(file, source) {
|
|
|
328
360
|
redirect: REDIRECT_KEY.test(meta),
|
|
329
361
|
parent: declarations.find(({ text }) => PARENT_KEY.test(text)),
|
|
330
362
|
surfaceRoot: SURFACE_KEY.test(meta.replace(PARENT_OBJECT, "")),
|
|
331
|
-
shell:
|
|
363
|
+
shell: "none"
|
|
332
364
|
};
|
|
333
365
|
}
|
|
334
366
|
|
|
335
367
|
// Whether a component draws the shell's top app bar, and with it Up: it, or a component it imports from the app,
|
|
336
|
-
// renders ShellLayout (directly or through the app's wrapper) or shell-web's ShellUpButton in a bar of its own
|
|
337
|
-
|
|
368
|
+
// renders ShellLayout (directly or through the app's wrapper) or shell-web's ShellUpButton in a bar of its own (or,
|
|
369
|
+
// given SHELL_TITLE_TAG, the bar's page title).
|
|
370
|
+
function createShellBarReader(appFiles, read, tag = SHELL_UP_TAG) {
|
|
338
371
|
const known = new Set(appFiles);
|
|
339
372
|
const verdicts = new Map();
|
|
340
373
|
async function drawsShellBar(file) {
|
|
341
374
|
if (verdicts.has(file)) return verdicts.get(file);
|
|
342
375
|
verdicts.set(file, false);
|
|
343
376
|
const source = await read(file);
|
|
344
|
-
let verdict = SHELL_LAYOUT_IMPORT.test(source) ||
|
|
377
|
+
let verdict = SHELL_LAYOUT_IMPORT.test(source) || tag.test(splitVue(source).template?.text || "");
|
|
345
378
|
for (const match of source.matchAll(LOCAL_IMPORT)) {
|
|
346
379
|
if (verdict) break;
|
|
347
380
|
const imported = localComponent(file, match[1], known);
|
|
@@ -353,12 +386,142 @@ function createShellBarReader(appFiles, read) {
|
|
|
353
386
|
return drawsShellBar;
|
|
354
387
|
}
|
|
355
388
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
389
|
+
const SHELL_LAYOUT_SPECIFIER = /^(?:@\/components\/ShellLayout|@jskit-ai\/shell-web\/client\/components\/ShellLayout)(?:\.vue)?$/u;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Where a layout renders its pages: "all" under the shell's top app bar (every <RouterView /> inside ShellLayout or
|
|
393
|
+
* an app component that draws the bar, or a bar drawn beside them: ShellUpButton, or such a component around no
|
|
394
|
+
* RouterView), "some" when a branch renders them without it (a shop device's sign-in in a v-if beside the
|
|
395
|
+
* ShellLayout), or "none".
|
|
396
|
+
* @param {string} file
|
|
397
|
+
* @param {string} source
|
|
398
|
+
* @param {(file: string) => Promise<boolean>} drawsShellBar
|
|
399
|
+
* @param {Set<string>} known The app's files.
|
|
400
|
+
* @param {RegExp} [barTag] The shell-web piece that marks a bar of the layout's own: Up, or, for the page title,
|
|
401
|
+
* ShellPageTitle (with drawsShellBar then reading the title too).
|
|
402
|
+
*/
|
|
403
|
+
async function shellPlacement(file, source, drawsShellBar, known, barTag = SHELL_UP_TAG) {
|
|
404
|
+
const { template, scripts } = splitVue(source);
|
|
405
|
+
const text = blankHtmlComments(template?.text || "");
|
|
406
|
+
if (barTag.test(text)) return "all";
|
|
407
|
+
const shellTags = new Set();
|
|
408
|
+
for (const block of scripts) {
|
|
409
|
+
for (const [, name, specifier] of blankJsComments(block.text).matchAll(DEFAULT_IMPORT)) {
|
|
410
|
+
const imported = localComponent(file, specifier, known);
|
|
411
|
+
if (SHELL_LAYOUT_SPECIFIER.test(specifier) || (imported && await drawsShellBar(imported))) shellTags.add(kebab(name));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const open = [];
|
|
415
|
+
let inside = 0;
|
|
416
|
+
let outside = 0;
|
|
417
|
+
let barBeside = false;
|
|
418
|
+
for (const [, closing, rawName, , selfClosing] of text.matchAll(TEMPLATE_TAG)) {
|
|
419
|
+
const name = kebab(rawName);
|
|
420
|
+
if (closing) {
|
|
421
|
+
const index = open.findLastIndex((entry) => entry.name === name);
|
|
422
|
+
if (index < 0) continue;
|
|
423
|
+
barBeside ||= open.slice(index).some((entry) => entry.shell && !entry.view);
|
|
424
|
+
open.length = index;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const shell = shellTags.has(name);
|
|
428
|
+
if (name === "router-view") {
|
|
429
|
+
const around = open.filter((entry) => entry.shell);
|
|
430
|
+
for (const entry of around) entry.view = true;
|
|
431
|
+
if (around.length > 0) inside += 1;
|
|
432
|
+
else outside += 1;
|
|
433
|
+
}
|
|
434
|
+
if (selfClosing || VOID_TAGS.has(name)) barBeside ||= shell;
|
|
435
|
+
else open.push({ name, shell, view: false });
|
|
436
|
+
}
|
|
437
|
+
if (barBeside) return "all";
|
|
438
|
+
if (inside === 0) return "none";
|
|
439
|
+
return outside === 0 ? "all" : "some";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The app's .vue files that render under the shell's page title, where an in-page h1 would be a second one: a file
|
|
444
|
+
* that shows the title (ShellLayout's bar, or ShellPageTitle in a bar of its own), a page a layout always renders
|
|
445
|
+
* under it, and a component such a file imports. A component no .vue file imports (routed or placed from JavaScript) counts
|
|
446
|
+
* too, as where it renders can't be read, unless JavaScript hands it to the shell to render outside every layout (the
|
|
447
|
+
* router's notFoundComponent or fallbackRoute, the app's rootComponent) or it is a package route's page, which the
|
|
448
|
+
* kernel adds at the top level: that one counts only as App.vue does, which counts only when it shows the title itself.
|
|
449
|
+
* @param {{ root: string, appFiles: string[] }} options
|
|
450
|
+
*/
|
|
451
|
+
async function readTitledFiles({ root, appFiles }) {
|
|
452
|
+
const sources = new Map();
|
|
453
|
+
const read = async (file) => {
|
|
454
|
+
if (!sources.has(file)) sources.set(file, await readFile(path.join(root, file), "utf8"));
|
|
455
|
+
return sources.get(file);
|
|
456
|
+
};
|
|
457
|
+
const known = new Set(appFiles);
|
|
458
|
+
const vueFiles = appFiles.filter((file) => file.endsWith(".vue"));
|
|
459
|
+
const showsTitle = createShellBarReader(appFiles, read, SHELL_TITLE_TAG);
|
|
460
|
+
const importers = new Map();
|
|
461
|
+
for (const file of vueFiles) {
|
|
462
|
+
for (const match of (await read(file)).matchAll(LOCAL_IMPORT)) {
|
|
463
|
+
const imported = localComponent(file, match[1], known);
|
|
464
|
+
if (imported) importers.set(imported, [...(importers.get(imported) || []), file]);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Whether each component JavaScript imports is handed to the shell only to render outside every layout.
|
|
468
|
+
const outsideLayouts = new Map();
|
|
469
|
+
for (const file of appFiles.filter((entry) => /\.(?:[cm]?[jt]s)$/u.test(entry))) {
|
|
470
|
+
const code = blankJsComments(await read(file));
|
|
471
|
+
for (const [, name, specifier] of code.matchAll(DEFAULT_IMPORT)) {
|
|
472
|
+
const imported = localComponent(file, specifier, known);
|
|
473
|
+
if (!imported) continue;
|
|
474
|
+
const outside = rendersOutsideLayouts(code, name);
|
|
475
|
+
outsideLayouts.set(imported, (outsideLayouts.get(imported) ?? true) && outside);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const packagePages = new Set((await readPackageRoutes(root, known)).map((route) => route.file));
|
|
479
|
+
const verdicts = new Map();
|
|
480
|
+
async function titled(file) {
|
|
481
|
+
if (verdicts.has(file)) return verdicts.get(file);
|
|
482
|
+
verdicts.set(file, false);
|
|
483
|
+
let verdict = await showsTitle(file);
|
|
484
|
+
if (!verdict && isPageFile(file)) {
|
|
485
|
+
// The layouts that render the page: src/pages/home/settings.vue renders home/settings/*.
|
|
486
|
+
const segments = pageKey(file).split("/");
|
|
487
|
+
for (let length = segments.length - 1; length > 0 && !verdict; length -= 1) {
|
|
488
|
+
const layout = `src/pages/${segments.slice(0, length).join("/")}.vue`;
|
|
489
|
+
// A layout that renders its pages without the title on one branch (a shop device's sign-in in a bare v-main
|
|
490
|
+
// beside the ShellLayout) leaves them unjudged, as which branch shows a page is decided at runtime.
|
|
491
|
+
if (known.has(layout) && ROUTER_VIEW_TAG.test(splitVue(await read(layout)).template?.text || "")) {
|
|
492
|
+
verdict = await shellPlacement(layout, await read(layout), showsTitle, known, SHELL_TITLE_TAG) === "all";
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} else if (!verdict && path.posix.basename(file) !== "App.vue") {
|
|
496
|
+
const users = importers.get(file) || [];
|
|
497
|
+
verdict = users.length === 0;
|
|
498
|
+
if (verdict && (outsideLayouts.get(file) === true || packagePages.has(file))) {
|
|
499
|
+
verdict = known.has("src/App.vue") && await showsTitle("src/App.vue");
|
|
500
|
+
}
|
|
501
|
+
for (const user of users) {
|
|
502
|
+
if (verdict) break;
|
|
503
|
+
verdict = await titled(user);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
verdicts.set(file, verdict);
|
|
507
|
+
return verdict;
|
|
508
|
+
}
|
|
509
|
+
const result = new Set();
|
|
510
|
+
for (const file of vueFiles) if (await titled(file)) result.add(file);
|
|
511
|
+
return result;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// A component JavaScript gives the shell to render outside every layout: the router's notFoundComponent or
|
|
515
|
+
// fallbackRoute component, or the app's rootComponent.
|
|
516
|
+
function rendersOutsideLayouts(code, name) {
|
|
517
|
+
const escaped = name.replaceAll("$", "\\$");
|
|
518
|
+
const named = String.raw`(?<![\w$.])${escaped}(?![\w$])`;
|
|
519
|
+
const handed = new RegExp(String.raw`\b(?:notFoundComponent|rootComponent)\s*:\s*${named}|\bcreateFallbackNotFoundRoute\s*\(\s*${named}|\bfallbackRoute\s*:\s*\{[^{}]*\bcomponent\s*:\s*${named}`, "gu");
|
|
520
|
+
// Every use after the import (whose specifier may spell the name too) is a hand-over, or it may render anywhere.
|
|
521
|
+
const body = code.replace(new RegExp(String.raw`\bimport\s+${escaped}\s+from\s*["'][^"']+["']`, "gu"), "");
|
|
522
|
+
const uses = [...body.matchAll(new RegExp(named, "gu"))].length;
|
|
523
|
+
const handovers = [...body.matchAll(handed)].length;
|
|
524
|
+
return handovers > 0 && handovers === uses;
|
|
362
525
|
}
|
|
363
526
|
|
|
364
527
|
/**
|
|
@@ -378,29 +541,56 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
378
541
|
const pages = new Map();
|
|
379
542
|
for (const file of appFiles.filter(isPageFile)) pages.set(pageKey(file), readPage(file, await read(file)));
|
|
380
543
|
const drawsShellBar = createShellBarReader(appFiles, read);
|
|
544
|
+
const known = new Set(appFiles);
|
|
381
545
|
for (const page of pages.values()) {
|
|
382
|
-
if (page.layout) page.shell = await
|
|
546
|
+
if (page.layout) page.shell = await shellPlacement(page.file, page.source, drawsShellBar, known);
|
|
383
547
|
}
|
|
384
548
|
const surfaces = readSurfaces(configSource, [...pages.values()].map(({ file }) => routeOf(file)));
|
|
385
549
|
const scripts = appFiles.filter((file) => /\.(?:[cm]?[jt]s|vue)$/u.test(file));
|
|
386
|
-
const navigation = readMainNavigation(await Promise.all(scripts.map(async (file) => blankJsComments(await read(file)))));
|
|
550
|
+
const navigation = readMainNavigation(await Promise.all(scripts.map(async (file) => ({ file, source: blankJsComments(await read(file)) }))));
|
|
551
|
+
for (const { file, source, offset, computed } of navigation.unreadable) {
|
|
552
|
+
if (!pageFiles.includes(file)) continue;
|
|
553
|
+
report({ rule: "nav/placement-data", file, ...lineIndex(source)(offset), message: `A main-navigation placement that isn't literal data: ${computed}. Declare each placement literally, with plain string suffixes and surface, so its destination can be read; until then no page is judged for nav/missing-parent.` });
|
|
554
|
+
}
|
|
387
555
|
const surfacePath = (file, surface) => normalisePath(routePattern(routeOf(file).slice(surface.pagesRoot.length)));
|
|
388
|
-
const
|
|
389
|
-
|
|
556
|
+
const addressOf = (relative, surface) => normalisePath(routePattern(`${surface.pagesRoot}${relative}`));
|
|
557
|
+
const shownOn = (owners, surface) => owners.length === 0 || owners.includes(surface.id) || owners.includes("*");
|
|
558
|
+
// A destination by its suffix on the surface, or by its address (`to`).
|
|
559
|
+
const isDestination = (relative, surface) => navigation.destinations.some(({ suffix, to, surfaces: owners }) =>
|
|
560
|
+
to === undefined ? suffix === relative && shownOn(owners, surface) : to === addressOf(relative, surface));
|
|
561
|
+
// A global route's address is a destination when a link's `to` names it, or a suffix leads there on a surface.
|
|
562
|
+
const isAddress = (address) => navigation.destinations.some(({ suffix, to, surfaces: owners }) =>
|
|
563
|
+
to === undefined ? surfaces.some((surface) => shownOn(owners, surface) && addressOf(suffix, surface) === address) : to === address);
|
|
564
|
+
// An App.vue that wraps every page in the shell's bar leaves no page outside it.
|
|
565
|
+
const appShell = appFiles.includes("src/App.vue") && await drawsShellBar("src/App.vue");
|
|
390
566
|
const judged = [];
|
|
391
567
|
for (const file of pageFiles.filter(isPageFile)) {
|
|
392
568
|
const page = pages.get(pageKey(file));
|
|
393
569
|
const surface = surfaceOf(routeOf(file), surfaces);
|
|
394
570
|
if (!surface || page.redirect) continue;
|
|
571
|
+
// The record that carries jskit.surface is the surface root, where Up stops, so a parent beside it is never read.
|
|
572
|
+
if (page.surfaceRoot && page.parent) {
|
|
573
|
+
const at = lineIndex(page.source)(page.parent.offset + PARENT_KEY.exec(page.parent.text).index);
|
|
574
|
+
const fix = page.layout ? "move jskit.parent to the page this layout renders" : "remove jskit.surface and keep jskit.parent";
|
|
575
|
+
report({ rule: "nav/surface-parent", file, ...at, message: `${path.posix.basename(file)} declares jskit.parent beside jskit.surface, so the shell never reads it and the page shows no Up: the record with the surface is the surface root, where Up stops. Declare the surface only on the surface's layout page (ShellLayout around <RouterView />) and the parent on the page it renders; here, ${fix}.` });
|
|
576
|
+
}
|
|
395
577
|
// The layouts that render this page, nearest first: src/pages/home/settings.vue renders home/settings/*.
|
|
396
578
|
const segments = pageKey(file).split("/");
|
|
397
579
|
const layouts = segments.slice(1).map((_, index) => pages.get(segments.slice(0, segments.length - 1 - index).join("/")))
|
|
398
580
|
.filter((entry) => entry?.layout);
|
|
399
|
-
// Up lives in the shell's top app bar, so only pages a shell layout renders have one.
|
|
400
|
-
if (!layouts.some((entry) => entry.shell)) continue;
|
|
401
581
|
const relative = surfacePath(file, surface);
|
|
402
|
-
const destination = isDestination(relative, surface);
|
|
403
582
|
const position = lineIndex(page.source);
|
|
583
|
+
// Up lives in the shell's top app bar, so only pages a shell layout renders have one. A page that declares a
|
|
584
|
+
// parent expects that bar, with its title, Up and page actions; under no shell layout it shows none of them.
|
|
585
|
+
if (!layouts.some((entry) => entry.shell !== "none")) {
|
|
586
|
+
if (page.parent && !appShell && !await drawsShellBar(file)) {
|
|
587
|
+
const at = position(page.parent.offset + PARENT_KEY.exec(page.parent.text).index);
|
|
588
|
+
const layout = surface.pagesRoot ? `, src/pages/${surface.pagesRoot}.vue` : "";
|
|
589
|
+
report({ rule: "nav/missing-shell-layout", file, ...at, message: `${relative} of ${surface.id} declares a parent, but no layout that draws the shell's top app bar renders it, so its title, Up and page actions never show. Give the surface its layout${layout}: ShellLayout around <RouterView />, with meta.jskit.surface "${surface.id}".` });
|
|
590
|
+
}
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const destination = isDestination(relative, surface);
|
|
404
594
|
if (destination && page.parent) {
|
|
405
595
|
const at = position(page.parent.offset + PARENT_KEY.exec(page.parent.text).index);
|
|
406
596
|
report({ rule: "nav/top-level-parent", file, ...at, message: `${relative} is a main-navigation destination of ${surface.id}, so it declares no parent: choosing it starts a fresh trail.` });
|
|
@@ -415,14 +605,63 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
415
605
|
const inherited = chain.find((entry) => entry.parent);
|
|
416
606
|
const topLevel = destination || relative === "/" || chain.some((entry) => isDestination(surfacePath(entry.file, surface), surface));
|
|
417
607
|
judged.push({ file, route: relative, surface: surface.id, topLevel, parent: page.parent ? "own" : inherited ? "inherited" : null });
|
|
418
|
-
|
|
608
|
+
// A layout that also renders its pages without the shell (a shop device's sign-in) leaves which of them show Up
|
|
609
|
+
// unreadable, so only a page that always renders under the bar needs a parent.
|
|
610
|
+
const alwaysInShell = layouts.some((entry) => entry.shell === "all");
|
|
611
|
+
if (!topLevel && !page.parent && !inherited && alwaysInShell && navigation.unreadable.length === 0) {
|
|
419
612
|
const at = position(page.declarations[0]?.offset ?? 0);
|
|
420
613
|
report({ rule: "nav/missing-parent", file, ...at, message: `Inner page ${relative} of ${surface.id} has no parent. Declare it once in the page's route meta (or its layout's, for tabs), so Up works after a direct entry.` });
|
|
421
614
|
}
|
|
422
615
|
}
|
|
616
|
+
for (const route of await readPackageRoutes(root, known)) {
|
|
617
|
+
if (!pageFiles.includes(route.file) && !pageFiles.includes(route.manifest)) continue;
|
|
618
|
+
const verdict = await judgePackageRoute(route, { surfaces, appShell, drawsShellBar, isDestination, isAddress, readable: navigation.unreadable.length === 0, report });
|
|
619
|
+
if (verdict) judged.push(verdict);
|
|
620
|
+
}
|
|
423
621
|
return judged;
|
|
424
622
|
}
|
|
425
623
|
|
|
624
|
+
/**
|
|
625
|
+
* The page rules for a package's route, which the kernel adds at the top level: under the shell's bar only when its
|
|
626
|
+
* component (or App.vue) draws it, with the parent its package.json declares in meta.jskit.parent. Findings point at
|
|
627
|
+
* the route's entry in package.json.
|
|
628
|
+
*/
|
|
629
|
+
async function judgePackageRoute(route, { surfaces, appShell, drawsShellBar, isDestination, isAddress, readable, report }) {
|
|
630
|
+
const position = lineIndex(route.source);
|
|
631
|
+
const at = (offset) => ({ file: route.manifest, ...position(offset) });
|
|
632
|
+
const address = normalisePath(route.path);
|
|
633
|
+
const name = `${address} (route "${route.id}" of ${route.manifest})`;
|
|
634
|
+
// A surface route's path starts at its surface's base: /app/manage/:view is /manage/:view of app.
|
|
635
|
+
let surface = { id: "", pagesRoot: "" };
|
|
636
|
+
let relative = address;
|
|
637
|
+
if (route.scope === "surface") {
|
|
638
|
+
surface = surfaces.find(({ id }) => id === route.surface);
|
|
639
|
+
if (!surface) return null;
|
|
640
|
+
const base = normalisePath(routePattern(surface.pagesRoot));
|
|
641
|
+
if (base !== "/" && address !== base && !address.startsWith(`${base}/`)) return null;
|
|
642
|
+
relative = normalisePath(address.slice(base === "/" ? 0 : base.length));
|
|
643
|
+
}
|
|
644
|
+
const where = surface.id ? ` of ${surface.id}` : "";
|
|
645
|
+
if (route.surfaceRoot && route.parent) {
|
|
646
|
+
report({ rule: "nav/surface-parent", ...at(route.parentOffset), message: `${name} declares jskit.parent beside jskit.surface, so the shell never reads it and the page shows no Up: the record with the surface is the surface root, where Up stops. Remove meta.jskit.surface; the route's own "surface" chooses where it is served.` });
|
|
647
|
+
}
|
|
648
|
+
if (!appShell && !await drawsShellBar(route.file)) {
|
|
649
|
+
if (route.parent) {
|
|
650
|
+
report({ rule: "nav/missing-shell-layout", ...at(route.parentOffset), message: `${name} declares a parent, but its component, ${route.file}, doesn't draw the shell's top app bar, so its title, Up and page actions never show. A package's route renders at the top level, under no layout: render ShellLayout around the page.` });
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
const destination = route.scope === "surface" ? isDestination(relative, surface) : isAddress(address);
|
|
655
|
+
if (destination && route.parent) {
|
|
656
|
+
report({ rule: "nav/top-level-parent", ...at(route.parentOffset), message: `${name} is a main-navigation destination${where}, so it declares no parent: choosing it starts a fresh trail.` });
|
|
657
|
+
}
|
|
658
|
+
const topLevel = destination || relative === "/";
|
|
659
|
+
if (!topLevel && !route.parent && readable) {
|
|
660
|
+
report({ rule: "nav/missing-parent", ...at(route.offset), message: `Inner page ${name}${where} has no parent. Declare it in the route's meta, "meta": { "jskit": { "parent": ... } }, so Up works after a direct entry.` });
|
|
661
|
+
}
|
|
662
|
+
return { file: route.file, route: relative, surface: surface.id || "global", topLevel, parent: route.parent ? "own" : null };
|
|
663
|
+
}
|
|
664
|
+
|
|
426
665
|
// src/pages/home/suppliers.vue is home/suppliers and src/pages/home/suppliers/index.vue is home/suppliers/index.
|
|
427
666
|
function pageKey(file) {
|
|
428
667
|
return file.replace(/^src\/pages\//u, "").replace(/\.vue$/u, "");
|
|
@@ -441,5 +680,6 @@ export {
|
|
|
441
680
|
checkPageParents,
|
|
442
681
|
checkScriptBackNames,
|
|
443
682
|
isInAppPath,
|
|
444
|
-
isPageFile
|
|
683
|
+
isPageFile,
|
|
684
|
+
readTitledFiles
|
|
445
685
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The pages an app's own packages give the router, for the navigation rules: each package.json route in
|
|
2
|
+
// jskit.metadata.ui.routes with the component its client entry's routeComponents names.
|
|
3
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { DEFAULT_IMPORT, balancedFrom, blankJsComments, enclosingObject, localComponent } from "./material3-source.mjs";
|
|
6
|
+
|
|
7
|
+
const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
const ROUTE_COMPONENTS = /\brouteComponents\s*=\s*(?:Object\s*\.\s*freeze\s*\(\s*)?(?=\{)/u;
|
|
9
|
+
// One routeComponents entry: `key: () => import("./page.vue")` or `"key": ImportedPage`.
|
|
10
|
+
const ROUTE_COMPONENT = /(?<=[{,]\s*)(?:(["'])([^"']+)\1|([A-Za-z_$][\w$]*))\s*:\s*(?:\(\s*\)\s*=>\s*import\s*\(\s*(["'])([^"']+)\4\s*\)|([A-Za-z_$][\w$]*))(?=\s*[,}])/gu;
|
|
11
|
+
|
|
12
|
+
// The file a package's "./client" export names, relative to the app: packages/shelves/src/client/index.js.
|
|
13
|
+
function clientEntryOf(manifest, directory) {
|
|
14
|
+
const target = manifest?.exports?.["./client"];
|
|
15
|
+
const entry = typeof target === "string" ? target : target?.import || target?.default;
|
|
16
|
+
return typeof entry === "string" ? path.posix.join(directory, entry) : "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The app's .vue file of each routeComponents key in a client entry, from its literal map: an entry another module
|
|
20
|
+
// builds, or one naming a file outside the app, has none.
|
|
21
|
+
function readRouteComponents(entry, code, known) {
|
|
22
|
+
const components = new Map();
|
|
23
|
+
const start = ROUTE_COMPONENTS.exec(code);
|
|
24
|
+
const map = start ? balancedFrom(code, start.index + start[0].length)?.text || "" : "";
|
|
25
|
+
const imports = new Map([...code.matchAll(DEFAULT_IMPORT)].map(([, name, specifier]) => [name, specifier]));
|
|
26
|
+
for (const match of map.matchAll(ROUTE_COMPONENT)) {
|
|
27
|
+
const specifier = match[5] ?? imports.get(match[6]);
|
|
28
|
+
const file = specifier ? localComponent(entry, specifier, known) : null;
|
|
29
|
+
if (file) components.set(match[2] ?? match[3], file);
|
|
30
|
+
}
|
|
31
|
+
return components;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The pages an app's own packages give the router: each packages/<name>/package.json's jskit.metadata.ui.routes entry
|
|
36
|
+
* the kernel registers (not autoRegister: false), with the component its client entry's literal routeComponents map
|
|
37
|
+
* names for componentKey. The kernel adds each at the top level, so no layout renders it: it is under the shell's top
|
|
38
|
+
* app bar only when its component (or App.vue) draws the bar. Its parent and surface are its meta.jskit, as declared.
|
|
39
|
+
* @param {string} root
|
|
40
|
+
* @param {Set<string>} known The app's files; a package whose client entry or component is not among them is skipped.
|
|
41
|
+
*/
|
|
42
|
+
async function readPackageRoutes(root, known) {
|
|
43
|
+
let directories = [];
|
|
44
|
+
try {
|
|
45
|
+
directories = (await readdir(path.join(root, "packages"), { withFileTypes: true })).filter((entry) => entry.isDirectory());
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error?.code !== "ENOENT") throw error;
|
|
48
|
+
}
|
|
49
|
+
const pages = [];
|
|
50
|
+
for (const directory of directories.map((entry) => `packages/${entry.name}`).sort()) {
|
|
51
|
+
const manifestFile = `${directory}/package.json`;
|
|
52
|
+
let manifestSource;
|
|
53
|
+
let manifest;
|
|
54
|
+
try {
|
|
55
|
+
manifestSource = await readFile(path.join(root, manifestFile), "utf8");
|
|
56
|
+
manifest = JSON.parse(manifestSource);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code === "ENOENT" || error instanceof SyntaxError) continue;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
const routes = manifest?.jskit?.metadata?.ui?.routes;
|
|
62
|
+
const entry = clientEntryOf(manifest, directory);
|
|
63
|
+
if (!Array.isArray(routes) || !known.has(entry)) continue;
|
|
64
|
+
const components = readRouteComponents(entry, blankJsComments(await readFile(path.join(root, entry), "utf8")), known);
|
|
65
|
+
for (const route of routes) {
|
|
66
|
+
const file = isRecord(route) && route.autoRegister !== false ? components.get(String(route.componentKey || "").trim()) : null;
|
|
67
|
+
if (!file) continue;
|
|
68
|
+
const id = String(route.id || "").trim();
|
|
69
|
+
const jskit = isRecord(route.meta?.jskit) ? route.meta.jskit : {};
|
|
70
|
+
const parent = typeof jskit.parent === "string" ? jskit.parent.trim() : isRecord(jskit.parent) ? jskit.parent : "";
|
|
71
|
+
// Where package.json declares the route, and its parent, for findings.
|
|
72
|
+
const declared = new RegExp(String.raw`"id"\s*:\s*${JSON.stringify(id).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}`, "u").exec(manifestSource);
|
|
73
|
+
const object = declared ? enclosingObject(manifestSource, declared.index) : null;
|
|
74
|
+
const parentKey = object ? /"parent"\s*:/u.exec(object.text) : null;
|
|
75
|
+
pages.push({
|
|
76
|
+
file,
|
|
77
|
+
manifest: manifestFile,
|
|
78
|
+
source: manifestSource,
|
|
79
|
+
id,
|
|
80
|
+
path: String(route.path || "").trim(),
|
|
81
|
+
scope: String(route.scope || "surface").trim().toLowerCase() === "global" ? "global" : "surface",
|
|
82
|
+
surface: String(route.surface || "").trim().toLowerCase(),
|
|
83
|
+
parent,
|
|
84
|
+
surfaceRoot: Boolean(jskit.surface),
|
|
85
|
+
offset: declared?.index ?? 0,
|
|
86
|
+
parentOffset: parentKey ? object.start + parentKey.index : declared?.index ?? 0
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return pages;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { readPackageRoutes };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
// The machine's test budget: how many test files (node --test), Playwright workers or Vitest workers a run may use at
|
|
5
|
+
// once. A test runner that hands out slots exports V64_TEST_CONCURRENCY for each run; anywhere else a run takes half
|
|
6
|
+
// of the machine, so a test suite neither runs one file at a time nor overwhelms the machine (a 2-vCPU VPS stays at 1).
|
|
7
|
+
const BUDGET_VARIABLE = "V64_TEST_CONCURRENCY";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {{ env?: Record<string, string | undefined>, parallelism?: number }} [options]
|
|
11
|
+
* @returns {number} a whole number of at least 1
|
|
12
|
+
*/
|
|
13
|
+
function testConcurrency({ env = process.env, parallelism = os.availableParallelism() } = {}) {
|
|
14
|
+
const configured = String(env[BUDGET_VARIABLE] ?? "").trim();
|
|
15
|
+
if (/^[1-9]\d*$/u.test(configured)) return Number(configured);
|
|
16
|
+
return Math.max(1, Math.floor(parallelism / 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { BUDGET_VARIABLE, testConcurrency };
|