@specverse/engines 4.2.1 → 4.3.0

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 (54) hide show
  1. package/dist/inference/core/specly-converter.d.ts.map +1 -1
  2. package/dist/inference/core/specly-converter.js +43 -22
  3. package/dist/inference/core/specly-converter.js.map +1 -1
  4. package/dist/inference/index.d.ts.map +1 -1
  5. package/dist/inference/index.js +29 -0
  6. package/dist/inference/index.js.map +1 -1
  7. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  8. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  9. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  10. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  11. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  12. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  13. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  14. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  15. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  16. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  17. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  18. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  19. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  20. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  21. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  22. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  23. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  24. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  25. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  26. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  27. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  28. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  29. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  30. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  31. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  32. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  33. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  34. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  35. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  36. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  37. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  38. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  39. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  40. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  41. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  42. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  43. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  44. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  45. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  46. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  47. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  48. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  49. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  50. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  51. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  52. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  53. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  54. package/package.json +2 -1
@@ -1,189 +1,22 @@
1
1
  /**
2
- * Dashboard-view body composer for ReactAppStarter
2
+ * Dashboard-view body composer for ReactAppStarter (Phase 3)
3
3
  *
4
- * Minimal starter-kit dashboard: metric cards derived from the list
5
- * query (total count; enum-field breakdowns if the model has a status-
6
- * like attribute), plus a compact preview of recent records.
7
- *
8
- * Aggregation / charts are deferred — they need backend endpoints
9
- * (`/api/posts/metrics`, `/api/posts/by-month`, …) that the starter
10
- * kit doesn't assume. TODO comments flag the extension points.
4
+ * Thin wrapper around walkPattern('dashboard-view'). The pattern
5
+ * registry + section resolvers (metric-cards, charts-placeholder,
6
+ * recent-items) decide what a dashboard view looks like; this
7
+ * composer just emits the resulting tree to JSX source.
11
8
  */
12
9
 
13
- import { METADATA_FIELDS } from '@specverse/runtime/views/core';
14
- import type { EmitContext, ModelSpec } from './view-emitter.js';
15
- import { buildFKMap } from './belongs-to.js';
16
-
17
- const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
10
+ import { walkPattern } from '@specverse/runtime/views/core';
11
+ import type { EmitContext } from './view-emitter.js';
12
+ import { emitJsxSource } from './emit-jsx-source.js';
18
13
 
