@jskit-ai/ui-generator 0.1.5 → 0.1.7

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,12 +326,15 @@ 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";
165
333
  import { usePaths } from "@jskit-ai/users-web/client/composables/usePaths";
166
- import { useWorkspaceRouteContext } from "@jskit-ai/users-web/client/composables/useWorkspaceRouteContext";
334
+ import {
335
+ normalizeMenuLinkPathname,
336
+ resolveMenuLinkTarget
337
+ } from "@jskit-ai/users-web/client/support/menuLinkTarget";
167
338
 
168
339
  const props = defineProps({
169
340
  label: {
@@ -194,51 +365,25 @@ const props = defineProps({
194
365
 
195
366
  const route = useRoute();
196
367
  const paths = usePaths();
197
- const { currentSurfaceId, workspaceSlugFromRoute } = useWorkspaceRouteContext();
198
-
199
- function normalizePathname(pathname = "") {
200
- const source = String(pathname || "").trim();
201
- if (!source) {
202
- return "";
203
- }
204
-
205
- const queryIndex = source.indexOf("?");
206
- const hashIndex = source.indexOf("#");
207
- const cutoff =
208
- queryIndex < 0
209
- ? hashIndex
210
- : hashIndex < 0
211
- ? queryIndex
212
- : Math.min(queryIndex, hashIndex);
213
- return cutoff < 0 ? source : source.slice(0, cutoff);
214
- }
215
-
216
- const targetSurfaceId = computed(() => {
217
- const explicitSurface = String(props.surface || "").trim().toLowerCase();
218
- if (explicitSurface && explicitSurface !== "*") {
219
- return explicitSurface;
220
- }
221
- return String(currentSurfaceId.value || paths.currentSurfaceId.value || "").trim().toLowerCase();
222
- });
223
368
 
224
369
  const resolvedTo = computed(() => {
225
- const explicitTo = String(props.to || "").trim();
226
- if (explicitTo) {
227
- return explicitTo;
228
- }
229
-
230
- const workspaceSlug = String(workspaceSlugFromRoute.value || "").trim();
231
- const suffix = workspaceSlug ? props.workspaceSuffix : props.nonWorkspaceSuffix;
232
- const normalizedSuffix = String(suffix || "/").trim() || "/";
233
- return paths.page(normalizedSuffix, {
234
- surface: targetSurfaceId.value,
235
- mode: "auto"
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
+ }
236
381
  });
237
382
  });
238
383
 
239
384
  const isActive = computed(() => {
240
- const targetPathname = normalizePathname(resolvedTo.value);
241
- const currentPathname = normalizePathname(route.fullPath || route.path);
385
+ const targetPathname = normalizeMenuLinkPathname(resolvedTo.value);
386
+ const currentPathname = normalizeMenuLinkPathname(route.fullPath || route.path);
242
387
  if (!targetPathname || !currentPathname) {
243
388
  return false;
244
389
  }
@@ -249,7 +394,7 @@ const isActive = computed(() => {
249
394
  <template>
250
395
  <v-btn
251
396
  v-if="resolvedTo"
252
- class="section-shell-tab-link text-none"
397
+ class="tab-link-item text-none"
253
398
  :to="resolvedTo"
254
399
  rounded="pill"
255
400
  size="small"
@@ -272,6 +417,11 @@ function renderContainerPageSource({
272
417
  containerPosition = CONTAINER_OUTLET_POSITION,
273
418
  sectionContainerComponentImportPath = "/src/components/SectionContainerShell.vue"
274
419
  } = {}) {
420
+ const routeMeta = createContainerRouteMeta({
421
+ surface,
422
+ containerHost,
423
+ containerPosition
424
+ });
275
425
  return `<script setup>
276
426
  import { RouterView } from "vue-router";
277
427
  import SectionContainerShell from "${sectionContainerComponentImportPath}";
@@ -288,15 +438,7 @@ import SectionContainerShell from "${sectionContainerComponentImportPath}";
288
438
  </SectionContainerShell>
289
439
  </template>
290
440
 
291
- <route lang="json">
292
- {
293
- "meta": {
294
- "jskit": {
295
- "surface": "${surface}"
296
- }
297
- }
298
- }
299
- </route>
441
+ ${renderContainerRouteMetaBlock(routeMeta)}
300
442
  `;
301
443
  }
302
444
 
@@ -322,18 +464,19 @@ async function runGeneratorSubcommand({
322
464
  const surface = requireOption(options, "surface", { context: "ui-generator container" }).toLowerCase();
323
465
  const routePrefix = normalizeRoutePrefix(options?.["directory-prefix"]);
324
466
  const componentDirectory = normalizeText(options?.path) || DEFAULT_COMPONENT_DIRECTORY;
467
+ const containerRoutePath = resolveContainerRoutePath({
468
+ name,
469
+ routePath: options?.["route-path"]
470
+ });
325
471
  const containerSlug = toKebabCase(name);
326
- if (!containerSlug) {
472
+ if (!containerSlug || !containerRoutePath) {
327
473
  throw new Error("ui-generator container requires a valid --name.");
328
474
  }
329
475
 
330
- const routePath = routePrefix ? `${routePrefix}/${containerSlug}` : containerSlug;
476
+ const routePath = routePrefix ? `${routePrefix}/${containerRoutePath}` : containerRoutePath;
331
477
  const pagesDirectory = await resolveSurfacePagesDirectory(resolvedAppRoot, surface);
332
478
  const containerFilePath = path.join(pagesDirectory, `${routePath}.vue`);
333
479
  const containerRelativePath = toPosixPath(path.relative(resolvedAppRoot, containerFilePath));
334
- const placementPath = resolvePathWithinApp(resolvedAppRoot, PLACEMENT_FILE, {
335
- context: "ui-generator container"
336
- });
337
480
  const providerPath = resolvePathWithinApp(resolvedAppRoot, MAIN_CLIENT_PROVIDER_FILE, {
338
481
  context: "ui-generator container"
339
482
  });
@@ -346,17 +489,20 @@ async function runGeneratorSubcommand({
346
489
  );
347
490
  const sectionTabLinkPath = resolvePathWithinApp(
348
491
  resolvedAppRoot,
349
- path.join(componentDirectory, `${SECTION_TAB_LINK_COMPONENT}.vue`),
492
+ path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`),
350
493
  {
351
494
  context: "ui-generator container"
352
495
  }
353
496
  );
354
497
 
355
- const placementTarget = await resolveShellOutletPlacementTargetFromApp({
356
- appRoot: resolvedAppRoot,
357
- context: "ui-generator container",
358
- placement: options?.placement
359
- });
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;
360
506
 
361
507
  const touchedFiles = new Set();
362
508
 
@@ -383,7 +529,7 @@ async function runGeneratorSubcommand({
383
529
  if (!existingSectionTabLinkSource) {
384
530
  if (dryRun !== true) {
385
531
  await mkdir(path.dirname(sectionTabLinkPath.absolutePath), { recursive: true });
386
- await writeFile(sectionTabLinkPath.absolutePath, renderSectionShellTabLinkItemSource(), "utf8");
532
+ await writeFile(sectionTabLinkPath.absolutePath, renderTabLinkItemSource(), "utf8");
387
533
  }
388
534
  touchedFiles.add(sectionTabLinkPath.relativePath);
389
535
  }
@@ -395,9 +541,9 @@ async function runGeneratorSubcommand({
395
541
  );
396
542
  }
397
543
 
398
- const providerImportLine = `import ${SECTION_TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${SECTION_TAB_LINK_COMPONENT}.vue`))}";`;
544
+ const providerImportLine = `import ${TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${TAB_LINK_COMPONENT}.vue`))}";`;
399
545
  const providerRegisterLine =
400
- `registerMainClientComponent("${SECTION_TAB_LINK_COMPONENT_TOKEN}", () => ${SECTION_TAB_LINK_COMPONENT});`;
546
+ `registerMainClientComponent("${TAB_LINK_COMPONENT_TOKEN}", () => ${TAB_LINK_COMPONENT});`;
401
547
  const providerImportApplied = insertImportIfMissing(providerSource, providerImportLine);
402
548
  const providerRegisterApplied = insertBeforeClassDeclaration(
403
549
  providerImportApplied.content,
@@ -436,36 +582,53 @@ async function runGeneratorSubcommand({
436
582
  );
437
583
  }
438
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
+ }
439
597
  }
440
598
 
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");
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);
467
631
  }
468
- touchedFiles.add(placementPath.relativePath);
469
632
  }
470
633
 
471
634
  const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));