@jskit-ai/kernel 0.1.31 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "typebox": "^1.0.81"
@@ -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
+ });