@jskit-ai/ui-generator 0.1.49 → 0.1.50
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.descriptor.mjs +87 -11
- package/package.json +3 -3
- package/src/server/buildTemplateContext.js +36 -2
- package/src/server/subcommands/addSubpages.js +32 -20
- package/src/server/subcommands/element.js +17 -3
- package/src/server/subcommands/outlet.js +305 -60
- package/src/server/subcommands/page.js +43 -22
- package/src/server/subcommands/pageSupport.js +114 -36
- package/src/server/subcommands/support.js +126 -23
- package/test/addSubpagesSubcommand.test.js +132 -8
- package/test/buildTemplateContext.test.js +31 -0
- package/test/elementSubcommand.test.js +13 -1
- package/test/outletSubcommand.test.js +258 -0
- package/test/packageDescriptor.test.js +11 -0
- package/test/pageSubcommand.test.js +134 -5
|
@@ -13,21 +13,27 @@ import {
|
|
|
13
13
|
readLocalLinkItemComponentSource
|
|
14
14
|
} from "@jskit-ai/shell-web/server/support/localLinkItemScaffolds";
|
|
15
15
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
16
|
+
import {
|
|
17
|
+
buildGeneratedUiScreenClassName,
|
|
18
|
+
resolveGeneratedUiSurfaceProfile
|
|
19
|
+
} from "@jskit-ai/kernel/shared/support/generatedUiContract";
|
|
16
20
|
import {
|
|
17
21
|
DEFAULT_COMPONENT_DIRECTORY,
|
|
18
22
|
MAIN_CLIENT_PROVIDER_FILE,
|
|
19
23
|
resolvePathWithinApp,
|
|
20
24
|
insertImportIfMissing,
|
|
21
25
|
insertBeforeClassDeclaration,
|
|
22
|
-
|
|
26
|
+
findScriptSetupBlock,
|
|
27
|
+
insertScriptSetupBlock,
|
|
23
28
|
indentBlock
|
|
24
29
|
} from "./support.js";
|
|
25
30
|
|
|
26
31
|
const DEFAULT_SUBPAGES_POSITION = "sub-pages";
|
|
27
32
|
const SECTION_CONTAINER_SHELL_COMPONENT = "SectionContainerShell";
|
|
28
|
-
const SUBPAGES_LINK_COMPONENT_TOKEN = "local.main.ui.
|
|
33
|
+
const SUBPAGES_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
|
|
29
34
|
const DEFAULT_MENU_COMPONENT_DIRECTORY = path.join(DEFAULT_COMPONENT_DIRECTORY, "menus");
|
|
30
35
|
const SUBPAGES_LINK_COMPONENT_DEFINITION = findLocalLinkItemDefinition(SUBPAGES_LINK_COMPONENT_TOKEN);
|
|
36
|
+
const OPERATOR_SURFACE_IDS = new Set(["admin", "console"]);
|
|
31
37
|
|
|
32
38
|
if (!SUBPAGES_LINK_COMPONENT_DEFINITION) {
|
|
33
39
|
throw new Error(`ui-generator add-subpages could not resolve ${SUBPAGES_LINK_COMPONENT_TOKEN} scaffold definition.`);
|
|
@@ -35,25 +41,97 @@ if (!SUBPAGES_LINK_COMPONENT_DEFINITION) {
|
|
|
35
41
|
|
|
36
42
|
const SUBPAGES_LINK_COMPONENT = SUBPAGES_LINK_COMPONENT_DEFINITION.componentName;
|
|
37
43
|
|
|
38
|
-
const ROUTE_TAG_PATTERN = /<route\b[^>]*>[\s\S]*?<\/route>\s*/gi;
|
|
39
44
|
const TEMPLATE_TOKEN_PATTERN = /<\/?template\b[^>]*>/gi;
|
|
40
45
|
const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b[^>]*\/?>\s*/gi;
|
|
41
46
|
const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
|
|
42
47
|
const ROUTER_VIEW_LINE_PATTERN = /^\s*<RouterView(?:\s[^>]*)?\s*\/>\s*$/gm;
|
|
43
48
|
|
|
49
|
+
function resolveGeneratedPageSurfaceProfile({
|
|
50
|
+
surfaceId = "",
|
|
51
|
+
routePath = ""
|
|
52
|
+
} = {}) {
|
|
53
|
+
const routeSegments = normalizeText(routePath)
|
|
54
|
+
.replaceAll("\\", "/")
|
|
55
|
+
.split("/")
|
|
56
|
+
.map((entry) => normalizeText(entry).toLowerCase())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
if (routeSegments.includes("settings")) {
|
|
59
|
+
return "settings";
|
|
60
|
+
}
|
|
61
|
+
if (OPERATOR_SURFACE_IDS.has(normalizeText(surfaceId).toLowerCase())) {
|
|
62
|
+
return "operator";
|
|
63
|
+
}
|
|
64
|
+
return "task";
|
|
65
|
+
}
|
|
66
|
+
|
|
44
67
|
function trimEdgeBlankLines(source = "") {
|
|
45
68
|
return String(source || "")
|
|
46
69
|
.replace(/^\s*\n/, "")
|
|
47
70
|
.replace(/\n\s*$/, "");
|
|
48
71
|
}
|
|
49
72
|
|
|
50
|
-
function renderPlainPageSource(pageTitle = ""
|
|
73
|
+
function renderPlainPageSource(pageTitle = "", {
|
|
74
|
+
surfaceId = "",
|
|
75
|
+
routePath = ""
|
|
76
|
+
} = {}) {
|
|
77
|
+
const surfaceProfileId = resolveGeneratedPageSurfaceProfile({ surfaceId, routePath });
|
|
78
|
+
const surfaceProfile = resolveGeneratedUiSurfaceProfile(surfaceProfileId);
|
|
79
|
+
const screenClass = buildGeneratedUiScreenClassName("generated-page-screen d-flex flex-column ga-4", {
|
|
80
|
+
surfaceProfile: surfaceProfileId
|
|
81
|
+
});
|
|
51
82
|
return `<template>
|
|
52
|
-
<section class="
|
|
53
|
-
<
|
|
54
|
-
|
|
83
|
+
<section class="${screenClass}">
|
|
84
|
+
<header>
|
|
85
|
+
<p class="text-overline text-medium-emphasis mb-1">${surfaceProfile.titleLabel}</p>
|
|
86
|
+
<h1 class="generated-page-screen__title">${pageTitle}</h1>
|
|
87
|
+
</header>
|
|
88
|
+
|
|
89
|
+
<v-sheet rounded="lg" border class="generated-page-screen__empty-state">
|
|
90
|
+
<h2 class="text-h6 mb-2">No ${pageTitle} activity yet</h2>
|
|
91
|
+
<p class="text-body-2 text-medium-emphasis mb-0">
|
|
92
|
+
${surfaceProfile.emptyStateBody}
|
|
93
|
+
</p>
|
|
94
|
+
</v-sheet>
|
|
55
95
|
</section>
|
|
56
96
|
</template>
|
|
97
|
+
|
|
98
|
+
<style scoped>
|
|
99
|
+
.generated-ui-screen {
|
|
100
|
+
--generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
|
|
101
|
+
--generated-ui-screen-panel-padding: 2rem 1.25rem;
|
|
102
|
+
--generated-ui-screen-panel-align: center;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.generated-ui-screen--operator {
|
|
106
|
+
--generated-ui-screen-panel-padding: 1.5rem 1rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.generated-ui-screen--settings {
|
|
110
|
+
--generated-ui-screen-panel-padding: 1.5rem 1rem;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.generated-page-screen__title {
|
|
114
|
+
font-size: var(--generated-ui-screen-title-size);
|
|
115
|
+
font-weight: 650;
|
|
116
|
+
letter-spacing: -0.02em;
|
|
117
|
+
line-height: 1.15;
|
|
118
|
+
margin: 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.generated-page-screen__empty-state {
|
|
122
|
+
margin-inline: auto;
|
|
123
|
+
max-width: 34rem;
|
|
124
|
+
padding: var(--generated-ui-screen-panel-padding);
|
|
125
|
+
text-align: var(--generated-ui-screen-panel-align);
|
|
126
|
+
width: 100%;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@media (max-width: 640px) {
|
|
130
|
+
.generated-ui-screen {
|
|
131
|
+
--generated-ui-screen-panel-padding: 1.25rem 1rem;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
57
135
|
`;
|
|
58
136
|
}
|
|
59
137
|
|
|
@@ -81,41 +159,50 @@ const hasTabs = computed(() => Boolean(slots.tabs));
|
|
|
81
159
|
|
|
82
160
|
<template>
|
|
83
161
|
<section class="section-container-shell d-flex flex-column ga-4">
|
|
84
|
-
<v-
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<slot name="tabs" />
|
|
93
|
-
</v-card-text>
|
|
94
|
-
</template>
|
|
95
|
-
</v-card>
|
|
162
|
+
<header v-if="hasHeading" class="section-container-shell__heading">
|
|
163
|
+
<h1 v-if="resolvedTitle" class="section-container-shell__title">{{ resolvedTitle }}</h1>
|
|
164
|
+
<p v-if="resolvedSubtitle" class="text-body-2 text-medium-emphasis mb-0">{{ resolvedSubtitle }}</p>
|
|
165
|
+
</header>
|
|
166
|
+
|
|
167
|
+
<v-sheet v-if="hasTabs" rounded="lg" border class="section-container-shell__nav">
|
|
168
|
+
<slot name="tabs" />
|
|
169
|
+
</v-sheet>
|
|
96
170
|
|
|
97
171
|
<slot />
|
|
98
172
|
</section>
|
|
99
173
|
</template>
|
|
100
174
|
|
|
101
175
|
<style scoped>
|
|
102
|
-
.section-container-
|
|
103
|
-
|
|
176
|
+
.section-container-shell__heading {
|
|
177
|
+
min-width: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.section-container-shell__title {
|
|
181
|
+
font-size: clamp(1.35rem, 2vw, 1.85rem);
|
|
182
|
+
font-weight: 650;
|
|
183
|
+
letter-spacing: -0.02em;
|
|
184
|
+
line-height: 1.15;
|
|
185
|
+
margin: 0 0 0.35rem;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.section-container-shell__nav {
|
|
104
189
|
align-items: center;
|
|
190
|
+
display: flex;
|
|
105
191
|
gap: 0.5rem;
|
|
106
192
|
overflow-x: auto;
|
|
107
|
-
padding: 0.
|
|
193
|
+
padding: 0.5rem;
|
|
108
194
|
scrollbar-width: thin;
|
|
109
195
|
}
|
|
110
196
|
|
|
111
|
-
.section-container-
|
|
197
|
+
.section-container-shell__nav :deep(.tab-link-item) {
|
|
112
198
|
flex: 0 0 auto;
|
|
199
|
+
min-height: 48px;
|
|
113
200
|
}
|
|
114
201
|
|
|
115
202
|
@media (max-width: 640px) {
|
|
116
|
-
.section-container-
|
|
203
|
+
.section-container-shell__nav {
|
|
117
204
|
gap: 0.375rem;
|
|
118
|
-
|
|
205
|
+
margin-inline: -0.25rem;
|
|
119
206
|
}
|
|
120
207
|
}
|
|
121
208
|
</style>
|
|
@@ -332,7 +419,7 @@ function renderSubpagesTemplate({
|
|
|
332
419
|
|
|
333
420
|
function applySubpagesScriptImports(source = "", { sectionContainerComponentImportPath = "" } = {}) {
|
|
334
421
|
const sourceText = String(source || "");
|
|
335
|
-
const scriptBlock =
|
|
422
|
+
const scriptBlock = findScriptSetupBlock(sourceText);
|
|
336
423
|
|
|
337
424
|
const importLines = [
|
|
338
425
|
"import ShellOutlet from \"@jskit-ai/shell-web/client/components/ShellOutlet\";",
|
|
@@ -341,16 +428,7 @@ function applySubpagesScriptImports(source = "", { sectionContainerComponentImpo
|
|
|
341
428
|
];
|
|
342
429
|
|
|
343
430
|
if (!scriptBlock) {
|
|
344
|
-
|
|
345
|
-
let insertionIndex = 0;
|
|
346
|
-
for (const match of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
|
|
347
|
-
insertionIndex = match.index + String(match[0] || "").length;
|
|
348
|
-
}
|
|
349
|
-
const separator = insertionIndex > 0 ? "\n" : "";
|
|
350
|
-
return {
|
|
351
|
-
changed: true,
|
|
352
|
-
content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
|
|
353
|
-
};
|
|
431
|
+
return insertScriptSetupBlock(sourceText, importLines.join("\n"));
|
|
354
432
|
}
|
|
355
433
|
|
|
356
434
|
let nextScriptContent = scriptBlock.content;
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import {
|
|
3
|
+
importFreshModuleFromAbsolutePath,
|
|
3
4
|
resolveRequiredAppRoot,
|
|
4
5
|
toPosixPath
|
|
5
6
|
} from "@jskit-ai/kernel/server/support";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
PLACEMENT_LAYOUT_CLASSES,
|
|
9
|
+
normalizePlacementOwnerId,
|
|
10
|
+
normalizePlacementTopologyDefinition,
|
|
11
|
+
normalizeSemanticPlacementId,
|
|
12
|
+
normalizeShellOutletTargetId
|
|
13
|
+
} from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
|
|
7
14
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
8
15
|
import { toCamelCase, toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
|
|
9
16
|
|
|
@@ -208,38 +215,132 @@ function insertBeforeClassDeclaration(source = "", line = "", { className = "",
|
|
|
208
215
|
};
|
|
209
216
|
}
|
|
210
217
|
|
|
211
|
-
function
|
|
218
|
+
function findScriptSetupBlock(source = "") {
|
|
212
219
|
const sourceText = String(source || "");
|
|
213
|
-
let firstMatch = null;
|
|
214
|
-
|
|
215
220
|
for (const match of sourceText.matchAll(SCRIPT_TAG_PATTERN)) {
|
|
216
|
-
if (!firstMatch) {
|
|
217
|
-
firstMatch = match;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
221
|
const attributesSource = String(match[1] || "");
|
|
221
|
-
if (SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
|
|
222
|
-
|
|
223
|
-
index: match.index,
|
|
224
|
-
source: String(match[0] || ""),
|
|
225
|
-
attributesSource,
|
|
226
|
-
content: String(match[2] || "")
|
|
227
|
-
});
|
|
222
|
+
if (!SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
|
|
223
|
+
continue;
|
|
228
224
|
}
|
|
225
|
+
|
|
226
|
+
return Object.freeze({
|
|
227
|
+
index: match.index,
|
|
228
|
+
source: String(match[0] || ""),
|
|
229
|
+
attributesSource,
|
|
230
|
+
content: String(match[2] || "")
|
|
231
|
+
});
|
|
229
232
|
}
|
|
230
233
|
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function insertScriptSetupBlock(source = "", content = "") {
|
|
238
|
+
const sourceText = String(source || "");
|
|
239
|
+
const normalizedContent = String(content || "").trim();
|
|
240
|
+
if (!normalizedContent) {
|
|
241
|
+
return {
|
|
242
|
+
changed: false,
|
|
243
|
+
content: sourceText
|
|
244
|
+
};
|
|
233
245
|
}
|
|
234
246
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
247
|
+
const scriptSetupBlock = `<script setup>\n${normalizedContent}\n</script>\n`;
|
|
248
|
+
let insertionIndex = 0;
|
|
249
|
+
for (const match of sourceText.matchAll(/<route\b[^>]*>[\s\S]*?<\/route>\s*/gi)) {
|
|
250
|
+
insertionIndex = match.index + String(match[0] || "").length;
|
|
251
|
+
}
|
|
252
|
+
const separator = insertionIndex > 0 ? "\n" : "";
|
|
253
|
+
return {
|
|
254
|
+
changed: true,
|
|
255
|
+
content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function loadPlacementTopologyDefinitionFromPath(topologyPath = {}, { context = "ui-generator topology" } = {}) {
|
|
260
|
+
const moduleNamespace = await importFreshModuleFromAbsolutePath(topologyPath.absolutePath);
|
|
261
|
+
const exported = moduleNamespace?.default;
|
|
262
|
+
const resolved = typeof exported === "function" ? exported() : exported;
|
|
263
|
+
return normalizePlacementTopologyDefinition(resolved, {
|
|
264
|
+
context
|
|
240
265
|
});
|
|
241
266
|
}
|
|
242
267
|
|
|
268
|
+
function normalizeExpectedTopologyVariantTargets(variantTargets = null) {
|
|
269
|
+
if (!variantTargets || typeof variantTargets !== "object" || Array.isArray(variantTargets)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const targets = {};
|
|
274
|
+
for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
|
|
275
|
+
const target = normalizeShellOutletTargetId(variantTargets[layoutClass]);
|
|
276
|
+
if (target) {
|
|
277
|
+
targets[layoutClass] = target;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return Object.keys(targets).length > 0 ? Object.freeze(targets) : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function describeTopologyVariantTargets(variantTargets = {}) {
|
|
285
|
+
return PLACEMENT_LAYOUT_CLASSES
|
|
286
|
+
.map((layoutClass) => {
|
|
287
|
+
const target = normalizeShellOutletTargetId(variantTargets?.[layoutClass]);
|
|
288
|
+
return target ? `${layoutClass}:${target}` : "";
|
|
289
|
+
})
|
|
290
|
+
.filter(Boolean)
|
|
291
|
+
.join(", ");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function placementMatchesExpectedVariantTargets(placement = {}, expectedVariantTargets = null) {
|
|
295
|
+
if (!expectedVariantTargets) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const variants = placement?.variants && typeof placement.variants === "object" ? placement.variants : {};
|
|
300
|
+
return Object.entries(expectedVariantTargets).every(([layoutClass, expectedTarget]) =>
|
|
301
|
+
normalizeShellOutletTargetId(variants?.[layoutClass]?.outlet) === expectedTarget
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function appendTopologyBlockIfPlacementMissing({
|
|
306
|
+
topologyPath = {},
|
|
307
|
+
source = "",
|
|
308
|
+
marker = "",
|
|
309
|
+
block = "",
|
|
310
|
+
placementId = "",
|
|
311
|
+
owner = "",
|
|
312
|
+
variantTargets = null,
|
|
313
|
+
context = "ui-generator topology"
|
|
314
|
+
} = {}) {
|
|
315
|
+
const normalizedPlacementId = normalizeSemanticPlacementId(placementId);
|
|
316
|
+
const normalizedOwner = normalizePlacementOwnerId(owner);
|
|
317
|
+
if (!normalizedPlacementId) {
|
|
318
|
+
throw new Error(`${context} requires semantic placement id in "area.slot" format.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const topology = await loadPlacementTopologyDefinitionFromPath(topologyPath, { context });
|
|
322
|
+
const existingPlacement = (Array.isArray(topology.placements) ? topology.placements : []).find(
|
|
323
|
+
(placement) => placement.id === normalizedPlacementId && (placement.owner || "") === normalizedOwner
|
|
324
|
+
);
|
|
325
|
+
if (existingPlacement) {
|
|
326
|
+
const expectedVariantTargets = normalizeExpectedTopologyVariantTargets(variantTargets);
|
|
327
|
+
if (!placementMatchesExpectedVariantTargets(existingPlacement, expectedVariantTargets)) {
|
|
328
|
+
const ownerLabel = normalizedOwner ? ` for owner "${normalizedOwner}"` : "";
|
|
329
|
+
throw new Error(
|
|
330
|
+
`${context} semantic placement "${normalizedPlacementId}"${ownerLabel} already exists with different outlet mapping. ` +
|
|
331
|
+
`Existing: ${describeTopologyVariantTargets(existingPlacement.variants)}. ` +
|
|
332
|
+
`Requested: ${describeTopologyVariantTargets(expectedVariantTargets)}.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
changed: false,
|
|
337
|
+
content: String(source || "")
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return appendBlockIfMarkerMissing(source, marker, block);
|
|
342
|
+
}
|
|
343
|
+
|
|
243
344
|
function parseTagAttributes(attributesSource = "") {
|
|
244
345
|
const attributes = {};
|
|
245
346
|
const source = String(attributesSource || "");
|
|
@@ -279,7 +380,9 @@ export {
|
|
|
279
380
|
appendBlockIfMarkerMissing,
|
|
280
381
|
insertImportIfMissing,
|
|
281
382
|
insertBeforeClassDeclaration,
|
|
282
|
-
|
|
383
|
+
findScriptSetupBlock,
|
|
384
|
+
insertScriptSetupBlock,
|
|
385
|
+
appendTopologyBlockIfPlacementMissing,
|
|
283
386
|
parseTagAttributes,
|
|
284
387
|
indentBlock
|
|
285
388
|
};
|
|
@@ -113,7 +113,7 @@ test("ui-generator add-subpages derives the default target from an index-route p
|
|
|
113
113
|
|
|
114
114
|
assert.deepEqual(result.touchedFiles, [
|
|
115
115
|
"packages/main/src/client/providers/MainClientProvider.js",
|
|
116
|
-
"src/components/menus/
|
|
116
|
+
"src/components/menus/TabLinkItem.vue",
|
|
117
117
|
"src/components/SectionContainerShell.vue",
|
|
118
118
|
`src/pages/${targetFile}`,
|
|
119
119
|
"src/placementTopology.js"
|
|
@@ -130,10 +130,11 @@ test("ui-generator add-subpages derives the default target from an index-route p
|
|
|
130
130
|
assert.match(topologySource, /compact: \{/);
|
|
131
131
|
assert.match(topologySource, /medium: \{/);
|
|
132
132
|
assert.match(topologySource, /expanded: \{/);
|
|
133
|
+
assert.match(topologySource, /link: "local\.main\.ui\.tab-link-item"/);
|
|
133
134
|
assert.match(pageSource, /<RouterView \/>/);
|
|
134
135
|
assert.equal(
|
|
135
|
-
await readFile(path.join(appRoot, "src", "components", "menus", "
|
|
136
|
-
await readLocalLinkItemComponentSource("local.main.ui.
|
|
136
|
+
await readFile(path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"), "utf8"),
|
|
137
|
+
await readLocalLinkItemComponentSource("local.main.ui.tab-link-item")
|
|
137
138
|
);
|
|
138
139
|
});
|
|
139
140
|
});
|
|
@@ -227,6 +228,41 @@ test("ui-generator add-subpages supports explicit target host:position", async (
|
|
|
227
228
|
});
|
|
228
229
|
});
|
|
229
230
|
|
|
231
|
+
test("ui-generator add-subpages creates script setup instead of adding template imports to normal script", async () => {
|
|
232
|
+
await withTempApp(async (appRoot) => {
|
|
233
|
+
await writeAppFixture(appRoot);
|
|
234
|
+
|
|
235
|
+
const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
|
|
236
|
+
await writePageFile(
|
|
237
|
+
appRoot,
|
|
238
|
+
targetFile,
|
|
239
|
+
`<script>
|
|
240
|
+
export default {
|
|
241
|
+
name: "PracticePage"
|
|
242
|
+
};
|
|
243
|
+
</script>
|
|
244
|
+
|
|
245
|
+
<template>
|
|
246
|
+
<section>Practice</section>
|
|
247
|
+
</template>
|
|
248
|
+
`
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await runGeneratorSubcommand({
|
|
252
|
+
appRoot,
|
|
253
|
+
subcommand: "add-subpages",
|
|
254
|
+
args: [targetFile],
|
|
255
|
+
options: {}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const pageSource = await readPageFile(appRoot, targetFile);
|
|
259
|
+
assert.match(pageSource, /<script setup>\nimport ShellOutlet from "@jskit-ai\/shell-web\/client\/components\/ShellOutlet";/);
|
|
260
|
+
assert.match(pageSource, /import \{ RouterView \} from "vue-router";/);
|
|
261
|
+
assert.match(pageSource, /import SectionContainerShell from "\/src\/components\/SectionContainerShell\.vue";/);
|
|
262
|
+
assert.match(pageSource, /<script>\nexport default/);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
230
266
|
test("ui-generator add-subpages does not rewrite existing scaffold support components", async () => {
|
|
231
267
|
await withTempApp(async (appRoot) => {
|
|
232
268
|
await writeAppFixture(appRoot);
|
|
@@ -234,7 +270,7 @@ test("ui-generator add-subpages does not rewrite existing scaffold support compo
|
|
|
234
270
|
const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
|
|
235
271
|
await writePageFile(appRoot, targetFile);
|
|
236
272
|
const customSectionShellSource = `<template><section class="custom-shell"><slot /></section></template>\n`;
|
|
237
|
-
const
|
|
273
|
+
const customTabLinkSource = `<template><button class="custom-tab-link"><slot /></button></template>\n`;
|
|
238
274
|
await writeFile(
|
|
239
275
|
path.join(appRoot, "src", "components", "SectionContainerShell.vue"),
|
|
240
276
|
customSectionShellSource,
|
|
@@ -242,8 +278,8 @@ test("ui-generator add-subpages does not rewrite existing scaffold support compo
|
|
|
242
278
|
);
|
|
243
279
|
await mkdir(path.join(appRoot, "src", "components", "menus"), { recursive: true });
|
|
244
280
|
await writeFile(
|
|
245
|
-
path.join(appRoot, "src", "components", "menus", "
|
|
246
|
-
|
|
281
|
+
path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"),
|
|
282
|
+
customTabLinkSource,
|
|
247
283
|
"utf8"
|
|
248
284
|
);
|
|
249
285
|
|
|
@@ -266,9 +302,97 @@ test("ui-generator add-subpages does not rewrite existing scaffold support compo
|
|
|
266
302
|
customSectionShellSource
|
|
267
303
|
);
|
|
268
304
|
assert.equal(
|
|
269
|
-
await readFile(path.join(appRoot, "src", "components", "menus", "
|
|
270
|
-
|
|
305
|
+
await readFile(path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"), "utf8"),
|
|
306
|
+
customTabLinkSource
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("ui-generator add-subpages validates topology before changing page or support files", async () => {
|
|
312
|
+
await withTempApp(async (appRoot) => {
|
|
313
|
+
await writeAppFixture(appRoot);
|
|
314
|
+
|
|
315
|
+
const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
|
|
316
|
+
const originalPageSource = "<template><section>Practice</section></template>\n";
|
|
317
|
+
await writePageFile(appRoot, targetFile, originalPageSource);
|
|
318
|
+
const providerPath = path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js");
|
|
319
|
+
const originalProviderSource = await readFile(providerPath, "utf8");
|
|
320
|
+
await writeFile(
|
|
321
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
322
|
+
`export default {
|
|
323
|
+
placements: [
|
|
324
|
+
{
|
|
325
|
+
id: "page.section-nav",
|
|
326
|
+
owner: "practice",
|
|
327
|
+
variants: {}
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
};
|
|
331
|
+
`,
|
|
332
|
+
"utf8"
|
|
271
333
|
);
|
|
334
|
+
|
|
335
|
+
await assert.rejects(
|
|
336
|
+
runGeneratorSubcommand({
|
|
337
|
+
appRoot,
|
|
338
|
+
subcommand: "add-subpages",
|
|
339
|
+
args: [targetFile],
|
|
340
|
+
options: {}
|
|
341
|
+
}),
|
|
342
|
+
/requires compact topology variant/
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
assert.equal(await readPageFile(appRoot, targetFile), originalPageSource);
|
|
346
|
+
assert.equal(await readFile(providerPath, "utf8"), originalProviderSource);
|
|
347
|
+
await assert.rejects(
|
|
348
|
+
readFile(path.join(appRoot, "src", "components", "SectionContainerShell.vue"), "utf8"),
|
|
349
|
+
/ENOENT/
|
|
350
|
+
);
|
|
351
|
+
await assert.rejects(
|
|
352
|
+
readFile(path.join(appRoot, "src", "components", "menus", "TabLinkItem.vue"), "utf8"),
|
|
353
|
+
/ENOENT/
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("ui-generator add-subpages rejects existing section-nav topology for a different outlet", async () => {
|
|
359
|
+
await withTempApp(async (appRoot) => {
|
|
360
|
+
await writeAppFixture(appRoot);
|
|
361
|
+
|
|
362
|
+
const targetFile = "w/[workspaceSlug]/admin/practice/index.vue";
|
|
363
|
+
const originalPageSource = "<template><section>Practice</section></template>\n";
|
|
364
|
+
await writePageFile(appRoot, targetFile, originalPageSource);
|
|
365
|
+
await writeFile(
|
|
366
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
367
|
+
`export default {
|
|
368
|
+
placements: [
|
|
369
|
+
{
|
|
370
|
+
id: "page.section-nav",
|
|
371
|
+
owner: "practice",
|
|
372
|
+
surfaces: ["admin"],
|
|
373
|
+
variants: {
|
|
374
|
+
compact: { outlet: "practice:existing-tabs" },
|
|
375
|
+
medium: { outlet: "practice:existing-tabs" },
|
|
376
|
+
expanded: { outlet: "practice:existing-tabs" }
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
};
|
|
381
|
+
`,
|
|
382
|
+
"utf8"
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
await assert.rejects(
|
|
386
|
+
runGeneratorSubcommand({
|
|
387
|
+
appRoot,
|
|
388
|
+
subcommand: "add-subpages",
|
|
389
|
+
args: [targetFile],
|
|
390
|
+
options: {}
|
|
391
|
+
}),
|
|
392
|
+
/semantic placement "page\.section-nav" for owner "practice" already exists with different outlet mapping/
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
assert.equal(await readPageFile(appRoot, targetFile), originalPageSource);
|
|
272
396
|
});
|
|
273
397
|
});
|
|
274
398
|
|
|
@@ -90,6 +90,12 @@ async function writePlacementTopology(appRoot, entries = []) {
|
|
|
90
90
|
surfaces: ["*"],
|
|
91
91
|
outlet: "shell-layout:top-right",
|
|
92
92
|
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
93
|
+
}),
|
|
94
|
+
renderTopologyEntry({
|
|
95
|
+
id: "shell.global-actions",
|
|
96
|
+
surfaces: ["*"],
|
|
97
|
+
outlet: "shell-layout:top-right",
|
|
98
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
93
99
|
})
|
|
94
100
|
];
|
|
95
101
|
await writeFileInApp(
|
|
@@ -186,6 +192,31 @@ test("buildUiPageTemplateContext supports explicit link placement override", asy
|
|
|
186
192
|
});
|
|
187
193
|
});
|
|
188
194
|
|
|
195
|
+
test("buildUiPageTemplateContext maps utility navigation role to global actions", async () => {
|
|
196
|
+
await withTempApp(async (appRoot) => {
|
|
197
|
+
await writeConfig(
|
|
198
|
+
appRoot,
|
|
199
|
+
`export const config = {
|
|
200
|
+
surfaceDefinitions: {
|
|
201
|
+
admin: { id: "admin", pagesRoot: "admin", enabled: true }
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
`
|
|
205
|
+
);
|
|
206
|
+
await writeShellLayout(appRoot);
|
|
207
|
+
|
|
208
|
+
const context = await buildUiPageTemplateContext({
|
|
209
|
+
appRoot,
|
|
210
|
+
targetFile: "admin/help/index.vue",
|
|
211
|
+
options: {
|
|
212
|
+
"navigation-role": "utility"
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
assert.equal(context.__JSKIT_UI_LINK_PLACEMENT_TARGET__, "shell.global-actions");
|
|
216
|
+
assert.equal(context.__JSKIT_UI_LINK_COMPONENT_TOKEN__, "");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
189
220
|
test("buildUiPageTemplateContext supports explicit package semantic link placement", async () => {
|
|
190
221
|
await withTempApp(async (appRoot) => {
|
|
191
222
|
await writeConfig(
|
|
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
|
|
6
7
|
import { runGeneratorSubcommand } from "../src/server/subcommands/element.js";
|
|
7
8
|
|
|
8
9
|
async function withTempApp(run) {
|
|
@@ -175,6 +176,16 @@ test("ui-generator placed-element subcommand creates component and outlet placem
|
|
|
175
176
|
assert.match(providerSource, /import OpsPanelElement from "\/src\/components\/OpsPanelElement\.vue";/);
|
|
176
177
|
assert.match(providerSource, /registerMainClientComponent\("local\.main\.ui\.element\.ops-panel", \(\) => OpsPanelElement\);/);
|
|
177
178
|
|
|
179
|
+
const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
|
|
180
|
+
assertGeneratedUiSourceContract(componentSource, {
|
|
181
|
+
profile: "placed-element",
|
|
182
|
+
sourceName: "OpsPanelElement.vue"
|
|
183
|
+
});
|
|
184
|
+
assert.match(componentSource, /class="generated-element-panel"/);
|
|
185
|
+
assert.match(componentSource, /<h2 class="text-subtitle-1 font-weight-medium mb-0">Ops Panel<\/h2>/);
|
|
186
|
+
assert.match(componentSource, /<v-chip color="primary" variant="tonal" size="small">Ready<\/v-chip>/);
|
|
187
|
+
assert.doesNotMatch(componentSource, /Replace this scaffold|Use this area|This is your page/);
|
|
188
|
+
|
|
178
189
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
179
190
|
assert.match(placementSource, /id: "ui-generator\.element\.ops-panel"/);
|
|
180
191
|
assert.match(placementSource, /target: "shell\.status"/);
|
|
@@ -350,7 +361,8 @@ test("ui-generator placed-element subcommand overwrites an existing component wh
|
|
|
350
361
|
]);
|
|
351
362
|
|
|
352
363
|
const componentSource = await readFile(path.join(appRoot, "src", "components", "OpsPanelElement.vue"), "utf8");
|
|
353
|
-
assert.match(componentSource, /<h2 class="text-
|
|
364
|
+
assert.match(componentSource, /<h2 class="text-subtitle-1 font-weight-medium mb-0">Ops Panel<\/h2>/);
|
|
365
|
+
assert.doesNotMatch(componentSource, /custom/);
|
|
354
366
|
});
|
|
355
367
|
});
|
|
356
368
|
|