@jskit-ai/ui-generator 0.1.48 → 0.1.50

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.
@@ -13,21 +13,27 @@ import {
13
13
  readLocalLinkItemComponentSource
14
14
  } from "@jskit-ai/shell-web/server/support/localLinkItemScaffolds";
15
15
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
16
+ import {
17
+ buildGeneratedUiScreenClassName,
18
+ resolveGeneratedUiSurfaceProfile
19
+ } from "@jskit-ai/kernel/shared/support/generatedUiContract";
16
20
  import {
17
21
  DEFAULT_COMPONENT_DIRECTORY,
18
22
  MAIN_CLIENT_PROVIDER_FILE,
19
23
  resolvePathWithinApp,
20
24
  insertImportIfMissing,
21
25
  insertBeforeClassDeclaration,
22
- findScriptBlock,
26
+ findScriptSetupBlock,
27
+ insertScriptSetupBlock,
23
28
  indentBlock
24
29
  } from "./support.js";
25
30
 
26
31
  const DEFAULT_SUBPAGES_POSITION = "sub-pages";
27
32
  const SECTION_CONTAINER_SHELL_COMPONENT = "SectionContainerShell";
28
- const SUBPAGES_LINK_COMPONENT_TOKEN = "local.main.ui.surface-aware-menu-link-item";
33
+ const SUBPAGES_LINK_COMPONENT_TOKEN = "local.main.ui.tab-link-item";
29
34
  const DEFAULT_MENU_COMPONENT_DIRECTORY = path.join(DEFAULT_COMPONENT_DIRECTORY, "menus");
30
35
  const SUBPAGES_LINK_COMPONENT_DEFINITION = findLocalLinkItemDefinition(SUBPAGES_LINK_COMPONENT_TOKEN);
36
+ const OPERATOR_SURFACE_IDS = new Set(["admin", "console"]);
31
37
 
