@jskit-ai/ui-generator 0.1.48 → 0.1.50
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/package.descriptor.mjs +117 -24
- package/package.json +3 -3
- package/src/server/buildTemplateContext.js +44 -3
- package/src/server/subcommands/addSubpages.js +89 -2
- package/src/server/subcommands/element.js +42 -10
- package/src/server/subcommands/outlet.js +399 -20
- package/src/server/subcommands/page.js +49 -23
- package/src/server/subcommands/pageSupport.js +115 -37
- package/src/server/subcommands/support.js +128 -23
- package/test/addSubpagesSubcommand.test.js +163 -15
- package/test/buildTemplateContext.test.js +227 -34
- package/test/elementSubcommand.test.js +89 -12
- package/test/outletSubcommand.test.js +305 -14
- package/test/packageDescriptor.test.js +11 -0
- package/test/pageSubcommand.test.js +234 -17
|
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { assertGeneratedUiSourceContract } from "@jskit-ai/kernel/shared/support/generatedUiContract";
|
|
6
7
|
import { runGeneratorSubcommand } from "../src/server/subcommands/page.js";
|
|
7
8
|
|
|
8
9
|
async function withTempApp(run) {
|
|
@@ -18,6 +19,79 @@ function toPagePath(targetFile = "") {
|
|
|
18
19
|
return path.join("src/pages", targetFile);
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function renderTopologyVariant(outlet, { linkRenderer = "" } = {}) {
|
|
23
|
+
const rendererLines = linkRenderer
|
|
24
|
+
? `,
|
|
25
|
+
renderers: {
|
|
26
|
+
link: "${linkRenderer}"
|
|
27
|
+
}`
|
|
28
|
+
: "";
|
|
29
|
+
return `{
|
|
30
|
+
outlet: "${outlet}"${rendererLines}
|
|
31
|
+
}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderTopologyEntry({
|
|
35
|
+
id = "",
|
|
36
|
+
owner = "",
|
|
37
|
+
surfaces = ["*"],
|
|
38
|
+
defaultPlacement = false,
|
|
39
|
+
outlet = "",
|
|
40
|
+
linkRenderer = ""
|
|
41
|
+
} = {}) {
|
|
42
|
+
const ownerLine = owner ? ` owner: "${owner}",\n` : "";
|
|
43
|
+
const defaultLine = defaultPlacement ? " default: true,\n" : "";
|
|
44
|
+
return ` {
|
|
45
|
+
id: "${id}",
|
|
46
|
+
${ownerLine} surfaces: ${JSON.stringify(surfaces)},
|
|
47
|
+
${defaultLine} variants: {
|
|
48
|
+
compact: ${renderTopologyVariant(outlet, { linkRenderer })},
|
|
49
|
+
medium: ${renderTopologyVariant(outlet, { linkRenderer })},
|
|
50
|
+
expanded: ${renderTopologyVariant(outlet, { linkRenderer })}
|
|
51
|
+
}
|
|
52
|
+
}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writePlacementTopology(appRoot, entries = []) {
|
|
56
|
+
const defaultEntries = [
|
|
57
|
+
renderTopologyEntry({
|
|
58
|
+
id: "shell.primary-nav",
|
|
59
|
+
surfaces: ["*"],
|
|
60
|
+
defaultPlacement: true,
|
|
61
|
+
outlet: "shell-layout:primary-menu",
|
|
62
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
63
|
+
}),
|
|
64
|
+
renderTopologyEntry({
|
|
65
|
+
id: "shell.status",
|
|
66
|
+
surfaces: ["*"],
|
|
67
|
+
outlet: "shell-layout:top-right",
|
|
68
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
69
|
+
}),
|
|
70
|
+
renderTopologyEntry({
|
|
71
|
+
id: "shell.global-actions",
|
|
72
|
+
surfaces: ["*"],
|
|
73
|
+
outlet: "shell-layout:top-right",
|
|
74
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
75
|
+
}),
|
|
76
|
+
renderTopologyEntry({
|
|
77
|
+
id: "shell.secondary-nav",
|
|
78
|
+
surfaces: ["*"],
|
|
79
|
+
outlet: "shell-layout:secondary-menu",
|
|
80
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
81
|
+
})
|
|
82
|
+
];
|
|
83
|
+
await writeFile(
|
|
84
|
+
path.join(appRoot, "src", "placementTopology.js"),
|
|
85
|
+
`export default {
|
|
86
|
+
placements: [
|
|
87
|
+
${[...defaultEntries, ...entries].join(",\n")}
|
|
88
|
+
]
|
|
89
|
+
};
|
|
90
|
+
`,
|
|
91
|
+
"utf8"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
21
95
|
async function writeAppFixture(appRoot, { configSource = "" } = {}) {
|
|
22
96
|
await mkdir(path.join(appRoot, "config"), { recursive: true });
|
|
23
97
|
await mkdir(path.join(appRoot, "src", "components"), { recursive: true });
|
|
@@ -41,9 +115,9 @@ async function writeAppFixture(appRoot, { configSource = "" } = {}) {
|
|
|
41
115
|
<ShellOutlet
|
|
42
116
|
target="shell-layout:primary-menu"
|
|
43
117
|
default
|
|
44
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
45
118
|
/>
|
|
46
119
|
<ShellOutlet target="shell-layout:top-right" />
|
|
120
|
+
<ShellOutlet target="shell-layout:secondary-menu" />
|
|
47
121
|
</div>
|
|
48
122
|
</template>
|
|
49
123
|
`,
|
|
@@ -60,6 +134,7 @@ export default function getPlacements() {
|
|
|
60
134
|
`,
|
|
61
135
|
"utf8"
|
|
62
136
|
);
|
|
137
|
+
await writePlacementTopology(appRoot);
|
|
63
138
|
}
|
|
64
139
|
|
|
65
140
|
test("ui-generator page subcommand creates an index page from an explicit target file", async () => {
|
|
@@ -80,7 +155,16 @@ test("ui-generator page subcommand creates an index page from an explicit target
|
|
|
80
155
|
assert.equal(result.summary, 'Generated UI page "/practice" at src/pages/w/[workspaceSlug]/admin/practice/index.vue.');
|
|
81
156
|
|
|
82
157
|
const pageSource = await readFile(path.join(appRoot, toPagePath(targetFile)), "utf8");
|
|
83
|
-
|
|
158
|
+
assertGeneratedUiSourceContract(pageSource, {
|
|
159
|
+
profile: "page",
|
|
160
|
+
sourceName: targetFile
|
|
161
|
+
});
|
|
162
|
+
assert.match(pageSource, /generated-ui-screen generated-ui-screen--operator generated-page-screen/);
|
|
163
|
+
assert.match(pageSource, /<p class="text-overline text-medium-emphasis mb-1">Workspace tool<\/p>/);
|
|
164
|
+
assert.match(pageSource, /<h1 class="generated-page-screen__title">Practice<\/h1>/);
|
|
165
|
+
assert.match(pageSource, /<v-sheet rounded="lg" border class="generated-page-screen__empty-state">/);
|
|
166
|
+
assert.match(pageSource, /No Practice activity yet/);
|
|
167
|
+
assert.doesNotMatch(pageSource, /Replace this scaffold|Use this area|This is your page|<v-card\b|v-card-title/);
|
|
84
168
|
|
|
85
169
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
86
170
|
assert.match(placementSource, /id: "ui-generator\.page\.admin\.practice\.link"/);
|
|
@@ -89,7 +173,90 @@ test("ui-generator page subcommand creates an index page from an explicit target
|
|
|
89
173
|
});
|
|
90
174
|
});
|
|
91
175
|
|
|
92
|
-
test("ui-generator page subcommand
|
|
176
|
+
test("ui-generator page subcommand maps secondary navigation role to shell.secondary-nav", async () => {
|
|
177
|
+
await withTempApp(async (appRoot) => {
|
|
178
|
+
await writeAppFixture(appRoot);
|
|
179
|
+
|
|
180
|
+
const targetFile = "w/[workspaceSlug]/admin/reports/index.vue";
|
|
181
|
+
await runGeneratorSubcommand({
|
|
182
|
+
appRoot,
|
|
183
|
+
subcommand: "page",
|
|
184
|
+
args: [targetFile],
|
|
185
|
+
options: {
|
|
186
|
+
name: "Reports",
|
|
187
|
+
"navigation-role": "secondary"
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
192
|
+
assert.match(placementSource, /target: "shell\.secondary-nav"/);
|
|
193
|
+
assert.match(placementSource, /kind: "link"/);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("ui-generator page subcommand maps utility navigation role to shell.global-actions", async () => {
|
|
198
|
+
await withTempApp(async (appRoot) => {
|
|
199
|
+
await writeAppFixture(appRoot);
|
|
200
|
+
|
|
201
|
+
const targetFile = "w/[workspaceSlug]/admin/help/index.vue";
|
|
202
|
+
await runGeneratorSubcommand({
|
|
203
|
+
appRoot,
|
|
204
|
+
subcommand: "page",
|
|
205
|
+
args: [targetFile],
|
|
206
|
+
options: {
|
|
207
|
+
name: "Help",
|
|
208
|
+
"navigation-role": "utility"
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
213
|
+
assert.match(placementSource, /target: "shell\.global-actions"/);
|
|
214
|
+
assert.match(placementSource, /kind: "link"/);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("ui-generator page subcommand can generate detail pages without navigation placement", async () => {
|
|
219
|
+
await withTempApp(async (appRoot) => {
|
|
220
|
+
await writeAppFixture(appRoot);
|
|
221
|
+
|
|
222
|
+
const targetFile = "w/[workspaceSlug]/admin/reports/[reportId].vue";
|
|
223
|
+
const result = await runGeneratorSubcommand({
|
|
224
|
+
appRoot,
|
|
225
|
+
subcommand: "page",
|
|
226
|
+
args: [targetFile],
|
|
227
|
+
options: {
|
|
228
|
+
name: "Report",
|
|
229
|
+
"navigation-role": "detail"
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
assert.deepEqual(result.touchedFiles, [toPagePath(targetFile)]);
|
|
234
|
+
|
|
235
|
+
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
236
|
+
assert.doesNotMatch(placementSource, /ui-generator\.page\.admin\.reports\.report-id\.link/);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("ui-generator page subcommand rejects no-link navigation roles with link placement options", async () => {
|
|
241
|
+
await withTempApp(async (appRoot) => {
|
|
242
|
+
await writeAppFixture(appRoot);
|
|
243
|
+
|
|
244
|
+
await assert.rejects(
|
|
245
|
+
runGeneratorSubcommand({
|
|
246
|
+
appRoot,
|
|
247
|
+
subcommand: "page",
|
|
248
|
+
args: ["w/[workspaceSlug]/admin/reports/[reportId].vue"],
|
|
249
|
+
options: {
|
|
250
|
+
"navigation-role": "detail",
|
|
251
|
+
"link-placement": "shell.primary-nav"
|
|
252
|
+
}
|
|
253
|
+
}),
|
|
254
|
+
/navigation-role "detail" cannot be combined with --link-placement/
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("ui-generator page subcommand treats dynamic file routes as detail pages by default", async () => {
|
|
93
260
|
await withTempApp(async (appRoot) => {
|
|
94
261
|
await writeAppFixture(appRoot);
|
|
95
262
|
|
|
@@ -101,10 +268,33 @@ test("ui-generator page subcommand creates a file route and derives label from t
|
|
|
101
268
|
options: {}
|
|
102
269
|
});
|
|
103
270
|
|
|
104
|
-
assert.deepEqual(result.touchedFiles, [toPagePath(targetFile)
|
|
271
|
+
assert.deepEqual(result.touchedFiles, [toPagePath(targetFile)]);
|
|
105
272
|
|
|
106
273
|
const pageSource = await readFile(path.join(appRoot, toPagePath(targetFile)), "utf8");
|
|
107
|
-
assert.match(pageSource,
|
|
274
|
+
assert.match(pageSource, /generated-ui-screen generated-ui-screen--operator generated-page-screen/);
|
|
275
|
+
assert.match(pageSource, /<h1 class="generated-page-screen__title">Contact Id<\/h1>/);
|
|
276
|
+
assert.match(pageSource, /No Contact Id activity yet/);
|
|
277
|
+
|
|
278
|
+
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
279
|
+
assert.doesNotMatch(placementSource, /ui-generator\.page\.admin\.contacts\.contact-id\.link/);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("ui-generator page subcommand allows explicit primary navigation for dynamic routes", async () => {
|
|
284
|
+
await withTempApp(async (appRoot) => {
|
|
285
|
+
await writeAppFixture(appRoot);
|
|
286
|
+
|
|
287
|
+
const targetFile = "w/[workspaceSlug]/admin/contacts/[contactId].vue";
|
|
288
|
+
const result = await runGeneratorSubcommand({
|
|
289
|
+
appRoot,
|
|
290
|
+
subcommand: "page",
|
|
291
|
+
args: [targetFile],
|
|
292
|
+
options: {
|
|
293
|
+
"navigation-role": "primary"
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
assert.deepEqual(result.touchedFiles, [toPagePath(targetFile), "src/placement.js"]);
|
|
108
298
|
|
|
109
299
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
110
300
|
assert.match(placementSource, /scopedSuffix: "\/contacts\/\[contactId\]"/);
|
|
@@ -153,15 +343,15 @@ test("ui-generator page subcommand supports link placement options", async () =>
|
|
|
153
343
|
subcommand: "page",
|
|
154
344
|
args: [targetFile],
|
|
155
345
|
options: {
|
|
156
|
-
"link-placement": "shell
|
|
157
|
-
"link-component-token": "local.main.ui.tab-link-item",
|
|
346
|
+
"link-placement": "shell.status",
|
|
158
347
|
"link-to": "./notes"
|
|
159
348
|
}
|
|
160
349
|
});
|
|
161
350
|
|
|
162
351
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
163
|
-
assert.match(placementSource, /target: "shell
|
|
164
|
-
assert.match(placementSource, /
|
|
352
|
+
assert.match(placementSource, /target: "shell\.status"/);
|
|
353
|
+
assert.match(placementSource, /kind: "link"/);
|
|
354
|
+
assert.doesNotMatch(placementSource, /componentToken: "local\.main\.ui\.tab-link-item"/);
|
|
165
355
|
assert.match(placementSource, /icon: "mdi-view-list-outline"/);
|
|
166
356
|
assert.match(placementSource, /to: "\.\/notes"/);
|
|
167
357
|
});
|
|
@@ -170,6 +360,15 @@ test("ui-generator page subcommand supports link placement options", async () =>
|
|
|
170
360
|
test("ui-generator page subcommand infers subpage link placement, tab token, and link-to from the nearest parent host", async () => {
|
|
171
361
|
await withTempApp(async (appRoot) => {
|
|
172
362
|
await writeAppFixture(appRoot);
|
|
363
|
+
await writePlacementTopology(appRoot, [
|
|
364
|
+
renderTopologyEntry({
|
|
365
|
+
id: "page.section-nav",
|
|
366
|
+
owner: "contact-view",
|
|
367
|
+
surfaces: ["admin"],
|
|
368
|
+
outlet: "contact-view:sub-pages",
|
|
369
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
370
|
+
})
|
|
371
|
+
]);
|
|
173
372
|
|
|
174
373
|
const parentFile = "w/[workspaceSlug]/admin/contacts/[contactId].vue";
|
|
175
374
|
await mkdir(path.dirname(path.join(appRoot, toPagePath(parentFile))), { recursive: true });
|
|
@@ -196,8 +395,9 @@ test("ui-generator page subcommand infers subpage link placement, tab token, and
|
|
|
196
395
|
});
|
|
197
396
|
|
|
198
397
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
199
|
-
assert.match(placementSource, /target: "
|
|
200
|
-
assert.match(placementSource, /
|
|
398
|
+
assert.match(placementSource, /target: "page\.section-nav"/);
|
|
399
|
+
assert.match(placementSource, /owner: "contact-view"/);
|
|
400
|
+
assert.doesNotMatch(placementSource, /componentToken: "local\.main\.ui\.surface-aware-menu-link-item"/);
|
|
201
401
|
assert.match(placementSource, /icon: "mdi-view-list-outline"/);
|
|
202
402
|
assert.match(placementSource, /to: "\.\/notes"/);
|
|
203
403
|
});
|
|
@@ -206,6 +406,22 @@ test("ui-generator page subcommand infers subpage link placement, tab token, and
|
|
|
206
406
|
test("ui-generator page subcommand prefers the nearest index-route parent host", async () => {
|
|
207
407
|
await withTempApp(async (appRoot) => {
|
|
208
408
|
await writeAppFixture(appRoot);
|
|
409
|
+
await writePlacementTopology(appRoot, [
|
|
410
|
+
renderTopologyEntry({
|
|
411
|
+
id: "page.section-nav",
|
|
412
|
+
owner: "catalog",
|
|
413
|
+
surfaces: ["admin"],
|
|
414
|
+
outlet: "catalog:sub-pages",
|
|
415
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
416
|
+
}),
|
|
417
|
+
renderTopologyEntry({
|
|
418
|
+
id: "page.section-nav",
|
|
419
|
+
owner: "catalog-products",
|
|
420
|
+
surfaces: ["admin"],
|
|
421
|
+
outlet: "catalog-products:sub-pages",
|
|
422
|
+
linkRenderer: "local.main.ui.surface-aware-menu-link-item"
|
|
423
|
+
})
|
|
424
|
+
]);
|
|
209
425
|
|
|
210
426
|
await mkdir(path.join(appRoot, "src/pages/w/[workspaceSlug]/admin/catalog/index/products"), {
|
|
211
427
|
recursive: true
|
|
@@ -247,8 +463,9 @@ test("ui-generator page subcommand prefers the nearest index-route parent host",
|
|
|
247
463
|
});
|
|
248
464
|
|
|
249
465
|
const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
|
|
250
|
-
assert.match(placementSource, /target: "
|
|
251
|
-
assert.match(placementSource, /
|
|
466
|
+
assert.match(placementSource, /target: "page\.section-nav"/);
|
|
467
|
+
assert.match(placementSource, /owner: "catalog-products"/);
|
|
468
|
+
assert.doesNotMatch(placementSource, /componentToken: "local\.main\.ui\.surface-aware-menu-link-item"/);
|
|
252
469
|
assert.match(placementSource, /to: "\.\/variants"/);
|
|
253
470
|
});
|
|
254
471
|
});
|
|
@@ -401,7 +618,7 @@ test("ui-generator page subcommand overwrites an existing page when --force is p
|
|
|
401
618
|
assert.equal(result.summary, 'Regenerated UI page "/practice" at src/pages/w/[workspaceSlug]/admin/practice/index.vue.');
|
|
402
619
|
|
|
403
620
|
const pageSource = await readFile(path.join(appRoot, toPagePath(targetFile)), "utf8");
|
|
404
|
-
assert.match(pageSource, /<h1 class="
|
|
621
|
+
assert.match(pageSource, /<h1 class="generated-page-screen__title">Practice<\/h1>/);
|
|
405
622
|
assert.doesNotMatch(pageSource, /custom practice page/);
|
|
406
623
|
});
|
|
407
624
|
});
|
|
@@ -423,7 +640,7 @@ test("ui-generator page subcommand rejects invalid link placement before creatin
|
|
|
423
640
|
"link-placement": "missing:target"
|
|
424
641
|
}
|
|
425
642
|
}),
|
|
426
|
-
/ui-generator page option "placement" target "
|
|
643
|
+
/ui-generator page option "placement" must be a semantic target in "area.slot" format/
|
|
427
644
|
);
|
|
428
645
|
|
|
429
646
|
await assert.rejects(readFile(path.join(appRoot, toPagePath(targetFile)), "utf8"), /ENOENT/);
|
|
@@ -455,10 +672,10 @@ test("ui-generator page subcommand rejects invalid link placement before overwri
|
|
|
455
672
|
args: [targetFile],
|
|
456
673
|
options: {
|
|
457
674
|
force: "true",
|
|
458
|
-
"link-placement": "missing
|
|
675
|
+
"link-placement": "missing.target"
|
|
459
676
|
}
|
|
460
677
|
}),
|
|
461
|
-
/ui-generator page
|
|
678
|
+
/ui-generator page semantic placement "missing.target" is not declared/
|
|
462
679
|
);
|
|
463
680
|
|
|
464
681
|
const pageSource = await readFile(targetPath, "utf8");
|