@specverse/engines 4.2.2 → 4.3.1

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 (63) 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/inference/ui-contracts/rules/action-buttons-present.d.ts +8 -5
  8. package/dist/inference/ui-contracts/rules/action-buttons-present.d.ts.map +1 -1
  9. package/dist/inference/ui-contracts/rules/action-buttons-present.js +85 -19
  10. package/dist/inference/ui-contracts/rules/action-buttons-present.js.map +1 -1
  11. package/dist/inference/ui-contracts/test-case-types.d.ts +3 -0
  12. package/dist/inference/ui-contracts/test-case-types.d.ts.map +1 -1
  13. package/dist/inference/ui-contracts/translator.d.ts.map +1 -1
  14. package/dist/inference/ui-contracts/translator.js +4 -0
  15. package/dist/inference/ui-contracts/translator.js.map +1 -1
  16. package/dist/libs/instance-factories/applications/templates/react/index-html-generator.js +2 -2
  17. package/dist/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.js +291 -59
  18. package/dist/libs/instance-factories/applications/templates/react-starter/belongs-to.js +37 -0
  19. package/dist/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.js +9 -124
  20. package/dist/libs/instance-factories/applications/templates/react-starter/detail-body-composer.js +9 -75
  21. package/dist/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.js +129 -0
  22. package/dist/libs/instance-factories/applications/templates/react-starter/form-body-composer.js +9 -212
  23. package/dist/libs/instance-factories/applications/templates/react-starter/helpers-emitter.js +260 -5
  24. package/dist/libs/instance-factories/applications/templates/react-starter/list-body-composer.js +8 -50
  25. package/dist/libs/instance-factories/applications/templates/react-starter/operation-emitters.js +470 -0
  26. package/dist/libs/instance-factories/applications/templates/react-starter/operation-view-generator.js +136 -0
  27. package/dist/libs/instance-factories/applications/templates/react-starter/package-json-generator.js +2 -1
  28. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  29. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  30. package/dist/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  31. package/dist/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.js +49 -10
  32. package/dist/libs/instance-factories/applications/templates/react-starter/view-emitter.js +223 -10
  33. package/dist/libs/instance-factories/applications/templates/react-starter/views-generator.js +14 -1
  34. package/dist/libs/instance-factories/controllers/templates/fastify/routes-generator.js +13 -1
  35. package/dist/libs/instance-factories/controllers/templates/fastify/server-generator.js +18 -0
  36. package/libs/instance-factories/applications/templates/react/index-html-generator.ts +2 -2
  37. package/libs/instance-factories/applications/templates/react-starter/__tests__/dashboard-body-composer.test.ts +3 -1
  38. package/libs/instance-factories/applications/templates/react-starter/__tests__/detail-body-composer.test.ts +24 -25
  39. package/libs/instance-factories/applications/templates/react-starter/__tests__/list-body-composer.test.ts +3 -3
  40. package/libs/instance-factories/applications/templates/react-starter/__tests__/orchestrator.test.ts +2 -0
  41. package/libs/instance-factories/applications/templates/react-starter/__tests__/parity-p3-rendered-output.test.ts +1 -1
  42. package/libs/instance-factories/applications/templates/react-starter/__tests__/starter-generators.test.ts +5 -4
  43. package/libs/instance-factories/applications/templates/react-starter/__tests__/views-generator.test.ts +11 -4
  44. package/libs/instance-factories/applications/templates/react-starter/app-tsx-generator.ts +377 -71
  45. package/libs/instance-factories/applications/templates/react-starter/belongs-to.ts +66 -0
  46. package/libs/instance-factories/applications/templates/react-starter/dashboard-body-composer.ts +15 -182
  47. package/libs/instance-factories/applications/templates/react-starter/detail-body-composer.ts +16 -128
  48. package/libs/instance-factories/applications/templates/react-starter/emit-jsx-source.ts +233 -0
  49. package/libs/instance-factories/applications/templates/react-starter/form-body-composer.ts +16 -376
  50. package/libs/instance-factories/applications/templates/react-starter/helpers-emitter.ts +282 -4
  51. package/libs/instance-factories/applications/templates/react-starter/list-body-composer.ts +26 -135
  52. package/libs/instance-factories/applications/templates/react-starter/operation-emitters.ts +497 -0
  53. package/libs/instance-factories/applications/templates/react-starter/operation-view-generator.ts +209 -0
  54. package/libs/instance-factories/applications/templates/react-starter/package-json-generator.ts +1 -0
  55. package/libs/instance-factories/applications/templates/react-starter/skeletons/detail.tsx.template +54 -61
  56. package/libs/instance-factories/applications/templates/react-starter/skeletons/form.tsx.template +115 -27
  57. package/libs/instance-factories/applications/templates/react-starter/skeletons/list.tsx.template +17 -9
  58. package/libs/instance-factories/applications/templates/react-starter/use-api-hooks-starter-generator.ts +71 -10
  59. package/libs/instance-factories/applications/templates/react-starter/view-emitter.ts +359 -18
  60. package/libs/instance-factories/applications/templates/react-starter/views-generator.ts +28 -1
  61. package/libs/instance-factories/controllers/templates/fastify/routes-generator.ts +13 -1
  62. package/libs/instance-factories/controllers/templates/fastify/server-generator.ts +18 -0
  63. package/package.json +2 -2
