@jskit-ai/ui-generator 0.1.48 → 0.1.49

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.
@@ -8,6 +8,23 @@ import { runGeneratorSubcommand } from "../src/server/subcommands/outlet.js";
8
8
  async function withTempApp(run) {
9
9
  const appRoot = await mkdtemp(path.join(tmpdir(), "ui-generator-outlet-"));
10
10
  try {
11
+ await mkdir(path.join(appRoot, "src"), { recursive: true });
12
+ await writeFile(
13
+ path.join(appRoot, "src", "placementTopology.js"),
14
+ `const placements = [];
15
+
16
+ function addPlacementTopology(value = {}) {
17
+ placements.push(value);
18
+ }
19
+
20
+ export { addPlacementTopology };
21
+
22
+ export default function getPlacementTopology() {
23
+ return { placements };
24
+ }
25
+ `,
26
+ "utf8"
27
+ );
11
28
  return await run(appRoot);
12
29
  } finally {
13
30
  await rm(appRoot, { recursive: true, force: true });
@@ -40,24 +57,32 @@ import { computed } from "vue";
40
57
  subcommand: "outlet",
41
58
  args: [targetFile],
42
59
  options: {
43
- target: "contact-view:sub-pages"
60
+ target: "contact-view:sub-pages",
61
+ placement: "page.section-nav"
44
62
  }
45
63
  });
46
64
 
47
- assert.deepEqual(result.touchedFiles, [targetFile]);
65
+ assert.deepEqual(result.touchedFiles, [targetFile, "src/placementTopology.js"]);
48
66
 
49
67
  const output = await readFile(targetPath, "utf8");
50
68
  assert.match(output, /import ShellOutlet from "@jskit-ai\/shell-web\/client\/components\/ShellOutlet";/);
51
69
  assert.match(output, /<ShellOutlet target="contact-view:sub-pages" \/>/);
52
70
  assert.doesNotMatch(output, /RouterView/);
53
71
  assert.doesNotMatch(output, /jskit:ui-generator\.outlet:/);
72
+ const topologySource = await readFile(path.join(appRoot, "src", "placementTopology.js"), "utf8");
73
+ assert.match(topologySource, /id: "page\.section-nav"/);
74
+ assert.match(topologySource, /owner: "contact-view"/);
75
+ assert.match(topologySource, /compact: \{/);
76
+ assert.match(topologySource, /medium: \{/);
77
+ assert.match(topologySource, /expanded: \{/);
54
78
 
55
79
  const rerun = await runGeneratorSubcommand({
56
80
  appRoot,
57
81
  subcommand: "outlet",
58
82
  args: [targetFile],
59
83
  options: {
60
- target: "contact-view:sub-pages"
84
+ target: "contact-view:sub-pages",
85
+ placement: "page.section-nav"
61
86
  }
62
87
  });
63
88
 
@@ -91,7 +116,8 @@ import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";
91
116
  subcommand: "outlet",
92
117
  args: [targetFile],
93
118
  options: {
94
- target: "contact-view:sub-pages"
119
+ target: "contact-view:sub-pages",
120
+ placement: "page.section-nav"
95
121
  }
96
122
  });
97
123
 
@@ -124,7 +150,8 @@ test("ui-generator outlet creates script setup when missing", async () => {
124
150
  subcommand: "outlet",
125
151
  args: [targetFile],
126
152
  options: {
127
- target: "contact-view:sub-pages"
153
+ target: "contact-view:sub-pages",
154
+ placement: "page.section-nav"
128
155
  }
129
156
  });
130
157
 
@@ -161,7 +188,8 @@ test("ui-generator outlet inserts generated script after existing route block",
161
188
  subcommand: "outlet",
162
189
  args: [targetFile],
163
190
  options: {
164
- target: "contact-view:sub-pages"
191
+ target: "contact-view:sub-pages",
192
+ placement: "page.section-nav"
165
193
  }
166
194
  });
167
195
 
@@ -203,7 +231,8 @@ test("ui-generator outlet keeps indentation when injected into nested template b
203
231
  subcommand: "outlet",
204
232
  args: [targetFile],
205
233
  options: {
206
- target: "contact-view:sub-pages"
234
+ target: "contact-view:sub-pages",
235
+ placement: "page.section-nav"
207
236
  }
208
237
  });
209
238
 
@@ -229,6 +258,7 @@ test("ui-generator outlet rejects unsupported options", async () => {
229
258
  args: [targetFile],
230
259
  options: {
231
260
  target: "contact-view:sub-pages",
261
+ placement: "page.section-nav",
232
262
  bogus: "routed"
233
263
  }
234
264
  }),
@@ -250,7 +280,8 @@ test("ui-generator outlet supports explicit target host:position", async () => {
250
280
  subcommand: "outlet",
251
281
  args: [targetFile],
252
282
  options: {
253
- target: "customer-view:summary-actions"
283
+ target: "customer-view:summary-actions",
284
+ placement: "page.actions"
254
285
  }
255
286
  });
