@jskit-ai/ui-generator 0.1.13 → 0.1.15

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.
@@ -4,7 +4,7 @@ import {
4
4
  resolveShellOutletPlacementTargetFromApp,
5
5
  toPosixPath
6
6
  } from "@jskit-ai/kernel/server/support";
7
- import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
7
+ import { normalizeBoolean, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
8
8
  import {
9
9
  DEFAULT_COMPONENT_DIRECTORY,
10
10
  MAIN_CLIENT_PROVIDER_FILE,
@@ -12,12 +12,15 @@ import {
12
12
  toKebabCase,
13
13
  toPascalCase,
14
14
  requireOption,
15
+ rejectUnexpectedOptions,
15
16
  resolvePathWithinApp,
16
17
  appendBlockIfMarkerMissing,
17
18
  insertImportIfMissing,
18
19
  insertBeforeClassDeclaration
19
20
  } from "./support.js";
20
21
 
22
+ const DEFAULT_ELEMENT_PLACEMENT = "shell-layout:top-right";
23
+
21
24
  function renderElementComponentSource(elementName = "") {
22
25
  return `<template>
23
26
  <section class="pa-4">
@@ -36,40 +39,47 @@ async function runGeneratorSubcommand({
36
39
  dryRun = false
37
40
  } = {}) {
38
41
  const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
39
- if (normalizedSubcommand !== "element") {
42
+ if (normalizedSubcommand !== "placed-element") {
40
43
  throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
41
44
  }
42
45
  if (Array.isArray(args) && args.length > 0) {
43
- throw new Error("ui-generator element does not accept positional arguments.");
46
+ throw new Error("ui-generator placed-element does not accept positional arguments.");
44
47
  }
48
+ rejectUnexpectedOptions(options, ["name", "surface", "path", "placement", "force"], {
49
+ context: "ui-generator placed-element"
50
+ });
45
51
 
46
- const name = requireOption(options, "name", { context: "ui-generator element" });
47
- const surface = requireOption(options, "surface", { context: "ui-generator element" }).toLowerCase();
52
+ const name = requireOption(options, "name", { context: "ui-generator placed-element" });
53
+ const surface = requireOption(options, "surface", { context: "ui-generator placed-element" }).toLowerCase();
48
54
  const componentDirectory = normalizeText(options.path) || DEFAULT_COMPONENT_DIRECTORY;
55
+ const forceOverwrite = Object.prototype.hasOwnProperty.call(options, "force")
56
+ ? normalizeBoolean(options.force)
57
+ : false;
49
58
  const elementNamePascal = toPascalCase(name);
50
59
  const elementNameKebab = toKebabCase(name);
51
60
 
52
61
  if (!elementNamePascal || !elementNameKebab) {
53
- throw new Error("ui-generator element requires a valid --name.");
62
+ throw new Error("ui-generator placed-element requires a valid --name.");
54
63
  }
55
64
 
56
65
  const componentPath = resolvePathWithinApp(appRoot, path.join(componentDirectory, `${elementNamePascal}Element.vue`), {
57
- context: "ui-generator element"
66
+ context: "ui-generator placed-element"
58
67
  });
59
68
  const providerPath = resolvePathWithinApp(appRoot, MAIN_CLIENT_PROVIDER_FILE, {
60
- context: "ui-generator element"
69
+ context: "ui-generator placed-element"
61
70
  });
62
71
  const placementPath = resolvePathWithinApp(appRoot, PLACEMENT_FILE, {
63
- context: "ui-generator element"
72
+ context: "ui-generator placed-element"
64
73
  });
65
74
  const componentToken = `local.main.ui.element.${elementNameKebab}`;
66
75
  const placementTarget = await resolveShellOutletPlacementTargetFromApp({
67
76
  appRoot,
68
77
  context: "ui-generator",
69
- placement: options?.placement
78
+ placement: options?.placement || DEFAULT_ELEMENT_PLACEMENT
70
79
  });
71
80
 
72
81
  const touchedFiles = new Set();
82
+ const desiredComponentSource = renderElementComponentSource(name);
73
83
 
74
84
  let componentSource = "";
75
85
  try {
@@ -77,10 +87,15 @@ async function runGeneratorSubcommand({
77
87
  } catch {
78
88
  componentSource = "";
79
89
  }
80
- if (!componentSource) {
90
+ if (componentSource && !forceOverwrite) {
91
+ throw new Error(
92
+ `ui-generator placed-element will not overwrite existing component file ${componentPath.relativePath}. Re-run with --force to overwrite it.`
93
+ );
94
+ }
95
+ if (!componentSource || (forceOverwrite && componentSource !== desiredComponentSource)) {
81
96
  if (dryRun !== true) {
82
97
  await mkdir(path.dirname(componentPath.absolutePath), { recursive: true });
83
- await writeFile(componentPath.absolutePath, renderElementComponentSource(name), "utf8");
98
+ await writeFile(componentPath.absolutePath, desiredComponentSource, "utf8");
84
99
  }
85
100
  touchedFiles.add(componentPath.relativePath);
86
101
  }
@@ -88,7 +103,7 @@ async function runGeneratorSubcommand({
88
103
  const providerSource = await readFile(providerPath.absolutePath, "utf8");
89
104
  if (!/\bregisterMainClientComponent\s*\(/.test(providerSource)) {
90
105
  throw new Error(
91
- `ui-generator element could not find registerMainClientComponent() contract in ${MAIN_CLIENT_PROVIDER_FILE}.`
106
+ `ui-generator placed-element could not find registerMainClientComponent() contract in ${MAIN_CLIENT_PROVIDER_FILE}.`
92
107
  );
93
108
  }
94
109
 
@@ -140,8 +155,8 @@ async function runGeneratorSubcommand({
140
155
  touchedFiles: touchedFileList,
141
156
  summary:
142
157
  touchedFileList.length > 0
143
- ? `Generated UI element "${elementNameKebab}" and placement token "${componentToken}".`
144
- : `UI element "${elementNameKebab}" is already up to date.`
158
+ ? `Generated placed UI element "${elementNameKebab}" and placement token "${componentToken}".`
159
+ : `Placed UI element "${elementNameKebab}" is already up to date.`
145
160
  };
146
161
  }
147
162
 
@@ -1,91 +1,22 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import {
4
- requireOption,
4
+ requireSinglePositionalTargetFile,
5
+ rejectUnexpectedOptions,
6
+ resolveOutletTargetId,
5
7
  resolvePathWithinApp,
6
8
  ensureTrailingNewline,
7
- insertImportIfMissing
9
+ insertImportIfMissing,
10
+ findScriptBlock,
11
+ parseTagAttributes,
12
+ indentBlock
8
13
  } from "./support.js";
9
14
 
10
15
  const DEFAULT_OUTLET_POSITION = "sub-pages";
11
- const MODE_ROUTED = "routed";
12
- const MODE_OUTLET_ONLY = "outlet-only";
13
16
 
14
- const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
15
17
  const ROUTE_TAG_PATTERN = /<route\b[^>]*>[\s\S]*?<\/route>\s*/gi;
16
18
  const TEMPLATE_CLOSE_TAG_PATTERN = /<\/template>/gi;
17
- const ROUTER_VIEW_TAG_PATTERN = /<RouterView(?:\s|\/|>)/;
18
19
  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
20
 
90
21
  function hasShellOutletTarget(source = "", { host = "", position = "" } = {}) {
91
22
  const normalizedHost = normalizeText(host);
@@ -106,19 +37,14 @@ function hasShellOutletTarget(source = "", { host = "", position = "" } = {}) {
106
37
  return false;
107
38
  }
108
39
 
109
- function applyScriptImports(source = "", { includeRouterViewImport = false } = {}) {
40
+ function applyScriptImports(source = "") {
110
41
  const sourceText = String(source || "");
111
42
  const scriptBlock = findScriptBlock(sourceText);
112
43
 
113
44
  const shellOutletImport = "import ShellOutlet from \"@jskit-ai/shell-web/client/components/ShellOutlet\";";
114
- const routerViewImport = "import { RouterView } from \"vue-router\";";
115
45
 
116
46
  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`;
47
+ const scriptSetupBlock = `<script setup>\n${shellOutletImport}\n</script>\n`;
122
48
  let insertionIndex = 0;
123
49
  for (const match of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
124
50
  insertionIndex = match.index + String(match[0] || "").length;
@@ -133,15 +59,7 @@ function applyScriptImports(source = "", { includeRouterViewImport = false } = {
133
59
  let nextScriptContent = scriptBlock.content;
134
60
  const shellImportApplied = insertImportIfMissing(nextScriptContent, shellOutletImport);
135
61
  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) {
62
+ if (!shellImportApplied.changed) {
145
63
  return {
146
64
  changed: false,
147
65
  content: sourceText
@@ -157,23 +75,8 @@ function applyScriptImports(source = "", { includeRouterViewImport = false } = {
157
75
  };
158
76
  }
159
77
 
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");
78
+ function createOutletBlock({ host = "", position = "" } = {}) {
79
+ return `<ShellOutlet host=\"${host}\" position=\"${position}\" />`;
177
80
  }
178
81
 
179
82
  function findLastTemplateCloseTag(source = "") {
@@ -185,9 +88,9 @@ function findLastTemplateCloseTag(source = "") {
185
88
  return lastMatch;
186
89
  }
187
90
 
188
- function applyOutletTemplateBlock(source = "", { host = "", position = "", includeRouterView = false } = {}) {
91
+ function applyOutletTemplateBlock(source = "", { host = "", position = "" } = {}) {
189
92
  const sourceText = String(source || "");
190
- const outletBlock = createOutletBlock({ host, position, includeRouterView });
93
+ const outletBlock = createOutletBlock({ host, position });
191
94
 
192
95
  const templateTagMatch = findLastTemplateCloseTag(sourceText);
193
96
  if (!templateTagMatch) {
@@ -223,14 +126,17 @@ async function runGeneratorSubcommand({
223
126
  if (normalizedSubcommand !== "outlet") {
224
127
  throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
225
128
  }
226
- if (Array.isArray(args) && args.length > 0) {
227
- throw new Error("ui-generator outlet does not accept positional arguments.");
228
- }
129
+ const targetFile = requireSinglePositionalTargetFile(args, { context: "ui-generator outlet" });
130
+ rejectUnexpectedOptions(options, ["target"], {
131
+ context: "ui-generator outlet"
132
+ });
229
133
 
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);
134
+ const outletTarget = resolveOutletTargetId(options?.target, {
135
+ context: "ui-generator outlet",
136
+ optionName: "target",
137
+ defaultPosition: DEFAULT_OUTLET_POSITION
138
+ });
139
+ const { host, position } = outletTarget;
234
140
 
235
141
  const targetFilePath = resolvePathWithinApp(appRoot, targetFile, {
236
142
  context: "ui-generator outlet"
@@ -244,19 +150,13 @@ async function runGeneratorSubcommand({
244
150
  }
245
151
 
246
152
  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
153
  const templateApplied = hasTargetOutlet
251
154
  ? { changed: false, content: source }
252
155
  : applyOutletTemplateBlock(source, {
253
156
  host,
254
- position,
255
- includeRouterView: shouldInsertRouterView
157
+ position
256
158
  });
257
- const scriptApplied = applyScriptImports(templateApplied.content, {
258
- includeRouterViewImport: shouldInsertRouterView
259
- });
159
+ const scriptApplied = applyScriptImports(templateApplied.content);
260
160
 
261
161
  const changed = templateApplied.changed || scriptApplied.changed;
262
162
  if (changed && dryRun !== true) {
@@ -0,0 +1,142 @@
1
+ import path from "node:path";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { normalizeBoolean, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { buildUiPageTemplateContext } from "../buildTemplateContext.js";
5
+ import {
6
+ PLACEMENT_FILE,
7
+ requireSinglePositionalTargetFile,
8
+ rejectUnexpectedOptions,
9
+ resolvePathWithinApp,
10
+ appendBlockIfMarkerMissing
11
+ } from "./support.js";
12
+ import {
13
+ resolvePageTargetDetails,
14
+ renderPlainPageSource,
15
+ } from "./pageSupport.js";
16
+
17
+ function renderPageLinkPlacementBlock({
18
+ marker = "",
19
+ context = {},
20
+ label = "",
21
+ surface = ""
22
+ } = {}) {
23
+ return (
24
+ `// ${marker}\n` +
25
+ "{\n" +
26
+ " addPlacement({\n" +
27
+ ` id: "${context.__JSKIT_UI_LINK_PLACEMENT_ID__}",\n` +
28
+ ` host: "${context.__JSKIT_UI_LINK_PLACEMENT_HOST__}",\n` +
29
+ ` position: "${context.__JSKIT_UI_LINK_PLACEMENT_POSITION__}",\n` +
30
+ ` surfaces: ["${surface}"],\n` +
31
+ " order: 155,\n" +
32
+ ` componentToken: "${context.__JSKIT_UI_LINK_COMPONENT_TOKEN__}",\n` +
33
+ " props: {\n" +
34
+ ` label: "${label}",\n` +
35
+ ` surface: "${surface}",\n` +
36
+ ` workspaceSuffix: "${context.__JSKIT_UI_LINK_WORKSPACE_SUFFIX__}",\n` +
37
+ ` nonWorkspaceSuffix: "${context.__JSKIT_UI_LINK_NON_WORKSPACE_SUFFIX__}",\n` +
38
+ `${context.__JSKIT_UI_LINK_TO_PROP_LINE__} },\n` +
39
+ " when: ({ auth }) => Boolean(auth?.authenticated)\n" +
40
+ " });\n" +
41
+ "}\n"
42
+ );
43
+ }
44
+
45
+ async function runGeneratorSubcommand({
46
+ appRoot,
47
+ subcommand = "",
48
+ args = [],
49
+ options = {},
50
+ dryRun = false
51
+ } = {}) {
52
+ const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
53
+ if (normalizedSubcommand !== "page") {
54
+ throw new Error(`Unsupported ui-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
55
+ }
56
+ const targetFile = requireSinglePositionalTargetFile(args, { context: "ui-generator page" });
57
+ rejectUnexpectedOptions(
58
+ options,
59
+ ["name", "link-placement", "link-component-token", "link-to", "force"],
60
+ { context: "ui-generator page" }
61
+ );
62
+
63
+ const pageTarget = await resolvePageTargetDetails({
64
+ appRoot,
65
+ targetFile,
66
+ context: "ui-generator page"
67
+ });
68
+ const pageLabel = normalizeText(options?.name) || pageTarget.defaultName;
69
+ const forceOverwrite = Object.prototype.hasOwnProperty.call(options, "force")
70
+ ? normalizeBoolean(options.force)
71
+ : false;
72
+ const pageFilePath = pageTarget.targetFilePath.absolutePath;
73
+ const pageRelativePath = pageTarget.targetFilePath.relativePath;
74
+
75
+ const touchedFiles = new Set();
76
+ let pageAlreadyExisted = true;
77
+ try {
78
+ await readFile(pageFilePath, "utf8");
79
+ } catch {
80
+ pageAlreadyExisted = false;
81
+ }
82
+
83
+ if (pageAlreadyExisted && !forceOverwrite) {
84
+ throw new Error(
85
+ `ui-generator page will not overwrite existing page ${pageRelativePath}. Re-run with --force to overwrite it.`
86
+ );
87
+ }
88
+
89
+ if (!pageAlreadyExisted || forceOverwrite) {
90
+ if (dryRun !== true) {
91
+ await mkdir(path.dirname(pageFilePath), { recursive: true });
92
+ await writeFile(pageFilePath, renderPlainPageSource(pageLabel), "utf8");
93
+ }
94
+ touchedFiles.add(pageRelativePath);
95
+ }
96
+
97
+ const placementContext = await buildUiPageTemplateContext({
98
+ appRoot: pageTarget.appRoot,
99
+ targetFile,
100
+ options
101
+ });
102
+ const placementPath = resolvePathWithinApp(pageTarget.appRoot, PLACEMENT_FILE, {
103
+ context: "ui-generator page"
104
+ });
105
+ const placementSource = await readFile(placementPath.absolutePath, "utf8");
106
+ const placementMarker = `jskit:ui-generator.page.link:${pageTarget.surfaceId}:${pageTarget.routeUrlSuffix}`;
107
+ const placementApplied = appendBlockIfMarkerMissing(
108
+ placementSource,
109
+ placementMarker,
110
+ renderPageLinkPlacementBlock({
111
+ marker: placementMarker,
112
+ context: placementContext,
113
+ label: pageLabel,
114
+ surface: pageTarget.surfaceId
115
+ })
116
+ );
117
+ if (placementApplied.changed) {
118
+ if (dryRun !== true) {
119
+ await writeFile(placementPath.absolutePath, placementApplied.content, "utf8");
120
+ }
121
+ touchedFiles.add(placementPath.relativePath);
122
+ }
123
+
124
+ const touchedFileList = [...touchedFiles].sort((left, right) => left.localeCompare(right));
125
+ const summaryParts = [];
126
+ if (!pageAlreadyExisted) {
127
+ summaryParts.push(`Generated UI page "${pageTarget.routeUrlSuffix}" at ${pageRelativePath}.`);
128
+ } else if (forceOverwrite) {
129
+ summaryParts.push(`Regenerated UI page "${pageTarget.routeUrlSuffix}" at ${pageRelativePath}.`);
130
+ } else if (placementApplied.changed) {
131
+ summaryParts.push(`Updated page link placement for existing UI page "${pageTarget.routeUrlSuffix}".`);
132
+ } else {
133
+ summaryParts.push(`UI page "${pageTarget.routeUrlSuffix}" is already up to date.`);
134
+ }
135
+
136
+ return {
137
+ touchedFiles: touchedFileList,
138
+ summary: summaryParts.join(" ")
139
+ };
140
+ }
141
+
142
+ export { runGeneratorSubcommand };