@@ -25,18 +25,19 @@ export function emitEntityDisplay(): string {
25
25
  * @specverse/realize (ReactAppStarter); edit freely — nothing in
26
26
  * the factory's regeneration will clobber your edits.
27
27
  */
28
- export function getEntityDisplayName(entity: Record<string, unknown> | null | undefined): string {
29
- if (!entity) return '';
28
+ export function getEntityDisplayName(entity: unknown): string {
29
+ if (!entity || typeof entity !== 'object') return '';
30
+ const record = entity as Record<string, unknown>;
30
31
 
31
32
  const candidates = ['name', 'title', 'displayName', 'label', 'username', 'email'];
32
33
  for (const key of candidates) {
33
- const value = entity[key];
34
+ const value = record[key];
34
35
  if (typeof value === 'string' && value.trim().length > 0) {
35
36
  return value;
36
37
  }
37
38
  }
38
39
 
39
- const id = entity.id;
40
+ const id = record.id;
40
41
  if (typeof id === 'string') {
41
42
  return id.length > 8 ? id.slice(0, 8) + '…' : id;
42
43
  }
@@ -62,5 +63,282 @@ export function resolveEntityDisplayName(
62
63
  );
63
64
  return match ? getEntityDisplayName(match) : String(id);
64
65
  }
66
+
67
+ /**
68
+ * Format an arbitrary value for display in list cells, detail rows,
69
+ * and dashboard previews. Handles the cases where \`String(v ?? '')\`
70
+ * produces ugly output: nulls become em-dashes, booleans become
71
+ * "Yes"/"No", nested objects become \`{k: v, ...}\` summaries
72
+ * instead of "[object Object]", ISO datetimes are localized.
73
+ *
74
+ * Single source of truth for value display. Resolvers / emitters
75
+ * call this from expression strings.
76
+ */
77
+ export function formatDisplayValue(value: unknown): string {
78
+ if (value === null || value === undefined) return '—';
79
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
80
+ if (value instanceof Date) return value.toLocaleString();
81
+ if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2}T/.test(value)) {
82
+ return new Date(value).toLocaleString();
83
+ }
84
+ if (Array.isArray(value)) return \`[\${value.length} items]\`;
85
+ if (typeof value === 'object') {
86
+ const keys = Object.keys(value as Record<string, unknown>);
87
+ if (keys.length === 0) return '{}';
88
+ if (keys.length <= 5) {
89
+ const rec = value as Record<string, unknown>;
90
+ return keys.map(k => \`\${k}: \${formatDisplayValue(rec[k])}\`).join(', ');
91
+ }
92
+ return \`{\${keys.length} fields}\`;
93
+ }
94
+ return String(value);
95
+ }
96
+ `;
97
+ }
98
+
99
+ /**
100
+ * Source of `src/hooks/useAppEvents.ts`.
101
+ *
102
+ * Connects to the backend's `/ws` endpoint, subscribes to every
103
+ * CURED event, and invalidates the matching React Query cache on
104
+ * Created / Updated / Deleted / Evolved events. Drives live-refresh
105
+ * everywhere in the app.
106
+ *
107
+ * Message shapes the backend sends:
108
+ * - `{ type: 'connected', timestamp }` — open handshake.
109
+ * - `{ type: 'subscribed-all', events }` — ack of subscribe-all.
110
+ * - `{ type: 'event', event: '{Model}{Action}', payload, timestamp }`.
111
+ *
112
+ * Message the client sends:
113
+ * - `{ type: 'subscribe-all' }` once the socket opens.
114
+ */
115
+ export function emitUseAppEvents(): string {
116
+ return `/**
117
+ * AppEventsProvider + useAppEvents + useRecentEvents
118
+ *
119
+ * One WebSocket connection to the backend's /ws, shared across the
120
+ * app. Two consumers:
121
+ * - useAppEvents(): React Query invalidation on mutations (no-op
122
+ * call — side effect is via the provider).
123
+ * - useRecentEvents(): the Events tab's scrolling stream display.
124
+ *
125
+ * Inlined into this project by @specverse/realize (ReactAppStarter).
126
+ * Edit freely.
127
+ */
128
+ import { useEffect, useState, useContext, createContext, type ReactNode } from 'react';
129
+ import { useQueryClient } from '@tanstack/react-query';
130
+
131
+ export interface AppEvent {
132
+ event: string;
133
+ payload: any;
134
+ timestamp: string;
135
+ }
136
+
137
+ interface AppEventsCtx {
138
+ recentEvents: AppEvent[];
139
+ clear: () => void;
140
+ }
141
+
142
+ const EventsContext = createContext<AppEventsCtx | null>(null);
143
+
144
+ function pluralize(s: string): string {
145
+ if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
146
+ if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
147
+ return s + 's';
148
+ }
149
+
150
+ function deriveWsUrl(): string {
151
+ const apiBase = import.meta.env.VITE_API_BASE_URL || '';
152
+ if (apiBase) return apiBase.replace(/^http/, 'ws') + '/ws';
153
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
154
+ return \`\${proto}//\${window.location.host}/ws\`;
155
+ }
156
+
157
+ /** Parse "CategoryCreated" → "Category". Returns null for event names
158
+ * that don't end in one of the 4 CURED verbs. */
159
+ function modelFromEvent(eventName: string): string | null {
160
+ const m = eventName.match(/^(.+)(Created|Updated|Deleted|Evolved)$/);
161
+ return m ? m[1] : null;
162
+ }
163
+
164
+ /**
165
+ * Provider — wrap the app once. Maintains a single WS connection,
166
+ * invalidates React Query caches on mutation events, and keeps a
167
+ * buffer of recent events (default 100) for the Events tab.
168
+ */
169
+ export function AppEventsProvider({ children, bufferSize = 100 }: { children: ReactNode; bufferSize?: number }) {
170
+ const qc = useQueryClient();
171
+ const [recentEvents, setRecentEvents] = useState<AppEvent[]>([]);
172
+
173
+ const clear = () => setRecentEvents([]);
174
+
175
+ useEffect(() => {
176
+ let cancelled = false;
177
+ let ws: WebSocket | null = null;
178
+ let reconnectDelay = 500;
179
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
180
+
181
+ const connect = () => {
182
+ if (cancelled) return;
183
+ try {
184
+ ws = new WebSocket(deriveWsUrl());
185
+ } catch {
186
+ scheduleReconnect();
187
+ return;
188
+ }
189
+
190
+ ws.addEventListener('open', () => {
191
+ if (cancelled) { ws?.close(); return; }
192
+ reconnectDelay = 500;
193
+ ws?.send(JSON.stringify({ type: 'subscribe-all' }));
194
+ });
195
+
196
+ ws.addEventListener('message', (evt) => {
197
+ if (cancelled) return;
198
+ let data: any;
199
+ try { data = JSON.parse(evt.data as string); } catch { return; }
200
+ if (data?.type !== 'event') return;
201
+ const appEvent: AppEvent = {
202
+ event: data.event,
203
+ payload: data.payload,
204
+ timestamp: data.timestamp ?? new Date().toISOString(),
205
+ };
206
+ // Append + cap at bufferSize.
207
+ setRecentEvents(prev => {
208
+ const next = [...prev, appEvent];
209
+ return next.length > bufferSize ? next.slice(-bufferSize) : next;
210
+ });
211
+ // Invalidate React Query cache for affected model.
212
+ const model = modelFromEvent(data.event);
213
+ if (model) {
214
+ const resource = pluralize(model).toLowerCase();
215
+ qc.invalidateQueries({ queryKey: [resource] });
216
+ }
217
+ });
218
+
219
+ ws.addEventListener('close', () => {
220
+ if (!cancelled) scheduleReconnect();
221
+ });
222
+
223
+ ws.addEventListener('error', () => {
224
+ try { ws?.close(); } catch { /* ignore */ }
225
+ });
226
+ };
227
+
228
+ const scheduleReconnect = () => {
229
+ if (cancelled) return;
230
+ if (reconnectTimer) clearTimeout(reconnectTimer);
231
+ reconnectTimer = setTimeout(() => {
232
+ reconnectDelay = Math.min(reconnectDelay * 2, 10_000);
233
+ connect();
234
+ }, reconnectDelay);
235
+ };
236
+
237
+ // Microtask-deferred connect — StrictMode-safe (synchronous
238
+ // mount/unmount/remount won't leave a half-open socket).
239
+ Promise.resolve().then(() => { if (!cancelled) connect(); });
240
+
241
+ return () => {
242
+ cancelled = true;
243
+ if (reconnectTimer) clearTimeout(reconnectTimer);
244
+ if (ws && ws.readyState === WebSocket.CONNECTING) {
245
+ ws.addEventListener('open', () => ws?.close(), { once: true });
246
+ } else if (ws) {
247
+ try { ws.close(); } catch { /* ignore */ }
248
+ }
249
+ };
250
+ }, [qc, bufferSize]);
251
+
252
+ return (
253
+ <EventsContext.Provider value={{ recentEvents, clear }}>
254
+ {children}
255
+ </EventsContext.Provider>
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Shorthand for components that don't care about the buffer — they
261
+ * get live-refresh behavior "for free" because the provider runs
262
+ * query invalidation. Kept as a no-op hook so sites that called it
263
+ * under the old API still type-check and still document intent.
264
+ */
265
+ export function useAppEvents() {
266
+ // No-op — the Provider does the work. Retained to preserve intent
267
+ // at call sites like "the app needs live refresh".
268
+ useContext(EventsContext);
269
+ }
270
+
271
+ /**
272
+ * Read the recent-events buffer — used by the Events tab to render
273
+ * a scrolling stream. Returns [] if no AppEventsProvider is mounted.
274
+ */
275
+ export function useRecentEvents(): AppEventsCtx {
276
+ return useContext(EventsContext) ?? { recentEvents: [], clear: () => {} };
277
+ }
278
+ `;
279
+ }
280
+
281
+ /**
282
+ * Source of `src/hooks/useResizableSidebar.ts`.
283
+ *
284
+ * Ported verbatim from `@specverse/runtime/views/react/hooks/
285
+ * useResizableSidebar` (app-demo re-exports the same hook). Drag-to-
286
+ * resize sidebar width with localStorage persistence.
287
+ */
288
+ export function emitUseResizableSidebar(): string {
289
+ return `/**
290
+ * useResizableSidebar — drag-to-resize sidebar width.
291
+ *
292
+ * Inlined into this project by @specverse/realize (ReactAppStarter);
293
+ * edit freely — nothing in the factory's regeneration will clobber
294
+ * your edits.
295
+ *
296
+ * Returns { width, isResizing, startResizing } — width is the current
297
+ * pixel value, isResizing is true during an active drag, startResizing
298
+ * is an onMouseDown handler to attach to the drag handle element.
299
+ */
300
+ import { useState, useEffect, useCallback } from 'react';
301
+
302
+ export interface ResizableSidebarOptions {
303
+ defaultWidth?: number;
304
+ minWidth?: number;
305
+ maxWidth?: number;
306
+ }
307
+
308
+ export function useResizableSidebar(options: ResizableSidebarOptions = {}) {
309
+ const { defaultWidth = 256, minWidth = 200, maxWidth = 500 } = options;
310
+ const [width, setWidth] = useState(defaultWidth);
311
+ const [isResizing, setIsResizing] = useState(false);
312
+
313
+ const startResizing = useCallback(() => {
314
+ setIsResizing(true);
315
+ }, []);
316
+
317
+ useEffect(() => {
318
+ if (!isResizing) return;
319
+
320
+ const handleMouseMove = (e: MouseEvent) => {
321
+ const newWidth = Math.min(maxWidth, Math.max(minWidth, e.clientX));
322
+ setWidth(newWidth);
323
+ };
324
+ const handleMouseUp = () => {
325
+ setIsResizing(false);
326
+ document.body.style.cursor = '';
327
+ document.body.style.userSelect = '';
328
+ };
329
+
330
+ document.body.style.cursor = 'col-resize';
331
+ document.body.style.userSelect = 'none';
332
+ document.addEventListener('mousemove', handleMouseMove);
333
+ document.addEventListener('mouseup', handleMouseUp);
334
+
335
+ return () => {
336
+ document.removeEventListener('mousemove', handleMouseMove);
337
+ document.removeEventListener('mouseup', handleMouseUp);
338
+ };
339
+ }, [isResizing, minWidth, maxWidth]);
340
+
341
+ return { width, isResizing, startResizing };
342
+ }
65
343
  `;
66
344
  }
