@jskit-ai/ui-generator 0.1.14 → 0.1.16

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.
@@ -0,0 +1,552 @@
1
+ import path from "node:path";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import {
4
+ deriveDefaultSubpagesHost,
5
+ resolveNearestParentSubpagesHost,
6
+ resolvePageTargetDetails,
7
+ resolveRequiredAppRoot,
8
+ toPosixPath
9
+ } from "@jskit-ai/kernel/server/support";
10
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
11
+ import {
12
+ DEFAULT_COMPONENT_DIRECTORY,
13
+ MAIN_CLIENT_PROVIDER_FILE,
14
+ resolvePathWithinApp,
15
+ insertImportIfMissing,
16
+ insertBeforeClassDeclaration,
17
+ findScriptBlock,
18
+ indentBlock
19
+ } from "./support.js";
20
+
21
+ const DEFAULT_SUBPAGES_POSITION = "sub-pages";
22
+ const SECTION_CONTAINER_SHELL_COMPONENT = "SectionContainerShell";
23
+ const TAB_LINK_COMPONENT = "TabLinkItem";
24
+ const TAB_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
25
+
26
+ const ROUTE_TAG_PATTERN = /<route\b[^>]*>[\s\S]*?<\/route>\s*/gi;
27
+ const TEMPLATE_TOKEN_PATTERN = /<\/?template\b[^>]*>/gi;
28
+ const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b[^>]*\/?>\s*/gi;
29
+ const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
30
+ const ROUTER_VIEW_LINE_PATTERN = /^\s*<RouterView(?:\s[^>]*)?\s*\/>\s*$/gm;
31
+
32
+ function trimEdgeBlankLines(source = "") {
33
+ return String(source || "")
34
+ .replace(/^\s*\n/, "")
35
+ .replace(/\n\s*$/, "");
36
+ }
37
+
38
+ function renderPlainPageSource(pageTitle = "") {
39
+ return `<template>
40
+ <section class="pa-4">
41
+ <h1 class="text-h5 mb-2">${pageTitle}</h1>
42
+ <p class="text-body-2 text-medium-emphasis">Replace this scaffold with your page implementation.</p>
43
+ </section>
44
+ </template>
45
+ `;
46
+ }
47
+
48
+ function renderSectionContainerShellSource() {
49
+ return `<script setup>
50
+ import { computed, useSlots } from "vue";
51
+
52
+ const props = defineProps({
53
+ title: {
54
+ type: String,
55
+ default: ""
56
+ },
57
+ subtitle: {
58
+ type: String,
59
+ default: ""
60
+ }
61
+ });
62
+
63
+ const slots = useSlots();
64
+ const resolvedTitle = computed(() => String(props.title || "").trim());
65
+ const resolvedSubtitle = computed(() => String(props.subtitle || "").trim());
66
+ const hasHeading = computed(() => Boolean(resolvedTitle.value || resolvedSubtitle.value));
67
+ const hasTabs = computed(() => Boolean(slots.tabs));
68
+ </script>
69
+
70
+ <template>
71
+ <section class="section-container-shell d-flex flex-column ga-4">
72
+ <v-card rounded="lg" elevation="1" border>
73
+ <v-card-item v-if="hasHeading">
74
+ <v-card-title v-if="resolvedTitle" class="px-0">{{ resolvedTitle }}</v-card-title>
75
+ <v-card-subtitle v-if="resolvedSubtitle" class="px-0">{{ resolvedSubtitle }}</v-card-subtitle>
76
+ </v-card-item>
77
+ <template v-if="hasTabs">
78
+ <v-divider v-if="hasHeading" />
79
+ <v-card-text class="section-container-shell__tabs">
80
+ <slot name="tabs" />
81
+ </v-card-text>
82
+ </template>
83
+ </v-card>
84
+
85
+ <slot />
86
+ </section>
87
+ </template>
88
+
89
+ <style scoped>
90
+ .section-container-shell__tabs {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 0.5rem;
94
+ overflow-x: auto;
95
+ padding: 0.75rem;
96
+ scrollbar-width: thin;
97
+ }
98
+
99
+ .section-container-shell__tabs :deep(.tab-link-item) {
100
+ flex: 0 0 auto;
101
+ }
102
+
103
+ @media (max-width: 640px) {
104
+ .section-container-shell__tabs {
105
+ gap: 0.375rem;
106
+ padding: 0.5rem;
107
+ }
108
+ }
109
+ </style>
110
+ `;
111
+ }
112
+
113
+ function renderTabLinkItemSource() {
114
+ return `<script setup>
115
+ import { computed } from "vue";
116
+ import { useRoute } from "vue-router";
117
+ import { usePaths } from "@jskit-ai/users-web/client/composables/usePaths";
118
+ import {
119
+ normalizeMenuLinkPathname,
120
+ resolveMenuLinkTarget
121
+ } from "@jskit-ai/users-web/client/support/menuLinkTarget";
122
+
123
+ const props = defineProps({
124
+ label: {
125
+ type: String,
126
+ default: ""
127
+ },
128
+ to: {
129
+ type: String,
130
+ default: ""
131
+ },
132
+ surface: {
133
+ type: String,
134
+ default: ""
135
+ },
136
+ workspaceSuffix: {
137
+ type: String,
138
+ default: "/"
139
+ },
140
+ nonWorkspaceSuffix: {
141
+ type: String,
142
+ default: "/"
143
+ },
144
+ disabled: {
145
+ type: Boolean,
146
+ default: false
147
+ }
148
+ });
149
+
150
+ const route = useRoute();
151
+ const paths = usePaths();
152
+
153
+ const resolvedTo = computed(() => {
154
+ return resolveMenuLinkTarget({
155
+ to: props.to,
156
+ surface: props.surface,
157
+ currentSurfaceId: paths.currentSurfaceId.value,
158
+ placementContext: paths.placementContext.value,
159
+ workspaceSuffix: props.workspaceSuffix,
160
+ nonWorkspaceSuffix: props.nonWorkspaceSuffix,
161
+ routeParams: route.params || {},
162
+ resolvePagePath(relativePath, options = {}) {
163
+ return paths.page(relativePath, options);
164
+ }
165
+ });
166
+ });
167
+
168
+ const isActive = computed(() => {
169
+ const targetPathname = normalizeMenuLinkPathname(resolvedTo.value);
170
+ const currentPathname = normalizeMenuLinkPathname(route.fullPath || route.path);
171
+ if (!targetPathname || !currentPathname) {
172
+ return false;
173
+ }
174
+ return currentPathname === targetPathname || currentPathname.startsWith(\`\${targetPathname}/\`);
175
+ });
176
+ </script>
177
+
178
+ <template>
179
+ <v-btn
180
+ v-if="resolvedTo"
181
+ class="tab-link-item text-none"
182
+ :to="resolvedTo"
183
+ rounded="pill"
184
+ size="small"
185
+ :variant="isActive ? 'flat' : 'tonal'"
186
+ :color="isActive ? 'primary' : undefined"
187
+ :disabled="props.disabled"
188
+ :aria-current="isActive ? 'page' : undefined"
189
+ >
190
+ {{ props.label }}
191
+ </v-btn>
192
+ </template>
193
+ `;
194
+ }
195
+
196
+ async function ensureSubpagesSupportScaffold({
197
+ appRoot,
198
+ componentDirectory = DEFAULT_COMPONENT_DIRECTORY,
199
+ dryRun = false
200
+ } = {}) {
201
+ const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
202
+ context: "ui-generator add-subpages"
203
+ });
204
+ const normalizedComponentDirectory = normalizeText(componentDirectory) || DEFAULT_COMPONENT_DIRECTORY;
205
+ const providerPath = resolvePathWithinApp(resolvedAppRoot, MAIN_CLIENT_PROVIDER_FILE, {
206
+ context: "ui-generator add-subpages"
207
+ });
208
+ const sectionContainerShellPath = resolvePathWithinApp(
209
+ resolvedAppRoot,
210
+ path.join(normalizedComponentDirectory, `${SECTION_CONTAINER_SHELL_COMPONENT}.vue`),
211
+ { context: "ui-generator add-subpages" }
212
+ );
213
+ const tabLinkPath = resolvePathWithinApp(
214
+ resolvedAppRoot,
215
+ path.join(normalizedComponentDirectory, `${TAB_LINK_COMPONENT}.vue`),
216
+ { context: "ui-generator add-subpages" }
217
+ );
218
+
219
+ const touchedFiles = new Set();
220
+ for (const supportFile of [
221
+ {
222
+ path: sectionContainerShellPath,
223
+ desiredSource: renderSectionContainerShellSource()
224
+ },
225
+ {
226
+ path: tabLinkPath,
227
+ desiredSource: renderTabLinkItemSource()
228
+ }
229
+ ]) {
230
+ let alreadyExists = true;
231
+ try {
232
+ await readFile(supportFile.path.absolutePath, "utf8");
233
+ } catch {
234
+ alreadyExists = false;
235
+ }
236
+
237
+ if (alreadyExists) {
238
+ continue;
239
+ }
240
+
241
+ if (dryRun !== true) {
242
+ await mkdir(path.dirname(supportFile.path.absolutePath), { recursive: true });
243
+ await writeFile(supportFile.path.absolutePath, supportFile.desiredSource, "utf8");
244
+ }
245
+ touchedFiles.add(supportFile.path.relativePath);
246
+ }
247
+
248
+ const providerSource = await readFile(providerPath.absolutePath, "utf8");
249
+ if (!/\bregisterMainClientComponent\s*\(/.test(providerSource)) {
250
+ throw new Error(
251
+ `ui-generator add-subpages could not find registerMainClientComponent() contract in ${MAIN_CLIENT_PROVIDER_FILE}.`
252
+ );
253
+ }
254
+
255
+ const providerImportLine = `import ${TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(normalizedComponentDirectory, `${TAB_LINK_COMPONENT}.vue`))}";`;
256
+ const providerRegisterLine = `registerMainClientComponent("${TAB_LINK_COMPONENT_TOKEN}", () => ${TAB_LINK_COMPONENT});`;
257
+ const providerImportApplied = insertImportIfMissing(providerSource, providerImportLine);
258
+ const providerRegisterApplied = insertBeforeClassDeclaration(
259
+ providerImportApplied.content,
260
+ providerRegisterLine,
261
+ {
262
+ className: "MainClientProvider",
263
+ contextFile: MAIN_CLIENT_PROVIDER_FILE
264
+ }
265
+ );
266
+ if (providerImportApplied.changed || providerRegisterApplied.changed) {
267
+ if (dryRun !== true) {
268
+ await writeFile(providerPath.absolutePath, providerRegisterApplied.content, "utf8");
269
+ }
270
+ touchedFiles.add(providerPath.relativePath);
271
+ }
272
+
273
+ return Object.freeze({
274
+ touchedFiles: [...touchedFiles].sort((left, right) => left.localeCompare(right)),
275
+ sectionContainerComponentImportPath: `/${toPosixPath(path.join(normalizedComponentDirectory, `${SECTION_CONTAINER_SHELL_COMPONENT}.vue`))}`
276
+ });
277
+ }
278
+
279
+ function findTemplateBlock(source = "") {
280
+ const sourceText = String(source || "");
281
+ let openIndex = -1;
282
+ let openTagSource = "";
283
+ let openAttributesSource = "";
284
+ let depth = 0;
285
+
286
+ for (const match of sourceText.matchAll(TEMPLATE_TOKEN_PATTERN)) {
287
+ const tokenSource = String(match[0] || "");
288
+ const tokenIndex = Number(match.index);
289
+ const isClosingToken = /^<\/template/i.test(tokenSource);
290
+
291
+ if (!isClosingToken) {
292
+ if (openIndex < 0) {
293
+ openIndex = tokenIndex;
294
+ openTagSource = tokenSource;
295
+ const openTagMatch = /^<template\b([^>]*)>$/i.exec(tokenSource);
296
+ openAttributesSource = String(openTagMatch?.[1] || "");
297
+ }
298
+ depth += 1;
299
+ continue;
300
+ }
301
+
302
+ if (openIndex < 0) {
303
+ continue;
304
+ }
305
+
306
+ depth -= 1;
307
+ if (depth === 0) {
308
+ const closeIndex = tokenIndex;
309
+ const endIndex = closeIndex + tokenSource.length;
310
+ return Object.freeze({
311
+ index: openIndex,
312
+ source: sourceText.slice(openIndex, endIndex),
313
+ attributesSource: openAttributesSource,
314
+ content: sourceText.slice(openIndex + openTagSource.length, closeIndex)
315
+ });
316
+ }
317
+ }
318
+
319
+ return null;
320
+ }
321
+
322
+ function unwrapSectionContainerShell(source = "") {
323
+ const trimmedSource = trimEdgeBlankLines(source);
324
+ const match = /^\s*<SectionContainerShell\b[^>]*>([\s\S]*?)<\/SectionContainerShell>\s*$/i.exec(trimmedSource);
325
+ if (!match) {
326
+ return trimmedSource;
327
+ }
328
+
329
+ let nextContent = String(match[1] || "");
330
+ nextContent = nextContent.replace(/^\s*<template\b[^>]*#tabs[^>]*>[\s\S]*?<\/template>\s*/i, "");
331
+ nextContent = nextContent.replace(ROUTER_VIEW_LINE_PATTERN, "");
332
+ nextContent = nextContent.replace(SHELL_OUTLET_TAG_PATTERN, "");
333
+ return trimEdgeBlankLines(nextContent);
334
+ }
335
+
336
+ function stripExistingSubpagesStructure(source = "") {
337
+ const nextContent = unwrapSectionContainerShell(source)
338
+ .replace(SHELL_OUTLET_TAG_PATTERN, "")
339
+ .replace(ROUTER_VIEW_LINE_PATTERN, "");
340
+ return trimEdgeBlankLines(nextContent);
341
+ }
342
+
343
+ function renderSectionContainerOpenTag({ title = "", subtitle = "" } = {}) {
344
+ const lines = [" <SectionContainerShell"];
345
+ if (normalizeText(title)) {
346
+ lines.push(` title=${JSON.stringify(normalizeText(title))}`);
347
+ }
348
+ if (normalizeText(subtitle)) {
349
+ lines.push(` subtitle=${JSON.stringify(normalizeText(subtitle))}`);
350
+ }
351
+ if (lines.length === 1) {
352
+ return " <SectionContainerShell>";
353
+ }
354
+ return `${lines.join("\n")}\n >`;
355
+ }
356
+
357
+ function renderSubpagesTemplate({
358
+ bodyContent = "",
359
+ title = "",
360
+ subtitle = "",
361
+ host = "",
362
+ position = DEFAULT_SUBPAGES_POSITION
363
+ } = {}) {
364
+ const lines = [
365
+ "<template>",
366
+ renderSectionContainerOpenTag({ title, subtitle }),
367
+ " <template #tabs>",
368
+ ` <ShellOutlet host="${host}" position="${position}" />`,
369
+ " </template>"
370
+ ];
371
+
372
+ const normalizedBodyContent = trimEdgeBlankLines(bodyContent);
373
+ if (normalizedBodyContent) {
374
+ lines.push("");
375
+ lines.push(indentBlock(normalizedBodyContent, " "));
376
+ }
377
+
378
+ lines.push("");
379
+ lines.push(" <RouterView />");
380
+ lines.push(" </SectionContainerShell>");
381
+ lines.push("</template>");
382
+ return `${lines.join("\n")}\n`;
383
+ }
384
+
385
+ function applySubpagesScriptImports(source = "", { sectionContainerComponentImportPath = "" } = {}) {
386
+ const sourceText = String(source || "");
387
+ const scriptBlock = findScriptBlock(sourceText);
388
+
389
+ const importLines = [
390
+ "import ShellOutlet from \"@jskit-ai/shell-web/client/components/ShellOutlet\";",
391
+ "import { RouterView } from \"vue-router\";",
392
+ `import SectionContainerShell from "${sectionContainerComponentImportPath}";`
393
+ ];
394
+
395
+ if (!scriptBlock) {
396
+ const scriptSetupBlock = `<script setup>\n${importLines.join("\n")}\n</script>\n`;
397
+ let insertionIndex = 0;
398
+ for (const match of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
399
+ insertionIndex = match.index + String(match[0] || "").length;
400
+ }
401
+ const separator = insertionIndex > 0 ? "\n" : "";
402
+ return {
403
+ changed: true,
404
+ content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
405
+ };
406
+ }
407
+
408
+ let nextScriptContent = scriptBlock.content;
409
+ let changed = false;
410
+ for (const importLine of importLines) {
411
+ const importApplied = insertImportIfMissing(nextScriptContent, importLine);
412
+ nextScriptContent = importApplied.content;
413
+ changed = changed || importApplied.changed;
414
+ }
415
+
416
+ if (!changed) {
417
+ return {
418
+ changed: false,
419
+ content: sourceText
420
+ };
421
+ }
422
+
423
+ const nextScriptTag = `<script${scriptBlock.attributesSource}>${nextScriptContent}</script>`;
424
+ return {
425
+ changed: true,
426
+ content: `${sourceText.slice(0, scriptBlock.index)}${nextScriptTag}${sourceText.slice(scriptBlock.index + scriptBlock.source.length)}`
427
+ };
428
+ }
429
+
430
+ function applySubpagesUpgradeToPageSource(
431
+ source = "",
432
+ {
433
+ host = "",
434
+ position = DEFAULT_SUBPAGES_POSITION,
435
+ title = "",
436
+ subtitle = "",
437
+ sectionContainerComponentImportPath = "/src/components/SectionContainerShell.vue",
438
+ preserveExistingContent = true
439
+ } = {}
440
+ ) {
441
+ const normalizedHost = normalizeText(host);
442
+ const normalizedPosition = normalizeText(position) || DEFAULT_SUBPAGES_POSITION;
443
+ if (!normalizedHost) {
444
+ throw new Error("ui-generator add-subpages requires a valid host.");
445
+ }
446
+
447
+ const sourceText = String(source || "");
448
+ const templateBlock = findTemplateBlock(sourceText);
449
+ const existingTemplateContent = templateBlock ? templateBlock.content : "";
450
+ const bodyContent = preserveExistingContent
451
+ ? stripExistingSubpagesStructure(existingTemplateContent)
452
+ : "";
453
+ const replacementTemplate = renderSubpagesTemplate({
454
+ bodyContent,
455
+ title,
456
+ subtitle,
457
+ host: normalizedHost,
458
+ position: normalizedPosition
459
+ });
460
+
461
+ const nextSource = templateBlock
462
+ ? `${sourceText.slice(0, templateBlock.index)}${replacementTemplate}${sourceText.slice(templateBlock.index + templateBlock.source.length)}`
463
+ : `${sourceText}\n${replacementTemplate}`;
464
+ const scriptApplied = applySubpagesScriptImports(nextSource, {
465
+ sectionContainerComponentImportPath
466
+ });
467
+
468
+ return {
469
+ changed: scriptApplied.content !== sourceText,
470
+ content: scriptApplied.content
471
+ };
472
+ }
473
+
474
+ function hasExistingSubpagesRouting(source = "") {
475
+ return ROUTER_VIEW_TAG_PATTERN.test(String(source || ""));
476
+ }
477
+
478
+ async function upgradePageFileToSubpages({
479
+ appRoot,
480
+ targetFile,
481
+ host = "",
482
+ position = DEFAULT_SUBPAGES_POSITION,
483
+ title = "",
484
+ subtitle = "",
485
+ componentDirectory = DEFAULT_COMPONENT_DIRECTORY,
486
+ preserveExistingContent = true,
487
+ dryRun = false
488
+ } = {}) {
489
+ const pageTarget = await resolvePageTargetDetails({
490
+ appRoot,
491
+ targetFile,
492
+ context: "ui-generator add-subpages"
493
+ });
494
+
495
+ let source = "";
496
+ try {
497
+ source = await readFile(pageTarget.targetFilePath.absolutePath, "utf8");
498
+ } catch {
499
+ throw new Error(`ui-generator add-subpages target file not found: ${pageTarget.targetFilePath.relativePath}.`);
500
+ }
501
+
502
+ if (hasExistingSubpagesRouting(source)) {
503
+ throw new Error(
504
+ `ui-generator add-subpages found existing RouterView in ${pageTarget.targetFilePath.relativePath}. Subpages are already enabled.`
505
+ );
506
+ }
507
+
508
+ const supportScaffold = await ensureSubpagesSupportScaffold({
509
+ appRoot,
510
+ componentDirectory,
511
+ dryRun
512
+ });
513
+
514
+ const upgradeApplied = applySubpagesUpgradeToPageSource(source, {
515
+ host,
516
+ position,
517
+ title,
518
+ subtitle,
519
+ sectionContainerComponentImportPath: supportScaffold.sectionContainerComponentImportPath,
520
+ preserveExistingContent
521
+ });
522
+
523
+ const touchedFiles = new Set(supportScaffold.touchedFiles);
524
+ if (upgradeApplied.changed) {
525
+ if (dryRun !== true) {
526
+ await writeFile(pageTarget.targetFilePath.absolutePath, upgradeApplied.content, "utf8");
527
+ }
528
+ touchedFiles.add(pageTarget.targetFilePath.relativePath);
529
+ }
530
+
531
+ return Object.freeze({
532
+ touchedFiles: [...touchedFiles].sort((left, right) => left.localeCompare(right)),
533
+ targetFile: pageTarget.targetFilePath.relativePath,
534
+ surfaceId: pageTarget.surfaceId,
535
+ routeUrlSuffix: pageTarget.routeUrlSuffix
536
+ });
537
+ }
538
+
539
+ export {
540
+ DEFAULT_SUBPAGES_POSITION,
541
+ DEFAULT_COMPONENT_DIRECTORY,
542
+ SECTION_CONTAINER_SHELL_COMPONENT,
543
+ TAB_LINK_COMPONENT,
544
+ TAB_LINK_COMPONENT_TOKEN,
545
+ resolvePageTargetDetails,
546
+ resolveNearestParentSubpagesHost,
547
+ deriveDefaultSubpagesHost,
548
+ renderPlainPageSource,
549
+ ensureSubpagesSupportScaffold,
550
+ applySubpagesUpgradeToPageSource,
551
+ upgradePageFileToSubpages
552
+ };