@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
@@ -7,18 +7,19 @@ function emitEntityDisplay() {
7
7
  * @specverse/realize (ReactAppStarter); edit freely \u2014 nothing in
8
8
  * the factory's regeneration will clobber your edits.
9
9
  */
10
- export function getEntityDisplayName(entity: Record<string, unknown> | null | undefined): string {
11
- if (!entity) return '';
10
+ export function getEntityDisplayName(entity: unknown): string {
11
+ if (!entity || typeof entity !== 'object') return '';
12
+ const record = entity as Record<string, unknown>;
12
13
 
13
14
  const candidates = ['name', 'title', 'displayName', 'label', 'username', 'email'];
14
15
  for (const key of candidates) {
15
- const value = entity[key];
16
+ const value = record[key];
16
17
  if (typeof value === 'string' && value.trim().length > 0) {
17
18
  return value;
18
19
  }
19
20
  }
20
21
 
21
- const id = entity.id;
22
+ const id = record.id;
22
23
  if (typeof id === 'string') {
23
24
  return id.length > 8 ? id.slice(0, 8) + '\u2026' : id;
24
25
  }
@@ -44,8 +45,262 @@ export function resolveEntityDisplayName(
44
45
  );
45
46
  return match ? getEntityDisplayName(match) : String(id);
46
47
  }
48
+
49
+ /**
50
+ * Format an arbitrary value for display in list cells, detail rows,
51
+ * and dashboard previews. Handles the cases where \`String(v ?? '')\`
52
+ * produces ugly output: nulls become em-dashes, booleans become
53
+ * "Yes"/"No", nested objects become \`{k: v, ...}\` summaries
54
+ * instead of "[object Object]", ISO datetimes are localized.
55
+ *
56
+ * Single source of truth for value display. Resolvers / emitters
57
+ * call this from expression strings.
58
+ */
59
+ export function formatDisplayValue(value: unknown): string {
60
+ if (value === null || value === undefined) return '\u2014';
61
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
62
+ if (value instanceof Date) return value.toLocaleString();
63
+ if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2}T/.test(value)) {
64
+ return new Date(value).toLocaleString();
65
+ }
66
+ if (Array.isArray(value)) return \`[\${value.length} items]\`;
67
+ if (typeof value === 'object') {
68
+ const keys = Object.keys(value as Record<string, unknown>);
69
+ if (keys.length === 0) return '{}';
70
+ if (keys.length <= 5) {
71
+ const rec = value as Record<string, unknown>;
72
+ return keys.map(k => \`\${k}: \${formatDisplayValue(rec[k])}\`).join(', ');
73
+ }
74
+ return \`{\${keys.length} fields}\`;
75
+ }
76
+ return String(value);
77
+ }
78
+ `;
79
+ }
80
+ function emitUseAppEvents() {
81
+ return `/**
82
+ * AppEventsProvider + useAppEvents + useRecentEvents
83
+ *
84
+ * One WebSocket connection to the backend's /ws, shared across the
85
+ * app. Two consumers:
86
+ * - useAppEvents(): React Query invalidation on mutations (no-op
87
+ * call \u2014 side effect is via the provider).
88
+ * - useRecentEvents(): the Events tab's scrolling stream display.
89
+ *
90
+ * Inlined into this project by @specverse/realize (ReactAppStarter).
91
+ * Edit freely.
92
+ */
93
+ import { useEffect, useState, useContext, createContext, type ReactNode } from 'react';
94
+ import { useQueryClient } from '@tanstack/react-query';
95
+
96
+ export interface AppEvent {
97
+ event: string;
98
+ payload: any;
99
+ timestamp: string;
100
+ }
101
+
102
+ interface AppEventsCtx {
103
+ recentEvents: AppEvent[];
104
+ clear: () => void;
105
+ }
106
+
107
+ const EventsContext = createContext<AppEventsCtx | null>(null);
108
+
109
+ function pluralize(s: string): string {
110
+ if (/[^aeiou]y$/i.test(s)) return s.slice(0, -1) + 'ies';
111
+ if (/(s|x|z|ch|sh)$/i.test(s)) return s + 'es';
112
+ return s + 's';
113
+ }
114
+
115
+ function deriveWsUrl(): string {
116
+ const apiBase = import.meta.env.VITE_API_BASE_URL || '';
117
+ if (apiBase) return apiBase.replace(/^http/, 'ws') + '/ws';
118
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
119
+ return \`\${proto}//\${window.location.host}/ws\`;
120
+ }
121
+
122
+ /** Parse "CategoryCreated" \u2192 "Category". Returns null for event names
123
+ * that don't end in one of the 4 CURED verbs. */
124
+ function modelFromEvent(eventName: string): string | null {
125
+ const m = eventName.match(/^(.+)(Created|Updated|Deleted|Evolved)$/);
126
+ return m ? m[1] : null;
127
+ }
128
+
129
+ /**
130
+ * Provider \u2014 wrap the app once. Maintains a single WS connection,
131
+ * invalidates React Query caches on mutation events, and keeps a
132
+ * buffer of recent events (default 100) for the Events tab.
133
+ */
134
+ export function AppEventsProvider({ children, bufferSize = 100 }: { children: ReactNode; bufferSize?: number }) {
135
+ const qc = useQueryClient();
136
+ const [recentEvents, setRecentEvents] = useState<AppEvent[]>([]);
137
+
138
+ const clear = () => setRecentEvents([]);
139
+
140
+ useEffect(() => {
141
+ let cancelled = false;
142
+ let ws: WebSocket | null = null;
143
+ let reconnectDelay = 500;
144
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
145
+
146
+ const connect = () => {
147
+ if (cancelled) return;
148
+ try {
149
+ ws = new WebSocket(deriveWsUrl());
150
+ } catch {
151
+ scheduleReconnect();
152
+ return;
153
+ }
154
+
155
+ ws.addEventListener('open', () => {
156
+ if (cancelled) { ws?.close(); return; }
157
+ reconnectDelay = 500;
158
+ ws?.send(JSON.stringify({ type: 'subscribe-all' }));
159
+ });
160
+
161
+ ws.addEventListener('message', (evt) => {
162
+ if (cancelled) return;
163
+ let data: any;
164
+ try { data = JSON.parse(evt.data as string); } catch { return; }
165
+ if (data?.type !== 'event') return;
166
+ const appEvent: AppEvent = {
167
+ event: data.event,
168
+ payload: data.payload,
169
+ timestamp: data.timestamp ?? new Date().toISOString(),
170
+ };
171
+ // Append + cap at bufferSize.
172
+ setRecentEvents(prev => {
173
+ const next = [...prev, appEvent];
174
+ return next.length > bufferSize ? next.slice(-bufferSize) : next;
175
+ });
176
+ // Invalidate React Query cache for affected model.
177
+ const model = modelFromEvent(data.event);
178
+ if (model) {
179
+ const resource = pluralize(model).toLowerCase();
180
+ qc.invalidateQueries({ queryKey: [resource] });
181
+ }
182
+ });
183
+
184
+ ws.addEventListener('close', () => {
185
+ if (!cancelled) scheduleReconnect();
186
+ });
187
+
188
+ ws.addEventListener('error', () => {
189
+ try { ws?.close(); } catch { /* ignore */ }
190
+ });
191
+ };
192
+
193
+ const scheduleReconnect = () => {
194
+ if (cancelled) return;
195
+ if (reconnectTimer) clearTimeout(reconnectTimer);
196
+ reconnectTimer = setTimeout(() => {
197
+ reconnectDelay = Math.min(reconnectDelay * 2, 10_000);
198
+ connect();
199
+ }, reconnectDelay);
200
+ };
201
+
202
+ // Microtask-deferred connect \u2014 StrictMode-safe (synchronous
203
+ // mount/unmount/remount won't leave a half-open socket).
204
+ Promise.resolve().then(() => { if (!cancelled) connect(); });
205
+
206
+ return () => {
207
+ cancelled = true;
208
+ if (reconnectTimer) clearTimeout(reconnectTimer);
209
+ if (ws && ws.readyState === WebSocket.CONNECTING) {
210
+ ws.addEventListener('open', () => ws?.close(), { once: true });
211
+ } else if (ws) {
212
+ try { ws.close(); } catch { /* ignore */ }
213
+ }
214
+ };
215
+ }, [qc, bufferSize]);
216
+
217
+ return (
218
+ <EventsContext.Provider value={{ recentEvents, clear }}>
219
+ {children}
220
+ </EventsContext.Provider>
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Shorthand for components that don't care about the buffer \u2014 they
226
+ * get live-refresh behavior "for free" because the provider runs
227
+ * query invalidation. Kept as a no-op hook so sites that called it
228
+ * under the old API still type-check and still document intent.
229
+ */
230
+ export function useAppEvents() {
231
+ // No-op \u2014 the Provider does the work. Retained to preserve intent
232
+ // at call sites like "the app needs live refresh".
233
+ useContext(EventsContext);
234
+ }
235
+
236
+ /**
237
+ * Read the recent-events buffer \u2014 used by the Events tab to render
238
+ * a scrolling stream. Returns [] if no AppEventsProvider is mounted.
239
+ */
240
+ export function useRecentEvents(): AppEventsCtx {
241
+ return useContext(EventsContext) ?? { recentEvents: [], clear: () => {} };
242
+ }
243
+ `;
244
+ }
245
+ function emitUseResizableSidebar() {
246
+ return `/**
247
+ * useResizableSidebar \u2014 drag-to-resize sidebar width.
248
+ *
249
+ * Inlined into this project by @specverse/realize (ReactAppStarter);
250
+ * edit freely \u2014 nothing in the factory's regeneration will clobber
251
+ * your edits.
252
+ *
253
+ * Returns { width, isResizing, startResizing } \u2014 width is the current
254
+ * pixel value, isResizing is true during an active drag, startResizing
255
+ * is an onMouseDown handler to attach to the drag handle element.
256
+ */
257
+ import { useState, useEffect, useCallback } from 'react';
258
+
259
+ export interface ResizableSidebarOptions {
260
+ defaultWidth?: number;
261
+ minWidth?: number;
262
+ maxWidth?: number;
263
+ }
264
+
265
+ export function useResizableSidebar(options: ResizableSidebarOptions = {}) {
266
+ const { defaultWidth = 256, minWidth = 200, maxWidth = 500 } = options;
267
+ const [width, setWidth] = useState(defaultWidth);
268
+ const [isResizing, setIsResizing] = useState(false);
269
+
270
+ const startResizing = useCallback(() => {
271
+ setIsResizing(true);
272
+ }, []);
273
+
274
+ useEffect(() => {
275
+ if (!isResizing) return;
276
+
277
+ const handleMouseMove = (e: MouseEvent) => {
278
+ const newWidth = Math.min(maxWidth, Math.max(minWidth, e.clientX));
279
+ setWidth(newWidth);
280
+ };
281
+ const handleMouseUp = () => {
282
+ setIsResizing(false);
283
+ document.body.style.cursor = '';
284
+ document.body.style.userSelect = '';
285
+ };
286
+
287
+ document.body.style.cursor = 'col-resize';
288
+ document.body.style.userSelect = 'none';
289
+ document.addEventListener('mousemove', handleMouseMove);
290
+ document.addEventListener('mouseup', handleMouseUp);
291
+
292
+ return () => {
293
+ document.removeEventListener('mousemove', handleMouseMove);
294
+ document.removeEventListener('mouseup', handleMouseUp);
295
+ };
296
+ }, [isResizing, minWidth, maxWidth]);
297
+
298
+ return { width, isResizing, startResizing };
299
+ }
47
300
  `;
48
301
  }