@@ -1,146 +1,37 @@
1
1
  /**
2
- * List-view body composer for ReactAppStarter
2
+ * List-view body composer for ReactAppStarter (Phase 3)
3
3
  *
4
- * Implements the `renderBody` contract of view-emitter.ts for list
5
- * views. Uses the canonical Tailwind adapter from
6
- * `@specverse/runtime/views/tailwind` to render the TABLE SHELL, then
7
- * injects a JSX `.map()` expression into the tbody so the generated
8
- * component renders rows from the `filtered` array defined by the
9
- * skeleton.
4
+ * Now a thin wrapper around the pattern walker. The 'what a list
5
+ * view looks like' knowledge lives in the pattern registry (section
6
+ * content declarations) + section resolvers in
7
+ * `@specverse/runtime/views/core/section-resolvers/`. This composer
8
+ * just calls `walkPattern('list-view', context)` and emits the
9
+ * resulting RenderNode tree as JSX source.
10
10
  *
11
- * Why split it this way: the adapter produces static HTML (the same
12
- * HTML app-demo uses). For Factory B we want React that maps over
13
- * runtime data so we take the adapter's shell and replace a sentinel
14
- * inside the tbody with a `{filtered.map(...)}` expression. The shell
15
- * stays canonical; only the row-iteration JSX is synthesized here.
11
+ * Notice the shape: no column inference, no row-iteration JSX, no
12
+ * header rendering in this file. That logic now lives in the walker
13
+ * where app-demo and ReactAppRuntime can use it too.
16
14
  *
17
- * See README.md for the full architecture.
15
+ * This is the first of four composers migrated to the walker path.
16
+ * detail, form, dashboard follow the same shape.
18
17
  */
