@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.
- package/catalog/packages.json +270 -257
- package/package.json +4 -2
- package/scripts/browser-test-build.mjs +153 -0
- package/scripts/browser-test-servers.mjs +132 -0
- package/scripts/browser-test-times.mjs +33 -0
- package/scripts/browser-tests.mjs +166 -0
- package/scripts/jskit.mjs +27 -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 +223 -0
- package/scripts/test-databases.mjs +50 -0
- package/scripts/test-file-stop.mjs +32 -0
- package/scripts/test-runs.mjs +203 -0
- package/scripts/test-time-report.mjs +150 -0
- package/scripts/verify-packages.mjs +23 -1
|
@@ -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,
|
package/scripts/material3.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
if (headings
|
|
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);
|
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, "");
|