@jskit-ai/ui-generator 0.1.4 → 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.
package/README.md CHANGED
@@ -46,28 +46,128 @@ Generate a route container page with nested outlet (for embedded sub-pages):
46
46
  npx jskit generate @jskit-ai/ui-generator container --name "Practice" --surface admin
47
47
  ```
48
48
 
49
+ Generate a route container with explicit dynamic route path:
50
+
51
+ ```bash
52
+ npx jskit generate @jskit-ai/ui-generator container --name "Contact" --surface admin --directory-prefix contacts --route-path "[contactId]"
53
+ ```
54
+
55
+ Add a shell menu entry for that container (optional):
56
+
57
+ ```bash
58
+ npx jskit generate @jskit-ai/ui-generator container --name "Practice" --surface admin --placement shell-layout:primary-menu
59
+ ```
60
+
61
+ Inject an inline outlet into an existing Vue page/component:
62
+
63
+ ```bash
64
+ npx jskit generate @jskit-ai/ui-generator outlet --file src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue --host contact-view
65
+ ```
66
+
67
+ Show generator and subcommand help:
68
+
69
+ ```bash
70
+ npx jskit generate @jskit-ai/ui-generator help
71
+ npx jskit generate @jskit-ai/ui-generator outlet help
72
+ npx jskit generate @jskit-ai/ui-generator outlet
73
+ ```
74
+
49
75
  ## Commands
50
76
 
51
77
  - `page`: `--name --surface [--directory-prefix] [--placement]`
52
78
  - `element`: `--name --surface [--path] [--placement]`
53
- - `container`: `--name --surface [--directory-prefix] [--placement]`
79
+ - `container`: `--name --surface [--directory-prefix] [--route-path] [--placement]`
80
+ - `outlet`: `--file --host [--position] [--mode]`
81
+
82
+ `page` also supports:
83
+
84
+ - `--placement-component-token` to override the placement component token.
85
+ - `--placement-to` to set explicit `props.to` in the generated placement block.
86
+ - if `--placement-to` is omitted and `--directory-prefix` includes a `(nestedChildren)` route group, `props.to` is auto-set to `./<page-slug>`.
54
87
 
55
88
  ## Container Workflow
56
89
 
57
90
  - `container` creates app-owned scaffolding:
58
91
  - `src/components/SectionContainerShell.vue` (shared container shell with responsive tab row)
59
- - `src/components/SectionShellTabLinkItem.vue` (tab link item token component)
60
- - `packages/main/src/client/providers/MainClientProvider.js` registration for `local.main.ui.section-shell.tab-link-item`
61
- - `<route>.vue` as a thin wrapper around `SectionContainerShell` + `<RouterView />`
92
+ - `src/components/TabLinkItem.vue` (tab link item token component)
93
+ - `packages/main/src/client/providers/MainClientProvider.js` registration for `local.main.ui.tab-link-item`
94
+ - `<route>.vue` as a thin wrapper around `SectionContainerShell` + `<RouterView />`, with route meta outlet declaration at `meta.jskit.placements.outlets`
95
+ - no shell menu placement is added unless `--placement` is explicitly provided
62
96
  - Generate CRUD pages into that container using `@jskit-ai/crud-ui-generator` with:
63
97
  - `--container <route-slug>`
64
98
  - `--route-path <resource-slug>`
65
99
  - optional `--placement` override (default becomes `<container>:sub-pages` for list pages)
66
100
 
101
+ ## Inline Outlet Workflow
102
+
103
+ - `outlet` patches an app-owned Vue SFC by adding:
104
+ - `import ShellOutlet from "@jskit-ai/shell-web/client/components/ShellOutlet";`
105
+ - `<ShellOutlet host="<host>" position="<position>" />` in template
106
+ - optional `<RouterView />` (when `--mode routed`, only if one does not already exist in the file)
107
+ - `--mode` supports:
108
+ - `routed` (default): insert `RouterView` if missing
109
+ - `outlet-only`: insert only `ShellOutlet`
110
+
111
+ ## End-to-End Example: Embed Pets CRUD in Contact View
112
+
113
+ Goal: render pets CRUD pages inside `contacts/[contactId]/index.vue` using a routed outlet and tab-style placement links.
114
+
115
+ 1. Inject a routed outlet into the contact page:
116
+
117
+ ```bash
118
+ npx jskit generate ui-generator outlet \
119
+ --file src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/index.vue \
120
+ --host contact-view \
121
+ --position sub-pages \
122
+ --mode routed
123
+ ```
124
+
125
+ What each option does:
126
+
127
+ - `--file`: target Vue SFC to patch.
128
+ - `--host`: outlet host namespace (used later by placements).
129
+ - `--position`: outlet position key under that host.
130
+ - `--mode routed`: ensures `<RouterView />` exists so nested pages render inline.
131
+
132
+ 2. Generate pets CRUD pages under the nested-children group and place a tab link into that outlet:
133
+
134
+ ```bash
135
+ npx jskit generate crud-ui-generator \
136
+ --namespace pets \
137
+ --surface admin \
138
+ --operations list,view,new,edit \
139
+ --resource-file packages/pets/src/shared/petResource.js \
140
+ --directory-prefix "contacts/[contactId]/(nestedChildren)" \
141
+ --placement contact-view:sub-pages \
142
+ --placement-component-token local.main.ui.tab-link-item \
143
+ --placement-to ./pets \
144
+ --id-param petId
145
+ ```
146
+
147
+ What each option does:
148
+
149
+ - `--namespace pets`: CRUD namespace for generated UI artifacts.
150
+ - `--surface admin`: generate pages under admin surface routes.
151
+ - `--operations list,view,new,edit`: generate full CRUD page set.
152
+ - `--resource-file`: resource contract used to scaffold fields/forms.
153
+ - `--directory-prefix "contacts/[contactId]/(nestedChildren)"`: place generated routes under the contact context, in a route-group folder that does not appear in URL.
154
+ - `--placement contact-view:sub-pages`: append a placement targeting the outlet created in step 1.
155
+ - `--placement-component-token local.main.ui.tab-link-item`: render placement as a tab link component.
156
+ - `--placement-to ./pets`: tab link resolves relative to current contact route (for example `/contacts/538779/pets`).
157
+ - `--id-param petId`: dynamic route parameter for view/edit pages.
158
+
159
+ Expected result:
160
+
161
+ - Contact page keeps its own route and renders nested pets pages inline via `RouterView`.
162
+ - Pets routes are generated under `contacts/[contactId]/(nestedChildren)/pets/...`.
163
+ - URL remains clean (`(nestedChildren)` is not part of URL).
164
+ - A placement entry is added so the pets tab appears in `contact-view:sub-pages`.
165
+
67
166
  ## Placement Notes
68
167
 
69
168
  - `--placement` expects `host:position`.
70
169
  - Targets come from:
71
170
  - app-declared `<ShellOutlet host="..." position="..." />` in `src/**/*.vue`
171
+ - app route meta `meta.jskit.placements.outlets` declarations in `src/**/*.vue`
72
172
  - installed package metadata `metadata.ui.placements.outlets`
73
173
  - If `--placement` is omitted, the app default outlet is used.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/ui-generator",
4
- version: "0.1.4",
4
+ version: "0.1.6",
5
5
  kind: "generator",
6
6
  description: "Generate app-local non-CRUD UI pages and outlet elements.",
7
7
  options: {
@@ -33,12 +33,64 @@ export default Object.freeze({
33
33
  promptLabel: "Page directory prefix",
34
34
  promptHint: "Optional subpath under the selected surface pages root (example: crm or ops/team-a)."
35
35
  },
36
+ "route-path": {
37
+ required: false,
38
+ inputType: "text",
39
+ defaultValue: "",
40
+ promptLabel: "Route path",
41
+ promptHint:
42
+ "Optional explicit container route path (example: contact-tools or contacts/[contactId]). Defaults to --name."
43
+ },
36
44
  placement: {
37
45
  required: false,
38
46
  inputType: "text",
39
47
  defaultValue: "",
40
48
  promptLabel: "Placement target",
41
49
  promptHint: "Optional host:position target (defaults to app ShellOutlet default target)."
50
+ },
51
+ "placement-component-token": {
52
+ required: false,
53
+ inputType: "text",
54
+ defaultValue: "",
55
+ promptLabel: "Placement component token",
56
+ promptHint:
57
+ "Optional component token override for generated menu placement (example: local.main.ui.tab-link-item)."
58
+ },
59
+ "placement-to": {
60
+ required: false,
61
+ inputType: "text",
62
+ defaultValue: "",
63
+ promptLabel: "Placement to",
64
+ promptHint:
65
+ "Optional explicit props.to value for generated menu placement (example: ./notes). If omitted and directory-prefix includes a nestedChildren route group, defaults to ./<page-slug>."
66
+ },
67
+ file: {
68
+ required: false,
69
+ inputType: "text",
70
+ defaultValue: "",
71
+ promptLabel: "Target Vue file",
72
+ promptHint: "Vue SFC path relative to app root (used by outlet subcommand)."
73
+ },
74
+ host: {
75
+ required: false,
76
+ inputType: "text",
77
+ defaultValue: "",
78
+ promptLabel: "Outlet host",
79
+ promptHint: "ShellOutlet host value to inject into target file."
80
+ },
81
+ position: {
82
+ required: false,
83
+ inputType: "text",
84
+ defaultValue: "sub-pages",
85
+ promptLabel: "Outlet position",
86
+ promptHint: "ShellOutlet position value to inject into target file."
87
+ },
88
+ mode: {
89
+ required: false,
90
+ inputType: "text",
91
+ defaultValue: "routed",
92
+ promptLabel: "Outlet mode",
93
+ promptHint: "routed | outlet-only (routed injects RouterView when missing)."
42
94
  }
43
95
  },
44
96
  dependsOn: [],
@@ -57,13 +109,28 @@ export default Object.freeze({
57
109
  metadata: {
58
110
  generatorPrimarySubcommand: "page",
59
111
  generatorSubcommands: {
112
+ page: {
113
+ description: "Scaffold a non-CRUD page and add a menu placement entry.",
114
+ optionNames: ["name", "surface", "directory-prefix", "placement", "placement-component-token", "placement-to"]
115
+ },
60
116
  element: {
61
117
  entrypoint: "src/server/subcommands/element.js",
62
- export: "runGeneratorSubcommand"
118
+ export: "runGeneratorSubcommand",
119
+ description: "Scaffold a reusable UI element component and register a placement.",
120
+ optionNames: ["name", "surface", "path", "placement"]
63
121
  },
64
122
  container: {
65
123
  entrypoint: "src/server/subcommands/container.js",
66
- export: "runGeneratorSubcommand"
124
+ export: "runGeneratorSubcommand",
125
+ description: "Scaffold a routed section container page with a tab outlet. Adds a menu entry only when --placement is passed.",
126
+ optionNames: ["name", "surface", "directory-prefix", "route-path", "path", "placement"]
127
+ },
128
+ outlet: {
129
+ entrypoint: "src/server/subcommands/outlet.js",
130
+ export: "runGeneratorSubcommand",
131
+ description: "Inject a ShellOutlet block into an existing Vue page/component.",
132
+ optionNames: ["file", "host", "position", "mode"],
133
+ requiredOptionNames: ["file", "host"]
67
134
  }
68
135
  },
69
136
  apiSummary: {
@@ -82,7 +149,7 @@ export default Object.freeze({
82
149
  mutations: {
83
150
  dependencies: {
84
151
  runtime: {
85
- "@jskit-ai/users-web": "0.1.35"
152
+ "@jskit-ai/users-web": "0.1.37"
86
153
  },
87
154
  dev: {}
88
155
  },
@@ -107,7 +174,7 @@ export default Object.freeze({
107
174
  position: "bottom",
108
175
  skipIfContains: "jskit:ui-generator.page.menu:${option:surface|lower}:${option:directory-prefix|path}:${option:name|path}",
109
176
  value:
110
- "\n// jskit:ui-generator.page.menu:${option:surface|lower}:${option:directory-prefix|path}:${option:name|path}\n{\n addPlacement({\n id: \"ui-generator.page.${option:name|kebab}.menu\",\n host: \"__JSKIT_UI_MENU_PLACEMENT_HOST__\",\n position: \"__JSKIT_UI_MENU_PLACEMENT_POSITION__\",\n surfaces: [\"${option:surface|lower}\"],\n order: 155,\n componentToken: \"users.web.shell.surface-aware-menu-link-item\",\n props: {\n label: \"${option:name|trim}\",\n surface: \"${option:surface|lower}\",\n workspaceSuffix: \"/${option:directory-prefix|pathprefix}${option:name|path}\",\n nonWorkspaceSuffix: \"/${option:directory-prefix|pathprefix}${option:name|path}\"\n },\n when: ({ auth }) => Boolean(auth?.authenticated)\n });\n}\n",
177
+ "\n// jskit:ui-generator.page.menu:${option:surface|lower}:${option:directory-prefix|path}:${option:name|path}\n{\n addPlacement({\n id: \"ui-generator.page.${option:name|kebab}.menu\",\n host: \"__JSKIT_UI_MENU_PLACEMENT_HOST__\",\n position: \"__JSKIT_UI_MENU_PLACEMENT_POSITION__\",\n surfaces: [\"${option:surface|lower}\"],\n order: 155,\n componentToken: \"__JSKIT_UI_MENU_COMPONENT_TOKEN__\",\n props: {\n label: \"${option:name|trim}\",\n surface: \"${option:surface|lower}\",\n workspaceSuffix: \"__JSKIT_UI_MENU_WORKSPACE_SUFFIX__\",\n nonWorkspaceSuffix: \"__JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__\",\n__JSKIT_UI_MENU_TO_PROP_LINE__ },\n when: ({ auth }) => Boolean(auth?.authenticated)\n });\n}\n",
111
178
  reason: "Append generated UI page menu placement.",
112
179
  category: "ui-generator",
113
180
  id: "ui-generator-page-placement-menu-${option:name|snake}",
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@jskit-ai/ui-generator",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
7
7
  },
8
8
  "dependencies": {
9
- "@jskit-ai/kernel": "0.1.21"
9
+ "@jskit-ai/kernel": "0.1.23"
10
10
  },
11
11
  "exports": {
12
12
  "./server/buildTemplateContext": "./src/server/buildTemplateContext.js"
@@ -1,6 +1,118 @@
1
1
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
2
  import { resolveShellOutletPlacementTargetFromApp } from "@jskit-ai/kernel/server/support";
3
3
 
4
+ const DEFAULT_MENU_COMPONENT_TOKEN = "users.web.shell.surface-aware-menu-link-item";
5
+ const NESTED_CHILDREN_GROUPS = new Set(["nestedchildren", "nested-children"]);
6
+
7
+ function splitTextIntoWords(value = "") {
8
+ const normalized = String(value || "")
9
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
10
+ .replace(/[^A-Za-z0-9]+/g, " ")
11
+ .trim();
12
+ if (!normalized) {
13
+ return [];
14
+ }
15
+ return normalized
16
+ .split(/\s+/)
17
+ .map((entry) => entry.toLowerCase())
18
+ .filter(Boolean);
19
+ }
20
+
21
+ function wordsToKebab(words = []) {
22
+ return (Array.isArray(words) ? words : [])
23
+ .map((entry) => String(entry || "").toLowerCase())
24
+ .filter(Boolean)
25
+ .join("-");
26
+ }
27
+
28
+ function normalizePathValue(value = "") {
29
+ return String(value || "")
30
+ .replaceAll("\\", "/")
31
+ .split("/")
32
+ .map((segment) => {
33
+ const normalizedSegment = normalizeText(segment);
34
+ if (!normalizedSegment) {
35
+ return "";
36
+ }
37
+ if (/^\[[^\]]+\]$/.test(normalizedSegment)) {
38
+ return normalizedSegment;
39
+ }
40
+ const routeGroupMatch = /^\(([^()]+)\)$/.exec(normalizedSegment);
41
+ if (routeGroupMatch) {
42
+ const routeGroupName = wordsToKebab(splitTextIntoWords(routeGroupMatch[1]));
43
+ return routeGroupName ? `(${routeGroupName})` : "";
44
+ }
45
+ return wordsToKebab(splitTextIntoWords(normalizedSegment));
46
+ })
47
+ .filter(Boolean)
48
+ .join("/");
49
+ }
50
+
51
+ function splitPathSegments(value = "") {
52
+ return normalizePathValue(value)
53
+ .split("/")
54
+ .map((entry) => normalizeText(entry))
55
+ .filter(Boolean);
56
+ }
57
+
58
+ function isRouteGroupSegment(value = "") {
59
+ const source = normalizeText(value);
60
+ return source.startsWith("(") && source.endsWith(")");
61
+ }
62
+
63
+ function isNestedChildrenRouteGroupSegment(value = "") {
64
+ const source = normalizeText(value);
65
+ if (!isRouteGroupSegment(source)) {
66
+ return false;
67
+ }
68
+ const groupName = source.slice(1, -1).trim().toLowerCase();
69
+ return NESTED_CHILDREN_GROUPS.has(groupName);
70
+ }
71
+
72
+ function resolvePlacementUrlSuffix(options = {}) {
73
+ const routeSegments = [
74
+ ...splitPathSegments(options?.["directory-prefix"]),
75
+ ...splitPathSegments(options?.name)
76
+ ].filter((segment) => !isRouteGroupSegment(segment));
77
+ if (routeSegments.length < 1) {
78
+ return "/";
79
+ }
80
+ return `/${routeSegments.join("/")}`;
81
+ }
82
+
83
+ function resolveMenuComponentToken(options = {}) {
84
+ const explicitToken = normalizeText(options?.["placement-component-token"]);
85
+ if (explicitToken) {
86
+ return explicitToken;
87
+ }
88
+ return DEFAULT_MENU_COMPONENT_TOKEN;
89
+ }
90
+
91
+ function resolveAutoRelativePlacementTo(options = {}) {
92
+ const explicitPlacementTo = normalizeText(options?.["placement-to"]);
93
+ if (explicitPlacementTo) {
94
+ return explicitPlacementTo;
95
+ }
96
+ const directorySegments = splitPathSegments(options?.["directory-prefix"]);
97
+ const hasNestedChildrenGroup = directorySegments.some((segment) => isNestedChildrenRouteGroupSegment(segment));
98
+ if (!hasNestedChildrenGroup) {
99
+ return "";
100
+ }
101
+ const pagePath = normalizePathValue(options?.name);
102
+ if (!pagePath) {
103
+ return "";
104
+ }
105
+ return `./${pagePath}`;
106
+ }
107
+
108
+ function resolveMenuToPropLine(options = {}) {
109
+ const placementTo = resolveAutoRelativePlacementTo(options);
110
+ if (!placementTo) {
111
+ return "";
112
+ }
113
+ return ` to: ${JSON.stringify(placementTo)},\n`;
114
+ }
115
+
4
116
  async function buildUiPageTemplateContext({ appRoot, options } = {}) {
5
117
  const placementTarget = await resolveShellOutletPlacementTargetFromApp({
6
118
  appRoot,
@@ -10,7 +122,11 @@ async function buildUiPageTemplateContext({ appRoot, options } = {}) {
10
122
 
11
123
  return {
12
124
  __JSKIT_UI_MENU_PLACEMENT_HOST__: normalizeText(placementTarget?.host),
13
- __JSKIT_UI_MENU_PLACEMENT_POSITION__: normalizeText(placementTarget?.position)
125
+ __JSKIT_UI_MENU_PLACEMENT_POSITION__: normalizeText(placementTarget?.position),
126
+ __JSKIT_UI_MENU_COMPONENT_TOKEN__: resolveMenuComponentToken(options),
127
+ __JSKIT_UI_MENU_WORKSPACE_SUFFIX__: resolvePlacementUrlSuffix(options),
128
+ __JSKIT_UI_MENU_NON_WORKSPACE_SUFFIX__: resolvePlacementUrlSuffix(options),
129
+ __JSKIT_UI_MENU_TO_PROP_LINE__: resolveMenuToPropLine(options)
14
130
  };
15
131
  }
16
132