19
18
 
20
- import { createUniversalTailwindAdapter } from '@specverse/runtime/views/tailwind';
21
- import { inferFieldsFromSchema } from '@specverse/runtime/views/core';
22
- import { htmlToJsx } from './html-to-jsx.js';
23
- import type { EmitContext, ModelSpec } from './view-emitter.js';
24
- import { buildFKMap } from './belongs-to.js';
19
+ import { walkPattern } from '@specverse/runtime/views/core';
20
+ import type { EmitContext } from './view-emitter.js';
21
+ import { emitJsxSource } from './emit-jsx-source.js';
25
22
 
26
- /**
27
- * Sentinel token. Must be ASCII-safe (no curly braces) so it survives
28
- * the html-to-jsx transform unchanged, and must be unique enough not
29
- * to appear in real HTML output.
30
- */
31
- const TBODY_SENTINEL = '__SPECVERSE_TBODY_ROWS__';
32
-
33
- /**
34
- * Compose the list view's body as JSX-safe source. Returned string is
35
- * meant to be dropped at `{{BODY}}` inside `skeletons/list.tsx.template`.
36
- */
37
23
  export function composeListBody(context: EmitContext): string {
38
- const fkMap = buildFKMap(context.model);
39
- // Inference (`inferFieldsFromSchema`) reads attributes and doesn't
40
- // see belongsTo relationships. For list views we want a column per
41
- // belongsTo so users can scan "who owns this" at a glance —
42
- // otherwise a Task table hides its Project and Assignee columns.
43
- // Append any FK columns the inference missed, preserving order.
44
- const inferred = inferColumns(context);
45
- const inferredSet = new Set(inferred);
46
- const extraFKs = [...fkMap.keys()].filter(k => !inferredSet.has(k));
47
- const columns = [...inferred, ...extraFKs];
48
-
49
- // Header label for an FK column should use the relationship name
50
- // ("Owner") not the raw column name ("Owner Id"). The cell rendering
51
- // in buildRowMap applies the matching FK→name resolution.
52
- const headers = columns.map(col => {
53
- const fk = fkMap.get(col);
54
- return humanize(fk ? fk.name : col);
24
+ // The walker returns a rendering-agnostic RenderNode tree. The
25
+ // imports set collects any capitalised composite components the
26
+ // tree references view-emitter injects them at the top of the
27
+ // generated .tsx file. For list views today the tree is all
28
+ // lowercase HTML tags, so imports is typically empty.
29
+ const imports = new Set<string>();
30
+ const nodes = walkPattern('list-view', {
31
+ model: context.model,
32
+ modelSchemas: context.modelSchemas as any,
33
+ view: context.view,
55
34
  });
56
35
 
57
- const adapter = createUniversalTailwindAdapter({ darkMode: true });
58
- const shellHtml = adapter.components.table.render({
59
- properties: { columns: headers },
60
- children: TBODY_SENTINEL,
61
- });
62
-
63
- // Transform the static HTML shell to JSX-safe source. The sentinel
64
- // lands in a text position inside the <tbody> and survives the
65
- // transform untouched.
66
- const shellJsx = htmlToJsx(shellHtml);
67
-
68
- // Build the row-iteration JSX that replaces the sentinel. The
69
- // skeleton's useMemo produces `filtered: Model[]`, so the map
70
- // expression binds over that. onSelect is a prop the skeleton
71
- // declares. Delete action wires to the deleteItem mutation.
72
- const rowMap = buildRowMap(columns, fkMap);
73
-
74
- if (!shellJsx.includes(TBODY_SENTINEL)) {
75
- // Defensive: catches adapter output changes that no longer flow
76
- // `children` into the tbody. Caught at composer time, not runtime.
77
- throw new Error(
78
- 'composeListBody: tbody sentinel not present in adapter output. ' +
79
- 'The canonical Tailwind adapter may have changed its table rendering.'
80
- );
81
- }
82
-
83
- return shellJsx.replace(TBODY_SENTINEL, rowMap);
84
- }
85
-
86
- /**
87
- * Select the non-metadata attributes of a model. Delegates to the
88
- * canonical pattern library so the column set is identical to what
89
- * the runtime React adapter picks for the same model — parity test P3
90
- * relies on this being one-and-the-same function.
91
- */
92
- function inferColumns(context: EmitContext): string[] {
93
- return inferFieldsFromSchema(context.modelSchemas, context.model.name);
94
- }
95
-
96
- /**
97
- * Convert a camelCase attribute name to Title-Case words for display
98
- * as a column header. Matches the convention used by runtime's
99
- * pattern adapter (see `humanize` in runtime react-pattern-adapter).
100
- */
101
- function humanize(name: string): string {
102
- return name
103
- .replace(/([A-Z])/g, ' $1')
104
- .replace(/^./, c => c.toUpperCase())
105
- .trim();
106
- }
107
-
108
- /**
109
- * Emit the `{filtered.map(...)}` JSX expression that renders each
110
- * row. Keep the output readable so users inspecting the generated
111
- * file can follow what's happening.
112
- */
113
- function buildRowMap(columns: string[], fkMap: Map<string, { name: string; target: string }>): string {
114
- // Type-cast to `any` to avoid TS generic syntax (`<string, unknown>`)
115
- // inside JSX expressions, which the TSX parser can't always
116
- // disambiguate from a JSX opening tag. `any` is the right choice for
117
- // starter-kit output anyway — the user will often reshape the row
118
- // type after editing.
119
- //
120
- // FK columns render through `resolveEntityDisplayName(id, options)`
121
- // so the user sees a name instead of a UUID. The options array
122
- // (`${relName}Options`) is populated by the hook call wired into
123
- // the list skeleton via view-emitter's RELATED_HOOKS substitution.
124
- const cells = columns
125
- .map(col => {
126
- const fk = fkMap.get(col);
127
- const expr = fk
128
- ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}`
129
- : `{String((item as any).${col} ?? '')}`;
130
- return ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">` +
131
- `${expr}</td>`;
132
- })
133
- .join('\n');
134
-
135
- return [
136
- '{filtered.map((item, idx) => (',
137
- ' <tr',
138
- ' key={idx}',
139
- ' onClick={() => onSelect?.(item)}',
140
- ' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"',
141
- ' >',
142
- cells,
143
- ' </tr>',
144
- '))}',
145
- ].join('\n');
36
+ return emitJsxSource(nodes, { imports, indent: 6 });
146
37
  }