@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.
@@ -1,6 +1,19 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { blankJsComments, lineIndex, splitVue } from "./material3-source.mjs";
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; a variable, an import or an in-component step is not back logic.
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
- function isBackTag(tag) {
175
- const segments = tag.name.split("-");
176
- return segments.includes("back") && !tag.name.startsWith("v-");
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
- if (!navigatesFromControl(tag, script)) return;
201
- if (isBackTag(tag)) {
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
- for (const source of sources) {
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 surfaces = [stringValue(placement, "surface"), ...(/\bsurfaces\s*:\s*\[([^\]]*)\]/u.exec(placement)?.[1] || "")
257
- .split(",").map((entry) => entry.trim().replace(/^["'`]|["'`]$/gu, ""))].filter(Boolean);
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: false
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
- function createShellBarReader(appFiles, read) {
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) || SHELL_UP_TAG.test(splitVue(source).template?.text || "");
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
- // The app's .vue file an import names: "@/components/Bar.vue" is src/components/Bar.vue; "./Bar" is beside the file.
357
- function localComponent(file, specifier, known) {
358
- const base = specifier.startsWith("@/")
359
- ? path.posix.join("src", specifier.slice(2))
360
- : path.posix.join(path.posix.dirname(file), specifier);
361
- return [base, `${base}.vue`].find((candidate) => candidate.endsWith(".vue") && known.has(candidate)) || null;
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 drawsShellBar(page.file);
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 isDestination = (relative, surface) => navigation.some(({ suffix, surfaces: owners }) =>
389
- suffix === relative && (owners.length === 0 || owners.includes(surface.id) || owners.includes("*")));
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
- if (!topLevel && !page.parent && !inherited) {
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 };