32
38
  if (!SUBPAGES_LINK_COMPONENT_DEFINITION) {
33
39
  throw new Error(`ui-generator add-subpages could not resolve ${SUBPAGES_LINK_COMPONENT_TOKEN} scaffold definition.`);
@@ -35,25 +41,97 @@ if (!SUBPAGES_LINK_COMPONENT_DEFINITION) {
35
41
 
36
42
  const SUBPAGES_LINK_COMPONENT = SUBPAGES_LINK_COMPONENT_DEFINITION.componentName;
37
43
 
38
- const ROUTE_TAG_PATTERN = /<route\b[^>]*>[\s\S]*?<\/route>\s*/gi;
39
44
  const TEMPLATE_TOKEN_PATTERN = /<\/?template\b[^>]*>/gi;
40
45
  const SHELL_OUTLET_TAG_PATTERN = /<ShellOutlet\b[^>]*\/?>\s*/gi;
41
46
  const ROUTER_VIEW_TAG_PATTERN = /<RouterView\b/i;
42
47
  const ROUTER_VIEW_LINE_PATTERN = /^\s*<RouterView(?:\s[^>]*)?\s*\/>\s*$/gm;
43
48
 
49
+ function resolveGeneratedPageSurfaceProfile({
50
+ surfaceId = "",
51
+ routePath = ""
52
+ } = {}) {
53
+ const routeSegments = normalizeText(routePath)
54
+ .replaceAll("\\", "/")
55
+ .split("/")
56
+ .map((entry) => normalizeText(entry).toLowerCase())
57
+ .filter(Boolean);
58
+ if (routeSegments.includes("settings")) {
59
+ return "settings";
60
+ }
61
+ if (OPERATOR_SURFACE_IDS.has(normalizeText(surfaceId).toLowerCase())) {
62
+ return "operator";
63
+ }
64
+ return "task";
65
+ }
66
+
44
67
  function trimEdgeBlankLines(source = "") {
45
68
  return String(source || "")
46
69
  .replace(/^\s*\n/, "")
47
70
  .replace(/\n\s*$/, "");
48
71
  }
49
72
 
50
- function renderPlainPageSource(pageTitle = "") {
73
+ function renderPlainPageSource(pageTitle = "", {
74
+ surfaceId = "",
75
+ routePath = ""
76
+ } = {}) {
77
+ const surfaceProfileId = resolveGeneratedPageSurfaceProfile({ surfaceId, routePath });
78
+ const surfaceProfile = resolveGeneratedUiSurfaceProfile(surfaceProfileId);
79
+ const screenClass = buildGeneratedUiScreenClassName("generated-page-screen d-flex flex-column ga-4", {
80
+ surfaceProfile: surfaceProfileId
81
+ });
51
82
  return `<template>
52
- <section class="pa-4">
53
- <h1 class="text-h5 mb-2">${pageTitle}</h1>
54
- <p class="text-body-2 text-medium-emphasis">Replace this scaffold with your page implementation.</p>
83
+ <section class="${screenClass}">
84
+ <header>
85
+ <p class="text-overline text-medium-emphasis mb-1">${surfaceProfile.titleLabel}</p>
86
+ <h1 class="generated-page-screen__title">${pageTitle}</h1>
87
+ </header>
88
+
89
+ <v-sheet rounded="lg" border class="generated-page-screen__empty-state">
90
+ <h2 class="text-h6 mb-2">No ${pageTitle} activity yet</h2>
91
+ <p class="text-body-2 text-medium-emphasis mb-0">
92
+ ${surfaceProfile.emptyStateBody}
93
+ </p>
94
+ </v-sheet>
55
95
  </section>
56
96
  </template>
97
+
98
+ <style scoped>
99
+ .generated-ui-screen {
100
+ --generated-ui-screen-title-size: clamp(1.35rem, 2vw, 1.85rem);
101
+ --generated-ui-screen-panel-padding: 2rem 1.25rem;
102
+ --generated-ui-screen-panel-align: center;
103
+ }
104
+
105
+ .generated-ui-screen--operator {
106
+ --generated-ui-screen-panel-padding: 1.5rem 1rem;
107
+ }
108
+
109
+ .generated-ui-screen--settings {
110
+ --generated-ui-screen-panel-padding: 1.5rem 1rem;
111
+ }
112
+
113
+ .generated-page-screen__title {
114
+ font-size: var(--generated-ui-screen-title-size);
115
+ font-weight: 650;
116
+ letter-spacing: -0.02em;
117
+ line-height: 1.15;
118
+ margin: 0;
119
+ }
120
+
121
+ .generated-page-screen__empty-state {
122
+ margin-inline: auto;
123
+ max-width: 34rem;
124
+ padding: var(--generated-ui-screen-panel-padding);
125
+ text-align: var(--generated-ui-screen-panel-align);
126
+ width: 100%;
127
+ }
128
+
129
+ @media (max-width: 640px) {
130
+ .generated-ui-screen {
131
+ --generated-ui-screen-panel-padding: 1.25rem 1rem;
132
+ }
133
+ }
134
+ </style>
57
135
  `;
58
136
  }
59
137
 
@@ -81,41 +159,50 @@ const hasTabs = computed(() => Boolean(slots.tabs));
81
159
 
82
160
  <template>
83
161
  <section class="section-container-shell d-flex flex-column ga-4">
84
- <v-card rounded="lg" elevation="1" border>
85
- <v-card-item v-if="hasHeading">
86
- <v-card-title v-if="resolvedTitle" class="px-0">{{ resolvedTitle }}</v-card-title>
87
- <v-card-subtitle v-if="resolvedSubtitle" class="px-0">{{ resolvedSubtitle }}</v-card-subtitle>
88
- </v-card-item>
89
- <template v-if="hasTabs">
90
- <v-divider v-if="hasHeading" />
91
- <v-card-text class="section-container-shell__tabs">
92
- <slot name="tabs" />
93
- </v-card-text>
94
- </template>
95
- </v-card>
162
+ <header v-if="hasHeading" class="section-container-shell__heading">
163
+ <h1 v-if="resolvedTitle" class="section-container-shell__title">{{ resolvedTitle }}</h1>
164
+ <p v-if="resolvedSubtitle" class="text-body-2 text-medium-emphasis mb-0">{{ resolvedSubtitle }}</p>
165
+ </header>
166
+
167
+ <v-sheet v-if="hasTabs" rounded="lg" border class="section-container-shell__nav">
168
+ <slot name="tabs" />
169
+ </v-sheet>
96
170
 
97
171
  <slot />
98
172
  </section>
99
173
  </template>
100
174
 
101
175
  <style scoped>
102
- .section-container-shell__tabs {
103
- display: flex;
176
+ .section-container-shell__heading {
177
+ min-width: 0;
178
+ }
179
+
180
+ .section-container-shell__title {
181
+ font-size: clamp(1.35rem, 2vw, 1.85rem);
182
+ font-weight: 650;
183
+ letter-spacing: -0.02em;
184
+ line-height: 1.15;
185
+ margin: 0 0 0.35rem;
186
+ }
187
+
188
+ .section-container-shell__nav {
104
189
  align-items: center;
190
+ display: flex;
105
191
  gap: 0.5rem;
106
192
  overflow-x: auto;
107
- padding: 0.75rem;
193
+ padding: 0.5rem;
108
194
  scrollbar-width: thin;
109
195
  }
110
196
 
111
- .section-container-shell__tabs :deep(.tab-link-item) {
197
+ .section-container-shell__nav :deep(.tab-link-item) {
112
198
  flex: 0 0 auto;
199
+ min-height: 48px;
113
200
  }
114
201
 
115
202
  @media (max-width: 640px) {
116
- .section-container-shell__tabs {
203
+ .section-container-shell__nav {
117
204
  gap: 0.375rem;
118
- padding: 0.5rem;
205
+ margin-inline: -0.25rem;
119
206
  }
120
207
  }
121
208
  </style>
@@ -313,7 +400,7 @@ function renderSubpagesTemplate({
313
400
  "<template>",
314
401
  renderSectionContainerOpenTag({ title, subtitle }),
315
402
  " <template #tabs>",
316
- ` <ShellOutlet target="${normalizedTarget}" default-link-component-token="${SUBPAGES_LINK_COMPONENT_TOKEN}" />`,
403
+ ` <ShellOutlet target="${normalizedTarget}" />`,
317
404
  " </template>"
318
405
  ];
319
406
 
@@ -332,7 +419,7 @@ function renderSubpagesTemplate({
332
419
 
333
420
  function applySubpagesScriptImports(source = "", { sectionContainerComponentImportPath = "" } = {}) {
334
421
  const sourceText = String(source || "");
335
- const scriptBlock = findScriptBlock(sourceText);
422
+ const scriptBlock = findScriptSetupBlock(sourceText);
336
423
 
337
424
  const importLines = [
338
425
  "import ShellOutlet from \"@jskit-ai/shell-web/client/components/ShellOutlet\";",
@@ -341,16 +428,7 @@ function applySubpagesScriptImports(source = "", { sectionContainerComponentImpo
341
428
  ];
342
429
 
343
430
  if (!scriptBlock) {
344
- const scriptSetupBlock = `<script setup>\n${importLines.join("\n")}\n</script>\n`;
345
- let insertionIndex = 0;
346
- for (const match of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
347
- insertionIndex = match.index + String(match[0] || "").length;
348
- }
349
- const separator = insertionIndex > 0 ? "\n" : "";
350
- return {
351
- changed: true,
352
- content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
353
- };
431
+ return insertScriptSetupBlock(sourceText, importLines.join("\n"));
354
432
  }
355
433
 
356
434
  let nextScriptContent = scriptBlock.content;
@@ -1,15 +1,23 @@
1
1
  import path from "node:path";
2
2
  import {
3
+ importFreshModuleFromAbsolutePath,
3
4
  resolveRequiredAppRoot,
4
5
  toPosixPath
5
6
  } from "@jskit-ai/kernel/server/support";
6
- import { normalizeShellOutletTargetId } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
7
+ import {
8
+ PLACEMENT_LAYOUT_CLASSES,
9
+ normalizePlacementOwnerId,
10
+ normalizePlacementTopologyDefinition,
11
+ normalizeSemanticPlacementId,
12
+ normalizeShellOutletTargetId
13
+ } from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
7
14
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
8
15
  import { toCamelCase, toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
9
16
 
10
17
  const DEFAULT_COMPONENT_DIRECTORY = "src/components";
11
18
  const MAIN_CLIENT_PROVIDER_FILE = "packages/main/src/client/providers/MainClientProvider.js";
12
19
  const PLACEMENT_FILE = "src/placement.js";
20
+ const PLACEMENT_TOPOLOGY_FILE = "src/placementTopology.js";
13
21
  const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
14
22
  const SCRIPT_SETUP_ATTRIBUTE_PATTERN = /\bsetup\b/i;
15
23
  const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
@@ -207,38 +215,132 @@ function insertBeforeClassDeclaration(source = "", line = "", { className = "",
207
215
  };
208
216
  }
209
217
 
210
- function findScriptBlock(source = "") {
218
+ function findScriptSetupBlock(source = "") {
211
219
  const sourceText = String(source || "");
212
- let firstMatch = null;
213
-
214
220
  for (const match of sourceText.matchAll(SCRIPT_TAG_PATTERN)) {
215
- if (!firstMatch) {
216
- firstMatch = match;
217
- }
218
-
219
221
  const attributesSource = String(match[1] || "");
220
- if (SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
221
- return Object.freeze({
222
- index: match.index,
223
- source: String(match[0] || ""),
224
- attributesSource,
225
- content: String(match[2] || "")
226
- });
222
+ if (!SCRIPT_SETUP_ATTRIBUTE_PATTERN.test(attributesSource)) {
223
+ continue;
227
224
  }
225
+
226
+ return Object.freeze({
227
+ index: match.index,
228
+ source: String(match[0] || ""),
229
+ attributesSource,
230
+ content: String(match[2] || "")
231
+ });
228
232
  }
229
233
 
230
- if (!firstMatch) {
231
- return null;
234
+ return null;
235
+ }
236
+
237
+ function insertScriptSetupBlock(source = "", content = "") {
238
+ const sourceText = String(source || "");
239
+ const normalizedContent = String(content || "").trim();
240
+ if (!normalizedContent) {
241
+ return {
242
+ changed: false,
243
+ content: sourceText
244
+ };
232
245
  }
233
246
 
234
- return Object.freeze({
235
- index: firstMatch.index,
236
- source: String(firstMatch[0] || ""),
237
- attributesSource: String(firstMatch[1] || ""),
238
- content: String(firstMatch[2] || "")
247
+ const scriptSetupBlock = `<script setup>\n${normalizedContent}\n</script>\n`;
248
+ let insertionIndex = 0;
249
+ for (const match of sourceText.matchAll(/<route\b[^>]*>[\s\S]*?<\/route>\s*/gi)) {
250
+ insertionIndex = match.index + String(match[0] || "").length;
251
+ }
252
+ const separator = insertionIndex > 0 ? "\n" : "";
253
+ return {
254
+ changed: true,
255
+ content: `${sourceText.slice(0, insertionIndex)}${separator}${scriptSetupBlock}\n${sourceText.slice(insertionIndex)}`
256
+ };
257
+ }
258
+
259
+ async function loadPlacementTopologyDefinitionFromPath(topologyPath = {}, { context = "ui-generator topology" } = {}) {
260
+ const moduleNamespace = await importFreshModuleFromAbsolutePath(topologyPath.absolutePath);
261
+ const exported = moduleNamespace?.default;
262
+ const resolved = typeof exported === "function" ? exported() : exported;
263
+ return normalizePlacementTopologyDefinition(resolved, {
264
+ context
239
265
  });
240
266
  }
241
267
 
268
+ function normalizeExpectedTopologyVariantTargets(variantTargets = null) {
269
+ if (!variantTargets || typeof variantTargets !== "object" || Array.isArray(variantTargets)) {
270
+ return null;
271
+ }
272
+
273
+ const targets = {};
274
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
275
+ const target = normalizeShellOutletTargetId(variantTargets[layoutClass]);
276
+ if (target) {
277
+ targets[layoutClass] = target;
278
+ }
279
+ }
280
+
281
+ return Object.keys(targets).length > 0 ? Object.freeze(targets) : null;
282
+ }
283
+
284
+ function describeTopologyVariantTargets(variantTargets = {}) {
285
+ return PLACEMENT_LAYOUT_CLASSES
286
+ .map((layoutClass) => {
287
+ const target = normalizeShellOutletTargetId(variantTargets?.[layoutClass]);
288
+ return target ? `${layoutClass}:${target}` : "";
289
+ })
290
+ .filter(Boolean)
291
+ .join(", ");
292
+ }
293
+
294
+ function placementMatchesExpectedVariantTargets(placement = {}, expectedVariantTargets = null) {
295
+ if (!expectedVariantTargets) {
296
+ return true;
297
+ }
298
+
299
+ const variants = placement?.variants && typeof placement.variants === "object" ? placement.variants : {};
300
+ return Object.entries(expectedVariantTargets).every(([layoutClass, expectedTarget]) =>
301
+ normalizeShellOutletTargetId(variants?.[layoutClass]?.outlet) === expectedTarget
302
+ );
303
+ }
304
+
305
+ async function appendTopologyBlockIfPlacementMissing({
306
+ topologyPath = {},
307
+ source = "",
308
+ marker = "",
309
+ block = "",
310
+ placementId = "",
311
+ owner = "",
312
+ variantTargets = null,
313
+ context = "ui-generator topology"
314
+ } = {}) {
315
+ const normalizedPlacementId = normalizeSemanticPlacementId(placementId);
316
+ const normalizedOwner = normalizePlacementOwnerId(owner);
317
+ if (!normalizedPlacementId) {
318
+ throw new Error(`${context} requires semantic placement id in "area.slot" format.`);
319
+ }
320
+
321
+ const topology = await loadPlacementTopologyDefinitionFromPath(topologyPath, { context });
322
+ const existingPlacement = (Array.isArray(topology.placements) ? topology.placements : []).find(
323
+ (placement) => placement.id === normalizedPlacementId && (placement.owner || "") === normalizedOwner
324
+ );
325
+ if (existingPlacement) {
326
+ const expectedVariantTargets = normalizeExpectedTopologyVariantTargets(variantTargets);
327
+ if (!placementMatchesExpectedVariantTargets(existingPlacement, expectedVariantTargets)) {
328
+ const ownerLabel = normalizedOwner ? ` for owner "${normalizedOwner}"` : "";
329
+ throw new Error(
330
+ `${context} semantic placement "${normalizedPlacementId}"${ownerLabel} already exists with different outlet mapping. ` +
331
+ `Existing: ${describeTopologyVariantTargets(existingPlacement.variants)}. ` +
332
+ `Requested: ${describeTopologyVariantTargets(expectedVariantTargets)}.`
333
+ );
334
+ }
335
+ return {
336
+ changed: false,
337
+ content: String(source || "")
338
+ };
339
+ }
340
+
341
+ return appendBlockIfMarkerMissing(source, marker, block);
342
+ }
343
+
242
344
  function parseTagAttributes(attributesSource = "") {
243
345
  const attributes = {};
244
346
  const source = String(attributesSource || "");
@@ -266,6 +368,7 @@ export {
266
368
  DEFAULT_COMPONENT_DIRECTORY,
267
369
  MAIN_CLIENT_PROVIDER_FILE,
268
370
  PLACEMENT_FILE,
371
+ PLACEMENT_TOPOLOGY_FILE,
269
372
  toKebabCase,
270
373
  toPascalCase,
271
374
  requireOption,
@@ -277,7 +380,9 @@ export {
277
380
  appendBlockIfMarkerMissing,
278
381
  insertImportIfMissing,
279
382
  insertBeforeClassDeclaration,
280
- findScriptBlock,
383
+ findScriptSetupBlock,
384
+ insertScriptSetupBlock,
385
+ appendTopologyBlockIfPlacementMissing,
281
386
  parseTagAttributes,
282
387
  indentBlock
283
388
  };