@jskit-ai/jskit-catalog 0.2.26 → 0.2.27
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 +284 -256
- package/package.json +1 -1
- package/scripts/jskit.mjs +58 -8
- package/scripts/material3-source.mjs +222 -0
- package/scripts/material3.mjs +371 -0
- package/scripts/navigation.mjs +338 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { blankJsComments, lineIndex, splitVue } from "./material3-source.mjs";
|
|
4
|
+
|
|
5
|
+
// The navigation rules of the JSKIT Material 3 guide (#241). The shell owns Up and the per-tab trail;
|
|
6
|
+
// an app declares each inner page's parent and links through JSKIT navigation, nothing more. Plain `jskit check`
|
|
7
|
+
// fails on every rule here, for every JSKIT app, and no comment excuses one; `jskit check m3` lists them with the
|
|
8
|
+
// visual rules.
|
|
9
|
+
|
|
10
|
+
const RULES = Object.freeze({
|
|
11
|
+
"nav/history": "forbidden-in-app-code",
|
|
12
|
+
"nav/window-location": "forbidden-in-app-code",
|
|
13
|
+
"nav/raw-link": "forbidden-in-app-code",
|
|
14
|
+
"nav/back-control": "forbidden-in-app-code",
|
|
15
|
+
"nav/missing-parent": "what-an-app-declares",
|
|
16
|
+
"nav/top-level-parent": "what-an-app-declares"
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const HISTORY_CALL = /(?<![\w$.])(?:(?:window|globalThis)\s*\.\s*)?history\s*\.\s*(?:back|go|forward|pushState|replaceState)\s*\(/gu;
|
|
20
|
+
const ROUTER_HISTORY_CALL = /(?<![\w$])(?:[\w$]*router|useRouter\(\s*\))\s*\.\s*(?:back|go|forward)\s*\(/giu;
|
|
21
|
+
const LOCATION_WRITE = new RegExp([
|
|
22
|
+
String.raw`\b(?:window|document|globalThis)\s*\.\s*location(?:\s*\.\s*(?:href|pathname|search|hash))?\s*=(?!=)`,
|
|
23
|
+
String.raw`(?<![\w$.])location\s*\.\s*(?:href|pathname)\s*=(?!=)`,
|
|
24
|
+
String.raw`(?<![\w$])(?:(?:window|document|globalThis)\s*\.\s*)?location\s*\.\s*(?:assign|replace)\s*\(`
|
|
25
|
+
].join("|"), "gu");
|
|
26
|
+
const ROUTER_PATH = /(?<![\w$])(?:[\w$]*router|useRouter\(\s*\))\s*\.\s*(?:push|replace)\s*\(\s*(?:\{\s*path\s*:\s*)?(["'`])(\/[^"'`]*)/giu;
|
|
27
|
+
const OBJECT_PATH = /(?<![\w$])(?:to|href)\s*:\s*(["'`])(\/[^"'`]*)/gu;
|
|
28
|
+
const BOUND_PATH = /^\s*(?:\{\s*path\s*:\s*)?(["'`])(\/[^"'`]*)/u;
|
|
29
|
+
// A back handler or back component: goBack, onBack, handleBackClick, useBackNavigation, PageBackButton, backToList.
|
|
30
|
+
const BACK_NAME = String.raw`(?:[A-Za-z][\w$]*Back(?:[A-Z][\w$]*)?|Back[A-Z][\w$]*|back(?:To[A-Z][\w$]*)?)`;
|
|
31
|
+
const BACK_IDENTIFIER = new RegExp(String.raw`(?<![\w$])${BACK_NAME}(?![\w$])`, "u");
|
|
32
|
+
const BACK_DEFINITION = new RegExp(String.raw`\b(?:function\s*\*?\s*|const\s+|let\s+|var\s+|import\s+)(${BACK_NAME})(?![\w$])|\bimport\s*\{[^}]*?(?<![\w$])(${BACK_NAME})(?![\w$])[^}]*\}`, "gu");
|
|
33
|
+
const BACK_TEXT = /^\s*(?:[←‹«]|(?:go\s+)?back\b|return to\b)|\bback to\b/iu;
|
|
34
|
+
const BACK_KEY = /\bback(?:To)?\b/iu;
|
|
35
|
+
const ARROW_ICON = /arrow[-_]?back|arrow[-_]?left|keyboard[-_]?backspace|keyboard[-_]?return/iu;
|
|
36
|
+
const CHEVRON_ICON = /chevron[-_]?left|\$prev\b|navigate[-_]?before/iu;
|
|
37
|
+
const CLICKABLE_TAGS = new Set(["v-btn", "router-link", "a", "v-list-item", "v-chip", "v-icon", "v-app-bar-nav-icon"]);
|
|
38
|
+
const BREADCRUMB_TAGS = new Set(["v-breadcrumbs", "v-breadcrumbs-item"]);
|
|
39
|
+
const LABEL_ATTRIBUTES = new Set(["aria-label", "title", "label", "text"]);
|
|
40
|
+
const ICON_ATTRIBUTES = new Set(["icon", "prepend-icon", "append-icon"]);
|
|
41
|
+
const LINK_ATTRIBUTES = new Set(["to", "href"]);
|
|
42
|
+
const NAVIGATES = /(?<![\w$])(?:[\w$]*router|useRouter\(\s*\))\s*\.\s*(?:push|replace)\s*\(|\.page\s*\(|resolve\w*Url\s*\(/iu;
|
|
43
|
+
// Main navigation: the navigation bar, rail and drawer render shell.primary-nav and shell.secondary-nav links.
|
|
44
|
+
const MAIN_NAV_TARGET = /\btarget\s*:\s*["']shell\.(?:primary|secondary)-nav["']/gu;
|
|
45
|
+
// A layout that imports the app's ShellLayout wrapper or shell-web's ShellLayout renders the top app bar.
|
|
46
|
+
const SHELL_LAYOUT_IMPORT = /\bfrom\s*["'](?:@\/components\/ShellLayout|@jskit-ai\/shell-web\/client\/components\/ShellLayout)(?:\.vue)?["']/u;
|
|
47
|
+
const ROUTER_VIEW_TAG = /<\s*(?:RouterView|router-view)\b/u;
|
|
48
|
+
|
|
49
|
+
function isInAppPath(value) {
|
|
50
|
+
const target = String(value || "").trim();
|
|
51
|
+
if (!target.startsWith("/") || target.startsWith("//") || target.startsWith("/api/")) return false;
|
|
52
|
+
const pathname = target.split(/[?#]/u)[0];
|
|
53
|
+
// A file the server serves (/favicon.svg, /exports/report.pdf) is not an app page.
|
|
54
|
+
return !/\.[a-z0-9]{1,5}$/iu.test(pathname.split("/").pop() || "");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const compact = (text) => text.replace(/\s+/gu, "");
|
|
58
|
+
const RAW_LINK_FIX = "Resolve it through JSKIT navigation: usePaths().page(), the CRUD runtime's resolve helpers or a shell link item.";
|
|
59
|
+
|
|
60
|
+
// History, location and hand-built paths in any JavaScript: a script block, a module, or an attribute's expression.
|
|
61
|
+
function checkNavigationCode(code, offset, report) {
|
|
62
|
+
for (const pattern of [HISTORY_CALL, ROUTER_HISTORY_CALL]) {
|
|
63
|
+
for (const match of code.matchAll(pattern)) {
|
|
64
|
+
report("nav/history", offset + match.index, `${compact(match[0])}...) navigates by browser history. The shell's Up arrow and the browser's own Back do that; an app never does.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const match of code.matchAll(LOCATION_WRITE)) {
|
|
68
|
+
report("nav/window-location", offset + match.index, `${compact(match[0])} navigates outside the router. Reach in-app pages through JSKIT navigation; for a full reload into one (after sign-in or a user switch) use reloadAppPage(path), and to leave the app leaveApp(url), from @jskit-ai/shell-web/client/navigation/documentNavigation.`);
|
|
69
|
+
}
|
|
70
|
+
for (const pattern of [ROUTER_PATH, OBJECT_PATH]) {
|
|
71
|
+
for (const match of code.matchAll(pattern)) {
|
|
72
|
+
if (!isInAppPath(match[2]) || (pattern === OBJECT_PATH && isPlacementLink(code, match.index))) continue;
|
|
73
|
+
report("nav/raw-link", offset + match.index, `Hand-built in-app path "${match[2]}". ${RAW_LINK_FIX}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A placement's link props (to, scopedSuffix, unscopedSuffix) are JSKIT navigation input: shell-web resolves them
|
|
79
|
+
// with resolveMenuLinkTarget() and renders router links, so `props: { to: "/auth/login" }` is not a hand-built link.
|
|
80
|
+
function isPlacementLink(code, offset) {
|
|
81
|
+
const props = enclosingObject(code, offset);
|
|
82
|
+
if (!props) return false;
|
|
83
|
+
const placement = /\bprops\s*:\s*$/u.test(code.slice(0, props.start)) ? enclosingObject(code, props.start - 1) : props;
|
|
84
|
+
return Boolean(placement) && /\btarget\s*:\s*["'`][\w.-]+["'`]/u.test(placement.text) && /\bkind\s*:/u.test(placement.text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkScriptBackNames(code, offset, report) {
|
|
88
|
+
for (const match of code.matchAll(BACK_DEFINITION)) {
|
|
89
|
+
const name = match[1] || match[2];
|
|
90
|
+
report("nav/back-control", offset + match.index, `${name} is back logic in the app. Up belongs to the shell; delete it and declare the page's parent.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function attribute(tag, name) {
|
|
95
|
+
return tag.attributes.find((entry) => entry.name === name);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function visibleText(inner) {
|
|
99
|
+
return inner.replace(/<[^>]*>/gu, " ").replace(/\{\{[\s\S]*?\}\}/gu, " ").replace(/\s+/gu, " ").trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isBackTag(tag) {
|
|
103
|
+
const segments = tag.name.split("-");
|
|
104
|
+
return segments.includes("back") && !tag.name.startsWith("v-");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function looksLikeBack(tag) {
|
|
108
|
+
const mustaches = [...tag.inner.matchAll(/\{\{([\s\S]*?)\}\}/gu)].map((match) => match[1]);
|
|
109
|
+
const labels = tag.attributes.filter((entry) => LABEL_ATTRIBUTES.has(entry.name));
|
|
110
|
+
const icons = tag.attributes.filter((entry) => ICON_ATTRIBUTES.has(entry.name)).map((entry) => entry.value);
|
|
111
|
+
const navigates = tag.attributes.some((entry) => LINK_ATTRIBUTES.has(entry.name) || (entry.event && NAVIGATES.test(entry.value)));
|
|
112
|
+
const iconText = [...icons, tag.inner].join(" ");
|
|
113
|
+
return BACK_TEXT.test(visibleText(tag.inner)) ||
|
|
114
|
+
mustaches.some((expression) => BACK_KEY.test(expression)) ||
|
|
115
|
+
labels.some((entry) => (entry.bound ? BACK_KEY : BACK_TEXT).test(entry.value)) ||
|
|
116
|
+
ARROW_ICON.test(iconText) ||
|
|
117
|
+
(navigates && CHEVRON_ICON.test(iconText));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Every back control but the shell's Up arrow: "Back to X", arrows and navigating chevrons, breadcrumbs, back
|
|
121
|
+
// components and back handlers.
|
|
122
|
+
function checkBackControl(tag, report) {
|
|
123
|
+
if (BREADCRUMB_TAGS.has(tag.name)) {
|
|
124
|
+
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.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (isBackTag(tag)) {
|
|
128
|
+
report("nav/back-control", tag.offset, `<${tag.name}> is a back control. Up belongs to the shell; remove it and declare the page's parent.`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const handler = tag.attributes.find((entry) => entry.event && BACK_IDENTIFIER.test(entry.value));
|
|
132
|
+
if (handler) {
|
|
133
|
+
report("nav/back-control", handler.offset, `${handler.raw}="${handler.value.trim()}" is a back handler. Up belongs to the shell; remove the control and declare the page's parent.`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (CLICKABLE_TAGS.has(tag.name) && looksLikeBack(tag)) {
|
|
137
|
+
report("nav/back-control", tag.offset, "A back control in the page. Up appears once, in the top app bar, from the trail or the declared parent; remove this one.");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function checkLinkAttribute(tag, entry, report) {
|
|
142
|
+
if (!LINK_ATTRIBUTES.has(entry.name) || attribute(tag, "download")) return;
|
|
143
|
+
const value = entry.bound ? BOUND_PATH.exec(entry.value)?.[2] : entry.value;
|
|
144
|
+
if (value !== undefined && isInAppPath(value)) {
|
|
145
|
+
report("nav/raw-link", entry.valueOffset, `Hand-built in-app path "${value.trim()}". ${RAW_LINK_FIX}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The value of the first balanced { ... } or [ ... ] starting at or after `start`.
|
|
150
|
+
function balancedFrom(text, start, open = "{", close = "}") {
|
|
151
|
+
const first = text.indexOf(open, start);
|
|
152
|
+
if (first < 0) return null;
|
|
153
|
+
let depth = 0;
|
|
154
|
+
for (let index = first; index < text.length; index += 1) {
|
|
155
|
+
if (text[index] === open) depth += 1;
|
|
156
|
+
else if (text[index] === close && --depth === 0) return { start: first, end: index + 1, text: text.slice(first, index + 1) };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The object literal that encloses `offset`, as { start, end, text }, or null.
|
|
162
|
+
function enclosingObject(text, offset) {
|
|
163
|
+
let depth = 0;
|
|
164
|
+
for (let index = offset; index >= 0; index -= 1) {
|
|
165
|
+
if (text[index] === "}") depth += 1;
|
|
166
|
+
else if (text[index] === "{") {
|
|
167
|
+
if (depth === 0) return balancedFrom(text, index);
|
|
168
|
+
depth -= 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const stringValue = (text, key) => new RegExp(`\\b${key}\\s*:\\s*(["'\\u0060])([^"'\\u0060]*)\\1`, "u").exec(text)?.[2];
|
|
175
|
+
|
|
176
|
+
// Main-navigation destinations, by surface: every main-navigation placement's suffixes.
|
|
177
|
+
function readMainNavigation(sources) {
|
|
178
|
+
const destinations = [];
|
|
179
|
+
for (const source of sources) {
|
|
180
|
+
for (const match of source.matchAll(MAIN_NAV_TARGET)) {
|
|
181
|
+
const placement = enclosingObject(source, match.index)?.text || "";
|
|
182
|
+
const surfaces = [stringValue(placement, "surface"), ...(/\bsurfaces\s*:\s*\[([^\]]*)\]/u.exec(placement)?.[1] || "")
|
|
183
|
+
.split(",").map((entry) => entry.trim().replace(/^["'`]|["'`]$/gu, ""))].filter(Boolean);
|
|
184
|
+
for (const key of ["scopedSuffix", "unscopedSuffix"]) {
|
|
185
|
+
const suffix = stringValue(placement, key);
|
|
186
|
+
if (suffix !== undefined) destinations.push({ suffix: normalisePath(suffix.split(/[?#]/u)[0]), surfaces });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return destinations;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalisePath(value) {
|
|
194
|
+
const trimmed = `/${String(value || "").replace(/^\/+|\/+$/gu, "")}`;
|
|
195
|
+
return trimmed === "/" ? "/" : trimmed;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// unplugin-vue-router file routes: src/pages/home/suppliers/[supplierId]/edit.vue is home/suppliers/:supplierId/edit,
|
|
199
|
+
// and an index file or folder adds no segment.
|
|
200
|
+
function routeOf(file) {
|
|
201
|
+
return pageKey(file).split("/").filter((segment) => segment !== "index").join("/");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function routePattern(route) {
|
|
205
|
+
return route.replace(/\[\.\.\.([^\]]+)\]/gu, ":$1*").replace(/\[([^\]]+)\]/gu, ":$1");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Each surface's pages root, from config/public.js (config.surfaceDefinitions.<id> = { pagesRoot: "..." }).
|
|
209
|
+
function readSurfaces(configSource, pageRoutes) {
|
|
210
|
+
const surfaces = [];
|
|
211
|
+
for (const match of configSource.matchAll(/surfaceDefinitions\s*\.\s*([\w$]+)\s*=\s*\{/gu)) {
|
|
212
|
+
const body = balancedFrom(configSource, match.index + match[0].length - 1)?.text || "";
|
|
213
|
+
const pagesRoot = stringValue(body, "pagesRoot");
|
|
214
|
+
surfaces.push({ id: match[1], pagesRoot: pagesRoot === undefined ? match[1] : pagesRoot.replace(/^\/+|\/+$/gu, "") });
|
|
215
|
+
}
|
|
216
|
+
if (surfaces.length > 0) return surfaces.sort((left, right) => right.pagesRoot.length - left.pagesRoot.length);
|
|
217
|
+
const roots = new Set(pageRoutes.map((route) => route.split("/")[0]).filter(Boolean));
|
|
218
|
+
return [...roots].map((root) => ({ id: root, pagesRoot: root }));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function surfaceOf(route, surfaces) {
|
|
222
|
+
return surfaces.find(({ pagesRoot }) => pagesRoot === "" || route === pagesRoot || route.startsWith(`${pagesRoot}/`));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The route block's JSON and the definePage() argument, where a page declares its route meta.
|
|
226
|
+
function routeDeclarations(source) {
|
|
227
|
+
const declarations = [];
|
|
228
|
+
const routeBlock = /<route(?:\s[^>]*)?>([\s\S]*?)<\/route\s*>/iu.exec(source);
|
|
229
|
+
if (routeBlock) declarations.push({ text: routeBlock[1], offset: routeBlock.index + routeBlock[0].indexOf(">") + 1 });
|
|
230
|
+
const { scripts } = splitVue(source);
|
|
231
|
+
for (const block of scripts) {
|
|
232
|
+
const code = blankJsComments(block.text);
|
|
233
|
+
const call = /\bdefinePage\s*\(/u.exec(code);
|
|
234
|
+
const argument = call ? balancedFrom(code, call.index + call[0].length) : null;
|
|
235
|
+
if (argument) declarations.push({ text: argument.text, offset: block.offset + argument.start });
|
|
236
|
+
}
|
|
237
|
+
return declarations;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const PARENT_KEY = /(?:\bparent|"parent")\s*:/u;
|
|
241
|
+
const REDIRECT_KEY = /(?:\bredirect|"redirect")\s*:/u;
|
|
242
|
+
// The surface root record carries meta.jskit.surface; a cross-surface parent ({ surface, path }) does not count.
|
|
243
|
+
const SURFACE_KEY = /(?:\bsurface|"surface")\s*:/u;
|
|
244
|
+
const PARENT_OBJECT = /(?:\bparent|"parent")\s*:\s*\{[^}]*\}/gu;
|
|
245
|
+
|
|
246
|
+
function readPage(file, source) {
|
|
247
|
+
const declarations = routeDeclarations(source);
|
|
248
|
+
const meta = declarations.map(({ text }) => text).join("\n");
|
|
249
|
+
return {
|
|
250
|
+
file,
|
|
251
|
+
source,
|
|
252
|
+
declarations,
|
|
253
|
+
layout: ROUTER_VIEW_TAG.test(splitVue(source).template?.text || ""),
|
|
254
|
+
redirect: REDIRECT_KEY.test(meta),
|
|
255
|
+
parent: declarations.find(({ text }) => PARENT_KEY.test(text)),
|
|
256
|
+
surfaceRoot: SURFACE_KEY.test(meta.replace(PARENT_OBJECT, "")),
|
|
257
|
+
shell: SHELL_LAYOUT_IMPORT.test(source)
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The page rules: an inner page of a shell surface has a parent, its own or its layout's; a main-navigation
|
|
263
|
+
* destination declares none.
|
|
264
|
+
* @param {{ root: string, appFiles: string[], pageFiles: string[], report: Function }} options appFiles are the whole
|
|
265
|
+
* app's source files (for surfaces, layouts and main navigation); pageFiles are the pages to judge
|
|
266
|
+
*/
|
|
267
|
+
async function checkPageParents({ root, appFiles, pageFiles, report }) {
|
|
268
|
+
const read = async (file) => readFile(path.join(root, file), "utf8");
|
|
269
|
+
let configSource = "";
|
|
270
|
+
try {
|
|
271
|
+
configSource = blankJsComments(await readFile(path.join(root, "config", "public.js"), "utf8"));
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (error?.code !== "ENOENT") throw error;
|
|
274
|
+
}
|
|
275
|
+
const pages = new Map();
|
|
276
|
+
for (const file of appFiles.filter(isPageFile)) pages.set(pageKey(file), readPage(file, await read(file)));
|
|
277
|
+
const surfaces = readSurfaces(configSource, [...pages.values()].map(({ file }) => routeOf(file)));
|
|
278
|
+
const scripts = appFiles.filter((file) => /\.(?:[cm]?[jt]s|vue)$/u.test(file));
|
|
279
|
+
const navigation = readMainNavigation(await Promise.all(scripts.map(async (file) => blankJsComments(await read(file)))));
|
|
280
|
+
const surfacePath = (file, surface) => normalisePath(routePattern(routeOf(file).slice(surface.pagesRoot.length)));
|
|
281
|
+
const isDestination = (relative, surface) => navigation.some(({ suffix, surfaces: owners }) =>
|
|
282
|
+
suffix === relative && (owners.length === 0 || owners.includes(surface.id) || owners.includes("*")));
|
|
283
|
+
const judged = [];
|
|
284
|
+
for (const file of pageFiles.filter(isPageFile)) {
|
|
285
|
+
const page = pages.get(pageKey(file));
|
|
286
|
+
const surface = surfaceOf(routeOf(file), surfaces);
|
|
287
|
+
if (!surface || page.redirect) continue;
|
|
288
|
+
// The layouts that render this page, nearest first: src/pages/home/settings.vue renders home/settings/*.
|
|
289
|
+
const segments = pageKey(file).split("/");
|
|
290
|
+
const layouts = segments.slice(1).map((_, index) => pages.get(segments.slice(0, segments.length - 1 - index).join("/")))
|
|
291
|
+
.filter((entry) => entry?.layout);
|
|
292
|
+
// Up lives in the shell's top app bar, so only pages a shell layout renders have one.
|
|
293
|
+
if (!layouts.some((entry) => entry.shell)) continue;
|
|
294
|
+
const relative = surfacePath(file, surface);
|
|
295
|
+
const destination = isDestination(relative, surface);
|
|
296
|
+
const position = lineIndex(page.source);
|
|
297
|
+
if (destination && page.parent) {
|
|
298
|
+
const at = position(page.parent.offset + PARENT_KEY.exec(page.parent.text).index);
|
|
299
|
+
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.` });
|
|
300
|
+
}
|
|
301
|
+
if (page.layout) continue;
|
|
302
|
+
// A parent is inherited through the layouts below the surface root; a top-level layout's tabs are top-level.
|
|
303
|
+
const chain = [];
|
|
304
|
+
for (const entry of layouts) {
|
|
305
|
+
if (entry.surfaceRoot) break;
|
|
306
|
+
chain.push(entry);
|
|
307
|
+
}
|
|
308
|
+
const inherited = chain.find((entry) => entry.parent);
|
|
309
|
+
const topLevel = destination || relative === "/" || chain.some((entry) => isDestination(surfacePath(entry.file, surface), surface));
|
|
310
|
+
judged.push({ file, route: relative, surface: surface.id, topLevel, parent: page.parent ? "own" : inherited ? "inherited" : null });
|
|
311
|
+
if (!topLevel && !page.parent && !inherited) {
|
|
312
|
+
const at = position(page.declarations[0]?.offset ?? 0);
|
|
313
|
+
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.` });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return judged;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/pages/home/suppliers.vue is home/suppliers and src/pages/home/suppliers/index.vue is home/suppliers/index.
|
|
320
|
+
function pageKey(file) {
|
|
321
|
+
return file.replace(/^src\/pages\//u, "").replace(/\.vue$/u, "");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// A folder or file whose name starts with "_" (such as a CRUD screen's _components) holds components, not pages.
|
|
325
|
+
function isPageFile(file) {
|
|
326
|
+
return file.startsWith("src/pages/") && file.endsWith(".vue") && !pageKey(file).split("/").some((segment) => segment.startsWith("_"));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export {
|
|
330
|
+
RULES as NAVIGATION_RULES,
|
|
331
|
+
checkBackControl,
|
|
332
|
+
checkLinkAttribute,
|
|
333
|
+
checkNavigationCode,
|
|
334
|
+
checkPageParents,
|
|
335
|
+
checkScriptBackNames,
|
|
336
|
+
isInAppPath,
|
|
337
|
+
isPageFile
|
|
338
|
+
};
|