@jskit-ai/kernel 0.1.65 → 0.1.66
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 +1 -1
- package/server/support/index.js +2 -0
- package/server/support/pageTargets.js +80 -61
- package/server/support/pageTargets.test.js +103 -15
- package/server/support/shellOutlets.js +177 -44
- package/server/support/shellOutlets.test.js +0 -67
- package/shared/support/shellLayoutTargets.js +219 -4
- package/shared/support/shellLayoutTargets.test.js +0 -2
package/package.json
CHANGED
package/server/support/index.js
CHANGED
|
@@ -16,6 +16,8 @@ export {
|
|
|
16
16
|
resolvePageLinkTargetDetails
|
|
17
17
|
} from "./pageTargets.js";
|
|
18
18
|
export {
|
|
19
|
+
discoverPlacementTopologyFromApp,
|
|
19
20
|
discoverShellOutletTargetsFromApp,
|
|
21
|
+
resolveSemanticPlacementTargetFromApp,
|
|
20
22
|
resolveShellOutletPlacementTargetFromApp
|
|
21
23
|
} from "./shellOutlets.js";
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
findShellOutletTargetById,
|
|
15
15
|
normalizeShellOutletTargetId
|
|
16
16
|
} from "../../shared/support/shellLayoutTargets.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
discoverPlacementTopologyFromApp,
|
|
19
|
+
resolveSemanticPlacementTargetFromApp
|
|
20
|
+
} from "./shellOutlets.js";
|
|
18
21
|
import { resolveRequiredAppRoot, toPosixPath } from "./path.js";
|
|
19
22
|
import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
|
|
20
23
|
|
|
@@ -562,6 +565,48 @@ function normalizePlacementTargetId(target = {}) {
|
|
|
562
565
|
return normalizeShellOutletTargetId(target?.id || target?.target || target);
|
|
563
566
|
}
|
|
564
567
|
|
|
568
|
+
function resolveConcreteTargetOwner(target = "") {
|
|
569
|
+
const normalizedTarget = normalizeShellOutletTargetId(target);
|
|
570
|
+
if (!normalizedTarget) {
|
|
571
|
+
return "";
|
|
572
|
+
}
|
|
573
|
+
return normalizedTarget.slice(0, normalizedTarget.indexOf(":"));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function topologyPlacementTargetsConcreteOutlet(placement = {}, target = "") {
|
|
577
|
+
const normalizedTarget = normalizeShellOutletTargetId(target);
|
|
578
|
+
if (!normalizedTarget) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const variants = placement?.variants && typeof placement.variants === "object" ? placement.variants : {};
|
|
582
|
+
return Object.values(variants).some((variant) => normalizeShellOutletTargetId(variant?.outlet) === normalizedTarget);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function resolveSemanticPlacementTargetForConcreteOutlet({
|
|
586
|
+
appRoot,
|
|
587
|
+
concreteTarget = "",
|
|
588
|
+
surface = ""
|
|
589
|
+
} = {}) {
|
|
590
|
+
const normalizedConcreteTarget = normalizeShellOutletTargetId(concreteTarget);
|
|
591
|
+
if (!normalizedConcreteTarget) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const topology = await discoverPlacementTopologyFromApp({ appRoot });
|
|
596
|
+
const owner = resolveConcreteTargetOwner(normalizedConcreteTarget);
|
|
597
|
+
const normalizedSurface = normalizeSurfaceId(surface);
|
|
598
|
+
return (Array.isArray(topology.placements) ? topology.placements : []).find((placement) => {
|
|
599
|
+
if (!topologyPlacementTargetsConcreteOutlet(placement, normalizedConcreteTarget)) {
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
if (placement.owner && placement.owner !== owner) {
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
const surfaces = Array.isArray(placement.surfaces) ? placement.surfaces : ["*"];
|
|
606
|
+
return !normalizedSurface || surfaces.includes("*") || surfaces.includes(normalizedSurface);
|
|
607
|
+
}) || null;
|
|
608
|
+
}
|
|
609
|
+
|
|
565
610
|
function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
|
|
566
611
|
const childSegments = Array.isArray(pageTarget?.visibleRouteSegments) ? pageTarget.visibleRouteSegments : [];
|
|
567
612
|
const parentSegments = Array.isArray(parentHost?.visibleRouteSegments) ? parentHost.visibleRouteSegments : [];
|
|
@@ -598,7 +643,7 @@ function resolveInferredPageLinkTo({
|
|
|
598
643
|
explicitLinkTo = "",
|
|
599
644
|
pageTarget = {},
|
|
600
645
|
parentHost = null,
|
|
601
|
-
|
|
646
|
+
preservesRelativeSubpageLinks = false,
|
|
602
647
|
suppressImplicitRelativeLinks = false
|
|
603
648
|
} = {}) {
|
|
604
649
|
const normalizedExplicitLinkTo = normalizeText(explicitLinkTo);
|
|
@@ -609,14 +654,10 @@ function resolveInferredPageLinkTo({
|
|
|
609
654
|
return "";
|
|
610
655
|
}
|
|
611
656
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
|
|
617
|
-
if (inferredLinkTo) {
|
|
618
|
-
return inferredLinkTo;
|
|
619
|
-
}
|
|
657
|
+
if (preservesRelativeSubpageLinks === true && parentHost?.isSectionSubpagesHost === true) {
|
|
658
|
+
const inferredLinkTo = resolveRelativeLinkToFromParent(pageTarget, parentHost);
|
|
659
|
+
if (inferredLinkTo) {
|
|
660
|
+
return inferredLinkTo;
|
|
620
661
|
}
|
|
621
662
|
}
|
|
622
663
|
|
|
@@ -627,36 +668,6 @@ function resolveInferredPageLinkTo({
|
|
|
627
668
|
return "";
|
|
628
669
|
}
|
|
629
670
|
|
|
630
|
-
function resolveInferredPageLinkComponentToken({
|
|
631
|
-
explicitComponentToken = "",
|
|
632
|
-
parentHost = null,
|
|
633
|
-
placementTarget = null,
|
|
634
|
-
defaultComponentToken = DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
635
|
-
subpageComponentToken = DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN
|
|
636
|
-
} = {}) {
|
|
637
|
-
const normalizedExplicitToken = normalizeText(explicitComponentToken);
|
|
638
|
-
if (normalizedExplicitToken) {
|
|
639
|
-
return normalizedExplicitToken;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const normalizedPlacementTargetDefaultToken = normalizeText(placementTarget?.defaultLinkComponentToken);
|
|
643
|
-
if (normalizedPlacementTargetDefaultToken) {
|
|
644
|
-
return normalizedPlacementTargetDefaultToken;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const parentTargetId = normalizePlacementTargetId(parentHost);
|
|
648
|
-
const placementTargetId = normalizePlacementTargetId(placementTarget);
|
|
649
|
-
if (
|
|
650
|
-
parentHost?.isSectionSubpagesHost === true &&
|
|
651
|
-
parentTargetId &&
|
|
652
|
-
parentTargetId === placementTargetId
|
|
653
|
-
) {
|
|
654
|
-
return normalizeText(subpageComponentToken) || DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
671
|
function renderPageLinkWhenLine(pageTarget = {}) {
|
|
661
672
|
if (pageTarget?.surfaceRequiresAuth !== true) {
|
|
662
673
|
return "";
|
|
@@ -672,8 +683,6 @@ async function resolvePageLinkTargetDetails({
|
|
|
672
683
|
placement = "",
|
|
673
684
|
componentToken = "",
|
|
674
685
|
linkTo = "",
|
|
675
|
-
defaultComponentToken = DEFAULT_PAGE_LINK_COMPONENT_TOKEN,
|
|
676
|
-
subpageComponentToken = DEFAULT_SUBPAGE_LINK_COMPONENT_TOKEN,
|
|
677
686
|
context = "page target"
|
|
678
687
|
} = {}) {
|
|
679
688
|
const resolvedPageTarget = pageTarget || await resolvePageTargetDetails({
|
|
@@ -686,39 +695,49 @@ async function resolvePageLinkTargetDetails({
|
|
|
686
695
|
pageTarget: resolvedPageTarget,
|
|
687
696
|
context
|
|
688
697
|
});
|
|
689
|
-
const placementTarget = await resolveShellOutletPlacementTargetFromApp({
|
|
690
|
-
appRoot: resolvedPageTarget.appRoot,
|
|
691
|
-
context,
|
|
692
|
-
placement: normalizeText(placement) || parentHost?.id || ""
|
|
693
|
-
});
|
|
694
|
-
const resolvedComponentToken = resolveInferredPageLinkComponentToken({
|
|
695
|
-
explicitComponentToken: componentToken,
|
|
696
|
-
parentHost,
|
|
697
|
-
placementTarget,
|
|
698
|
-
defaultComponentToken,
|
|
699
|
-
subpageComponentToken
|
|
700
|
-
});
|
|
701
698
|
const parentTargetId = normalizePlacementTargetId(parentHost);
|
|
702
|
-
|
|
699
|
+
let placementTarget = null;
|
|
700
|
+
const explicitPlacement = normalizeText(placement);
|
|
701
|
+
if (explicitPlacement) {
|
|
702
|
+
placementTarget = await resolveSemanticPlacementTargetFromApp({
|
|
703
|
+
appRoot: resolvedPageTarget.appRoot,
|
|
704
|
+
context,
|
|
705
|
+
placement: explicitPlacement,
|
|
706
|
+
owner: parentTargetId ? resolveConcreteTargetOwner(parentTargetId) : "",
|
|
707
|
+
surface: resolvedPageTarget.surfaceId
|
|
708
|
+
});
|
|
709
|
+
} else if (parentTargetId) {
|
|
710
|
+
placementTarget = await resolveSemanticPlacementTargetForConcreteOutlet({
|
|
711
|
+
appRoot: resolvedPageTarget.appRoot,
|
|
712
|
+
concreteTarget: parentTargetId,
|
|
713
|
+
surface: resolvedPageTarget.surfaceId,
|
|
714
|
+
context
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
if (!placementTarget) {
|
|
718
|
+
placementTarget = await resolveSemanticPlacementTargetFromApp({
|
|
719
|
+
appRoot: resolvedPageTarget.appRoot,
|
|
720
|
+
context,
|
|
721
|
+
surface: resolvedPageTarget.surfaceId
|
|
722
|
+
});
|
|
723
|
+
}
|
|
703
724
|
const preservesRelativeSubpageLinks =
|
|
704
725
|
parentHost?.isSectionSubpagesHost === true &&
|
|
705
726
|
Boolean(parentTargetId) &&
|
|
706
|
-
parentTargetId
|
|
727
|
+
topologyPlacementTargetsConcreteOutlet(placementTarget, parentTargetId);
|
|
707
728
|
|
|
708
729
|
return Object.freeze({
|
|
709
730
|
pageTarget: resolvedPageTarget,
|
|
710
731
|
parentHost,
|
|
711
732
|
placementTarget,
|
|
712
|
-
componentToken:
|
|
733
|
+
componentToken: normalizeText(componentToken),
|
|
713
734
|
whenLine: renderPageLinkWhenLine(resolvedPageTarget),
|
|
714
735
|
linkTo: resolveInferredPageLinkTo({
|
|
715
736
|
explicitLinkTo: linkTo,
|
|
716
737
|
pageTarget: resolvedPageTarget,
|
|
717
738
|
parentHost,
|
|
718
|
-
|
|
719
|
-
suppressImplicitRelativeLinks:
|
|
720
|
-
resolvedComponentToken === (normalizeText(defaultComponentToken) || DEFAULT_PAGE_LINK_COMPONENT_TOKEN) &&
|
|
721
|
-
preservesRelativeSubpageLinks !== true
|
|
739
|
+
preservesRelativeSubpageLinks,
|
|
740
|
+
suppressImplicitRelativeLinks: preservesRelativeSubpageLinks !== true
|
|
722
741
|
})
|
|
723
742
|
});
|
|
724
743
|
}
|
|
@@ -40,10 +40,71 @@ async function writeShellLayout(appRoot, source = "") {
|
|
|
40
40
|
<ShellOutlet
|
|
41
41
|
target="shell-layout:primary-menu"
|
|
42
42
|
default
|
|
43
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
44
43
|
/>
|
|
45
44
|
</div>
|
|
46
45
|
</template>
|
|
46
|
+
`
|
|
47
|
+
);
|
|
48
|
+
await writePlacementTopology(appRoot);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderTopologyVariant(outlet, { linkRenderer = "" } = {}) {
|
|
52
|
+
const rendererLines = linkRenderer
|
|
53
|
+
? `,
|
|
54
|
+
renderers: {
|
|
55
|
+
link: "${linkRenderer}"
|
|
56
|
+
}`
|
|
57
|
+
: "";
|
|
58
|
+
return `{
|
|
59
|
+
outlet: "${outlet}"${rendererLines}
|
|
60
|
+
}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderTopologyEntry({
|
|
64
|
+
id = "",
|
|
65
|
+
owner = "",
|
|
66
|
+
surfaces = ["*"],
|
|
67
|
+
defaultPlacement = false,
|
|
68
|
+
outlet = "",
|
|
69
|
+
linkRenderer = ""
|
|
70
|
+
} = {}) {
|
|
71
|
+
const ownerLine = owner ? ` owner: "${owner}",\n` : "";
|
|
72
|
+
const defaultLine = defaultPlacement ? " default: true,\n" : "";
|
|
73
|
+
return ` {
|
|
74
|
+
id: "${id}",
|
|
75
|
+
${ownerLine} surfaces: ${JSON.stringify(surfaces)},
|
|
76
|
+
${defaultLine} variants: {
|
|
77
|
+
compact: ${renderTopologyVariant(outlet, { linkRenderer })},
|
|
78
|
+
medium: ${renderTopologyVariant(outlet, { linkRenderer })},
|
|
79
|
+
expanded: ${renderTopologyVariant(outlet, { linkRenderer })}
|
|
80
|
+
}
|
|
81
|
+
}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function writePlacementTopology(appRoot, entries = []) {
|
|
85
|
+
const defaultEntries = [
|
|
86
|
+
renderTopologyEntry({
|
|
87
|
+
id: "shell.primary-nav",
|
|
88
|
+
surfaces: ["*"],
|
|
89
|
+
defaultPlacement: true,
|
|
90
|
+
outlet: "shell-layout:primary-menu",
|
|
91
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
92
|
+
}),
|
|
93
|
+
renderTopologyEntry({
|
|
94
|
+
id: "shell.status",
|
|
95
|
+
surfaces: ["*"],
|
|
96
|
+
outlet: "shell-layout:top-right",
|
|
97
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
98
|
+
})
|
|
99
|
+
];
|
|
100
|
+
await writeFileInApp(
|
|
101
|
+
appRoot,
|
|
102
|
+
"src/placementTopology.js",
|
|
103
|
+
`export default {
|
|
104
|
+
placements: [
|
|
105
|
+
${[...defaultEntries, ...entries].join(",\n")}
|
|
106
|
+
]
|
|
107
|
+
};
|
|
47
108
|
`
|
|
48
109
|
);
|
|
49
110
|
}
|
|
@@ -246,8 +307,8 @@ test("resolvePageLinkTargetDetails falls back to the app default placement targe
|
|
|
246
307
|
});
|
|
247
308
|
|
|
248
309
|
assert.equal(details.pageTarget.surfaceId, "admin");
|
|
249
|
-
assert.equal(details.placementTarget.id, "shell
|
|
250
|
-
assert.equal(details.componentToken, "
|
|
310
|
+
assert.equal(details.placementTarget.id, "shell.primary-nav");
|
|
311
|
+
assert.equal(details.componentToken, "");
|
|
251
312
|
assert.equal(details.linkTo, "");
|
|
252
313
|
assert.equal(details.whenLine, "");
|
|
253
314
|
});
|
|
@@ -292,15 +353,21 @@ test("resolvePageLinkTargetDetails prefers an outlet-declared default link token
|
|
|
292
353
|
};
|
|
293
354
|
`
|
|
294
355
|
);
|
|
356
|
+
await writePlacementTopology(appRoot, [
|
|
357
|
+
renderTopologyEntry({
|
|
358
|
+
id: "page.section-nav",
|
|
359
|
+
owner: "home-settings",
|
|
360
|
+
surfaces: ["home"],
|
|
361
|
+
outlet: "home-settings:primary-menu",
|
|
362
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
363
|
+
})
|
|
364
|
+
]);
|
|
295
365
|
await writeFileInApp(
|
|
296
366
|
appRoot,
|
|
297
367
|
"src/pages/home/settings.vue",
|
|
298
368
|
`<template>
|
|
299
369
|
<section>
|
|
300
|
-
<ShellOutlet
|
|
301
|
-
target="home-settings:primary-menu"
|
|
302
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
303
|
-
/>
|
|
370
|
+
<ShellOutlet target="home-settings:primary-menu" />
|
|
304
371
|
<RouterView />
|
|
305
372
|
</section>
|
|
306
373
|
</template>
|
|
@@ -314,8 +381,9 @@ test("resolvePageLinkTargetDetails prefers an outlet-declared default link token
|
|
|
314
381
|
});
|
|
315
382
|
|
|
316
383
|
assert.equal(details.parentHost?.id, "home-settings:primary-menu");
|
|
317
|
-
assert.equal(details.placementTarget.id, "
|
|
318
|
-
assert.equal(details.
|
|
384
|
+
assert.equal(details.placementTarget.id, "page.section-nav");
|
|
385
|
+
assert.equal(details.placementTarget.owner, "home-settings");
|
|
386
|
+
assert.equal(details.componentToken, "");
|
|
319
387
|
assert.equal(details.linkTo, "");
|
|
320
388
|
});
|
|
321
389
|
});
|
|
@@ -332,6 +400,15 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
|
|
|
332
400
|
`
|
|
333
401
|
);
|
|
334
402
|
await writeShellLayout(appRoot);
|
|
403
|
+
await writePlacementTopology(appRoot, [
|
|
404
|
+
renderTopologyEntry({
|
|
405
|
+
id: "page.section-nav",
|
|
406
|
+
owner: "contact-view",
|
|
407
|
+
surfaces: ["admin"],
|
|
408
|
+
outlet: "contact-view:sub-pages",
|
|
409
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
410
|
+
})
|
|
411
|
+
]);
|
|
335
412
|
await writeFileInApp(
|
|
336
413
|
appRoot,
|
|
337
414
|
"src/pages/admin/contacts/[contactId].vue",
|
|
@@ -353,8 +430,9 @@ test("resolvePageLinkTargetDetails inherits a file-route parent subpages host",
|
|
|
353
430
|
});
|
|
354
431
|
|
|
355
432
|
assert.equal(details.parentHost?.id, "contact-view:sub-pages");
|
|
356
|
-
assert.equal(details.placementTarget.id, "
|
|
357
|
-
assert.equal(details.
|
|
433
|
+
assert.equal(details.placementTarget.id, "page.section-nav");
|
|
434
|
+
assert.equal(details.placementTarget.owner, "contact-view");
|
|
435
|
+
assert.equal(details.componentToken, "");
|
|
358
436
|
assert.equal(details.linkTo, "./notes");
|
|
359
437
|
});
|
|
360
438
|
});
|
|
@@ -375,13 +453,13 @@ test("resolvePageLinkTargetDetails honors explicit placement and link overrides"
|
|
|
375
453
|
const details = await resolvePageLinkTargetDetails({
|
|
376
454
|
appRoot,
|
|
377
455
|
targetFile: "admin/contacts/[contactId]/index/notes/index.vue",
|
|
378
|
-
placement: "shell
|
|
456
|
+
placement: "shell.status",
|
|
379
457
|
componentToken: "custom.link-item",
|
|
380
458
|
linkTo: "./assistant-notes",
|
|
381
459
|
context: "page target"
|
|
382
460
|
});
|
|
383
461
|
|
|
384
|
-
assert.equal(details.placementTarget.id, "shell
|
|
462
|
+
assert.equal(details.placementTarget.id, "shell.status");
|
|
385
463
|
assert.equal(details.componentToken, "custom.link-item");
|
|
386
464
|
assert.equal(details.linkTo, "./assistant-notes");
|
|
387
465
|
});
|
|
@@ -399,6 +477,15 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
|
|
|
399
477
|
`
|
|
400
478
|
);
|
|
401
479
|
await writeShellLayout(appRoot);
|
|
480
|
+
await writePlacementTopology(appRoot, [
|
|
481
|
+
renderTopologyEntry({
|
|
482
|
+
id: "page.section-nav",
|
|
483
|
+
owner: "customer-view",
|
|
484
|
+
surfaces: ["admin"],
|
|
485
|
+
outlet: "customer-view:sub-pages",
|
|
486
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
487
|
+
})
|
|
488
|
+
]);
|
|
402
489
|
await writeFileInApp(
|
|
403
490
|
appRoot,
|
|
404
491
|
"src/pages/admin/customers/[customerId]/index.vue",
|
|
@@ -421,8 +508,9 @@ test("resolvePageLinkTargetDetails inherits an index-route parent subpages host
|
|
|
421
508
|
|
|
422
509
|
assert.equal(details.parentHost?.id, "customer-view:sub-pages");
|
|
423
510
|
assert.equal(details.parentHost?.pageFile, "src/pages/admin/customers/[customerId]/index.vue");
|
|
424
|
-
assert.equal(details.placementTarget.id, "
|
|
425
|
-
assert.equal(details.
|
|
511
|
+
assert.equal(details.placementTarget.id, "page.section-nav");
|
|
512
|
+
assert.equal(details.placementTarget.owner, "customer-view");
|
|
513
|
+
assert.equal(details.componentToken, "");
|
|
426
514
|
assert.equal(details.linkTo, "./pets");
|
|
427
515
|
});
|
|
428
516
|
});
|
|
@@ -8,13 +8,15 @@ import {
|
|
|
8
8
|
describeShellOutletTargets,
|
|
9
9
|
discoverShellOutletTargetsFromVueSource,
|
|
10
10
|
findShellOutletTargetById,
|
|
11
|
+
normalizePlacementTopologyDefinition,
|
|
12
|
+
normalizeSemanticPlacementId,
|
|
11
13
|
normalizeShellOutletTargetId,
|
|
12
14
|
normalizeShellOutletTargetRecord
|
|
13
15
|
} from "../../shared/support/shellLayoutTargets.js";
|
|
14
|
-
import { loadAppConfigFromModuleUrl } from "./appConfigFiles.js";
|
|
15
16
|
|
|
16
17
|
const VUE_DISCOVERY_IGNORED_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
|
|
17
18
|
const LOCK_FILE_RELATIVE_PATH = ".jskit/lock.json";
|
|
19
|
+
const PLACEMENT_TOPOLOGY_RELATIVE_PATH = "src/placementTopology.js";
|
|
18
20
|
const ROUTE_TAG_PATTERN = /<route\b([^>]*)>([\s\S]*?)<\/route>/g;
|
|
19
21
|
const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
20
22
|
|
|
@@ -218,46 +220,6 @@ function normalizePackageOutletTarget({
|
|
|
218
220
|
});
|
|
219
221
|
}
|
|
220
222
|
|
|
221
|
-
async function loadOutletDefaultOverrides(appRoot = "") {
|
|
222
|
-
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
|
|
223
|
-
context: "discoverShellOutletTargetsFromApp"
|
|
224
|
-
});
|
|
225
|
-
let appConfig = {};
|
|
226
|
-
try {
|
|
227
|
-
appConfig = normalizeObject(
|
|
228
|
-
await loadAppConfigFromModuleUrl({
|
|
229
|
-
moduleUrl: pathToFileURL(path.join(resolvedAppRoot, "config", "public.js")).href
|
|
230
|
-
})
|
|
231
|
-
);
|
|
232
|
-
} catch {
|
|
233
|
-
return {};
|
|
234
|
-
}
|
|
235
|
-
return normalizeObject(normalizeObject(appConfig.ui).outletDefaults);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function applyOutletDefaultOverrides(target = {}, outletDefaultOverrides = {}) {
|
|
239
|
-
const targetRecord = normalizeObject(target);
|
|
240
|
-
const outletTargetId = normalizeShellOutletTargetId(targetRecord.id);
|
|
241
|
-
if (!outletTargetId) {
|
|
242
|
-
return targetRecord;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const overrideRecord = outletDefaultOverrides?.[outletTargetId];
|
|
246
|
-
const normalizedOverrideToken =
|
|
247
|
-
typeof overrideRecord === "string"
|
|
248
|
-
? normalizeText(overrideRecord)
|
|
249
|
-
: normalizeText(normalizeObject(overrideRecord).linkComponentToken) ||
|
|
250
|
-
normalizeText(normalizeObject(overrideRecord)["link-component-token"]);
|
|
251
|
-
if (!normalizedOverrideToken) {
|
|
252
|
-
return targetRecord;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return Object.freeze({
|
|
256
|
-
...targetRecord,
|
|
257
|
-
defaultLinkComponentToken: normalizedOverrideToken
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
223
|
async function collectInstalledPackageOutletTargets(appRoot) {
|
|
262
224
|
const installedPackageStates = await readInstalledPackageStates(appRoot);
|
|
263
225
|
const packageIds = Object.keys(installedPackageStates).sort((left, right) => left.localeCompare(right));
|
|
@@ -290,11 +252,180 @@ async function collectInstalledPackageOutletTargets(appRoot) {
|
|
|
290
252
|
return targets;
|
|
291
253
|
}
|
|
292
254
|
|
|
255
|
+
function withTopologySource(placement = {}, sourcePath = "") {
|
|
256
|
+
return Object.freeze({
|
|
257
|
+
...placement,
|
|
258
|
+
sourcePath
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function loadAppPlacementTopology(appRoot) {
|
|
263
|
+
const topologyPath = path.resolve(appRoot, PLACEMENT_TOPOLOGY_RELATIVE_PATH);
|
|
264
|
+
try {
|
|
265
|
+
await readFile(topologyPath, "utf8");
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const errorCode = normalizeText(error?.code).toUpperCase();
|
|
268
|
+
if (errorCode === "ENOENT" || errorCode === "ENOTDIR") {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let moduleNamespace = null;
|
|
275
|
+
try {
|
|
276
|
+
moduleNamespace = await import(pathToFileURL(topologyPath).href);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Could not load ${PLACEMENT_TOPOLOGY_RELATIVE_PATH}: ${String(error?.message || error || "unknown error")}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const exported = moduleNamespace?.default;
|
|
284
|
+
const resolved = typeof exported === "function" ? exported() : exported;
|
|
285
|
+
const normalized = normalizePlacementTopologyDefinition(resolved, {
|
|
286
|
+
context: PLACEMENT_TOPOLOGY_RELATIVE_PATH
|
|
287
|
+
});
|
|
288
|
+
return normalized.placements.map((placement) => withTopologySource(placement, PLACEMENT_TOPOLOGY_RELATIVE_PATH));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function collectInstalledPackagePlacementTopology(appRoot) {
|
|
292
|
+
const installedPackageStates = await readInstalledPackageStates(appRoot);
|
|
293
|
+
const packageIds = Object.keys(installedPackageStates).sort((left, right) => left.localeCompare(right));
|
|
294
|
+
const placements = [];
|
|
295
|
+
|
|
296
|
+
for (const packageId of packageIds) {
|
|
297
|
+
const installedPackageState = normalizeObject(installedPackageStates[packageId]);
|
|
298
|
+
const descriptorRecord = await loadInstalledPackageDescriptor({
|
|
299
|
+
appRoot,
|
|
300
|
+
packageId,
|
|
301
|
+
installedPackageState
|
|
302
|
+
});
|
|
303
|
+
const descriptor = normalizeObject(descriptorRecord.descriptor);
|
|
304
|
+
const metadata = normalizeObject(descriptor.metadata);
|
|
305
|
+
const ui = normalizeObject(metadata.ui);
|
|
306
|
+
const placementsMeta = normalizeObject(ui.placements);
|
|
307
|
+
const topology = placementsMeta.topology;
|
|
308
|
+
if (!topology) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const normalized = normalizePlacementTopologyDefinition(topology, {
|
|
313
|
+
context: `package:${packageId}:metadata.ui.placements.topology`
|
|
314
|
+
});
|
|
315
|
+
for (const placement of normalized.placements) {
|
|
316
|
+
placements.push(
|
|
317
|
+
withTopologySource(
|
|
318
|
+
placement,
|
|
319
|
+
`package:${packageId}${descriptorRecord.descriptorPath ? `:${toPosixPath(descriptorRecord.descriptorPath)}` : ""}`
|
|
320
|
+
)
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return placements;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function discoverPlacementTopologyFromApp({ appRoot } = {}) {
|
|
329
|
+
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
|
|
330
|
+
context: "discoverPlacementTopologyFromApp"
|
|
331
|
+
});
|
|
332
|
+
const appPlacements = await loadAppPlacementTopology(resolvedAppRoot);
|
|
333
|
+
const packagePlacements = await collectInstalledPackagePlacementTopology(resolvedAppRoot);
|
|
334
|
+
const placementByKey = new Map();
|
|
335
|
+
|
|
336
|
+
for (const placement of [...packagePlacements, ...appPlacements]) {
|
|
337
|
+
const key = `${placement.id}::${placement.owner || ""}`;
|
|
338
|
+
placementByKey.set(key, placement);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return Object.freeze({
|
|
342
|
+
placements: Object.freeze(
|
|
343
|
+
[...placementByKey.values()].sort((left, right) => {
|
|
344
|
+
const idCompare = left.id.localeCompare(right.id);
|
|
345
|
+
if (idCompare !== 0) {
|
|
346
|
+
return idCompare;
|
|
347
|
+
}
|
|
348
|
+
return String(left.owner || "").localeCompare(String(right.owner || ""));
|
|
349
|
+
})
|
|
350
|
+
)
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function findSemanticPlacementById(placements = [], { id = "", owner = "", surface = "" } = {}) {
|
|
355
|
+
const normalizedId = normalizeSemanticPlacementId(id);
|
|
356
|
+
const normalizedOwner = normalizeText(owner).toLowerCase();
|
|
357
|
+
const normalizedSurface = normalizeText(surface).toLowerCase();
|
|
358
|
+
if (!normalizedId) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (Array.isArray(placements) ? placements : []).find((placement) => {
|
|
363
|
+
if (placement.id !== normalizedId) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
if (normalizedOwner && placement.owner !== normalizedOwner) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
if (!normalizedOwner && placement.owner) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
const surfaces = Array.isArray(placement.surfaces) ? placement.surfaces : ["*"];
|
|
373
|
+
return !normalizedSurface || surfaces.includes("*") || surfaces.includes(normalizedSurface);
|
|
374
|
+
}) || null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function resolveSemanticPlacementTargetFromApp({
|
|
378
|
+
appRoot,
|
|
379
|
+
placement = "",
|
|
380
|
+
owner = "",
|
|
381
|
+
surface = "",
|
|
382
|
+
context = "ui-generator"
|
|
383
|
+
} = {}) {
|
|
384
|
+
const resolvedContext = normalizeText(context) || "ui-generator";
|
|
385
|
+
const topology = await discoverPlacementTopologyFromApp({ appRoot });
|
|
386
|
+
const placements = Array.isArray(topology.placements) ? topology.placements : [];
|
|
387
|
+
if (placements.length < 1) {
|
|
388
|
+
throw new Error(`${resolvedContext} could not find semantic placement topology in ${PLACEMENT_TOPOLOGY_RELATIVE_PATH}.`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const requestedPlacement = normalizeText(placement);
|
|
392
|
+
if (requestedPlacement) {
|
|
393
|
+
const requestedId = normalizeSemanticPlacementId(requestedPlacement);
|
|
394
|
+
if (!requestedId) {
|
|
395
|
+
throw new Error(`${resolvedContext} option "placement" must be a semantic target in "area.slot" format.`);
|
|
396
|
+
}
|
|
397
|
+
const match = findSemanticPlacementById(placements, {
|
|
398
|
+
id: requestedId,
|
|
399
|
+
owner,
|
|
400
|
+
surface
|
|
401
|
+
}) || (owner
|
|
402
|
+
? findSemanticPlacementById(placements, {
|
|
403
|
+
id: requestedId,
|
|
404
|
+
owner: "",
|
|
405
|
+
surface
|
|
406
|
+
})
|
|
407
|
+
: null);
|
|
408
|
+
if (!match) {
|
|
409
|
+
throw new Error(`${resolvedContext} semantic placement "${requestedId}" is not declared in app placement topology.`);
|
|
410
|
+
}
|
|
411
|
+
return match;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const defaultPlacement =
|
|
415
|
+
placements.find((entry) => entry.default === true && (!surface || entry.surfaces.includes("*") || entry.surfaces.includes(surface))) ||
|
|
416
|
+
placements.find((entry) => !entry.owner && (!surface || entry.surfaces.includes("*") || entry.surfaces.includes(surface))) ||
|
|
417
|
+
null;
|
|
418
|
+
if (defaultPlacement) {
|
|
419
|
+
return defaultPlacement;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
throw new Error(`${resolvedContext} could not resolve a default semantic placement target.`);
|
|
423
|
+
}
|
|
424
|
+
|
|
293
425
|
async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" } = {}) {
|
|
294
426
|
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
|
|
295
427
|
context: "discoverShellOutletTargetsFromApp"
|
|
296
428
|
});
|
|
297
|
-
const outletDefaultOverrides = await loadOutletDefaultOverrides(resolvedAppRoot);
|
|
298
429
|
|
|
299
430
|
const sourceDirectory = path.resolve(resolvedAppRoot, String(sourceRoot || "src"));
|
|
300
431
|
const targetById = new Map();
|
|
@@ -363,10 +494,10 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
363
494
|
|
|
364
495
|
const targets = [...targetById.values()].sort((left, right) => left.id.localeCompare(right.id));
|
|
365
496
|
const normalizedTargets = targets.map((target) =>
|
|
366
|
-
|
|
497
|
+
Object.freeze({
|
|
367
498
|
...target,
|
|
368
499
|
default: target.id === defaultTargetId
|
|
369
|
-
}
|
|
500
|
+
})
|
|
370
501
|
);
|
|
371
502
|
|
|
372
503
|
return Object.freeze({
|
|
@@ -418,6 +549,8 @@ async function resolveShellOutletPlacementTargetFromApp({ appRoot, placement = "
|
|
|
418
549
|
}
|
|
419
550
|
|
|
420
551
|
export {
|
|
552
|
+
discoverPlacementTopologyFromApp,
|
|
421
553
|
discoverShellOutletTargetsFromApp,
|
|
554
|
+
resolveSemanticPlacementTargetFromApp,
|
|
422
555
|
resolveShellOutletPlacementTargetFromApp
|
|
423
556
|
};
|
|
@@ -99,7 +99,6 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
99
99
|
outlets: [
|
|
100
100
|
{
|
|
101
101
|
target: "admin-cog:primary-menu",
|
|
102
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
103
102
|
source: "src/client/components/UsersWorkspaceToolsWidget.vue"
|
|
104
103
|
}
|
|
105
104
|
]
|
|
@@ -118,7 +117,6 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
118
117
|
assert.deepEqual(discovered.targets[0], {
|
|
119
118
|
id: "admin-cog:primary-menu",
|
|
120
119
|
default: false,
|
|
121
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
122
120
|
sourcePath: "package:@example/users-web:src/client/components/UsersWorkspaceToolsWidget.vue",
|
|
123
121
|
sourcePackageId: "@example/users-web"
|
|
124
122
|
});
|
|
@@ -132,68 +130,6 @@ test("discoverShellOutletTargetsFromApp includes installed package placement out
|
|
|
132
130
|
});
|
|
133
131
|
});
|
|
134
132
|
|
|
135
|
-
test("discoverShellOutletTargetsFromApp applies app config default-link overrides by target id", async () => {
|
|
136
|
-
await withTempApp(async (appRoot) => {
|
|
137
|
-
await writeFileInApp(
|
|
138
|
-
appRoot,
|
|
139
|
-
"config/public.js",
|
|
140
|
-
`export const config = {
|
|
141
|
-
ui: {
|
|
142
|
-
outletDefaults: {
|
|
143
|
-
"admin-cog:primary-menu": {
|
|
144
|
-
linkComponentToken: "local.main.ui.surface-aware-menu-link-item"
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
`
|
|
150
|
-
);
|
|
151
|
-
await writeFileInApp(
|
|
152
|
-
appRoot,
|
|
153
|
-
".jskit/lock.json",
|
|
154
|
-
`${JSON.stringify(
|
|
155
|
-
{
|
|
156
|
-
lockVersion: 1,
|
|
157
|
-
installedPackages: {
|
|
158
|
-
"@example/users-web": {
|
|
159
|
-
packageId: "@example/users-web",
|
|
160
|
-
source: {
|
|
161
|
-
type: "npm-installed-package",
|
|
162
|
-
descriptorPath: "node_modules/@example/users-web/package.descriptor.mjs"
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
null,
|
|
168
|
-
2
|
|
169
|
-
)}\n`
|
|
170
|
-
);
|
|
171
|
-
await writeFileInApp(
|
|
172
|
-
appRoot,
|
|
173
|
-
"node_modules/@example/users-web/package.descriptor.mjs",
|
|
174
|
-
`export default {
|
|
175
|
-
packageId: "@example/users-web",
|
|
176
|
-
metadata: {
|
|
177
|
-
ui: {
|
|
178
|
-
placements: {
|
|
179
|
-
outlets: [
|
|
180
|
-
{
|
|
181
|
-
target: "admin-cog:primary-menu",
|
|
182
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item"
|
|
183
|
-
}
|
|
184
|
-
]
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
`
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
|
|
193
|
-
assert.equal(discovered.targets[0].defaultLinkComponentToken, "local.main.ui.surface-aware-menu-link-item");
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
133
|
test("discoverShellOutletTargetsFromApp returns targets with sourcePath and default marker", async () => {
|
|
198
134
|
await withTempApp(async (appRoot) => {
|
|
199
135
|
await writeFileInApp(
|
|
@@ -223,13 +159,11 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
223
159
|
{
|
|
224
160
|
id: "admin-toolbox:widgets",
|
|
225
161
|
default: true,
|
|
226
|
-
defaultLinkComponentToken: "",
|
|
227
162
|
sourcePath: "src/pages/admin/toolbox/index.vue"
|
|
228
163
|
},
|
|
229
164
|
{
|
|
230
165
|
id: "shell-layout:primary-menu",
|
|
231
166
|
default: false,
|
|
232
|
-
defaultLinkComponentToken: "",
|
|
233
167
|
sourcePath: "src/components/ShellLayout.vue"
|
|
234
168
|
}
|
|
235
169
|
]);
|
|
@@ -266,7 +200,6 @@ test("discoverShellOutletTargetsFromApp discovers route meta placement outlets",
|
|
|
266
200
|
{
|
|
267
201
|
id: "contact-tools:sub-pages",
|
|
268
202
|
default: false,
|
|
269
|
-
defaultLinkComponentToken: "",
|
|
270
203
|
sourcePath: "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue"
|
|
271
204
|
}
|
|
272
205
|
]);
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
|
|
6
6
|
const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b([^>]*)\/?>/g;
|
|
7
7
|
const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
8
|
+
const PLACEMENT_LAYOUT_CLASSES = Object.freeze(["compact", "medium", "expanded"]);
|
|
9
|
+
const WEB_PLACEMENT_SURFACE_ANY = "*";
|
|
8
10
|
|
|
9
11
|
function parseTagAttributes(attributesSource = "") {
|
|
10
12
|
const attributes = {};
|
|
@@ -43,6 +45,211 @@ function normalizeShellOutletTargetId(value = "") {
|
|
|
43
45
|
return `${host}:${position}`;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
function normalizeSemanticPlacementId(value = "") {
|
|
49
|
+
const normalizedValue = normalizeText(value).toLowerCase();
|
|
50
|
+
if (!normalizedValue || normalizedValue.includes(":") || !normalizedValue.includes(".")) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const segments = normalizedValue.split(".");
|
|
55
|
+
if (segments.length < 2) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const normalizedSegments = [];
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
const normalizedSegment = normalizeText(segment);
|
|
62
|
+
if (!/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/.test(normalizedSegment)) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
normalizedSegments.push(normalizedSegment);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return normalizedSegments.join(".");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizePlacementOwnerId(value = "") {
|
|
72
|
+
const normalizedValue = normalizeText(value).toLowerCase();
|
|
73
|
+
if (!normalizedValue) {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(normalizedValue)) {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
return normalizedValue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizePlacementLayoutClass(value = "") {
|
|
83
|
+
const normalizedValue = normalizeText(value).toLowerCase();
|
|
84
|
+
if (PLACEMENT_LAYOUT_CLASSES.includes(normalizedValue)) {
|
|
85
|
+
return normalizedValue;
|
|
86
|
+
}
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizePlacementKind(value = "") {
|
|
91
|
+
const normalizedValue = normalizeText(value).toLowerCase();
|
|
92
|
+
if (normalizedValue === "link" || normalizedValue === "component") {
|
|
93
|
+
return normalizedValue;
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizePlacementSurfaceId(value = "") {
|
|
99
|
+
const normalizedValue = normalizeText(value).toLowerCase();
|
|
100
|
+
if (!normalizedValue) {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
if (normalizedValue === WEB_PLACEMENT_SURFACE_ANY) {
|
|
104
|
+
return WEB_PLACEMENT_SURFACE_ANY;
|
|
105
|
+
}
|
|
106
|
+
if (/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(normalizedValue)) {
|
|
107
|
+
return normalizedValue;
|
|
108
|
+
}
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizePlacementSurfaces(value) {
|
|
113
|
+
const candidates = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
114
|
+
if (candidates.length < 1) {
|
|
115
|
+
return Object.freeze([WEB_PLACEMENT_SURFACE_ANY]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const normalized = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
const surface = normalizePlacementSurfaceId(candidate);
|
|
122
|
+
if (!surface || seen.has(surface)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (surface === WEB_PLACEMENT_SURFACE_ANY) {
|
|
126
|
+
return Object.freeze([WEB_PLACEMENT_SURFACE_ANY]);
|
|
127
|
+
}
|
|
128
|
+
seen.add(surface);
|
|
129
|
+
normalized.push(surface);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (normalized.length < 1) {
|
|
133
|
+
return Object.freeze([WEB_PLACEMENT_SURFACE_ANY]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Object.freeze(normalized);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolvePlacementTargetReference(value = "") {
|
|
140
|
+
const semanticId = normalizeSemanticPlacementId(value);
|
|
141
|
+
if (semanticId) {
|
|
142
|
+
return Object.freeze({
|
|
143
|
+
id: semanticId,
|
|
144
|
+
type: "semantic"
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const concreteId = normalizeShellOutletTargetId(value);
|
|
149
|
+
if (concreteId) {
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
id: concreteId,
|
|
152
|
+
type: "concrete"
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizePlacementRenderers(value = {}) {
|
|
160
|
+
const record = normalizeObject(value);
|
|
161
|
+
const renderers = {};
|
|
162
|
+
for (const [key, rendererToken] of Object.entries(record)) {
|
|
163
|
+
const kind = normalizePlacementKind(key);
|
|
164
|
+
const token = normalizeText(rendererToken);
|
|
165
|
+
if (!kind || !token) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
renderers[kind] = token;
|
|
169
|
+
}
|
|
170
|
+
return Object.freeze(renderers);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizePlacementTopologyVariant(
|
|
174
|
+
value = {},
|
|
175
|
+
{
|
|
176
|
+
context = "placement topology variant"
|
|
177
|
+
} = {}
|
|
178
|
+
) {
|
|
179
|
+
const record = normalizeObject(value);
|
|
180
|
+
const outlet = normalizeShellOutletTargetId(record.outlet || record.target);
|
|
181
|
+
if (!outlet) {
|
|
182
|
+
throw new Error(`${normalizeText(context) || "placement topology variant"} requires outlet in "host:position" format.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return Object.freeze({
|
|
186
|
+
outlet,
|
|
187
|
+
renderers: normalizePlacementRenderers(record.renderers)
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizePlacementTopologyEntry(
|
|
192
|
+
value = {},
|
|
193
|
+
{
|
|
194
|
+
context = "placement topology"
|
|
195
|
+
} = {}
|
|
196
|
+
) {
|
|
197
|
+
const record = normalizeObject(value);
|
|
198
|
+
const resolvedContext = normalizeText(context) || "placement topology";
|
|
199
|
+
const id = normalizeSemanticPlacementId(record.id || record.target);
|
|
200
|
+
if (!id) {
|
|
201
|
+
throw new Error(`${resolvedContext} requires semantic placement id in "area.slot" format.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const owner = normalizePlacementOwnerId(record.owner);
|
|
205
|
+
const surfaces = normalizePlacementSurfaces(record.surfaces);
|
|
206
|
+
const variantsRecord = normalizeObject(record.variants);
|
|
207
|
+
const variants = {};
|
|
208
|
+
for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
|
|
209
|
+
const variant = variantsRecord[layoutClass];
|
|
210
|
+
if (!variant) {
|
|
211
|
+
throw new Error(`${resolvedContext} "${id}" requires ${layoutClass} topology variant.`);
|
|
212
|
+
}
|
|
213
|
+
variants[layoutClass] = normalizePlacementTopologyVariant(variant, {
|
|
214
|
+
context: `${resolvedContext} "${id}" ${layoutClass}`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return Object.freeze({
|
|
219
|
+
id,
|
|
220
|
+
owner,
|
|
221
|
+
description: normalizeText(record.description),
|
|
222
|
+
surfaces,
|
|
223
|
+
default: record.default === true,
|
|
224
|
+
variants: Object.freeze(variants)
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizePlacementTopologyDefinition(
|
|
229
|
+
value = {},
|
|
230
|
+
{
|
|
231
|
+
context = "placement topology"
|
|
232
|
+
} = {}
|
|
233
|
+
) {
|
|
234
|
+
const record = normalizeObject(value);
|
|
235
|
+
const entries = Array.isArray(record.placements) ? record.placements : Array.isArray(value) ? value : [];
|
|
236
|
+
const normalized = [];
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
const placement = normalizePlacementTopologyEntry(entry, { context });
|
|
240
|
+
const key = `${placement.id}::${placement.owner || ""}`;
|
|
241
|
+
if (seen.has(key)) {
|
|
242
|
+
throw new Error(`${normalizeText(context) || "placement topology"} contains duplicate semantic placement "${placement.id}"${placement.owner ? ` for owner "${placement.owner}"` : ""}.`);
|
|
243
|
+
}
|
|
244
|
+
seen.add(key);
|
|
245
|
+
normalized.push(placement);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return Object.freeze({
|
|
249
|
+
placements: Object.freeze(normalized)
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
46
253
|
function resolveShellOutletTargetParts(
|
|
47
254
|
{
|
|
48
255
|
target = ""
|
|
@@ -117,10 +324,7 @@ function normalizeShellOutletTargetRecord(
|
|
|
117
324
|
...targetParts,
|
|
118
325
|
default:
|
|
119
326
|
Object.hasOwn(record, "default") &&
|
|
120
|
-
isDefaultAttributeEnabled(record.default)
|
|
121
|
-
defaultLinkComponentToken:
|
|
122
|
-
normalizeText(record.defaultLinkComponentToken) ||
|
|
123
|
-
normalizeText(record["default-link-component-token"])
|
|
327
|
+
isDefaultAttributeEnabled(record.default)
|
|
124
328
|
});
|
|
125
329
|
}
|
|
126
330
|
|
|
@@ -161,10 +365,21 @@ function discoverShellOutletTargetsFromVueSource(source = "", { context = "shell
|
|
|
161
365
|
}
|
|
162
366
|
|
|
163
367
|
export {
|
|
368
|
+
PLACEMENT_LAYOUT_CLASSES,
|
|
164
369
|
describeShellOutletTargets,
|
|
165
370
|
discoverShellOutletTargetsFromVueSource,
|
|
166
371
|
findShellOutletTargetById,
|
|
372
|
+
normalizePlacementKind,
|
|
373
|
+
normalizePlacementLayoutClass,
|
|
374
|
+
normalizePlacementOwnerId,
|
|
375
|
+
normalizePlacementSurfaceId,
|
|
376
|
+
normalizePlacementSurfaces,
|
|
377
|
+
normalizePlacementTopologyDefinition,
|
|
378
|
+
normalizePlacementTopologyEntry,
|
|
379
|
+
normalizePlacementTopologyVariant,
|
|
380
|
+
normalizeSemanticPlacementId,
|
|
167
381
|
normalizeShellOutletTargetId,
|
|
168
382
|
normalizeShellOutletTargetRecord,
|
|
383
|
+
resolvePlacementTargetReference,
|
|
169
384
|
resolveShellOutletTargetParts
|
|
170
385
|
};
|
|
@@ -22,7 +22,6 @@ test("discoverShellOutletTargetsFromVueSource resolves legal targets and one def
|
|
|
22
22
|
<ShellOutlet
|
|
23
23
|
target="shell-layout:primary-menu"
|
|
24
24
|
default
|
|
25
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
26
25
|
/>
|
|
27
26
|
<ShellOutlet target="shell-layout:secondary-menu" />
|
|
28
27
|
</template>
|
|
@@ -37,7 +36,6 @@ test("discoverShellOutletTargetsFromVueSource resolves legal targets and one def
|
|
|
37
36
|
discovered.targets.map((entry) => entry.id),
|
|
38
37
|
["shell-layout:top-left", "shell-layout:primary-menu", "shell-layout:secondary-menu"]
|
|
39
38
|
);
|
|
40
|
-
assert.equal(discovered.targets[1].defaultLinkComponentToken, "local.main.ui.surface-aware-menu-link-item");
|
|
41
39
|
assert.equal(
|
|
42
40
|
describeShellOutletTargets(discovered.targets),
|
|
43
41
|
"shell-layout:top-left, shell-layout:primary-menu, shell-layout:secondary-menu"
|