@jskit-ai/kernel 0.1.65 → 0.1.67
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/client/moduleBootstrap.js +4 -0
- package/client/moduleBootstrap.test.js +4 -0
- package/client/shellBootstrap.js +2 -0
- package/client/shellBootstrap.test.js +3 -0
- package/client/vite/clientBootstrapPlugin.js +1 -0
- package/client/vite/clientBootstrapPlugin.test.js +3 -3
- package/package.json +2 -1
- package/server/http/lib/httpRuntime.js +3 -1
- package/server/http/lib/kernel.test.js +4 -0
- package/server/runtime/fastifyBootstrap.js +61 -0
- package/server/runtime/fastifyBootstrap.test.js +47 -1
- package/server/support/appConfigFiles.js +3 -2
- package/server/support/appConfigFiles.test.js +15 -0
- package/server/support/index.js +3 -0
- package/server/support/pageTargets.js +98 -63
- package/server/support/pageTargets.test.js +246 -15
- package/server/support/shellOutlets.js +245 -56
- package/server/support/shellOutlets.test.js +31 -67
- package/shared/support/generatedUiContract.js +542 -0
- package/shared/support/generatedUiContract.test.js +208 -0
- package/shared/support/shellLayoutTargets.js +230 -7
- package/shared/support/shellLayoutTargets.test.js +20 -2
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
GENERATED_UI_NAVIGATION_ROLE_OPTION,
|
|
5
|
+
GENERATED_UI_NAVIGATION_ROLE_VALUES,
|
|
6
|
+
GENERATED_UI_SURFACE_PROFILES,
|
|
7
|
+
assertGeneratedUiSourceContract,
|
|
8
|
+
buildGeneratedUiScreenClassName,
|
|
9
|
+
collectGeneratedUiSourceContractIssues,
|
|
10
|
+
inferGeneratedUiNavigationRole,
|
|
11
|
+
isGeneratedUiNoLinkNavigationRole,
|
|
12
|
+
normalizeGeneratedUiNavigationRole,
|
|
13
|
+
resolveGeneratedUiSurfaceProfile,
|
|
14
|
+
resolveGeneratedUiNavigationRoleLinkPlacement,
|
|
15
|
+
shouldCreateGeneratedUiNavigationLink
|
|
16
|
+
} from "./generatedUiContract.js";
|
|
17
|
+
|
|
18
|
+
test("generated UI navigation role metadata is descriptor-ready", () => {
|
|
19
|
+
assert.deepEqual(
|
|
20
|
+
GENERATED_UI_NAVIGATION_ROLE_VALUES,
|
|
21
|
+
["primary", "secondary", "utility", "detail", "workflow", "none"]
|
|
22
|
+
);
|
|
23
|
+
assert.equal(GENERATED_UI_NAVIGATION_ROLE_OPTION.validationType, "enum");
|
|
24
|
+
assert.deepEqual(GENERATED_UI_NAVIGATION_ROLE_OPTION.allowedValues, GENERATED_UI_NAVIGATION_ROLE_VALUES);
|
|
25
|
+
assert.equal(GENERATED_UI_NAVIGATION_ROLE_OPTION.defaultValue, "");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("generated UI surface profiles map app, operator, and settings density", () => {
|
|
29
|
+
assert.deepEqual(Object.keys(GENERATED_UI_SURFACE_PROFILES), ["task", "operator", "settings"]);
|
|
30
|
+
assert.equal(resolveGeneratedUiSurfaceProfile("").id, "task");
|
|
31
|
+
assert.equal(resolveGeneratedUiSurfaceProfile("operator").id, "operator");
|
|
32
|
+
assert.equal(resolveGeneratedUiSurfaceProfile("operator").density, "compact");
|
|
33
|
+
assert.equal(resolveGeneratedUiSurfaceProfile("settings").id, "settings");
|
|
34
|
+
assert.equal(
|
|
35
|
+
buildGeneratedUiScreenClassName("generated-page-screen d-flex", {
|
|
36
|
+
surfaceProfile: "operator"
|
|
37
|
+
}),
|
|
38
|
+
"generated-ui-screen generated-ui-screen--operator generated-page-screen d-flex"
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("normalizeGeneratedUiNavigationRole defaults and validates roles", () => {
|
|
43
|
+
assert.equal(normalizeGeneratedUiNavigationRole(""), "primary");
|
|
44
|
+
assert.equal(normalizeGeneratedUiNavigationRole(" Secondary "), "secondary");
|
|
45
|
+
assert.throws(
|
|
46
|
+
() => normalizeGeneratedUiNavigationRole("drawer"),
|
|
47
|
+
/navigation-role must be one of: primary, secondary, utility, detail, workflow, none/
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("generated UI navigation roles resolve semantic link placements", () => {
|
|
52
|
+
assert.equal(resolveGeneratedUiNavigationRoleLinkPlacement({}), "");
|
|
53
|
+
assert.equal(resolveGeneratedUiNavigationRoleLinkPlacement({ "navigation-role": "secondary" }), "shell.secondary-nav");
|
|
54
|
+
assert.equal(resolveGeneratedUiNavigationRoleLinkPlacement({ "navigation-role": "utility" }), "shell.global-actions");
|
|
55
|
+
assert.equal(
|
|
56
|
+
resolveGeneratedUiNavigationRoleLinkPlacement({
|
|
57
|
+
"navigation-role": "secondary",
|
|
58
|
+
"link-placement": "settings.sections"
|
|
59
|
+
}),
|
|
60
|
+
"settings.sections"
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("generated UI navigation role inference keeps detail and workflow routes out of primary nav", () => {
|
|
65
|
+
assert.equal(inferGeneratedUiNavigationRole({}, { routePath: "/reports" }), "primary");
|
|
66
|
+
assert.equal(inferGeneratedUiNavigationRole({}, { routePath: "/reports/[reportId]" }), "detail");
|
|
67
|
+
assert.equal(inferGeneratedUiNavigationRole({}, { routePath: "/reports/[reportId]/activity" }), "primary");
|
|
68
|
+
assert.equal(
|
|
69
|
+
inferGeneratedUiNavigationRole({}, {
|
|
70
|
+
dynamicRoutePolicy: "any",
|
|
71
|
+
routePath: "/reports/[reportId]/activity"
|
|
72
|
+
}),
|
|
73
|
+
"detail"
|
|
74
|
+
);
|
|
75
|
+
assert.equal(inferGeneratedUiNavigationRole({}, { routePath: "/reports/new" }), "workflow");
|
|
76
|
+
assert.equal(
|
|
77
|
+
inferGeneratedUiNavigationRole({ "navigation-role": "primary" }, { routePath: "/reports/[reportId]" }),
|
|
78
|
+
"primary"
|
|
79
|
+
);
|
|
80
|
+
assert.equal(
|
|
81
|
+
shouldCreateGeneratedUiNavigationLink({}, {
|
|
82
|
+
allowLinkTo: true,
|
|
83
|
+
routePath: "/reports/[reportId]"
|
|
84
|
+
}),
|
|
85
|
+
false
|
|
86
|
+
);
|
|
87
|
+
assert.equal(
|
|
88
|
+
shouldCreateGeneratedUiNavigationLink({ "navigation-role": "primary" }, {
|
|
89
|
+
allowLinkTo: true,
|
|
90
|
+
routePath: "/reports/[reportId]"
|
|
91
|
+
}),
|
|
92
|
+
true
|
|
93
|
+
);
|
|
94
|
+
assert.equal(
|
|
95
|
+
shouldCreateGeneratedUiNavigationLink({ "link-placement": "shell.secondary-nav" }, {
|
|
96
|
+
allowLinkTo: true,
|
|
97
|
+
routePath: "/reports/[reportId]"
|
|
98
|
+
}),
|
|
99
|
+
true
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("generated UI no-link roles reject conflicting link options", () => {
|
|
104
|
+
assert.equal(isGeneratedUiNoLinkNavigationRole("detail"), true);
|
|
105
|
+
assert.equal(shouldCreateGeneratedUiNavigationLink({ "navigation-role": "workflow" }), false);
|
|
106
|
+
assert.equal(shouldCreateGeneratedUiNavigationLink({ "navigation-role": "primary" }), true);
|
|
107
|
+
assert.throws(
|
|
108
|
+
() => shouldCreateGeneratedUiNavigationLink({
|
|
109
|
+
"navigation-role": "detail",
|
|
110
|
+
"link-placement": "shell.primary-nav"
|
|
111
|
+
}),
|
|
112
|
+
/navigation-role "detail" cannot be combined with --link-placement/
|
|
113
|
+
);
|
|
114
|
+
assert.throws(
|
|
115
|
+
() => shouldCreateGeneratedUiNavigationLink({
|
|
116
|
+
"navigation-role": "none",
|
|
117
|
+
"link-to": "./details"
|
|
118
|
+
}, {
|
|
119
|
+
allowLinkTo: true
|
|
120
|
+
}),
|
|
121
|
+
/navigation-role "none" cannot be combined with --link-placement or --link-to/
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("generated UI source contract flags placeholder copy and missing profile hooks", () => {
|
|
126
|
+
const issues = collectGeneratedUiSourceContractIssues(
|
|
127
|
+
`<template>
|
|
128
|
+
<section>
|
|
129
|
+
<v-card>Replace this content</v-card>
|
|
130
|
+
</section>
|
|
131
|
+
</template>`,
|
|
132
|
+
{
|
|
133
|
+
profile: "page"
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
issues.map((issue) => issue.id),
|
|
139
|
+
[
|
|
140
|
+
"replace-this-copy",
|
|
141
|
+
"vuetify-card-shell",
|
|
142
|
+
"shared-screen-class",
|
|
143
|
+
"page-screen-title",
|
|
144
|
+
"page-empty-state-sheet",
|
|
145
|
+
"page-responsive-title-type",
|
|
146
|
+
"page-compact-rules"
|
|
147
|
+
]
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("generated UI source contract accepts compact-first CRUD detail structure", () => {
|
|
152
|
+
assert.doesNotThrow(() => assertGeneratedUiSourceContract(
|
|
153
|
+
`<template>
|
|
154
|
+
<CrudAddEditScreen :screen="screen">
|
|
155
|
+
<template #fields="{ formState, addEdit, resolveFieldErrors }"></template>
|
|
156
|
+
</CrudAddEditScreen>
|
|
157
|
+
</template>
|
|
158
|
+
|
|
159
|
+
<script setup>
|
|
160
|
+
const screen = useCrudAddEditScreen({
|
|
161
|
+
title: "New Customer",
|
|
162
|
+
resource: uiResource
|
|
163
|
+
});
|
|
164
|
+
</script>`,
|
|
165
|
+
{
|
|
166
|
+
profile: "crud-detail",
|
|
167
|
+
sourceName: "NewElement.vue"
|
|
168
|
+
}
|
|
169
|
+
));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("generated UI source contract accepts compact-first CRUD list structure", () => {
|
|
173
|
+
assert.doesNotThrow(() => assertGeneratedUiSourceContract(
|
|
174
|
+
`<template>
|
|
175
|
+
<CrudListScreen
|
|
176
|
+
:screen="screen"
|
|
177
|
+
empty-title="__JSKIT_UI_LIST_EMPTY_TITLE__"
|
|
178
|
+
load-error-title="__JSKIT_UI_LIST_LOAD_ERROR_TITLE__"
|
|
179
|
+
>
|
|
180
|
+
<template #card-fields="{ record, records, formatListCardValue }"></template>
|
|
181
|
+
<template #table-header></template>
|
|
182
|
+
<template #table-row="{ record, records }"></template>
|
|
183
|
+
</CrudListScreen>
|
|
184
|
+
</template>
|
|
185
|
+
|
|
186
|
+
<script setup>
|
|
187
|
+
const screen = useCrudListScreen({
|
|
188
|
+
resource: uiResource,
|
|
189
|
+
listFilters,
|
|
190
|
+
listBulkActions
|
|
191
|
+
});
|
|
192
|
+
</script>`,
|
|
193
|
+
{
|
|
194
|
+
profile: "crud-list",
|
|
195
|
+
sourceName: "ListElement.vue"
|
|
196
|
+
}
|
|
197
|
+
));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("generated UI responsive smoke profile requires compact medium expanded checks", () => {
|
|
201
|
+
assert.throws(
|
|
202
|
+
() => assertGeneratedUiSourceContract("const width = 390; const selector = 'generated-ui-screen';", {
|
|
203
|
+
profile: "responsive-smoke",
|
|
204
|
+
sourceName: "tests/e2e/example.spec.ts"
|
|
205
|
+
}),
|
|
206
|
+
/missing:medium-viewport/
|
|
207
|
+
);
|
|
208
|
+
});
|
|
@@ -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,14 +324,17 @@ 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
|
|
|
127
|
-
function discoverShellOutletTargetsFromVueSource(
|
|
331
|
+
function discoverShellOutletTargetsFromVueSource(
|
|
332
|
+
source = "",
|
|
333
|
+
{
|
|
334
|
+
context = "shell layout",
|
|
335
|
+
enforceSingleDefault = true
|
|
336
|
+
} = {}
|
|
337
|
+
) {
|
|
128
338
|
const sourceText = String(source || "");
|
|
129
339
|
const resolvedContext = normalizeText(context) || "shell layout";
|
|
130
340
|
const targetById = new Map();
|
|
@@ -143,12 +353,14 @@ function discoverShellOutletTargetsFromVueSource(source = "", { context = "shell
|
|
|
143
353
|
}
|
|
144
354
|
|
|
145
355
|
if (normalizedTarget.default) {
|
|
146
|
-
if (defaultTargetId) {
|
|
356
|
+
if (enforceSingleDefault === true && defaultTargetId) {
|
|
147
357
|
throw new Error(
|
|
148
358
|
`${resolvedContext} defines multiple default ShellOutlet targets: "${defaultTargetId}" and "${normalizedTarget.id}".`
|
|
149
359
|
);
|
|
150
360
|
}
|
|
151
|
-
defaultTargetId
|
|
361
|
+
if (!defaultTargetId) {
|
|
362
|
+
defaultTargetId = normalizedTarget.id;
|
|
363
|
+
}
|
|
152
364
|
}
|
|
153
365
|
|
|
154
366
|
targetById.set(normalizedTarget.id, normalizedTarget);
|
|
@@ -161,10 +373,21 @@ function discoverShellOutletTargetsFromVueSource(source = "", { context = "shell
|
|
|
161
373
|
}
|
|
162
374
|
|
|
163
375
|
export {
|
|
376
|
+
PLACEMENT_LAYOUT_CLASSES,
|
|
164
377
|
describeShellOutletTargets,
|
|
165
378
|
discoverShellOutletTargetsFromVueSource,
|
|
166
379
|
findShellOutletTargetById,
|
|
380
|
+
normalizePlacementKind,
|
|
381
|
+
normalizePlacementLayoutClass,
|
|
382
|
+
normalizePlacementOwnerId,
|
|
383
|
+
normalizePlacementSurfaceId,
|
|
384
|
+
normalizePlacementSurfaces,
|
|
385
|
+
normalizePlacementTopologyDefinition,
|
|
386
|
+
normalizePlacementTopologyEntry,
|
|
387
|
+
normalizePlacementTopologyVariant,
|
|
388
|
+
normalizeSemanticPlacementId,
|
|
167
389
|
normalizeShellOutletTargetId,
|
|
168
390
|
normalizeShellOutletTargetRecord,
|
|
391
|
+
resolvePlacementTargetReference,
|
|
169
392
|
resolveShellOutletTargetParts
|
|
170
393
|
};
|
|
@@ -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"
|
|
@@ -76,6 +74,26 @@ test("discoverShellOutletTargetsFromVueSource throws for multiple defaults", ()
|
|
|
76
74
|
);
|
|
77
75
|
});
|
|
78
76
|
|
|
77
|
+
test("discoverShellOutletTargetsFromVueSource can collect source hints without enforcing one default", () => {
|
|
78
|
+
const source = `
|
|
79
|
+
<template>
|
|
80
|
+
<ShellOutlet target="shell-layout:primary-menu" default />
|
|
81
|
+
<ShellOutlet target="shell-layout:primary-bottom-nav" default />
|
|
82
|
+
</template>
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const discovered = discoverShellOutletTargetsFromVueSource(source, {
|
|
86
|
+
context: "ShellLayout.vue",
|
|
87
|
+
enforceSingleDefault: false
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(discovered.defaultTargetId, "shell-layout:primary-menu");
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
discovered.targets.map((entry) => entry.id),
|
|
93
|
+
["shell-layout:primary-menu", "shell-layout:primary-bottom-nav"]
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
79
97
|
test("discoverShellOutletTargetsFromVueSource ignores disabled default markers", () => {
|
|
80
98
|
const source = `
|
|
81
99
|
<template>
|