@astryxdesign/cli 0.1.1 → 0.1.2-canary.2149fa3

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 (113) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/docs/theme.doc.mjs +2 -2
  3. package/docs/working-with-ai.doc.mjs +6 -6
  4. package/package.json +19 -8
  5. package/src/api/discover.mjs +78 -26
  6. package/src/api/index.mjs +1 -0
  7. package/src/api/layout.mjs +301 -0
  8. package/src/api/layout.test.mjs +238 -0
  9. package/src/api/template.mjs +191 -50
  10. package/src/api/template.test.mjs +2 -0
  11. package/src/api/theme-add.mjs +182 -0
  12. package/src/codemods/__tests__/registry.test.mjs +1 -0
  13. package/src/codemods/registry.mjs +1 -0
  14. package/src/codemods/transforms/v0.1.2/__tests__/rename-text-color-active-to-accent.test.mjs +120 -0
  15. package/src/codemods/transforms/v0.1.2/index.mjs +19 -0
  16. package/src/codemods/transforms/v0.1.2/rename-text-color-active-to-accent.mjs +136 -0
  17. package/src/commands/build-theme.import-path.test.mjs +22 -2
  18. package/src/commands/build-theme.mjs +185 -16
  19. package/src/commands/gap-report.mjs +17 -9
  20. package/src/commands/gap-report.test.mjs +21 -16
  21. package/src/commands/init.mjs +34 -8
  22. package/src/commands/init.next-steps.test.mjs +46 -0
  23. package/src/commands/layout.mjs +139 -0
  24. package/src/commands/swizzle.mjs +51 -23
  25. package/src/commands/upgrade.mjs +2 -71
  26. package/src/config.mjs +31 -0
  27. package/src/config.test.mjs +24 -0
  28. package/src/index.mjs +6 -0
  29. package/src/lib/config-schema.mjs +119 -0
  30. package/src/lib/config.mjs +34 -7
  31. package/src/lib/config.test.mjs +51 -2
  32. package/src/lib/error-codes.mjs +11 -0
  33. package/src/lib/integrations.mjs +155 -0
  34. package/src/lib/integrations.test.mjs +154 -0
  35. package/src/lib/levenshtein.mjs +29 -0
  36. package/src/lib/manifest.mjs +10 -0
  37. package/src/lib/package-scanner.mjs +31 -7
  38. package/src/lib/string-utils.mjs +5 -14
  39. package/src/lib/xle/browser.d.ts +100 -0
  40. package/src/lib/xle/browser.mjs +120 -0
  41. package/src/lib/xle/expand.mjs +622 -0
  42. package/src/lib/xle/parse.mjs +581 -0
  43. package/src/lib/xle/print.mjs +174 -0
  44. package/src/lib/xle/registry-core.mjs +170 -0
  45. package/src/lib/xle/registry.mjs +237 -0
  46. package/src/lib/xle/splice.mjs +137 -0
  47. package/src/lib/xle/validate.mjs +356 -0
  48. package/src/lib/xle/xle.test.mjs +333 -0
  49. package/src/types/config.d.ts +99 -0
  50. package/src/utils/github.mjs +12 -27
  51. package/templates/blocks/components/Card/ClickableCardWithNestedButton.doc.mjs +1 -1
  52. package/templates/blocks/components/Card/SelectableCardMulti.doc.mjs +1 -1
  53. package/templates/blocks/components/ChatComposerDrawer/ChatComposerDrawerWithProgress.tsx +1 -1
  54. package/templates/blocks/components/ChatDictationButton/ChatDictationButtonShowcase.tsx +3 -3
  55. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.doc.mjs +15 -0
  56. package/templates/blocks/components/CommandPaletteEmpty/CommandPaletteEmptyShowcase.tsx +26 -0
  57. package/templates/blocks/components/DateInput/DateInputDateRange.doc.mjs +2 -2
  58. package/templates/blocks/components/FileInput/FileInputShowcase.tsx +4 -2
  59. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.doc.mjs +14 -0
  60. package/templates/blocks/components/LinkProvider/LinkProviderCustomLink.tsx +43 -0
  61. package/templates/blocks/components/MultiSelector/MultiSelectorShowcase.tsx +6 -2
  62. package/templates/blocks/components/NumberInput/NumberInputShowcase.tsx +6 -2
  63. package/templates/blocks/components/Outline/OutlineShowcase.doc.mjs +15 -0
  64. package/templates/blocks/components/Outline/OutlineShowcase.tsx +22 -0
  65. package/templates/blocks/components/RadioList/RadioListShowcase.tsx +8 -1
  66. package/templates/blocks/components/SegmentedControl/SegmentedControlShowcase.tsx +3 -1
  67. package/templates/blocks/components/Selector/SelectorShowcase.tsx +4 -1
  68. package/templates/blocks/components/Slider/SliderShowcase.tsx +10 -1
  69. package/templates/blocks/components/Tab/TabShowcase.tsx +3 -1
  70. package/templates/blocks/components/TabList/TabListShowcase.tsx +3 -1
  71. package/templates/blocks/components/TabMenu/TabMenuShowcase.tsx +3 -1
  72. package/templates/blocks/components/Table/ColumnResizeHookUsage.doc.mjs +14 -0
  73. package/templates/blocks/components/Table/ColumnResizeHookUsage.tsx +59 -0
  74. package/templates/blocks/components/Table/StickyColumnsHookUsage.doc.mjs +14 -0
  75. package/templates/blocks/components/Table/StickyColumnsHookUsage.tsx +104 -0
  76. package/templates/blocks/components/Text/TextColors.tsx +20 -5
  77. package/templates/blocks/components/TextArea/TextAreaShowcase.tsx +4 -2
  78. package/templates/blocks/components/TextInput/TextInputShowcase.tsx +4 -2
  79. package/templates/blocks/components/Thumbnail/ThumbnailDisabled.tsx +11 -6
  80. package/templates/blocks/components/Thumbnail/ThumbnailGallery.tsx +43 -7
  81. package/templates/blocks/components/Thumbnail/ThumbnailRemovable.tsx +40 -3
  82. package/templates/blocks/components/Thumbnail/ThumbnailShowcase.tsx +6 -5
  83. package/templates/blocks/components/Thumbnail/ThumbnailStates.tsx +11 -6
  84. package/templates/blocks/components/Timestamp/TimestampColors.tsx +2 -2
  85. package/templates/blocks/components/ToggleButton/ToggleButtonGroup.doc.mjs +1 -1
  86. package/templates/blocks/components/Tokenizer/TokenizerShowcase.tsx +8 -6
  87. package/templates/blocks/components/Toolbar/ToolbarTableFilter.doc.mjs +2 -2
  88. package/templates/blocks/components/Toolbar/ToolbarTableFilter.tsx +23 -14
  89. package/templates/blocks/components/Typeahead/TypeaheadShowcase.tsx +6 -2
  90. package/templates/pages/shell-nav/page.tsx +321 -0
  91. package/templates/pages/shell-nav/template.doc.mjs +12 -0
  92. package/templates/pages/shell-side-nav/page.tsx +242 -0
  93. package/templates/pages/shell-side-nav/template.doc.mjs +12 -0
  94. package/templates/pages/shell-top-nav/page.tsx +224 -0
  95. package/templates/pages/shell-top-nav/template.doc.mjs +12 -0
  96. package/templates/pages/theme-showcase/page.tsx +8 -19
  97. package/templates/themes/butter/butterTheme.ts +916 -0
  98. package/templates/themes/butter/icons.tsx +77 -0
  99. package/templates/themes/chocolate/chocolateTheme.ts +230 -0
  100. package/templates/themes/chocolate/icons.tsx +77 -0
  101. package/templates/themes/gothic/gothicTheme.ts +657 -0
  102. package/templates/themes/gothic/icons.tsx +77 -0
  103. package/templates/themes/manifest.json +90 -0
  104. package/templates/themes/matcha/icons.tsx +67 -0
  105. package/templates/themes/matcha/matchaTheme.ts +247 -0
  106. package/templates/themes/neutral/icons.tsx +77 -0
  107. package/templates/themes/neutral/neutralTheme.ts +603 -0
  108. package/templates/themes/stone/icons.tsx +77 -0
  109. package/templates/themes/stone/stoneTheme.ts +652 -0
  110. package/templates/themes/y2k/icons.tsx +67 -0
  111. package/templates/themes/y2k/y2kTheme.ts +617 -0
  112. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.doc.mjs +0 -14
  113. package/templates/blocks/components/MoreMenu/MoreMenuInToolbar.tsx +0 -57
