@jskit-ai/ui-generator 0.1.5 → 0.1.6
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/README.md +104 -4
- package/package.descriptor.mjs +72 -5
- package/package.json +2 -2
- package/src/server/buildTemplateContext.js +117 -1
- package/src/server/subcommands/container.js +269 -56
- package/src/server/subcommands/outlet.js +274 -0
- package/test/buildTemplateContext.test.js +43 -0
- package/test/containerSubcommand.test.js +181 -7
- package/test/outletSubcommand.test.js +219 -0
|
@@ -24,8 +24,27 @@ import {
|
|
|
24
24
|
|
|
25
25
|
const CONTAINER_OUTLET_POSITION = "sub-pages";
|
|
26
26
|
const SECTION_CONTAINER_SHELL_COMPONENT = "SectionContainerShell";
|
|
27
|
-
const
|
|
28
|
-
const
|
|
27
|
+
const TAB_LINK_COMPONENT = "TabLinkItem";
|
|
28
|
+
const TAB_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
|
|
29
|
+
const ROUTE_TAG_PATTERN = /<route\b([^>]*)>([\s\S]*?)<\/route>/;
|
|
30
|
+
const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
31
|
+
|
|
32
|
+
function isBracketRouteParamSegment(value = "") {
|
|
33
|
+
return value.startsWith("[") && value.endsWith("]");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeRoutePrefixSegment(value = "") {
|
|
37
|
+
const source = normalizeText(value);
|
|
38
|
+
if (!source) {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isBracketRouteParamSegment(source)) {
|
|
43
|
+
return source;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return toKebabCase(source);
|
|
47
|
+
}
|
|
29
48
|
|
|
30
49
|
function normalizeRoutePrefix(value = "") {
|
|
31
50
|
const source = normalizeText(value).replaceAll("\\", "/");
|
|
@@ -35,11 +54,160 @@ function normalizeRoutePrefix(value = "") {
|
|
|
35
54
|
|
|
36
55
|
const parts = source
|
|
37
56
|
.split("/")
|
|
38
|
-
.map((entry) =>
|
|
57
|
+
.map((entry) => normalizeRoutePrefixSegment(entry))
|
|
39
58
|
.filter(Boolean);
|
|
40
59
|
return parts.join("/");
|
|
41
60
|
}
|
|
42
61
|
|
|
62
|
+
function resolveContainerRoutePath({ name = "", routePath = "" } = {}) {
|
|
63
|
+
const rawRoutePath = normalizeText(routePath);
|
|
64
|
+
const normalizedRoutePath = normalizeRoutePrefix(routePath);
|
|
65
|
+
if (rawRoutePath && !normalizedRoutePath) {
|
|
66
|
+
throw new Error("ui-generator container requires a valid --route-path when provided.");
|
|
67
|
+
}
|
|
68
|
+
if (normalizedRoutePath) {
|
|
69
|
+
return normalizedRoutePath;
|
|
70
|
+
}
|
|
71
|
+
return toKebabCase(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseTagAttributes(attributesSource = "") {
|
|
75
|
+
const attributes = {};
|
|
76
|
+
const source = String(attributesSource || "");
|
|
77
|
+
for (const match of source.matchAll(ATTRIBUTE_PATTERN)) {
|
|
78
|
+
const attributeName = normalizeText(match[1]);
|
|
79
|
+
if (!attributeName) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const hasValue = match[2] != null || match[3] != null;
|
|
84
|
+
const attributeValue = hasValue ? String(match[2] ?? match[3] ?? "") : true;
|
|
85
|
+
attributes[attributeName] = attributeValue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return attributes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createContainerRouteMeta({
|
|
92
|
+
surface = "",
|
|
93
|
+
containerHost = "",
|
|
94
|
+
containerPosition = CONTAINER_OUTLET_POSITION
|
|
95
|
+
} = {}) {
|
|
96
|
+
return {
|
|
97
|
+
meta: {
|
|
98
|
+
jskit: {
|
|
99
|
+
surface,
|
|
100
|
+
placements: {
|
|
101
|
+
outlets: [
|
|
102
|
+
{
|
|
103
|
+
host: containerHost,
|
|
104
|
+
position: containerPosition
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderContainerRouteMetaBlock(routeMeta = {}) {
|
|
114
|
+
return `<route lang="json">
|
|
115
|
+
${JSON.stringify(routeMeta, null, 2)}
|
|
116
|
+
</route>
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeOutletTargetId(outlet = {}) {
|
|
121
|
+
const host = normalizeText(outlet?.host);
|
|
122
|
+
const position = normalizeText(outlet?.position);
|
|
123
|
+
if (!host || !position) {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
return `${host}:${position}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ensureContainerRouteMetaOutlets(source = "", { surface = "", containerHost = "", containerPosition = "" } = {}) {
|
|
130
|
+
const sourceText = String(source || "");
|
|
131
|
+
const routeTagMatch = ROUTE_TAG_PATTERN.exec(sourceText);
|
|
132
|
+
const expectedTargetId = normalizeOutletTargetId({
|
|
133
|
+
host: containerHost,
|
|
134
|
+
position: containerPosition
|
|
135
|
+
});
|
|
136
|
+
if (!expectedTargetId) {
|
|
137
|
+
return {
|
|
138
|
+
changed: false,
|
|
139
|
+
content: sourceText
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!routeTagMatch) {
|
|
144
|
+
const appendedContent = `${sourceText.trimEnd()}\n\n${renderContainerRouteMetaBlock(createContainerRouteMeta({
|
|
145
|
+
surface,
|
|
146
|
+
containerHost,
|
|
147
|
+
containerPosition
|
|
148
|
+
}))}`;
|
|
149
|
+
return {
|
|
150
|
+
changed: appendedContent !== sourceText,
|
|
151
|
+
content: appendedContent
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const routeTagAttributes = parseTagAttributes(routeTagMatch[1]);
|
|
156
|
+
const routeTagLanguage = normalizeText(routeTagAttributes.lang).toLowerCase();
|
|
157
|
+
if (routeTagLanguage && routeTagLanguage !== "json") {
|
|
158
|
+
return {
|
|
159
|
+
changed: false,
|
|
160
|
+
content: sourceText
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let routeMetaRecord = null;
|
|
165
|
+
try {
|
|
166
|
+
routeMetaRecord = JSON.parse(String(routeTagMatch[2] || "").trim());
|
|
167
|
+
} catch {
|
|
168
|
+
return {
|
|
169
|
+
changed: false,
|
|
170
|
+
content: sourceText
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const routeMeta = normalizeObject(routeMetaRecord);
|
|
175
|
+
const metadata = normalizeObject(routeMeta.meta);
|
|
176
|
+
const jskitMetadata = normalizeObject(metadata.jskit);
|
|
177
|
+
const placementsMetadata = normalizeObject(jskitMetadata.placements);
|
|
178
|
+
const outletRecords = Array.isArray(placementsMetadata.outlets) ? [...placementsMetadata.outlets] : [];
|
|
179
|
+
const knownTargetIds = new Set(outletRecords.map((entry) => normalizeOutletTargetId(entry)).filter(Boolean));
|
|
180
|
+
if (!knownTargetIds.has(expectedTargetId)) {
|
|
181
|
+
outletRecords.push({
|
|
182
|
+
host: containerHost,
|
|
183
|
+
position: containerPosition
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const normalizedSurface = normalizeText(jskitMetadata.surface) || normalizeText(surface);
|
|
188
|
+
const nextRouteMeta = {
|
|
189
|
+
...routeMeta,
|
|
190
|
+
meta: {
|
|
191
|
+
...metadata,
|
|
192
|
+
jskit: {
|
|
193
|
+
...jskitMetadata,
|
|
194
|
+
surface: normalizedSurface,
|
|
195
|
+
placements: {
|
|
196
|
+
...placementsMetadata,
|
|
197
|
+
outlets: outletRecords
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const renderedRouteMeta = renderContainerRouteMetaBlock(nextRouteMeta);
|
|
203
|
+
const replacementContent =
|
|
204
|
+
`${sourceText.slice(0, routeTagMatch.index)}${renderedRouteMeta}${sourceText.slice(routeTagMatch.index + routeTagMatch[0].length)}`;
|
|
205
|
+
return {
|
|
206
|
+
changed: replacementContent !== sourceText,
|
|
207
|
+
content: replacementContent
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
43
211
|
async function loadPublicConfig(appRoot = "") {
|
|
44
212
|
const configPath = path.join(appRoot, "config", "public.js");
|
|
45
213
|
|
|
@@ -144,7 +312,7 @@ const resolvedSubtitle = computed(() => String(props.subtitle || "").trim());
|
|
|
144
312
|
scrollbar-width: thin;
|
|
145
313
|
}
|
|
146
314
|
|
|
147
|
-
.section-container-shell__tabs :deep(.
|
|
315
|
+
.section-container-shell__tabs :deep(.tab-link-item) {
|
|
148
316
|
flex: 0 0 auto;
|
|
149
317
|
}
|
|
150
318
|
|
|
@@ -158,7 +326,7 @@ const resolvedSubtitle = computed(() => String(props.subtitle || "").trim());
|
|
|
158
326
|
`;
|
|
159
327
|
}
|
|
160
328
|
|
|
161
|
-
function
|
|
329
|
+
function renderTabLinkItemSource() {
|
|
162
330
|
return `<script setup>
|
|
163
331
|
import { computed } from "vue";
|
|
164
332
|
import { useRoute } from "vue-router";
|
|
@@ -213,6 +381,22 @@ function normalizePathname(pathname = "") {
|
|
|
213
381
|
return cutoff < 0 ? source : source.slice(0, cutoff);
|
|
214
382
|
}
|
|
215
383
|
|
|
384
|
+
function interpolateBracketParams(pathTemplate = "", params = {}) {
|
|
385
|
+
const source = String(pathTemplate || "").trim();
|
|
386
|
+
if (!source) {
|
|
387
|
+
return "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return source.replace(/\\[([^\\]]+)\\]/g, (_match, rawKey) => {
|
|
391
|
+
const key = String(rawKey || "").trim();
|
|
392
|
+
if (!key) {
|
|
393
|
+
return "";
|
|
394
|
+
}
|
|
395
|
+
const value = params?.[key];
|
|
396
|
+
return value == null ? "[" + key + "]" : encodeURIComponent(String(value));
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
216
400
|
const targetSurfaceId = computed(() => {
|
|
217
401
|
const explicitSurface = String(props.surface || "").trim().toLowerCase();
|
|
218
402
|
if (explicitSurface && explicitSurface !== "*") {
|
|
@@ -224,6 +408,17 @@ const targetSurfaceId = computed(() => {
|
|
|
224
408
|
const resolvedTo = computed(() => {
|
|
225
409
|
const explicitTo = String(props.to || "").trim();
|
|
226
410
|
if (explicitTo) {
|
|
411
|
+
if (explicitTo.startsWith("./")) {
|
|
412
|
+
const workspaceSlug = String(workspaceSlugFromRoute.value || "").trim();
|
|
413
|
+
const suffixTemplate = workspaceSlug ? props.workspaceSuffix : props.nonWorkspaceSuffix;
|
|
414
|
+
const interpolatedSuffix = interpolateBracketParams(suffixTemplate, route.params || {});
|
|
415
|
+
if (interpolatedSuffix && !interpolatedSuffix.includes("[")) {
|
|
416
|
+
return paths.page(interpolatedSuffix, {
|
|
417
|
+
surface: targetSurfaceId.value,
|
|
418
|
+
mode: "auto"
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
227
422
|
return explicitTo;
|
|
228
423
|
}
|
|
229
424
|
|
|
@@ -249,7 +444,7 @@ const isActive = computed(() => {
|
|
|
249
444
|
<template>
|
|
250
445
|
<v-btn
|
|
251
446
|
v-if="resolvedTo"
|
|
252
|
-
class="
|
|
447
|
+
class="tab-link-item text-none"
|
|
253
448
|
:to="resolvedTo"
|
|
254
449
|
rounded="pill"
|
|
255
450
|
size="small"
|
|
@@ -272,6 +467,11 @@ function renderContainerPageSource({
|
|
|
272
467
|
containerPosition = CONTAINER_OUTLET_POSITION,
|
|
273
468
|
sectionContainerComponentImportPath = "/src/components/SectionContainerShell.vue"
|
|
274
469
|
} = {}) {
|
|
470
|
+
const routeMeta = createContainerRouteMeta({
|
|
471
|
+
surface,
|
|
472
|
+
containerHost,
|
|
473
|
+
containerPosition
|
|
474
|
+
});
|
|
275
475
|
return `<script setup>
|
|
276
476
|
import { RouterView } from "vue-router";
|
|
277
477
|
import SectionContainerShell from "${sectionContainerComponentImportPath}";
|
|
@@ -288,15 +488,7 @@ import SectionContainerShell from "${sectionContainerComponentImportPath}";
|
|
|
288
488
|
</SectionContainerShell>
|
|
289
489
|
</template>
|
|
290
490
|
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
"meta": {
|
|
294
|
-
"jskit": {
|
|
295
|
-
"surface": "${surface}"
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
</route>
|
|
491
|
+
${renderContainerRouteMetaBlock(routeMeta)}
|
|
300
492
|
`;
|
|
301
493
|
}
|
|
302
494
|
|
|
@@ -322,18 +514,19 @@ async function runGeneratorSubcommand({
|
|
|
322
514
|
const surface = requireOption(options, "surface", { context: "ui-generator container" }).toLowerCase();
|
|
323
515
|
const routePrefix = normalizeRoutePrefix(options?.["directory-prefix"]);
|
|
324
516
|
const componentDirectory = normalizeText(options?.path) || DEFAULT_COMPONENT_DIRECTORY;
|
|
517
|
+
const containerRoutePath = resolveContainerRoutePath({
|
|
518
|
+
name,
|
|
519
|
+
routePath: options?.["route-path"]
|
|
520
|
+
});
|
|
325
521
|
const containerSlug = toKebabCase(name);
|
|
326
|
-
if (!containerSlug) {
|
|
522
|
+
if (!containerSlug || !containerRoutePath) {
|
|
327
523
|
throw new Error("ui-generator container requires a valid --name.");
|
|
328
524
|
}
|
|
329
525
|
|
|
330
|
-
const routePath = routePrefix ? `${routePrefix}/${
|
|
526
|
+
const routePath = routePrefix ? `${routePrefix}/${containerRoutePath}` : containerRoutePath;
|
|
331
527
|
const pagesDirectory = await resolveSurfacePagesDirectory(resolvedAppRoot, surface);
|
|
332
528
|
const containerFilePath = path.join(pagesDirectory, `${routePath}.vue`);
|
|
333
529
|
const containerRelativePath = toPosixPath(path.relative(resolvedAppRoot, containerFilePath));
|
|
334
|
-
const placementPath = resolvePathWithinApp(resolvedAppRoot, PLACEMENT_FILE, {
|
|
335
|
-
context: "ui-generator container"
|
|
336
|
-
});
|
|
337
530
|
const providerPath = resolvePathWithinApp(resolvedAppRoot, MAIN_CLIENT_PROVIDER_FILE, {
|
|
338
531
|
context: "ui-generator container"
|
|
339
532
|
});
|
|
@@ -346,17 +539,20 @@ async function runGeneratorSubcommand({
|
|
|
346
539
|
);
|
|
347
540
|
const sectionTabLinkPath = resolvePathWithinApp(
|
|
348
541
|
resolvedAppRoot,
|
|
349
|
-
path.join(componentDirectory, `${
|
|
542
|
+
path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`),
|
|
350
543
|
{
|
|
351
544
|
context: "ui-generator container"
|
|
352
545
|
}
|
|
353
546
|
);
|
|
354
547
|
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
548
|
+
const placementOption = normalizeText(options?.placement);
|
|
549
|
+
const placementTarget = placementOption
|
|
550
|
+
? await resolveShellOutletPlacementTargetFromApp({
|
|
551
|
+
appRoot: resolvedAppRoot,
|
|
552
|
+
context: "ui-generator container",
|
|
553
|
+
placement: placementOption
|
|
554
|
+
})
|
|
555
|
+
: null;
|
|
360
556
|
|
|
361
557
|
const touchedFiles = new Set();
|
|
362
558
|
|
|
@@ -383,7 +579,7 @@ async function runGeneratorSubcommand({
|
|
|
383
579
|
if (!existingSectionTabLinkSource) {
|
|
384
580
|
if (dryRun !== true) {
|
|
385
581
|
await mkdir(path.dirname(sectionTabLinkPath.absolutePath), { recursive: true });
|
|
386
|
-
await writeFile(sectionTabLinkPath.absolutePath,
|
|
582
|
+
await writeFile(sectionTabLinkPath.absolutePath, renderTabLinkItemSource(), "utf8");
|
|
387
583
|
}
|
|
388
584
|
touchedFiles.add(sectionTabLinkPath.relativePath);
|
|
389
585
|
}
|
|
@@ -395,9 +591,9 @@ async function runGeneratorSubcommand({
|
|
|
395
591
|
);
|
|
396
592
|
}
|
|
397
593
|
|
|
398
|
-
const providerImportLine = `import ${
|
|
594
|
+
const providerImportLine = `import ${TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`))}";`;
|
|
399
595
|
const providerRegisterLine =
|
|
400
|
-
`registerMainClientComponent("${
|
|
596
|
+
`registerMainClientComponent("${TAB_LINK_COMPONENT_TOKEN}", () => ${TAB_LINK_COMPONENT});`;
|
|
401
597
|
const providerImportApplied = insertImportIfMissing(providerSource, providerImportLine);
|
|
402
598
|
const providerRegisterApplied = insertBeforeClassDeclaration(
|
|
403
599
|
providerImportApplied.content,
|
|
@@ -436,36 +632,53 @@ async function runGeneratorSubcommand({
|
|
|
436
632
|
);
|
|
437
633
|
}
|
|
438
634
|
touchedFiles.add(containerRelativePath);
|
|
635
|
+
} else {
|
|
636
|
+
const routeMetaApplied = ensureContainerRouteMetaOutlets(existingContainerSource, {
|
|
637
|
+
surface,
|
|
638
|
+
containerHost: containerSlug,
|
|
639
|
+
containerPosition: CONTAINER_OUTLET_POSITION
|
|
640
|
+
});
|
|
641
|
+
if (routeMetaApplied.changed) {
|
|
642
|
+
if (dryRun !== true) {
|
|
643
|
+
await writeFile(containerFilePath, routeMetaApplied.content, "utf8");
|
|
644
|
+
}
|
|
645
|
+
touchedFiles.add(containerRelativePath);
|
|
646
|
+
}
|
|
439
647
|
}
|
|
440
648
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
"
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
649
|
+
if (placementTarget) {
|
|
650
|
+
const placementPath = resolvePathWithinApp(resolvedAppRoot, PLACEMENT_FILE, {
|
|
651
|
+
context: "ui-generator container"
|
|
652
|
+
});
|
|
653
|
+
const placementSource = await readFile(placementPath.absolutePath, "utf8");
|
|
654
|
+
const placementIdSuffix = routePath.replaceAll("/", "-");
|
|
655
|
+
const placementMarker = `jskit:ui-generator.container.menu:${surface}:${routePath}`;
|
|
656
|
+
const placementBlock =
|
|
657
|
+
`// ${placementMarker}\n` +
|
|
658
|
+
"{\n" +
|
|
659
|
+
" addPlacement({\n" +
|
|
660
|
+
` id: "ui-generator.container.${placementIdSuffix}.menu",\n` +
|
|
661
|
+
` host: "${placementTarget.host}",\n` +
|
|
662
|
+
` position: "${placementTarget.position}",\n` +
|
|
663
|
+
` surfaces: ["${surface}"],\n` +
|
|
664
|
+
" order: 155,\n" +
|
|
665
|
+
' componentToken: "users.web.shell.surface-aware-menu-link-item",\n' +
|
|
666
|
+
" props: {\n" +
|
|
667
|
+
` label: "${name}",\n` +
|
|
668
|
+
` surface: "${surface}",\n` +
|
|
669
|
+
` workspaceSuffix: "/${routePath}",\n` +
|
|
670
|
+
` nonWorkspaceSuffix: "/${routePath}"\n` +
|
|
671
|
+
" },\n" +
|
|
672
|
+
" when: ({ auth }) => Boolean(auth?.authenticated)\n" +
|
|
673
|
+
" });\n" +
|
|
674
|
+
"}\n";
|
|
675
|
+
const placementApplied = appendBlockIfMarkerMissing(placementSource, placementMarker, placementBlock);
|
|
676
|
+
if (placementApplied.changed) {
|
|
677
|
+
if (dryRun !== true) {
|
|
678
|
+
await writeFile(placementPath.absolutePath, placementApplied.content, "utf8");
|
|
679
|
+
}
|
|
680
|
+
touchedFiles.add(placementPath.relativePath);
|
|
467
681
|
}
|
|
468
|
-
touchedFiles.add(placementPath.relativePath);
|
|
469
682
|
}
|
|
470
683
|
|
|
471
684
|
const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));
|