@jskit-ai/jskit-cli 0.2.74 → 0.2.76

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.
@@ -14,6 +14,9 @@ const REGISTER_MAIN_CLIENT_COMPONENT_PATTERN = /registerMainClientComponent\(\s*
14
14
  const LINK_ITEM_TOKEN_SUFFIX = "link-item";
15
15
  const JSKIT_SCOPE_PREFIX = "@jskit-ai/";
16
16
  const FEATURE_SERVER_GENERATOR_PACKAGE_ID = "@jskit-ai/feature-server-generator";
17
+ const PLACEMENT_LAYOUT_CLASSES = Object.freeze(["compact", "medium", "expanded"]);
18
+ const PLACEMENT_KIND_COMPONENT = "component";
19
+ const PLACEMENT_KIND_LINK = "link";
17
20
  const PROVIDER_SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx"]);
18
21
  const READ_FILE_IGNORE_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EISDIR", "EACCES", "EPERM"]);
19
22
  const READ_DIRECTORY_IGNORE_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
@@ -82,6 +85,331 @@ function isLinkItemToken(token = "") {
82
85
  return String(token || "").trim().toLowerCase().endsWith(LINK_ITEM_TOKEN_SUFFIX);
83
86
  }
84
87
 
88
+ function collectPlacementRendererKinds(placementTarget = {}) {
89
+ const kinds = new Set();
90
+ const variants = ensureObject(placementTarget.variants);
91
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
92
+ const variant = ensureObject(variants[layoutClass]);
93
+ const renderers = ensureObject(variant.renderers);
94
+ for (const kind of Object.keys(renderers)) {
95
+ const normalizedKind = String(kind || "").trim();
96
+ if (normalizedKind) {
97
+ kinds.add(normalizedKind);
98
+ }
99
+ }
100
+ }
101
+ if (kinds.size < 1) {
102
+ kinds.add(PLACEMENT_KIND_COMPONENT);
103
+ }
104
+ return sortStrings([...kinds]);
105
+ }
106
+
107
+ function collectPlacementConcreteOutlets(placementTarget = {}) {
108
+ const outlets = new Set();
109
+ const variants = ensureObject(placementTarget.variants);
110
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
111
+ const variant = ensureObject(variants[layoutClass]);
112
+ const outlet = String(variant.outlet || "").trim();
113
+ if (outlet) {
114
+ outlets.add(outlet);
115
+ }
116
+ }
117
+ return sortStrings([...outlets]);
118
+ }
119
+
120
+ function classifyPlacementTarget(placementTarget = {}) {
121
+ const owner = String(placementTarget.owner || "").trim();
122
+ const kinds = collectPlacementRendererKinds(placementTarget);
123
+ const acceptsLinks = kinds.includes(PLACEMENT_KIND_LINK);
124
+ const acceptsComponents = kinds.includes(PLACEMENT_KIND_COMPONENT);
125
+
126
+ if (acceptsLinks && acceptsComponents) {
127
+ return owner ? "ownerMixed" : "mixed";
128
+ }
129
+ if (acceptsLinks) {
130
+ return owner ? "ownerLinks" : "links";
131
+ }
132
+ return owner ? "ownerComponents" : "components";
133
+ }
134
+
135
+ function createConcreteTargetSourcePathMap(concreteTargets = []) {
136
+ const sourcePathByTarget = new Map();
137
+ for (const concreteTarget of ensureArray(concreteTargets)) {
138
+ const target = String(ensureObject(concreteTarget).id || "").trim();
139
+ const sourcePath = String(ensureObject(concreteTarget).sourcePath || "").trim();
140
+ if (target && sourcePath && !sourcePathByTarget.has(target)) {
141
+ sourcePathByTarget.set(target, sourcePath);
142
+ }
143
+ }
144
+ return sourcePathByTarget;
145
+ }
146
+
147
+ function resolveChildPagePatternFromHostSourcePath(sourcePath = "") {
148
+ const normalizedPath = String(sourcePath || "").replaceAll("\\", "/").trim();
149
+ if (!normalizedPath.startsWith("src/pages/")) {
150
+ return "";
151
+ }
152
+ const pagePath = normalizedPath.slice("src/pages/".length);
153
+ if (pagePath.endsWith("/index.vue")) {
154
+ const hostPath = pagePath.slice(0, -"/index.vue".length);
155
+ return hostPath
156
+ ? `${hostPath}/<page>.vue or ${hostPath}/<page>/index.vue`
157
+ : "<page>.vue or <page>/index.vue";
158
+ }
159
+ if (pagePath.endsWith(".vue")) {
160
+ const hostPath = pagePath.slice(0, -".vue".length);
161
+ return hostPath
162
+ ? `${hostPath}/<page>.vue or ${hostPath}/<page>/index.vue`
163
+ : "<page>.vue or <page>/index.vue";
164
+ }
165
+ return "";
166
+ }
167
+
168
+ function resolveOwnerScopedChildPagePattern(placementTarget = {}, concreteSourcePathByTarget = new Map()) {
169
+ const outlets = collectPlacementConcreteOutlets(placementTarget);
170
+ for (const outlet of outlets) {
171
+ const childPagePattern = resolveChildPagePatternFromHostSourcePath(
172
+ concreteSourcePathByTarget.get(outlet) || ""
173
+ );
174
+ if (childPagePattern) {
175
+ return childPagePattern;
176
+ }
177
+ }
178
+ return "";
179
+ }
180
+
181
+ function createPlacementTargetSummary(placementTarget = {}, color, { concreteSourcePathByTarget = new Map() } = {}) {
182
+ const placementId = String(placementTarget.id || "").trim();
183
+ const owner = String(placementTarget.owner || "").trim();
184
+ const ownerLabel = owner ? color.dim(` [owner:${owner}]`) : "";
185
+ const defaultLabel = placementTarget.default === true ? color.installed(" (default)") : "";
186
+ const description = String(placementTarget.description || "").trim();
187
+ const childPagePattern = owner
188
+ ? resolveOwnerScopedChildPagePattern(placementTarget, concreteSourcePathByTarget)
189
+ : "";
190
+ const childPagePatternLabel = childPagePattern ? ` -> ${color.dim(childPagePattern)}` : "";
191
+ const descriptionSuffix = description ? `: ${description}` : "";
192
+ return `- ${color.item(placementId)}${ownerLabel}${defaultLabel}${childPagePatternLabel}${descriptionSuffix}`;
193
+ }
194
+
195
+ function appendPlacementLayoutDetails(lines, placementTarget = {}, color) {
196
+ const variants = ensureObject(placementTarget.variants);
197
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
198
+ const variant = ensureObject(variants[layoutClass]);
199
+ const outlet = String(variant.outlet || "").trim();
200
+ if (outlet) {
201
+ lines.push(` ${layoutClass} -> ${color.dim(outlet)}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ function collectMappedConcreteOutletIds(semanticPlacements = []) {
207
+ const mapped = new Set();
208
+ for (const placementTarget of ensureArray(semanticPlacements)) {
209
+ const variants = ensureObject(placementTarget.variants);
210
+ for (const layoutClass of PLACEMENT_LAYOUT_CLASSES) {
211
+ const variant = ensureObject(variants[layoutClass]);
212
+ const outlet = String(variant.outlet || "").trim();
213
+ if (outlet) {
214
+ mapped.add(outlet);
215
+ }
216
+ }
217
+ }
218
+ return mapped;
219
+ }
220
+
221
+ function resolveUnmappedConcreteTargets({
222
+ semanticPlacements = [],
223
+ concreteTargets = []
224
+ } = {}) {
225
+ const mappedConcreteOutletIds = collectMappedConcreteOutletIds(semanticPlacements);
226
+ return ensureArray(concreteTargets)
227
+ .map((entry) => ensureObject(entry))
228
+ .filter((entry) => {
229
+ const id = String(entry.id || "").trim();
230
+ return id && !mappedConcreteOutletIds.has(id);
231
+ })
232
+ .sort((left, right) => String(left.id || "").localeCompare(String(right.id || "")));
233
+ }
234
+
235
+ function formatPlacementGuidanceLine(line = "", color) {
236
+ const guidanceLine = String(line || "").trim();
237
+ const labelMatch = /^([^:]+):\s*(.*)$/u.exec(guidanceLine);
238
+ if (!labelMatch) {
239
+ return ` ${color.dim(guidanceLine)}`;
240
+ }
241
+ const label = String(labelMatch[1] || "").trim();
242
+ const value = String(labelMatch[2] || "").trim();
243
+ const renderedLabel = color.installed(`${label}:`);
244
+ const renderedValue = value ? ` ${color.provider(value)}` : "";
245
+ return ` ${renderedLabel}${renderedValue}`;
246
+ }
247
+
248
+ function appendPlacementGroup(lines, {
249
+ color,
250
+ title = "",
251
+ description = "",
252
+ guidance = [],
253
+ targets = [],
254
+ concreteSourcePathByTarget = new Map(),
255
+ showLayoutDetails = false
256
+ } = {}) {
257
+ const placementTargets = ensureArray(targets);
258
+ if (placementTargets.length < 1) {
259
+ return;
260
+ }
261
+ if (lines.length > 1) {
262
+ lines.push("");
263
+ }
264
+ lines.push(color.heading(title));
265
+ if (description) {
266
+ lines.push(description);
267
+ }
268
+ const guidanceLines = ensureArray(guidance).map((value) => String(value || "").trim()).filter(Boolean);
269
+ for (const guidanceLine of guidanceLines) {
270
+ lines.push(formatPlacementGuidanceLine(guidanceLine, color));
271
+ }
272
+ for (const placementTarget of placementTargets) {
273
+ lines.push(createPlacementTargetSummary(placementTarget, color, { concreteSourcePathByTarget }));
274
+ if (showLayoutDetails) {
275
+ appendPlacementLayoutDetails(lines, placementTarget, color);
276
+ }
277
+ }
278
+ }
279
+
280
+ function appendUnmappedConcreteTargetWarnings(lines, {
281
+ color,
282
+ concreteTargets = []
283
+ } = {}) {
284
+ const targets = ensureArray(concreteTargets);
285
+ if (targets.length < 1) {
286
+ return;
287
+ }
288
+
289
+ if (lines.length > 1) {
290
+ lines.push("");
291
+ }
292
+ lines.push(color.heading("Unmapped concrete outlets:"));
293
+ lines.push(
294
+ "These concrete ShellOutlet recipients are discoverable but are not reached by semantic topology. Add a semantic mapping or keep them private/internal."
295
+ );
296
+ lines.push(
297
+ color.dim(
298
+ "Format: npx jskit generate ui-generator topology --placement <area.slot> --kind <link|component> --compact-target <host:position> --medium-target <host:position> --expanded-target <host:position>"
299
+ )
300
+ );
301
+ for (const placementTarget of targets) {
302
+ const placementId = String(placementTarget.id || "").trim();
303
+ const sourcePath = String(placementTarget.sourcePath || "").trim();
304
+ const sourceLabel = sourcePath ? ` ${color.dim(`[${sourcePath}]`)}` : "";
305
+ lines.push(`- ${color.item(placementId)}${sourceLabel}`);
306
+ }
307
+ }
308
+
309
+ function appendSemanticPlacementGroups(lines, {
310
+ color,
311
+ semanticPlacements = [],
312
+ concreteTargets = [],
313
+ showLayoutDetails = false
314
+ } = {}) {
315
+ const concreteSourcePathByTarget = createConcreteTargetSourcePathMap(concreteTargets);
316
+ const groupedTargets = {
317
+ links: [],
318
+ ownerLinks: [],
319
+ components: [],
320
+ ownerComponents: [],
321
+ mixed: [],
322
+ ownerMixed: []
323
+ };
324
+
325
+ for (const placementTarget of ensureArray(semanticPlacements)) {
326
+ const groupKey = classifyPlacementTarget(placementTarget);
327
+ if (groupedTargets[groupKey]) {
328
+ groupedTargets[groupKey].push(placementTarget);
329
+ }
330
+ }
331
+
332
+ appendPlacementGroup(lines, {
333
+ color,
334
+ title: "Navigation link placements",
335
+ guidance: [
336
+ 'Format: npx jskit generate ui-generator page <page-file> --name "Label" --link-placement <placement>',
337
+ 'Example: npx jskit generate ui-generator page admin/reports/index.vue --name "Reports" --link-placement admin.tools-menu'
338
+ ],
339
+ targets: groupedTargets.links,
340
+ concreteSourcePathByTarget,
341
+ showLayoutDetails
342
+ });
343
+
344
+ appendPlacementGroup(lines, {
345
+ color,
346
+ title: "Owner-scoped navigation link placements",
347
+ guidance: [
348
+ 'Format: npx jskit generate ui-generator page <host-path>/<page>.vue --name "Label"',
349
+ 'Alternative: npx jskit generate ui-generator page <host-path>/<page>/index.vue --name "Label"',
350
+ 'Example: npx jskit generate ui-generator page home/settings/profile.vue --name "Profile"'
351
+ ],
352
+ targets: groupedTargets.ownerLinks,
353
+ concreteSourcePathByTarget,
354
+ showLayoutDetails
355
+ });
356
+
357
+ appendPlacementGroup(lines, {
358
+ color,
359
+ title: "Component, widget, and section placements",
360
+ guidance: [
361
+ 'Format: npx jskit generate ui-generator placed-element --name "Widget Name" --placement <placement>',
362
+ 'Example: npx jskit generate ui-generator placed-element --name "Connection Status" --placement shell.status'
363
+ ],
364
+ targets: groupedTargets.components,
365
+ concreteSourcePathByTarget,
366
+ showLayoutDetails
367
+ });
368
+
369
+ appendPlacementGroup(lines, {
370
+ color,
371
+ title: "Owner-scoped component and section placements",
372
+ guidance: [
373
+ 'Format: npx jskit generate ui-generator placed-element --name "Section Name" --placement <placement> --owner <owner>',
374
+ 'Example: npx jskit generate ui-generator placed-element --name "Security Section" --placement settings.sections --owner account-settings'
375
+ ],
376
+ targets: groupedTargets.ownerComponents,
377
+ concreteSourcePathByTarget,
378
+ showLayoutDetails
379
+ });
380
+
381
+ appendPlacementGroup(lines, {
382
+ color,
383
+ title: "Mixed-kind placements",
384
+ description: "These topology entries accept more than one semantic kind. Choose the generator by what you are adding.",
385
+ guidance: [
386
+ 'Link format: npx jskit generate ui-generator page <page-file> --name "Label" --link-placement <placement>',
387
+ 'Component format: npx jskit generate ui-generator placed-element --name "Widget Name" --placement <placement>',
388
+ 'Link example: npx jskit generate ui-generator page admin/reports/index.vue --name "Reports" --link-placement <placement>',
389
+ 'Component example: npx jskit generate ui-generator placed-element --name "Ops Panel" --placement <placement>'
390
+ ],
391
+ targets: groupedTargets.mixed,
392
+ concreteSourcePathByTarget,
393
+ showLayoutDetails
394
+ });
395
+
396
+ appendPlacementGroup(lines, {
397
+ color,
398
+ title: "Owner-scoped mixed-kind placements",
399
+ description: "These owner-scoped entries accept more than one semantic kind. Include the owner when adding manual placement entries.",
400
+ guidance: [
401
+ 'Link format: npx jskit generate ui-generator page <host-path>/<page>.vue --name "Label"',
402
+ 'Link alternative: npx jskit generate ui-generator page <host-path>/<page>/index.vue --name "Label"',
403
+ 'Component format: npx jskit generate ui-generator placed-element --name "Widget Name" --placement <placement> --owner <owner>',
404
+ 'Link example: npx jskit generate ui-generator page home/settings/profile.vue --name "Profile"',
405
+ 'Component example: npx jskit generate ui-generator placed-element --name "Security Section" --placement <placement> --owner <owner>'
406
+ ],
407
+ targets: groupedTargets.ownerMixed,
408
+ concreteSourcePathByTarget,
409
+ showLayoutDetails
410
+ });
411
+ }
412
+
85
413
  async function readFileIfExists(filePath = "") {
86
414
  try {
87
415
  return await readFile(filePath, "utf8");
@@ -177,6 +505,8 @@ function createListCommands(ctx = {}) {
177
505
  loadBundleRegistry,
178
506
  loadAppLocalPackageRegistry,
179
507
  resolveInstalledNodeModulePackageEntry,
508
+ discoverPlacementTopologyFromApp,
509
+ discoverShellOutletSourcePathsFromApp,
180
510
  discoverShellOutletTargetsFromApp,
181
511
  normalizePlacementContributions,
182
512
  resolvePackageKind
@@ -435,18 +765,66 @@ function createListCommands(ctx = {}) {
435
765
 
436
766
  async function commandListPlacements({ options, cwd, stdout }) {
437
767
  const appRoot = await resolveAppRootFromCwd(cwd);
438
- const discoveredPlacements = await discoverShellOutletTargetsFromApp({
439
- appRoot,
440
- sourceRoot: "src"
441
- });
442
- const placementTargets = ensureArray(discoveredPlacements.targets)
768
+ const showConcreteOnly = options.concrete === true && options.all !== true;
769
+ const showConcrete = options.concrete === true || options.all === true;
770
+ const showSemantic = showConcreteOnly !== true;
771
+ const showLayoutDetails = options.details === true;
772
+ const shouldLookupHostPaths = showSemantic;
773
+ const discoveredTopology = await discoverPlacementTopologyFromApp({ appRoot });
774
+ const semanticPlacements = ensureArray(discoveredTopology.placements)
775
+ .map((entry) => ensureObject(entry))
776
+ .filter((entry) => String(entry.id || "").trim())
777
+ .sort((left, right) => {
778
+ const idCompare = String(left.id || "").localeCompare(String(right.id || ""));
779
+ if (idCompare !== 0) {
780
+ return idCompare;
781
+ }
782
+ return String(left.owner || "").localeCompare(String(right.owner || ""));
783
+ });
784
+ let discoveredConcrete = { targets: [] };
785
+ if (showConcrete) {
786
+ discoveredConcrete = await discoverShellOutletTargetsFromApp({
787
+ appRoot,
788
+ sourceRoot: "src"
789
+ });
790
+ } else if (shouldLookupHostPaths) {
791
+ discoveredConcrete = await discoverShellOutletSourcePathsFromApp({
792
+ appRoot,
793
+ sourceRoot: "src"
794
+ });
795
+ }
796
+ const concreteTargets = ensureArray(discoveredConcrete.targets)
443
797
  .map((entry) => ensureObject(entry))
444
798
  .filter((entry) => String(entry.id || "").trim())
445
799
  .sort((left, right) => String(left.id || "").localeCompare(String(right.id || "")));
800
+ const unmappedConcreteTargets = showSemantic
801
+ ? resolveUnmappedConcreteTargets({
802
+ semanticPlacements,
803
+ concreteTargets
804
+ })
805
+ : [];
446
806
 
447
807
  if (options.json) {
448
808
  const payload = {
449
- placements: placementTargets.map((placementTarget) => ({
809
+ placements: showSemantic
810
+ ? semanticPlacements.map((placementTarget) => ({
811
+ target: String(placementTarget.id || "").trim(),
812
+ owner: String(placementTarget.owner || "").trim(),
813
+ default: placementTarget.default === true,
814
+ description: String(placementTarget.description || "").trim(),
815
+ surfaces: ensureArray(placementTarget.surfaces).map((entry) => String(entry || "").trim()).filter(Boolean),
816
+ variants: ensureObject(placementTarget.variants),
817
+ sourcePath: String(placementTarget.sourcePath || "").trim()
818
+ }))
819
+ : [],
820
+ concretePlacements: showConcrete
821
+ ? concreteTargets.map((placementTarget) => ({
822
+ target: String(placementTarget.id || "").trim(),
823
+ default: placementTarget.default === true,
824
+ sourcePath: String(placementTarget.sourcePath || "").trim()
825
+ }))
826
+ : [],
827
+ unmappedConcretePlacements: unmappedConcreteTargets.map((placementTarget) => ({
450
828
  target: String(placementTarget.id || "").trim(),
451
829
  default: placementTarget.default === true,
452
830
  sourcePath: String(placementTarget.sourcePath || "").trim()
@@ -457,17 +835,42 @@ function createListCommands(ctx = {}) {
457
835
  }
458
836
 
459
837
  const color = createColorFormatter(stdout);
460
- const lines = [color.heading("Available placements:")];
461
- if (placementTargets.length < 1) {
462
- lines.push("- none");
463
- } else {
464
- for (const placementTarget of placementTargets) {
465
- const placementId = String(placementTarget.id || "").trim();
466
- const sourcePath = String(placementTarget.sourcePath || "").trim();
467
- const isDefault = placementTarget.default === true;
468
- const defaultLabel = isDefault ? color.installed(" (default)") : "";
469
- const sourceLabel = sourcePath ? ` ${color.dim(`[${sourcePath}]`)}` : "";
470
- lines.push(`- ${color.item(placementId)}${defaultLabel}${sourceLabel}`);
838
+ const lines = [];
839
+ if (showSemantic) {
840
+ lines.push(color.heading("Available placements:"));
841
+ lines.push("Semantic placement targets are the stable IDs you should author against. Use --concrete only for low-level ShellOutlet recipients.");
842
+ if (semanticPlacements.length < 1) {
843
+ lines.push("- none");
844
+ } else {
845
+ appendSemanticPlacementGroups(lines, {
846
+ color,
847
+ semanticPlacements,
848
+ concreteTargets,
849
+ showLayoutDetails
850
+ });
851
+ }
852
+ appendUnmappedConcreteTargetWarnings(lines, {
853
+ color,
854
+ concreteTargets: unmappedConcreteTargets
855
+ });
856
+ }
857
+
858
+ if (showConcrete) {
859
+ if (lines.length > 0) {
860
+ lines.push("");
861
+ }
862
+ lines.push(color.heading("Available concrete outlets:"));
863
+ if (concreteTargets.length < 1) {
864
+ lines.push("- none");
865
+ } else {
866
+ for (const placementTarget of concreteTargets) {
867
+ const placementId = String(placementTarget.id || "").trim();
868
+ const sourcePath = String(placementTarget.sourcePath || "").trim();
869
+ const isDefault = placementTarget.default === true;
870
+ const defaultLabel = isDefault ? color.installed(" (default)") : "";
871
+ const sourceLabel = sourcePath ? ` ${color.dim(`[${sourcePath}]`)}` : "";
872
+ lines.push(`- ${color.item(placementId)}${defaultLabel}${sourceLabel}`);
873
+ }
471
874
  }
472
875
  }
473
876