@jskit-ai/jskit-catalog 0.2.31 → 0.2.33

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.
@@ -2,6 +2,8 @@
2
2
  // so these read Vue, JavaScript and CSS just far enough for the Material 3 rules: blocks, tags with attributes,
3
3
  // declarations, and comments blanked to spaces so every offset still maps to its original line and column.
4
4
 
5
+ import path from "node:path";
6
+
5
7
  const HEX_DIGITS = "[0-9a-fA-F]";
6
8
  // #rgb, #rgba, #rrggbb and #rrggbbaa, not followed by another word character.
7
9
  const HEX_COLOUR = `#(?:${HEX_DIGITS}{8}|${HEX_DIGITS}{6}|${HEX_DIGITS}{3,4})(?![\\w-])`;
@@ -121,6 +123,10 @@ function splitVue(source) {
121
123
 
122
124
  const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/gu;
123
125
  const TAG = /<([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
126
+ // An opening or closing tag in a template, with its attributes; HTML void elements never close.
127
+ const TEMPLATE_TAG = /<(\/?)([A-Za-z][\w.-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/gu;
128
+ const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
129
+ const CONDITIONAL = /(?:^|\s)v-(if|else-if|else)(?=[\s=]|$)/u;
124
130
 
125
131
  function kebab(name) {
126
132
  return name.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase();
@@ -172,6 +178,39 @@ function readTags(template) {
172
178
  return tags;
173
179
  }
174
180
 
181
+ // The v-if chains each element of a template (comments already blanked) renders under, by its offset: a list of
182
+ // [chain, branch] pairs, outermost first. A v-if starts a chain among its siblings, each v-else-if or v-else right
183
+ // after it is the chain's next branch, so two elements on different branches of one chain never render together.
184
+ function readConditionalBranches(template) {
185
+ const branches = new Map();
186
+ const open = [{ name: "", path: [], chain: null }];
187
+ let chains = 0;
188
+ for (const match of template.text.matchAll(TEMPLATE_TAG)) {
189
+ const [, closing, rawName, attributes, selfClosing] = match;
190
+ const name = kebab(rawName);
191
+ if (closing) {
192
+ const index = open.findLastIndex((entry) => entry.name === name);
193
+ if (index > 0) open.length = index;
194
+ continue;
195
+ }
196
+ const parent = open.at(-1);
197
+ const directive = CONDITIONAL.exec(attributes)?.[1];
198
+ if (directive === "if") parent.chain = { id: (chains += 1), branch: 0 };
199
+ else if (directive && parent.chain) parent.chain.branch += 1;
200
+ else parent.chain = null;
201
+ const path = parent.chain ? [...parent.path, [parent.chain.id, parent.chain.branch]] : parent.path;
202
+ if (directive === "else") parent.chain = null;
203
+ branches.set(template.offset + match.index, path);
204
+ if (!selfClosing && !VOID_TAGS.has(name)) open.push({ name, path, chain: null });
205
+ }
206
+ return branches;
207
+ }
208
+
209
+ // Whether two elements sit on different branches of one v-if chain, so at most one of them renders.
210
+ function exclusiveBranches(left, right) {
211
+ return left.some(([chain, branch]) => right.some(([other, otherBranch]) => other === chain && otherBranch !== branch));
212
+ }
213
+
175
214
  // CSS declarations with the selectors that enclose them, for flat CSS, nested SCSS and at-rule blocks.
176
215
  function readDeclarations(css, offset) {
177
216
  const declarations = [];
@@ -207,13 +246,57 @@ function readInlineDeclarations(text, offset) {
207
246
  return readDeclarations(`x{${text}}`, offset - 2);
208
247
  }
209
248
 
249
+ // A default import: `import Name from "specifier"`, its name and specifier.
250
+ const DEFAULT_IMPORT = /\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*["']([^"']+)["']/gu;
251
+
252
+ // The value of the first balanced { ... } or [ ... ] starting at or after `start`.
253
+ function balancedFrom(text, start, open = "{", close = "}") {
254
+ const first = text.indexOf(open, start);
255
+ if (first < 0) return null;
256
+ let depth = 0;
257
+ for (let index = first; index < text.length; index += 1) {
258
+ if (text[index] === open) depth += 1;
259
+ else if (text[index] === close && --depth === 0) return { start: first, end: index + 1, text: text.slice(first, index + 1) };
260
+ }
261
+ return null;
262
+ }
263
+
264
+ // The object literal that encloses `offset`, as { start, end, text }, or null.
265
+ function enclosingObject(text, offset) {
266
+ let depth = 0;
267
+ for (let index = offset; index >= 0; index -= 1) {
268
+ if (text[index] === "}") depth += 1;
269
+ else if (text[index] === "{") {
270
+ if (depth === 0) return balancedFrom(text, index);
271
+ depth -= 1;
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+
277
+ // The app's .vue file an import names: "@/components/Bar.vue" is src/components/Bar.vue; "./Bar" is beside the file.
278
+ function localComponent(file, specifier, known) {
279
+ const base = specifier.startsWith("@/")
280
+ ? path.posix.join("src", specifier.slice(2))
281
+ : path.posix.join(path.posix.dirname(file), specifier);
282
+ return [base, `${base}.vue`].find((candidate) => candidate.endsWith(".vue") && known.has(candidate)) || null;
283
+ }
284
+
210
285
  export {
286
+ DEFAULT_IMPORT,
287
+ TEMPLATE_TAG,
288
+ VOID_TAGS,
211
289
  HEX_COLOUR,
290
+ balancedFrom,
212
291
  blankCssComments,
213
292
  blankHtmlComments,
214
293
  blankJsComments,
294
+ enclosingObject,
295
+ exclusiveBranches,
215
296
  kebab,
216
297
  lineIndex,
298
+ localComponent,
299
+ readConditionalBranches,
217
300
  readDeclarations,
218
301
  readExceptions,
219
302
  readInlineDeclarations,
@@ -15,7 +15,9 @@ import {
15
15
  blankCssComments,
16
16
  blankHtmlComments,
17
17
  blankJsComments,
18
+ exclusiveBranches,
18
19
  lineIndex,
20
+ readConditionalBranches,
19
21
  readDeclarations,
20
22
  readExceptions,
21
23
  readInlineDeclarations,
@@ -51,6 +53,11 @@ const SKIPPED_DIRECTORIES = new Set([
51
53
  "fixtures", "server", "migrations"
52
54
  ]);
53
55
  const SKIPPED_FILE = /\.(?:test|spec)\.[cm]?[jt]s$|\.d\.ts$/u;
56
+ // In JSKIT's own repository, packages/ holds the framework, which implements the navigation, Up and reloads the rules
57
+ // send an app to (ShellUpButton, reloadAppPage(), leaveApp(), the system Back button). A whole-app run reads only the
58
+ // app code a JSKIT package carries, its patterns and templates; an app's own packages are read in full.
59
+ const FRAMEWORK_SCOPE = "@jskit-ai/";
60
+ const FRAMEWORK_APP_CODE = new Set(["patterns", "templates"]);
54
61
  // The generated colour scheme is the one home of hex values.
55
62
  const THEME_DIRECTORY = "theme";
56
63
  // Colours that are content, not UI, keep one value in both schemes, so they are not theme colours (which are toned per
@@ -61,6 +68,12 @@ const WHOLE_APP_ROOTS = Object.freeze(["src", "packages"]);
61
68
 
62
69
  const hexPattern = () => new RegExp(HEX_COLOUR, "gu");
63
70
  const exactHex = new RegExp(`^${HEX_COLOUR}$`, "u");
71
+ // A colour written as channels, as a hand-made shadow is: rgba(0, 0, 0, 0.15), rgb(0 0 0 / 20%), hsl(...). One that
72
+ // reads a theme role, rgba(var(--v-theme-shadow), 0.2), is not.
73
+ const CHANNEL_COLOUR = String.raw`\b(?:rgba?|hsla?)\(\s*[\d.][^()]*\)`;
74
+ const channelColour = () => new RegExp(CHANNEL_COLOUR, "giu");
75
+ const exactChannelColour = new RegExp(`^${CHANNEL_COLOUR}$`, "iu");
76
+ const CHANNEL_FIX = "Use rgb(var(--v-theme-<role>)), with an alpha for a shadow or scrim: rgba(var(--v-theme-shadow), 0.2).";
64
77
  const quotedHex = () => new RegExp(`(["'\`])(${HEX_COLOUR})\\1`, "gu");
65
78
  const MDI_STRING = /\bmdi-[a-z][a-z0-9-]*/gu;
66
79
  const MDI_IMPORT = /(?:from\s*|import\s*|require\(\s*)["'](@mdi\/(?:js|font)[^"']*)["']/gu;
@@ -83,7 +96,18 @@ function isSourceFile(relativePath) {
83
96
  return !relativePath.split("/").slice(0, -1).some(isSkippedDirectory);
84
97
  }
85
98
 
86
- async function walk(projectRoot, directory, files) {
99
+ async function isFrameworkPackage(projectRoot, directory) {
100
+ if (!/^packages\/[^/]+$/u.test(directory)) return false;
101
+ try {
102
+ const manifest = JSON.parse(await readFile(path.join(projectRoot, directory, "package.json"), "utf8"));
103
+ return String(manifest?.name || "").startsWith(FRAMEWORK_SCOPE);
104
+ } catch (error) {
105
+ if (error?.code === "ENOENT") return false;
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ async function walk(projectRoot, directory, files, wholeApp = false) {
87
111
  let entries;
88
112
  try {
89
113
  entries = await readdir(path.join(projectRoot, directory), { withFileTypes: true });
@@ -91,10 +115,12 @@ async function walk(projectRoot, directory, files) {
91
115
  if (error?.code === "ENOENT") return;
92
116
  throw error;
93
117
  }
118
+ const framework = wholeApp && await isFrameworkPackage(projectRoot, directory);
94
119
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
95
120
  const relative = directory ? `${directory}/${entry.name}` : entry.name;
121
+ if (framework && !(entry.isDirectory() && FRAMEWORK_APP_CODE.has(entry.name))) continue;
96
122
  if (entry.isDirectory()) {
97
- if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files);
123
+ if (!isSkippedDirectory(entry.name)) await walk(projectRoot, relative, files, wholeApp);
98
124
  } else if (entry.isFile() && isSourceFile(relative)) {
99
125
  files.add(relative);
100
126
  }
@@ -120,7 +146,7 @@ async function collectFiles(projectRoot, paths) {
120
146
  if (paths.length > 0) missing.push(target);
121
147
  continue;
122
148
  }
123
- if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files);
149
+ if (info.isDirectory()) await walk(projectRoot, relative === "" ? "" : relative, files, paths.length === 0);
124
150
  else if (isSourceFile(relative)) files.add(relative);
125
151
  }
126
152
  return { files: [...files].sort(), missing };
@@ -164,6 +190,9 @@ function checkCode(code, offset, { report, themed }) {
164
190
  for (const match of code.matchAll(quotedHex())) {
165
191
  report("m3/raw-hex", offset + match.index, `Raw colour ${match[2]}. Use a theme colour role. ${CONTENT_COLOUR}`);
166
192
  }
193
+ for (const match of code.matchAll(channelColour())) {
194
+ report("m3/raw-hex", offset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
195
+ }
167
196
  }
168
197
  checkNavigationCode(code, offset, report);
169
198
  }
@@ -198,6 +227,9 @@ function checkDeclaration(declaration, context) {
198
227
  for (const match of declaration.value.matchAll(hexPattern())) {
199
228
  report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. Use rgb(var(--v-theme-<role>)). ${CONTENT_COLOUR}`);
200
229
  }
230
+ for (const match of declaration.value.matchAll(channelColour())) {
231
+ report("m3/raw-hex", declaration.valueOffset + match.index, `Raw colour ${match[0]}. ${CHANNEL_FIX} ${CONTENT_COLOUR}`);
232
+ }
201
233
  }
202
234
  const buttonRule = declaration.selectors.some((selector) => /\.v-btn\b/u.test(selector));
203
235
  if (buttonRule && ["height", "min-height", "--v-btn-height"].includes(declaration.property)) {
@@ -215,7 +247,9 @@ function checkTemplate(template, context) {
215
247
  for (const match of template.text.matchAll(MDI_STRING)) {
216
248
  report("m3/mdi-icon", template.offset + match.index, `MDI icon "${match[0]}". Use a Material Symbols alias such as "$add" from the app's Vuetify icon aliases.`);
217
249
  }
218
- let headings = 0;
250
+ // The branches each h1 renders under: h1s on different branches of one v-if chain never show together.
251
+ const branches = readConditionalBranches(template);
252
+ const headings = [];
219
253
  for (const tag of tags) {
220
254
  for (const entry of tag.attributes) {
221
255
  checkAttribute(tag, entry, context);
@@ -224,9 +258,10 @@ function checkTemplate(template, context) {
224
258
  if (tag.name === "v-btn") checkButtonSize(tag, report);
225
259
  checkBackControl(tag, report, context.script);
226
260
  if (tag.name === "h1") {
227
- headings += 1;
228
- if (headings > 1) report("m3/duplicate-h1", tag.offset, "A second h1 in one template. A page has one title, carried by the top app bar.");
261
+ const branch = branches.get(tag.offset) || [];
262
+ if (headings.some((other) => !exclusiveBranches(other, branch))) report("m3/duplicate-h1", tag.offset, "A second h1 in one template. A page has one title, carried by the top app bar.");
229
263
  else if (context.titled) report("m3/duplicate-h1", tag.offset, "An in-page h1 under the top app bar, which carries the page title; use a title role (text-title-large) for a section title.");
264
+ headings.push(branch);
230
265
  }
231
266
  }
232
267
  }
@@ -239,7 +274,7 @@ function checkAttribute(tag, entry, context) {
239
274
  for (const declaration of readInlineDeclarations(entry.value, entry.valueOffset)) {
240
275
  checkDeclaration({ ...declaration, selectors: tag.name === "v-btn" ? [".v-btn"] : [] }, context);
241
276
  }
242
- } else if (!themed && exactHex.test(entry.value.trim())) {
277
+ } else if (!themed && (exactHex.test(entry.value.trim()) || exactChannelColour.test(entry.value.trim()))) {
243
278
  report("m3/raw-hex", entry.valueOffset, `Raw colour ${entry.value.trim()}. Use a theme colour role such as color="primary". ${CONTENT_COLOUR}`);
244
279
  }
245
280
  checkLinkAttribute(tag, entry, context.report);
@@ -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`
@@ -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; 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.
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
- function isBackTag(tag) {
178
- const segments = tag.name.split("-");
179
- 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);
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
- if (!navigatesFromControl(tag, script)) return;
204
- 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)) {
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. A link that names its
254
- // surface (props.surface) leads to that surface's page wherever it shows, so it is a destination of that surface only;
255
- // one that names none leads to the page on each surface it shows on. A placement is literal data; one whose suffixes or
256
- // surface are computed (built by a helper, spread or interpolated) can't be read, and is returned as unreadable.
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: false
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 showing it renders,
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; App.vue counts only when it shows the title itself.
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 showsTitle(layout);
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
- // The app's .vue file an import names: "@/components/Bar.vue" is src/components/Bar.vue; "./Bar" is beside the file.
440
- function localComponent(file, specifier, known) {
441
- const base = specifier.startsWith("@/")
442
- ? path.posix.join("src", specifier.slice(2))
443
- : path.posix.join(path.posix.dirname(file), specifier);
444
- return [base, `${base}.vue`].find((candidate) => candidate.endsWith(".vue") && known.has(candidate)) || null;
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 drawsShellBar(page.file);
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 isDestination = (relative, surface) => navigation.destinations.some(({ suffix, surfaces: owners }) =>
476
- 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);
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
- if (!topLevel && !page.parent && !inherited && navigation.unreadable.length === 0) {
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, "");