@specverse/engines 4.2.2 → 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.
- package/dist/inference/core/specly-converter.d.ts.map +1 -1
- package/dist/inference/core/specly-converter.js +43 -22
- package/dist/inference/core/specly-converter.js.map +1 -1
- package/dist/inference/index.d.ts.map +1 -1
- package/dist/inference/index.js +29 -0
- package/dist/inference/index.js.map +1 -1
- package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
- package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
- package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
- package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
- package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
- package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
- package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
- package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
- package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
- package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
- package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
- package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
- package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
- package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
- package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
- package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
- package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
- package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
- package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
- package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
- package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
- package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
- package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
- package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
- package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
- package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
- package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
- package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
- package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
- package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
- package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
- package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
- package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
- package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
- package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
- package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
- package/package.json +2 -2
package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
14
|
-
import type { EmitContext
|
|
15
|
-
import {
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
}
|
package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
15
|
-
import type { EmitContext
|
|
16
|
-
import {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
]);
|