@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.
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 -2
@@ -1,383 +1,23 @@
1
1
  /**
2
- * Form-view body composer for ReactAppStarter
2
+ * Form-view body composer for ReactAppStarter (Phase 3)
3
3
  *
4
- * Emits one <label>+<input> pair per editable attribute of the model.
5
- * Input type is inferred from the attribute's type + constraints
6
- * (mirrors the canonical mapping in
7
- * `entities/src/core/views/inference/component-mappings.json`, minus
8
- * the atomic-component indirection).
9
- *
10
- * Auto-generated fields (id, timestamps, `auto=...` markers) are
11
- * omitted — the backend assigns them.
12
- *
13
- * Relationship fields (belongsTo) are emitted as <select> dropdowns
14
- * backed by the related model's list query. The corresponding hook
15
- * calls and imports are wired by view-emitter via the RELATED_HOOKS /
16
- * RELATED_IMPORTS substitutions — this composer only emits the JSX
17
- * that consumes the `${relName}Options` variable.
4
+ * Thin wrapper around walkPattern('form-view'). 'What a form view
5
+ * looks like' lives in the runtime pattern registry + section
6
+ * resolvers (form-fields, existing-entities-list). Each resolver is
7
+ * a pure function producing a RenderNode tree; this composer just
8
+ * emits the tree to JSX source.
18
9
  */
19
10
 
20
- import { METADATA_FIELDS } from '@specverse/runtime/views/core';
21
- import type { EmitContext, ModelSpec } from './view-emitter.js';
22
- import { extractBelongsToTargets, type BelongsToRel } from './belongs-to.js';
23
-
24
- /**
25
- * Attribute names the backend always generates — never render as
26
- * inputs. Sourced from the pattern library's metadata set; any
27
- * attribute with `auto=...` or category=metadata is additionally
28
- * skipped via per-field checks in `selectFormFields` below.
29
- */
30
- const AUTO_GENERATED_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';
31
14
 
