@jskit-ai/ui-generator 0.1.5 → 0.1.7
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 +104 -4
- package/package.descriptor.mjs +72 -5
- package/package.json +2 -2
- package/src/server/buildTemplateContext.js +117 -1
- package/src/server/subcommands/container.js +259 -96
- package/src/server/subcommands/outlet.js +274 -0
- package/test/buildTemplateContext.test.js +43 -0
- package/test/containerSubcommand.test.js +182 -8
- package/test/outletSubcommand.test.js +219 -0
|
@@ -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/
|
|
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", "
|
|
115
|
-
assert.match(tabLinkSource, /
|
|
116
|
-
assert.match(tabLinkSource, /
|
|
117
|
+
const tabLinkSource = await readFile(path.join(appRoot, "src", "components", "TabLinkItem.vue"), "utf8");
|
|
118
|
+
assert.match(tabLinkSource, /@jskit-ai\/users-web\/client\/support\/menuLinkTarget/);
|
|
119
|
+
assert.match(tabLinkSource, /resolveMenuLinkTarget/);
|
|
120
|
+
assert.match(tabLinkSource, /normalizeMenuLinkPathname/);
|
|
121
|
+
assert.match(tabLinkSource, /class="tab-link-item text-none"/);
|
|
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\.
|
|
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: "
|
|
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
|
+
});
|