@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.
@@ -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 SECTION_TAB_LINK_COMPONENT = "SectionShellTabLinkItem";
28
- const SECTION_TAB_LINK_COMPONENT_TOKEN = "local.main.ui.section-shell.tab-link-item";
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) => toKebabCase(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(.section-shell-tab-link) {
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 renderSectionShellTabLinkItemSource() {
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="section-shell-tab-link text-none"
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
- <route lang="json">
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}/${containerSlug}` : containerSlug;
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, `${SECTION_TAB_LINK_COMPONENT}.vue`),
542
+ path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`),
350
543
  {
351
544
  context: "ui-generator container"
352
545
  }
353
546
  );
354
547
 
355
- const placementTarget = await resolveShellOutletPlacementTargetFromApp({
356
- appRoot: resolvedAppRoot,
357
- context: "ui-generator container",
358
- placement: options?.placement
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, renderSectionShellTabLinkItemSource(), "utf8");
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 ${SECTION_TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${SECTION_TAB_LINK_COMPONENT}.vue`))}";`;
594
+ const providerImportLine = `import ${TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`))}";`;
399
595
  const providerRegisterLine =
400
- `registerMainClientComponent("${SECTION_TAB_LINK_COMPONENT_TOKEN}", () => ${SECTION_TAB_LINK_COMPONENT});`;
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
- const placementSource = await readFile(placementPath.absolutePath, "utf8");
442
- const placementIdSuffix = routePath.replaceAll("/", "-");
443
- const placementMarker = `jskit:ui-generator.container.menu:${surface}:${routePath}`;
444
- const placementBlock =
445
- `// ${placementMarker}\n` +
446
- "{\n" +
447
- " addPlacement({\n" +
448
- ` id: "ui-generator.container.${placementIdSuffix}.menu",\n` +
449
- ` host: "${placementTarget.host}",\n` +
450
- ` position: "${placementTarget.position}",\n` +
451
- ` surfaces: ["${surface}"],\n` +
452
- " order: 155,\n" +
453
- ' componentToken: "users.web.shell.surface-aware-menu-link-item",\n' +
454
- " props: {\n" +
455
- ` label: "${name}",\n` +
456
- ` surface: "${surface}",\n` +
457
- ` workspaceSuffix: "/${routePath}",\n` +
458
- ` nonWorkspaceSuffix: "/${routePath}"\n` +
459
- " },\n" +
460
- " when: ({ auth }) => Boolean(auth?.authenticated)\n" +
461
- " });\n" +
462
- "}\n";
463
- const placementApplied = appendBlockIfMarkerMissing(placementSource, placementMarker, placementBlock);
464
- if (placementApplied.changed) {
465
- if (dryRun !== true) {
466
- await writeFile(placementPath.absolutePath, placementApplied.content, "utf8");
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));