32
- /**
33
- * Compose the form body as JSX-safe source. Dropped at `{{BODY}}`.
34
- */
35
15
  export function composeFormBody(context: EmitContext): string {
36
- const belongsTo = extractBelongsToTargets(context.model);
37
- // FK columns shadowed by a belongsTo relationship are emitted in the
38
- // belongsTo section as <select>s — skip them in the main loop so we
39
- // don't render both a plain-text input and a dropdown for the same
40
- // column.
41
- const shadowedFKs = new Set(belongsTo.map(rel => `${rel.name}Id`));
42
- const fields = selectFormFields(context.model, shadowedFKs);
43
-
44
- const lines: string[] = [];
45
- lines.push('<div className="space-y-4">');
46
-
47
- if (fields.length === 0 && belongsTo.length === 0) {
48
- lines.push(' <p className="text-sm text-gray-400">No editable fields for this model.</p>');
49
- lines.push('</div>');
50
- return lines.join('\n');
51
- }
52
-
53
- for (const field of fields) {
54
- lines.push(...renderInput(field));
55
- }
56
-
57
- for (const rel of belongsTo) {
58
- lines.push(...renderRelationshipSelect(rel));
59
- }
60
-
61
- lines.push('</div>');
62
- return lines.join('\n');
63
- }
64
-
65
- /**
66
- * Emit a <select> dropdown for a belongsTo relationship. The options
67
- * array (`${relName}Options`) is populated by the hook call wired in
68
- * via view-emitter's RELATED_HOOKS substitution. Labels use
69
- * `getEntityDisplayName` so users see human names instead of UUIDs.
70
- */
71
- function renderRelationshipSelect(rel: BelongsToRel): string[] {
72
- const fkName = `${rel.name}Id`;
73
- const varName = `${rel.name}Options`;
74
- const label = humanize(rel.name);
75
-
76
- return [
77
- ' <div>',
78
- ` <label className="${LABEL_CLS}" htmlFor="${fkName}">${label} *</label>`,
79
- ` <select`,
80
- ` id="${fkName}"`,
81
- ` className="${INPUT_CLS}"`,
82
- ` value={String((formData as any).${fkName} ?? '')}`,
83
- ` onChange={e => handleChange('${fkName}', e.target.value)} required`,
84
- ` >`,
85
- ` <option value="">— choose —</option>`,
86
- ` {${varName}.map((opt: any) => (`,
87
- ` <option key={opt.id} value={opt.id}>{getEntityDisplayName(opt)}</option>`,
88
- ` ))}`,
89
- ` </select>`,
90
- ' </div>',
91
- ];
92
- }
93
-
94
- // ──────────────────────────────────────────────────────────────────────
95
- // Field selection
96
- // ──────────────────────────────────────────────────────────────────────
97
-
98
- interface FieldInfo {
99
- name: string;
100
- label: string;
101
- type: string;
102
- required: boolean;
103
- values?: string[]; // enum options if present
104
- isLongString?: boolean;
105
- auto?: boolean;
106
- }
107
-
108
- function selectFormFields(
109
- model: ModelSpec,
110
- skip: Set<string> = new Set()
111
- ): FieldInfo[] {
112
- const out: FieldInfo[] = [];
113
- const attrs = model.attributes ?? {};
114
- const lifecycleStates = extractLifecycleStates(model);
115
-
116
- for (const [name, rawDef] of Object.entries(attrs)) {
117
- if (AUTO_GENERATED_FIELD_NAMES.has(name)) continue;
118
- if (skip.has(name)) continue;
119
-
120
- const def = rawDef as AttributeShape;
121
- if (def?.auto) continue;
122
-
123
- // Heuristic: parse a type like "String required unique" to detect
124
- // convention-string shapes. We prefer the structured fields if
125
- // they're present.
126
- const type = (def?.type ?? parseTypeFromConvention(def)) ?? 'String';
127
- const required = def?.required === true || hasConventionFlag(def, 'required');
128
- const declaredValues = def?.values as string[] | undefined;
129
- // Lifecycle-backed attributes: if the model declares a lifecycle
130
- // with the same name as this attribute, the attribute's valid
131
- // values are the lifecycle states. Matches the FK rule's spirit —
132
- // any attribute whose legal values are declared elsewhere in the
133
- // spec should render as a <select>, not free text.
134
- const lifecycleValues = lifecycleStates.get(name);
135
- const values = declaredValues ?? lifecycleValues;
136
- const maxLength = def?.maxLength ?? def?.max as number | undefined;
137
- const isLongString =
138
- typeof maxLength === 'number' && maxLength > 100;
139
-
140
- out.push({
141
- name,
142
- label: humanize(name),
143
- type: normalizeType(type),
144
- required,
145
- values,
146
- isLongString,
147
- });
148
- }
149
- return out;
150
- }
151
-
152
- /**
153
- * Map from attribute name → list of lifecycle states, extracted from
154
- * the model's `lifecycles` section. Supports both the convention-string
155
- * shape (`flow: "a -> b -> c"`) and structured (`states: ["a", ...]`).
156
- *
157
- * Example:
158
- * lifecycles:
159
- * status:
160
- * flow: "planning -> active -> completed -> archived"
161
- * returns { status → ['planning', 'active', 'completed', 'archived'] }
162
- */
163
- function extractLifecycleStates(model: ModelSpec): Map<string, string[]> {
164
- const out = new Map<string, string[]>();
165
- const lifecycles = (model as ModelSpec & { lifecycles?: Record<string, unknown> }).lifecycles ?? {};
166
-
167
- for (const [name, rawDef] of Object.entries(lifecycles)) {
168
- if (!rawDef || typeof rawDef !== 'object') continue;
169
- // The parser normalizes lifecycles into a rich structured form:
170
- // { flow: "a -> b -> c",
171
- // states: [{ name: "a" }, { name: "b" }, { name: "c" }],
172
- // transitions: [...],
173
- // initialState: "a" }
174
- // Also support the pre-normalization shape (states as plain strings,
175
- // or just a flow string) so the composer works on raw specs too.
176
- const def = rawDef as {
177
- flow?: string;
178
- states?: Array<string | { name?: string; id?: string }>;
179
- };
180
-
181
- if (Array.isArray(def.states) && def.states.length > 0) {
182
- const names = def.states
183
- .map(s => (typeof s === 'string' ? s : s?.name ?? s?.id ?? ''))
184
- .filter(Boolean);
185
- if (names.length > 0) {
186
- out.set(name, names);
187
- continue;
188
- }
189
- }
190
-
191
- if (typeof def.flow === 'string') {
192
- // Split on -> with optional whitespace. Also accept commas as a
193
- // fallback separator so a state list like "a, b, c" still works.
194
- const states = def.flow
195
- .split(/\s*(?:->|,)\s*/)
196
- .map(s => s.trim())
197
- .filter(Boolean);
198
- if (states.length > 0) out.set(name, states);
199
- }
200
- }
201
- return out;
202
- }
203
-
204
- /** Structured attribute object from the parsed spec. */
205
- interface AttributeShape {
206
- type?: string;
207
- required?: boolean;
208
- unique?: boolean;
209
- auto?: string;
210
- values?: string[];
211
- maxLength?: number;
212
- max?: number;
213
- [k: string]: unknown;
214
- }
215
-
216
- function parseTypeFromConvention(def: unknown): string | undefined {
217
- // Convention form: "String required unique"
218
- if (typeof def === 'string') {
219
- const first = def.trim().split(/\s+/)[0];
220
- return first;
221
- }
222
- return undefined;
223
- }
224
-
225
- function hasConventionFlag(def: unknown, flag: string): boolean {
226
- if (typeof def === 'string') {
227
- return def.split(/\s+/).includes(flag);
228
- }
229
- return false;
230
- }
231
-
232
- function normalizeType(type: string): string {
233
- // Strip any trailing modifiers from a compact convention-string form.
234
- return type.replace(/[^A-Za-z].*$/, '');
235
- }
236
-
237
- // (`extractBelongsTo` was removed — the shared helper in
238
- // `./belongs-to.ts` returns both the relationship name AND the target
239
- // model so view-emitter can wire the hook call for each dropdown.)
240
-
241
- // ──────────────────────────────────────────────────────────────────────
242
- // Input rendering
243
- // ──────────────────────────────────────────────────────────────────────
244
-
245
- const INPUT_CLS =
246
- 'w-full rounded border border-gray-300 px-3 py-2 text-sm ' +
247
- 'focus:outline-none focus:ring-2 focus:ring-blue-500 ' +
248
- 'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100';
249
-
250
- const LABEL_CLS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
251
-
252
- function renderInput(field: FieldInfo): string[] {
253
- const requiredMark = field.required ? ' *' : '';
254
- const reqAttr = field.required ? ' required' : '';
255
-
256
- // Enum → <select>
257
- if (field.values && field.values.length > 0) {
258
- const options = field.values
259
- .map(v => ` <option value="${escapeAttr(v)}">${escapeText(v)}</option>`)
260
- .join('\n');
261
- return [
262
- ' <div>',
263
- ` <label className="${LABEL_CLS}" htmlFor="${field.name}">${field.label}${requiredMark}</label>`,
264
- ` <select`,
265
- ` id="${field.name}"`,
266
- ` className="${INPUT_CLS}"`,
267
- ` value={String((formData as any).${field.name} ?? '')}`,
268
- ` onChange={e => handleChange('${field.name}', e.target.value)}${reqAttr}`,
269
- ` >`,
270
- ` <option value="">— choose —</option>`,
271
- options,
272
- ` </select>`,
273
- ' </div>',
274
- ];
275
- }
276
-
277
- // Long-string / Text → <textarea>
278
- if (field.type === 'Text' || field.isLongString) {
279
- return [
280
- ' <div>',
281
- ` <label className="${LABEL_CLS}" htmlFor="${field.name}">${field.label}${requiredMark}</label>`,
282
- ` <textarea`,
283
- ` id="${field.name}"`,
284
- ` className="${INPUT_CLS}"`,
285
- ` rows={4}`,
286
- ` value={String((formData as any).${field.name} ?? '')}`,
287
- ` onChange={e => handleChange('${field.name}', e.target.value)}${reqAttr}`,
288
- ` />`,
289
- ' </div>',
290
- ];
291
- }
292
-
293
- // Boolean → checkbox
294
- if (field.type === 'Boolean') {
295
- return [
296
- ' <div>',
297
- ` <label className="inline-flex items-center gap-2">`,
298
- ` <input`,
299
- ` id="${field.name}"`,
300
- ` type="checkbox"`,
301
- ` checked={Boolean((formData as any).${field.name})}`,
302
- ` onChange={e => handleChange('${field.name}', e.target.checked)}`,
303
- ` />`,
304
- ` <span className="text-sm text-gray-700 dark:text-gray-300">${field.label}${requiredMark}</span>`,
305
- ` </label>`,
306
- ' </div>',
307
- ];
308
- }
309
-
310
- const inputType = mapInputType(field.type);
311
- return [
312
- ' <div>',
313
- ` <label className="${LABEL_CLS}" htmlFor="${field.name}">${field.label}${requiredMark}</label>`,
314
- ` <input`,
315
- ` id="${field.name}"`,
316
- ` type="${inputType}"`,
317
- ` className="${INPUT_CLS}"`,
318
- ` value={String((formData as any).${field.name} ?? '')}`,
319
- ` onChange={e => handleChange('${field.name}', ${coerceOnChange(field.type)})}${reqAttr}`,
320
- ` />`,
321
- ' </div>',
322
- ];
323
- }
324
-
325
- /** Scalar SpecVerse type → HTML input type attribute. */
326
- function mapInputType(type: string): string {
327
- switch (type) {
328
- case 'Integer':
329
- case 'Float':
330
- case 'Number':
331
- case 'Money':
332
- return 'number';
333
- case 'DateTime':
334
- return 'datetime-local';
335
- case 'Date':
336
- return 'date';
337
- case 'Email':
338
- return 'email';
339
- case 'URL':
340
- return 'url';
341
- case 'String':
342
- case 'UUID':
343
- default:
344
- return 'text';
345
- }
346
- }
347
-
348
- /**
349
- * JSX snippet for the onChange handler's second argument — coerces
350
- * the string value to the right shape for the field. The user can
351
- * edit this; defaults are a reasonable starter.
352
- */
353
- function coerceOnChange(type: string): string {
354
- switch (type) {
355
- case 'Integer':
356
- case 'Number':
357
- return "e.target.value === '' ? undefined : Number(e.target.value)";
358
- case 'Float':
359
- case 'Money':
360
- return "e.target.value === '' ? undefined : parseFloat(e.target.value)";
361
- default:
362
- return 'e.target.value';
363
- }
364
- }
365
-
366
- // ──────────────────────────────────────────────────────────────────────
367
- // Small helpers
368
- // ──────────────────────────────────────────────────────────────────────
369
-
370
- function humanize(name: string): string {
371
- return name
372
- .replace(/([A-Z])/g, ' $1')
373
- .replace(/^./, c => c.toUpperCase())
374
- .trim();
375
- }
376
-
377
- function escapeAttr(s: string): string {
378
- return s.replace(/"/g, '&quot;');
379
- }
380
-
381
- function escapeText(s: string): string {
382
- return s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
16
+ const imports = new Set<string>();
17
+ const nodes = walkPattern('form-view', {
18
+ model: context.model,
19
+ modelSchemas: context.modelSchemas as any,
20
+ view: context.view,
21
+ });
22
+ return emitJsxSource(nodes, { imports, indent: 6 });
383
23
  }