@jskit-ai/jskit-catalog 0.2.31 → 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 +258 -254
- package/package.json +3 -2
- package/scripts/jskit.mjs +22 -3
- package/scripts/material3-source.mjs +83 -0
- package/scripts/material3.mjs +42 -7
- package/scripts/navigation.mjs +195 -53
- 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`
|
|
@@ -15,7 +28,8 @@ const RULES = Object.freeze({
|
|
|
15
28
|
"nav/missing-parent": "what-an-app-declares",
|
|
16
29
|
"nav/top-level-parent": "what-an-app-declares",
|
|
17
30
|
"nav/placement-data": "what-an-app-declares",
|
|
18
|
-
"nav/missing-shell-layout": "what-an-app-declares"
|
|
31
|
+
"nav/missing-shell-layout": "what-an-app-declares",
|
|
32
|
+
"nav/surface-parent": "what-an-app-declares"
|
|
19
33
|
});
|
|
20
34
|
|
|
21
35
|
const HISTORY_CALL = /(?<![\w$.])(?:(?:window|globalThis)\s*\.\s*)?history\s*\.\s*(?:back|go|forward|pushState|replaceState)\s*\(/gu;
|
|
@@ -104,12 +118,17 @@ function isPlacementLink(code, offset) {
|
|
|
104
118
|
return Boolean(placement) && /\btarget\s*:\s*["'`][\w.-]+["'`]/u.test(placement.text) && /\bkind\s*:/u.test(placement.text);
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
// 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.
|
|
108
123
|
function checkScriptBackNames(code, offset, report) {
|
|
109
124
|
for (const match of code.matchAll(BACK_DEFINITION)) {
|
|
110
125
|
if (!navigatesApp(definitionOf(code, match[1]), code)) continue;
|
|
111
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.`);
|
|
112
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
|
+
}
|
|
113
132
|
}
|
|
114
133
|
|
|
115
134
|
// The text of a local function's definition: function name(...) { ... }, or const name = ... to its end.
|
|
@@ -174,9 +193,13 @@ function visibleText(inner) {
|
|
|
174
193
|
return inner.replace(/<[^>]*>/gu, " ").replace(/\{\{[\s\S]*?\}\}/gu, " ").replace(/\s+/gu, " ").trim();
|
|
175
194
|
}
|
|
176
195
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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);
|
|
180
203
|
}
|
|
181
204
|
|
|
182
205
|
function looksLikeBack(tag) {
|
|
@@ -200,11 +223,12 @@ function checkBackControl(tag, report, script = "") {
|
|
|
200
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.");
|
|
201
224
|
return;
|
|
202
225
|
}
|
|
203
|
-
|
|
204
|
-
if (
|
|
226
|
+
// A back component navigates by itself, so its tag needs no link or handler to be one.
|
|
227
|
+
if (isBackComponentName(tag.name)) {
|
|
205
228
|
report("nav/back-control", tag.offset, `<${tag.name}> is a back control. ${UP_OWNER}; remove it and declare the page's parent.`);
|
|
206
229
|
return;
|
|
207
230
|
}
|
|
231
|
+
if (!navigatesFromControl(tag, script)) return;
|
|
208
232
|
const handler = tag.attributes.find((entry) => entry.event && BACK_IDENTIFIER.test(entry.value));
|
|
209
233
|
if (handler) {
|
|
210
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.`);
|
|
@@ -223,37 +247,13 @@ function checkLinkAttribute(tag, entry, report) {
|
|
|
223
247
|
}
|
|
224
248
|
}
|
|
225
249
|
|
|
226
|
-
// The value of the first balanced { ... } or [ ... ] starting at or after `start`.
|
|
227
|
-
function balancedFrom(text, start, open = "{", close = "}") {
|
|
228
|
-
const first = text.indexOf(open, start);
|
|
229
|
-
if (first < 0) return null;
|
|
230
|
-
let depth = 0;
|
|
231
|
-
for (let index = first; index < text.length; index += 1) {
|
|
232
|
-
if (text[index] === open) depth += 1;
|
|
233
|
-
else if (text[index] === close && --depth === 0) return { start: first, end: index + 1, text: text.slice(first, index + 1) };
|
|
234
|
-
}
|
|
235
|
-
return null;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// The object literal that encloses `offset`, as { start, end, text }, or null.
|
|
239
|
-
function enclosingObject(text, offset) {
|
|
240
|
-
let depth = 0;
|
|
241
|
-
for (let index = offset; index >= 0; index -= 1) {
|
|
242
|
-
if (text[index] === "}") depth += 1;
|
|
243
|
-
else if (text[index] === "{") {
|
|
244
|
-
if (depth === 0) return balancedFrom(text, index);
|
|
245
|
-
depth -= 1;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
250
|
const stringValue = (text, key) => new RegExp(`\\b${key}\\s*:\\s*(["'\\u0060])([^"'\\u0060]*)\\1`, "u").exec(text)?.[2];
|
|
252
251
|
|
|
253
|
-
// Main-navigation destinations, by surface: every main-navigation placement's suffixes
|
|
254
|
-
// surface (props.surface) leads to that surface's page wherever it shows, so it is a
|
|
255
|
-
// one that names none leads to the page on each surface it shows on. A placement is
|
|
256
|
-
// surface are computed (built by a helper, spread or interpolated) can't
|
|
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.
|
|
257
257
|
function readMainNavigation(sources) {
|
|
258
258
|
const destinations = [];
|
|
259
259
|
const unreadable = [];
|
|
@@ -272,6 +272,9 @@ function readMainNavigation(sources) {
|
|
|
272
272
|
const suffix = stringValue(placement, key);
|
|
273
273
|
if (suffix !== undefined) destinations.push({ suffix: normalisePath(suffix.split(/[?#]/u)[0]), surfaces });
|
|
274
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 });
|
|
275
278
|
}
|
|
276
279
|
}
|
|
277
280
|
return { destinations, unreadable };
|
|
@@ -357,7 +360,7 @@ function readPage(file, source) {
|
|
|
357
360
|
redirect: REDIRECT_KEY.test(meta),
|
|
358
361
|
parent: declarations.find(({ text }) => PARENT_KEY.test(text)),
|
|
359
362
|
surfaceRoot: SURFACE_KEY.test(meta.replace(PARENT_OBJECT, "")),
|
|
360
|
-
shell:
|
|
363
|
+
shell: "none"
|
|
361
364
|
};
|
|
362
365
|
}
|
|
363
366
|
|
|
@@ -383,11 +386,66 @@ function createShellBarReader(appFiles, read, tag = SHELL_UP_TAG) {
|
|
|
383
386
|
return drawsShellBar;
|
|
384
387
|
}
|
|
385
388
|
|
|
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
|
+
|
|
386
442
|
/**
|
|
387
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
|
|
388
|
-
* that shows the title (ShellLayout's bar, or ShellPageTitle in a bar of its own), a page a layout
|
|
389
|
-
* and a component such a file imports. A component no .vue file imports (routed or placed from JavaScript) counts
|
|
390
|
-
* too, as where it renders can't be read
|
|
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.
|
|
391
449
|
* @param {{ root: string, appFiles: string[] }} options
|
|
392
450
|
*/
|
|
393
451
|
async function readTitledFiles({ root, appFiles }) {
|
|
@@ -406,6 +464,18 @@ async function readTitledFiles({ root, appFiles }) {
|
|
|
406
464
|
if (imported) importers.set(imported, [...(importers.get(imported) || []), file]);
|
|
407
465
|
}
|
|
408
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));
|
|
409
479
|
const verdicts = new Map();
|
|
410
480
|
async function titled(file) {
|
|
411
481
|
if (verdicts.has(file)) return verdicts.get(file);
|
|
@@ -416,13 +486,18 @@ async function readTitledFiles({ root, appFiles }) {
|
|
|
416
486
|
const segments = pageKey(file).split("/");
|
|
417
487
|
for (let length = segments.length - 1; length > 0 && !verdict; length -= 1) {
|
|
418
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.
|
|
419
491
|
if (known.has(layout) && ROUTER_VIEW_TAG.test(splitVue(await read(layout)).template?.text || "")) {
|
|
420
|
-
verdict = await
|
|
492
|
+
verdict = await shellPlacement(layout, await read(layout), showsTitle, known, SHELL_TITLE_TAG) === "all";
|
|
421
493
|
}
|
|
422
494
|
}
|
|
423
495
|
} else if (!verdict && path.posix.basename(file) !== "App.vue") {
|
|
424
496
|
const users = importers.get(file) || [];
|
|
425
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
|
+
}
|
|
426
501
|
for (const user of users) {
|
|
427
502
|
if (verdict) break;
|
|
428
503
|
verdict = await titled(user);
|
|
@@ -436,12 +511,17 @@ async function readTitledFiles({ root, appFiles }) {
|
|
|
436
511
|
return result;
|
|
437
512
|
}
|
|
438
513
|
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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;
|
|
445
525
|
}
|
|
446
526
|
|
|
447
527
|
/**
|
|
@@ -461,8 +541,9 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
461
541
|
const pages = new Map();
|
|
462
542
|
for (const file of appFiles.filter(isPageFile)) pages.set(pageKey(file), readPage(file, await read(file)));
|
|
463
543
|
const drawsShellBar = createShellBarReader(appFiles, read);
|
|
544
|
+
const known = new Set(appFiles);
|
|
464
545
|
for (const page of pages.values()) {
|
|
465
|
-
if (page.layout) page.shell = await
|
|
546
|
+
if (page.layout) page.shell = await shellPlacement(page.file, page.source, drawsShellBar, known);
|
|
466
547
|
}
|
|
467
548
|
const surfaces = readSurfaces(configSource, [...pages.values()].map(({ file }) => routeOf(file)));
|
|
468
549
|
const scripts = appFiles.filter((file) => /\.(?:[cm]?[jt]s|vue)$/u.test(file));
|
|
@@ -472,8 +553,14 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
472
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.` });
|
|
473
554
|
}
|
|
474
555
|
const surfacePath = (file, surface) => normalisePath(routePattern(routeOf(file).slice(surface.pagesRoot.length)));
|
|
475
|
-
const
|
|
476
|
-
|
|
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);
|
|
477
564
|
// An App.vue that wraps every page in the shell's bar leaves no page outside it.
|
|
478
565
|
const appShell = appFiles.includes("src/App.vue") && await drawsShellBar("src/App.vue");
|
|
479
566
|
const judged = [];
|
|
@@ -481,6 +568,12 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
481
568
|
const page = pages.get(pageKey(file));
|
|
482
569
|
const surface = surfaceOf(routeOf(file), surfaces);
|
|
483
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
|
+
}
|
|
484
577
|
// The layouts that render this page, nearest first: src/pages/home/settings.vue renders home/settings/*.
|
|
485
578
|
const segments = pageKey(file).split("/");
|
|
486
579
|
const layouts = segments.slice(1).map((_, index) => pages.get(segments.slice(0, segments.length - 1 - index).join("/")))
|
|
@@ -489,7 +582,7 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
489
582
|
const position = lineIndex(page.source);
|
|
490
583
|
// Up lives in the shell's top app bar, so only pages a shell layout renders have one. A page that declares a
|
|
491
584
|
// parent expects that bar, with its title, Up and page actions; under no shell layout it shows none of them.
|
|
492
|
-
if (!layouts.some((entry) => entry.shell)) {
|
|
585
|
+
if (!layouts.some((entry) => entry.shell !== "none")) {
|
|
493
586
|
if (page.parent && !appShell && !await drawsShellBar(file)) {
|
|
494
587
|
const at = position(page.parent.offset + PARENT_KEY.exec(page.parent.text).index);
|
|
495
588
|
const layout = surface.pagesRoot ? `, src/pages/${surface.pagesRoot}.vue` : "";
|
|
@@ -512,14 +605,63 @@ async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
|
512
605
|
const inherited = chain.find((entry) => entry.parent);
|
|
513
606
|
const topLevel = destination || relative === "/" || chain.some((entry) => isDestination(surfacePath(entry.file, surface), surface));
|
|
514
607
|
judged.push({ file, route: relative, surface: surface.id, topLevel, parent: page.parent ? "own" : inherited ? "inherited" : null });
|
|
515
|
-
|
|
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) {
|
|
516
612
|
const at = position(page.declarations[0]?.offset ?? 0);
|
|
517
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.` });
|
|
518
614
|
}
|
|
519
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
|
+
}
|
|
520
621
|
return judged;
|
|
521
622
|
}
|
|
522
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
|
+
|
|
523
665
|
// src/pages/home/suppliers.vue is home/suppliers and src/pages/home/suppliers/index.vue is home/suppliers/index.
|
|
524
666
|
function pageKey(file) {
|
|
525
667
|
return file.replace(/^src\/pages\//u, "").replace(/\.vue$/u, "");
|
|
@@ -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 };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { globSync, readFileSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { toPosixPath } from "./files.mjs";
|
|
7
|
+
import { BUDGET_VARIABLE, testConcurrency } from "./test-budget.mjs";
|
|
8
|
+
|
|
9
|
+
// `jskit test [node options] [files or globs]`: node --test, running test files in parallel within the machine's test
|
|
10
|
+
// budget (test-budget.mjs). Its arguments go to node --test unchanged. A file that truly must run alone says why on its
|
|
11
|
+
// first line, `// jskit-test: alone - <reason>`, and runs after the others, one such file at a time.
|
|
12
|
+
|
|
13
|
+
// node --test's own default (Node 26) when it is given no files.
|
|
14
|
+
const DEFAULT_PATTERN = "**/{test,test/**/*,test-*,*[._-]test}.{js,mjs,cjs,ts,mts,cts}";
|
|
15
|
+
const MARKER = /^\s*\/\/\s*jskit-test:(.*)$/u;
|
|
16
|
+
const ALONE = /^\s*alone\s+-\s+(\S.*)$/u;
|
|
17
|
+
|
|
18
|
+
function findTestFiles(patterns, cwd) {
|
|
19
|
+
const entries = globSync(patterns, { cwd, withFileTypes: true, exclude: (entry) => entry.name === "node_modules" });
|
|
20
|
+
const files = entries
|
|
21
|
+
.filter((entry) => entry.isFile() || entry.isSymbolicLink())
|
|
22
|
+
.map((entry) => toPosixPath(path.relative(cwd, path.join(entry.parentPath, entry.name))));
|
|
23
|
+
return [...new Set(files)].sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The files of a run that must run alone, from their first line. A marker that is malformed or not on the first line
|
|
28
|
+
* (after a shebang) is an error, so a file never runs in parallel because its marker was missed.
|
|
29
|
+
* @returns {{ alone: Array<{ file: string, reason: string }>, problems: string[] }}
|
|
30
|
+
*/
|
|
31
|
+
function readAloneFiles(files, cwd) {
|
|
32
|
+
const alone = [];
|
|
33
|
+
const problems = [];
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const lines = readFileSync(path.resolve(cwd, file), "utf8").split("\n");
|
|
36
|
+
const top = lines[0]?.startsWith("#!") ? 1 : 0;
|
|
37
|
+
lines.forEach((line, index) => {
|
|
38
|
+
const marker = MARKER.exec(line);
|
|
39
|
+
if (!marker) return;
|
|
40
|
+
const reason = ALONE.exec(marker[1])?.[1]?.trim();
|
|
41
|
+
if (index !== top) problems.push(`${file}:${index + 1}: a jskit-test marker belongs on the file's first line.`);
|
|
42
|
+
else if (!reason) problems.push(`${file}:${index + 1}: write it as \`// jskit-test: alone - <why this file cannot run beside others>\`.`);
|
|
43
|
+
else alone.push({ file, reason });
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { alone, problems };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The node --test runs for these arguments: one in parallel within the budget, then the files that must run alone.
|
|
51
|
+
* @param {string[]} args the arguments after `jskit test`
|
|
52
|
+
* @param {{ cwd?: string, concurrency?: number }} [options]
|
|
53
|
+
*/
|
|
54
|
+
function planTestRuns(args, { cwd = process.cwd(), concurrency = testConcurrency() } = {}) {
|
|
55
|
+
if (args.some((argument) => /^--test-concurrency(?:=|$)/u.test(argument))) {
|
|
56
|
+
throw new Error("jskit test takes the test concurrency from the machine's budget; remove --test-concurrency.");
|
|
57
|
+
}
|
|
58
|
+
const candidates = args.filter((argument) => !argument.startsWith("-"));
|
|
59
|
+
// An argument that matches no file is an option's value (--test-reporter spec) or a typo node reports itself.
|
|
60
|
+
const matched = new Map(candidates.map((candidate) => [candidate, findTestFiles([candidate], cwd)]));
|
|
61
|
+
const files = candidates.length > 0
|
|
62
|
+
? [...new Set([...matched.values()].flat())].sort()
|
|
63
|
+
: findTestFiles([DEFAULT_PATTERN], cwd);
|
|
64
|
+
const { alone, problems } = readAloneFiles(files, cwd);
|
|
65
|
+
if (problems.length > 0) throw new Error(`jskit test:\n- ${problems.join("\n- ")}`);
|
|
66
|
+
const parallel = ["--test", `--test-concurrency=${concurrency}`];
|
|
67
|
+
if (alone.length < 1) return { concurrency, alone, runs: [[...parallel, ...args]] };
|
|
68
|
+
|
|
69
|
+
const options = args.filter((argument) => !(matched.get(argument)?.length > 0));
|
|
70
|
+
const aloneFiles = new Set(alone.map((entry) => entry.file));
|
|
71
|
+
const others = files.filter((file) => !aloneFiles.has(file));
|
|
72
|
+
return {
|
|
73
|
+
concurrency,
|
|
74
|
+
alone,
|
|
75
|
+
runs: [
|
|
76
|
+
...(others.length > 0 ? [[...parallel, ...options, ...others]] : []),
|
|
77
|
+
["--test", "--test-concurrency=1", ...options, ...aloneFiles]
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runNode(args) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const child = spawn(process.execPath, args, { stdio: "inherit" });
|
|
85
|
+
const forward = (signal) => child.kill(signal);
|
|
86
|
+
process.on("SIGINT", forward);
|
|
87
|
+
process.on("SIGTERM", forward);
|
|
88
|
+
child.once("error", reject);
|
|
89
|
+
child.once("exit", (code, signal) => {
|
|
90
|
+
process.off("SIGINT", forward);
|
|
91
|
+
process.off("SIGTERM", forward);
|
|
92
|
+
resolve({ code: code ?? 128 + (os.constants.signals[signal] ?? 0), signal });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Runs `jskit test` and resolves to its exit code: the first failing run's, or 0.
|
|
99
|
+
* @param {string[]} args
|
|
100
|
+
*/
|
|
101
|
+
async function runTests(args) {
|
|
102
|
+
const plan = planTestRuns(args);
|
|
103
|
+
const source = process.env[BUDGET_VARIABLE] ? BUDGET_VARIABLE : "half of this machine";
|
|
104
|
+
process.stderr.write(`jskit test: ${plan.concurrency} test file(s) at a time (${source}).\n`);
|
|
105
|
+
for (const { file, reason } of plan.alone) process.stderr.write(`jskit test: ${file} runs alone: ${reason}\n`);
|
|
106
|
+
let exitCode = 0;
|
|
107
|
+
for (const run of plan.runs) {
|
|
108
|
+
const { code, signal } = await runNode(run);
|
|
109
|
+
if (exitCode === 0) exitCode = code;
|
|
110
|
+
// An interrupted run ends the whole command.
|
|
111
|
+
if (signal) break;
|
|
112
|
+
}
|
|
113
|
+
return exitCode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { planTestRuns, runTests };
|