256
287
 
@@ -272,9 +303,10 @@ test("ui-generator outlet rejects non-vue target files without changing them", a
272
303
  runGeneratorSubcommand({
273
304
  appRoot,
274
305
  subcommand: "outlet",
275
- args: [targetFile],
276
- options: {
277
- target: "vet-view:sub-pages"
306
+ args: [targetFile],
307
+ options: {
308
+ target: "vet-view:sub-pages",
309
+ placement: "page.section-nav"
278
310
  }
279
311
  }),
280
312
  /ui-generator outlet target file must be an existing Vue SFC \(\.vue\): src\/pages\/w\/\[workspaceSlug\]\/admin\/practice\/vets\/_components\/VetAddEditFormFields\.js\./
@@ -297,9 +329,10 @@ test("ui-generator outlet validates target format", async () => {
297
329
  runGeneratorSubcommand({
298
330
  appRoot,
299
331
  subcommand: "outlet",
300
- args: [targetFile],
301
- options: {
302
- target: "customer-view:"
332
+ args: [targetFile],
333
+ options: {
334
+ target: "customer-view:",
335
+ placement: "page.actions"
303
336
  }
304
337
  }),
305
338
  /ui-generator outlet option "target" must be a target in "host:position" format\./
@@ -18,6 +18,67 @@ function toPagePath(targetFile = "") {
18
18
  return path.join("src/pages", targetFile);
19
19
  }
20
20
 
21
+ function renderTopologyVariant(outlet, { linkRenderer = "" } = {}) {
22
+ const rendererLines = linkRenderer
23
+ ? `,
24
+ renderers: {
25
+ link: "${linkRenderer}"
26
+ }`
27
+ : "";
28
+ return `{
29
+ outlet: "${outlet}"${rendererLines}
30
+ }`;
31
+ }
32
+
33
+ function renderTopologyEntry({
34
+ id = "",
35
+ owner = "",
36
+ surfaces = ["*"],
37
+ defaultPlacement = false,
38
+ outlet = "",
39
+ linkRenderer = ""
40
+ } = {}) {
41
+ const ownerLine = owner ? ` owner: "${owner}",\n` : "";
42
+ const defaultLine = defaultPlacement ? " default: true,\n" : "";
43
+ return ` {
44
+ id: "${id}",
45
+ ${ownerLine} surfaces: ${JSON.stringify(surfaces)},
46
+ ${defaultLine} variants: {
47
+ compact: ${renderTopologyVariant(outlet, { linkRenderer })},
48
+ medium: ${renderTopologyVariant(outlet, { linkRenderer })},
49
+ expanded: ${renderTopologyVariant(outlet, { linkRenderer })}
50
+ }
51
+ }`;
52
+ }
53
+
54
+ async function writePlacementTopology(appRoot, entries = []) {
55
+ const defaultEntries = [
56
+ renderTopologyEntry({
57
+ id: "shell.primary-nav",
58
+ surfaces: ["*"],
59
+ defaultPlacement: true,
60
+ outlet: "shell-layout:primary-menu",
61
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
62
+ }),
63
+ renderTopologyEntry({
64
+ id: "shell.status",
65
+ surfaces: ["*"],
66
+ outlet: "shell-layout:top-right",
67
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
68
+ })
69
+ ];
70
+ await writeFile(
71
+ path.join(appRoot, "src", "placementTopology.js"),
72
+ `export default {
73
+ placements: [
74
+ ${[...defaultEntries, ...entries].join(",\n")}
75
+ ]
76
+ };
77
+ `,
78
+ "utf8"
79
+ );
80
+ }
81
+
21
82
  async function writeAppFixture(appRoot, { configSource = "" } = {}) {
22
83
  await mkdir(path.join(appRoot, "config"), { recursive: true });
23
84
  await mkdir(path.join(appRoot, "src", "components"), { recursive: true });
@@ -41,7 +102,6 @@ async function writeAppFixture(appRoot, { configSource = "" } = {}) {
41
102
  <ShellOutlet
42
103
  target="shell-layout:primary-menu"
43
104
  default
44
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
45
105
  />
46
106
  <ShellOutlet target="shell-layout:top-right" />
47
107
  </div>
@@ -60,6 +120,7 @@ export default function getPlacements() {
60
120
  `,
61
121
  "utf8"
62
122
  );
123
+ await writePlacementTopology(appRoot);
63
124
  }
64
125
 
65
126
  test("ui-generator page subcommand creates an index page from an explicit target file", async () => {
@@ -153,15 +214,15 @@ test("ui-generator page subcommand supports link placement options", async () =>
153
214
  subcommand: "page",
154
215
  args: [targetFile],
155
216
  options: {
156
- "link-placement": "shell-layout:top-right",
157
- "link-component-token": "local.main.ui.tab-link-item",
217
+ "link-placement": "shell.status",
158
218
  "link-to": "./notes"
159
219
  }
160
220
  });
161
221
 
162
222
  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"/);
223
+ assert.match(placementSource, /target: "shell\.status"/);
224
+ assert.match(placementSource, /kind: "link"/);
225
+ assert.doesNotMatch(placementSource, /componentToken: "local\.main\.ui\.tab-link-item"/);
165
226
  assert.match(placementSource, /icon: "mdi-view-list-outline"/);
166
227
  assert.match(placementSource, /to: "\.\/notes"/);
167
228
  });
@@ -170,6 +231,15 @@ test("ui-generator page subcommand supports link placement options", async () =>
170
231
  test("ui-generator page subcommand infers subpage link placement, tab token, and link-to from the nearest parent host", async () => {
171
232
  await withTempApp(async (appRoot) => {
172
233
  await writeAppFixture(appRoot);
234
+ await writePlacementTopology(appRoot, [
235
+ renderTopologyEntry({
236
+ id: "page.section-nav",
237
+ owner: "contact-view",
238
+ surfaces: ["admin"],
239
+ outlet: "contact-view:sub-pages",
240
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
241
+ })
242
+ ]);
173
243
 
174
244
  const parentFile = "w/[workspaceSlug]/admin/contacts/[contactId].vue";
175
245
  await mkdir(path.dirname(path.join(appRoot, toPagePath(parentFile))), { recursive: true });
@@ -196,8 +266,9 @@ test("ui-generator page subcommand infers subpage link placement, tab token, and
196
266
  });
197
267
 
198
268
  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"/);
269
+ assert.match(placementSource, /target: "page\.section-nav"/);
270
+ assert.match(placementSource, /owner: "contact-view"/);
271
+ assert.doesNotMatch(placementSource, /componentToken: "local\.main\.ui\.surface-aware-menu-link-item"/);
201
272
  assert.match(placementSource, /icon: "mdi-view-list-outline"/);
202
273
  assert.match(placementSource, /to: "\.\/notes"/);
203
274
  });
@@ -206,6 +277,22 @@ test("ui-generator page subcommand infers subpage link placement, tab token, and
206
277
  test("ui-generator page subcommand prefers the nearest index-route parent host", async () => {
207
278
  await withTempApp(async (appRoot) => {
208
279
  await writeAppFixture(appRoot);
280
+ await writePlacementTopology(appRoot, [
281
+ renderTopologyEntry({
282
+ id: "page.section-nav",
283
+ owner: "catalog",
284
+ surfaces: ["admin"],
285
+ outlet: "catalog:sub-pages",
286
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
287
+ }),
288
+ renderTopologyEntry({
289
+ id: "page.section-nav",
290
+ owner: "catalog-products",
291
+ surfaces: ["admin"],
292
+ outlet: "catalog-products:sub-pages",
293
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
294
+ })
295
+ ]);
209
296
 
210
297
  await mkdir(path.join(appRoot, "src/pages/w/[workspaceSlug]/admin/catalog/index/products"), {
211
298
  recursive: true
@@ -247,8 +334,9 @@ test("ui-generator page subcommand prefers the nearest index-route parent host",
247
334
  });
248
335
 
249
336
  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"/);
337
+ assert.match(placementSource, /target: "page\.section-nav"/);
338
+ assert.match(placementSource, /owner: "catalog-products"/);
339
+ assert.doesNotMatch(placementSource, /componentToken: "local\.main\.ui\.surface-aware-menu-link-item"/);
252
340
  assert.match(placementSource, /to: "\.\/variants"/);
253
341
  });
254
342
  });
@@ -423,7 +511,7 @@ test("ui-generator page subcommand rejects invalid link placement before creatin
423
511
  "link-placement": "missing:target"
424
512
  }
425
513
  }),
426
- /ui-generator page option "placement" target "missing:target" is not declared/
514
+ /ui-generator page option "placement" must be a semantic target in "area.slot" format/
427
515
  );
428
516
 
429
517
  await assert.rejects(readFile(path.join(appRoot, toPagePath(targetFile)), "utf8"), /ENOENT/);
@@ -455,10 +543,10 @@ test("ui-generator page subcommand rejects invalid link placement before overwri
455
543
  args: [targetFile],
456
544
  options: {
457
545
  force: "true",
458
- "link-placement": "missing:target"
546
+ "link-placement": "missing.target"
459
547
  }
460
548
  }),
461
- /ui-generator page option "placement" target "missing:target" is not declared/
549
+ /ui-generator page semantic placement "missing.target" is not declared/
462
550
  );
463
551
 
464
552
  const pageSource = await readFile(targetPath, "utf8");