@loomweaver/cli 0.7.2

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.
Files changed (3) hide show
  1. package/README.md +51 -0
  2. package/dist/main.mjs +2526 -0
  3. package/package.json +33 -0
package/dist/main.mjs ADDED
@@ -0,0 +1,2526 @@
1
+ #!/usr/bin/env node
2
+
3
+ // ../devkit/src/lib/generate/generate.ts
4
+ function isUnsafePath(path) {
5
+ return path.length === 0 || path.startsWith("/") || path.includes("\\") || path.split("/").includes("..");
6
+ }
7
+ function generate(recipe, input) {
8
+ const files = recipe.build(input);
9
+ const unsafe = Object.keys(files).find(isUnsafePath);
10
+ if (unsafe !== void 0) {
11
+ throw new Error(`Recipe "${recipe.id}" produced an unsafe path: "${unsafe}".`);
12
+ }
13
+ return files;
14
+ }
15
+
16
+ // ../devkit/src/lib/generate/casing.ts
17
+ function isKebabId(value) {
18
+ return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(value);
19
+ }
20
+ function words(value) {
21
+ return value.split(/[-_\s]+/).filter(Boolean);
22
+ }
23
+ function toPascalCase(value) {
24
+ return words(value).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
25
+ }
26
+ function toCamelCase(value) {
27
+ const pascal = toPascalCase(value);
28
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
29
+ }
30
+ function toTitleCase(value) {
31
+ return words(value).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
32
+ }
33
+
34
+ // ../devkit/src/lib/validate/manifest.ts
35
+ var KNOWN_CAPABILITIES = [
36
+ "contributions",
37
+ "ui",
38
+ "host",
39
+ "navigation",
40
+ "session",
41
+ "theme",
42
+ "automation"
43
+ ];
44
+ function validateManifest(manifest2, known = KNOWN_CAPABILITIES) {
45
+ const findings = [];
46
+ if (typeof manifest2.id !== "string" || !isKebabId(manifest2.id)) {
47
+ findings.push({
48
+ level: "error",
49
+ code: "manifest.id",
50
+ message: `Plugin id must be a kebab-case string; got ${JSON.stringify(manifest2.id)}.`,
51
+ path: "manifest.id"
52
+ });
53
+ }
54
+ if (manifest2.name !== void 0 && typeof manifest2.name !== "string") {
55
+ findings.push({
56
+ level: "error",
57
+ code: "manifest.name",
58
+ message: "Plugin name must be a string when provided.",
59
+ path: "manifest.name"
60
+ });
61
+ }
62
+ findings.push(...validateCapabilities(manifest2.capabilities, known));
63
+ return findings;
64
+ }
65
+ function validateCapabilities(capabilities, known) {
66
+ if (capabilities === void 0) {
67
+ return [];
68
+ }
69
+ if (!Array.isArray(capabilities)) {
70
+ return [
71
+ {
72
+ level: "error",
73
+ code: "manifest.capabilities",
74
+ message: "capabilities must be an array when provided.",
75
+ path: "manifest.capabilities"
76
+ }
77
+ ];
78
+ }
79
+ const findings = [];
80
+ const seen = /* @__PURE__ */ new Set();
81
+ for (const capability of capabilities) {
82
+ if (typeof capability !== "string" || !known.includes(capability)) {
83
+ findings.push({
84
+ level: "error",
85
+ code: "manifest.capability.unknown",
86
+ message: `Unknown capability ${JSON.stringify(capability)}. Known: ${known.join(", ")}.`,
87
+ path: "manifest.capabilities"
88
+ });
89
+ continue;
90
+ }
91
+ if (seen.has(capability)) {
92
+ findings.push({
93
+ level: "warning",
94
+ code: "manifest.capability.duplicate",
95
+ message: `Duplicate capability "${capability}".`,
96
+ path: "manifest.capabilities"
97
+ });
98
+ }
99
+ seen.add(capability);
100
+ }
101
+ return findings;
102
+ }
103
+
104
+ // ../devkit/src/recipes/angular-weaver/weaver-i18n.ts
105
+ function i18nBundle(w) {
106
+ const bundle = { title: w.name };
107
+ if (w.features.container) {
108
+ bundle["canvas"] = "Canvas";
109
+ bundle["details"] = "Details";
110
+ }
111
+ if (w.features.command) {
112
+ bundle["action"] = `${w.name} action`;
113
+ bundle["actionDescription"] = `Shows a short ${w.name} message.`;
114
+ }
115
+ if (w.features.about) bundle["about"] = `About ${w.name}`;
116
+ if (w.features.settings)
117
+ bundle["settings"] = { title: w.name, enabled: "Enabled", note: "Note" };
118
+ return bundle;
119
+ }
120
+ function i18nFile(w) {
121
+ return JSON.stringify(i18nBundle(w), null, 2) + "\n";
122
+ }
123
+
124
+ // ../devkit/src/recipes/angular-weaver/recipe.ts
125
+ var DEFAULT_MENU_SLOT = "content/tab/context";
126
+ var SURFACE_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M8 9h8M8 13h8M8 17h5"/></svg>';
127
+ function accessLiteral(spec) {
128
+ if (spec === "authenticated") return "{ authenticated: true }";
129
+ if (spec === "anonymous") return "{ authenticated: false }";
130
+ if (spec.startsWith("role:")) {
131
+ const role = spec.slice("role:".length);
132
+ if (!role)
133
+ throw new Error('Access "role:" needs a role name, e.g. "role:admin".');
134
+ if (/['\\]/.test(role))
135
+ throw new Error(
136
+ `Access role "${role}" must not contain quotes or backslashes.`
137
+ );
138
+ return `{ anyRole: ['${role}'] }`;
139
+ }
140
+ throw new Error(
141
+ `Unknown access "${spec}". Use "authenticated", "anonymous" or "role:<name>".`
142
+ );
143
+ }
144
+ function resolveMenuSlot(menu) {
145
+ if (menu === true) {
146
+ return DEFAULT_MENU_SLOT;
147
+ }
148
+ return typeof menu === "string" && menu.length ? menu : void 0;
149
+ }
150
+ var PLATFORM_BOUND_CHORD_TOKENS = [
151
+ "cmd",
152
+ "command",
153
+ "ctrl",
154
+ "control",
155
+ "meta"
156
+ ];
157
+ function assertPlatformNeutralChord(shortcut) {
158
+ const tokens = shortcut.toLowerCase().split("+").map((token) => token.trim());
159
+ const bound = tokens.find(
160
+ (token) => PLATFORM_BOUND_CHORD_TOKENS.includes(token)
161
+ );
162
+ if (bound) {
163
+ throw new Error(
164
+ `Shortcut "${shortcut}" binds the platform-specific "${bound}" key. Use the neutral 'mod' token (e.g. 'mod+shift+k') \u2014 the host renders it as \u2318 on macOS and Ctrl elsewhere.`
165
+ );
166
+ }
167
+ }
168
+ function resolveFeatures(id, input) {
169
+ const menuSlot = resolveMenuSlot(input?.menu);
170
+ const barItem = Boolean(input?.barItem);
171
+ const hasShortcut = Boolean(input?.shortcut?.trim());
172
+ if (hasShortcut) {
173
+ assertPlatformNeutralChord(input?.shortcut?.trim() ?? "");
174
+ }
175
+ const instanceable = Boolean(input?.instanceable);
176
+ const container = Boolean(input?.container);
177
+ if (instanceable && container) {
178
+ throw new Error(
179
+ 'A surface cannot be both a container and instanceable: a container tab holds its own ":id" and is therefore routable, while named instances exist only for a docked, non-routable surface. Pick one.'
180
+ );
181
+ }
182
+ return {
183
+ command: Boolean(input?.command) || menuSlot !== void 0 || barItem || hasShortcut,
184
+ menuSlot,
185
+ settings: Boolean(input?.settings),
186
+ access: input?.access ? accessLiteral(input.access) : void 0,
187
+ shortcut: input?.shortcut?.trim() || `mod+shift+${id.charAt(0)}`,
188
+ barItem,
189
+ about: Boolean(input?.about),
190
+ instanceable,
191
+ container,
192
+ spec: input?.spec !== false
193
+ };
194
+ }
195
+ function deriveCapabilities(features) {
196
+ const set = /* @__PURE__ */ new Set(["contributions", "navigation"]);
197
+ if (features.command) set.add("ui");
198
+ if (features.about) {
199
+ set.add("ui");
200
+ set.add("host");
201
+ }
202
+ return KNOWN_CAPABILITIES.filter((capability) => set.has(capability));
203
+ }
204
+ function resolveWeaverInput(input) {
205
+ if (!isKebabId(input.id)) {
206
+ throw new Error(
207
+ `Weaver id must be kebab-case (e.g. "notes"); got "${input.id}".`
208
+ );
209
+ }
210
+ const features = resolveFeatures(input.id, input.features);
211
+ const capabilities = input.capabilities?.length ? [...input.capabilities] : deriveCapabilities(features);
212
+ return {
213
+ id: input.id,
214
+ name: input.name?.trim() || toTitleCase(input.id),
215
+ className: toPascalCase(input.id),
216
+ propertyName: toCamelCase(input.id),
217
+ capabilities,
218
+ features,
219
+ prefix: input.prefix?.trim() || "lw",
220
+ importPath: input.importPath?.trim() || `@loomweaver/${input.id}-weaver`
221
+ };
222
+ }
223
+ function capabilityItems(capabilities) {
224
+ return capabilities.map((capability) => `'${capability}'`).join(", ");
225
+ }
226
+ var CONTAINER_EXAMPLE_ID = "example";
227
+ function containerChildIds(w) {
228
+ return [`${w.id}.canvas`, `${w.id}.details`];
229
+ }
230
+ function containerSurfaceBlock(w) {
231
+ const children = containerChildIds(w).map((id) => `'${id}'`).join(", ");
232
+ const lines = [
233
+ " ctx.registerSurface({",
234
+ ` id: '${w.id}',`,
235
+ ` title: '${w.id}.title',`,
236
+ ` icon: '${w.id}',`,
237
+ ` routable: { path: '${w.id}/:id' },`,
238
+ " container: {",
239
+ ` children: [${children}],`,
240
+ ` initial: [${children}],`,
241
+ " },"
242
+ ];
243
+ if (w.features.access) lines.push(` access: ${w.features.access},`);
244
+ lines.push(" });");
245
+ for (const [suffix, className] of [
246
+ ["canvas", `${w.className}CanvasView`],
247
+ ["details", `${w.className}DetailsView`]
248
+ ]) {
249
+ lines.push(
250
+ " ctx.registerSurface({",
251
+ ` id: '${w.id}.${suffix}',`,
252
+ ` title: '${w.id}.${suffix}',`,
253
+ " docks: [],",
254
+ ` component: ${className},`,
255
+ " });"
256
+ );
257
+ }
258
+ return lines.join("\n");
259
+ }
260
+ function surfaceBlock(w) {
261
+ if (w.features.container) {
262
+ return containerSurfaceBlock(w);
263
+ }
264
+ const lines = [
265
+ " ctx.registerSurface({",
266
+ ` id: '${w.id}',`,
267
+ ` title: '${w.id}.title',`,
268
+ ` icon: '${w.id}',`,
269
+ ` component: ${w.className}View,`
270
+ ];
271
+ if (w.features.instanceable) {
272
+ lines.push(" docks: ['primary'],", " instanceable: true,");
273
+ } else {
274
+ lines.push(` routable: { path: '${w.id}' },`);
275
+ }
276
+ if (w.features.access) lines.push(` access: ${w.features.access},`);
277
+ lines.push(" });");
278
+ return lines.join("\n");
279
+ }
280
+ function railTarget(w) {
281
+ if (w.features.container) {
282
+ return `ctx.navigateContent('${w.id}/${CONTAINER_EXAMPLE_ID}')`;
283
+ }
284
+ if (w.features.instanceable) {
285
+ return `ctx.revealSurface('${w.id}')`;
286
+ }
287
+ return `ctx.navigateContent('${w.id}')`;
288
+ }
289
+ function railBlock(w) {
290
+ const lines = [
291
+ " ctx.registerRailItem({",
292
+ ` id: '${w.id}.rail',`,
293
+ " rail: 'primary',",
294
+ ` icon: '${w.id}',`,
295
+ ` title: '${w.id}.title',`,
296
+ ` run: () => ${railTarget(w)},`
297
+ ];
298
+ if (w.features.access) lines.push(` access: ${w.features.access},`);
299
+ lines.push(" });");
300
+ return lines.join("\n");
301
+ }
302
+ function commandBlock(w) {
303
+ return [
304
+ " ctx.registerCommand({",
305
+ ` id: '${w.id}.hello',`,
306
+ ` title: '${w.id}.action',`,
307
+ ` description: '${w.id}.actionDescription',`,
308
+ ` shortcut: '${w.features.shortcut}',`,
309
+ " callable: true,",
310
+ ` run: () => ctx.ui.toast({ message: '${w.id}.action', kind: 'info' }),`,
311
+ " });"
312
+ ].join("\n");
313
+ }
314
+ function barItemBlock(w) {
315
+ return [
316
+ " ctx.registerBarItem({",
317
+ ` id: '${w.id}.bar',`,
318
+ " bar: 'status-bar',",
319
+ " slot: 'end',",
320
+ ` icon: '${w.id}',`,
321
+ ` tooltip: '${w.id}.action',`,
322
+ ` command: '${w.id}.hello',`,
323
+ " });"
324
+ ].join("\n");
325
+ }
326
+ function aboutCommandBlock(w) {
327
+ return [
328
+ " ctx.registerCommand({",
329
+ ` id: '${w.id}.about',`,
330
+ ` title: '${w.id}.about',`,
331
+ ` run: () => ctx.ui.open(${w.className}AboutDialog, { data: ctx.host, title: '${w.id}.title' }),`,
332
+ " });"
333
+ ].join("\n");
334
+ }
335
+ function aboutRailBlock(w) {
336
+ return [
337
+ " ctx.registerRailItem({",
338
+ ` id: '${w.id}.rail.about',`,
339
+ " rail: 'primary',",
340
+ " anchor: 'bottom',",
341
+ ` icon: '${w.id}',`,
342
+ ` title: '${w.id}.about',`,
343
+ ` command: '${w.id}.about',`,
344
+ " });"
345
+ ].join("\n");
346
+ }
347
+ function menuBlock(w) {
348
+ return [
349
+ " ctx.registerMenuItem({",
350
+ ` menu: '${w.features.menuSlot}',`,
351
+ ` command: '${w.id}.hello',`,
352
+ " });"
353
+ ].join("\n");
354
+ }
355
+ function settingsBlock(w) {
356
+ return [
357
+ " ctx.registerSettingsSection({",
358
+ ` id: '${w.id}',`,
359
+ ` title: '${w.id}.settings.title',`,
360
+ " rows: [",
361
+ " {",
362
+ " id: 'enabled',",
363
+ ` label: '${w.id}.settings.enabled',`,
364
+ ` control: { kind: 'toggle', value: () => ${w.propertyName}Enabled(), set: (v) => ${w.propertyName}Enabled.set(v) },`,
365
+ " },",
366
+ " {",
367
+ " id: 'note',",
368
+ ` label: '${w.id}.settings.note',`,
369
+ ` control: { kind: 'text', value: () => ${w.propertyName}Note(), set: (v) => ${w.propertyName}Note.set(v) },`,
370
+ " },",
371
+ " ],",
372
+ " });"
373
+ ].join("\n");
374
+ }
375
+ function pluginFile(w) {
376
+ const imports = ["import { Plugin } from '@loomweaver/plugin-sdk';"];
377
+ if (w.features.container) {
378
+ imports.push(
379
+ `import { ${w.className}CanvasView } from '../views/${w.id}-canvas-view';`,
380
+ `import { ${w.className}DetailsView } from '../views/${w.id}-details-view';`
381
+ );
382
+ } else {
383
+ imports.push(`import { ${w.className}View } from '../views/${w.id}-view';`);
384
+ }
385
+ if (w.features.about) {
386
+ imports.push(
387
+ `import { ${w.className}AboutDialog } from '../dialogs/${w.id}-about-dialog';`
388
+ );
389
+ }
390
+ if (w.features.settings) {
391
+ imports.unshift("import { signal } from '@angular/core';");
392
+ }
393
+ const consts = [`const icon =
394
+ '${SURFACE_ICON}';`];
395
+ if (w.features.settings) {
396
+ consts.push(
397
+ `const ${w.propertyName}Enabled = signal(true);`,
398
+ `const ${w.propertyName}Note = signal('');`
399
+ );
400
+ }
401
+ const body = [` ctx.contributeIcons({ '${w.id}': icon });`];
402
+ if (w.features.command) body.push(commandBlock(w));
403
+ if (w.features.about) body.push(aboutCommandBlock(w));
404
+ body.push(surfaceBlock(w), railBlock(w));
405
+ if (w.features.about) body.push(aboutRailBlock(w));
406
+ if (w.features.barItem) body.push(barItemBlock(w));
407
+ if (w.features.menuSlot) body.push(menuBlock(w));
408
+ if (w.features.settings) body.push(settingsBlock(w));
409
+ return `${imports.join("\n")}
410
+
411
+ ${consts.join("\n")}
412
+
413
+ export const ${w.propertyName}Plugin: Plugin = {
414
+ manifest: {
415
+ id: '${w.id}',
416
+ name: '${w.name}',
417
+ capabilities: [${capabilityItems(w.capabilities)}],
418
+ },
419
+ activate(ctx) {
420
+ ${body.join("\n")}
421
+ },
422
+ };
423
+ `;
424
+ }
425
+ function aboutDialogFile(w) {
426
+ return `import { Component, inject } from '@angular/core';
427
+ import { DialogRef, PluginHost } from '@loomweaver/plugin-sdk';
428
+
429
+ @Component({
430
+ selector: '${w.prefix}-${w.id}-about-dialog',
431
+ templateUrl: './${w.id}-about-dialog.html',
432
+ })
433
+ export class ${w.className}AboutDialog {
434
+ protected readonly host = inject(DialogRef).data as PluginHost;
435
+ }
436
+ `;
437
+ }
438
+ function aboutDialogTemplateFile(w) {
439
+ return `<div class="flex flex-col items-center gap-2 text-center">
440
+ <h2 class="text-lg font-semibold text-content">${w.name}</h2>
441
+ <span class="text-xs text-content-faint tabular-nums">v{{ host.version() }}</span>
442
+ </div>
443
+ `;
444
+ }
445
+ function indexFile(w) {
446
+ return `export { ${w.propertyName}Plugin } from './lib/plugin/${w.id}.plugin';
447
+ `;
448
+ }
449
+ function viewFile(w) {
450
+ if (!w.features.instanceable) {
451
+ return `import { Component } from '@angular/core';
452
+
453
+ @Component({
454
+ selector: '${w.prefix}-${w.id}-view',
455
+ templateUrl: './${w.id}-view.html',
456
+ })
457
+ export class ${w.className}View {}
458
+ `;
459
+ }
460
+ return `import { Component, computed, inject } from '@angular/core';
461
+ import { VIEW_STATE, type ViewState } from '@loomweaver/plugin-sdk';
462
+
463
+ interface ${w.className}State {
464
+ readonly sort: 'natural' | 'alpha';
465
+ }
466
+
467
+ const FRESH: ${w.className}State = { sort: 'natural' };
468
+
469
+ @Component({
470
+ selector: '${w.prefix}-${w.id}-view',
471
+ templateUrl: './${w.id}-view.html',
472
+ })
473
+ export class ${w.className}View {
474
+ private readonly viewState = inject(VIEW_STATE) as ViewState<${w.className}State>;
475
+
476
+ // undefined = a fresh instance, so apply your own default.
477
+ private readonly state = computed(() => this.viewState.value() ?? FRESH);
478
+
479
+ protected readonly sort = computed(() => this.state().sort);
480
+
481
+ protected toggleSort(): void {
482
+ // set() replaces the whole blob, so spread what is already there.
483
+ this.viewState.set({
484
+ ...this.state(),
485
+ sort: this.sort() === 'alpha' ? 'natural' : 'alpha',
486
+ });
487
+ }
488
+ }
489
+ `;
490
+ }
491
+ function viewTemplateFile(w) {
492
+ const stateNote = w.features.instanceable ? ` <button type="button" class="lw-btn lw-btn--default self-start" (click)="toggleSort()">
493
+ Sort: {{ sort() }}
494
+ </button>
495
+ <p class="text-sm text-content-faint">
496
+ That choice lives in this instance's <code class="text-content">VIEW_STATE</code>, so it survives a
497
+ tab switch, a collapsed sidebar and a reload. Anything that must not be lost belongs there.
498
+ </p>
499
+ ` : "";
500
+ return `<div class="mx-auto flex max-w-2xl flex-col gap-4 p-6">
501
+ <h2 class="text-lg font-semibold text-content">${w.name}</h2>
502
+ <p class="text-sm text-content-faint">
503
+ Your new weaver surface. Register more surfaces, commands, rail items and menus on
504
+ <code class="text-content">ctx</code> inside the plugin's
505
+ <code class="text-content">activate</code>.
506
+ </p>
507
+ ${stateNote}</div>
508
+ `;
509
+ }
510
+ function childViewFile(w, suffix, className) {
511
+ return `import { Component, inject } from '@angular/core';
512
+ import { ActivatedRoute } from '@angular/router';
513
+
514
+ @Component({
515
+ selector: '${w.prefix}-${w.id}-${suffix}-view',
516
+ templateUrl: './${w.id}-${suffix}-view.html',
517
+ })
518
+ export class ${className} {
519
+ private readonly route = inject(ActivatedRoute, { optional: true });
520
+
521
+ protected readonly instanceId =
522
+ this.route?.snapshot.paramMap.get('id') ?? '\u2014';
523
+ }
524
+ `;
525
+ }
526
+ function childViewTemplateFile(w, heading) {
527
+ return `<div class="flex h-full flex-col gap-3 p-4">
528
+ <h3 class="text-sm font-semibold text-content">${heading}</h3>
529
+ <p class="text-sm text-content-faint">
530
+ Scoped to <code class="text-content">{{ instanceId }}</code> \u2014 the id of the container tab this
531
+ pane lives in. Every open container tab has its own inner tree, so two of them show two
532
+ different ids side by side.
533
+ </p>
534
+ </div>
535
+ `;
536
+ }
537
+ function specFile(w) {
538
+ return `import { ${w.propertyName}Plugin } from './${w.id}.plugin';
539
+
540
+ describe('${w.propertyName}Plugin', () => {
541
+ it('declares its manifest', () => {
542
+ expect(${w.propertyName}Plugin.manifest.id).toBe('${w.id}');
543
+ expect(${w.propertyName}Plugin.manifest.capabilities).toContain('contributions');
544
+ });
545
+ });
546
+ `;
547
+ }
548
+ function surfaceNotes(w) {
549
+ const railNote = "Rail and bar items reference region ids (`primary`, `status`) that must exist in your layout.";
550
+ if (w.features.container) {
551
+ return [
552
+ `The surface is a **container**: it is routable at \`/${w.id}/:id\`, and its tab holds a`,
553
+ "nested pane tree of child surfaces. The host draws the inner tabs, splits and drag targets; this",
554
+ "weaver only declares which children it offers.",
555
+ "",
556
+ `- \`children\` is what the inner "new tab" picker lists \u2014 the host access-gates it for you.`,
557
+ "- `initial` is what a freshly opened container tab starts with.",
558
+ `- The children declare \`docks: []\`. That is the container-only convention: they are never seeded`,
559
+ " into a sidebar, they exist solely inside this container.",
560
+ `- Each child reads the container's \`:id\` from an injected \`ActivatedRoute\` \u2014 the host supplies a`,
561
+ " synthetic one, so a child needs no knowledge of where it is mounted. Two open container tabs are",
562
+ " two independent trees, each scoped to its own id.",
563
+ `- The inner tree is **sealed**: a child cannot be dragged out, and nothing can be dragged in. It`,
564
+ " travels with the tab, including into a sidebar or a pop-out window.",
565
+ "",
566
+ `The rail item opens the fixed id \`${CONTAINER_EXAMPLE_ID}\`. Replace that with whatever the user`,
567
+ "actually picked \u2014 a document, a run, a project.",
568
+ "",
569
+ railNote
570
+ ];
571
+ }
572
+ if (w.features.instanceable) {
573
+ return [
574
+ `The surface is **docked** into the \`primary\` region and marked \`instanceable\`, so the host shows a`,
575
+ "switcher for saving, naming, renaming and deleting several configurations of it, each with its own",
576
+ "`VIEW_STATE` blob.",
577
+ "",
578
+ "It is deliberately **not** routable. Named instances exist only for a docked surface \u2014 a routable",
579
+ "one holds the URL pane instead, and the host drops `instanceable` on that path. The rail item",
580
+ "therefore reveals the surface (`ctx.revealSurface`) rather than navigating to a URL, which focuses",
581
+ "it wherever the user has since moved it.",
582
+ "",
583
+ "The generated view already uses that blob for its sort order, because a hidden surface is destroyed",
584
+ "as soon as it is clean: state kept in a component field survives neither a tab switch nor",
585
+ "a collapsed sidebar, and never survived a reload. The rule is *evictable = reload-safe* \u2014 anything",
586
+ "that must not be lost goes through `VIEW_STATE`, and `set()` replaces the whole blob, so spread it.",
587
+ "",
588
+ railNote
589
+ ];
590
+ }
591
+ return [
592
+ `The surface is routable at \`/${w.id}\`; ${railNote.charAt(0).toLowerCase()}${railNote.slice(1)}`,
593
+ "",
594
+ "A routable surface has **no `VIEW_STATE` handle** \u2014 injecting the token there throws. It owns a URL,",
595
+ "so anything shareable (a filter, the active sub-tab) belongs in route params or `subRoutes`, where it",
596
+ "survives a deep link too; unsaved edits are `DirtySurface`, and an instance that is expensive to",
597
+ "rebuild declares `retain: 'always'`. Generate with `--instanceable` for the docked, `VIEW_STATE`",
598
+ "flavour instead."
599
+ ];
600
+ }
601
+ function readmeFile(w) {
602
+ return [
603
+ `# ${w.name} weaver`,
604
+ "",
605
+ `A LoomWeaver weaver (a domain plugin bundle). It consumes only the public \`@loomweaver/plugin-sdk\` contract.`,
606
+ "",
607
+ "## Wire it into a distribution",
608
+ "",
609
+ `1. Add the plugin to \`providePlugins\` in \`src/app/app.config.ts\`. It is **variadic** and`,
610
+ ` returns an array, so spread it:`,
611
+ "",
612
+ " ```ts",
613
+ ` import { ${w.propertyName}Plugin } from '${w.importPath}'; // Nx: the workspace alias; without one, a relative path to this library's src/index.ts`,
614
+ ` ...providePlugins(${w.propertyName}Plugin),`,
615
+ " ```",
616
+ "",
617
+ `2. Grant its capabilities (default-deny) via \`provideCapabilityGrants\`:`,
618
+ "",
619
+ " ```ts",
620
+ ` provideCapabilityGrants({ '${w.id}': [${capabilityItems(w.capabilities)}] });`,
621
+ " ```",
622
+ "",
623
+ `3. Compose its translations with \`provideTranslationNamespaces('${w.id}')\` \u2014 and serve the`,
624
+ ` bundle by adding an assets glob to your application's build target, so the loader can fetch`,
625
+ ` \`/i18n/${w.id}/<lang>.json\` (the Nx generator adds this glob for you):`,
626
+ "",
627
+ " ```json",
628
+ ` { "glob": "**/*.json", "input": "<path to this library>/src/lib/i18n", "output": "i18n/${w.id}" }`,
629
+ " ```",
630
+ "",
631
+ `4. If your application compiles the shell's theme with Tailwind, name this library as a source`,
632
+ ` for it, so the utility classes in these templates are emitted. Tailwind also detects sources`,
633
+ ` by itself, but that depends on where it resolves the project root and on \`.gitignore\`, and`,
634
+ ` what the scaffold names covers the application alone (the Nx generator adds this line for`,
635
+ ` you). Applications scaffolded with \`--styles precompiled\` run no Tailwind and need nothing:`,
636
+ "",
637
+ " ```css",
638
+ ` @source '<path from that stylesheet to this library>/src';`,
639
+ " ```",
640
+ "",
641
+ ...surfaceNotes(w),
642
+ "",
643
+ "## After scaffolding",
644
+ "",
645
+ "- `src/lib/i18n/de.json` starts as a copy of the English strings \u2014 translate it.",
646
+ `- A scaffolded command defaults its shortcut to \`mod+shift+<first letter of the id>\` \u2014 two weavers whose ids share a first letter collide; pass \`--shortcut\` or edit the command.`,
647
+ "- The project is generated **untagged**: Nx tags belong to your `depConstraints`, and inventing",
648
+ " one would fail a lint policy you never opted this project into. If your workspace enforces",
649
+ " module boundaries, give it tags your constraints allow \u2014 `--tags` at generation time, or",
650
+ " `tags` in `project.json` afterwards.",
651
+ ""
652
+ ].join("\n");
653
+ }
654
+ var angularWeaver = {
655
+ id: "angular-weaver",
656
+ build(input) {
657
+ const w = resolveWeaverInput(input);
658
+ const files = {
659
+ "src/index.ts": indexFile(w),
660
+ [`src/lib/plugin/${w.id}.plugin.ts`]: pluginFile(w),
661
+ "src/lib/i18n/en.json": i18nFile(w),
662
+ "src/lib/i18n/de.json": i18nFile(w),
663
+ "README.md": readmeFile(w)
664
+ };
665
+ if (w.features.container) {
666
+ files[`src/lib/views/${w.id}-canvas-view.ts`] = childViewFile(
667
+ w,
668
+ "canvas",
669
+ `${w.className}CanvasView`
670
+ );
671
+ files[`src/lib/views/${w.id}-canvas-view.html`] = childViewTemplateFile(
672
+ w,
673
+ "Canvas"
674
+ );
675
+ files[`src/lib/views/${w.id}-details-view.ts`] = childViewFile(
676
+ w,
677
+ "details",
678
+ `${w.className}DetailsView`
679
+ );
680
+ files[`src/lib/views/${w.id}-details-view.html`] = childViewTemplateFile(
681
+ w,
682
+ "Details"
683
+ );
684
+ } else {
685
+ files[`src/lib/views/${w.id}-view.ts`] = viewFile(w);
686
+ files[`src/lib/views/${w.id}-view.html`] = viewTemplateFile(w);
687
+ }
688
+ if (w.features.about) {
689
+ files[`src/lib/dialogs/${w.id}-about-dialog.ts`] = aboutDialogFile(w);
690
+ files[`src/lib/dialogs/${w.id}-about-dialog.html`] = aboutDialogTemplateFile(w);
691
+ }
692
+ if (w.features.spec) {
693
+ files[`src/lib/plugin/${w.id}.plugin.spec.ts`] = specFile(w);
694
+ }
695
+ return files;
696
+ }
697
+ };
698
+
699
+ // ../devkit/src/recipes/frame-plugin/recipe.ts
700
+ function resolveFramePluginInput(input) {
701
+ if (!isKebabId(input.id)) {
702
+ throw new Error(`Sandbox plugin id must be kebab-case (e.g. "notes"); got "${input.id}".`);
703
+ }
704
+ return { id: input.id, name: input.name?.trim() || toTitleCase(input.id) };
705
+ }
706
+ function pluginHtml(p) {
707
+ return `<!doctype html>
708
+ <html lang="en">
709
+ <head>
710
+ <meta charset="utf-8" />
711
+ <title>${p.name} \u2014 frame plugin (logic)</title>
712
+ </head>
713
+ <body>
714
+ <script src="/frame-kit/penpal.global.js"></script>
715
+ <script src="./plugin.js"></script>
716
+ </body>
717
+ </html>
718
+ `;
719
+ }
720
+ function pluginJs(p) {
721
+ return `(function () {
722
+ const Penpal = globalThis.Penpal;
723
+ const messenger = new Penpal.WindowMessenger({
724
+ remoteWindow: globalThis.parent,
725
+ allowedOrigins: ['*'],
726
+ });
727
+ const connection = Penpal.connect({ messenger });
728
+
729
+ connection.promise
730
+ .then(function (ctx) {
731
+ return Promise.all([
732
+ ctx.toast({ message: '${p.name} ready', kind: 'success', timeoutMs: 4000 }),
733
+ ctx.registerSurface({
734
+ id: '${p.id}.view',
735
+ title: '${p.name}',
736
+ iframe: '/${p.id}/view.html',
737
+ routable: { path: '${p.id}', titleIsLiteral: true },
738
+ }),
739
+ ]);
740
+ })
741
+ .catch(function (error) {
742
+ console.error('[${p.id}] frame plugin failed', error);
743
+ });
744
+ })();
745
+ `;
746
+ }
747
+ function viewHtml(p) {
748
+ return `<!doctype html>
749
+ <html lang="en">
750
+ <head>
751
+ <meta charset="utf-8" />
752
+ <title>${p.name}</title>
753
+ <link rel="stylesheet" href="/frame-kit/lw-frame.css" />
754
+ <style>
755
+ body {
756
+ margin: 0;
757
+ font-family: var(--lw-font-sans, system-ui, sans-serif);
758
+ color: var(--lw-content, #1f2937);
759
+ background: var(--lw-surface, transparent);
760
+ }
761
+ .wrap { max-width: 42rem; margin: 0 auto; padding: 1.5rem; }
762
+ h1 { font-size: 1.125rem; font-weight: 600; }
763
+ p { color: var(--lw-content-faint, #6b7280); }
764
+ </style>
765
+ </head>
766
+ <body>
767
+ <div class="wrap">
768
+ <h1>${p.name}</h1>
769
+ <p>
770
+ Your sandboxed surface. It runs isolated in its own iframe, so its body can be built with
771
+ any framework (React, Vue, Svelte, vanilla). The frame UI kit (served by the distribution
772
+ under <code>/frame-kit/</code>) defines the <code>lw-*</code> element family and
773
+ the <code>.lw-*</code> class contracts; the host pushes its resolved design tokens over RPC.
774
+ </p>
775
+ <p><lw-button variant="primary" size="sm">Kit button</lw-button></p>
776
+ </div>
777
+ <script src="/frame-kit/penpal.global.js"></script>
778
+ <script src="/frame-kit/lw-elements.global.js"></script>
779
+ <script>
780
+ globalThis.Penpal.connect({
781
+ messenger: new globalThis.Penpal.WindowMessenger({
782
+ remoteWindow: globalThis.parent,
783
+ allowedOrigins: ['*'],
784
+ }),
785
+ methods: {
786
+ render: function (state) {
787
+ globalThis.LwFrame.applySurfaceState(state);
788
+ },
789
+ },
790
+ });
791
+ </script>
792
+ </body>
793
+ </html>
794
+ `;
795
+ }
796
+ function readme(p) {
797
+ return [
798
+ `# ${p.name} \u2014 frame plugin`,
799
+ "",
800
+ `A framework-agnostic LoomWeaver plugin: it runs in an isolated \`<iframe sandbox>\` and`,
801
+ `receives \`ctx\` over Penpal RPC through the same default-deny broker a trusted plugin uses.`,
802
+ "",
803
+ "## Serve it + wire it into a distribution",
804
+ "",
805
+ `1. Put these files under the distribution's static dir, e.g. \`public/${p.id}/\`. The plugin`,
806
+ ` references the **frame UI kit** (\`@loomweaver/frame-kit\`) at \`/frame-kit/\` \u2014`,
807
+ ` the distribution serves it via an assets glob (generated distributions already do):`,
808
+ "",
809
+ " ```jsonc",
810
+ ' { "input": "node_modules/@loomweaver/frame-kit/dist", "glob": "**", "output": "frame-kit" }',
811
+ " ```",
812
+ "",
813
+ `2. Register + grant it in the composition root:`,
814
+ "",
815
+ " ```ts",
816
+ ` provideFramePlugins({ id: '${p.id}', entryUrl: '/${p.id}/plugin.html', capabilities: ['contributions', 'ui'] });`,
817
+ ` provideCapabilityGrants({ '${p.id}': ['contributions', 'ui'] });`,
818
+ " ```",
819
+ "",
820
+ `The surface is routable at \`/${p.id}\`. Replace \`view.html\` with your own UI in any framework.`,
821
+ ""
822
+ ].join("\n");
823
+ }
824
+ var framePlugin = {
825
+ id: "frame-plugin",
826
+ build(input) {
827
+ const p = resolveFramePluginInput(input);
828
+ return {
829
+ "plugin.html": pluginHtml(p),
830
+ "plugin.js": pluginJs(p),
831
+ "view.html": viewHtml(p),
832
+ "README.md": readme(p)
833
+ };
834
+ }
835
+ };
836
+
837
+ // ../devkit/src/recipes/shell-regions.ts
838
+ var SHELL_REGIONS = [
839
+ "{ id: 'top-bar', type: 'bar', dock: 'top' }",
840
+ "{ id: 'primary', type: 'rail', dock: 'left' }",
841
+ "{ id: 'left-panel', type: 'panel', dock: 'left' }",
842
+ "{ id: 'right-panel', type: 'panel', dock: 'right' }",
843
+ "{ id: 'main', type: 'content', dock: 'center' }",
844
+ "{ id: 'status-bar', type: 'bar', dock: 'bottom' }"
845
+ ];
846
+ function renderRegions(indent) {
847
+ return SHELL_REGIONS.map((region) => `${indent}${region},`).join("\n");
848
+ }
849
+
850
+ // ../devkit/src/recipes/angular-distribution/logo.ts
851
+ var PLACEHOLDER_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="512" height="512" viewBox="87.5 -53 1129.9 1129.9"><g paint-order="stroke"><path d="m0 0 12.6-10.6-9.2-8-14.5 12.1q-.7.8 1.2 1.5A17 17 0 0 1-2.4-.5Q-1 .7 0 0" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 265.2 368.8)"/><path d="m0 0 19-16c.6-.5.5-1.6-.2-2.3q-2.8-3-7.5-4.5c-1.2-.4-2.8-.1-3.4.4l-17 14.3z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 593.2 644.5)"/><path d="m0 0 5.8-4.9-9.2-8-2.8 2.3q-.9 1 0 2.5l4 7.4Q-1 .7 0 0" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#c59a2f;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 386.3 170)"/><path d="m0 0 12.4-10.4-9.2-8.1L-9.2-8.1Z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#c59a2f;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 608.2 356.6)"/><path d="m0 0 5-4.2c.6-.5.6-1.6 0-2.5l-4-7.4q-1-1.4-2.1-.7l-8 6.7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#c59a2f;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 933.4 630)"/><path d="m0 0 14.6-12.3-9.2-8L-11-6.6c-.6.5-.6 1.6.2 2.4Q-8-1.2-3.3.3C-2.2.7-.6.5 0 0" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 589.5 28.9)"/><path d="m0 0 17-14.3q.8-.8-1.3-1.4a17 17 0 0 1-7.5-4.5c-.7-.8-1.8-1-2.4-.5l-15 12.6Z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 948.5 330.8)"/><path d="m0 0 8.4-7-13.6-12-8.4 7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 514.5 213.2)"/><path d="m0 0 5.7-4.8q1.3-1 .4-2L3-9.5l-8.4 7 3 2.7A2 2 0 0 0 0 0" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 743 48)"/><path d="m0 0 8.4-7-2.2-2q-1-.7-2.3.2L-2-4q-1 1.2-.2 2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 158.6 526.5)"/><path d="m0 0 8.4-7L-5-18.9c-.5-.5-1.6-.4-2.3.2l-5.8 4.8q-1 1-.3 2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 492.8 507)"/><path d="m0 0 5.7-4.8q1.3-1 .4-2l-14.7-13-8.4 7.1L-2.3.3C-1.8.6-.7.5 0 0" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 902.6 182)"/><path d="m0 0 5.9-5Q7-6 6.2-7l-3-2.7-8.7 7.2L-2.4.3Q-1.4.9 0 0" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 1065.8 319.3)"/><path d="m0 0 8.6-7.2-13.6-12-8.6 7.2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 836.9 484.2)"/><path d="m0 0 8.6-7.2-2.2-2q-1-.6-2.4.3l-5.9 5Q-3-3-2.2-2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:#2e96c9;fill-rule:nonzero;opacity:1" transform="matrix(15.6303 0 0 -15.6303 481 797.5)"/><g transform="matrix(15.6303 0 0 -15.6303 877.6 706.6)"><linearGradient id="a" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 64.4 103.4)scale(2.18666 -2.18666)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#c59a2f;stop-opacity:1"/><stop offset="100%" style="stop-color:#614d06;stop-opacity:1"/></linearGradient><path d="m176.7 129.6 2-1.7 9.2 8-2 1.8z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#a);fill-rule:nonzero;opacity:1" transform="translate(-182.3 -132.8)"/></g><g transform="matrix(15.6303 0 0 -15.6303 711.4 567.2)"><linearGradient id="b" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 56.7 106)scale(-2.18726 2.18726)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#c59a2f;stop-opacity:1"/><stop offset="100%" style="stop-color:#614d06;stop-opacity:1"/></linearGradient><path d="m166 138.5 2.1-1.7 9.2 8.1-2 1.7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#b);fill-rule:nonzero;opacity:1" transform="translate(-171.7 -141.7)"/></g><g transform="matrix(15.6303 0 0 -15.6303 532.5 867.7)"><linearGradient id="c" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(45.7 -65.1 251)scale(2.11643 -2.11643)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m155 125.2 8.5-7.2 2 1.8-8.6 7.2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#c);fill-rule:nonzero;opacity:1" transform="translate(-160.2 -122.5)"/></g><g transform="matrix(15.6303 0 0 -15.6303 707.2 713.6)"><linearGradient id="d" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(-134.3 114 30.2)scale(2.11524 -2.11524)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m166.1 135 8.6-7.1 2 1.7-8.6 7.2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#d);fill-rule:nonzero;opacity:1" transform="translate(-171.4 -132.3)"/></g><g transform="matrix(15.6303 0 0 -15.6303 888.2 554.5)"><linearGradient id="e" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(45.7 -77.5 288)scale(2.11554 -2.11554)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m177.7 145.3 8.6-7.3 2 1.8-8.6 7.2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#e);fill-rule:nonzero;opacity:1" transform="translate(-183 -142.5)"/></g><g transform="matrix(15.6303 0 0 -15.6303 1062 401.2)"><linearGradient id="f" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(45.7 -84.1 307.3)scale(-2.11583 2.11583)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m188.8 155 8.6-7.2 2 1.8-8.6 7.2z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#f);fill-rule:nonzero;opacity:1" transform="translate(-194.1 -152.3)"/></g><g transform="matrix(15.6303 0 0 -15.6303 206.5 595.5)"><linearGradient id="g" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(225.7 98.9 40.5)scale(-2.0962 2.0962)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m134.3 142.5 8.2-7 2 1.8-8.3 7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#g);fill-rule:nonzero;opacity:1" transform="translate(-139.4 -139.9)"/></g><g transform="matrix(15.6303 0 0 -15.6303 381 441.4)"><linearGradient id="h" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(-134.3 107 43.3)scale(2.0962 -2.0962)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m145.4 152.3 8.3-6.9 2 1.8-8.3 6.9z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#h);fill-rule:nonzero;opacity:1" transform="translate(-150.5 -149.8)"/></g><g transform="matrix(15.6303 0 0 -15.6303 562 282.3)"><linearGradient id="i" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(225.7 114.5 45.7)scale(-2.0953 2.0953)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m157 162.5 8.2-7 2 1.8-8.2 7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#i);fill-rule:nonzero;opacity:1" transform="translate(-162.1 -160)"/></g><g transform="matrix(15.6303 0 0 -15.6303 736 129)"><linearGradient id="j" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(45.7 -115.1 291)scale(-2.0956 2.0956)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m168.1 172.3 8.3-6.9 2 1.7-8.3 7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#j);fill-rule:nonzero;opacity:1" transform="translate(-173.2 -169.7)"/></g><g transform="matrix(15.6303 0 0 -15.6303 544.8 441.3)"><linearGradient id="k" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 50.4 108)scale(2.18666 -2.18666)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#c59a2f;stop-opacity:1"/><stop offset="100%" style="stop-color:#614d06;stop-opacity:1"/></linearGradient><path d="m155.9 147 2-1.6 9.2 8-2 1.8z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#k);fill-rule:nonzero;opacity:1" transform="translate(-161.5 -150.3)"/></g><g transform="matrix(15.6303 0 0 -15.6303 390.7 294.8)"><linearGradient id="l" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 42.9 110.5)scale(-2.17357 2.17357)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#c59a2f;stop-opacity:1"/><stop offset="100%" style="stop-color:#614d06;stop-opacity:1"/></linearGradient><path d="m145.6 156 2-1.7 9 8-2 1.6z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#l);fill-rule:nonzero;opacity:1" transform="translate(-151.2 -159.1)"/></g><g transform="matrix(15.6303 0 0 -15.6303 375.9 585.7)"><linearGradient id="m" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 46.2 101)scale(-1.9968 1.9968)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m144.7 137.2 1.8-1.5 9.2 8.1-1.8 1.5z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#m);fill-rule:nonzero;opacity:1" transform="translate(-150.2 -140.5)"/></g><g transform="matrix(15.6303 0 0 -15.6303 733.7 274)"><linearGradient id="n" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(-44.3 284 -131.7)scale(1.9968 -1.9968)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m167.6 157.2 1.8-1.5 9.2 8-1.8 1.6z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#n);fill-rule:nonzero;opacity:1" transform="translate(-173.1 -160.5)"/></g><g transform="matrix(15.6303 0 0 -15.6303 893.6 408.2)"><linearGradient id="o" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 61 113.2)scale(1.9962 -1.9962)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m177.8 148.6 1.8-1.5 9.2 8-1.8 1.6z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#o);fill-rule:nonzero;opacity:1" transform="translate(-183.3 -151.9)"/></g><g transform="matrix(15.6303 0 0 -15.6303 535.9 720)"><linearGradient id="p" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(135.7 53.6 98.6)scale(1.9962 -1.9962)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m155 128.6 1.7-1.5 9.2 8.1-1.8 1.5z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#p);fill-rule:nonzero;opacity:1" transform="translate(-160.4 -132)"/></g><g transform="matrix(15.6303 0 0 -15.6303 717.3 421.6)"><linearGradient id="q" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(45.7 -93.5 280)scale(-2.095 2.095)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m167 153.6 8.2-7 2 1.8-8.3 7z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#q);fill-rule:nonzero;opacity:1" transform="translate(-172 -151)"/></g><g transform="matrix(15.6303 0 0 -15.6303 542.1 575.9)"><linearGradient id="r" x1="0" x2="1" y1="0" y2="0" gradientTransform="rotate(45.7 -87 261)scale(2.0956 -2.0956)" gradientUnits="userSpaceOnUse"><stop offset="0%" style="stop-color:#2e96c9;stop-opacity:1"/><stop offset="100%" style="stop-color:#2479a3;stop-opacity:1"/></linearGradient><path d="m155.7 143.8 8.3-7 2 1.8-8.3 6.9z" style="stroke:none;stroke-width:1;stroke-dasharray:none;stroke-linecap:butt;stroke-dashoffset:0;stroke-linejoin:miter;stroke-miterlimit:4;fill:url(#r);fill-rule:nonzero;opacity:1" transform="translate(-160.8 -141.2)"/></g></g></svg>
852
+ `;
853
+
854
+ // ../devkit/src/recipes/angular-distribution/recipe.ts
855
+ var STYLES = ["tailwind", "precompiled"];
856
+ function resolveDistributionInput(input) {
857
+ if (!isKebabId(input.name)) {
858
+ throw new Error(
859
+ `Distribution name must be kebab-case (e.g. "acme-studio"); got "${input.name}".`
860
+ );
861
+ }
862
+ const styles = input.styles ?? "tailwind";
863
+ if (!STYLES.includes(styles)) {
864
+ throw new Error(
865
+ `Unknown styles option "${styles}"; expected one of ${STYLES.join(", ")}.`
866
+ );
867
+ }
868
+ const directory = input.directory === void 0 ? `apps/${input.name}` : input.directory.trim();
869
+ const depth = directory.split("/").filter(Boolean).length;
870
+ return {
871
+ name: input.name,
872
+ title: input.title?.trim() || toTitleCase(input.name),
873
+ nodeModulesFromSrc: `${"../".repeat(depth + 1)}node_modules`,
874
+ withTests: input.withTests !== false,
875
+ styles
876
+ };
877
+ }
878
+ function mainTs() {
879
+ return `import { bootstrapApplication } from '@angular/platform-browser';
880
+ import { appConfig } from './app/app.config';
881
+ import { App } from './app/app';
882
+
883
+ bootstrapApplication(App, appConfig).catch((err) => console.error(err));
884
+ `;
885
+ }
886
+ function appConfigTs(d) {
887
+ return `import { ApplicationConfig } from '@angular/core';
888
+ import {
889
+ provideLayout,
890
+ provideShell,
891
+ provideShellRouter,
892
+ type ShellLayout,
893
+ } from '@loomweaver/shell';
894
+ import { provideProductIdentity } from '@loomweaver/plugin-sdk';
895
+
896
+ /* Which regions exist and where they dock. Contributions target these ids, so a region a
897
+ weaver names but this layout omits renders nothing \u2014 silently. 'primary' (rail) and
898
+ 'status-bar' (bar) are what the scaffolded weaver targets. */
899
+ export const layout: ShellLayout = {
900
+ regions: [
901
+ ${renderRegions(" ")}
902
+ ],
903
+ };
904
+
905
+ /* Everything this product is made of goes in this array: your weavers, their capability
906
+ grants and your branding. The shell arrives with every capability on; switch gestures
907
+ off with provideShellFeatures and drop contributions with provideShell({ omit }).
908
+ See LOOMWEAVER.md. */
909
+ export const appConfig: ApplicationConfig = {
910
+ providers: [
911
+ provideShellRouter(),
912
+ provideShell(),
913
+ provideLayout(layout),
914
+ provideProductIdentity({
915
+ name: '${d.title}',
916
+ tagline: 'Built on LoomWeaver',
917
+ logoUrl: 'logo.svg',
918
+ }),
919
+ ],
920
+ };
921
+ `;
922
+ }
923
+ function appTs() {
924
+ return `import { Component } from '@angular/core';
925
+ import { Shell } from '@loomweaver/shell';
926
+
927
+ @Component({
928
+ selector: 'app-root',
929
+ imports: [Shell],
930
+ templateUrl: './app.html',
931
+ })
932
+ export class App {}
933
+ `;
934
+ }
935
+ function appHtml() {
936
+ return `<lw-shell />
937
+ `;
938
+ }
939
+ function appConfigSpec() {
940
+ return `import { layout } from './app.config';
941
+
942
+ /* A green starting point that pins the one trap the compiler cannot catch: a contribution aimed at
943
+ a region id this layout omits renders nothing, and says nothing. List the ids your weavers target
944
+ here, and this fails the day someone edits the layout instead of failing in the browser. */
945
+ describe('layout', () => {
946
+ it('declares the regions contributions target', () => {
947
+ const ids = layout.regions.map((region) => region.id);
948
+ for (const id of ['primary', 'status-bar', 'main']) {
949
+ expect(ids).toContain(id);
950
+ }
951
+ });
952
+ });
953
+ `;
954
+ }
955
+ function indexHtml(d) {
956
+ return `<!DOCTYPE html>
957
+ <html lang="en">
958
+ <head>
959
+ <meta charset="utf-8" />
960
+ <title>${d.title}</title>
961
+ <base href="/" />
962
+ <meta
963
+ http-equiv="Content-Security-Policy"
964
+ content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'"
965
+ />
966
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
967
+ <meta name="theme-color" content="#2E96C9" />
968
+ <link rel="icon" type="image/svg+xml" href="logo.svg" />
969
+ <link rel="manifest" href="manifest.webmanifest" />
970
+ </head>
971
+ <body>
972
+ <app-root></app-root>
973
+ </body>
974
+ </html>
975
+ `;
976
+ }
977
+ function precompiledCss() {
978
+ return `/* The stylesheet we compiled: the design tokens, the .lw-* class contracts and every utility
979
+ the shell's own templates use. This application needs no Tailwind to build.
980
+
981
+ BRINGING YOUR OWN CSS FRAMEWORK? Import it INTO A CASCADE LAYER. Every rule we ship is layered,
982
+ and unlayered CSS outranks layered CSS whatever its specificity \u2014 so an unlayered Bootstrap
983
+ Reboot (button { border-radius: 0 }) strips the chrome's radii and borders without a fight.
984
+ A @layer statement is one of the few things allowed before @import:
985
+
986
+ @layer vendor;
987
+ @import 'bootstrap/dist/css/bootstrap.css' layer(vendor);
988
+
989
+ Then re-theme by pointing the --lw-* tokens at your framework's variables. For Bootstrap 5.3 the
990
+ whole 29-token mapping is a scaffold:
991
+
992
+ loomweaver theme --name acme --preset bootstrap
993
+ @import './themes/acme.css'; (after the import below) */
994
+
995
+ @import '@loomweaver/shell/styles/shell.css';
996
+ `;
997
+ }
998
+ function tailwindCss(d) {
999
+ return `@import 'tailwindcss';
1000
+
1001
+ /* LoomWeaver design tokens + theme (light/dark, brand colors). Every @import has to precede the
1002
+ other at-rules below: that is what plain CSS requires, and it is what keeps editors from
1003
+ flagging a misplaced @import in a file you did not write. */
1004
+ @import '@loomweaver/shell/styles/theme.css';
1005
+
1006
+ @plugin '@tailwindcss/typography';
1007
+
1008
+ /* Generate the utility classes the shell (and your own components) use. The @source path must
1009
+ reach your workspace's node_modules FROM THIS FILE \u2014 adjust the ../ hops if this project does
1010
+ not sit at that depth, or Tailwind silently emits none of the shell's classes. */
1011
+ @source '${d.nodeModulesFromSrc}/@loomweaver/shell';
1012
+ @source './';
1013
+ `;
1014
+ }
1015
+ function stylesCss(d) {
1016
+ return d.styles === "precompiled" ? precompiledCss() : tailwindCss(d);
1017
+ }
1018
+ function ngswConfig() {
1019
+ return JSON.stringify(
1020
+ {
1021
+ $schema: "./node_modules/@angular/service-worker/config/schema.json",
1022
+ index: "/index.html",
1023
+ assetGroups: [
1024
+ {
1025
+ name: "app",
1026
+ installMode: "prefetch",
1027
+ resources: {
1028
+ files: ["/index.html", "/manifest.webmanifest", "/*.css", "/*.js"]
1029
+ }
1030
+ },
1031
+ {
1032
+ name: "i18n",
1033
+ installMode: "prefetch",
1034
+ updateMode: "prefetch",
1035
+ resources: {
1036
+ files: ["/i18n/**/*.json"]
1037
+ }
1038
+ },
1039
+ {
1040
+ name: "assets",
1041
+ installMode: "lazy",
1042
+ updateMode: "prefetch",
1043
+ resources: {
1044
+ files: [
1045
+ "/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2|ico)"
1046
+ ]
1047
+ }
1048
+ }
1049
+ ]
1050
+ },
1051
+ null,
1052
+ 2
1053
+ );
1054
+ }
1055
+ function manifest(d) {
1056
+ return JSON.stringify(
1057
+ {
1058
+ name: d.title,
1059
+ short_name: d.title,
1060
+ description: `${d.title} \u2014 a LoomWeaver distribution.`,
1061
+ start_url: "/",
1062
+ display: "standalone",
1063
+ background_color: "#ffffff",
1064
+ theme_color: "#2E96C9",
1065
+ icons: [{ src: "logo.svg", type: "image/svg+xml", sizes: "any" }]
1066
+ },
1067
+ null,
1068
+ 2
1069
+ );
1070
+ }
1071
+ function stylesNotes(d) {
1072
+ if (d.styles === "precompiled") {
1073
+ return [
1074
+ "`src/styles.css` imports the stylesheet **we** compiled \u2014 tokens, the `.lw-*` class contracts",
1075
+ "and every utility the shell's own templates use, 67 KB minified and 11 KB over the wire. There",
1076
+ "is nothing to install and nothing to configure: no `tailwindcss`, no `.postcssrc.json`, no",
1077
+ "`@source` paths to miscount.",
1078
+ "",
1079
+ "What you give up is writing Tailwind utilities in *your own* templates. The `--lw-*` tokens stay",
1080
+ "available to any CSS you write, so re-theming stays a token remap rather than a fight.",
1081
+ "",
1082
+ "If you bring a CSS framework of your own, **import it into a cascade layer** \u2014 the file says how,",
1083
+ "and it is the one decision that determines whether the chrome survives the introduction. Prefer",
1084
+ "Tailwind after all? Re-run the scaffold with `--styles tailwind`."
1085
+ ];
1086
+ }
1087
+ return [
1088
+ "`src/styles.css` compiles the shell's source theme with Tailwind 4, which is also what lets you",
1089
+ "write Tailwind utilities in your own templates. It needs two things the scaffold cannot add for",
1090
+ "you \u2014 the packages:",
1091
+ "",
1092
+ "```sh",
1093
+ "npm install -D tailwindcss @tailwindcss/postcss @tailwindcss/typography",
1094
+ "```",
1095
+ "",
1096
+ "and the PostCSS plugin, in a file next to your `package.json`:",
1097
+ "",
1098
+ "```jsonc",
1099
+ "// .postcssrc.json",
1100
+ '{ "plugins": { "@tailwindcss/postcss": {} } }',
1101
+ "```",
1102
+ "",
1103
+ "Use **semantic tokens only** in your own templates (`bg-surface`, `text-content`, `text-brand`,",
1104
+ "`border-border`), never raw palette colours.",
1105
+ "",
1106
+ "**Count the `../` hops in the `@source` line.** It is resolved from the stylesheet, not from the",
1107
+ "workspace root, and the scaffold derived it from where this project sits. Move the project and",
1108
+ "nothing errors \u2014 Tailwind simply emits none of the shell's classes and the app renders unstyled.",
1109
+ "",
1110
+ "Want none of this? Re-run the scaffold with `--styles precompiled` and you get a one-line",
1111
+ "stylesheet that needs no Tailwind at all \u2014 the right choice if your product is themed with",
1112
+ "Bootstrap, Bulma or hand-written CSS."
1113
+ ];
1114
+ }
1115
+ function readme2(d) {
1116
+ return [
1117
+ `# ${d.title} \u2014 a LoomWeaver distribution`,
1118
+ "",
1119
+ `The composition root that assembles the platform into a shippable product. It renders the bare`,
1120
+ `shell out of the box; add your weavers and branding below. Your own README is untouched \u2014 the`,
1121
+ `scaffold writes its notes here so it can never overwrite prose you wrote.`,
1122
+ "",
1123
+ "## Run it",
1124
+ "",
1125
+ "In an Nx workspace (the generator wired the project):",
1126
+ "",
1127
+ "```sh",
1128
+ `nx serve ${d.name}`,
1129
+ "```",
1130
+ "",
1131
+ "Scaffolded over the CLI or MCP, these files are sources without build wiring \u2014 drop them into",
1132
+ "an application you already serve (`ng new`, or an Nx application). They keep Angular's own",
1133
+ "shape: `main.ts` bootstraps `App`, `App` renders `<lw-shell />`, and everything this product is",
1134
+ "made of lives in `app.config.ts`. Nothing of the generated app is deleted. Over an existing",
1135
+ "application `--force` replaces exactly the files above \u2014 all of them bootstrap wiring, none of",
1136
+ "them content you authored. Under Nx it additionally merges the build targets into that",
1137
+ "project's `project.json`, keeping the targets, `implicitDependencies` and tags it already had.",
1138
+ "",
1139
+ "Two leftovers from `ng new` are no longer referenced and can go: `src/app/app.routes.ts` (the",
1140
+ "shell owns content routing via `provideShellRouter()`) and `src/app/app.css`. The generated",
1141
+ "`src/app/app.spec.ts` now fails \u2014 `App` pulls the whole shell into a bare `TestBed` \u2014 so delete",
1142
+ "it and test your own components instead of the composition root.",
1143
+ "",
1144
+ "The project is generated **untagged**: Nx tags belong to your `depConstraints`, and inventing",
1145
+ "one would fail a lint policy you never opted this project into. If your workspace enforces",
1146
+ "module boundaries, give it tags your constraints allow \u2014 `--tags` at generation time, or",
1147
+ "`tags` in `project.json` afterwards.",
1148
+ "",
1149
+ "## Compose weavers + branding (in `src/app/app.config.ts`)",
1150
+ "",
1151
+ "The layout is already there. Adding a weaver is three providers plus its import \u2014 note the",
1152
+ "**spread**, since `providePlugins` is variadic and returns an array:",
1153
+ "",
1154
+ "```ts",
1155
+ "import { providePlugins, provideCapabilityGrants, provideTranslationNamespaces } from '@loomweaver/shell';",
1156
+ "import { notesPlugin } from '@acme/notes-weaver'; // Nx: the workspace alias; otherwise a relative path",
1157
+ "",
1158
+ " provideTranslationNamespaces('notes'),",
1159
+ " provideCapabilityGrants({ notes: ['contributions', 'ui', 'navigation'] }),",
1160
+ " ...providePlugins(notesPlugin),",
1161
+ "```",
1162
+ "",
1163
+ "Grant exactly what the weaver's manifest declares \u2014 the broker is default-deny, so an ungranted",
1164
+ "plugin throws `CapabilityError` instead of quietly doing less. A weaver also needs its",
1165
+ "translations served: add an assets glob for its `src/lib/i18n` under `i18n/<id>` (the Nx",
1166
+ "generator does this for you).",
1167
+ "",
1168
+ "`public/logo.svg` is the LoomWeaver mark, dropped in as a placeholder so the top bar renders",
1169
+ "something from the first run instead of a broken image. Replace it with your own \u2014 any square",
1170
+ "image will do; `logoUrl` resolves against your served root. `tagline` is a",
1171
+ "translation key that falls back to rendering itself, so the literal above works but makes",
1172
+ "Transloco log a missing-translation warning in dev; point it at a key of your own to silence it.",
1173
+ "",
1174
+ "That same file is also the **app icon**: the browser tab reads it, and the manifest names it so",
1175
+ "the app has an icon at all. One gap is left on purpose, because a scaffold writes text and",
1176
+ "cannot invent your artwork: **Chromium only offers installation once the manifest names a 192",
1177
+ "and a 512 raster icon**, and iOS ignores manifest icons entirely in favour of",
1178
+ "`apple-touch-icon`. Drop `icon-192.png` and `icon-512.png` next to the logo, add them to the",
1179
+ "manifest's `icons` and add an `apple-touch-icon` link to `index.html`, and the app becomes",
1180
+ "installable. Until then it runs and caches offline, it simply is not offered for installation.",
1181
+ "",
1182
+ "## Styles",
1183
+ "",
1184
+ ...stylesNotes(d),
1185
+ "",
1186
+ "## Build wiring",
1187
+ "",
1188
+ "The Nx generator put all of this in `project.json`. Scaffolded over the CLI or MCP, add it to",
1189
+ "your build target yourself \u2014 `angular.json` under `projects.<name>.architect.build.options` with",
1190
+ "the Angular CLI, `project.json` under `targets.build.options` in Nx. Paths inside `assets` are",
1191
+ "resolved from the workspace root, so they read the same either way:",
1192
+ "",
1193
+ "```jsonc",
1194
+ '"styles": ["src/styles.css"],',
1195
+ '"assets": [',
1196
+ ' { "glob": "**/*", "input": "public" },',
1197
+ ' { "glob": "**/*", "input": "node_modules/@loomweaver/shell/i18n", "output": "i18n" },',
1198
+ ' { "glob": "**/*", "input": "node_modules/@loomweaver/frame-kit/dist", "output": "frame-kit" }',
1199
+ "],",
1200
+ '"serviceWorker": "ngsw-config.json"',
1201
+ "```",
1202
+ "",
1203
+ "And in the **production** configuration of that same target:",
1204
+ "",
1205
+ "```jsonc",
1206
+ '"optimization": { "styles": { "inlineCritical": false } }',
1207
+ "```",
1208
+ "",
1209
+ "Each of those earns its place. The **`@loomweaver/shell/i18n` glob** is the one whose absence is easy",
1210
+ "to misread: the shell fetches its own UI strings at runtime, so without it every label in the",
1211
+ "chrome renders as its raw translation key and nothing errors. The **frame-kit** glob only",
1212
+ "matters if you host sandboxed (iframe) plugins \u2014 install that package then; until you do, the",
1213
+ "glob simply matches nothing. **`serviceWorker`** emits the worker that `provideShell()` already",
1214
+ "registers for you (inert in dev) \u2014 never add `provideServiceWorker` yourself, and if you would",
1215
+ "rather ship no worker at all, drop `ngsw-config.json` and pass",
1216
+ "`provideShell({ serviceWorker: false })`, because otherwise the registration 404s in production.",
1217
+ "**`inlineCritical: false`** is not optional here: the `index.html` above ships a strict",
1218
+ "`script-src 'self'`, and Angular's critical-CSS pass loads the stylesheet with an **inline**",
1219
+ "`onload` handler that the policy blocks \u2014 the app then renders completely unstyled, and only in",
1220
+ "production builds.",
1221
+ "",
1222
+ "## Ship less than the whole workbench",
1223
+ "",
1224
+ "The shell arrives with every capability on: splitting, dragging tabs between panes, pinning,",
1225
+ "stacking views, pop-out windows, keyboard shortcuts, the curation checklists. A product whose",
1226
+ "users would be overwhelmed by that switches parts off in the same providers array:",
1227
+ "",
1228
+ "```ts",
1229
+ "import { provideShellFeatures } from '@loomweaver/shell';",
1230
+ "",
1231
+ " provideShellFeatures({",
1232
+ " content: { splitRight: false, splitDown: false, moveTabs: false },",
1233
+ " sidebar: { stackViews: false },",
1234
+ " windows: { popout: false },",
1235
+ " }),",
1236
+ "```",
1237
+ "",
1238
+ "A switch takes the **affordance and the gesture**: turning `splitRight` off removes the toolbar",
1239
+ "button, the drop edges *and* `mod+\\`, so the capability cannot come back through a second door.",
1240
+ "Fields merge group by group, so name only what you turn off. The groups are `content`,",
1241
+ "`sidebar`, `rail`, `workspaces`, `windows` and `commands`; everything is on by default except",
1242
+ "`content.escalate`, the unlabelled double-click cycle on a tab.",
1243
+ "",
1244
+ "That provider is for **gestures**. A command, a bar or rail item, a settings row or a menu entry",
1245
+ "is a *contribution* and goes instead \u2014 by id \u2014 into `provideShell({ omit: [...] })`. Where a",
1246
+ "capability is both, the feature switch wins and takes the menu entry with it.",
1247
+ "",
1248
+ "A product backend is optional: implement the settings-store / auth-source ports",
1249
+ "(`provideSettingsStore` / `provideAuthSource`, both against the `KeyValueStore` shape)",
1250
+ "with your own backend, or keep the local/anonymous defaults for a standalone UI.",
1251
+ "Working state stays on `WORKING_STATE_STORE` and never reaches your",
1252
+ "settings backend; back it separately with `provideWorkingStateStore` only if",
1253
+ "working state should travel across devices.",
1254
+ ""
1255
+ ].join("\n");
1256
+ }
1257
+ var angularDistribution = {
1258
+ id: "angular-distribution",
1259
+ build(input) {
1260
+ const d = resolveDistributionInput(input);
1261
+ return {
1262
+ "src/main.ts": mainTs(),
1263
+ "src/app/app.config.ts": appConfigTs(d),
1264
+ ...d.withTests ? { "src/app/app.config.spec.ts": appConfigSpec() } : {},
1265
+ "src/app/app.ts": appTs(),
1266
+ "src/app/app.html": appHtml(),
1267
+ "src/index.html": indexHtml(d),
1268
+ "src/styles.css": stylesCss(d),
1269
+ "public/logo.svg": PLACEHOLDER_LOGO_SVG,
1270
+ "public/manifest.webmanifest": manifest(d) + "\n",
1271
+ "ngsw-config.json": ngswConfig() + "\n",
1272
+ "LOOMWEAVER.md": readme2(d)
1273
+ };
1274
+ }
1275
+ };
1276
+
1277
+ // ../devkit/src/recipes/auth-source/recipe.ts
1278
+ function resolveAuthSourceInput(input) {
1279
+ if (!isKebabId(input.name)) {
1280
+ throw new Error(`Auth source name must be kebab-case (e.g. "dev"); got "${input.name}".`);
1281
+ }
1282
+ return {
1283
+ name: input.name,
1284
+ className: toPascalCase(input.name),
1285
+ propertyName: toCamelCase(input.name)
1286
+ };
1287
+ }
1288
+ function moduleFile(a) {
1289
+ return `// Provider-neutral AuthSource. LoomWeaver owns no authentication \u2014 it only reacts to
1290
+ // a session snapshot. Wire it with: provideAuthSource(() => ${a.propertyName}AuthSource()).
1291
+ // Replace the dev switcher below by mapping your product's real session onto an AuthSnapshot.
1292
+ import { signal, Signal } from '@angular/core';
1293
+ import { ANONYMOUS, AuthSnapshot } from '@loomweaver/plugin-sdk';
1294
+
1295
+ const USER: AuthSnapshot = {
1296
+ authenticated: true,
1297
+ roles: ['user'],
1298
+ claims: {},
1299
+ displayName: 'Signed-in user',
1300
+ };
1301
+
1302
+ const ADMIN: AuthSnapshot = {
1303
+ authenticated: true,
1304
+ roles: ['user', 'admin'],
1305
+ claims: {},
1306
+ displayName: 'Administrator',
1307
+ };
1308
+
1309
+ const state = signal<AuthSnapshot>(ANONYMOUS);
1310
+
1311
+ export function ${a.propertyName}AuthSource(): Signal<AuthSnapshot> {
1312
+ return state.asReadonly();
1313
+ }
1314
+
1315
+ export function cycle${a.className}User(): void {
1316
+ const current = state();
1317
+ const next = !current.authenticated
1318
+ ? USER
1319
+ : current.roles.includes('admin')
1320
+ ? ANONYMOUS
1321
+ : ADMIN;
1322
+ state.set(next);
1323
+ }
1324
+ `;
1325
+ }
1326
+ var authSource = {
1327
+ id: "auth-source",
1328
+ build(input) {
1329
+ const a = resolveAuthSourceInput(input);
1330
+ return { [`${a.name}-auth-source.ts`]: moduleFile(a) };
1331
+ }
1332
+ };
1333
+
1334
+ // ../devkit/src/recipes/settings-store/recipe.ts
1335
+ function resolveSettingsStoreInput(input) {
1336
+ if (!isKebabId(input.name)) {
1337
+ throw new Error(
1338
+ `Settings store name must be kebab-case (e.g. "backend"); got "${input.name}".`
1339
+ );
1340
+ }
1341
+ return { name: input.name, className: toPascalCase(input.name) };
1342
+ }
1343
+ function moduleFile2(s) {
1344
+ return `// A backend-backed settings store for the SETTINGS_STORE port. The platform
1345
+ // ships local defaults; the product persists settings against its own backend. Only genuine
1346
+ // settings flow through this port \u2014 working state stays on WORKING_STATE_STORE. Wire it with:
1347
+ // provideSettingsStore(new ${s.className}SettingsStore()).
1348
+ import { KeyValueStore } from '@loomweaver/shell';
1349
+
1350
+ export class ${s.className}SettingsStore implements KeyValueStore {
1351
+ constructor(private readonly baseUrl = '/api/settings') {}
1352
+
1353
+ async get(key: string): Promise<string | undefined> {
1354
+ const response = await fetch(this.url(key));
1355
+ if (!response.ok) {
1356
+ return undefined;
1357
+ }
1358
+ return response.text();
1359
+ }
1360
+
1361
+ async set(key: string, value: string): Promise<void> {
1362
+ await fetch(this.url(key), { method: 'PUT', body: value });
1363
+ }
1364
+
1365
+ async delete(key: string): Promise<void> {
1366
+ await fetch(this.url(key), { method: 'DELETE' });
1367
+ }
1368
+
1369
+ private url(key: string): string {
1370
+ return \`\${this.baseUrl}/\${encodeURIComponent(key)}\`;
1371
+ }
1372
+ }
1373
+ `;
1374
+ }
1375
+ var settingsStore = {
1376
+ id: "settings-store",
1377
+ build(input) {
1378
+ const s = resolveSettingsStoreInput(input);
1379
+ return { [`${s.name}-settings-store.ts`]: moduleFile2(s) };
1380
+ }
1381
+ };
1382
+
1383
+ // ../devkit/src/recipes/theme/recipe.ts
1384
+ var PRESETS = ["literal", "bootstrap"];
1385
+ function resolveThemeInput(input) {
1386
+ if (!isKebabId(input.name)) {
1387
+ throw new Error(`Theme name must be kebab-case (e.g. "midnight"); got "${input.name}".`);
1388
+ }
1389
+ const preset = input.preset ?? "literal";
1390
+ if (!PRESETS.includes(preset)) {
1391
+ throw new Error(`Unknown theme preset "${preset}"; expected one of ${PRESETS.join(", ")}.`);
1392
+ }
1393
+ return { name: input.name, title: toTitleCase(input.name), preset };
1394
+ }
1395
+ function cssFile(t) {
1396
+ return `/* ${t.title} theme \u2014 overrides LoomWeaver's --lw-* design tokens.
1397
+ Import it AFTER the shell theme in your distribution styles.css:
1398
+ @import '@loomweaver/shell/styles/theme.css';
1399
+ @import './themes/${t.name}.css';
1400
+ Declared in @layer lw-tenant-theme so it beats any plugin's ctx.contributeTheme
1401
+ (Product default < Plugin < Tenant). The --lw-* ladder flips in :root.dark, so override both. */
1402
+
1403
+ @layer lw-tenant-theme {
1404
+ :root {
1405
+ --lw-brand: #2e96c9;
1406
+ --lw-brand-strong: #237aa6;
1407
+ --lw-brand-fill: #2e96c9;
1408
+ --lw-brand-text: #1f6f97;
1409
+ --lw-accent: #c59a2f;
1410
+ --lw-surface: #ffffff;
1411
+ --lw-surface-raised: #f7f8fa;
1412
+ --lw-content: #1f2937;
1413
+ --lw-content-muted: #4b5563;
1414
+ --lw-content-faint: #6b7280;
1415
+ --lw-border: #e5e7eb;
1416
+ }
1417
+
1418
+ :root.dark {
1419
+ --lw-brand: #3aa9dd;
1420
+ --lw-brand-strong: #2e96c9;
1421
+ --lw-brand-fill: #2e96c9;
1422
+ --lw-brand-text: #7cc4e6;
1423
+ --lw-accent: #d8b45a;
1424
+ --lw-surface: #0f141a;
1425
+ --lw-surface-raised: #171d25;
1426
+ --lw-content: #e5e7eb;
1427
+ --lw-content-muted: #9ca3af;
1428
+ --lw-content-faint: #6b7280;
1429
+ --lw-border: #2a323c;
1430
+ }
1431
+ }
1432
+ `;
1433
+ }
1434
+ function bootstrapFile(t) {
1435
+ return `/* ${t.title} theme \u2014 maps LoomWeaver's --lw-* design tokens onto Bootstrap 5.3's --bs-*.
1436
+ Import it AFTER the shell theme in your distribution styles.css, and import Bootstrap itself
1437
+ INTO A LAYER \u2014 unlayered CSS outranks layered CSS whatever its specificity, so Bootstrap's
1438
+ Reboot (button { border-radius: 0 }) would otherwise beat our .lw-* component classes:
1439
+ @layer vendor;
1440
+ @import 'bootstrap/dist/css/bootstrap.css' layer(vendor);
1441
+ @import '@loomweaver/shell/styles/shell.css'; (or styles/theme.css if you run Tailwind)
1442
+ @import './themes/${t.name}.css';
1443
+ Declared in @layer lw-tenant-theme so it beats any plugin's ctx.contributeTheme
1444
+ (Product default < Plugin < Tenant).
1445
+
1446
+ THERE IS NO :root.dark BLOCK, on purpose. Bootstrap redefines its own --bs-* variables under
1447
+ [data-bs-theme="dark"], so every var() below already resolves to the dark value \u2014 provided that
1448
+ attribute is in step with the shell's mode. Mirror it once at startup, from somewhere with an
1449
+ injection context \u2014 your root component, or provideEnvironmentInitializer in app.config.ts:
1450
+
1451
+ private readonly theme = inject(ThemeService); // from '@loomweaver/shell'
1452
+ private readonly html = inject(DOCUMENT).documentElement;
1453
+ constructor() {
1454
+ effect(() => this.html.setAttribute('data-bs-theme', this.theme.resolvedTheme()));
1455
+ }
1456
+
1457
+ Use resolvedTheme, never mode: mode can be 'system', which Bootstrap does not understand.
1458
+
1459
+ WHERE THIS MAPPING IS DELIBERATELY NOT ONE TO ONE: LoomWeaver splits a brand colour into the
1460
+ identity colour, the colour it is safe to *read* as text, and the colour it is safe to *fill*
1461
+ behind white text, because each clears a different WCAG threshold. Bootstrap 5.3 draws the same
1462
+ distinction with -text-emphasis, so that is what the text tokens point at. The on-* tokens are
1463
+ literal because they must contrast with the fill, not follow it, and they match what Bootstrap
1464
+ itself puts on .btn-primary, .btn-warning and .btn-info.
1465
+
1466
+ CONTRAST IS YOURS NOW. LoomWeaver's own palette is verified against WCAG 2.1 AA. These values are
1467
+ your Bootstrap theme's, so the guarantee travels with them: if your --bs-primary does not clear
1468
+ 4.5:1 behind white text, neither will our buttons. */
1469
+
1470
+ @layer lw-tenant-theme {
1471
+ :root {
1472
+ --lw-brand: var(--bs-primary);
1473
+ --lw-brand-strong: var(--bs-primary-text-emphasis);
1474
+ --lw-brand-text: var(--bs-primary-text-emphasis);
1475
+ --lw-brand-fill: var(--bs-primary);
1476
+ --lw-on-brand: #fff;
1477
+ --lw-accent: var(--bs-secondary);
1478
+ --lw-accent-strong: var(--bs-secondary-text-emphasis);
1479
+
1480
+ --lw-surface: var(--bs-body-bg);
1481
+ --lw-surface-raised: var(--bs-body-bg);
1482
+ --lw-surface-overlay: var(--bs-tertiary-bg);
1483
+ --lw-field: var(--bs-body-bg);
1484
+ --lw-border: var(--bs-border-color);
1485
+
1486
+ --lw-content: var(--bs-body-color);
1487
+ --lw-content-muted: var(--bs-secondary-color);
1488
+ --lw-content-faint: var(--bs-secondary-color);
1489
+
1490
+ --lw-tooltip: var(--bs-emphasis-color);
1491
+ --lw-tooltip-content: var(--bs-body-bg);
1492
+
1493
+ --lw-positive: var(--bs-success);
1494
+ --lw-on-positive: #fff;
1495
+ --lw-negative: var(--bs-danger);
1496
+ --lw-negative-fill: var(--bs-danger);
1497
+ --lw-on-negative: #fff;
1498
+ --lw-caution: var(--bs-warning);
1499
+ --lw-on-caution: #000;
1500
+ --lw-info: var(--bs-info);
1501
+ --lw-on-info: #000;
1502
+
1503
+ --lw-scrim: rgb(0 0 0 / 0.5);
1504
+
1505
+ --lw-font-sans: var(--bs-body-font-family);
1506
+ --lw-font-mono: var(--bs-font-monospace);
1507
+ }
1508
+ }
1509
+ `;
1510
+ }
1511
+ var theme = {
1512
+ id: "theme",
1513
+ build(input) {
1514
+ const t = resolveThemeInput(input);
1515
+ const content = t.preset === "bootstrap" ? bootstrapFile(t) : cssFile(t);
1516
+ return { [`${t.name}.css`]: content };
1517
+ }
1518
+ };
1519
+
1520
+ // ../devkit/src/recipes/layout/recipe.ts
1521
+ function resolveLayoutInput(input) {
1522
+ const name = input.name?.trim() || "base";
1523
+ if (!isKebabId(name)) {
1524
+ throw new Error(`Layout name must be kebab-case (e.g. "base"); got "${name}".`);
1525
+ }
1526
+ return { name, propertyName: toCamelCase(name) };
1527
+ }
1528
+ function moduleFile3(l) {
1529
+ return `// A base layout for the distribution. Pass it to provideLayout(${l.propertyName}Layout)
1530
+ // in src/app/app.config.ts. Region ids are what contributions target \u2014 'primary' (rail) and 'status-bar' (bar) match
1531
+ // the devkit weaver defaults, so a scaffolded weaver's rail + bar items land here.
1532
+ import { ShellLayout } from '@loomweaver/shell';
1533
+
1534
+ export const ${l.propertyName}Layout: ShellLayout = {
1535
+ regions: [
1536
+ ${renderRegions(" ")}
1537
+ ],
1538
+ };
1539
+ `;
1540
+ }
1541
+ var layout = {
1542
+ id: "layout",
1543
+ build(input) {
1544
+ const l = resolveLayoutInput(input);
1545
+ return { [`${l.name}-layout.ts`]: moduleFile3(l) };
1546
+ }
1547
+ };
1548
+
1549
+ // ../devkit/src/lib/scaffolds/scaffolds.ts
1550
+ function str(values, name) {
1551
+ const value = values[name];
1552
+ return typeof value === "string" ? value : void 0;
1553
+ }
1554
+ function bool(values, name) {
1555
+ const value = values[name];
1556
+ return typeof value === "boolean" ? value : void 0;
1557
+ }
1558
+ function kebabCase(name) {
1559
+ return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
1560
+ }
1561
+ var ID_PATTERN = "^[a-z][a-z0-9]*(-[a-z0-9]+)*$";
1562
+ var PLACEMENT_OPTIONS = [
1563
+ {
1564
+ name: "directory",
1565
+ type: "string",
1566
+ description: "Project root, relative to the workspace root.",
1567
+ workspaceOnly: true
1568
+ },
1569
+ {
1570
+ name: "tags",
1571
+ type: "string",
1572
+ description: "Comma-separated Nx tags for the project.",
1573
+ workspaceOnly: true
1574
+ },
1575
+ {
1576
+ name: "prefix",
1577
+ type: "string",
1578
+ description: "Selector prefix for generated components and directives.",
1579
+ default: "lw",
1580
+ pattern: ID_PATTERN,
1581
+ workspaceOnly: true
1582
+ },
1583
+ {
1584
+ name: "unitTestRunner",
1585
+ type: "string",
1586
+ description: "Test wiring to emit. 'vitest' uses @nx/angular:unit-test; 'none' emits no test target.",
1587
+ choices: ["vitest", "none"],
1588
+ default: "vitest",
1589
+ workspaceOnly: true
1590
+ }
1591
+ ];
1592
+ var APP_OPTION = {
1593
+ name: "app",
1594
+ type: "string",
1595
+ description: "Application to drop into. Inferred when the workspace has exactly one.",
1596
+ workspaceOnly: true
1597
+ };
1598
+ var SCAFFOLDS = [
1599
+ {
1600
+ name: "weaver",
1601
+ summary: "an Angular in-process plugin: manifest, surface, rail item, i18n, test",
1602
+ options: [
1603
+ {
1604
+ name: "id",
1605
+ type: "string",
1606
+ description: "Plugin id in kebab-case, e.g. 'notes'.",
1607
+ required: true,
1608
+ pattern: ID_PATTERN
1609
+ },
1610
+ {
1611
+ name: "name",
1612
+ type: "string",
1613
+ description: "Human-readable name. Defaults to a title-cased id."
1614
+ },
1615
+ {
1616
+ name: "command",
1617
+ type: "boolean",
1618
+ description: "Also scaffold a registered command the weaver can trigger.",
1619
+ default: false
1620
+ },
1621
+ {
1622
+ name: "shortcut",
1623
+ type: "string",
1624
+ description: "Keyboard chord for the command, e.g. 'mod+shift+k'. Implies --command."
1625
+ },
1626
+ {
1627
+ name: "menu",
1628
+ type: "string",
1629
+ description: "Hook a menu item into a slot, e.g. 'content/tab/context'. Implies --command."
1630
+ },
1631
+ {
1632
+ name: "barItem",
1633
+ type: "boolean",
1634
+ description: "Also add a status-bar button that triggers the command. Implies --command.",
1635
+ default: false
1636
+ },
1637
+ {
1638
+ name: "settings",
1639
+ type: "boolean",
1640
+ description: "Also scaffold a settings section with signal-backed value owners.",
1641
+ default: false
1642
+ },
1643
+ {
1644
+ name: "about",
1645
+ type: "boolean",
1646
+ description: "Also scaffold an About dialog that reads ctx.host, plus a bottom rail item.",
1647
+ default: false
1648
+ },
1649
+ {
1650
+ name: "instanceable",
1651
+ type: "boolean",
1652
+ description: "Dock the surface and give it named saved instances, each with its own view state. Not combinable with --container: named instances exist only for a docked, non-routable surface.",
1653
+ default: false
1654
+ },
1655
+ {
1656
+ name: "container",
1657
+ type: "boolean",
1658
+ description: "Make the surface a container: a routable tab at '<id>/:id' holding a nested pane tree of child surfaces, which the host draws. Not combinable with --instanceable.",
1659
+ default: false
1660
+ },
1661
+ {
1662
+ name: "access",
1663
+ type: "string",
1664
+ description: "Auth-gate the surface and rail item: 'authenticated', 'anonymous' or 'role:<name>'."
1665
+ },
1666
+ {
1667
+ name: "spec",
1668
+ type: "boolean",
1669
+ description: "Generate a starter unit test. Use --no-spec to skip it.",
1670
+ default: true
1671
+ },
1672
+ {
1673
+ name: "projectName",
1674
+ type: "string",
1675
+ description: "Nx project name. Defaults to '<id>-weaver'.",
1676
+ workspaceOnly: true
1677
+ },
1678
+ {
1679
+ name: "importPath",
1680
+ type: "string",
1681
+ description: "Import path for the workspace alias. Defaults to the workspace scope plus the project name.",
1682
+ workspaceOnly: true
1683
+ },
1684
+ APP_OPTION,
1685
+ ...PLACEMENT_OPTIONS
1686
+ ],
1687
+ build: (values) => generate(angularWeaver, {
1688
+ id: str(values, "id") ?? "",
1689
+ name: str(values, "name"),
1690
+ prefix: str(values, "prefix"),
1691
+ importPath: str(values, "importPath"),
1692
+ features: {
1693
+ command: bool(values, "command"),
1694
+ shortcut: str(values, "shortcut"),
1695
+ menu: str(values, "menu"),
1696
+ barItem: bool(values, "barItem"),
1697
+ settings: bool(values, "settings"),
1698
+ about: bool(values, "about"),
1699
+ instanceable: bool(values, "instanceable"),
1700
+ container: bool(values, "container"),
1701
+ access: str(values, "access"),
1702
+ spec: bool(values, "spec")
1703
+ }
1704
+ })
1705
+ },
1706
+ {
1707
+ name: "frame-plugin",
1708
+ summary: "a framework-agnostic iframe plugin (Penpal + the frame UI kit)",
1709
+ options: [
1710
+ {
1711
+ name: "id",
1712
+ type: "string",
1713
+ description: "Plugin id in kebab-case, e.g. 'notes'.",
1714
+ required: true,
1715
+ pattern: ID_PATTERN
1716
+ },
1717
+ {
1718
+ name: "name",
1719
+ type: "string",
1720
+ description: "Human-readable name. Defaults to a title-cased id."
1721
+ },
1722
+ APP_OPTION
1723
+ ],
1724
+ build: (values) => generate(framePlugin, {
1725
+ id: str(values, "id") ?? "",
1726
+ name: str(values, "name")
1727
+ })
1728
+ },
1729
+ {
1730
+ name: "distribution",
1731
+ summary: "a runnable composition root that boots the shell",
1732
+ options: [
1733
+ {
1734
+ name: "name",
1735
+ type: "string",
1736
+ description: "Distribution name in kebab-case, e.g. 'acme-studio'.",
1737
+ required: true,
1738
+ pattern: ID_PATTERN
1739
+ },
1740
+ {
1741
+ name: "title",
1742
+ type: "string",
1743
+ description: "Product display title. Defaults to a title-cased name."
1744
+ },
1745
+ {
1746
+ name: "styles",
1747
+ type: "string",
1748
+ description: "Which stylesheet to emit. 'tailwind' compiles the shell's source theme and lets you write Tailwind utilities of your own; 'precompiled' imports the stylesheet we compiled, so the application needs no Tailwind and can be themed with Bootstrap or anything else.",
1749
+ choices: ["tailwind", "precompiled"],
1750
+ default: "tailwind"
1751
+ },
1752
+ {
1753
+ name: "force",
1754
+ type: "boolean",
1755
+ description: "Compose into the application already at that path, replacing the bootstrap files this scaffold owns and merging its build targets. Without it an existing project is an error.",
1756
+ workspaceOnly: true
1757
+ },
1758
+ ...PLACEMENT_OPTIONS
1759
+ ],
1760
+ build: (values) => generate(angularDistribution, {
1761
+ name: str(values, "name") ?? "",
1762
+ title: str(values, "title"),
1763
+ directory: str(values, "directory"),
1764
+ styles: str(values, "styles") ?? "tailwind"
1765
+ })
1766
+ },
1767
+ {
1768
+ name: "auth-source",
1769
+ summary: "a provider-neutral AuthSource that feeds the session",
1770
+ options: [
1771
+ {
1772
+ name: "name",
1773
+ type: "string",
1774
+ description: "Source name in kebab-case, e.g. 'dev'.",
1775
+ required: true,
1776
+ pattern: ID_PATTERN
1777
+ },
1778
+ APP_OPTION
1779
+ ],
1780
+ build: (values) => generate(authSource, { name: str(values, "name") ?? "" })
1781
+ },
1782
+ {
1783
+ name: "settings-store",
1784
+ summary: "a settings-store implementation backed by your API",
1785
+ options: [
1786
+ {
1787
+ name: "name",
1788
+ type: "string",
1789
+ description: "Store name in kebab-case, e.g. 'backend'.",
1790
+ required: true,
1791
+ pattern: ID_PATTERN
1792
+ },
1793
+ APP_OPTION
1794
+ ],
1795
+ build: (values) => generate(settingsStore, { name: str(values, "name") ?? "" })
1796
+ },
1797
+ {
1798
+ name: "theme",
1799
+ summary: "a token-override stylesheet in @layer lw-tenant-theme",
1800
+ options: [
1801
+ {
1802
+ name: "name",
1803
+ type: "string",
1804
+ description: "Theme name in kebab-case, e.g. 'midnight'.",
1805
+ required: true,
1806
+ pattern: ID_PATTERN
1807
+ },
1808
+ {
1809
+ name: "preset",
1810
+ type: "string",
1811
+ description: "Where the token values come from. 'literal' writes editable colours; 'bootstrap' maps them onto Bootstrap 5.3's --bs-* variables, which makes the shell follow your Bootstrap theme live.",
1812
+ choices: ["literal", "bootstrap"],
1813
+ default: "literal"
1814
+ },
1815
+ APP_OPTION
1816
+ ],
1817
+ build: (values) => generate(theme, {
1818
+ name: str(values, "name") ?? "",
1819
+ preset: str(values, "preset") ?? "literal"
1820
+ })
1821
+ },
1822
+ {
1823
+ name: "layout",
1824
+ summary: "a ShellLayout with the regions a weaver expects",
1825
+ options: [
1826
+ {
1827
+ name: "name",
1828
+ type: "string",
1829
+ description: "Layout name. Defaults to a base layout."
1830
+ },
1831
+ APP_OPTION
1832
+ ],
1833
+ build: (values) => generate(layout, { name: str(values, "name") })
1834
+ }
1835
+ ];
1836
+ function findScaffold(name) {
1837
+ return SCAFFOLDS.find((scaffold2) => scaffold2.name === name);
1838
+ }
1839
+ function portableOptions(scaffold2) {
1840
+ return scaffold2.options.filter((option) => !option.workspaceOnly);
1841
+ }
1842
+ function usageFor(scaffold2) {
1843
+ const parts = portableOptions(scaffold2).map((option) => {
1844
+ const flag = `--${kebabCase(option.name)}`;
1845
+ const body = option.type === "boolean" ? flag : `${flag} <${option.name}>`;
1846
+ return option.required ? body : `[${body}]`;
1847
+ });
1848
+ return [scaffold2.name, ...parts].join(" ");
1849
+ }
1850
+
1851
+ // ../devkit/src/lib/validate/i18n.ts
1852
+ function flattenKeys(bundle, prefix = "") {
1853
+ return Object.entries(bundle).flatMap(([key, value]) => {
1854
+ const path = prefix ? `${prefix}.${key}` : key;
1855
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
1856
+ return flattenKeys(value, path);
1857
+ }
1858
+ return [path];
1859
+ });
1860
+ }
1861
+ function validateI18nParity(bundles) {
1862
+ const languages = Object.keys(bundles);
1863
+ if (languages.length < 2) {
1864
+ return [];
1865
+ }
1866
+ const keysByLanguage = new Map(languages.map((lang) => [lang, new Set(flattenKeys(bundles[lang]))]));
1867
+ const allKeys = /* @__PURE__ */ new Set();
1868
+ for (const keys of keysByLanguage.values()) {
1869
+ for (const key of keys) {
1870
+ allKeys.add(key);
1871
+ }
1872
+ }
1873
+ const findings = [];
1874
+ for (const lang of languages) {
1875
+ const keys = keysByLanguage.get(lang);
1876
+ for (const key of allKeys) {
1877
+ if (!keys?.has(key)) {
1878
+ findings.push({
1879
+ level: "warning",
1880
+ code: "i18n.missingKey",
1881
+ message: `Language "${lang}" is missing translation key "${key}".`,
1882
+ path: `i18n/${lang}`
1883
+ });
1884
+ }
1885
+ }
1886
+ }
1887
+ return findings;
1888
+ }
1889
+
1890
+ // ../devkit/src/lib/validate/catalog.ts
1891
+ var CATALOG_ENTRY_KEYS = [
1892
+ "id",
1893
+ "name",
1894
+ "entryUrl",
1895
+ "capabilities",
1896
+ "version",
1897
+ "iconUrl",
1898
+ "description",
1899
+ "icon",
1900
+ "category",
1901
+ "author",
1902
+ "downloads",
1903
+ "updated",
1904
+ "repository",
1905
+ "readmeUrl"
1906
+ ];
1907
+ var SAME_ORIGIN_FIELDS = ["entryUrl", "iconUrl", "readmeUrl"];
1908
+ function at(index, field) {
1909
+ return field ? `catalog[${index}].${field}` : `catalog[${index}]`;
1910
+ }
1911
+ function isPlainObject(raw) {
1912
+ return typeof raw === "object" && raw !== null && !Array.isArray(raw);
1913
+ }
1914
+ function urlFinding(value, index, field, required) {
1915
+ if (typeof value !== "string" || value.length === 0) {
1916
+ return required ? {
1917
+ level: "error",
1918
+ code: "catalog.entryUrl",
1919
+ message: `${at(index, field)} must be a non-empty string; the host drops the whole entry without it.`,
1920
+ path: at(index, field)
1921
+ } : {
1922
+ level: "warning",
1923
+ code: "catalog.url.empty",
1924
+ message: `${at(index, field)} is present but not a non-empty string, so the host ignores it.`,
1925
+ path: at(index, field)
1926
+ };
1927
+ }
1928
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
1929
+ if (scheme && scheme !== "http" && scheme !== "https") {
1930
+ return {
1931
+ level: "error",
1932
+ code: "catalog.url.scheme",
1933
+ message: `${at(index, field)} uses the "${scheme}:" scheme. The host accepts same-origin http(s) URLs only${required ? " and drops the entry" : " and ignores the field"}.`,
1934
+ path: at(index, field)
1935
+ };
1936
+ }
1937
+ if (scheme) {
1938
+ return {
1939
+ level: "warning",
1940
+ code: "catalog.url.absolute",
1941
+ message: `${at(index, field)} is absolute. The host requires same-origin, so this holds only while it matches the origin the app is served from; a root-relative path is same-origin by construction.`,
1942
+ path: at(index, field)
1943
+ };
1944
+ }
1945
+ return void 0;
1946
+ }
1947
+ function validateCapabilities2(value, index, known) {
1948
+ if (value === void 0) {
1949
+ return [
1950
+ {
1951
+ level: "warning",
1952
+ code: "catalog.capabilities.missing",
1953
+ message: `${at(index)} declares no capabilities. Accepting the install dialog grants exactly what is declared, so the plugin will be denied everything at runtime.`,
1954
+ path: at(index, "capabilities")
1955
+ }
1956
+ ];
1957
+ }
1958
+ if (!Array.isArray(value)) {
1959
+ return [
1960
+ {
1961
+ level: "error",
1962
+ code: "catalog.capabilities",
1963
+ message: `${at(index, "capabilities")} must be an array; the host ignores any other shape and grants nothing.`,
1964
+ path: at(index, "capabilities")
1965
+ }
1966
+ ];
1967
+ }
1968
+ const findings = [];
1969
+ for (const capability of value) {
1970
+ if (typeof capability !== "string" || !known.includes(capability)) {
1971
+ findings.push({
1972
+ level: "error",
1973
+ code: "catalog.capability.unknown",
1974
+ message: `${at(index, "capabilities")} contains ${JSON.stringify(capability)}, which the host filters out silently \u2014 the plugin then throws CapabilityError at runtime. Known: ${known.join(", ")}.`,
1975
+ path: at(index, "capabilities")
1976
+ });
1977
+ }
1978
+ }
1979
+ return findings;
1980
+ }
1981
+ function validateEntry(raw, index, known) {
1982
+ if (!isPlainObject(raw)) {
1983
+ return [
1984
+ {
1985
+ level: "error",
1986
+ code: "catalog.entry",
1987
+ message: `${at(index)} is not an object, so the host drops it.`,
1988
+ path: at(index)
1989
+ }
1990
+ ];
1991
+ }
1992
+ const findings = [];
1993
+ if (typeof raw["id"] !== "string" || raw["id"].length === 0) {
1994
+ findings.push({
1995
+ level: "error",
1996
+ code: "catalog.id",
1997
+ message: `${at(index, "id")} must be a non-empty string; the host drops the whole entry without it.`,
1998
+ path: at(index, "id")
1999
+ });
2000
+ }
2001
+ for (const field of SAME_ORIGIN_FIELDS) {
2002
+ if (field !== "entryUrl" && raw[field] === void 0) {
2003
+ continue;
2004
+ }
2005
+ const finding = urlFinding(raw[field], index, field, field === "entryUrl");
2006
+ if (finding) {
2007
+ findings.push(finding);
2008
+ }
2009
+ }
2010
+ if (raw["name"] === void 0) {
2011
+ findings.push({
2012
+ level: "warning",
2013
+ code: "catalog.name.missing",
2014
+ message: `${at(index)} has no name, so the store falls back to showing the id.`,
2015
+ path: at(index, "name")
2016
+ });
2017
+ } else if (typeof raw["name"] !== "string" || raw["name"].length === 0) {
2018
+ findings.push({
2019
+ level: "warning",
2020
+ code: "catalog.name",
2021
+ message: `${at(index, "name")} is not a non-empty string, so the store falls back to showing the id.`,
2022
+ path: at(index, "name")
2023
+ });
2024
+ }
2025
+ if (raw["version"] === void 0) {
2026
+ findings.push({
2027
+ level: "warning",
2028
+ code: "catalog.version.missing",
2029
+ message: `${at(index)} carries no version. Update detection compares catalog versions, so the store can never offer an update and republishing the plugin will not respawn it for anyone who already installed it.`,
2030
+ path: at(index, "version")
2031
+ });
2032
+ }
2033
+ findings.push(...validateCapabilities2(raw["capabilities"], index, known));
2034
+ if (raw["downloads"] !== void 0 && (typeof raw["downloads"] !== "number" || raw["downloads"] < 0)) {
2035
+ findings.push({
2036
+ level: "warning",
2037
+ code: "catalog.downloads",
2038
+ message: `${at(index, "downloads")} must be a non-negative number, so the host ignores it.`,
2039
+ path: at(index, "downloads")
2040
+ });
2041
+ }
2042
+ if (raw["updated"] !== void 0 && !isRenderableDate(raw["updated"])) {
2043
+ findings.push({
2044
+ level: "warning",
2045
+ code: "catalog.updated",
2046
+ message: `${at(index, "updated")} is not a date the store can parse, so it renders the raw string instead of "2 days ago".`,
2047
+ path: at(index, "updated")
2048
+ });
2049
+ }
2050
+ if (raw["repository"] !== void 0 && !isHttpUrl(raw["repository"])) {
2051
+ findings.push({
2052
+ level: "warning",
2053
+ code: "catalog.repository",
2054
+ message: `${at(index, "repository")} must be an http(s) URL, so the host drops it and the detail pane shows no link.`,
2055
+ path: at(index, "repository")
2056
+ });
2057
+ }
2058
+ for (const key of Object.keys(raw)) {
2059
+ if (!CATALOG_ENTRY_KEYS.includes(key)) {
2060
+ findings.push({
2061
+ level: "warning",
2062
+ code: "catalog.unknown-key",
2063
+ message: `${at(index, key)} is not one of the fields the host reads (${CATALOG_ENTRY_KEYS.join(", ")}), so it is ignored without a word \u2014 which is exactly what a misspelled field looks like.`,
2064
+ path: at(index, key)
2065
+ });
2066
+ }
2067
+ }
2068
+ return findings;
2069
+ }
2070
+ function isHttpUrl(raw) {
2071
+ if (typeof raw !== "string") {
2072
+ return false;
2073
+ }
2074
+ try {
2075
+ const url = new URL(raw);
2076
+ return url.protocol === "http:" || url.protocol === "https:";
2077
+ } catch {
2078
+ return false;
2079
+ }
2080
+ }
2081
+ function isRenderableDate(raw) {
2082
+ return typeof raw === "string" && !Number.isNaN(new Date(raw).getTime());
2083
+ }
2084
+ function validateCatalog(catalog, known = KNOWN_CAPABILITIES) {
2085
+ if (!Array.isArray(catalog)) {
2086
+ return [
2087
+ {
2088
+ level: "error",
2089
+ code: "catalog.shape",
2090
+ message: "A plugin catalog must be a JSON array of entries; the host loads nothing otherwise.",
2091
+ path: "catalog"
2092
+ }
2093
+ ];
2094
+ }
2095
+ const findings = [];
2096
+ const seen = /* @__PURE__ */ new Set();
2097
+ catalog.forEach((entry, index) => {
2098
+ findings.push(...validateEntry(entry, index, known));
2099
+ const id = isPlainObject(entry) ? entry["id"] : void 0;
2100
+ if (typeof id === "string" && id.length > 0) {
2101
+ if (seen.has(id)) {
2102
+ findings.push({
2103
+ level: "warning",
2104
+ code: "catalog.id.duplicate",
2105
+ message: `${at(index, "id")} repeats "${id}". The host keeps the first entry with an id and drops the rest.`,
2106
+ path: at(index, "id")
2107
+ });
2108
+ }
2109
+ seen.add(id);
2110
+ }
2111
+ });
2112
+ return findings;
2113
+ }
2114
+
2115
+ // src/lib/run.ts
2116
+ import { readdirSync, readFileSync } from "node:fs";
2117
+ import { join } from "node:path";
2118
+
2119
+ // src/lib/args.ts
2120
+ var ArgError = class extends Error {
2121
+ };
2122
+ function assign(flags, token, next) {
2123
+ const body = token.slice(2);
2124
+ const eq = body.indexOf("=");
2125
+ if (eq !== -1) {
2126
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
2127
+ return false;
2128
+ }
2129
+ if (body.startsWith("no-")) {
2130
+ flags[body.slice(3)] = false;
2131
+ return false;
2132
+ }
2133
+ if (next === void 0 || next.startsWith("-")) {
2134
+ flags[body] = true;
2135
+ return false;
2136
+ }
2137
+ flags[body] = next;
2138
+ return true;
2139
+ }
2140
+ function parseArgs(argv) {
2141
+ const flags = {};
2142
+ let command = "";
2143
+ for (let i = 0; i < argv.length; i++) {
2144
+ const token = argv[i];
2145
+ if (token === "-h" || token === "--help") {
2146
+ flags["help"] = true;
2147
+ continue;
2148
+ }
2149
+ if (token === "-v" || token === "--version") {
2150
+ flags["version"] = true;
2151
+ continue;
2152
+ }
2153
+ if (token.startsWith("--")) {
2154
+ if (assign(flags, token, argv[i + 1])) {
2155
+ i++;
2156
+ }
2157
+ continue;
2158
+ }
2159
+ if (token.startsWith("-")) {
2160
+ throw new ArgError(`Unknown option "${token}".`);
2161
+ }
2162
+ if (command) {
2163
+ throw new ArgError(`Unexpected argument "${token}" after "${command}".`);
2164
+ }
2165
+ command = token;
2166
+ }
2167
+ return { command, flags };
2168
+ }
2169
+ function rejectUnknownFlags(args, allowed) {
2170
+ const known = /* @__PURE__ */ new Set(["help", "version", ...allowed]);
2171
+ const unknown = Object.keys(args.flags).filter((name) => !known.has(name));
2172
+ if (unknown.length > 0) {
2173
+ throw new ArgError(
2174
+ `Unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((name) => `"--${name}"`).join(", ")}. Run "loomweaver list" to see every option.`
2175
+ );
2176
+ }
2177
+ }
2178
+ function stringFlag(args, name) {
2179
+ const value = args.flags[name];
2180
+ if (value === void 0) {
2181
+ return void 0;
2182
+ }
2183
+ if (typeof value !== "string") {
2184
+ throw new ArgError(`Option --${name} needs a value.`);
2185
+ }
2186
+ return value;
2187
+ }
2188
+ function requiredFlag(args, name) {
2189
+ const value = stringFlag(args, name);
2190
+ if (!value) {
2191
+ throw new ArgError(`Option --${name} is required.`);
2192
+ }
2193
+ return value;
2194
+ }
2195
+ function boolFlag(args, name) {
2196
+ const value = args.flags[name];
2197
+ if (value === void 0) {
2198
+ return void 0;
2199
+ }
2200
+ if (typeof value !== "boolean") {
2201
+ throw new ArgError(`Option --${name} does not take a value.`);
2202
+ }
2203
+ return value;
2204
+ }
2205
+
2206
+ // src/lib/scaffold.ts
2207
+ import { relative, resolve } from "node:path";
2208
+ function findScaffold2(name) {
2209
+ const scaffold2 = findScaffold(name);
2210
+ if (!scaffold2) {
2211
+ throw new ArgError(
2212
+ `Unknown command "${name}". Run "loomweaver list" to see what is available.`
2213
+ );
2214
+ }
2215
+ return scaffold2;
2216
+ }
2217
+ function allowedFlagsFor(scaffold2) {
2218
+ return portableOptions(scaffold2).flatMap((option) => [
2219
+ option.name,
2220
+ kebabCase(option.name)
2221
+ ]);
2222
+ }
2223
+ function readFlag(args, option) {
2224
+ const kebab = kebabCase(option.name);
2225
+ const value = args.flags[kebab] ?? args.flags[option.name];
2226
+ if (value === void 0) {
2227
+ return void 0;
2228
+ }
2229
+ if (option.type === "string" && typeof value !== "string") {
2230
+ throw new ArgError(`Option --${kebab} needs a value.`);
2231
+ }
2232
+ if (option.type === "boolean" && typeof value !== "boolean") {
2233
+ throw new ArgError(`Option --${kebab} does not take a value.`);
2234
+ }
2235
+ return value;
2236
+ }
2237
+ function valuesFor(scaffold2, args) {
2238
+ const values = {};
2239
+ for (const option of portableOptions(scaffold2)) {
2240
+ const value = readFlag(args, option);
2241
+ if (value === void 0) {
2242
+ if (option.required) {
2243
+ throw new ArgError(
2244
+ `Option --${kebabCase(option.name)} is required.`
2245
+ );
2246
+ }
2247
+ continue;
2248
+ }
2249
+ if (option.choices && !option.choices.includes(String(value))) {
2250
+ throw new ArgError(
2251
+ `Option --${kebabCase(option.name)} must be one of: ${option.choices.join(", ")}.`
2252
+ );
2253
+ }
2254
+ values[option.name] = value;
2255
+ }
2256
+ return values;
2257
+ }
2258
+ function directoryFromOut(out) {
2259
+ const below = relative(process.cwd(), resolve(out ?? "."));
2260
+ return below.startsWith("..") ? "" : below;
2261
+ }
2262
+ function buildScaffold(scaffold2, args) {
2263
+ const values = valuesFor(scaffold2, args);
2264
+ const takesDirectory = scaffold2.options.some(
2265
+ (option) => option.name === "directory"
2266
+ );
2267
+ const out = args.flags["out"];
2268
+ return scaffold2.build(
2269
+ takesDirectory ? {
2270
+ ...values,
2271
+ directory: directoryFromOut(
2272
+ typeof out === "string" ? out : void 0
2273
+ )
2274
+ } : values
2275
+ );
2276
+ }
2277
+
2278
+ // src/lib/write.ts
2279
+ import {
2280
+ lstatSync,
2281
+ mkdirSync,
2282
+ realpathSync,
2283
+ rmSync,
2284
+ writeFileSync
2285
+ } from "node:fs";
2286
+ import { dirname, isAbsolute, relative as relative2, resolve as resolve2 } from "node:path";
2287
+ var WriteError = class extends Error {
2288
+ };
2289
+ function planWrite(files, root) {
2290
+ const absoluteRoot = resolve2(root);
2291
+ const planned = [];
2292
+ const conflicts = [];
2293
+ for (const path of Object.keys(files).sort()) {
2294
+ const absolute = resolve2(absoluteRoot, path);
2295
+ const inside = relative2(absoluteRoot, absolute);
2296
+ if (inside.startsWith("..") || isAbsolute(inside)) {
2297
+ throw new WriteError(`Refusing to write outside the target directory: ${path}`);
2298
+ }
2299
+ planned.push({ path, absolute });
2300
+ if (entryExists(absolute)) {
2301
+ conflicts.push(path);
2302
+ }
2303
+ }
2304
+ return { root: absoluteRoot, files: planned, conflicts };
2305
+ }
2306
+ function applyWrite(files, plan) {
2307
+ for (const file of plan.files) {
2308
+ mkdirSync(dirname(file.absolute), { recursive: true });
2309
+ assertResolvesInsideRoot(plan.root, file.path, file.absolute);
2310
+ replaceSymlinkEntry(file.absolute);
2311
+ writeFileSync(file.absolute, files[file.path], "utf8");
2312
+ }
2313
+ }
2314
+ function entryExists(absolute) {
2315
+ try {
2316
+ lstatSync(absolute);
2317
+ return true;
2318
+ } catch {
2319
+ return false;
2320
+ }
2321
+ }
2322
+ function assertResolvesInsideRoot(root, path, absolute) {
2323
+ const inside = relative2(realpathSync(root), realpathSync(dirname(absolute)));
2324
+ if (inside.startsWith("..") || isAbsolute(inside)) {
2325
+ throw new WriteError(
2326
+ `Refusing to write through a link that leaves the target directory: ${path}`
2327
+ );
2328
+ }
2329
+ }
2330
+ function replaceSymlinkEntry(absolute) {
2331
+ if (entryExists(absolute) && lstatSync(absolute).isSymbolicLink()) {
2332
+ rmSync(absolute);
2333
+ }
2334
+ }
2335
+
2336
+ // src/lib/run.ts
2337
+ var VERSION = "0.7.2";
2338
+ function help() {
2339
+ const commands = SCAFFOLDS.map((s) => ` ${s.name.padEnd(16)}${s.summary}`);
2340
+ return [
2341
+ "loomweaver \u2014 LoomWeaver scaffolding",
2342
+ "",
2343
+ "Usage: loomweaver <command> [options]",
2344
+ "",
2345
+ "Scaffolds:",
2346
+ ...commands,
2347
+ "",
2348
+ "Other commands:",
2349
+ " list print every scaffold with its options",
2350
+ " validate-manifest --id <id> [--name <name>] [--capabilities <a,b>]",
2351
+ " validate-i18n --dir <dir> check <lang>.json bundles for key parity",
2352
+ " validate-catalog --file <path> check a plugin store catalog the host parses defensively",
2353
+ "",
2354
+ "Options:",
2355
+ " --out <dir> where to write (default: the current directory)",
2356
+ " --dry-run list the files without writing them",
2357
+ " --force overwrite files that already exist",
2358
+ " --strict make validation warnings fail the exit code (for CI)",
2359
+ " -h, --help this text",
2360
+ " -v, --version the version, which matches the platform packages"
2361
+ ].join("\n");
2362
+ }
2363
+ function list(args, io) {
2364
+ rejectUnknownFlags(args, []);
2365
+ for (const scaffold2 of SCAFFOLDS) {
2366
+ io.out(`${scaffold2.name}`);
2367
+ io.out(` ${scaffold2.summary}`);
2368
+ io.out(` loomweaver ${usageFor(scaffold2)}`);
2369
+ for (const option of portableOptions(scaffold2)) {
2370
+ const flag = `--${kebabCase(option.name)}`;
2371
+ io.out(` ${flag.padEnd(18)}${option.description}`);
2372
+ }
2373
+ io.out("");
2374
+ }
2375
+ return 0;
2376
+ }
2377
+ function reportFindings(io, findings, strict) {
2378
+ if (findings.length === 0) {
2379
+ io.out("No findings.");
2380
+ return 0;
2381
+ }
2382
+ findings.forEach((f) => io.err(`${f.level}: ${f.message}`));
2383
+ if (findings.some((f) => f.level === "error")) {
2384
+ return 1;
2385
+ }
2386
+ return strict ? 1 : 0;
2387
+ }
2388
+ function validateManifestCommand(args, io) {
2389
+ rejectUnknownFlags(args, ["id", "name", "capabilities", "strict"]);
2390
+ const capabilities = (stringFlag(args, "capabilities") ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
2391
+ return reportFindings(
2392
+ io,
2393
+ validateManifest({
2394
+ id: requiredFlag(args, "id"),
2395
+ name: stringFlag(args, "name"),
2396
+ capabilities
2397
+ }),
2398
+ boolFlag(args, "strict") === true
2399
+ );
2400
+ }
2401
+ function readBundles(dir) {
2402
+ const bundles = {};
2403
+ for (const entry of readdirSync(dir)) {
2404
+ if (!entry.endsWith(".json")) {
2405
+ continue;
2406
+ }
2407
+ const language = entry.slice(0, -".json".length);
2408
+ try {
2409
+ bundles[language] = JSON.parse(readFileSync(join(dir, entry), "utf8"));
2410
+ } catch (error) {
2411
+ throw new ArgError(`${entry} is not valid JSON: ${error.message}`);
2412
+ }
2413
+ }
2414
+ if (Object.keys(bundles).length === 0) {
2415
+ throw new ArgError(`No <lang>.json bundles found in ${dir}.`);
2416
+ }
2417
+ return bundles;
2418
+ }
2419
+ function validateI18nCommand(args, io) {
2420
+ rejectUnknownFlags(args, ["dir", "strict"]);
2421
+ return reportFindings(
2422
+ io,
2423
+ validateI18nParity(readBundles(requiredFlag(args, "dir"))),
2424
+ boolFlag(args, "strict") === true
2425
+ );
2426
+ }
2427
+ function readCatalog(file) {
2428
+ let raw;
2429
+ try {
2430
+ raw = readFileSync(file, "utf8");
2431
+ } catch (error) {
2432
+ throw new ArgError(`Cannot read ${file}: ${error.message}`);
2433
+ }
2434
+ try {
2435
+ return JSON.parse(raw);
2436
+ } catch (error) {
2437
+ throw new ArgError(`${file} is not valid JSON: ${error.message}`);
2438
+ }
2439
+ }
2440
+ function validateCatalogCommand(args, io) {
2441
+ rejectUnknownFlags(args, ["file", "strict"]);
2442
+ return reportFindings(
2443
+ io,
2444
+ validateCatalog(readCatalog(requiredFlag(args, "file"))),
2445
+ boolFlag(args, "strict") === true
2446
+ );
2447
+ }
2448
+ function scaffold(args, io) {
2449
+ const descriptor = findScaffold2(args.command);
2450
+ rejectUnknownFlags(args, [
2451
+ ...allowedFlagsFor(descriptor),
2452
+ "out",
2453
+ "dry-run",
2454
+ "force"
2455
+ ]);
2456
+ const files = buildScaffold(descriptor, args);
2457
+ const plan = planWrite(files, stringFlag(args, "out") ?? ".");
2458
+ const paths = plan.files.map((file) => file.path);
2459
+ if (boolFlag(args, "dry-run")) {
2460
+ io.out(`Would write ${paths.length} file(s) into ${plan.root}:`);
2461
+ paths.forEach((path) => io.out(` ${path}`));
2462
+ if (plan.conflicts.length > 0) {
2463
+ io.out(
2464
+ `${plan.conflicts.length} of them already exist and would need --force:`
2465
+ );
2466
+ plan.conflicts.forEach((path) => io.out(` ${path}`));
2467
+ }
2468
+ return 0;
2469
+ }
2470
+ if (plan.conflicts.length > 0 && !boolFlag(args, "force")) {
2471
+ io.err(
2472
+ `${plan.conflicts.length} file(s) already exist; pass --force to overwrite:`
2473
+ );
2474
+ plan.conflicts.forEach((path) => io.err(` ${path}`));
2475
+ return 1;
2476
+ }
2477
+ applyWrite(files, plan);
2478
+ io.out(`Wrote ${paths.length} file(s) into ${plan.root}:`);
2479
+ paths.forEach((path) => io.out(` ${path}`));
2480
+ return 0;
2481
+ }
2482
+ function run(argv, io) {
2483
+ let args;
2484
+ try {
2485
+ args = parseArgs(argv);
2486
+ } catch (error) {
2487
+ io.err(error.message);
2488
+ return 1;
2489
+ }
2490
+ if (args.flags["version"]) {
2491
+ io.out(VERSION);
2492
+ return 0;
2493
+ }
2494
+ if (args.flags["help"]) {
2495
+ io.out(help());
2496
+ return 0;
2497
+ }
2498
+ if (!args.command) {
2499
+ io.out(help());
2500
+ return 1;
2501
+ }
2502
+ try {
2503
+ if (args.command === "list") {
2504
+ return list(args, io);
2505
+ }
2506
+ if (args.command === "validate-manifest") {
2507
+ return validateManifestCommand(args, io);
2508
+ }
2509
+ if (args.command === "validate-i18n") {
2510
+ return validateI18nCommand(args, io);
2511
+ }
2512
+ if (args.command === "validate-catalog") {
2513
+ return validateCatalogCommand(args, io);
2514
+ }
2515
+ return scaffold(args, io);
2516
+ } catch (error) {
2517
+ io.err(error.message);
2518
+ return 1;
2519
+ }
2520
+ }
2521
+
2522
+ // src/main.ts
2523
+ process.exitCode = run(process.argv.slice(2), {
2524
+ out: (line) => console.log(line),
2525
+ err: (line) => console.error(line)
2526
+ });