@jskit-ai/ui-generator 0.1.3
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 +73 -0
- package/package.descriptor.mjs +121 -0
- package/package.json +14 -0
- package/src/server/buildTemplateContext.js +17 -0
- package/src/server/subcommands/container.js +481 -0
- package/src/server/subcommands/element.js +148 -0
- package/src/server/subcommands/support.js +165 -0
- package/templates/src/pages/admin/ui-generator/Page.vue +6 -0
- package/test/buildTemplateContext.test.js +148 -0
- package/test/containerSubcommand.test.js +133 -0
- package/test/elementSubcommand.test.js +127 -0
|
@@ -0,0 +1,481 @@
|
|
|
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 SECTION_TAB_LINK_COMPONENT = "SectionShellTabLinkItem";
|
|
28
|
+
const SECTION_TAB_LINK_COMPONENT_TOKEN = "local.main.ui.section-shell.tab-link-item";
|
|
29
|
+
|
|
30
|
+
function normalizeRoutePrefix(value = "") {
|
|
31
|
+
const source = normalizeText(value).replaceAll("\\", "/");
|
|
32
|
+
if (!source) {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parts = source
|
|
37
|
+
.split("/")
|
|
38
|
+
.map((entry) => toKebabCase(entry))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
return parts.join("/");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadPublicConfig(appRoot = "") {
|
|
44
|
+
const configPath = path.join(appRoot, "config", "public.js");
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await readFile(configPath, "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("ui-generator container requires app config at config/public.js.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let moduleNamespace = null;
|
|
53
|
+
try {
|
|
54
|
+
moduleNamespace = await import(`${pathToFileURL(configPath).href}?t=${Date.now()}_${Math.random()}`);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`ui-generator container could not load config/public.js: ${String(error?.message || error || "unknown error")}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const config = normalizeObject(
|
|
62
|
+
moduleNamespace?.config ||
|
|
63
|
+
moduleNamespace?.default?.config ||
|
|
64
|
+
moduleNamespace?.default
|
|
65
|
+
);
|
|
66
|
+
if (Object.keys(config).length < 1) {
|
|
67
|
+
throw new Error("ui-generator container requires exported config in config/public.js.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveSurfacePagesDirectory(appRoot = "", surfaceId = "") {
|
|
74
|
+
const config = await loadPublicConfig(appRoot);
|
|
75
|
+
const surfaceDefinitions = normalizeObject(config.surfaceDefinitions);
|
|
76
|
+
const normalizedSurfaceId = normalizeText(surfaceId).toLowerCase();
|
|
77
|
+
const surfaceDefinition = normalizeObject(surfaceDefinitions[normalizedSurfaceId]);
|
|
78
|
+
if (Object.keys(surfaceDefinition).length < 1) {
|
|
79
|
+
throw new Error(`ui-generator container surface "${normalizedSurfaceId}" is not defined in config/public.js.`);
|
|
80
|
+
}
|
|
81
|
+
if (surfaceDefinition.enabled === false) {
|
|
82
|
+
throw new Error(`ui-generator container surface "${normalizedSurfaceId}" is disabled in config/public.js.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pagesRoot = normalizeText(surfaceDefinition.pagesRoot);
|
|
86
|
+
if (!pagesRoot) {
|
|
87
|
+
return path.join(appRoot, "src", "pages");
|
|
88
|
+
}
|
|
89
|
+
return path.join(appRoot, "src", "pages", pagesRoot);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderSectionContainerShellSource() {
|
|
93
|
+
return `<script setup>
|
|
94
|
+
import { computed } from "vue";
|
|
95
|
+
import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
|
|
96
|
+
|
|
97
|
+
const props = defineProps({
|
|
98
|
+
title: {
|
|
99
|
+
type: String,
|
|
100
|
+
default: ""
|
|
101
|
+
},
|
|
102
|
+
subtitle: {
|
|
103
|
+
type: String,
|
|
104
|
+
default: ""
|
|
105
|
+
},
|
|
106
|
+
host: {
|
|
107
|
+
type: String,
|
|
108
|
+
default: ""
|
|
109
|
+
},
|
|
110
|
+
position: {
|
|
111
|
+
type: String,
|
|
112
|
+
default: "${CONTAINER_OUTLET_POSITION}"
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const resolvedTitle = computed(() => String(props.title || "").trim() || "Section");
|
|
117
|
+
const resolvedSubtitle = computed(() => String(props.subtitle || "").trim());
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<template>
|
|
121
|
+
<section class="section-container-shell d-flex flex-column ga-4">
|
|
122
|
+
<v-card rounded="lg" elevation="1" border>
|
|
123
|
+
<v-card-item>
|
|
124
|
+
<v-card-title class="px-0">{{ resolvedTitle }}</v-card-title>
|
|
125
|
+
<v-card-subtitle v-if="resolvedSubtitle" class="px-0">{{ resolvedSubtitle }}</v-card-subtitle>
|
|
126
|
+
</v-card-item>
|
|
127
|
+
<v-divider />
|
|
128
|
+
<v-card-text class="section-container-shell__tabs">
|
|
129
|
+
<ShellOutlet :host="props.host" :position="props.position" />
|
|
130
|
+
</v-card-text>
|
|
131
|
+
</v-card>
|
|
132
|
+
|
|
133
|
+
<slot />
|
|
134
|
+
</section>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<style scoped>
|
|
138
|
+
.section-container-shell__tabs {
|
|
139
|
+
display: flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: 0.5rem;
|
|
142
|
+
overflow-x: auto;
|
|
143
|
+
padding: 0.75rem;
|
|
144
|
+
scrollbar-width: thin;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.section-container-shell__tabs :deep(.section-shell-tab-link) {
|
|
148
|
+
flex: 0 0 auto;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@media (max-width: 640px) {
|
|
152
|
+
.section-container-shell__tabs {
|
|
153
|
+
gap: 0.375rem;
|
|
154
|
+
padding: 0.5rem;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderSectionShellTabLinkItemSource() {
|
|
162
|
+
return `<script setup>
|
|
163
|
+
import { computed } from "vue";
|
|
164
|
+
import { useRoute } from "vue-router";
|
|
165
|
+
import { usePaths } from "@jskit-ai/users-web/client/composables/usePaths";
|
|
166
|
+
import { useWorkspaceRouteContext } from "@jskit-ai/users-web/client/composables/useWorkspaceRouteContext";
|
|
167
|
+
|
|
168
|
+
const props = defineProps({
|
|
169
|
+
label: {
|
|
170
|
+
type: String,
|
|
171
|
+
default: ""
|
|
172
|
+
},
|
|
173
|
+
to: {
|
|
174
|
+
type: String,
|
|
175
|
+
default: ""
|
|
176
|
+
},
|
|
177
|
+
surface: {
|
|
178
|
+
type: String,
|
|
179
|
+
default: ""
|
|
180
|
+
},
|
|
181
|
+
workspaceSuffix: {
|
|
182
|
+
type: String,
|
|
183
|
+
default: "/"
|
|
184
|
+
},
|
|
185
|
+
nonWorkspaceSuffix: {
|
|
186
|
+
type: String,
|
|
187
|
+
default: "/"
|
|
188
|
+
},
|
|
189
|
+
disabled: {
|
|
190
|
+
type: Boolean,
|
|
191
|
+
default: false
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const route = useRoute();
|
|
196
|
+
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
|
+
|
|
224
|
+
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"
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const isActive = computed(() => {
|
|
240
|
+
const targetPathname = normalizePathname(resolvedTo.value);
|
|
241
|
+
const currentPathname = normalizePathname(route.fullPath || route.path);
|
|
242
|
+
if (!targetPathname || !currentPathname) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
return currentPathname === targetPathname || currentPathname.startsWith(\`\${targetPathname}/\`);
|
|
246
|
+
});
|
|
247
|
+
</script>
|
|
248
|
+
|
|
249
|
+
<template>
|
|
250
|
+
<v-btn
|
|
251
|
+
v-if="resolvedTo"
|
|
252
|
+
class="section-shell-tab-link text-none"
|
|
253
|
+
:to="resolvedTo"
|
|
254
|
+
rounded="pill"
|
|
255
|
+
size="small"
|
|
256
|
+
:variant="isActive ? 'flat' : 'tonal'"
|
|
257
|
+
:color="isActive ? 'primary' : undefined"
|
|
258
|
+
:disabled="props.disabled"
|
|
259
|
+
:aria-current="isActive ? 'page' : undefined"
|
|
260
|
+
>
|
|
261
|
+
{{ props.label }}
|
|
262
|
+
</v-btn>
|
|
263
|
+
</template>
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function renderContainerPageSource({
|
|
268
|
+
surface = "",
|
|
269
|
+
title = "",
|
|
270
|
+
subtitle = "",
|
|
271
|
+
containerHost = "",
|
|
272
|
+
containerPosition = CONTAINER_OUTLET_POSITION,
|
|
273
|
+
sectionContainerComponentImportPath = "/src/components/SectionContainerShell.vue"
|
|
274
|
+
} = {}) {
|
|
275
|
+
return `<script setup>
|
|
276
|
+
import { RouterView } from "vue-router";
|
|
277
|
+
import SectionContainerShell from "${sectionContainerComponentImportPath}";
|
|
278
|
+
</script>
|
|
279
|
+
|
|
280
|
+
<template>
|
|
281
|
+
<SectionContainerShell
|
|
282
|
+
title="${title}"
|
|
283
|
+
subtitle="${subtitle}"
|
|
284
|
+
host="${containerHost}"
|
|
285
|
+
position="${containerPosition}"
|
|
286
|
+
>
|
|
287
|
+
<RouterView />
|
|
288
|
+
</SectionContainerShell>
|
|
289
|
+
</template>
|
|
290
|
+
|
|
291
|
+
<route lang="json">
|
|
292
|
+
{
|
|
293
|
+
"meta": {
|
|
294
|
+
"jskit": {
|
|
295
|
+
"surface": "${surface}"
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
</route>
|
|
300
|
+
`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function runGeneratorSubcommand({
|
|
304
|
+
appRoot,
|
|
305
|
+
subcommand = "",
|
|
306
|
+
args = [],
|
|
307
|
+
options = {},
|
|
308
|
+
dryRun = false
|
|
309
|
+
} = {}) {
|
|
310
|
+
const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
|
|
311
|
+
if (normalizedSubcommand !== "container") {
|
|
312
|
+
throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
|
|
313
|
+
}
|
|
314
|
+
if (Array.isArray(args) && args.length > 0) {
|
|
315
|
+
throw new Error("ui-generator container does not accept positional arguments.");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
|
|
319
|
+
context: "ui-generator container"
|
|
320
|
+
});
|
|
321
|
+
const name = requireOption(options, "name", { context: "ui-generator container" });
|
|
322
|
+
const surface = requireOption(options, "surface", { context: "ui-generator container" }).toLowerCase();
|
|
323
|
+
const routePrefix = normalizeRoutePrefix(options?.["directory-prefix"]);
|
|
324
|
+
const componentDirectory = normalizeText(options?.path) || DEFAULT_COMPONENT_DIRECTORY;
|
|
325
|
+
const containerSlug = toKebabCase(name);
|
|
326
|
+
if (!containerSlug) {
|
|
327
|
+
throw new Error("ui-generator container requires a valid --name.");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const routePath = routePrefix ? `${routePrefix}/${containerSlug}` : containerSlug;
|
|
331
|
+
const pagesDirectory = await resolveSurfacePagesDirectory(resolvedAppRoot, surface);
|
|
332
|
+
const containerFilePath = path.join(pagesDirectory, `${routePath}.vue`);
|
|
333
|
+
const containerRelativePath = toPosixPath(path.relative(resolvedAppRoot, containerFilePath));
|
|
334
|
+
const placementPath = resolvePathWithinApp(resolvedAppRoot, PLACEMENT_FILE, {
|
|
335
|
+
context: "ui-generator container"
|
|
336
|
+
});
|
|
337
|
+
const providerPath = resolvePathWithinApp(resolvedAppRoot, MAIN_CLIENT_PROVIDER_FILE, {
|
|
338
|
+
context: "ui-generator container"
|
|
339
|
+
});
|
|
340
|
+
const sectionContainerShellPath = resolvePathWithinApp(
|
|
341
|
+
resolvedAppRoot,
|
|
342
|
+
path.join(componentDirectory, `${SECTION_CONTAINER_SHELL_COMPONENT}.vue`),
|
|
343
|
+
{
|
|
344
|
+
context: "ui-generator container"
|
|
345
|
+
}
|
|
346
|
+
);
|
|
347
|
+
const sectionTabLinkPath = resolvePathWithinApp(
|
|
348
|
+
resolvedAppRoot,
|
|
349
|
+
path.join(componentDirectory, `${SECTION_TAB_LINK_COMPONENT}.vue`),
|
|
350
|
+
{
|
|
351
|
+
context: "ui-generator container"
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const placementTarget = await resolveShellOutletPlacementTargetFromApp({
|
|
356
|
+
appRoot: resolvedAppRoot,
|
|
357
|
+
context: "ui-generator container",
|
|
358
|
+
placement: options?.placement
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const touchedFiles = new Set();
|
|
362
|
+
|
|
363
|
+
let existingSectionContainerSource = "";
|
|
364
|
+
try {
|
|
365
|
+
existingSectionContainerSource = await readFile(sectionContainerShellPath.absolutePath, "utf8");
|
|
366
|
+
} catch {
|
|
367
|
+
existingSectionContainerSource = "";
|
|
368
|
+
}
|
|
369
|
+
if (!existingSectionContainerSource) {
|
|
370
|
+
if (dryRun !== true) {
|
|
371
|
+
await mkdir(path.dirname(sectionContainerShellPath.absolutePath), { recursive: true });
|
|
372
|
+
await writeFile(sectionContainerShellPath.absolutePath, renderSectionContainerShellSource(), "utf8");
|
|
373
|
+
}
|
|
374
|
+
touchedFiles.add(sectionContainerShellPath.relativePath);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let existingSectionTabLinkSource = "";
|
|
378
|
+
try {
|
|
379
|
+
existingSectionTabLinkSource = await readFile(sectionTabLinkPath.absolutePath, "utf8");
|
|
380
|
+
} catch {
|
|
381
|
+
existingSectionTabLinkSource = "";
|
|
382
|
+
}
|
|
383
|
+
if (!existingSectionTabLinkSource) {
|
|
384
|
+
if (dryRun !== true) {
|
|
385
|
+
await mkdir(path.dirname(sectionTabLinkPath.absolutePath), { recursive: true });
|
|
386
|
+
await writeFile(sectionTabLinkPath.absolutePath, renderSectionShellTabLinkItemSource(), "utf8");
|
|
387
|
+
}
|
|
388
|
+
touchedFiles.add(sectionTabLinkPath.relativePath);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const providerSource = await readFile(providerPath.absolutePath, "utf8");
|
|
392
|
+
if (!/\bregisterMainClientComponent\s*\(/.test(providerSource)) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`ui-generator container could not find registerMainClientComponent() contract in ${MAIN_CLIENT_PROVIDER_FILE}.`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const providerImportLine = `import ${SECTION_TAB_LINK_COMPONENT} from "/${toPosixPath(path.join(componentDirectory, `${SECTION_TAB_LINK_COMPONENT}.vue`))}";`;
|
|
399
|
+
const providerRegisterLine =
|
|
400
|
+
`registerMainClientComponent("${SECTION_TAB_LINK_COMPONENT_TOKEN}", () => ${SECTION_TAB_LINK_COMPONENT});`;
|
|
401
|
+
const providerImportApplied = insertImportIfMissing(providerSource, providerImportLine);
|
|
402
|
+
const providerRegisterApplied = insertBeforeClassDeclaration(
|
|
403
|
+
providerImportApplied.content,
|
|
404
|
+
providerRegisterLine,
|
|
405
|
+
{
|
|
406
|
+
className: "MainClientProvider",
|
|
407
|
+
contextFile: MAIN_CLIENT_PROVIDER_FILE
|
|
408
|
+
}
|
|
409
|
+
);
|
|
410
|
+
if (providerImportApplied.changed || providerRegisterApplied.changed) {
|
|
411
|
+
if (dryRun !== true) {
|
|
412
|
+
await writeFile(providerPath.absolutePath, providerRegisterApplied.content, "utf8");
|
|
413
|
+
}
|
|
414
|
+
touchedFiles.add(providerPath.relativePath);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let existingContainerSource = "";
|
|
418
|
+
try {
|
|
419
|
+
existingContainerSource = await readFile(containerFilePath, "utf8");
|
|
420
|
+
} catch {
|
|
421
|
+
existingContainerSource = "";
|
|
422
|
+
}
|
|
423
|
+
if (!existingContainerSource) {
|
|
424
|
+
if (dryRun !== true) {
|
|
425
|
+
await mkdir(path.dirname(containerFilePath), { recursive: true });
|
|
426
|
+
await writeFile(
|
|
427
|
+
containerFilePath,
|
|
428
|
+
renderContainerPageSource({
|
|
429
|
+
surface,
|
|
430
|
+
title: name,
|
|
431
|
+
subtitle: `Manage ${toKebabCase(name).replaceAll("-", " ")} modules.`,
|
|
432
|
+
containerHost: containerSlug,
|
|
433
|
+
sectionContainerComponentImportPath: `/${toPosixPath(path.join(componentDirectory, `${SECTION_CONTAINER_SHELL_COMPONENT}.vue`))}`
|
|
434
|
+
}),
|
|
435
|
+
"utf8"
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
touchedFiles.add(containerRelativePath);
|
|
439
|
+
}
|
|
440
|
+
|
|
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");
|
|
467
|
+
}
|
|
468
|
+
touchedFiles.add(placementPath.relativePath);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));
|
|
472
|
+
return {
|
|
473
|
+
touchedFiles: touchedFileList,
|
|
474
|
+
summary:
|
|
475
|
+
touchedFileList.length > 0
|
|
476
|
+
? `Generated UI container "${routePath}" with outlet "${containerSlug}:${CONTAINER_OUTLET_POSITION}".`
|
|
477
|
+
: `UI container "${routePath}" is already up to date.`
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export { runGeneratorSubcommand };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
resolveShellOutletPlacementTargetFromApp,
|
|
5
|
+
toPosixPath
|
|
6
|
+
} from "@jskit-ai/kernel/server/support";
|
|
7
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_COMPONENT_DIRECTORY,
|
|
10
|
+
MAIN_CLIENT_PROVIDER_FILE,
|
|
11
|
+
PLACEMENT_FILE,
|
|
12
|
+
toKebabCase,
|
|
13
|
+
toPascalCase,
|
|
14
|
+
requireOption,
|
|
15
|
+
resolvePathWithinApp,
|
|
16
|
+
appendBlockIfMarkerMissing,
|
|
17
|
+
insertImportIfMissing,
|
|
18
|
+
insertBeforeClassDeclaration
|
|
19
|
+
} from "./support.js";
|
|
20
|
+
|
|
21
|
+
function renderElementComponentSource(elementName = "") {
|
|
22
|
+
return `<template>
|
|
23
|
+
<section class="pa-4">
|
|
24
|
+
<h2 class="text-h6 mb-2">${elementName}</h2>
|
|
25
|
+
<p class="text-body-2 text-medium-emphasis">Replace this scaffold with your UI element implementation.</p>
|
|
26
|
+
</section>
|
|
27
|
+
</template>
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runGeneratorSubcommand({
|
|
32
|
+
appRoot,
|
|
33
|
+
subcommand = "",
|
|
34
|
+
args = [],
|
|
35
|
+
options = {},
|
|
36
|
+
dryRun = false
|
|
37
|
+
} = {}) {
|
|
38
|
+
const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
|
|
39
|
+
if (normalizedSubcommand !== "element") {
|
|
40
|
+
throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(args) && args.length > 0) {
|
|
43
|
+
throw new Error("ui-generator element does not accept positional arguments.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const name = requireOption(options, "name", { context: "ui-generator element" });
|
|
47
|
+
const surface = requireOption(options, "surface", { context: "ui-generator element" }).toLowerCase();
|
|
48
|
+
const componentDirectory = normalizeText(options.path) || DEFAULT_COMPONENT_DIRECTORY;
|
|
49
|
+
const elementNamePascal = toPascalCase(name);
|
|
50
|
+
const elementNameKebab = toKebabCase(name);
|
|
51
|
+
|
|
52
|
+
if (!elementNamePascal || !elementNameKebab) {
|
|
53
|
+
throw new Error("ui-generator element requires a valid --name.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const componentPath = resolvePathWithinApp(appRoot, path.join(componentDirectory, `${elementNamePascal}Element.vue`), {
|
|
57
|
+
context: "ui-generator element"
|
|
58
|
+
});
|
|
59
|
+
const providerPath = resolvePathWithinApp(appRoot, MAIN_CLIENT_PROVIDER_FILE, {
|
|
60
|
+
context: "ui-generator element"
|
|
61
|
+
});
|
|
62
|
+
const placementPath = resolvePathWithinApp(appRoot, PLACEMENT_FILE, {
|
|
63
|
+
context: "ui-generator element"
|
|
64
|
+
});
|
|
65
|
+
const componentToken = `local.main.ui.element.${elementNameKebab}`;
|
|
66
|
+
const placementTarget = await resolveShellOutletPlacementTargetFromApp({
|
|
67
|
+
appRoot,
|
|
68
|
+
context: "ui-generator",
|
|
69
|
+
placement: options?.placement
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const touchedFiles = new Set();
|
|
73
|
+
|
|
74
|
+
let componentSource = "";
|
|
75
|
+
try {
|
|
76
|
+
componentSource = await readFile(componentPath.absolutePath, "utf8");
|
|
77
|
+
} catch {
|
|
78
|
+
componentSource = "";
|
|
79
|
+
}
|
|
80
|
+
if (!componentSource) {
|
|
81
|
+
if (dryRun !== true) {
|
|
82
|
+
await mkdir(path.dirname(componentPath.absolutePath), { recursive: true });
|
|
83
|
+
await writeFile(componentPath.absolutePath, renderElementComponentSource(name), "utf8");
|
|
84
|
+
}
|
|
85
|
+
touchedFiles.add(componentPath.relativePath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const providerSource = await readFile(providerPath.absolutePath, "utf8");
|
|
89
|
+
if (!/\bregisterMainClientComponent\s*\(/.test(providerSource)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`ui-generator element could not find registerMainClientComponent() contract in ${MAIN_CLIENT_PROVIDER_FILE}.`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const componentImportLine =
|
|
96
|
+
`import ${elementNamePascal}Element from "/${toPosixPath(path.join(componentDirectory, `${elementNamePascal}Element.vue`))}";`;
|
|
97
|
+
const componentRegisterLine =
|
|
98
|
+
`registerMainClientComponent("${componentToken}", () => ${elementNamePascal}Element);`;
|
|
99
|
+
|
|
100
|
+
const providerImportApplied = insertImportIfMissing(providerSource, componentImportLine);
|
|
101
|
+
const providerRegisterApplied = insertBeforeClassDeclaration(
|
|
102
|
+
providerImportApplied.content,
|
|
103
|
+
componentRegisterLine,
|
|
104
|
+
{
|
|
105
|
+
className: "MainClientProvider",
|
|
106
|
+
contextFile: MAIN_CLIENT_PROVIDER_FILE
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
if (providerImportApplied.changed || providerRegisterApplied.changed) {
|
|
110
|
+
if (dryRun !== true) {
|
|
111
|
+
await writeFile(providerPath.absolutePath, providerRegisterApplied.content, "utf8");
|
|
112
|
+
}
|
|
113
|
+
touchedFiles.add(providerPath.relativePath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const placementSource = await readFile(placementPath.absolutePath, "utf8");
|
|
117
|
+
const placementMarker = `jskit:ui-generator.element:${surface}:${elementNameKebab}`;
|
|
118
|
+
const placementBlock =
|
|
119
|
+
`// ${placementMarker}\n` +
|
|
120
|
+
"{\n" +
|
|
121
|
+
" addPlacement({\n" +
|
|
122
|
+
` id: "ui-generator.element.${elementNameKebab}",\n` +
|
|
123
|
+
` host: "${placementTarget.host}",\n` +
|
|
124
|
+
` position: "${placementTarget.position}",\n` +
|
|
125
|
+
` surfaces: ["${surface}"],\n` +
|
|
126
|
+
" order: 155,\n" +
|
|
127
|
+
` componentToken: "${componentToken}"\n` +
|
|
128
|
+
" });\n" +
|
|
129
|
+
"}\n";
|
|
130
|
+
const placementApplied = appendBlockIfMarkerMissing(placementSource, placementMarker, placementBlock);
|
|
131
|
+
if (placementApplied.changed) {
|
|
132
|
+
if (dryRun !== true) {
|
|
133
|
+
await writeFile(placementPath.absolutePath, placementApplied.content, "utf8");
|
|
134
|
+
}
|
|
135
|
+
touchedFiles.add(placementPath.relativePath);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));
|
|
139
|
+
return {
|
|
140
|
+
touchedFiles: touchedFileList,
|
|
141
|
+
summary:
|
|
142
|
+
touchedFileList.length > 0
|
|
143
|
+
? `Generated UI element "${elementNameKebab}" and placement token "${componentToken}".`
|
|
144
|
+
: `UI element "${elementNameKebab}" is already up to date.`
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { runGeneratorSubcommand };
|