@jskit-ai/ui-generator 0.1.5 → 0.1.6

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,274 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import {
4
+ requireOption,
5
+ resolvePathWithinApp,
6
+ ensureTrailingNewline,
7
+ insertImportIfMissing
8
+ } from "./support.js";
9
+
10
+ const DEFAULT_OUTLET_POSITION = "sub-pages";
11
+ const MODE_ROUTED = "routed";
12
+ const MODE_OUTLET_ONLY = "outlet-only";
13
+
14
+ const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
15
+ const ROUTE_TAG_PATTERN = /<route\b[^>]*>[\s\S]*?<\/route>\s*/gi;
16
+ const TEMPLATE_CLOSE_TAG_PATTERN = /<\/template>/gi;
17
+ const ROUTER_VIEW_TAG_PATTERN = /<RouterView(?:\s|\/|>)/;
18
+ const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b([^>]*)\/?>/gi;
19
+ const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
20
+ const SCRIPT_SETUP_ATTRIBUTE_PATTERN = /\bsetup\b/i;
21
+
22
+ function resolveOutletMode(rawMode = "") {
23
+ const normalized = String(rawMode || "").trim().toLowerCase();
24
+ if (!normalized || normalized === MODE_ROUTED) {
25
+ return MODE_ROUTED;
26
+ }
27
+ if (normalized === MODE_OUTLET_ONLY || normalized === "outlet") {
28
+ return MODE_OUTLET_ONLY;
29
+ }
30
+
31
+ throw new Error(`ui-generator outlet received unsupported --mode value: ${rawMode}. Use routed or outlet-only.`);
32
+ }
33
+
34
+ function findScriptBlock(source = "") {
35
+ const sourceText = String(source || "");
36
+ let firstMatch = null;
37
+
38
+ for (const match of sourceText.matchAll(SCRIPT_TAG_PATTERN)) {
39
+ if (!firstMatch) {
40
+ firstMatch = match;
41
+ }
42
+
43
+ const attributesSource = String(match[1] || "");
44
+ if (SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
45
+ return Object.freeze({
46
+ index: match.index,
47
+ source: String(match[0] || ""),
48
+ attributesSource,
49
+ content: String(match[2] || "")
50
+ });
51
+ }
52
+ }
53
+
54
+ if (!firstMatch) {
55
+ return null;
56
+ }
57
+
58
+ return Object.freeze({
59
+ index: firstMatch.index,
60
+ source: String(firstMatch[0] || ""),
61
+ attributesSource: String(firstMatch[1] || ""),
62
+ content: String(firstMatch[2] || "")
63
+ });
64
+ }
65
+
66
+ function hasImportFromModule(source = "", modulePath = "") {
67
+ const normalizedModulePath = String(modulePath || "").trim();
68
+ if (!normalizedModulePath) {
69
+ return false;
70
+ }
71
+
72
+ const sourceText = String(source || "");
73
+ return sourceText.includes(`from "${normalizedModulePath}"`) || sourceText.includes(`from '${normalizedModulePath}'`);
74
+ }
75
+
76
+ function parseTagAttributes(attributesSource = "") {
77
+ const attributes = {};
78
+ const source = String(attributesSource || "");
79
+ for (const match of source.matchAll(ATTRIBUTE_PATTERN)) {
80
+ const attributeName = normalizeText(match[1]);
81
+ if (!attributeName) {
82
+ continue;
83
+ }
84
+ const hasValue = match[2] != null || match[3] != null;
85
+ attributes[attributeName] = hasValue ? String(match[2] ?? match[3] ?? "") : true;
86
+ }
87
+ return attributes;
88
+ }
89
+
90
+ function hasShellOutletTarget(source = "", { host = "", position = "" } = {}) {
91
+ const normalizedHost = normalizeText(host);
92
+ const normalizedPosition = normalizeText(position);
93
+ if (!normalizedHost || !normalizedPosition) {
94
+ return false;
95
+ }
96
+
97
+ const sourceText = String(source || "");
98
+ for (const match of sourceText.matchAll(SHELL_OUTLET_TAG_PATTERN)) {
99
+ const attributes = parseTagAttributes(match[1]);
100
+ const outletHost = normalizeText(attributes.host);
101
+ const outletPosition = normalizeText(attributes.position);
102
+ if (outletHost === normalizedHost && outletPosition === normalizedPosition) {
103
+ return true;
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+
109
+ function applyScriptImports(source = "", { includeRouterViewImport = false } = {}) {
110
+ const sourceText = String(source || "");
111
+ const scriptBlock = findScriptBlock(sourceText);
112
+
113
+ const shellOutletImport = "import ShellOutlet from \"@jskit-ai/shell-web/client/components/ShellOutlet\";";
114
+ const routerViewImport = "import { RouterView } from \"vue-router\";";
115
+
116
+ if (!scriptBlock) {
117
+ const importLines = [shellOutletImport];
118
+ if (includeRouterViewImport) {
119
+ importLines.push(routerViewImport);
120
+ }
121
+ const scriptSetupBlock = `<script setup>\n${importLines.join("\n")}\n</script>\n`;
122
+ let insertionIndex = 0;
123
+ for (const match of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
124
+ insertionIndex = match.index + String(match[0] || "").length;
125
+ }
126
+ const separator = insertionIndex > 0 ? "\n" : "";
127
+ return {
128
+ changed: true,
129
+ content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
130
+ };
131
+ }
132
+
133
+ let nextScriptContent = scriptBlock.content;
134
+ const shellImportApplied = insertImportIfMissing(nextScriptContent, shellOutletImport);
135
+ nextScriptContent = shellImportApplied.content;
136
+
137
+ let routerImportChanged = false;
138
+ if (includeRouterViewImport && !hasImportFromModule(nextScriptContent, "vue-router")) {
139
+ const routerImportApplied = insertImportIfMissing(nextScriptContent, routerViewImport);
140
+ nextScriptContent = routerImportApplied.content;
141
+ routerImportChanged = routerImportApplied.changed;
142
+ }
143
+
144
+ if (!shellImportApplied.changed && !routerImportChanged) {
145
+ return {
146
+ changed: false,
147
+ content: sourceText
148
+ };
149
+ }
150
+
151
+ const nextScriptTag = `<script${scriptBlock.attributesSource}>${nextScriptContent}</script>`;
152
+ const replacementContent =
153
+ `${sourceText.slice(0, scriptBlock.index)}${nextScriptTag}${sourceText.slice(scriptBlock.index + scriptBlock.source.length)}`;
154
+ return {
155
+ changed: true,
156
+ content: replacementContent
157
+ };
158
+ }
159
+
160
+ function createOutletBlock({ host = "", position = "", includeRouterView = false } = {}) {
161
+ const lines = [
162
+ `<ShellOutlet host=\"${host}\" position=\"${position}\" />`
163
+ ];
164
+ if (includeRouterView) {
165
+ lines.push("<RouterView />");
166
+ }
167
+ return lines.join("\n");
168
+ }
169
+
170
+ function indentBlock(source = "", indent = "") {
171
+ const sourceText = String(source || "");
172
+ const indentation = String(indent || "");
173
+ return sourceText
174
+ .split("\n")
175
+ .map((line) => `${indentation}${line}`)
176
+ .join("\n");
177
+ }
178
+
179
+ function findLastTemplateCloseTag(source = "") {
180
+ const sourceText = String(source || "");
181
+ let lastMatch = null;
182
+ for (const match of sourceText.matchAll(TEMPLATE_CLOSE_TAG_PATTERN)) {
183
+ lastMatch = match;
184
+ }
185
+ return lastMatch;
186
+ }
187
+
188
+ function applyOutletTemplateBlock(source = "", { host = "", position = "", includeRouterView = false } = {}) {
189
+ const sourceText = String(source || "");
190
+ const outletBlock = createOutletBlock({ host, position, includeRouterView });
191
+
192
+ const templateTagMatch = findLastTemplateCloseTag(sourceText);
193
+ if (!templateTagMatch) {
194
+ const nextContent = `${ensureTrailingNewline(sourceText)}\n<template>\n${indentBlock(outletBlock, " ")}\n</template>\n`;
195
+ return {
196
+ changed: true,
197
+ content: nextContent
198
+ };
199
+ }
200
+
201
+ const insertionIndex = templateTagMatch.index;
202
+ const templateCloseLineStart = sourceText.lastIndexOf("\n", insertionIndex - 1) + 1;
203
+ const closingIndent = sourceText.slice(templateCloseLineStart, insertionIndex);
204
+ const childIndent = `${closingIndent} `;
205
+ const beforeTemplateClose = sourceText.slice(0, templateCloseLineStart);
206
+ const afterTemplateClose = sourceText.slice(templateCloseLineStart);
207
+ const separator = beforeTemplateClose.endsWith("\n") || beforeTemplateClose.length < 1 ? "" : "\n";
208
+ const nextContent = `${beforeTemplateClose}${separator}${indentBlock(outletBlock, childIndent)}\n${afterTemplateClose}`;
209
+ return {
210
+ changed: true,
211
+ content: nextContent
212
+ };
213
+ }
214
+
215
+ async function runGeneratorSubcommand({
216
+ appRoot,
217
+ subcommand = "",
218
+ args = [],
219
+ options = {},
220
+ dryRun = false
221
+ } = {}) {
222
+ const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
223
+ if (normalizedSubcommand !== "outlet") {
224
+ throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
225
+ }
226
+ if (Array.isArray(args) && args.length > 0) {
227
+ throw new Error("ui-generator outlet does not accept positional arguments.");
228
+ }
229
+
230
+ const targetFile = requireOption(options, "file", { context: "ui-generator outlet" });
231
+ const host = requireOption(options, "host", { context: "ui-generator outlet" });
232
+ const position = normalizeText(options?.position) || DEFAULT_OUTLET_POSITION;
233
+ const mode = resolveOutletMode(options?.mode);
234
+
235
+ const targetFilePath = resolvePathWithinApp(appRoot, targetFile, {
236
+ context: "ui-generator outlet"
237
+ });
238
+
239
+ let source = "";
240
+ try {
241
+ source = await readFile(targetFilePath.absolutePath, "utf8");
242
+ } catch {
243
+ throw new Error(`ui-generator outlet target file not found: ${targetFilePath.relativePath}.`);
244
+ }
245
+
246
+ const hasTargetOutlet = hasShellOutletTarget(source, { host, position });
247
+ const hasRouterView = ROUTER_VIEW_TAG_PATTERN.test(source);
248
+ const shouldInsertRouterView = mode === MODE_ROUTED && !hasRouterView && !hasTargetOutlet;
249
+
250
+ const templateApplied = hasTargetOutlet
251
+ ? { changed: false, content: source }
252
+ : applyOutletTemplateBlock(source, {
253
+ host,
254
+ position,
255
+ includeRouterView: shouldInsertRouterView
256
+ });
257
+ const scriptApplied = applyScriptImports(templateApplied.content, {
258
+ includeRouterViewImport: shouldInsertRouterView
259
+ });
260
+
261
+ const changed = templateApplied.changed || scriptApplied.changed;
262
+ if (changed && dryRun !== true) {
263
+ await writeFile(targetFilePath.absolutePath, scriptApplied.content, "utf8");
264
+ }
265
+
266
+ return {
267
+ touchedFiles: changed ? [targetFilePath.relativePath] : [],
268
+ summary: changed
269
+ ? `Injected outlet \"${host}:${position}\" into ${targetFilePath.relativePath}.`
270
+ : `Outlet \"${host}:${position}\" is already present in ${targetFilePath.relativePath}.`
271
+ };
272
+ }
273
+
274
+ export { runGeneratorSubcommand };
@@ -61,6 +61,10 @@ test("buildUiPageTemplateContext resolves placement from default app ShellOutlet
61
61
  });
62
62
  assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_HOST__, "workspace-settings");
63
63
  assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "forms");
