@jskit-ai/kernel 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/server/support/index.js
CHANGED
|
@@ -2,6 +2,16 @@ export { symlinkSafeRequire } from "./symlinkSafeRequire.js";
|
|
|
2
2
|
export { resolveAppConfig } from "./appConfig.js";
|
|
3
3
|
export { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
|
|
4
4
|
export { resolveRequiredAppRoot, toPosixPath } from "./path.js";
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
7
|
+
DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN,
|
|
8
|
+
normalizePagesRelativeTargetFile,
|
|
9
|
+
normalizePagesRelativeTargetRoot,
|
|
10
|
+
resolvePageTargetDetails,
|
|
11
|
+
deriveDefaultSubpagesHost,
|
|
12
|
+
resolveNearestParentSubpagesHost,
|
|
13
|
+
resolvePageLinkTargetDetails
|
|
14
|
+
} from "./pageTargets.js";
|
|
5
15
|
export {
|
|
6
16
|
discoverShellOutletTargetsFromApp,
|
|
7
17
|
resolveShellOutletPlacementTargetFromApp
|
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
normalizeSurfaceId,
|
|
6
|
+
normalizeSurfacePagesRoot
|
|
7
|
+
} from "../../shared/surface/index.js";
|
|
8
|
+
import {
|
|
9
|
+
normalizeObject,
|
|
10
|
+
normalizeText
|
|
11
|
+
} from "../../shared/support/normalize.js";
|
|
12
|
+
import {
|
|
13
|
+
discoverShellOutletTargetsFromVueSource,
|
|
14
|
+
findShellOutletTargetById,
|
|
15
|
+
normalizeShellOutletTargetId
|
|
16
|
+
} from "../../shared/support/shellLayoutTargets.js";
|
|
17
|
+
import { resolveShellOutletPlacementTargetFromApp } from "./shellOutlets.js";
|
|
18
|
+
import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PAGE_LINK_COMPONENT_TOKEN = "users.web.shell.surface-aware-menu-link-item";
|
|
21
|
+
const DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
|
|
22
|
+
const PAGE_ROOT_PREFIX = "src/pages/";
|
|
23
|
+
const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
|
|
24
|
+
|
|
25
|
+
function normalizeRelativeFilePath(value = "") {
|
|
26
|
+
return String(value || "")
|
|
27
|
+
.replaceAll("\\", "/")
|
|
28
|
+
.replace(/^\.\/+/, "")
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateVueTargetFile(relativePath = "", { context = "page target" } = {}) {
|
|
33
|
+
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
|
|
34
|
+
if (!normalizedRelativePath.endsWith(".vue")) {
|
|
35
|
+
throw new Error(`${context} target file must be a .vue file: ${normalizedRelativePath || "<empty>"}.`);
|
|
36
|
+
}
|
|
37
|
+
return normalizedRelativePath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isAbsolutePathInput(value = "") {
|
|
41
|
+
const normalizedValue = normalizeRelativeFilePath(value);
|
|
42
|
+
if (!normalizedValue) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (normalizedValue.startsWith("/")) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return /^[A-Za-z]:\//u.test(normalizedValue);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolvePagesRelativeAppPath(
|
|
54
|
+
value = "",
|
|
55
|
+
{
|
|
56
|
+
context = "page target",
|
|
57
|
+
label = "target path"
|
|
58
|
+
} = {}
|
|
59
|
+
) {
|
|
60
|
+
const normalizedValue = normalizeRelativeFilePath(value);
|
|
61
|
+
if (!normalizedValue) {
|
|
62
|
+
throw new Error(`${context} requires ${label}.`);
|
|
63
|
+
}
|
|
64
|
+
if (isAbsolutePathInput(normalizedValue)) {
|
|
65
|
+
throw new Error(`${context} ${label} must be relative to src/pages/: ${normalizedValue}.`);
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
normalizedValue === "src/pages" ||
|
|
69
|
+
normalizedValue.startsWith(PAGE_ROOT_PREFIX)
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${context} ${label} must be relative to src/pages/, without the src/pages/ prefix: ${normalizedValue}.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (normalizedValue.startsWith("src/")) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`${context} ${label} must be relative to src/pages/, without a leading src/ segment: ${normalizedValue}.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return `${PAGE_ROOT_PREFIX}${normalizedValue}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizePagesRelativeTargetFile(
|
|
85
|
+
targetFile = "",
|
|
86
|
+
{
|
|
87
|
+
context = "page target",
|
|
88
|
+
label = "target file"
|
|
89
|
+
} = {}
|
|
90
|
+
) {
|
|
91
|
+
return validateVueTargetFile(
|
|
92
|
+
resolvePagesRelativeAppPath(targetFile, { context, label }),
|
|
93
|
+
{ context }
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizePagesRelativeTargetRoot(
|
|
98
|
+
targetRoot = "",
|
|
99
|
+
{
|
|
100
|
+
context = "page target",
|
|
101
|
+
label = "target root"
|
|
102
|
+
} = {}
|
|
103
|
+
) {
|
|
104
|
+
const normalizedRelativePath = resolvePagesRelativeAppPath(targetRoot, {
|
|
105
|
+
context,
|
|
106
|
+
label
|
|
107
|
+
});
|
|
108
|
+
if (normalizedRelativePath.endsWith(".vue")) {
|
|
109
|
+
throw new Error(`${context} ${label} must be a route directory, not a .vue file: ${normalizedRelativePath}.`);
|
|
110
|
+
}
|
|
111
|
+
return normalizedRelativePath;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function splitTextIntoWords(value = "") {
|
|
115
|
+
const normalized = String(value || "")
|
|
116
|
+
.replace(/^\[|\]$/g, "")
|
|
117
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
118
|
+
.replace(/[^A-Za-z0-9]+/g, " ")
|
|
119
|
+
.trim();
|
|
120
|
+
if (!normalized) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return normalized
|
|
125
|
+
.split(/\s+/)
|
|
126
|
+
.map((entry) => entry.toLowerCase())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function wordsToKebab(words = []) {
|
|
131
|
+
return (Array.isArray(words) ? words : [])
|
|
132
|
+
.map((entry) => String(entry || "").toLowerCase())
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.join("-");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toTitleCase(words = []) {
|
|
138
|
+
return (Array.isArray(words) ? words : [])
|
|
139
|
+
.map((word) => {
|
|
140
|
+
const value = String(word || "");
|
|
141
|
+
if (!value) {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
145
|
+
})
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.join(" ");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isRouteGroupSegment(value = "") {
|
|
151
|
+
const normalizedValue = normalizeText(value);
|
|
152
|
+
return normalizedValue.startsWith("(") && normalizedValue.endsWith(")");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isIndexRouteSegment(value = "") {
|
|
156
|
+
return normalizeText(value).toLowerCase() === "index";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isPathlessRouteSegment(value = "") {
|
|
160
|
+
return isRouteGroupSegment(value) || isIndexRouteSegment(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizePlacementIdSegment(value = "") {
|
|
164
|
+
return wordsToKebab(splitTextIntoWords(value));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function humanizePageSegment(value = "", fallback = "Page") {
|
|
168
|
+
const words = splitTextIntoWords(value);
|
|
169
|
+
if (words.length < 1) {
|
|
170
|
+
return fallback;
|
|
171
|
+
}
|
|
172
|
+
return toTitleCase(words);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function loadPublicConfig(appRoot = "", { context = "page target" } = {}) {
|
|
176
|
+
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, { context });
|
|
177
|
+
const configPath = path.join(resolvedAppRoot, "config", "public.js");
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await readFile(configPath, "utf8");
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error(`${context} requires app config at config/public.js.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let moduleNamespace = null;
|
|
186
|
+
try {
|
|
187
|
+
moduleNamespace = await import(pathToFileURL(configPath).href);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`${context} could not load config/public.js: ${String(error?.message || error || "unknown error")}`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const config = normalizeObject(
|
|
195
|
+
moduleNamespace?.config ||
|
|
196
|
+
moduleNamespace?.default?.config ||
|
|
197
|
+
moduleNamespace?.default
|
|
198
|
+
);
|
|
199
|
+
if (Object.keys(config).length < 1) {
|
|
200
|
+
throw new Error(`${context} requires exported config in config/public.js.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return config;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function listSurfacePageRoots(appRoot = "", { context = "page target" } = {}) {
|
|
207
|
+
const config = await loadPublicConfig(appRoot, { context });
|
|
208
|
+
const surfaceDefinitions = normalizeObject(config.surfaceDefinitions);
|
|
209
|
+
|
|
210
|
+
return Object.freeze(
|
|
211
|
+
Object.entries(surfaceDefinitions)
|
|
212
|
+
.map(([key, value]) => {
|
|
213
|
+
const definition = normalizeObject(value);
|
|
214
|
+
const surfaceId = normalizeSurfaceId(definition.id || key);
|
|
215
|
+
if (!surfaceId || definition.enabled === false) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return Object.freeze({
|
|
220
|
+
id: surfaceId,
|
|
221
|
+
pagesRoot: normalizeSurfacePagesRoot(definition.pagesRoot)
|
|
222
|
+
});
|
|
223
|
+
})
|
|
224
|
+
.filter(Boolean)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function deriveSurfaceMatchesFromPageFile(relativePath = "", surfacePageRoots = []) {
|
|
229
|
+
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
|
|
230
|
+
if (!normalizedRelativePath.startsWith(PAGE_ROOT_PREFIX)) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const pagePathWithinPagesRoot = normalizedRelativePath.slice(PAGE_ROOT_PREFIX.length);
|
|
235
|
+
return (Array.isArray(surfacePageRoots) ? surfacePageRoots : [])
|
|
236
|
+
.map((surface) => {
|
|
237
|
+
const pagesRoot = normalizeSurfacePagesRoot(surface?.pagesRoot);
|
|
238
|
+
if (!pagesRoot) {
|
|
239
|
+
return Object.freeze({
|
|
240
|
+
surfaceId: normalizeSurfaceId(surface?.id),
|
|
241
|
+
pagesRoot,
|
|
242
|
+
surfaceRelativeFilePath: pagePathWithinPagesRoot
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const requiredPrefix = `${pagesRoot}/`;
|
|
247
|
+
if (!pagePathWithinPagesRoot.startsWith(requiredPrefix)) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return Object.freeze({
|
|
252
|
+
surfaceId: normalizeSurfaceId(surface?.id),
|
|
253
|
+
pagesRoot,
|
|
254
|
+
surfaceRelativeFilePath: pagePathWithinPagesRoot.slice(requiredPrefix.length)
|
|
255
|
+
});
|
|
256
|
+
})
|
|
257
|
+
.filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function compareSurfaceMatchSpecificity(leftMatch = {}, rightMatch = {}) {
|
|
261
|
+
const leftPagesRoot = normalizeSurfacePagesRoot(leftMatch?.pagesRoot);
|
|
262
|
+
const rightPagesRoot = normalizeSurfacePagesRoot(rightMatch?.pagesRoot);
|
|
263
|
+
const leftSegmentCount = leftPagesRoot ? leftPagesRoot.split("/").filter(Boolean).length : 0;
|
|
264
|
+
const rightSegmentCount = rightPagesRoot ? rightPagesRoot.split("/").filter(Boolean).length : 0;
|
|
265
|
+
|
|
266
|
+
if (leftSegmentCount !== rightSegmentCount) {
|
|
267
|
+
return rightSegmentCount - leftSegmentCount;
|
|
268
|
+
}
|
|
269
|
+
if (leftPagesRoot.length !== rightPagesRoot.length) {
|
|
270
|
+
return rightPagesRoot.length - leftPagesRoot.length;
|
|
271
|
+
}
|
|
272
|
+
return leftPagesRoot.localeCompare(rightPagesRoot);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function resolveBestSurfaceMatchFromPageFile(relativePath = "", surfacePageRoots = [], { context = "page target" } = {}) {
|
|
276
|
+
const matches = deriveSurfaceMatchesFromPageFile(relativePath, surfacePageRoots);
|
|
277
|
+
if (matches.length < 1) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const sortedMatches = [...matches].sort(compareSurfaceMatchSpecificity);
|
|
282
|
+
const bestMatch = sortedMatches[0];
|
|
283
|
+
const bestPagesRoot = normalizeSurfacePagesRoot(bestMatch?.pagesRoot);
|
|
284
|
+
const conflictingMatches = sortedMatches.filter(
|
|
285
|
+
(match) => normalizeSurfacePagesRoot(match?.pagesRoot) === bestPagesRoot
|
|
286
|
+
);
|
|
287
|
+
if (conflictingMatches.length > 1) {
|
|
288
|
+
const surfaceIds = conflictingMatches.map((match) => match.surfaceId).filter(Boolean).join(", ");
|
|
289
|
+
const pagesRootLabel = bestPagesRoot || "/";
|
|
290
|
+
throw new Error(
|
|
291
|
+
`${context} target file is ambiguous because multiple surfaces share pagesRoot "${pagesRootLabel}" (${surfaceIds}): ${normalizeRelativeFilePath(relativePath)}.`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return bestMatch;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function deriveRouteInfoFromSurfaceRelativeFile(surfaceRelativeFilePath = "", surfaceId = "") {
|
|
299
|
+
const normalizedRelativeFilePath = validateVueTargetFile(surfaceRelativeFilePath, {
|
|
300
|
+
context: "page target"
|
|
301
|
+
});
|
|
302
|
+
const withoutExtension = normalizedRelativeFilePath.slice(0, -".vue".length);
|
|
303
|
+
const fileSegments = withoutExtension
|
|
304
|
+
.split("/")
|
|
305
|
+
.map((segment) => normalizeText(segment))
|
|
306
|
+
.filter(Boolean);
|
|
307
|
+
|
|
308
|
+
const routeSegments = [...fileSegments];
|
|
309
|
+
if (routeSegments[routeSegments.length - 1] === "index") {
|
|
310
|
+
routeSegments.pop();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const visibleRouteSegments = routeSegments.filter((segment) => !isPathlessRouteSegment(segment));
|
|
314
|
+
const routeUrlSuffix = visibleRouteSegments.length > 0 ? `/${visibleRouteSegments.join("/")}` : "/";
|
|
315
|
+
const surfacePlacementIdSegment = normalizePlacementIdSegment(surfaceId || "root") || "root";
|
|
316
|
+
const placementIdSegments = visibleRouteSegments
|
|
317
|
+
.map((segment) => normalizePlacementIdSegment(segment))
|
|
318
|
+
.filter(Boolean);
|
|
319
|
+
const pageLeafSegment = visibleRouteSegments[visibleRouteSegments.length - 1] || "";
|
|
320
|
+
const defaultNameSource = pageLeafSegment || surfaceId || "page";
|
|
321
|
+
const defaultName = humanizePageSegment(defaultNameSource, "Page");
|
|
322
|
+
|
|
323
|
+
return Object.freeze({
|
|
324
|
+
fileSegments,
|
|
325
|
+
routeSegments,
|
|
326
|
+
visibleRouteSegments,
|
|
327
|
+
routeUrlSuffix,
|
|
328
|
+
pageLeafSegment,
|
|
329
|
+
defaultName,
|
|
330
|
+
placementId:
|
|
331
|
+
placementIdSegments.length > 0
|
|
332
|
+
? `ui-generator.page.${surfacePlacementIdSegment}.${placementIdSegments.join(".")}.link`
|
|
333
|
+
: `ui-generator.page.${surfacePlacementIdSegment}.link`
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildRouteUrlSuffixFromVisibleSegments(segments = []) {
|
|
338
|
+
const visibleSegments = (Array.isArray(segments) ? segments : [])
|
|
339
|
+
.map((segment) => normalizeText(segment))
|
|
340
|
+
.filter(Boolean);
|
|
341
|
+
return visibleSegments.length > 0 ? `/${visibleSegments.join("/")}` : "/";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function buildAncestorRouteContexts(pageTarget = {}) {
|
|
345
|
+
const routeSegments = Array.isArray(pageTarget?.routeSegments)
|
|
346
|
+
? pageTarget.routeSegments
|
|
347
|
+
: [];
|
|
348
|
+
const visibleRouteSegments = Array.isArray(pageTarget?.visibleRouteSegments)
|
|
349
|
+
? pageTarget.visibleRouteSegments
|
|
350
|
+
: [];
|
|
351
|
+
if (visibleRouteSegments.length < 2) {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const ancestors = [];
|
|
356
|
+
|
|
357
|
+
for (let visiblePrefixLength = visibleRouteSegments.length - 1; visiblePrefixLength >= 1; visiblePrefixLength -= 1) {
|
|
358
|
+
const parentVisibleSegments = visibleRouteSegments.slice(0, visiblePrefixLength);
|
|
359
|
+
const actualRouteSegments = [];
|
|
360
|
+
let collectedVisibleSegments = 0;
|
|
361
|
+
|
|
362
|
+
for (const segment of routeSegments) {
|
|
363
|
+
actualRouteSegments.push(segment);
|
|
364
|
+
if (!isPathlessRouteSegment(segment)) {
|
|
365
|
+
collectedVisibleSegments += 1;
|
|
366
|
+
}
|
|
367
|
+
if (collectedVisibleSegments >= visiblePrefixLength) {
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (collectedVisibleSegments !== visiblePrefixLength) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const nextRouteSegment = normalizeText(routeSegments[actualRouteSegments.length]);
|
|
377
|
+
ancestors.push(
|
|
378
|
+
Object.freeze({
|
|
379
|
+
visibleRouteSegments: parentVisibleSegments,
|
|
380
|
+
actualRouteSegments,
|
|
381
|
+
childUsesIndexRouteOwner: isIndexRouteSegment(nextRouteSegment)
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return ancestors;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function buildParentPageFileCandidates(pageTarget = {}, ancestorRoute = {}) {
|
|
390
|
+
const surfacePagesRootSegments = normalizeRelativeFilePath(pageTarget?.surfacePagesRoot)
|
|
391
|
+
.split("/")
|
|
392
|
+
.map((segment) => normalizeText(segment))
|
|
393
|
+
.filter(Boolean);
|
|
394
|
+
const routeSegments = (Array.isArray(ancestorRoute?.actualRouteSegments) ? ancestorRoute.actualRouteSegments : [])
|
|
395
|
+
.map((segment) => normalizeText(segment))
|
|
396
|
+
.filter(Boolean);
|
|
397
|
+
if (routeSegments.length < 1) {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const baseSegments = ["src/pages", ...surfacePagesRootSegments, ...routeSegments];
|
|
402
|
+
const fileRoutePath = `${baseSegments.join("/")}.vue`;
|
|
403
|
+
const indexRoutePath = [...baseSegments, "index.vue"].join("/");
|
|
404
|
+
const preferredCandidates = ancestorRoute?.childUsesIndexRouteOwner === true
|
|
405
|
+
? [indexRoutePath, fileRoutePath]
|
|
406
|
+
: [fileRoutePath, indexRoutePath];
|
|
407
|
+
|
|
408
|
+
return preferredCandidates.map((relativePath) =>
|
|
409
|
+
Object.freeze({
|
|
410
|
+
relativePath,
|
|
411
|
+
pageShape: relativePath.endsWith("/index.vue") ? "index" : "file"
|
|
412
|
+
})
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function resolveSubpagesHostTargetFromPageSource(source = "") {
|
|
417
|
+
const sourceText = String(source || "");
|
|
418
|
+
if (!ROUTER_VIEW_TAG_PATTERN.test(sourceText)) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const discoveredTargets = discoverShellOutletTargetsFromVueSource(sourceText, {
|
|
423
|
+
context: "subpages host"
|
|
424
|
+
});
|
|
425
|
+
const targets = Array.isArray(discoveredTargets.targets) ? discoveredTargets.targets : [];
|
|
426
|
+
if (targets.length !== 1) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const target = findShellOutletTargetById(targets, targets[0]?.id);
|
|
431
|
+
if (!target) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return Object.freeze({
|
|
436
|
+
id: target.id,
|
|
437
|
+
host: target.host,
|
|
438
|
+
position: target.position
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function resolvePageTargetDetails({
|
|
443
|
+
appRoot,
|
|
444
|
+
targetFile = "",
|
|
445
|
+
context = "page target"
|
|
446
|
+
} = {}) {
|
|
447
|
+
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, { context });
|
|
448
|
+
const normalizedRelativePath = normalizePagesRelativeTargetFile(targetFile, {
|
|
449
|
+
context,
|
|
450
|
+
label: "target file"
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const surfacePageRoots = await listSurfacePageRoots(resolvedAppRoot, { context });
|
|
454
|
+
const surfaceMatch = resolveBestSurfaceMatchFromPageFile(normalizedRelativePath, surfacePageRoots, { context });
|
|
455
|
+
if (!surfaceMatch) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
`${context} target file must be relative to src/pages/ and resolve to a configured surface: ${normalizeRelativeFilePath(targetFile)}.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const routeInfo = deriveRouteInfoFromSurfaceRelativeFile(surfaceMatch.surfaceRelativeFilePath, surfaceMatch.surfaceId);
|
|
461
|
+
const absolutePath = path.resolve(resolvedAppRoot, normalizedRelativePath);
|
|
462
|
+
|
|
463
|
+
return Object.freeze({
|
|
464
|
+
appRoot: resolvedAppRoot,
|
|
465
|
+
targetFilePath: Object.freeze({
|
|
466
|
+
absolutePath,
|
|
467
|
+
relativePath: normalizedRelativePath
|
|
468
|
+
}),
|
|
469
|
+
surfaceId: surfaceMatch.surfaceId,
|
|
470
|
+
surfacePagesRoot: surfaceMatch.pagesRoot,
|
|
471
|
+
surfaceRelativeFilePath: surfaceMatch.surfaceRelativeFilePath,
|
|
472
|
+
...routeInfo
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function deriveDefaultSubpagesHost(pageTarget = {}) {
|
|
477
|
+
const visibleRouteSegments = Array.isArray(pageTarget?.visibleRouteSegments)
|
|
478
|
+
? pageTarget.visibleRouteSegments
|
|
479
|
+
: [];
|
|
480
|
+
const hostSegments = visibleRouteSegments
|
|
481
|
+
.map((segment) => normalizePlacementIdSegment(segment))
|
|
482
|
+
.filter(Boolean);
|
|
483
|
+
|
|
484
|
+
if (hostSegments.length > 0) {
|
|
485
|
+
return hostSegments.join("-");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return normalizePlacementIdSegment(pageTarget?.surfaceId || "page") || "page";
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function resolveNearestParentSubpagesHost({
|
|
492
|
+
appRoot,
|
|
493
|
+
pageTarget = {},
|
|
494
|
+
context = "page target"
|
|
495
|
+
} = {}) {
|
|
496
|
+
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, { context });
|
|
497
|
+
const ancestorRoutes = buildAncestorRouteContexts(pageTarget);
|
|
498
|
+
if (ancestorRoutes.length < 1) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const ancestorRoute of ancestorRoutes) {
|
|
503
|
+
const candidatePages = buildParentPageFileCandidates(pageTarget, ancestorRoute);
|
|
504
|
+
|
|
505
|
+
for (const candidatePage of candidatePages) {
|
|
506
|
+
const candidatePath = path.resolve(resolvedAppRoot, candidatePage.relativePath);
|
|
507
|
+
let source = "";
|
|
508
|
+
try {
|
|
509
|
+
source = await readFile(candidatePath, "utf8");
|
|
510
|
+
} catch {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const target = resolveSubpagesHostTargetFromPageSource(source);
|
|
515
|
+
if (!target) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return Object.freeze({
|
|
520
|
+
...target,
|
|
521
|
+
pageFile: toPosixPath(path.relative(resolvedAppRoot, candidatePath)),
|
|
522
|
+
pageShape: candidatePage.pageShape,
|
|
523
|
+
visibleRouteSegments: ancestorRoute.visibleRouteSegments,
|
|
524
|
+
routeUrlSuffix: buildRouteUrlSuffixFromVisibleSegments(ancestorRoute.visibleRouteSegments)
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function normalizePlacementTargetId(target = {}) {
|
|
533
|
+
const host = normalizeText(target?.host);
|
|
534
|
+
const position = normalizeText(target?.position);
|
|
535
|
+
if (!host || !position) {
|
|
536
|
+
return "";
|
|
537
|
+
}
|
|
538
|
+
return normalizeShellOutletTargetId(`${host}:${position}`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
|
|
542
|
+
const childSegments = Array.isArray(pageTarget?.visibleRouteSegments) ? pageTarget.visibleRouteSegments : [];
|
|
543
|
+
const parentSegments = Array.isArray(parentHost?.visibleRouteSegments) ? parentHost.visibleRouteSegments : [];
|
|
544
|
+
if (parentSegments.length < 1 || childSegments.length <= parentSegments.length) {
|
|
545
|
+
return "";
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const relativeSegments = childSegments.slice(parentSegments.length);
|
|
549
|
+
if (relativeSegments.length < 1) {
|
|
550
|
+
return "";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return `./${relativeSegments.join("/")}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function resolveRelativeLinkToFromNearestIndexOwner(pageTarget = {}) {
|
|
557
|
+
const routeSegments = Array.isArray(pageTarget?.routeSegments) ? pageTarget.routeSegments : [];
|
|
558
|
+
const deepestIndexOwnerIndex = routeSegments.findLastIndex((segment) => isIndexRouteSegment(segment));
|
|
559
|
+
if (deepestIndexOwnerIndex < 0 || deepestIndexOwnerIndex >= routeSegments.length - 1) {
|
|
560
|
+
return "";
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const relativeSegments = routeSegments
|
|
564
|
+
.slice(deepestIndexOwnerIndex + 1)
|
|
565
|
+
.filter((segment) => !isPathlessRouteSegment(segment));
|
|
566
|
+
if (relativeSegments.length < 1) {
|
|
567
|
+
return "";
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return `./${relativeSegments.join("/")}`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function resolveInferredPageLinkTo({
|
|
574
|
+
explicitLinkTo = "",
|
|
575
|
+
pageTarget = {},
|
|
576
|
+
parentHost = null,
|
|
577
|
+
placementTarget = null
|
|
578
|
+
} = {}) {
|
|
579
|
+
const normalizedExplicitLinkTo = normalizeText(explicitLinkTo);
|
|
580
|
+
if (normalizedExplicitLinkTo) {
|
|
581
|
+
return normalizedExplicitLinkTo;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const parentTargetId = normalizePlacementTargetId(parentHost);
|
|
585
|
+
const placementTargetId = normalizePlacementTargetId(placementTarget);
|
|
586
|
+
if (parentTargetId && parentTargetId === placementTargetId) {
|
|
587
|
+
const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
|
|
588
|
+
if (inferredLinkTo) {
|
|
589
|
+
return inferredLinkTo;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (normalizeText(parentHost?.pageShape) === "index") {
|
|
594
|
+
const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
|
|
595
|
+
if (inferredLinkTo) {
|
|
596
|
+
return inferredLinkTo;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const inferredLinkTo = resolveRelativeLinkToFromNearestIndexOwner(pageTarget);
|
|
601
|
+
if (inferredLinkTo) {
|
|
602
|
+
return inferredLinkTo;
|
|
603
|
+
}
|
|
604
|
+
return "";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function resolveInferredPageLinkComponentToken({
|
|
608
|
+
explicitComponentToken = "",
|
|
609
|
+
parentHost = null,
|
|
610
|
+
placementTarget = null,
|
|
611
|
+
defaultComponentToken = DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
612
|
+
subpageComponentToken = DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN
|
|
613
|
+
} = {}) {
|
|
614
|
+
const normalizedExplicitToken = normalizeText(explicitComponentToken);
|
|
615
|
+
if (normalizedExplicitToken) {
|
|
616
|
+
return normalizedExplicitToken;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const parentTargetId = normalizePlacementTargetId(parentHost);
|
|
620
|
+
const placementTargetId = normalizePlacementTargetId(placementTarget);
|
|
621
|
+
if (parentTargetId && parentTargetId === placementTargetId) {
|
|
622
|
+
return normalizeText(subpageComponentToken) || DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function resolvePageLinkTargetDetails({
|
|
629
|
+
appRoot,
|
|
630
|
+
targetFile = "",
|
|
631
|
+
pageTarget = null,
|
|
632
|
+
placement = "",
|
|
633
|
+
componentToken = "",
|
|
634
|
+
linkTo = "",
|
|
635
|
+
defaultComponentToken = DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
636
|
+
subpageComponentToken = DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN,
|
|
637
|
+
context = "page target"
|
|
638
|
+
} = {}) {
|
|
639
|
+
const resolvedPageTarget = pageTarget || await resolvePageTargetDetails({
|
|
640
|
+
appRoot,
|
|
641
|
+
targetFile,
|
|
642
|
+
context
|
|
643
|
+
});
|
|
644
|
+
const parentHost = await resolveNearestParentSubpagesHost({
|
|
645
|
+
appRoot: resolvedPageTarget.appRoot,
|
|
646
|
+
pageTarget: resolvedPageTarget,
|
|
647
|
+
context
|
|
648
|
+
});
|
|
649
|
+
const placementTarget = await resolveShellOutletPlacementTargetFromApp({
|
|
650
|
+
appRoot: resolvedPageTarget.appRoot,
|
|
651
|
+
context,
|
|
652
|
+
placement: normalizeText(placement) || parentHost?.id || ""
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
return Object.freeze({
|
|
656
|
+
pageTarget: resolvedPageTarget,
|
|
657
|
+
parentHost,
|
|
658
|
+
placementTarget,
|
|
659
|
+
componentToken: resolveInferredPageLinkComponentToken({
|
|
660
|
+
explicitComponentToken: componentToken,
|
|
661
|
+
parentHost,
|
|
662
|
+
placementTarget,
|
|
663
|
+
defaultComponentToken,
|
|
664
|
+
subpageComponentToken
|
|
665
|
+
}),
|
|
666
|
+
linkTo: resolveInferredPageLinkTo({
|
|
667
|
+
explicitLinkTo: linkTo,
|
|
668
|
+
pageTarget: resolvedPageTarget,
|
|
669
|
+
parentHost,
|
|
670
|
+
placementTarget
|
|
671
|
+
})
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export {
|
|
676
|
+
DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
677
|
+
DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN,
|
|
678
|
+
normalizePagesRelativeTargetFile,
|
|
679
|
+
normalizePagesRelativeTargetRoot,
|
|
680
|
+
resolvePageTargetDetails,
|
|
681
|
+
deriveDefaultSubpagesHost,
|
|
682
|
+
resolveNearestParentSubpagesHost,
|
|
683
|
+
resolvePageLinkTargetDetails
|
|
684
|
+
};
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
deriveDefaultSubpagesHost,
|
|
8
|
+
normalizePagesRelativeTargetRoot,
|
|
9
|
+
resolvePageLinkTargetDetails,
|
|
10
|
+
resolvePageTargetDetails
|
|
11
|
+
} from "./pageTargets.js";
|
|
12
|
+
|
|
13
|
+
async function withTempApp(run) {
|
|
14
|
+
const appRoot = await mkdtemp(path.join(tmpdir(), "kernel-page-targets-"));
|
|
15
|
+
try {
|
|
16
|
+
return await run(appRoot);
|
|
17
|
+
} finally {
|
|
18
|
+
await rm(appRoot, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function writeFileInApp(appRoot, relativePath, source) {
|
|
23
|
+
const absoluteFile = path.join(appRoot, relativePath);
|
|
24
|
+
await mkdir(path.dirname(absoluteFile), { recursive: true });
|
|
25
|
+
await writeFile(absoluteFile, source, "utf8");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function writeConfig(appRoot, source) {
|
|
29
|
+
await writeFileInApp(appRoot, "config/public.js", source);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeShellLayout(appRoot, source = "") {
|
|
33
|
+
await writeFileInApp(
|
|
34
|
+
appRoot,
|
|
35
|
+
"src/components/ShellLayout.vue",
|
|
36
|
+
source ||
|
|
37
|
+
`<template>
|
|
38
|
+
<div>
|
|
39
|
+
<ShellOutlet host="shell-layout" position="top-right" />
|
|
40
|
+
<ShellOutlet host="shell-layout" position="primary-menu" default />
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
43
|
+
`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("resolvePageTargetDetails derives the surface and route data from an explicit page file", async () => {
|
|
48
|
+
await withTempApp(async (appRoot) => {
|
|
49
|
+
await writeConfig(
|
|
50
|
+
appRoot,
|
|
51
|
+
`export const config = {
|
|
52
|
+
surfaceDefinitions: {
|
|
53
|
+
admin: { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", enabled: true }
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
`
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const pageTarget = await resolvePageTargetDetails({
|
|
60
|
+
appRoot,
|
|
61
|
+
targetFile: "w/[workspaceSlug]/admin/catalog/index/products/index.vue",
|
|
62
|
+
context: "page target"
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
assert.equal(pageTarget.surfaceId, "admin");
|
|
66
|
+
assert.equal(pageTarget.surfacePagesRoot, "w/[workspaceSlug]/admin");
|
|
67
|
+
assert.equal(pageTarget.routeUrlSuffix, "/catalog/products");
|
|
68
|
+
assert.equal(pageTarget.placementId, "ui-generator.page.admin.catalog.products.link");
|
|
69
|
+
assert.deepEqual(pageTarget.visibleRouteSegments, ["catalog", "products"]);
|
|
70
|
+
assert.equal(deriveDefaultSubpagesHost(pageTarget), "catalog-products");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("resolvePageTargetDetails includes surface in placement ids for identical routes on different surfaces", async () => {
|
|
75
|
+
await withTempApp(async (appRoot) => {
|
|
76
|
+
await writeConfig(
|
|
77
|
+
appRoot,
|
|
78
|
+
`export const config = {
|
|
79
|
+
surfaceDefinitions: {
|
|
80
|
+
app: { id: "app", pagesRoot: "app", enabled: true },
|
|
81
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
`
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const appPageTarget = await resolvePageTargetDetails({
|
|
88
|
+
appRoot,
|
|
89
|
+
targetFile: "app/reports/index.vue",
|
|
90
|
+
context: "page target"
|
|
91
|
+
});
|
|
92
|
+
const adminPageTarget = await resolvePageTargetDetails({
|
|
93
|
+
appRoot,
|
|
94
|
+
targetFile: "admin/reports/index.vue",
|
|
95
|
+
context: "page target"
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.equal(appPageTarget.placementId, "ui-generator.page.app.reports.link");
|
|
99
|
+
assert.equal(adminPageTarget.placementId, "ui-generator.page.admin.reports.link");
|
|
100
|
+
assert.notEqual(appPageTarget.placementId, adminPageTarget.placementId);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("resolvePageTargetDetails chooses the most specific matching surface pagesRoot", async () => {
|
|
105
|
+
await withTempApp(async (appRoot) => {
|
|
106
|
+
await writeConfig(
|
|
107
|
+
appRoot,
|
|
108
|
+
`export const config = {
|
|
109
|
+
surfaceDefinitions: {
|
|
110
|
+
app: { id: "app", pagesRoot: "w/[workspaceSlug]", enabled: true },
|
|
111
|
+
admin: { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", enabled: true }
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
`
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const pageTarget = await resolvePageTargetDetails({
|
|
118
|
+
appRoot,
|
|
119
|
+
targetFile: "w/[workspaceSlug]/admin/catalog/index.vue",
|
|
120
|
+
context: "page target"
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
assert.equal(pageTarget.surfaceId, "admin");
|
|
124
|
+
assert.equal(pageTarget.surfacePagesRoot, "w/[workspaceSlug]/admin");
|
|
125
|
+
assert.equal(pageTarget.surfaceRelativeFilePath, "catalog/index.vue");
|
|
126
|
+
assert.equal(pageTarget.routeUrlSuffix, "/catalog");
|
|
127
|
+
assert.equal(pageTarget.placementId, "ui-generator.page.admin.catalog.link");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("resolvePageTargetDetails rejects duplicate matching surface pagesRoot definitions", async () => {
|
|
132
|
+
await withTempApp(async (appRoot) => {
|
|
133
|
+
await writeConfig(
|
|
134
|
+
appRoot,
|
|
135
|
+
`export const config = {
|
|
136
|
+
surfaceDefinitions: {
|
|
137
|
+
adminA: { id: "admin-a", pagesRoot: "w/[workspaceSlug]/admin", enabled: true },
|
|
138
|
+
adminB: { id: "admin-b", pagesRoot: "w/[workspaceSlug]/admin", enabled: true }
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
`
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
() =>
|
|
146
|
+
resolvePageTargetDetails({
|
|
147
|
+
appRoot,
|
|
148
|
+
targetFile: "w/[workspaceSlug]/admin/catalog/index.vue",
|
|
149
|
+
context: "page target"
|
|
150
|
+
}),
|
|
151
|
+
/multiple surfaces share pagesRoot "w\/\[workspaceSlug\]\/admin" \(admin-a, admin-b\)/
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("resolvePageTargetDetails rejects target files with a src/pages prefix", async () => {
|
|
157
|
+
await withTempApp(async (appRoot) => {
|
|
158
|
+
await writeConfig(
|
|
159
|
+
appRoot,
|
|
160
|
+
`export const config = {
|
|
161
|
+
surfaceDefinitions: {
|
|
162
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
`
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
() =>
|
|
170
|
+
resolvePageTargetDetails({
|
|
171
|
+
appRoot,
|
|
172
|
+
targetFile: "src/pages/admin/reports/index.vue",
|
|
173
|
+
context: "page target"
|
|
174
|
+
}),
|
|
175
|
+
/must be relative to src\/pages\/, without the src\/pages\/ prefix/
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("normalizePagesRelativeTargetRoot rejects route roots with a src/pages prefix", () => {
|
|
181
|
+
assert.throws(
|
|
182
|
+
() =>
|
|
183
|
+
normalizePagesRelativeTargetRoot("src/pages/admin/customers", {
|
|
184
|
+
context: "crud-ui-generator",
|
|
185
|
+
label: 'option "target-root"'
|
|
186
|
+
}),
|
|
187
|
+
/must be relative to src\/pages\/, without the src\/pages\/ prefix/
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("resolvePageLinkTargetDetails falls back to the app default placement target", async () => {
|
|
192
|
+
await withTempApp(async (appRoot) => {
|
|
193
|
+
await writeConfig(
|
|
194
|
+
appRoot,
|
|
195
|
+
`export const config = {
|
|
196
|
+
surfaceDefinitions: {
|
|
197
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
`
|
|
201
|
+
);
|
|
202
|
+
await writeShellLayout(appRoot);
|
|
203
|
+
|
|
204
|
+
const details = await resolvePageLinkTargetDetails({
|
|
205
|
+
appRoot,
|
|
206
|
+
targetFile: "admin/reports/index.vue",
|
|
207
|
+
context: "page target"
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
assert.equal(details.pageTarget.surfaceId, "admin");
|
|
211
|
+
assert.equal(details.placementTarget.host, "shell-layout");
|
|
212
|
+
assert.equal(details.placementTarget.position, "primary-menu");
|
|
213
|
+
assert.equal(details.componentToken, "users.web.shell.surface-aware-menu-link-item");
|
|
214
|
+
assert.equal(details.linkTo, "");
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("resolvePageLinkTargetDetails inherits a file-route parent subpages host", async () => {
|
|
219
|
+
await withTempApp(async (appRoot) => {
|
|
220
|
+
await writeConfig(
|
|
221
|
+
appRoot,
|
|
222
|
+
`export const config = {
|
|
223
|
+
surfaceDefinitions: {
|
|
224
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
`
|
|
228
|
+
);
|
|
229
|
+
await writeShellLayout(appRoot);
|
|
230
|
+
await writeFileInApp(
|
|
231
|
+
appRoot,
|
|
232
|
+
"src/pages/admin/contacts/[contactId].vue",
|
|
233
|
+
`<template>
|
|
234
|
+
<SectionContainerShell>
|
|
235
|
+
<template #tabs>
|
|
236
|
+
<ShellOutlet host="contact-view" position="sub-pages" />
|
|
237
|
+
</template>
|
|
238
|
+
<RouterView />
|
|
239
|
+
</SectionContainerShell>
|
|
240
|
+
</template>
|
|
241
|
+
`
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const details = await resolvePageLinkTargetDetails({
|
|
245
|
+
appRoot,
|
|
246
|
+
targetFile: "admin/contacts/[contactId]/notes/index.vue",
|
|
247
|
+
context: "page target"
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
assert.equal(details.parentHost?.id, "contact-view:sub-pages");
|
|
251
|
+
assert.equal(details.placementTarget.host, "contact-view");
|
|
252
|
+
assert.equal(details.placementTarget.position, "sub-pages");
|
|
253
|
+
assert.equal(details.componentToken, "local.main.ui.tab-link-item");
|
|
254
|
+
assert.equal(details.linkTo, "./notes");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("resolvePageLinkTargetDetails honors explicit placement and link overrides", async () => {
|
|
259
|
+
await withTempApp(async (appRoot) => {
|
|
260
|
+
await writeConfig(
|
|
261
|
+
appRoot,
|
|
262
|
+
`export const config = {
|
|
263
|
+
surfaceDefinitions: {
|
|
264
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
`
|
|
268
|
+
);
|
|
269
|
+
await writeShellLayout(appRoot);
|
|
270
|
+
|
|
271
|
+
const details = await resolvePageLinkTargetDetails({
|
|
272
|
+
appRoot,
|
|
273
|
+
targetFile: "admin/contacts/[contactId]/index/notes/index.vue",
|
|
274
|
+
placement: "shell-layout:top-right",
|
|
275
|
+
componentToken: "custom.link-item",
|
|
276
|
+
linkTo: "./assistant-notes",
|
|
277
|
+
context: "page target"
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
assert.equal(details.placementTarget.host, "shell-layout");
|
|
281
|
+
assert.equal(details.placementTarget.position, "top-right");
|
|
282
|
+
assert.equal(details.componentToken, "custom.link-item");
|
|
283
|
+
assert.equal(details.linkTo, "./assistant-notes");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("resolvePageLinkTargetDetails inherits an index-route parent subpages host for index children", async () => {
|
|
288
|
+
await withTempApp(async (appRoot) => {
|
|
289
|
+
await writeConfig(
|
|
290
|
+
appRoot,
|
|
291
|
+
`export const config = {
|
|
292
|
+
surfaceDefinitions: {
|
|
293
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
`
|
|
297
|
+
);
|
|
298
|
+
await writeShellLayout(appRoot);
|
|
299
|
+
await writeFileInApp(
|
|
300
|
+
appRoot,
|
|
301
|
+
"src/pages/admin/customers/[customerId]/index.vue",
|
|
302
|
+
`<template>
|
|
303
|
+
<SectionContainerShell>
|
|
304
|
+
<template #tabs>
|
|
305
|
+
<ShellOutlet host="customer-view" position="sub-pages" />
|
|
306
|
+
</template>
|
|
307
|
+
<RouterView />
|
|
308
|
+
</SectionContainerShell>
|
|
309
|
+
</template>
|
|
310
|
+
`
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const details = await resolvePageLinkTargetDetails({
|
|
314
|
+
appRoot,
|
|
315
|
+
targetFile: "admin/customers/[customerId]/index/pets/index.vue",
|
|
316
|
+
context: "page target"
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
assert.equal(details.parentHost?.id, "customer-view:sub-pages");
|
|
320
|
+
assert.equal(details.parentHost?.pageFile, "src/pages/admin/customers/[customerId]/index.vue");
|
|
321
|
+
assert.equal(details.placementTarget.host, "customer-view");
|
|
322
|
+
assert.equal(details.placementTarget.position, "sub-pages");
|
|
323
|
+
assert.equal(details.componentToken, "local.main.ui.tab-link-item");
|
|
324
|
+
assert.equal(details.linkTo, "./pets");
|
|
325
|
+
});
|
|
326
|
+
});
|