@@ -0,0 +1,622 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file XLE expander — bound AST → TSX.
5
+ *
6
+ * Expansion is codegen, not templating: required value/onChange pairs
7
+ * become typed useState scaffolds, Layout children auto-route into its
8
+ * slot props, Table rows partition into header/body, `fill`-flagged
9
+ * stack children wrap in StackItem, and {hint} blocks become structured
10
+ * TODO(xle) markers pointing at the real block template.
11
+ *
12
+ * Output is deterministic: same bound AST → byte-identical TSX.
13
+ *
14
+ * @input validated doc (nodes carry .bound) + registry
15
+ * @output expand() → {code, componentsUsed, states, todos}
16
+ * @position lib/xle — last stage; api/layout.mjs orchestrates
17
+ */
18
+
19
+ import {PAYLOAD_PROPS} from './validate.mjs';
20
+ import {mergeImports, renderImport, prepareSpliceModule} from './splice.mjs';
21
+
22
+ const INDENT = ' ';
23
+
24
+ function slugify(text) {
25
+ return String(text).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
26
+ }
27
+
28
+ function camelCase(text) {
29
+ const parts = slugify(text).split('-').filter(Boolean);
30
+ if (parts.length === 0) return 'item';
31
+ return parts[0] + parts.slice(1).map(p => p[0].toUpperCase() + p.slice(1)).join('');
32
+ }
33
+
34
+ function escapeAttr(value) {
35
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
36
+ }
37
+
38
+ function cloneItem(item) {
39
+ // .bound holds a registry reference (Map props) — rebuild it after the
40
+ // JSON round-trip of the plain AST fields.
41
+ const {bound, ...rest} = item;
42
+ const copy = JSON.parse(JSON.stringify(rest, (key, value) => {
43
+ if (key === 'bound') return undefined;
44
+ return value;
45
+ }));
46
+ if (item.kind === 'node') {
47
+ copy.bound = bound
48
+ ? {component: bound.component, props: new Map(bound.props), slots: bound.slots, stray: []}
49
+ : null;
50
+ copy.children = item.children.map(cloneItem);
51
+ copy.slots = item.slots; // slot subexprs are emitted from the original binding
52
+ } else {
53
+ copy.children = item.children.map(cloneItem);
54
+ }
55
+ return copy;
56
+ }
57
+
58
+ /**
59
+ * Substitute Emmet-style `$` counters in payloads and string props.
60
+ * `\$` escapes a literal dollar sign (prices etc.).
61
+ */
62
+ function subCounterText(text, index) {
63
+ return text.replace(/\\\$|\$/g, m => (m === '\\$' ? '$' : String(index)));
64
+ }
65
+
66
+ /** Strip `\$` escapes when a node is NOT repeated (single emission). */
67
+ function unescapeCounterText(text) {
68
+ return text.replace(/\\\$/g, '$');
69
+ }
70
+
71
+ function substituteCounter(node, index) {
72
+ if (node.kind === 'group') {
73
+ for (const child of node.children) substituteCounter(child, index);
74
+ return;
75
+ }
76
+ if (node.payload) node.payload = subCounterText(node.payload, index);
77
+ if (node.payload2) node.payload2 = subCounterText(node.payload2, index);
78
+ if (node.bound) {
79
+ for (const [key, value] of node.bound.props) {
80
+ if (typeof value === 'string' && value.includes('$')) {
81
+ node.bound.props.set(key, subCounterText(value, index));
82
+ }
83
+ }
84
+ }
85
+ for (const child of node.children) substituteCounter(child, index);
86
+ }
87
+
88
+ class Emitter {
89
+ constructor(registry, options = {}) {
90
+ this.registry = registry;
91
+ // importPath → {named:Set, types:Set, default, namespace, sideEffect}
92
+ this.imports = new Map();
93
+ this.states = [];
94
+ this.usedStateNames = new Set();
95
+ this.todos = [];
96
+ this.componentsUsed = new Set();
97
+ this.componentName = options.componentName || 'GeneratedLayout';
98
+ // canonical block key → prepared module ({mode, componentName, ...})
99
+ this.blockModules = options.blockModules || new Map();
100
+ // names of blocks already co-defined in this module (dedup)
101
+ this.splicedBlocks = new Map(); // key → componentName
102
+ this.blockDefs = []; // co-defined block source bodies, in first-use order
103
+ }
104
+
105
+ importEntry(source) {
106
+ if (!this.imports.has(source)) {
107
+ this.imports.set(source, {named: new Set(), types: new Set(), default: null, namespace: null, sideEffect: false});
108
+ }
109
+ return this.imports.get(source);
110
+ }
111
+
112
+ addImport(component) {
113
+ this.importEntry(component.importPath).named.add(component.exportName);
114
+ this.componentsUsed.add(component.name);
115
+ }
116
+
117
+ addNamedImport(source, name) {
118
+ this.importEntry(source).named.add(name);
119
+ }
120
+
121
+ requireComponent(name) {
122
+ const component = this.registry.components.get(name);
123
+ if (component) this.addImport(component);
124
+ return component;
125
+ }
126
+
127
+ stateName(base) {
128
+ let name = camelCase(base);
129
+ if (!/^[a-z]/.test(name)) name = 'v' + name;
130
+ let candidate = name;
131
+ let n = 2;
132
+ while (this.usedStateNames.has(candidate)) candidate = name + n++;
133
+ this.usedStateNames.add(candidate);
134
+ return candidate;
135
+ }
136
+
137
+ addState(base, initial) {
138
+ const name = this.stateName(base);
139
+ const setter = 'set' + name[0].toUpperCase() + name.slice(1);
140
+ this.states.push({name, setter, initial});
141
+ return {name, setter};
142
+ }
143
+
144
+ // ── value → JSX prop text ────────────────────────────────────────────
145
+
146
+ jsxValue(value) {
147
+ if (value === true) return null; // bare flag
148
+ if (value === false) return '{false}';
149
+ if (typeof value === 'number') return `{${value}}`;
150
+ if (typeof value === 'string') return `"${escapeAttr(value)}"`;
151
+ if (value && value.__expr) return `{${value.__expr}}`;
152
+ if (Array.isArray(value)) {
153
+ return `{[${value.map(v => (typeof v === 'string' ? `'${v}'` : String(v))).join(', ')}]}`;
154
+ }
155
+ if (typeof value === 'object' && value !== null) {
156
+ const inner = Object.entries(value)
157
+ .map(([k, v]) => `${k}: ${typeof v === 'string' ? `'${v}'` : String(v)}`)
158
+ .join(', ');
159
+ return `{{${inner}}}`;
160
+ }
161
+ return `{${String(value)}}`;
162
+ }
163
+
164
+ propText(key, value) {
165
+ const v = this.jsxValue(value);
166
+ return v === null ? key : `${key}=${v}`;
167
+ }
168
+
169
+ // ── scaffolding ──────────────────────────────────────────────────────
170
+
171
+ initialFor(prop, node) {
172
+ const type = (prop.type || '').trim();
173
+ if (node.bound.component.name === 'TabList') {
174
+ const tabs = node.children.filter(c => c.kind === 'node');
175
+ const chosen = tabs.find(t => t.selected) || tabs[0];
176
+ return chosen ? `'${slugify(chosen.payload || chosen.name)}'` : `''`;
177
+ }
178
+ if (type === 'string') return `''`;
179
+ if (type.startsWith('boolean')) return 'false';
180
+ if (type === 'number') return '1';
181
+ if (prop.enumValues && prop.enumValues.length > 0) {
182
+ const first = prop.enumValues[0];
183
+ return typeof first === 'string' ? `'${first}'` : String(first);
184
+ }
185
+ return 'undefined';
186
+ }
187
+
188
+ scaffoldState(node, component, props) {
189
+ for (const valueKey of ['value', 'page']) {
190
+ const valueProp = component.props.get(valueKey);
191
+ const onChange = component.props.get('onChange');
192
+ if (!valueProp || !valueProp.required || !onChange) continue;
193
+ if (props.has(valueKey)) continue;
194
+ const base = node.payload || node.id || component.name;
195
+ const {name, setter} = this.addState(base, this.initialFor(valueProp, node));
196
+ props.set(valueKey, {__expr: name});
197
+ if (!props.has('onChange')) props.set('onChange', {__expr: setter});
198
+ return;
199
+ }
200
+ }
201
+
202
+ fillRequired(node, component, props, slotKeys) {
203
+ for (const prop of component.props.values()) {
204
+ if (!prop.required || props.has(prop.name) || slotKeys.has(prop.name)) continue;
205
+ if (prop.name === 'children') continue; // handled by child emission
206
+ if (prop.name === 'value' || prop.name === 'onChange' || prop.name === 'page') continue;
207
+ if (prop.isFunction) { props.set(prop.name, {__expr: '() => {}'}); continue; }
208
+ if (prop.enumValues && prop.enumValues.length > 0) {
209
+ props.set(prop.name, prop.enumValues[0]);
210
+ continue;
211
+ }
212
+ if (prop.type === 'string') {
213
+ props.set(prop.name, node.payload || component.name);
214
+ continue;
215
+ }
216
+ // Unfillable required prop (data arrays etc.) — structured TODO.
217
+ this.todos.push(`${component.name}.${prop.name} (required ${prop.type}) needs real data`);
218
+ props.set(prop.name, {__expr: `undefined /* TODO(xle): required ${prop.type} */`});
219
+ }
220
+ }
221
+
222
+ // ── node emission ────────────────────────────────────────────────────
223
+
224
+ emitItems(items, depth, context = {}) {
225
+ const lines = [];
226
+ for (const item of items) {
227
+ if (item.kind === 'group') {
228
+ const count = item.repeat || 1;
229
+ for (let i = 1; i <= count; i++) {
230
+ for (const child of item.children) {
231
+ const clone = count > 1 ? cloneItem(child) : child;
232
+ if (count > 1) substituteCounter(clone, i);
233
+ lines.push(...this.emitItems([clone], depth, context));
234
+ }
235
+ }
236
+ continue;
237
+ }
238
+ const count = item.repeat || 1;
239
+ for (let i = 1; i <= count; i++) {
240
+ const clone = count > 1 ? cloneItem(item) : item;
241
+ if (count > 1) {
242
+ clone.repeat = null;
243
+ substituteCounter(clone, i);
244
+ }
245
+ lines.push(...this.emitNode(clone, depth, context));
246
+ }
247
+ }
248
+ return lines;
249
+ }
250
+
251
+ emitNode(node, depth, context = {}) {
252
+ if (!node.bound) {
253
+ // Anonymous block reference — emit just the block, no wrapper element.
254
+ if (node.hint) return this.emitHint(node.hint, depth);
255
+ return [`${INDENT.repeat(depth)}{/* unresolved: ${node.name} */}`];
256
+ }
257
+ const component = node.bound.component;
258
+ this.addImport(component);
259
+ const pad = INDENT.repeat(depth);
260
+
261
+ const props = new Map(node.bound.props);
262
+ let textChild = null;
263
+
264
+ // Payload → primary text prop, else text child. Required props win
265
+ // (Button.label) over optional ones; a required children slot wins
266
+ // over an optional label (Link).
267
+ if (node.payload != null) {
268
+ node.payload = unescapeCounterText(node.payload);
269
+ const required = PAYLOAD_PROPS.find(p => component.props.get(p)?.required);
270
+ const childrenRequired = component.props.get('children')?.required;
271
+ const target = required
272
+ || (childrenRequired ? null : PAYLOAD_PROPS.find(p => component.props.has(p)));
273
+ if (target && !props.has(target)) props.set(target, node.payload);
274
+ else textChild = node.payload;
275
+ }
276
+ if (node.payload2 != null && component.props.has('value') && !component.props.get('value').required) {
277
+ props.set('value', node.payload2);
278
+ }
279
+
280
+ // `fill` in a stack context → wrap in StackItem.
281
+ const needsFillWrap = context.inStack && props.get('__fill') === true;
282
+
283
+ // Tab value synthesis inside TabList.
284
+ if (component.name === 'Tab' && !props.has('value')) {
285
+ props.set('value', slugify(node.payload || node.name));
286
+ }
287
+
288
+ this.scaffoldState(node, component, props);
289
+
290
+ // Trigger binding: opens=#id → onClick TODO.
291
+ const opens = props.get('__opens');
292
+ props.delete('__opens');
293
+ props.delete('__fill');
294
+ if (opens) {
295
+ const target = opens.idref || opens;
296
+ const handler = component.props.has('onClick') ? 'onClick' : 'clickAction';
297
+ props.set(handler, {__expr: `() => {/* TODO(xle): open #${target.idref ?? target} */}`});
298
+ }
299
+
300
+ // Slots → element-valued props (keys collected first so required-prop
301
+ // fill knows they're already satisfied).
302
+ const slotKeys = new Set(node.bound.slots.map(s => s.key));
303
+ this.fillRequired(node, component, props, slotKeys);
304
+
305
+ const slotEntries = [];
306
+ for (const slot of node.bound.slots) {
307
+ slotEntries.push([slot.key, this.emitSlotValue(slot, depth + 1)]);
308
+ }
309
+
310
+ // Structural routing.
311
+ let children = node.children;
312
+ if (component.name === 'Layout') {
313
+ const routed = this.routeLayoutChildren(node, props, slotEntries, depth);
314
+ children = routed;
315
+ } else if (component.name === 'Table') {
316
+ children = this.partitionTableChildren(node);
317
+ }
318
+
319
+ const childContext = {
320
+ inStack: component.name === 'VStack' || component.name === 'HStack',
321
+ };
322
+
323
+ // Mark fill-flagged children before emitting them.
324
+ for (const child of children) {
325
+ if (child.kind === 'node' && child.bound) {
326
+ const fillIdx = child.attrs.findIndex(a => a.kind === 'flag' && a.key === 'fill');
327
+ if (fillIdx !== -1 && childContext.inStack) {
328
+ child.bound.props.set('__fill', true);
329
+ child.bound.props.delete('fill');
330
+ }
331
+ }
332
+ }
333
+
334
+ const bodyLines = [];
335
+ if (node.hint) bodyLines.push(...this.emitHint(node.hint, depth + 1));
336
+ if (textChild != null) bodyLines.push(`${INDENT.repeat(depth + 1)}${textChild}`);
337
+ bodyLines.push(...this.emitItems(children, depth + 1, childContext));
338
+
339
+ // Required children with nothing to render → placeholder text.
340
+ const childrenProp = component.props.get('children');
341
+ if (bodyLines.length === 0 && childrenProp?.required) {
342
+ bodyLines.push(`${INDENT.repeat(depth + 1)}${node.payload || component.name}`);
343
+ }
344
+
345
+ const lines = this.renderTag(component, props, slotEntries, bodyLines, depth);
346
+ if (needsFillWrap) {
347
+ const stackItem = this.requireComponent('StackItem');
348
+ if (stackItem) {
349
+ return [
350
+ `${pad}<${stackItem.exportName} size="fill">`,
351
+ ...lines.map(l => INDENT + l),
352
+ `${pad}</${stackItem.exportName}>`,
353
+ ];
354
+ }
355
+ }
356
+ return lines;
357
+ }
358
+
359
+ renderTag(component, props, slotEntries, bodyLines, depth) {
360
+ const pad = INDENT.repeat(depth);
361
+ const propTexts = [...props.entries()].map(([k, v]) => this.propText(k, v));
362
+ const slotTexts = slotEntries.map(([key, valueLines]) => ({key, valueLines}));
363
+ const tag = component.exportName;
364
+
365
+ const open = [];
366
+ const simple = slotTexts.length === 0 && propTexts.join(' ').length <= 60;
367
+ if (simple) {
368
+ const attrs = propTexts.length > 0 ? ' ' + propTexts.join(' ') : '';
369
+ if (bodyLines.length === 0) return [`${pad}<${tag}${attrs} />`];
370
+ open.push(`${pad}<${tag}${attrs}>`);
371
+ } else {
372
+ open.push(`${pad}<${tag}`);
373
+ for (const text of propTexts) open.push(`${pad}${INDENT}${text}`);
374
+ for (const {key, valueLines} of slotTexts) {
375
+ open.push(`${pad}${INDENT}${key}={`);
376
+ open.push(...valueLines.map(l => `${pad}${INDENT}${INDENT}${l.trimStart() === l ? l : l}`));
377
+ open.push(`${pad}${INDENT}}`);
378
+ }
379
+ if (bodyLines.length === 0) {
380
+ open.push(`${pad}/>`);
381
+ return open;
382
+ }
383
+ open.push(`${pad}>`);
384
+ }
385
+ return [...open, ...bodyLines, `${pad}</${tag}>`];
386
+ }
387
+
388
+ emitSlotValue(slot, _depth) {
389
+ const value = slot.value;
390
+ if (value == null) {
391
+ return [`${INDENT.repeat(0)}null /* TODO(xle): @${slot.key} */`];
392
+ }
393
+ if (typeof value === 'string') {
394
+ const text = this.requireComponent('Text');
395
+ return [text ? `<${text.exportName}>${value}</${text.exportName}>` : `<>${value}</>`];
396
+ }
397
+ if (value.hint) {
398
+ return [
399
+ '<>',
400
+ ...this.emitHint(value.hint, 1).map(l => l.trimStart()).map(l => INDENT + l),
401
+ '</>',
402
+ ];
403
+ }
404
+ if (value.idref) {
405
+ return [`null /* TODO(xle): reference #${value.idref} */`];
406
+ }
407
+ if (value.subexpr) {
408
+ const lines = this.emitItems(value.subexpr, 0, {});
409
+ if (value.subexpr.length > 1) return ['<>', ...lines.map(l => INDENT + l), '</>'];
410
+ return lines;
411
+ }
412
+ return ['null'];
413
+ }
414
+
415
+ emitHint(hint, depth) {
416
+ const pad = INDENT.repeat(depth);
417
+ if (hint.block) {
418
+ const key = hint.block.name;
419
+ const mod = this.blockModules.get(key) || this.blockModules.get(key.toLowerCase());
420
+ if (mod) {
421
+ const name = this.referenceBlock(key, mod);
422
+ if (name) return [`${pad}<${name} />`];
423
+ }
424
+ // Source not available (e.g. browser, or unreadable) → pointer marker.
425
+ this.todos.push(`reference block ${hint.block.name}`);
426
+ const flags = hint.flags.length > 0 ? ` (+${hint.flags.join(' +')})` : '';
427
+ const arg = hint.arg ? `:${hint.arg}` : '';
428
+ return [
429
+ `${pad}{/* TODO(xle): content block '${hint.block.name}'${flags}${arg} — scaffold it with: astryx template ${hint.block.name} */}`,
430
+ ];
431
+ }
432
+ this.todos.push(`unresolved hint {${hint.name}}`);
433
+ return [`${pad}{/* TODO(xle): content '{${hint.name}}' (no matching block) */}`];
434
+ }
435
+
436
+ /**
437
+ * Register a referenced block (import-mode app component, or splice-mode
438
+ * template block co-defined once) and return the JSX component name to use.
439
+ */
440
+ referenceBlock(key, mod) {
441
+ if (this.splicedBlocks.has(key)) return this.splicedBlocks.get(key);
442
+
443
+ if (mod.mode === 'import') {
444
+ this.addNamedImport(mod.importPath, mod.componentName);
445
+ this.componentsUsed.add(mod.componentName);
446
+ this.splicedBlocks.set(key, mod.componentName);
447
+ return mod.componentName;
448
+ }
449
+
450
+ // splice mode — prepare from raw source, co-define, hoist imports.
451
+ const prepared = prepareSpliceModule(mod.source, mod.componentName);
452
+ if (!prepared) return null;
453
+ // Avoid name collisions with the generated component or another block.
454
+ let name = prepared.componentName;
455
+ if (name === this.componentName || [...this.splicedBlocks.values()].includes(name)) {
456
+ name = `${name}Block`;
457
+ }
458
+ const body = name === prepared.componentName
459
+ ? prepared.body
460
+ : prepared.body.replace(
461
+ new RegExp(`\\bfunction ${prepared.componentName}\\b`),
462
+ `function ${name}`,
463
+ );
464
+ mergeImports(this.imports, prepared.imports);
465
+ this.blockDefs.push(body);
466
+ this.componentsUsed.add(name);
467
+ this.splicedBlocks.set(key, name);
468
+ return name;
469
+ }
470
+
471
+ routeLayoutChildren(node, props, slotEntries, depth) {
472
+ const family = {
473
+ LayoutHeader: 'header',
474
+ LayoutContent: 'content',
475
+ LayoutFooter: 'footer',
476
+ };
477
+ const assigned = new Set(slotEntries.map(([k]) => k));
478
+ const loose = [];
479
+ let panelCount = 0;
480
+ for (const child of node.children) {
481
+ const childName = child.bound?.component?.name;
482
+ let slotName = family[childName];
483
+ if (childName === 'LayoutPanel') {
484
+ slotName = panelCount === 0 ? 'start' : 'end';
485
+ panelCount++;
486
+ }
487
+ if (slotName && !assigned.has(slotName)) {
488
+ assigned.add(slotName);
489
+ slotEntries.push([slotName, this.emitItems([child], 0, {})]);
490
+ } else {
491
+ loose.push(child);
492
+ }
493
+ }
494
+ // Loose children auto-wrap into an implicit LayoutContent slot.
495
+ if (loose.length > 0 && !assigned.has('content')) {
496
+ const lc = this.requireComponent('LayoutContent');
497
+ const inner = this.emitItems(loose, 1, {});
498
+ slotEntries.push(['content', [`<${lc.exportName}>`, ...inner, `</${lc.exportName}>`]]);
499
+ } else if (loose.length > 0) {
500
+ this.todos.push('Layout had extra children but content was already assigned');
501
+ }
502
+ void props; void depth;
503
+ return [];
504
+ }
505
+
506
+ /** Expand groups/repeats into a flat node list (clones carry repeat=null). */
507
+ flattenItems(items) {
508
+ const out = [];
509
+ for (const item of items) {
510
+ const count = item.repeat || 1;
511
+ for (let i = 1; i <= count; i++) {
512
+ const clone = count > 1 ? cloneItem(item) : item;
513
+ if (count > 1) {
514
+ clone.repeat = null;
515
+ substituteCounter(clone, i);
516
+ }
517
+ if (clone.kind === 'group') out.push(...this.flattenItems(clone.children));
518
+ else out.push(clone);
519
+ }
520
+ }
521
+ return out;
522
+ }
523
+
524
+ partitionTableChildren(node) {
525
+ const flat = this.flattenItems(node.children);
526
+ const rows = flat.filter(c => c.kind === 'node' && c.bound?.component?.name === 'TableRow');
527
+ if (rows.length === 0 || rows.length !== flat.length) return node.children;
528
+ const isHeaderRow = (row) => {
529
+ const cells = this.flattenItems(row.children);
530
+ return cells.length > 0 && cells.every(c => c.bound?.component?.name === 'TableHeaderCell');
531
+ };
532
+ const headerRows = rows.filter(isHeaderRow);
533
+ const bodyRows = rows.filter(r => !isHeaderRow(r));
534
+ const wrap = (name, rowNodes) => {
535
+ const comp = this.requireComponent(name);
536
+ const wrapper = {
537
+ kind: 'node', name, id: null, enumMods: [], payload: null, payload2: null,
538
+ attrs: [], slots: [], hint: null, repeat: null, selected: false,
539
+ children: rowNodes, line: node.line, col: node.col,
540
+ bound: {component: comp, props: new Map(), slots: [], stray: []},
541
+ };
542
+ return wrapper;
543
+ };
544
+ const out = [];
545
+ if (headerRows.length > 0) out.push(wrap('TableHeader', headerRows));
546
+ if (bodyRows.length > 0) out.push(wrap('TableBody', bodyRows));
547
+ return out;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Expand a validated document into a complete TSX module.
553
+ *
554
+ * @param {{roots: any[], overlays: any[]}} doc
555
+ * @param {object} registry
556
+ * @param {{componentName?: string}} [options]
557
+ * @returns {{code: string, componentsUsed: string[], states: number, todos: string[]}}
558
+ */
559
+ export function expand(doc, registry, options = {}) {
560
+ const emitter = new Emitter(registry, options);
561
+
562
+ const rootLines = emitter.emitItems(doc.roots, 2, {});
563
+ const overlayLines = doc.overlays.length > 0
564
+ ? [
565
+ `${INDENT.repeat(2)}{/* overlays — wire open state to the matching opens=#id trigger */}`,
566
+ ...emitter.emitItems(doc.overlays, 2, {}),
567
+ ]
568
+ : [];
569
+
570
+ const needsFragment = doc.roots.length + (doc.overlays.length > 0 ? 1 : 0) > 1
571
+ || doc.roots.length > 1
572
+ || (doc.overlays.length > 0);
573
+
574
+ const body = [];
575
+ if (needsFragment) {
576
+ body.push(`${INDENT.repeat(1)}return (`, `${INDENT.repeat(2)}<>`);
577
+ body.push(...[...rootLines, ...overlayLines].map(l => INDENT + l));
578
+ body.push(`${INDENT.repeat(2)}</>`, `${INDENT.repeat(1)});`);
579
+ } else {
580
+ body.push(`${INDENT.repeat(1)}return (`);
581
+ body.push(...rootLines);
582
+ body.push(`${INDENT.repeat(1)});`);
583
+ }
584
+
585
+ // useState is just another named import from 'react' so it merges with any
586
+ // hooks a spliced block brought in (e.g. a Table block's useState).
587
+ if (emitter.states.length > 0) emitter.addNamedImport('react', 'useState');
588
+
589
+ const importLines = [];
590
+ const sortedImports = [...emitter.imports.entries()].sort(([a], [b]) => a.localeCompare(b));
591
+ for (const [importPath, entry] of sortedImports) {
592
+ importLines.push(renderImport(importPath, entry));
593
+ }
594
+
595
+ const stateLines = emitter.states.map(
596
+ s => `${INDENT}const [${s.name}, ${s.setter}] = useState(${s.initial});`,
597
+ );
598
+
599
+ // Co-defined template blocks live between imports and the page component.
600
+ const blockSection = emitter.blockDefs.length > 0
601
+ ? ['', ...emitter.blockDefs.flatMap(b => [b, ''])]
602
+ : [];
603
+
604
+ const code = [
605
+ `// Generated by \`astryx layout expand\` — this file is the artifact; edit freely.`,
606
+ ...importLines,
607
+ ...blockSection,
608
+ '',
609
+ `export default function ${emitter.componentName}() {`,
610
+ ...stateLines,
611
+ ...body,
612
+ '}',
613
+ '',
614
+ ].join('\n');
615
+
616
+ return {
617
+ code,
618
+ componentsUsed: [...emitter.componentsUsed].sort(),
619
+ states: emitter.states.length,
620
+ todos: emitter.todos,
621
+ };
622
+ }