49
302
  export {
50
- emitEntityDisplay
303
+ emitEntityDisplay,
304
+ emitUseAppEvents,
305
+ emitUseResizableSidebar
51
306
  };
@@ -1,55 +1,13 @@
1
- import { createUniversalTailwindAdapter } from "@specverse/runtime/views/tailwind";
2
- import { inferFieldsFromSchema } from "@specverse/runtime/views/core";
3
- import { htmlToJsx } from "./html-to-jsx.js";
4
- import { buildFKMap } from "./belongs-to.js";
5
- const TBODY_SENTINEL = "__SPECVERSE_TBODY_ROWS__";
1
+ import { walkPattern } from "@specverse/runtime/views/core";
2
+ import { emitJsxSource } from "./emit-jsx-source.js";
6
3
  function composeListBody(context) {
7
- const fkMap = buildFKMap(context.model);
8
- const inferred = inferColumns(context);
9
- const inferredSet = new Set(inferred);
10
- const extraFKs = [...fkMap.keys()].filter((k) => !inferredSet.has(k));
11
- const columns = [...inferred, ...extraFKs];
12
- const headers = columns.map((col) => {
13
- const fk = fkMap.get(col);
14
- return humanize(fk ? fk.name : col);
4
+ const imports = /* @__PURE__ */ new Set();
5
+ const nodes = walkPattern("list-view", {
6
+ model: context.model,
7
+ modelSchemas: context.modelSchemas,
8
+ view: context.view
15
9
  });
16
- const adapter = createUniversalTailwindAdapter({ darkMode: true });
17
- const shellHtml = adapter.components.table.render({
18
- properties: { columns: headers },
19
- children: TBODY_SENTINEL
20
- });
21
- const shellJsx = htmlToJsx(shellHtml);
22
- const rowMap = buildRowMap(columns, fkMap);
23
- if (!shellJsx.includes(TBODY_SENTINEL)) {
24
- throw new Error(
25
- "composeListBody: tbody sentinel not present in adapter output. The canonical Tailwind adapter may have changed its table rendering."
26
- );
27
- }
28
- return shellJsx.replace(TBODY_SENTINEL, rowMap);
29
- }
30
- function inferColumns(context) {
31
- return inferFieldsFromSchema(context.modelSchemas, context.model.name);
32
- }
33
- function humanize(name) {
34
- return name.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase()).trim();
35
- }
36
- function buildRowMap(columns, fkMap) {
37
- const cells = columns.map((col) => {
38
- const fk = fkMap.get(col);
39
- const expr = fk ? `{resolveEntityDisplayName((item as any).${col}, ${fk.name}Options)}` : `{String((item as any).${col} ?? '')}`;
40
- return ` <td className="px-6 py-4 text-sm text-gray-700 dark:text-gray-300">${expr}</td>`;
41
- }).join("\n");
42
- return [
43
- "{filtered.map((item, idx) => (",
44
- " <tr",
45
- " key={idx}",
46
- " onClick={() => onSelect?.(item)}",
47
- ' className="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"',
48
- " >",
49
- cells,
50
- " </tr>",
51
- "))}"
52
- ].join("\n");
10
+ return emitJsxSource(nodes, { imports, indent: 6 });
53
11
  }
54
12
  export {
55
13
  composeListBody