@jskit-ai/ui-generator 0.1.14 → 0.1.15

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.
@@ -1,644 +0,0 @@
1
- import path from "node:path";
2
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3
- import { pathToFileURL } from "node:url";
4
- import {
5
- resolveRequiredAppRoot,
6
- resolveShellOutletPlacementTargetFromApp,
7
- toPosixPath
8
- } from "@jskit-ai/kernel/server/support";
9
- import {
10
- normalizeObject,
11
- normalizeText
12
- } from "@jskit-ai/kernel/shared/support/normalize";
13
- import {
14
- DEFAULT_COMPONENT_DIRECTORY,
15
- MAIN_CLIENT_PROVIDER_FILE,
16
- PLACEMENT_FILE,
17
- toKebabCase,
18
- requireOption,
19
- resolvePathWithinApp,
20
- appendBlockIfMarkerMissing,
21
- insertImportIfMissing,
22
- insertBeforeClassDeclaration
23
- } from "./support.js";
24
-
25
- const CONTAINER_OUTLET_POSITION = "sub-pages";
26
- const SECTION_CONTAINER_SHELL_COMPONENT = "SectionContainerShell";
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
- }
48
-
49
- function normalizeRoutePrefix(value = "") {
50
- const source = normalizeText(value).replaceAll("\\", "/");
51
- if (!source) {
52
- return "";
53
- }
54
-
55
- const parts = source
56
- .split("/")
57
- .map((entry) => normalizeRoutePrefixSegment(entry))
58
- .filter(Boolean);
59
- return parts.join("/");
60
- }
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
-
211
- async function loadPublicConfig(appRoot = "") {
212
- const configPath = path.join(appRoot, "config", "public.js");
213
-
214
- try {
215
- await readFile(configPath, "utf8");
216
- } catch {
217
- throw new Error("ui-generator container requires app config at config/public.js.");
218
- }
219
-
220
- let moduleNamespace = null;
221
- try {
222
- moduleNamespace = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}_${Math.random()}`);
223
- } catch (error) {
224
- throw new Error(
225
- `ui-generator container could not load config/public.js: ${String(error?.message || error || "unknown error")}`
226
- );
227
- }
228
-
229
- const config = normalizeObject(
230
- moduleNamespace?.config ||
231
- moduleNamespace?.default?.config ||
232
- moduleNamespace?.default
233
- );
234
- if (Object.keys(config).length < 1) {
235
- throw new Error("ui-generator container requires exported config in config/public.js.");
236
- }
237
-
238
- return config;
239
- }
240
-
241
- async function resolveSurfacePagesDirectory(appRoot = "", surfaceId = "") {
242
- const config = await loadPublicConfig(appRoot);
243
- const surfaceDefinitions = normalizeObject(config.surfaceDefinitions);
244
- const normalizedSurfaceId = normalizeText(surfaceId).toLowerCase();
245
- const surfaceDefinition = normalizeObject(surfaceDefinitions[normalizedSurfaceId]);
246
- if (Object.keys(surfaceDefinition).length < 1) {
247
- throw new Error(`ui-generator container surface "${normalizedSurfaceId}" is not defined in config/public.js.`);
248
- }
249
- if (surfaceDefinition.enabled === false) {
250
- throw new Error(`ui-generator container surface "${normalizedSurfaceId}" is disabled in config/public.js.`);
251
- }
252
-
253
- const pagesRoot = normalizeText(surfaceDefinition.pagesRoot);
254
- if (!pagesRoot) {
255
- return path.join(appRoot, "src", "pages");
256
- }
257
- return path.join(appRoot, "src", "pages", pagesRoot);
258
- }
259
-
260
- function renderSectionContainerShellSource() {
261
- return `<script setup>
262
- import { computed } from "vue";
263
- import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
264
-
265
- const props = defineProps({
266
- title: {
267
- type: String,
268
- default: ""
269
- },
270
- subtitle: {
271
- type: String,
272
- default: ""
273
- },
274
- host: {
275
- type: String,
276
- default: ""
277
- },
278
- position: {
279
- type: String,
280
- default: "${CONTAINER_OUTLET_POSITION}"
281
- }
282
- });
283
-
284
- const resolvedTitle = computed(() => String(props.title || "").trim() || "Section");
285
- const resolvedSubtitle = computed(() => String(props.subtitle || "").trim());
286
- </script>
287
-
288
- <template>
289
- <section class="section-container-shell d-flex flex-column ga-4">
290
- <v-card rounded="lg" elevation="1" border>
291
- <v-card-item>
292
- <v-card-title class="px-0">{{ resolvedTitle }}</v-card-title>
293
- <v-card-subtitle v-if="resolvedSubtitle" class="px-0">{{ resolvedSubtitle }}</v-card-subtitle>
294
- </v-card-item>
295
- <v-divider />
296
- <v-card-text class="section-container-shell__tabs">
297
- <ShellOutlet :host="props.host" :position="props.position" />
298
- </v-card-text>
299
- </v-card>
300
-
301
- <slot />
302
- </section>
303
- </template>
304
-
305
- <style scoped>
306
- .section-container-shell__tabs {
307
- display: flex;
308
- align-items: center;
309
- gap: 0.5rem;
310
- overflow-x: auto;
311
- padding: 0.75rem;
312
- scrollbar-width: thin;
313
- }
314
-
315
- .section-container-shell__tabs :deep(.tab-link-item) {
316
- flex: 0 0 auto;
317
- }
318
-
319
- @media (max-width: 640px) {
320
- .section-container-shell__tabs {
321
- gap: 0.375rem;
322
- padding: 0.5rem;
323
- }
324
- }
325
- </style>
326
- `;
327
- }
328
-
329
- function renderTabLinkItemSource() {
330
- return `<script setup>
331
- import { computed } from "vue";
332
- import { useRoute } from "vue-router";
333
- import { usePaths } from "@jskit-ai/users-web/client/composables/usePaths";
334
- import {
335
- normalizeMenuLinkPathname,
336
- resolveMenuLinkTarget
337
- } from "@jskit-ai/users-web/client/support/menuLinkTarget";
338
-
339
- const props = defineProps({
340
- label: {
341
- type: String,
342
- default: ""
343
- },
344
- to: {
345
- type: String,
346
- default: ""
347
- },
348
- surface: {
349
- type: String,
350
- default: ""
351
- },
352
- workspaceSuffix: {
353
- type: String,
354
- default: "/"
355
- },
356
- nonWorkspaceSuffix: {
357
- type: String,
358
- default: "/"
359
- },
360
- disabled: {
361
- type: Boolean,
362
- default: false
363
- }
364
- });
365
-
366
- const route = useRoute();
367
- const paths = usePaths();
368
-
369
- const resolvedTo = computed(() => {
370
- return resolveMenuLinkTarget({
371
- to: props.to,
372
- surface: props.surface,
373
- currentSurfaceId: paths.currentSurfaceId.value,
374
- placementContext: paths.placementContext.value,
375
- workspaceSuffix: props.workspaceSuffix,
376
- nonWorkspaceSuffix: props.nonWorkspaceSuffix,
377
- routeParams: route.params || {},
378
- resolvePagePath(relativePath, options = {}) {
379
- return paths.page(relativePath, options);
380
- }
381
- });
382
- });
383
-
384
- const isActive = computed(() => {
385
- const targetPathname = normalizeMenuLinkPathname(resolvedTo.value);
386
- const currentPathname = normalizeMenuLinkPathname(route.fullPath || route.path);
387
- if (!targetPathname || !currentPathname) {
388
- return false;
389
- }
390
- return currentPathname === targetPathname || currentPathname.startsWith(\`\${targetPathname}/\`);
391
- });
392
- </script>
393
-
394
- <template>
395
- <v-btn
396
- v-if="resolvedTo"
397
- class="tab-link-item text-none"
398
- :to="resolvedTo"
399
- rounded="pill"
400
- size="small"
401
- :variant="isActive ? 'flat' : 'tonal'"
402
- :color="isActive ? 'primary' : undefined"
403
- :disabled="props.disabled"
404
- :aria-current="isActive ? 'page' : undefined"
405
- >
406
- {{ props.label }}
407
- </v-btn>
408
- </template>
409
- `;
410
- }
411
-
412
- function renderContainerPageSource({
413
- surface = "",
414
- title = "",
415
- subtitle = "",
416
- containerHost = "",
417
- containerPosition = CONTAINER_OUTLET_POSITION,
418
- sectionContainerComponentImportPath = "/src/components/SectionContainerShell.vue"
419
- } = {}) {
420
- const routeMeta = createContainerRouteMeta({
421
- surface,
422
- containerHost,
423
- containerPosition
424
- });
425
- return `<script setup>
426
- import { RouterView } from "vue-router";
427
- import SectionContainerShell from "${sectionContainerComponentImportPath}";
428
- </script>
429
-
430
- <template>
431
- <SectionContainerShell
432
- title="${title}"
433
- subtitle="${subtitle}"
434
- host="${containerHost}"
435
- position="${containerPosition}"
436
- >
437
- <RouterView />
438
- </SectionContainerShell>
439
- </template>
440
-
441
- ${renderContainerRouteMetaBlock(routeMeta)}
442
- `;
443
- }
444
-
445
- async function runGeneratorSubcommand({
446
- appRoot,
447
- subcommand = "",
448
- args = [],
449
- options = {},
450
- dryRun = false
451
- } = {}) {
452
- const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
453
- if (normalizedSubcommand !== "container") {
454
- throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
455
- }
456
- if (Array.isArray(args) && args.length > 0) {
457
- throw new Error("ui-generator container does not accept positional arguments.");
458
- }
459
-
460
- const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
461
- context: "ui-generator container"
462
- });
463
- const name = requireOption(options, "name", { context: "ui-generator container" });
464
- const surface = requireOption(options, "surface", { context: "ui-generator container" }).toLowerCase();
465
- const routePrefix = normalizeRoutePrefix(options?.["directory-prefix"]);
466
- const componentDirectory = normalizeText(options?.path) || DEFAULT_COMPONENT_DIRECTORY;
467
- const containerRoutePath = resolveContainerRoutePath({
468
- name,
469
- routePath: options?.["route-path"]
470
- });
471
- const containerSlug = toKebabCase(name);
472
- if (!containerSlug || !containerRoutePath) {
473
- throw new Error("ui-generator container requires a valid --name.");
474
- }
475
-
476
- const routePath = routePrefix ? `${routePrefix}/${containerRoutePath}` : containerRoutePath;
477
- const pagesDirectory = await resolveSurfacePagesDirectory(resolvedAppRoot, surface);
478
- const containerFilePath = path.join(pagesDirectory, `${routePath}.vue`);
479
- const containerRelativePath = toPosixPath(path.relative(resolvedAppRoot, containerFilePath));
480
- const providerPath = resolvePathWithinApp(resolvedAppRoot, MAIN_CLIENT_PROVIDER_FILE, {
481
- context: "ui-generator container"
482
- });
483
- const sectionContainerShellPath = resolvePathWithinApp(
484
- resolvedAppRoot,
485
- path.join(componentDirectory, `${SECTION_CONTAINER_SHELL_COMPONENT}.vue`),
486
- {
487
- context: "ui-generator container"
488
- }
489
- );
490
- const sectionTabLinkPath = resolvePathWithinApp(
491
- resolvedAppRoot,
492
- path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`),
493
- {
494
- context: "ui-generator container"
495
- }
496
- );
497
-
498
- const placementOption = normalizeText(options?.placement);
499
- const placementTarget = placementOption
500
- ? await resolveShellOutletPlacementTargetFromApp({
501
- appRoot: resolvedAppRoot,
502
- context: "ui-generator container",
503
- placement: placementOption
504
- })
505
- : null;
506
-
507
- const touchedFiles = new Set();
508
-
509
- let existingSectionContainerSource = "";
510
- try {
511
- existingSectionContainerSource = await readFile(sectionContainerShellPath.absolutePath, "utf8");
512
- } catch {
513
- existingSectionContainerSource = "";
514
- }
515
- if (!existingSectionContainerSource) {
516
- if (dryRun !== true) {
517
- await mkdir(path.dirname(sectionContainerShellPath.absolutePath), { recursive: true });
518
- await writeFile(sectionContainerShellPath.absolutePath, renderSectionContainerShellSource(), "utf8");
519
- }
520
- touchedFiles.add(sectionContainerShellPath.relativePath);
521
- }
522
-
523
- let existingSectionTabLinkSource = "";
524
- try {
525
- existingSectionTabLinkSource = await readFile(sectionTabLinkPath.absolutePath, "utf8");
526
- } catch {
527
- existingSectionTabLinkSource = "";
528
- }
529
- if (!existingSectionTabLinkSource) {
530
- if (dryRun !== true) {
531
- await mkdir(path.dirname(sectionTabLinkPath.absolutePath), { recursive: true });
532
- await writeFile(sectionTabLinkPath.absolutePath, renderTabLinkItemSource(), "utf8");
533
- }
534
- touchedFiles.add(sectionTabLinkPath.relativePath);
535
- }
536
-
537
- const providerSource = await readFile(providerPath.absolutePath, "utf8");
538
- if (!/\bregisterMainClientComponent\s*\(/.test(providerSource)) {
539
- throw new Error(
540
- `ui-generator container could not find registerMainClientComponent() contract in ${MAIN_CLIENT_PROVIDER_FILE}.`
541
- );
542
- }
543
-
544
- const providerImportLine = `import ${TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`))}";`;
545
- const providerRegisterLine =
546
- `registerMainClientComponent("${TAB_LINK_COMPONENT_TOKEN}", () => ${TAB_LINK_COMPONENT});`;
547
- const providerImportApplied = insertImportIfMissing(providerSource, providerImportLine);
548
- const providerRegisterApplied = insertBeforeClassDeclaration(
549
- providerImportApplied.content,
550
- providerRegisterLine,
551
- {
552
- className: "MainClientProvider",
553
- contextFile: MAIN_CLIENT_PROVIDER_FILE
554
- }
555
- );
556
- if (providerImportApplied.changed || providerRegisterApplied.changed) {
557
- if (dryRun !== true) {
558
- await writeFile(providerPath.absolutePath, providerRegisterApplied.content, "utf8");
559
- }
560
- touchedFiles.add(providerPath.relativePath);
561
- }
562
-
563
- let existingContainerSource = "";
564
- try {
565
- existingContainerSource = await readFile(containerFilePath, "utf8");
566
- } catch {
567
- existingContainerSource = "";
568
- }
569
- if (!existingContainerSource) {
570
- if (dryRun !== true) {
571
- await mkdir(path.dirname(containerFilePath), { recursive: true });
572
- await writeFile(
573
- containerFilePath,
574
- renderContainerPageSource({
575
- surface,
576
- title: name,
577
- subtitle: `Manage ${toKebabCase(name).replaceAll("-", " ")} modules.`,
578
- containerHost: containerSlug,
579
- sectionContainerComponentImportPath: `/${toPosixPath(path.join(componentDirectory, `${SECTION_CONTAINER_SHELL_COMPONENT}.vue`))}`
580
- }),
581
- "utf8"
582
- );
583
- }
584
- touchedFiles.add(containerRelativePath);
585
- } else {
586
- const routeMetaApplied = ensureContainerRouteMetaOutlets(existingContainerSource, {
587
- surface,
588
- containerHost: containerSlug,
589
- containerPosition: CONTAINER_OUTLET_POSITION
590
- });
591
- if (routeMetaApplied.changed) {
592
- if (dryRun !== true) {
593
- await writeFile(containerFilePath, routeMetaApplied.content, "utf8");
594
- }
595
- touchedFiles.add(containerRelativePath);
596
- }
597
- }
598
-
599
- if (placementTarget) {
600
- const placementPath = resolvePathWithinApp(resolvedAppRoot, PLACEMENT_FILE, {
601
- context: "ui-generator container"
602
- });
603
- const placementSource = await readFile(placementPath.absolutePath, "utf8");
604
- const placementIdSuffix = routePath.replaceAll("/", "-");
605
- const placementMarker = `jskit:ui-generator.container.menu:${surface}:${routePath}`;
606
- const placementBlock =
607
- `// ${placementMarker}\n` +
608
- "{\n" +
609
- " addPlacement({\n" +
610
- ` id: "ui-generator.container.${placementIdSuffix}.menu",\n` +
611
- ` host: "${placementTarget.host}",\n` +
612
- ` position: "${placementTarget.position}",\n` +
613
- ` surfaces: ["${surface}"],\n` +
614
- " order: 155,\n" +
615
- ' componentToken: "users.web.shell.surface-aware-menu-link-item",\n' +
616
- " props: {\n" +
617
- ` label: "${name}",\n` +
618
- ` surface: "${surface}",\n` +
619
- ` workspaceSuffix: "/${routePath}",\n` +
620
- ` nonWorkspaceSuffix: "/${routePath}"\n` +
621
- " },\n" +
622
- " when: ({ auth }) => Boolean(auth?.authenticated)\n" +
623
- " });\n" +
624
- "}\n";
625
- const placementApplied = appendBlockIfMarkerMissing(placementSource, placementMarker, placementBlock);
626
- if (placementApplied.changed) {
627
- if (dryRun !== true) {
628
- await writeFile(placementPath.absolutePath, placementApplied.content, "utf8");
629
- }
630
- touchedFiles.add(placementPath.relativePath);
631
- }
632
- }
633
-
634
- const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));
635
- return {
636
- touchedFiles: touchedFileList,
637
- summary:
638
- touchedFileList.length > 0
639
- ? `Generated UI container "${routePath}" with outlet "${containerSlug}:${CONTAINER_OUTLET_POSITION}".`
640
- : `UI container "${routePath}" is already up to date.`
641
- };
642
- }
643
-
644
- export { runGeneratorSubcommand };
@@ -1,6 +0,0 @@
1
- <template>
2
- <section class="pa-4">
3
- <h1 class="text-h5 mb-2">${option:name|trim}</h1>
4
- <p class="text-body-2 text-medium-emphasis">Replace this scaffold with your page implementation.</p>
5
- </section>
6
- </template>