@jskit-ai/ui-generator 0.1.3

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,165 @@
1
+ import path from "node:path";
2
+ import {
3
+ resolveRequiredAppRoot,
4
+ toPosixPath
5
+ } from "@jskit-ai/kernel/server/support";
6
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
7
+ import { toCamelCase, toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
8
+
9
+ const DEFAULT_COMPONENT_DIRECTORY = "src/components";
10
+ const MAIN_CLIENT_PROVIDER_FILE = "packages/main/src/client/providers/MainClientProvider.js";
11
+ const PLACEMENT_FILE = "src/placement.js";
12
+
13
+ function toKebabCase(value = "") {
14
+ return toSnakeCase(value).replaceAll("_", "-");
15
+ }
16
+
17
+ function toPascalCase(value = "") {
18
+ const camel = toCamelCase(toSnakeCase(value));
19
+ if (!camel) {
20
+ return "";
21
+ }
22
+
23
+ return `${camel.slice(0, 1).toUpperCase()}${camel.slice(1)}`;
24
+ }
25
+
26
+ function requireOption(options = {}, optionName = "", { context = "ui-generator" } = {}) {
27
+ const optionValue = normalizeText(options?.[optionName]);
28
+ if (!optionValue) {
29
+ throw new Error(`${context} requires --${optionName}.`);
30
+ }
31
+
32
+ return optionValue;
33
+ }
34
+
35
+ function resolvePathWithinApp(appRoot, targetPath, { context = "ui-generator" } = {}) {
36
+ const resolvedAppRoot = resolveRequiredAppRoot(appRoot, {
37
+ context
38
+ });
39
+
40
+ const normalizedTargetPath = normalizeText(targetPath);
41
+ if (!normalizedTargetPath) {
42
+ throw new Error(`${context} requires target path.`);
43
+ }
44
+
45
+ const absolutePath = path.resolve(resolvedAppRoot, normalizedTargetPath);
46
+ const relativePath = path.relative(resolvedAppRoot, absolutePath);
47
+ if (
48
+ !relativePath ||
49
+ relativePath === ".." ||
50
+ relativePath.startsWith(`..${path.sep}`) ||
51
+ path.isAbsolute(relativePath)
52
+ ) {
53
+ throw new Error(`${context} target path must stay within app root: ${normalizedTargetPath}`);
54
+ }
55
+
56
+ return Object.freeze({
57
+ absolutePath,
58
+ relativePath: toPosixPath(relativePath)
59
+ });
60
+ }
61
+
62
+ function ensureTrailingNewline(value = "") {
63
+ const source = String(value || "");
64
+ return source.endsWith("\n") ? source : `${source}\n`;
65
+ }
66
+
67
+ function appendBlockIfMarkerMissing(source = "", marker = "", block = "") {
68
+ const normalizedMarker = String(marker || "").trim();
69
+ const normalizedBlock = String(block || "").trim();
70
+ const sourceText = String(source || "");
71
+ if (!normalizedMarker || !normalizedBlock || sourceText.includes(normalizedMarker)) {
72
+ return {
73
+ changed: false,
74
+ content: sourceText
75
+ };
76
+ }
77
+
78
+ return {
79
+ changed: true,
80
+ content: `${ensureTrailingNewline(sourceText)}${normalizedBlock}\n`
81
+ };
82
+ }
83
+
84
+ function insertImportIfMissing(source = "", importLine = "") {
85
+ const normalizedImportLine = String(importLine || "").trim();
86
+ if (!normalizedImportLine) {
87
+ return {
88
+ changed: false,
89
+ content: String(source || "")
90
+ };
91
+ }
92
+
93
+ const sourceText = String(source || "");
94
+ if (sourceText.includes(normalizedImportLine)) {
95
+ return {
96
+ changed: false,
97
+ content: sourceText
98
+ };
99
+ }
100
+
101
+ const importPattern = /^import\s+[^;]+;\s*$/gm;
102
+ let match = null;
103
+ let insertionIndex = 0;
104
+ while ((match = importPattern.exec(sourceText)) !== null) {
105
+ insertionIndex = match.index + match[0].length;
106
+ }
107
+
108
+ if (insertionIndex > 0) {
109
+ return {
110
+ changed: true,
111
+ content: `${sourceText.slice(0, insertionIndex)}\n${normalizedImportLine}${sourceText.slice(insertionIndex)}`
112
+ };
113
+ }
114
+
115
+ return {
116
+ changed: true,
117
+ content: `${normalizedImportLine}\n${sourceText}`
118
+ };
119
+ }
120
+
121
+ function insertBeforeClassDeclaration(source = "", line = "", { className = "", contextFile = "" } = {}) {
122
+ const normalizedLine = String(line || "").trim();
123
+ if (!normalizedLine) {
124
+ return {
125
+ changed: false,
126
+ content: String(source || "")
127
+ };
128
+ }
129
+
130
+ const sourceText = String(source || "");
131
+ if (sourceText.includes(normalizedLine)) {
132
+ return {
133
+ changed: false,
134
+ content: sourceText
135
+ };
136
+ }
137
+
138
+ const normalizedClassName = String(className || "").trim();
139
+ const classPattern = new RegExp(`^class\\s+${normalizedClassName}\\b`, "m");
140
+ const classMatch = classPattern.exec(sourceText);
141
+ if (!classMatch) {
142
+ const targetFile = String(contextFile || "").trim() || "target file";
143
+ throw new Error(`ui-generator could not find ${normalizedClassName} class declaration in ${targetFile}.`);
144
+ }
145
+
146
+ const insertionIndex = classMatch.index;
147
+ return {
148
+ changed: true,
149
+ content: `${sourceText.slice(0, insertionIndex)}${normalizedLine}\n\n${sourceText.slice(insertionIndex)}`
150
+ };
151
+ }
152
+
153
+ export {
154
+ DEFAULT_COMPONENT_DIRECTORY,
155
+ MAIN_CLIENT_PROVIDER_FILE,
156
+ PLACEMENT_FILE,
157
+ toKebabCase,
158
+ toPascalCase,
159
+ requireOption,
160
+ resolvePathWithinApp,
161
+ ensureTrailingNewline,
162
+ appendBlockIfMarkerMissing,
163
+ insertImportIfMissing,
164
+ insertBeforeClassDeclaration
165
+ };
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <section class="pa-4">
3
+ <h1 class="text-h5 mb-2">${option:name|trim}</h1>
4
+ <p class="text-body-2 text-medium-emphasis">Replace this scaffold with your page implementation.</p>
5
+ </section>
6
+ </template>
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import { buildUiPageTemplateContext } from "../src/server/buildTemplateContext.js";
7
+
8
+ async function withTempApp(run) {
9
+ const appRoot = await mkdtemp(path.join(tmpdir(), "ui-generator-"));
10
+ try {
11
+ return await run(appRoot);
12
+ } finally {
13
+ await rm(appRoot, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ async function writeVueFile(appRoot, relativePath, source = "") {
18
+ const absoluteFile = path.join(appRoot, relativePath);
19
+ await mkdir(path.dirname(absoluteFile), { recursive: true });
20
+ await writeFile(
21
+ absoluteFile,
22
+ source ||
23
+ `<template>
24
+ <div>
25
+ <ShellOutlet host="shell-layout" position="top-right" />
26
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
27
+ </div>
28
+ </template>
29
+ `,
30
+ "utf8"
31
+ );
32
+ }
33
+
34
+ test("buildUiPageTemplateContext resolves placement from default app ShellOutlet target", async () => {
35
+ await withTempApp(async (appRoot) => {
36
+ await writeVueFile(
37
+ appRoot,
38
+ "src/components/ShellLayout.vue",
39
+ `<template>
40
+ <div>
41
+ <ShellOutlet host="shell-layout" position="top-right" />
42
+ <ShellOutlet host="shell-layout" position="primary-menu" />
43
+ </div>
44
+ </template>
45
+ `
46
+ );
47
+ await writeVueFile(
48
+ appRoot,
49
+ "src/pages/admin/workspace/settings/index.vue",
50
+ `<template>
51
+ <section>
52
+ <ShellOutlet host="workspace-settings" position="forms" default />
53
+ </section>
54
+ </template>
55
+ `
56
+ );
57
+
58
+ const context = await buildUiPageTemplateContext({
59
+ appRoot,
60
+ options: {}
61
+ });
62
+ assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_HOST__, "workspace-settings");
63
+ assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "forms");
64
+ });
65
+ });
66
+
67
+ test("buildUiPageTemplateContext supports explicit placement override", async () => {
68
+ await withTempApp(async (appRoot) => {
69
+ await writeVueFile(appRoot, "src/components/ShellLayout.vue");
70
+
71
+ const context = await buildUiPageTemplateContext({
72
+ appRoot,
73
+ options: {
74
+ placement: "shell-layout:top-right"
75
+ }
76
+ });
77
+ assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "top-right");
78
+ });
79
+ });
80
+
81
+ test("buildUiPageTemplateContext supports explicit package outlet placement", async () => {
82
+ await withTempApp(async (appRoot) => {
83
+ await writeVueFile(appRoot, "src/components/ShellLayout.vue");
84
+ await writeVueFile(
85
+ appRoot,
86
+ ".jskit/lock.json",
87
+ `${JSON.stringify(
88
+ {
89
+ lockVersion: 1,
90
+ installedPackages: {
91
+ "@example/users-web": {
92
+ packageId: "@example/users-web",
93
+ source: {
94
+ type: "npm-installed-package",
95
+ descriptorPath: "node_modules/@example/users-web/package.descriptor.mjs"
96
+ }
97
+ }
98
+ }
99
+ },
100
+ null,
101
+ 2
102
+ )}\n`
103
+ );
104
+ await writeVueFile(
105
+ appRoot,
106
+ "node_modules/@example/users-web/package.descriptor.mjs",
107
+ `export default {
108
+ packageId: "@example/users-web",
109
+ metadata: {
110
+ ui: {
111
+ placements: {
112
+ outlets: [
113
+ { host: "workspace-tools", position: "primary-menu", source: "src/client/components/UsersWorkspaceToolsWidget.vue" }
114
+ ]
115
+ }
116
+ }
117
+ }
118
+ };
119
+ `
120
+ );
121
+
122
+ const context = await buildUiPageTemplateContext({
123
+ appRoot,
124
+ options: {
125
+ placement: "workspace-tools:primary-menu"
126
+ }
127
+ });
128
+ assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_HOST__, "workspace-tools");
129
+ assert.equal(context.__JSKIT_UI_MENU_PLACEMENT_POSITION__, "primary-menu");
130
+ });
131
+ });
132
+
133
+ test("buildUiPageTemplateContext validates placement format", async () => {
134
+ await withTempApp(async (appRoot) => {
135
+ await writeVueFile(appRoot, "src/components/ShellLayout.vue");
136
+
137
+ await assert.rejects(
138
+ () =>
139
+ buildUiPageTemplateContext({
140
+ appRoot,
141
+ options: {
142
+ placement: "invalid-placement"
143
+ }
144
+ }),
145
+ /option "placement" must be in "host:position" format/
146
+ );
147
+ });
148
+ });
@@ -0,0 +1,133 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import { runGeneratorSubcommand } from "../src/server/subcommands/container.js";
7
+
8
+ async function withTempApp(run) {
9
+ const appRoot = await mkdtemp(path.join(tmpdir(), "ui-generator-container-"));
10
+ try {
11
+ return await run(appRoot);
12
+ } finally {
13
+ await rm(appRoot, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ async function writeAppFixture(appRoot) {
18
+ await mkdir(path.join(appRoot, "config"), { recursive: true });
19
+ await mkdir(path.join(appRoot, "src", "components"), { recursive: true });
20
+ await mkdir(path.join(appRoot, "src"), { recursive: true });
21
+ await mkdir(path.join(appRoot, "packages", "main", "src", "client", "providers"), { recursive: true });
22
+
23
+ await writeFile(
24
+ path.join(appRoot, "config", "public.js"),
25
+ `export const config = {
26
+ surfaceDefinitions: {
27
+ app: { id: "app", pagesRoot: "", enabled: true, requiresAuth: false, requiresWorkspace: false },
28
+ admin: { id: "admin", pagesRoot: "w/[workspaceSlug]/admin", enabled: true, requiresAuth: true, requiresWorkspace: true }
29
+ }
30
+ };
31
+ `,
32
+ "utf8"
33
+ );
34
+ await writeFile(
35
+ path.join(appRoot, "src", "components", "ShellLayout.vue"),
36
+ `<template>
37
+ <div>
38
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
39
+ <ShellOutlet host="shell-layout" position="top-right" />
40
+ </div>
41
+ </template>
42
+ `,
43
+ "utf8"
44
+ );
45
+ await writeFile(
46
+ path.join(appRoot, "src", "placement.js"),
47
+ `function addPlacement() {}
48
+
49
+ export { addPlacement };
50
+ export default function getPlacements() {
51
+ return [];
52
+ }
53
+ `,
54
+ "utf8"
55
+ );
56
+ await writeFile(
57
+ path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js"),
58
+ `const mainClientComponents = [];
59
+
60
+ function registerMainClientComponent(componentToken, resolveComponent) {
61
+ const token = String(componentToken || "").trim();
62
+ if (!token || typeof resolveComponent !== "function") {
63
+ return;
64
+ }
65
+ mainClientComponents.push(
66
+ Object.freeze({
67
+ token,
68
+ resolveComponent
69
+ })
70
+ );
71
+ }
72
+
73
+ class MainClientProvider {}
74
+
75
+ export { MainClientProvider, registerMainClientComponent };
76
+ `,
77
+ "utf8"
78
+ );
79
+ }
80
+
81
+ test("ui-generator container subcommand creates parent route container with ShellOutlet and RouterView", async () => {
82
+ await withTempApp(async (appRoot) => {
83
+ await writeAppFixture(appRoot);
84
+
85
+ const result = await runGeneratorSubcommand({
86
+ appRoot,
87
+ subcommand: "container",
88
+ options: {
89
+ name: "Practice",
90
+ surface: "admin"
91
+ }
92
+ });
93
+
94
+ assert.deepEqual(result.touchedFiles, [
95
+ "packages/main/src/client/providers/MainClientProvider.js",
96
+ "src/components/SectionContainerShell.vue",
97
+ "src/components/SectionShellTabLinkItem.vue",
98
+ "src/pages/w/[workspaceSlug]/admin/practice.vue",
99
+ "src/placement.js"
100
+ ]);
101
+
102
+ const containerSource = await readFile(
103
+ path.join(appRoot, "src", "pages", "w", "[workspaceSlug]", "admin", "practice.vue"),
104
+ "utf8"
105
+ );
106
+ assert.match(containerSource, /<SectionContainerShell/);
107
+ assert.match(containerSource, /host="practice"/);
108
+ assert.match(containerSource, /<RouterView \/>/);
109
+ assert.match(containerSource, /"surface": "admin"/);
110
+
111
+ const sectionShellSource = await readFile(path.join(appRoot, "src", "components", "SectionContainerShell.vue"), "utf8");
112
+ assert.match(sectionShellSource, /<ShellOutlet :host="props\.host" :position="props\.position" \/>/);
113
+
114
+ const tabLinkSource = await readFile(path.join(appRoot, "src", "components", "SectionShellTabLinkItem.vue"), "utf8");
115
+ assert.match(tabLinkSource, /useWorkspaceRouteContext/);
116
+ assert.match(tabLinkSource, /class="section-shell-tab-link text-none"/);
117
+
118
+ const providerSource = await readFile(
119
+ path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js"),
120
+ "utf8"
121
+ );
122
+ assert.match(
123
+ providerSource,
124
+ /registerMainClientComponent\("local\.main\.ui\.section-shell\.tab-link-item", \(\) => SectionShellTabLinkItem\);/
125
+ );
126
+
127
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
128
+ assert.match(placementSource, /id: "ui-generator\.container\.practice\.menu"/);
129
+ assert.match(placementSource, /host: "shell-layout"/);
130
+ assert.match(placementSource, /position: "primary-menu"/);
131
+ assert.match(placementSource, /workspaceSuffix: "\/practice"/);
132
+ });
133
+ });
@@ -0,0 +1,127 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import test from "node:test";
6
+ import { runGeneratorSubcommand } from "../src/server/subcommands/element.js";
7
+
8
+ async function withTempApp(run) {
9
+ const appRoot = await mkdtemp(path.join(tmpdir(), "ui-generator-element-"));
10
+ try {
11
+ return await run(appRoot);
12
+ } finally {
13
+ await rm(appRoot, { recursive: true, force: true });
14
+ }
15
+ }
16
+
17
+ async function writeAppFixture(appRoot) {
18
+ await mkdir(path.join(appRoot, "src", "components"), { recursive: true });
19
+ await mkdir(path.join(appRoot, "src", "pages", "admin", "workspace", "settings"), { recursive: true });
20
+ await mkdir(path.join(appRoot, "packages", "main", "src", "client", "providers"), { recursive: true });
21
+
22
+ await writeFile(
23
+ path.join(appRoot, "src", "components", "ShellLayout.vue"),
24
+ `<template>
25
+ <div>
26
+ <ShellOutlet host="shell-layout" position="primary-menu" default />
27
+ <ShellOutlet host="shell-layout" position="top-right" />
28
+ </div>
29
+ </template>
30
+ `,
31
+ "utf8"
32
+ );
33
+ await writeFile(
34
+ path.join(appRoot, "src", "pages", "admin", "workspace", "settings", "index.vue"),
35
+ `<template>
36
+ <section>
37
+ <ShellOutlet host="workspace-settings" position="forms" />
38
+ </section>
39
+ </template>
40
+ `,
41
+ "utf8"
42
+ );
43
+ await writeFile(
44
+ path.join(appRoot, "src", "placement.js"),
45
+ `function addPlacement() {}
46
+
47
+ export { addPlacement };
48
+ export default function getPlacements() {
49
+ return [];
50
+ }
51
+ `,
52
+ "utf8"
53
+ );
54
+ await writeFile(
55
+ path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js"),
56
+ `const mainClientComponents = [];
57
+
58
+ function registerMainClientComponent(componentToken, resolveComponent) {
59
+ const token = String(componentToken || "").trim();
60
+ if (!token || typeof resolveComponent !== "function") {
61
+ return;
62
+ }
63
+ mainClientComponents.push(
64
+ Object.freeze({
65
+ token,
66
+ resolveComponent
67
+ })
68
+ );
69
+ }
70
+
71
+ class MainClientProvider {}
72
+
73
+ export { MainClientProvider, registerMainClientComponent };
74
+ `,
75
+ "utf8"
76
+ );
77
+ }
78
+
79
+ test("ui-generator element subcommand creates component and outlet placement", async () => {
80
+ await withTempApp(async (appRoot) => {
81
+ await writeAppFixture(appRoot);
82
+
83
+ const result = await runGeneratorSubcommand({
84
+ appRoot,
85
+ subcommand: "element",
86
+ options: {
87
+ name: "Ops Panel",
88
+ surface: "admin",
89
+ placement: "workspace-settings:forms"
90
+ }
91
+ });
92
+
93
+ assert.deepEqual(result.touchedFiles, [
94
+ "packages/main/src/client/providers/MainClientProvider.js",
95
+ "src/components/OpsPanelElement.vue",
96
+ "src/placement.js"
97
+ ]);
98
+
99
+ const providerSource = await readFile(
100
+ path.join(appRoot, "packages", "main", "src", "client", "providers", "MainClientProvider.js"),
101
+ "utf8"
102
+ );
103
+ assert.match(providerSource, /import OpsPanelElement from "\/src\/components\/OpsPanelElement\.vue";/);
104
+ assert.match(providerSource, /registerMainClientComponent\("local\.main\.ui\.element\.ops-panel", \(\) => OpsPanelElement\);/);
105
+
106
+ const placementSource = await readFile(path.join(appRoot, "src", "placement.js"), "utf8");
107
+ assert.match(placementSource, /id: "ui-generator\.element\.ops-panel"/);
108
+ assert.match(placementSource, /host: "workspace-settings"/);
109
+ assert.match(placementSource, /position: "forms"/);
110
+ assert.match(placementSource, /componentToken: "local\.main\.ui\.element\.ops-panel"/);
111
+ });
112
+ });
113
+
114
+ test("ui-generator element subcommand requires appRoot", async () => {
115
+ await assert.rejects(
116
+ () =>
117
+ runGeneratorSubcommand({
118
+ appRoot: "",
119
+ subcommand: "element",
120
+ options: {
121
+ name: "Ops Panel",
122
+ surface: "admin"
123
+ }
124
+ }),
125
+ /requires appRoot/
126
+ );
127
+ });