19
14
  export function composeDashboardBody(context: EmitContext): string {
20
- const modelName = context.model.name;
21
- const previewColumns = inferPreviewColumns(context.model);
22
- const enumFields = inferEnumFields(context.model);
23
- const fkMap = buildFKMap(context.model);
24
-
25
- const lines: string[] = [];
26
-
27
- // ── Metrics row ────────────────────────────────────────────────────
28
- lines.push('<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">');
29
- lines.push(...renderTotalCard(modelName));
30
- for (const enumField of enumFields) {
31
- lines.push(...renderEnumBreakdownCard(enumField));
32
- }
33
- if (enumFields.length === 0) {
34
- lines.push(...renderPlaceholderCard());
35
- }
36
- lines.push('</div>');
37
-
38
- // ── TODO: aggregation metrics ──────────────────────────────────────
39
- lines.push('');
40
- lines.push('{/* TODO: add aggregation metrics (averages / sums / time series)');
41
- lines.push(' by adding backend endpoints and per-metric hooks. Wire them in');
42
- lines.push(' alongside the count cards above. */}');
43
-
44
- // ── Recent-items preview ───────────────────────────────────────────
45
- lines.push('');
46
- lines.push('<div className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">');
47
- lines.push(' <div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3">');
48
- lines.push(' <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-200 uppercase tracking-wider">');
49
- lines.push(` Recent ${humanize(modelName)}s`);
50
- lines.push(' </h3>');
51
- lines.push(' </div>');
52
-
53
- if (previewColumns.length === 0) {
54
- lines.push(' <div className="px-6 py-4 text-sm text-gray-400">No displayable fields.</div>');
55
- } else {
56
- lines.push(' <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">');
57
- lines.push(' <thead>');
58
- lines.push(' <tr>');
59
- for (const col of previewColumns) {
60
- const fk = fkMap.get(col);
61
- const headerLabel = humanize(fk ? fk.name : col);
62
- lines.push(
63
- ` <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">` +
64
- headerLabel +
65
- `</th>`
66
- );
67
- }
68
- lines.push(' </tr>');
69
- lines.push(' </thead>');
70
- lines.push(' <tbody className="divide-y divide-gray-200 dark:divide-gray-700">');
71
- lines.push(' {preview.map((item, idx) => (');
72
- lines.push(' <tr');
73
- lines.push(' key={idx}');
74
- lines.push(' onClick={() => onSelect?.(item)}');
75
- lines.push(' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"');
76
- lines.push(' >');
77
- for (const col of previewColumns) {
78
- const fk = fkMap.get(col);
79
- const expr = fk
80
- ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}`
81
- : `{String((item as any).${col} ?? '')}`;
82
- lines.push(
83
- ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
84
- `${expr}</td>`
85
- );
86
- }
87
- lines.push(' </tr>');
88
- lines.push(' ))}');
89
- lines.push(' {preview.length === 0 && (');
90
- lines.push(` <tr><td colSpan={${previewColumns.length}} className="px-6 py-4 text-sm text-gray-400">No records yet.</td></tr>`);
91
- lines.push(' )}');
92
- lines.push(' </tbody>');
93
- lines.push(' </table>');
94
- }
95
- lines.push('</div>');
96
-
97
- return lines.join('\n');
98
- }
99
-
100
- // ──────────────────────────────────────────────────────────────────────
101
- // Card rendering
102
- // ──────────────────────────────────────────────────────────────────────
103
-
104
- function renderTotalCard(modelName: string): string[] {
105
- const pluralLower = humanize(modelName).toLowerCase() + 's';
106
- return [
107
- ' <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">',
108
- ' <p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">',
109
- ` Total ${pluralLower}`,
110
- ' </p>',
111
- ' <p className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">',
112
- ' {items.length}',
113
- ' </p>',
114
- ' </div>',
115
- ];
116
- }
117
-
118
- function renderEnumBreakdownCard(field: EnumField): string[] {
119
- // One card per enum value — shows count of records where the field
120
- // equals that value.
121
- const cards: string[] = [];
122
- for (const value of field.values) {
123
- cards.push(
124
- ' <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">',
125
- ' <p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">',
126
- ` ${humanize(field.name)}: ${humanize(value)}`,
127
- ' </p>',
128
- ' <p className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">',
129
- ` {items.filter((i: any) => i.${field.name} === ${JSON.stringify(value)}).length}`,
130
- ' </p>',
131
- ' </div>',
132
- );
133
- }
134
- return cards;
135
- }
136
-
137
- function renderPlaceholderCard(): string[] {
138
- return [
139
- ' <div className="rounded-lg border border-dashed border-gray-300 p-6 text-sm text-gray-400 dark:border-gray-600">',
140
- ' Add a metric here — e.g. a sum, average, or time-windowed count.',
141
- ' </div>',
142
- ];
143
- }
144
-
145
- // ──────────────────────────────────────────────────────────────────────
146
- // Field inference
147
- // ──────────────────────────────────────────────────────────────────────
148
-
149
- interface EnumField {
150
- name: string;
151
- values: string[];
152
- }
153
-
154
- function inferEnumFields(model: ModelSpec): EnumField[] {
155
- const out: EnumField[] = [];
156
- const attrs = model.attributes ?? {};
157
- for (const [name, rawDef] of Object.entries(attrs)) {
158
- const def = rawDef as { values?: string[] };
159
- const values = def?.values;
160
- if (Array.isArray(values) && values.length > 0 && values.length <= 6) {
161
- out.push({ name, values });
162
- }
163
- }
164
- // Cap: one enum breakdown in the card row to avoid card sprawl. The
165
- // remaining enum fields stay available in the list view; users can
166
- // promote more breakdowns manually.
167
- return out.slice(0, 1);
168
- }
169
-
170
- function inferPreviewColumns(model: ModelSpec): string[] {
171
- const attrs = model.attributes ?? {};
172
- const attrCols = Object.keys(attrs).filter(n => !METADATA_FIELD_NAMES.has(n));
173
-
174
- // Include belongsTo FK columns so the preview shows "who does this
175
- // belong to" at a glance. Matches the list-view convention.
176
- const fkCols = [...buildFKMap(model).keys()].filter(k => !attrCols.includes(k));
177
-
178
- // FKs are always useful at-a-glance, so keep them all. Cap the
179
- // combined set at 5 to keep the preview table readable without
180
- // crowding out the "who" columns.
181
- return [...fkCols, ...attrCols].slice(0, Math.max(5, fkCols.length + 1));
182
- }
183
-
184
- function humanize(name: string): string {
185
- return name
186
- .replace(/([A-Z])/g, ' $1')
187
- .replace(/^./, c => c.toUpperCase())
188
- .trim();
15
+ const imports = new Set<string>();
16
+ const nodes = walkPattern('dashboard-view', {
17
+ model: context.model,
18
+ modelSchemas: context.modelSchemas as any,
19
+ view: context.view,
20
+ });
21
+ return emitJsxSource(nodes, { imports, indent: 6 });
189
22
  }
@@ -1,135 +1,23 @@
1
1
  /**
2
- * Detail-view body composer for ReactAppStarter
2
+ * Detail-view body composer for ReactAppStarter (Phase 3)
3
3
  *
4
- * Renders the interior of a detail view as a JSX-safe definition list:
5
- * business fields first (prominent), belongsTo relationships resolved
6
- * to display names in a dedicated section, metadata fields last (muted).
7
- * The outer card + actions live in `skeletons/detail.tsx.template`.
8
- *
9
- * belongsTo FK columns are resolved via `resolveEntityDisplayName`
10
- * using option arrays (${relName}Options) wired in by view-emitter's
11
- * RELATED_HOOKS substitution.
4
+ * Thin wrapper around walkPattern('detail-view'). Section meanings
5
+ * (header / main / related-lists) and the rendering of each section
6
+ * live in the runtime pattern registry + section resolvers
7
+ * (field-list, related-tables); this composer just emits the
8
+ * resulting tree to JSX source.
12
9
  */
13
10
 
14
- import { METADATA_FIELDS } from '@specverse/runtime/views/core';
15
- import type { EmitContext, ModelSpec } from './view-emitter.js';
16
- import { extractBelongsToTargets, type BelongsToRel } from './belongs-to.js';
17
-
18
- /**
19
- * Metadata attribute names rendered in a muted style. Sourced from
20
- * the canonical pattern library so both this composer and the rest
21
- * of the system stay in sync automatically.
22
- */
23
- const METADATA_FIELD_NAMES = new Set(METADATA_FIELDS);
11
+ import { walkPattern } from '@specverse/runtime/views/core';
12
+ import type { EmitContext } from './view-emitter.js';
13
+ import { emitJsxSource } from './emit-jsx-source.js';
24
14
 
25
15
  export function composeDetailBody(context: EmitContext): string {
26
- // FK columns are excluded from business so we don't emit the raw
27
- // UUID next to the resolved name. They surface in the belongsTo
28
- // section instead, rendered via resolveEntityDisplayName.
29
- const belongsTo = extractBelongsToTargets(context.model);
30
- const fkExcludes = new Set(belongsTo.map(r => `${r.name}Id`));
31
- const { business, metadata } = partitionFields(context.model, fkExcludes);
32
-
33
- const lines: string[] = [];
34
- lines.push('<div className="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900">');
35
-
36
- if (business.length > 0) {
37
- lines.push(' <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">');
38
- for (const field of business) {
39
- lines.push(...renderField(field, { muted: false }));
40
- }
41
- lines.push(' </dl>');
42
- } else if (belongsTo.length === 0) {
43
- lines.push(' <p className="text-sm text-gray-400">No business fields defined for this model.</p>');
44
- }
45
-
46
- if (belongsTo.length > 0) {
47
- lines.push('');
48
- lines.push(' <dl className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 border-t border-gray-200 dark:border-gray-700 pt-4">');
49
- for (const rel of belongsTo) {
50
- lines.push(...renderRelationshipField(rel));
51
- }
52
- lines.push(' </dl>');
53
- }
54
-
55
- if (metadata.length > 0) {
56
- lines.push('');
57
- lines.push(' <dl className="mt-6 grid grid-cols-1 gap-2 sm:grid-cols-2 border-t border-gray-200 dark:border-gray-700 pt-4 text-xs text-gray-500 dark:text-gray-400">');
58
- for (const field of metadata) {
59
- lines.push(...renderField(field, { muted: true }));
60
- }
61
- lines.push(' </dl>');
62
- }
63
-
64
- lines.push('</div>');
65
- return lines.join('\n');
66
- }
67
-
68
- interface Field {
69
- name: string;
70
- /** Display label; defaults to humanize(name). */
71
- label?: string;
72
- }
73
-
74
- function partitionFields(
75
- model: ModelSpec,
76
- exclude: Set<string> = new Set(),
77
- ): { business: Field[]; metadata: Field[] } {
78
- const attrs = Object.keys(model.attributes ?? {});
79
- const business: Field[] = [];
80
- const metadata: Field[] = [];
81
- for (const name of attrs) {
82
- if (exclude.has(name)) continue;
83
- const field = { name, label: humanize(name) };
84
- if (METADATA_FIELD_NAMES.has(name)) {
85
- metadata.push(field);
86
- } else {
87
- business.push(field);
88
- }
89
- }
90
- return { business, metadata };
91
- }
92
-
93
- /**
94
- * Render a belongsTo row as a label + resolved display name. The
95
- * options array (${relName}Options) is populated by the hook call
96
- * wired into the detail skeleton via view-emitter's RELATED_HOOKS.
97
- */
98
- function renderRelationshipField(rel: BelongsToRel): string[] {
99
- const label = humanize(rel.name);
100
- const fkName = `${rel.name}Id`;
101
- const optionsVar = `${rel.name}Options`;
102
- return [
103
- ' <div>',
104
- ` <dt className="text-sm font-medium text-gray-500 dark:text-gray-400">${label}</dt>`,
105
- ` <dd className="mt-1 text-sm text-gray-900 dark:text-gray-100 break-words">` +
106
- `{resolveEntityDisplayName((item as any).${fkName}, ${optionsVar})}</dd>`,
107
- ' </div>',
108
- ];
109
- }
110
-
111
- function renderField(field: Field, opts: { muted: boolean }): string[] {
112
- const label = field.label ?? humanize(field.name);
113
- const labelCls = opts.muted
114
- ? 'font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500'
115
- : 'text-sm font-medium text-gray-500 dark:text-gray-400';
116
- const valueCls = opts.muted
117
- ? 'text-gray-500 dark:text-gray-400'
118
- : 'mt-1 text-sm text-gray-900 dark:text-gray-100 break-words';
119
-
120
- return [
121
- ' <div>',
122
- ` <dt className="${labelCls}">${label}</dt>`,
123
- ` <dd className="${valueCls}">` +
124
- `{String((item as any).${field.name} ?? '')}` +
125
- '</dd>',
126
- ' </div>',
127
- ];
128
- }
129
-
130
- function humanize(name: string): string {
131
- return name
132
- .replace(/([A-Z])/g, ' $1')
133
- .replace(/^./, c => c.toUpperCase())
134
- .trim();
16
+ const imports = new Set<string>();
17
+ const nodes = walkPattern('detail-view', {
18
+ model: context.model,
19
+ modelSchemas: context.modelSchemas as any,
20
+ view: context.view,
21
+ });
22
+ return emitJsxSource(nodes, { imports, indent: 6 });
135
23
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * JSX-source emitter for Factory B
3
+ *
4
+ * Consumes the pattern-walker's RenderNode tree and produces JSX
5
+ * text that can be written into a .tsx file. Pluggable into
6
+ * Factory B's per-model per-view emission; the composers are being
7
+ * migrated one view type at a time to use this path.
8
+ *
9
+ * Emitter is deliberately minimal:
10
+ * - Atomic nodes whose component is a lowercase HTML tag
11
+ * (div, table, tr, td, ...) → literal JSX element with props.
12
+ * - Named composite nodes (capitalised — DataTable, FieldList, ...)
13
+ * → treated as React component references; the emitter collects
14
+ * them into the imports set so view-emitter can add the
15
+ * imports at the top of the file.
16
+ * - Special pseudo-components (__text__, __when_empty__, __todo__)
17
+ * → each handled inline.
18
+ *
19
+ * Round-trip format: the output is readable, indented JSX. Not
20
+ * prettier-perfect but close.
21
+ */
22
+
23
+ import type { RenderNode } from '@specverse/runtime/views/core';
24
+
25
+ export interface EmitOptions {
26
+ /** Set of component names referenced (composite components only,
27
+ * not HTML tags). View-emitter injects these as imports at the
28
+ * top of the generated .tsx file. */
29
+ imports: Set<string>;
30
+ /** Initial indent — defaults to 6 (matches the {{BODY}} placement
31
+ * inside the skeleton's <form>/<div> wrapper). */
32
+ indent?: number;
33
+ }
34
+
35
+ /**
36
+ * Emit a tree of RenderNodes to JSX source. Returns the JSX text;
37
+ * composite component names used are written into `options.imports`.
38
+ */
39
+ export function emitJsxSource(
40
+ nodes: RenderNode[],
41
+ options: EmitOptions,
42
+ ): string {
43
+ const indent = options.indent ?? 0;
44
+ return nodes.map(n => emitNode(n, indent, options.imports)).join('\n');
45
+ }
46
+
47
+ /**
48
+ * Width past which we break the attr list onto its own lines.
49
+ * Keeps short elements compact (`<th className="...">Name</th>`)
50
+ * while long attribute lists (an input with required + value +
51
+ * onChange + ...) go multi-line for readability.
52
+ */
53
+ const ATTR_BREAK_WIDTH = 80;
54
+
55
+ function emitNode(node: RenderNode, indent: number, imports: Set<string>): string {
56
+ const pad = ' '.repeat(indent);
57
+
58
+ // Pseudo-components — intercepted before the generic path.
59
+ if (node.component === '__text__') {
60
+ return `${pad}${(node.props?.text as string) ?? ''}`;
61
+ }
62
+ if (node.component === '__todo__') {
63
+ const msg = (node.props?.message as string) ?? 'TODO';
64
+ return `${pad}{/* TODO: ${msg} */}`;
65
+ }
66
+ if (node.component === '__when_empty__') {
67
+ const source = (node.props?.source as string) ?? 'items';
68
+ const inner = (node.children ?? [])
69
+ .map(c => emitNode(c, indent + 2, imports))
70
+ .join('\n');
71
+ return [
72
+ `${pad}{${source}.length === 0 && (`,
73
+ inner,
74
+ `${pad})}`,
75
+ ].join('\n');
76
+ }
77
+ if (node.component === '__when__') {
78
+ // Generic conditional render — wraps children in
79
+ // `{condition && (...)}`. Used by detail-view tabbed lists where
80
+ // each panel renders only if `relatedTab === 'X'`.
81
+ const condition = (node.props?.condition as string) ?? 'true';
82
+ const inner = (node.children ?? [])
83
+ .map(c => emitNode(c, indent + 2, imports))
84
+ .join('\n');
85
+ return [
86
+ `${pad}{${condition} && (`,
87
+ inner,
88
+ `${pad})}`,
89
+ ].join('\n');
90
+ }
91
+
92
+ const isComposite = /^[A-Z]/.test(node.component);
93
+ if (isComposite) imports.add(node.component);
94
+
95
+ const attrParts = formatAttrParts(node.props, node.expressions);
96
+ const inlineAttrs = attrParts.length > 0 ? ' ' + attrParts.join(' ') : '';
97
+
98
+ // "Compact" elements — a single short text child, or inline
99
+ // expression content — get attrs inline regardless of length.
100
+ // Readability gain (long classNames on a header don't outweigh
101
+ // the ceremony of multi-line attrs for a one-word label).
102
+ const hasInlineExpression = Boolean(
103
+ node.expressions?.children && (!node.children || node.children.length === 0)
104
+ );
105
+ const hasSingleTextChild = Boolean(
106
+ node.children?.length === 1 &&
107
+ node.children[0].component === '__text__' &&
108
+ !node.children[0].iterate
109
+ );
110
+ const isCompact = hasInlineExpression || hasSingleTextChild;
111
+
112
+ const shouldBreakAttrs = !isCompact && inlineAttrs.length > ATTR_BREAK_WIDTH;
113
+ const multiLineAttrs = attrParts.length > 0
114
+ ? '\n' + attrParts.map(a => ' '.repeat(indent + 2) + a).join('\n') + '\n' + pad
115
+ : '';
116
+ const attrs = shouldBreakAttrs ? multiLineAttrs : inlineAttrs;
117
+
118
+ // Inline-expression content: `<tag>{expr}</tag>` on one line.
119
+ if (hasInlineExpression) {
120
+ return `${pad}<${node.component}${attrs}>{${node.expressions!.children}}</${node.component}>`;
121
+ }
122
+
123
+ // Single-text-child shortcut: `<tag>text</tag>` on one line.
124
+ if (hasSingleTextChild) {
125
+ const text = (node.children![0].props?.text as string) ?? '';
126
+ return `${pad}<${node.component}${attrs}>${text}</${node.component}>`;
127
+ }
128
+
129
+ const bodyLines = renderChildren(node, indent + 2, imports);
130
+
131
+ // Void elements — no children allowed, self-closing.
132
+ if (VOID_ELEMENTS.has(node.component) && bodyLines === null) {
133
+ return `${pad}<${node.component}${attrs} />`;
134
+ }
135
+
136
+ // No children → empty element.
137
+ if (bodyLines === null || bodyLines === '') {
138
+ return `${pad}<${node.component}${attrs} />`;
139
+ }
140
+
141
+ // Multi-line element with nested children.
142
+ return [
143
+ `${pad}<${node.component}${attrs}>`,
144
+ bodyLines,
145
+ `${pad}</${node.component}>`,
146
+ ].join('\n');
147
+ }
148
+
149
+ /**
150
+ * Render children as a string. Respects `iterate` — wraps children
151
+ * in a `.map(...)` when present. Returns `null` if the node has no
152
+ * content at all (caller may decide to self-close).
153
+ */
154
+ function renderChildren(node: RenderNode, indent: number, imports: Set<string>): string | null {
155
+ const pad = ' '.repeat(indent);
156
+ const children = node.children ?? [];
157
+
158
+ if (node.iterate) {
159
+ const { source, itemName, keyExpression } = node.iterate;
160
+ // The iterate wraps the WHOLE element-with-its-children, not
161
+ // just the children list. Caller (emitNode) uses the fact that
162
+ // `iterate` is present on the node itself; this path is
163
+ // unreachable in the current shape.
164
+ // Placeholder — iterate is handled by the parent context below.
165
+ return children.map(c => emitNode(c, indent, imports)).join('\n');
166
+ }
167
+
168
+ if (children.length === 0 && !node.expressions?.children) return null;
169
+ if (children.length === 0 && node.expressions?.children) return '';
170
+
171
+ return children
172
+ .map(c => {
173
+ if (c.iterate) return emitIterated(c, indent, imports);
174
+ return emitNode(c, indent, imports);
175
+ })
176
+ .join('\n');
177
+ }
178
+
179
+ /**
180
+ * Emit a node whose `iterate` produces N instances. The form is
181
+ * `{source.map((item, idx) => ( <element>...</element> ))}`.
182
+ */
183
+ function emitIterated(node: RenderNode, indent: number, imports: Set<string>): string {
184
+ const pad = ' '.repeat(indent);
185
+ const { source, itemName, keyExpression } = node.iterate!;
186
+ // Strip iterate so emitNode doesn't see it and re-wrap.
187
+ const inner = emitNode({ ...node, iterate: undefined }, indent + 2, imports);
188
+ const args = keyExpression ? `(${itemName}, ${keyExpression})` : `(${itemName})`;
189
+ return [
190
+ `${pad}{${source}.map(${args} => (`,
191
+ inner,
192
+ `${pad}))}`,
193
+ ].join('\n');
194
+ }
195
+
196
+ /**
197
+ * Return each attribute as its own formatted string — caller
198
+ * decides whether to join with spaces (inline) or newlines
199
+ * (multi-line readability).
200
+ */
201
+ function formatAttrParts(
202
+ props: Record<string, unknown> | undefined,
203
+ expressions: Record<string, string> | undefined,
204
+ ): string[] {
205
+ const parts: string[] = [];
206
+
207
+ for (const [k, v] of Object.entries(props ?? {})) {
208
+ if (v === undefined || v === null) continue;
209
+ parts.push(formatStaticProp(k, v));
210
+ }
211
+
212
+ for (const [k, expr] of Object.entries(expressions ?? {})) {
213
+ if (k === 'children') continue; // element content, not attr
214
+ parts.push(`${k}={${expr}}`);
215
+ }
216
+
217
+ return parts;
218
+ }
219
+
220
+ function formatStaticProp(name: string, value: unknown): string {
221
+ if (value === true) return name;
222
+ if (typeof value === 'string') return `${name}=${JSON.stringify(value)}`;
223
+ if (typeof value === 'number' || typeof value === 'boolean') {
224
+ return `${name}={${value}}`;
225
+ }
226
+ return `${name}={${JSON.stringify(value)}}`;
227
+ }
228
+
229
+ // HTML void elements (no children, must self-close in JSX).
230
+ const VOID_ELEMENTS = new Set([
231
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img',
232
+ 'input', 'link', 'meta', 'source', 'track', 'wbr',
233
+ ]);