64
+ assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
65
+ assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/");
66
+ assert.equal(context.__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__, "/");
67
+ assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, "");
64
68
  });
65
69
  });
66
70
 
@@ -75,6 +79,7 @@ test("buildUiPageTemplateContext supports explicit placement override", async ()
75
79
  }
76
80
  });
77
81
  assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "top-right");
82
+ assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "users.web.shell.surface-aware-menu-link-item");
78
83
  });
79
84
  });
80
85
 
@@ -130,6 +135,44 @@ test("buildUiPageTemplateContext supports explicit package outlet placement", as
130
135
  });
131
136
  });
132
137
 
138
+ test("buildUiPageTemplateContext supports explicit placement token and placement to", async () => {
139
+ await withTempApp(async (appRoot) => {
140
+ await writeVueFile(appRoot, "src/components/ShellLayout.vue");
141
+
142
+ const context = await buildUiPageTemplateContext({
143
+ appRoot,
144
+ options: {
145
+ name: "Notes",
146
+ "directory-prefix": "contacts/[contactId]/(nestedChildren)",
147
+ placement: "shell-layout:top-right",
148
+ "placement-component-token": "local.main.ui.tab-link-item",
149
+ "placement-to": "./notes"
150
+ }
151
+ });
152
+ assert.equal(context.__JSKIT_UI_MENU_COMPONENT_TOKEN__, "local.main.ui.tab-link-item");
153
+ assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
154
+ assert.equal(context.__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
155
+ assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./notes\",\n");
156
+ });
157
+ });
158
+
159
+ test("buildUiPageTemplateContext auto sets relative placement to for nestedChildren prefixes", async () => {
160
+ await withTempApp(async (appRoot) => {
161
+ await writeVueFile(appRoot, "src/components/ShellLayout.vue");
162
+
163
+ const context = await buildUiPageTemplateContext({
164
+ appRoot,
165
+ options: {
166
+ name: "Notes",
167
+ "directory-prefix": "contacts/[contactId]/(nestedChildren)",
168
+ placement: "shell-layout:top-right"
169
+ }
170
+ });
171
+ assert.equal(context.__JSKIT_UI_MENU_TO_PROP_LINE__, " to: \"./notes\",\n");
172
+ assert.equal(context.__JSKIT_UI_MENU_WORKSPACE_SUFFIX__, "/contacts/[contactId]/notes");
173
+ });
174
+ });
175
+
133
176
  test("buildUiPageTemplateContext validates placement format", async () => {
134
177
  await withTempApp(async (appRoot) => {
135
178
  await writeVueFile(appRoot, "src/components/ShellLayout.vue");
@@ -94,9 +94,8 @@ test("ui-generator container subcommand creates parent route container with Shel
94
94
  assert.deepEqual(result.touchedFiles, [
95
95
  "packages/main/src/client/providers/MainClientProvider.js",
96
96
  "src/components/SectionContainerShell.vue",
97
- "src/components/SectionShellTabLinkItem.vue",
98
- "src/pages/w/[workspaceSlug]/admin/practice.vue",
99
- "src/placement.js"
97
+ "src/components/TabLinkItem.vue",
98
+ "src/pages/w/[workspaceSlug]/admin/practice.vue"
100
99
  ]);
101
100
 
102
101
  const containerSource = await readFile(
@@ -107,13 +106,19 @@ test("ui-generator container subcommand creates parent route container with Shel
107
106
  assert.match(containerSource, /host="practice"/);
108
107
  assert.match(containerSource, /<RouterView \/>/);
109
108
  assert.match(containerSource, /"surface": "admin"/);
109
+ assert.match(containerSource, /"placements": \{/);
110
+ assert.match(containerSource, /"outlets": \[/);
111
+ assert.match(containerSource, /"host": "practice"/);
112
+ assert.match(containerSource, /"position": "sub-pages"/);
110
113
 
111
114
  const sectionShellSource = await readFile(path.join(appRoot, "src", "components", "SectionContainerShell.vue"), "utf8");
112
115
  assert.match(sectionShellSource, /<ShellOutlet :host="props\.host" :position="props\.position" \/>/);
113
116
 
114
- const tabLinkSource = await readFile(path.join(appRoot, "src", "components", "SectionShellTabLinkItem.vue"), "utf8");
117
+ const tabLinkSource = await readFile(path.join(appRoot, "src", "components", "TabLinkItem.vue"), "utf8");
115
118
  assert.match(tabLinkSource, /useWorkspaceRouteContext/);
116
- assert.match(tabLinkSource, /class="section-shell-tab-link text-none"/);
119
+ assert.match(tabLinkSource, /class="tab-link-item text-none"/);
120
+ assert.equal(tabLinkSource.includes("source.replace(/\\[([^\\]]+)\\]/g"), true);
121
+ assert.equal(tabLinkSource.includes("source.replace(/[([^]]+)]/g"), false);
117
122
 
118
123
  const providerSource = await readFile(
119
124
  path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js"),
@@ -121,13 +126,182 @@ test("ui-generator container subcommand creates parent route container with Shel
121
126
  );
122
127
  assert.match(
123
128
  providerSource,
124
- /registerMainClientComponent\("local\.main\.ui\.section-shell\.tab-link-item", \(\) => SectionShellTabLinkItem\);/
129
+ /registerMainClientComponent\("local\.main\.ui\.tab-link-item", \(\) => TabLinkItem\);/
130
+ );
131
+
132
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
133
+ assert.doesNotMatch(placementSource, /id: "ui-generator\.container\.practice\.menu"/);
134
+ });
135
+ });
136
+
137
+ test("ui-generator container preserves bracket route params in directory-prefix", async () => {
138
+ await withTempApp(async (appRoot) => {
139
+ await writeAppFixture(appRoot);
140
+
141
+ const result = await runGeneratorSubcommand({
142
+ appRoot,
143
+ subcommand: "container",
144
+ options: {
145
+ name: "Contact Tools",
146
+ surface: "admin",
147
+ "directory-prefix": "contacts/[contactId]"
148
+ }
149
+ });
150
+
151
+ assert.deepEqual(result.touchedFiles, [
152
+ "packages/main/src/client/providers/MainClientProvider.js",
153
+ "src/components/SectionContainerShell.vue",
154
+ "src/components/TabLinkItem.vue",
155
+ "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue"
156
+ ]);
157
+
158
+ const containerSource = await readFile(
159
+ path.join(appRoot, "src", "pages", "w", "[workspaceSlug]", "admin", "contacts", "[contactId]", "contact-tools.vue"),
160
+ "utf8"
161
+ );
162
+ assert.match(containerSource, /host="contact-tools"/);
163
+ assert.match(containerSource, /"host": "contact-tools"/);
164
+ assert.match(containerSource, /"position": "sub-pages"/);
165
+
166
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
167
+ assert.doesNotMatch(placementSource, /jskit:ui-generator\.container\.menu:admin:contacts\/\[contactId\]\/contact-tools/);
168
+ });
169
+ });
170
+
171
+ test("ui-generator container supports explicit route-path for dynamic container files", async () => {
172
+ await withTempApp(async (appRoot) => {
173
+ await writeAppFixture(appRoot);
174
+
175
+ const result = await runGeneratorSubcommand({
176
+ appRoot,
177
+ subcommand: "container",
178
+ options: {
179
+ name: "Contact",
180
+ surface: "admin",
181
+ "directory-prefix": "contacts",
182
+ "route-path": "[contactId]"
183
+ }
184
+ });
185
+
186
+ assert.deepEqual(result.touchedFiles, [
187
+ "packages/main/src/client/providers/MainClientProvider.js",
188
+ "src/components/SectionContainerShell.vue",
189
+ "src/components/TabLinkItem.vue",
190
+ "src/pages/w/[workspaceSlug]/admin/contacts/[contactId].vue"
191
+ ]);
192
+
193
+ const containerSource = await readFile(
194
+ path.join(appRoot, "src", "pages", "w", "[workspaceSlug]", "admin", "contacts", "[contactId].vue"),
195
+ "utf8"
196
+ );
197
+ assert.match(containerSource, /host="contact"/);
198
+ assert.match(containerSource, /<RouterView \/>/);
199
+ });
200
+ });
201
+
202
+ test("ui-generator container rejects invalid explicit route-path values", async () => {
203
+ await withTempApp(async (appRoot) => {
204
+ await writeAppFixture(appRoot);
205
+
206
+ await assert.rejects(
207
+ runGeneratorSubcommand({
208
+ appRoot,
209
+ subcommand: "container",
210
+ options: {
211
+ name: "Contact",
212
+ surface: "admin",
213
+ "route-path": "---"
214
+ }
215
+ }),
216
+ /ui-generator container requires a valid --route-path when provided\./
125
217
  );
218
+ });
219
+ });
220
+
221
+ test("ui-generator container appends menu placement only when --placement is provided", async () => {
222
+ await withTempApp(async (appRoot) => {
223
+ await writeAppFixture(appRoot);
224
+
225
+ const result = await runGeneratorSubcommand({
226
+ appRoot,
227
+ subcommand: "container",
228
+ options: {
229
+ name: "Practice",
230
+ surface: "admin",
231
+ placement: "shell-layout:top-right"
232
+ }
233
+ });
234
+
235
+ assert.deepEqual(result.touchedFiles, [
236
+ "packages/main/src/client/providers/MainClientProvider.js",
237
+ "src/components/SectionContainerShell.vue",
238
+ "src/components/TabLinkItem.vue",
239
+ "src/pages/w/[workspaceSlug]/admin/practice.vue",
240
+ "src/placement.js"
241
+ ]);
126
242
 
127
243
  const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
128
244
  assert.match(placementSource, /id: "ui-generator\.container\.practice\.menu"/);
129
245
  assert.match(placementSource, /host: "shell-layout"/);
130
- assert.match(placementSource, /position: "primary-menu"/);
246
+ assert.match(placementSource, /position: "top-right"/);
131
247
  assert.match(placementSource, /workspaceSuffix: "\/practice"/);
132
248
  });
133
249
  });
250
+
251
+ test("ui-generator container backfills route meta placements on existing container page", async () => {
252
+ await withTempApp(async (appRoot) => {
253
+ await writeAppFixture(appRoot);
254
+
255
+ await mkdir(path.join(appRoot, "src", "pages", "w", "[workspaceSlug]", "admin"), { recursive: true });
256
+ await writeFile(
257
+ path.join(appRoot, "src", "pages", "w", "[workspaceSlug]", "admin", "practice.vue"),
258
+ `<script setup>
259
+ import { RouterView } from "vue-router";
260
+ import SectionContainerShell from "/src/components/SectionContainerShell.vue";
261
+ </script>
262
+
263
+ <template>
264
+ <SectionContainerShell
265
+ title="Practice"
266
+ subtitle="Manage practice modules."
267
+ host="practice"
268
+ position="sub-pages"
269
+ >
270
+ <RouterView />
271
+ </SectionContainerShell>
272
+ </template>
273
+
274
+ <route lang="json">
275
+ {
276
+ "meta": {
277
+ "jskit": {
278
+ "surface": "admin"
279
+ }
280
+ }
281
+ }
282
+ </route>
283
+ `,
284
+ "utf8"
285
+ );
286
+
287
+ const result = await runGeneratorSubcommand({
288
+ appRoot,
289
+ subcommand: "container",
290
+ options: {
291
+ name: "Practice",
292
+ surface: "admin"
293
+ }
294
+ });
295
+
296
+ assert.match(result.touchedFiles.join("\n"), /src\/pages\/w\/\[workspaceSlug\]\/admin\/practice\.vue/);
297
+
298
+ const containerSource = await readFile(
299
+ path.join(appRoot, "src", "pages", "w", "[workspaceSlug]", "admin", "practice.vue"),
300
+ "utf8"
301
+ );
302
+ assert.match(containerSource, /"placements": \{/);
303
+ assert.match(containerSource, /"outlets": \[/);
304
+ assert.match(containerSource, /"host": "practice"/);
305
+ assert.match(containerSource, /"position": "sub-pages"/);
306
+ });
307
+ });