@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.
@@ -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
- assert.match(pageSource, /<h1 class="text-h5 mb-2">Practice<\/h1>/);
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 creates a file route and derives label from the file path", async () => {
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), "src/placement.js"]);
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, /<h1 class="text-h5 mb-2">Contact Id<\/h1>/);
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-layout:top-right",
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-layout:top-right"/);
164
- assert.match(placementSource, /componentToken: "local\.main\.ui\.tab-link-item"/);
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: "contact-view:sub-pages"/);
200
- assert.match(placementSource, /componentToken: "local\.main\.ui\.surface-aware-menu-link-item"/);
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: "catalog-products:sub-pages"/);
251
- assert.match(placementSource, /componentToken: "local\.main\.ui\.surface-aware-menu-link-item"/);
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="text-h5 mb-2">Practice<\/h1>/);
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 "missing:target" is not declared/
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:target"
675
+ "link-placement": "missing.target"
459
676
  }
460
677
  }),
461
- /ui-generator page option "placement" target "missing:target" is not declared/
678
+ /ui-generator page semantic placement "missing.target" is not declared/
462
679
  );
463
680
 
464
681
  const pageSource = await readFile(targetPath, "utf8");