view-contracts 0.1.0 → 0.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 (47) hide show
  1. package/cli-contract.yaml +1 -1
  2. package/dist/view-contracts.bundle.mjs +161 -153
  3. package/dist/view-contracts.bundle.mjs.map +4 -4
  4. package/docs/cli-reference.md +1 -1
  5. package/package.json +14 -4
  6. package/renderers/compose/templates/Screen.kt.hbs +15 -0
  7. package/renderers/compose/templates/VcTheme.kt.hbs +19 -0
  8. package/renderers/react/README.md +19 -3
  9. package/renderers/react/defaults/theme.yaml +41 -0
  10. package/renderers/react/defaults/tokens.yaml +59 -0
  11. package/renderers/react/element-config.meta.json +22 -0
  12. package/renderers/react/elementMap.ts +55 -0
  13. package/renderers/react/elements.yaml +86 -0
  14. package/renderers/react/index.ts +2 -0
  15. package/renderers/react/lowering/lowerToJsx.ts +433 -0
  16. package/renderers/react/paths.ts +10 -0
  17. package/renderers/react/renderComponents.ts +65 -0
  18. package/renderers/react/renderRuntime.ts +157 -0
  19. package/renderers/react/routes.yaml +3 -2
  20. package/{src/renderer → renderers}/react/runtime/actionRuntime.ts +3 -3
  21. package/renderers/react/runtime/dispatch.ts +21 -0
  22. package/renderers/react/runtime/index.ts +5 -0
  23. package/renderers/react/runtime/style.ts +101 -0
  24. package/renderers/react/runtime/theme.css +83 -0
  25. package/renderers/react/runtime/types.ts +63 -0
  26. package/renderers/react/templates/components-index.ts.hbs +5 -0
  27. package/renderers/react/templates/dispatch.ts.hbs +22 -0
  28. package/renderers/react/templates/handlers.ts.hbs +4 -0
  29. package/renderers/react/templates/runtime-types.ts.hbs +10 -0
  30. package/renderers/react/templates/style-helpers.ts.hbs +2 -0
  31. package/renderers/react/templates/theme.css.hbs +2 -0
  32. package/renderers/swiftui/templates/Screen.swift.hbs +21 -0
  33. package/renderers/swiftui/templates/VcTheme.swift.hbs +21 -0
  34. package/spec/ui-dsl.schema.json +24 -16
  35. package/src/generated/schema/allowed-components.ts +1 -1
  36. package/src/generated/schema/{tokens.ts → dsl-enums.ts} +1 -1
  37. package/src/generated/schema/dsl-properties.ts +3 -2
  38. package/src/generated/schema/index.ts +2 -2
  39. package/src/generated/schema/lowering-style-properties.ts +28 -0
  40. package/src/generated/schema/react-renderer-element-config.ts +25 -0
  41. package/src/generated/schema/schema-document.ts +2 -2
  42. package/src/renderer/react/runtime/components.tsx +0 -216
  43. package/src/renderer/react/runtime/index.ts +0 -4
  44. package/src/renderer/react/runtime/shared.tsx +0 -53
  45. package/src/renderer/react/runtime/style.ts +0 -69
  46. package/src/renderer/react/runtime/theme.css +0 -68
  47. package/src/renderer/react/runtime/types.ts +0 -46
@@ -0,0 +1,433 @@
1
+ import type { ElementMapEntry, ReactElementMap } from '../elementMap.js';
2
+ import { LOWERING_STYLE_PROPERTIES } from '../../../src/generated/schema/lowering-style-properties.js';
3
+ import type {
4
+ IrAction,
5
+ IrBinding,
6
+ IrComponentNode,
7
+ IrFragmentNode,
8
+ IrLiteral,
9
+ IrMapNode,
10
+ IrNode,
11
+ IrTextNode,
12
+ IrValue,
13
+ } from '../../../src/ir/types.js';
14
+
15
+ function indent(level: number): string {
16
+ return ' '.repeat(level);
17
+ }
18
+
19
+ function bindingExpr(binding: IrBinding, vmRef = 'vm'): string {
20
+ const root = binding.scope ?? vmRef;
21
+ return binding.path ? `${root}.${binding.path}` : root;
22
+ }
23
+
24
+ function renderAction(action: IrAction, cast = false): string {
25
+ const suffix = cast ? ' as ActionDescriptor' : '';
26
+ if (!action.params || Object.keys(action.params).length === 0) {
27
+ return `{ name: ${JSON.stringify(action.name)} }${suffix}`;
28
+ }
29
+ const params = Object.entries(action.params)
30
+ .map(([key, val]) => `${key}: ${renderValue(val, 0)}`)
31
+ .join(', ');
32
+ return `{ name: ${JSON.stringify(action.name)}, params: { ${params} } }${suffix}`;
33
+ }
34
+
35
+ function renderLiteralValue(value: unknown, level: number, castActions = false): string {
36
+ if (value === null) return 'null';
37
+ if (typeof value === 'string') return JSON.stringify(value);
38
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
39
+ if (Array.isArray(value)) {
40
+ return `[\n${value.map((item) => `${indent(level + 1)}${renderLiteralValue(item, level + 1, castActions)}`).join(',\n')}\n${indent(level)}]`;
41
+ }
42
+ if (typeof value === 'object' && value !== null) {
43
+ const obj = value as Record<string, unknown>;
44
+ if ('kind' in obj && obj.kind === 'action') {
45
+ return renderAction(obj as unknown as IrAction, castActions);
46
+ }
47
+ const entries = Object.entries(obj).map(([key, val]) => `${key}: ${renderLiteralValue(val, level + 1, castActions)}`);
48
+ return `{\n${entries.map((entry) => `${indent(level + 1)}${entry}`).join(',\n')}\n${indent(level)}}`;
49
+ }
50
+ return 'undefined';
51
+ }
52
+
53
+ export function renderValue(value: IrValue, level: number, castActions = false): string {
54
+ if (value === null) return 'null';
55
+ if (typeof value === 'string') return JSON.stringify(value);
56
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
57
+ if (Array.isArray(value)) {
58
+ return `[\n${value.map((item) => `${indent(level + 1)}${renderValue(item, level + 1, castActions)}`).join(',\n')}\n${indent(level)}]`;
59
+ }
60
+ if (typeof value === 'object' && value !== null && 'kind' in value) {
61
+ if (value.kind === 'binding') return bindingExpr(value as IrBinding);
62
+ if (value.kind === 'action') return renderAction(value as IrAction, castActions);
63
+ if (value.kind === 'literal') return renderLiteralValue((value as IrLiteral).value, level, castActions);
64
+ }
65
+ return 'undefined';
66
+ }
67
+
68
+ function propLiteral(props: Record<string, IrValue>, key: string): unknown | undefined {
69
+ const value = props[key];
70
+ if (value === null || value === undefined) return undefined;
71
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
72
+ if (typeof value === 'object' && 'kind' in value && value.kind === 'literal') {
73
+ return (value as IrLiteral).value;
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ function propExpr(props: Record<string, IrValue>, key: string): string | undefined {
79
+ const value = props[key];
80
+ if (value === null || value === undefined) return undefined;
81
+ return renderValue(value, 0);
82
+ }
83
+
84
+ function collectStyleProps(props: Record<string, IrValue>): string {
85
+ const entries = Object.entries(props).filter(([key]) => LOWERING_STYLE_PROPERTIES.has(key));
86
+ if (entries.length === 0) return '{}';
87
+ const body = entries.map(([key, val]) => `${key}: ${renderValue(val, 0)}`).join(', ');
88
+ return `{ ${body} }`;
89
+ }
90
+
91
+ function extraFlexStyle(entry: ElementMapEntry, props: Record<string, IrValue>): string {
92
+ if (!entry.flex) return '';
93
+ const parts = [`display: 'flex'`, `flexDirection: '${entry.flex === 'column' ? 'column' : 'row'}'`];
94
+ if (props.align) parts.push(`alignItems: mapAlign(${propExpr(props, 'align')})`);
95
+ if (props.justify) parts.push(`justifyContent: mapJustify(${propExpr(props, 'justify')})`);
96
+ return `, ${parts.join(', ')}`;
97
+ }
98
+
99
+ function styleAttr(entry: ElementMapEntry, props: Record<string, IrValue>): string {
100
+ const base = collectStyleProps(props);
101
+ const flex = extraFlexStyle(entry, props);
102
+ if (entry.flexGrowProp && props[entry.flexGrowProp]) {
103
+ const grow = propExpr(props, entry.flexGrowProp);
104
+ return `style={{ ...commonStyle(${base})${flex}, flexGrow: ${grow} ?? 1 }}`;
105
+ }
106
+ if (flex) return `style={{ ...commonStyle(${base})${flex} }}`;
107
+ return `style={commonStyle(${base})}`;
108
+ }
109
+
110
+ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSubmit'): string | undefined {
111
+ const action = props.action ?? props[event === 'onClick' ? 'onClick' : 'onSubmit'];
112
+ if (!action || typeof action !== 'object' || !('kind' in action) || action.kind !== 'action') {
113
+ return undefined;
114
+ }
115
+ const expr = renderAction(action as IrAction, true);
116
+ if (event === 'onSubmit') {
117
+ return `onSubmit={(e) => { e.preventDefault(); void dispatchAction(${expr}); }}`;
118
+ }
119
+ return `onClick={() => { void dispatchAction(${expr}); }}`;
120
+ }
121
+
122
+ function componentToKebab(name: string): string {
123
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
124
+ }
125
+
126
+ function variantClassSuffix(elementName: string, props: Record<string, IrValue>): string | undefined {
127
+ const variantLit = propLiteral(props, 'variant');
128
+ if (typeof variantLit === 'string') {
129
+ return ` vc-${componentToKebab(elementName)}--${variantLit}`;
130
+ }
131
+ const variant = propExpr(props, 'variant');
132
+ if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
133
+ return undefined;
134
+ }
135
+
136
+ function classNameAttr(
137
+ elementName: string,
138
+ entry: ElementMapEntry,
139
+ props: Record<string, IrValue>,
140
+ headingLevel?: number,
141
+ ): string {
142
+ const base = entry.className ?? '';
143
+ if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
144
+ const levelClass = `${base} vc-heading-${headingLevel}`;
145
+ const variantSuffix = variantClassSuffix(elementName, props);
146
+ if (!variantSuffix) return `className="${levelClass}"`;
147
+ const variantLit = propLiteral(props, 'variant');
148
+ if (typeof variantLit === 'string') {
149
+ return `className="${levelClass}${variantSuffix}"`;
150
+ }
151
+ return `className={\`${levelClass}${variantSuffix}\`}`;
152
+ }
153
+ const variantSuffix = variantClassSuffix(elementName, props);
154
+ if (variantSuffix) {
155
+ const variantLit = propLiteral(props, 'variant');
156
+ if (typeof variantLit === 'string') {
157
+ return `className="${base}${variantSuffix}"`;
158
+ }
159
+ return `className={\`${base}${variantSuffix}\`}`;
160
+ }
161
+ if (entry.wrapProp && propLiteral(props, entry.wrapProp) === true) {
162
+ return `className="${base} vc-wrap"`;
163
+ }
164
+ if (entry.sizeClass && props.size) {
165
+ const size = propExpr(props, 'size');
166
+ return `className={\`${entry.className} ${entry.sizeClass}-\${${size} ?? 'md'}\`}`;
167
+ }
168
+ return `className="${base}"`;
169
+ }
170
+
171
+ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: number): string {
172
+ const labelLit = propLiteral(props, 'label');
173
+ const helpLit = propLiteral(props, 'help');
174
+ const errorLit = propLiteral(props, 'error');
175
+ const label = propExpr(props, 'label');
176
+ const help = propExpr(props, 'help');
177
+ const error = propExpr(props, 'error');
178
+ const lines = [`${indent(level)}<label className="vc-field">`];
179
+ if (typeof labelLit === 'string') {
180
+ lines.push(`${indent(level + 1)}<span className="vc-field-label">{${JSON.stringify(labelLit)}}</span>`);
181
+ } else if (label) {
182
+ lines.push(`${indent(level + 1)}{${label} ? <span className="vc-field-label">{${label}}</span> : null}`);
183
+ }
184
+ lines.push(`${indent(level + 1)}${inner}`);
185
+ if (typeof helpLit === 'string') {
186
+ lines.push(`${indent(level + 1)}<span className="vc-field-help">{${JSON.stringify(helpLit)}}</span>`);
187
+ } else if (help) {
188
+ lines.push(`${indent(level + 1)}{${help} ? <span className="vc-field-help">{${help}}</span> : null}`);
189
+ }
190
+ if (typeof errorLit === 'string') {
191
+ lines.push(`${indent(level + 1)}<span className="vc-field-error">{${JSON.stringify(errorLit)}}</span>`);
192
+ } else if (error) {
193
+ lines.push(`${indent(level + 1)}{${error} ? <span className="vc-field-error">{${error}}</span> : null}`);
194
+ }
195
+ lines.push(`${indent(level)}</label>`);
196
+ return lines.join('\n');
197
+ }
198
+
199
+ function emitCheckbox(props: Record<string, IrValue>, level: number): string {
200
+ const name = propExpr(props, 'name') ?? '""';
201
+ const label = propExpr(props, 'label') ?? '""';
202
+ const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
203
+ const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
204
+ return `${indent(level)}<label className="vc-checkbox">
205
+ ${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
206
+ ${indent(level + 1)}<span>{${label}}</span>
207
+ ${indent(level)}</label>`;
208
+ }
209
+
210
+ function emitSelectOptions(props: Record<string, IrValue>, level: number): string {
211
+ const options = props.options;
212
+ if (!options || typeof options !== 'object' || !('kind' in options)) return '';
213
+ if (options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
214
+ const items = (options as IrLiteral).value as Array<{ label: string; value: string }>;
215
+ return items.map((opt) => `${indent(level + 1)}<option key={${JSON.stringify(opt.value)}} value={${JSON.stringify(opt.value)}}>{${JSON.stringify(opt.label)}}</option>`).join('\n');
216
+ }
217
+ const expr = renderValue(options, 0);
218
+ return `${indent(level + 1)}{${expr}.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}`;
219
+ }
220
+
221
+ function emitVoidInput(
222
+ elementName: string,
223
+ entry: ElementMapEntry,
224
+ props: Record<string, IrValue>,
225
+ level: number,
226
+ ): string {
227
+ const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
228
+ if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
229
+ if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
230
+ if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
231
+ if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
232
+ if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
233
+ if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
234
+ if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}`);
235
+ return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
236
+ }
237
+
238
+ function emitOpenTag(
239
+ elementName: string,
240
+ entry: ElementMapEntry,
241
+ props: Record<string, IrValue>,
242
+ tag: string,
243
+ headingLevel?: number,
244
+ ): string {
245
+ const attrs: string[] = [];
246
+ if (entry.ariaHidden) attrs.push('aria-hidden');
247
+ if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
248
+ if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
249
+ else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
250
+ if (entry.ariaLabel) {
251
+ if (props.ariaLabel) attrs.push(`aria-label={${propExpr(props, 'ariaLabel')}}`);
252
+ else if (props.label) attrs.push(`aria-label={${propExpr(props, 'label')}}`);
253
+ else if (props.title) attrs.push(`aria-label={${propExpr(props, 'title')}}`);
254
+ }
255
+ if (entry.actionEvent) {
256
+ const handler = actionHandler(props, entry.actionEvent);
257
+ if (handler) attrs.push(handler);
258
+ }
259
+ if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
260
+ if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
261
+ if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
262
+ attrs.push(classNameAttr(elementName, entry, props, headingLevel));
263
+ attrs.push(styleAttr(entry, props));
264
+ return `<${tag} ${attrs.join(' ')}`;
265
+ }
266
+
267
+ function visibilityGuard(inner: string, props: Record<string, IrValue>, level: number): string {
268
+ if (props.visible === undefined) return inner;
269
+ const vis = propExpr(props, 'visible');
270
+ return `${indent(level)}{${vis} !== false ? (\n${inner}\n${indent(level)}) : null}`;
271
+ }
272
+
273
+ type NodeRenderer = (node: IrComponentNode, level: number, itemScope?: string) => string;
274
+
275
+ function renderChildren(
276
+ children: IrNode[],
277
+ level: number,
278
+ renderNested: NodeRenderer,
279
+ itemScope?: string,
280
+ ): string {
281
+ if (children.length === 0) return '';
282
+ const vmRef = itemScope ?? 'vm';
283
+ const parts = children.map((child) => {
284
+ if (child.kind === 'text') {
285
+ const text = (child as IrTextNode).value;
286
+ return text ? `${indent(level)}{${JSON.stringify(text)}}` : '';
287
+ }
288
+ if (child.kind === 'binding') {
289
+ return `${indent(level)}{${bindingExpr(child, vmRef)}}`;
290
+ }
291
+ if (child.kind === 'map') return renderMap(child as IrMapNode, level, renderNested);
292
+ if (child.kind === 'fragment') {
293
+ return renderChildren(child.children, level, renderNested, itemScope);
294
+ }
295
+ if (child.kind === 'component') return renderNested(child, level, itemScope);
296
+ return '';
297
+ }).filter(Boolean);
298
+ return parts.join('\n');
299
+ }
300
+
301
+ function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer): string {
302
+ const item = node.item;
303
+ let body = renderNested(node.body as IrComponentNode, level + 1, item);
304
+ if (node.key) {
305
+ const keyAttr = ` key={${bindingExpr(node.key, item)}}`;
306
+ body = body.replace(/^( +)<(\w+)/, `$1<$2${keyAttr}`);
307
+ }
308
+ return `${indent(level)}{${bindingExpr(node.source)}.map((${item}) => (\n${body}\n${indent(level)}))}`;
309
+ }
310
+
311
+ function lowerVocabularyElement(
312
+ node: IrComponentNode,
313
+ entry: ElementMapEntry,
314
+ level: number,
315
+ itemScope: string | undefined,
316
+ renderNested: NodeRenderer,
317
+ ): string {
318
+ const props = node.props;
319
+ const childContent = renderChildren(node.children, level + 1, renderNested, itemScope);
320
+
321
+ if (entry.checkbox) {
322
+ return visibilityGuard(emitCheckbox(props, level), props, level);
323
+ }
324
+
325
+ if (entry.fieldWrap) {
326
+ const tag = entry.tag;
327
+ let inner: string;
328
+ if (entry.optionsProp && props[entry.optionsProp]) {
329
+ const open = emitOpenTag(node.name, entry, props, tag);
330
+ inner = `${indent(level + 1)}${open}>\n${emitSelectOptions(props, level + 2)}\n${indent(level + 1)}</${tag}>`;
331
+ } else {
332
+ inner = emitVoidInput(node.name, entry, props, level + 1);
333
+ }
334
+ return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
335
+ }
336
+
337
+ if (entry.tag === 'dynamic-heading') {
338
+ const levelLit = propLiteral(props, 'level');
339
+ if (typeof levelLit === 'number') {
340
+ const clamped = Math.min(6, Math.max(1, levelLit));
341
+ const tag = `h${clamped}`;
342
+ const open = emitOpenTag(node.name, entry, props, tag, clamped);
343
+ const body = childContent
344
+ ? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
345
+ : `${indent(level)}${open} />`;
346
+ return visibilityGuard(body, props, level);
347
+ }
348
+ const levelExpr = propExpr(props, 'level') ?? '2';
349
+ const inner = childContent
350
+ ? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`vc-heading vc-heading-\${__level}\`} style={commonStyle(${collectStyleProps(props)})}>{/* children below */}</Tag>; })()}`
351
+ : '';
352
+ return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
353
+ }
354
+
355
+ const tag = entry.tag;
356
+ if (entry.void) {
357
+ return visibilityGuard(`${indent(level)}${emitOpenTag(node.name, entry, props, tag)} />`, props, level);
358
+ }
359
+
360
+ const open = emitOpenTag(node.name, entry, props, tag);
361
+ let body: string;
362
+ if (!childContent && entry.optionsProp && props[entry.optionsProp]) {
363
+ body = `${indent(level)}${open}>\n${emitSelectOptions(props, level + 1)}\n${indent(level)}</${tag}>`;
364
+ } else if (!childContent) {
365
+ body = `${indent(level)}${open} />`;
366
+ } else {
367
+ body = `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`;
368
+ }
369
+ return visibilityGuard(body, props, level);
370
+ }
371
+
372
+ function renderExtensionProps(props: Record<string, IrValue>, itemScope?: string): string {
373
+ const vmRef = itemScope ?? 'vm';
374
+ return Object.entries(props).map(([key, value]) => {
375
+ if (key === 'action' && typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'action') {
376
+ return `${key}={${renderAction(value as IrAction, true)}}`;
377
+ }
378
+ if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
379
+ return `${key}={${bindingExpr(value as IrBinding, vmRef)}}`;
380
+ }
381
+ if (key === 'children') return '';
382
+ return `${key}={${renderValue(value, 0, key === 'columns')}}`;
383
+ }).join(' ');
384
+ }
385
+
386
+ function renderExtensionElement(
387
+ node: IrComponentNode,
388
+ level: number,
389
+ itemScope: string | undefined,
390
+ renderNested: NodeRenderer,
391
+ ): string {
392
+ const props = renderExtensionProps(node.props, itemScope);
393
+ const propStr = props ? ` ${props}` : '';
394
+ const childContent = renderChildren(node.children, level + 1, renderNested, itemScope);
395
+ if (!childContent) {
396
+ return `${indent(level)}<${node.name}${propStr} />`;
397
+ }
398
+ return `${indent(level)}<${node.name}${propStr}>\n${childContent}\n${indent(level)}</${node.name}>`;
399
+ }
400
+
401
+ function isVocabulary(name: string, map: ReactElementMap, allowlistExtra: string[]): boolean {
402
+ return name in map.elements && !allowlistExtra.includes(name);
403
+ }
404
+
405
+ export function collectExtensionComponents(
406
+ node: IrNode,
407
+ map: ReactElementMap,
408
+ allowlistExtra: string[],
409
+ names: Set<string>,
410
+ ): void {
411
+ if (node.kind === 'component') {
412
+ if (!isVocabulary(node.name, map, allowlistExtra)) names.add(node.name);
413
+ for (const child of node.children) collectExtensionComponents(child, map, allowlistExtra, names);
414
+ return;
415
+ }
416
+ if (node.kind === 'map') collectExtensionComponents(node.body, map, allowlistExtra, names);
417
+ if (node.kind === 'fragment') {
418
+ for (const child of node.children) collectExtensionComponents(child, map, allowlistExtra, names);
419
+ }
420
+ }
421
+
422
+ export function createNodeRenderer(
423
+ map: ReactElementMap,
424
+ allowlistExtra: string[],
425
+ ): NodeRenderer {
426
+ const renderNested: NodeRenderer = (node, level, itemScope) => {
427
+ if (isVocabulary(node.name, map, allowlistExtra)) {
428
+ return lowerVocabularyElement(node, map.elements[node.name]!, level, itemScope, renderNested);
429
+ }
430
+ return renderExtensionElement(node, level, itemScope, renderNested);
431
+ };
432
+ return renderNested;
433
+ }
@@ -0,0 +1,10 @@
1
+ import { resolve } from 'node:path';
2
+ import { packageRoot } from '../../src/lib/packageRoot.js';
3
+
4
+ export function reactTemplatesDir(customDir?: string): string {
5
+ return customDir ?? resolve(packageRoot(), 'renderers/react/templates');
6
+ }
7
+
8
+ export function reactRuntimeDir(): string {
9
+ return resolve(packageRoot(), 'renderers/react/runtime');
10
+ }
@@ -0,0 +1,65 @@
1
+ import type { ViewIrDocument } from '../../src/ir/types.js';
2
+ import { loadReactElementMap } from './elementMap.js';
3
+ import { collectExtensionComponents, createNodeRenderer } from './lowering/lowerToJsx.js';
4
+
5
+ export interface RenderReactOptions {
6
+ componentsImportFrom: string;
7
+ allowlistExtra?: string[];
8
+ elementMapPath?: string;
9
+ }
10
+
11
+ export async function renderReactComponent(
12
+ doc: ViewIrDocument,
13
+ options: RenderReactOptions,
14
+ ): Promise<string> {
15
+ const map = await loadReactElementMap(options.elementMapPath);
16
+ const allowlistExtra = options.allowlistExtra ?? [];
17
+ const baseName = doc.exportName.replace(/View$/, '') || doc.exportName;
18
+ const componentName = baseName.charAt(0).toUpperCase() + baseName.slice(1);
19
+ const root = doc.root.kind === 'component' ? doc.root : null;
20
+ if (!root) {
21
+ throw new Error(`View IR root must be a component node (${doc.source})`);
22
+ }
23
+
24
+ const renderNode = createNodeRenderer(map, allowlistExtra);
25
+ const body = renderNode(root, 2);
26
+
27
+ const extensions = new Set<string>();
28
+ collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
29
+ const extensionImports = [...extensions].sort();
30
+
31
+ const imports: string[] = [
32
+ `import type { ReactElement } from 'react';`,
33
+ `import { dispatchAction } from '../runtime/dispatch.js';`,
34
+ `import { commonStyle, mapAlign, mapJustify } from '../components/style-helpers.js';`,
35
+ `import type { ${doc.viewModel}, ActionDescriptor } from '../contracts';`,
36
+ ];
37
+ if (extensionImports.length > 0) {
38
+ imports.push(`import { ${extensionImports.join(', ')} } from '${options.componentsImportFrom}';`);
39
+ }
40
+
41
+ return `/**
42
+ * Auto-generated React component from ${doc.source}
43
+ * DO NOT EDIT — regenerate with: view-contracts render react
44
+ */
45
+ ${imports.join('\n')}
46
+
47
+ export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
48
+ return (
49
+ ${body}
50
+ );
51
+ }
52
+ `;
53
+ }
54
+
55
+ export async function renderReactFromIr(
56
+ doc: ViewIrDocument,
57
+ outputPath: string,
58
+ options: RenderReactOptions,
59
+ ): Promise<void> {
60
+ const { writeFile, mkdir } = await import('node:fs/promises');
61
+ const { dirname } = await import('node:path');
62
+ const content = await renderReactComponent(doc, options);
63
+ await mkdir(dirname(outputPath), { recursive: true });
64
+ await writeFile(outputPath, content, 'utf-8');
65
+ }
@@ -0,0 +1,157 @@
1
+ import { readFile, writeFile, mkdir, stat, cp, unlink } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import type { ViewContractsConfig } from '../../src/config.js';
4
+ import { projectRoot } from '../../src/config.js';
5
+ import { loadThemeCss } from '../../src/catalog/loadThemeCss.js';
6
+ import {
7
+ defaultThemePath,
8
+ defaultTokensPath,
9
+ emitTokenCss,
10
+ emitVariantCss,
11
+ extractStructuralThemeCss,
12
+ loadValidateDesign,
13
+ } from '../../src/design/index.js';
14
+ import { packageRoot } from '../../src/lib/packageRoot.js';
15
+ import { renderTemplate } from '../../src/renderer/template.js';
16
+ import { reactRuntimeDir, reactTemplatesDir } from './paths.js';
17
+
18
+ const toolchainRoot = packageRoot();
19
+
20
+ function toolchainSchemaDir(): string {
21
+ return resolve(toolchainRoot, 'src/generated/schema');
22
+ }
23
+
24
+ async function pathExists(target: string): Promise<boolean> {
25
+ try {
26
+ await stat(target);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function rewriteSchemaImports(content: string): string {
34
+ return content.split('../../../src/generated/schema/').join('./schema/');
35
+ }
36
+
37
+ async function copySchemaBundle(componentsDir: string): Promise<void> {
38
+ const schemaSrc = toolchainSchemaDir();
39
+ if (!(await pathExists(schemaSrc))) return;
40
+
41
+ const schemaDest = resolve(componentsDir, 'schema');
42
+ await cp(schemaSrc, schemaDest, { recursive: true });
43
+ for (const name of ['dsl-properties.ts', 'dsl-enums.ts', 'schema-document.ts', 'index.ts'] as const) {
44
+ const file = resolve(schemaDest, name);
45
+ if (await pathExists(file)) {
46
+ await writeFile(file, rewriteSchemaImports(await readFile(file, 'utf-8')), 'utf-8');
47
+ }
48
+ }
49
+ }
50
+
51
+ async function copyStyleBundle(componentsDir: string): Promise<void> {
52
+ const runtimeSrc = reactRuntimeDir();
53
+ const style = rewriteSchemaImports(await readFile(resolve(runtimeSrc, 'style.ts'), 'utf-8'));
54
+ await writeFile(resolve(componentsDir, 'style.ts'), style, 'utf-8');
55
+
56
+ const types = rewriteSchemaImports(await readFile(resolve(runtimeSrc, 'types.ts'), 'utf-8'));
57
+ await writeFile(resolve(componentsDir, 'types.ts'), types, 'utf-8');
58
+ }
59
+
60
+ async function writeThemeCss(
61
+ root: string,
62
+ themeOut: string,
63
+ config: ViewContractsConfig,
64
+ templatesDir?: string,
65
+ ): Promise<void> {
66
+ const tokensPath = config.design?.tokens ?? defaultTokensPath();
67
+ const themePath = config.design?.theme ?? defaultThemePath();
68
+ const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
69
+ const runtimeTheme = resolve(reactRuntimeDir(), 'theme.css');
70
+ const runtimeCss = await loadThemeCss(runtimeTheme);
71
+ const variantCss = emitVariantCss(tokenIR, themeIR);
72
+ let css = `${emitTokenCss(tokenIR)}\n\n${variantCss}\n\n${extractStructuralThemeCss(runtimeCss)}`;
73
+ const extensionsCss = config.design?.extensionsCss ?? resolve(root, 'src/theme.extensions.css');
74
+ if (await pathExists(extensionsCss)) {
75
+ css += `\n\n${await readFile(extensionsCss, 'utf-8')}`;
76
+ }
77
+ const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
78
+ await writeFile(themeOut, content, 'utf-8');
79
+
80
+ const themeDir = resolve(config.generated.reactDir, '../theme');
81
+ await mkdir(themeDir, { recursive: true });
82
+ await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
83
+ await writeFile(resolve(themeDir, 'theme.json'), `${JSON.stringify(themeIR, null, 2)}\n`, 'utf-8');
84
+ }
85
+
86
+ async function removeStaleGeneratedFiles(componentsDir: string): Promise<void> {
87
+ for (const name of ['ui-components.tsx', 'core.ts', 'actionRuntime.ts', 'shared.tsx', 'shared.ts'] as const) {
88
+ const target = resolve(componentsDir, name);
89
+ if (await pathExists(target)) {
90
+ await unlink(target);
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Emit standalone generated runtime (no preview vocabulary copy; no @view-contracts imports). */
96
+ export async function renderReactRuntime(configPath: string, config: ViewContractsConfig): Promise<void> {
97
+ const root = projectRoot(configPath);
98
+ const templatesDir = config.rendererTemplatesDir
99
+ ? resolve(root, config.rendererTemplatesDir)
100
+ : undefined;
101
+ const templates = reactTemplatesDir(templatesDir);
102
+ const componentsDir = resolve(config.generated.reactDir, '../components');
103
+ const runtimeDir = resolve(config.generated.reactDir, '../runtime');
104
+ const srcComponentsDir = resolve(root, 'src/components');
105
+
106
+ await mkdir(componentsDir, { recursive: true });
107
+ await mkdir(runtimeDir, { recursive: true });
108
+
109
+ await removeStaleGeneratedFiles(componentsDir);
110
+ await copySchemaBundle(componentsDir);
111
+ await copyStyleBundle(componentsDir);
112
+ await writeFile(
113
+ resolve(componentsDir, 'style-helpers.ts'),
114
+ await renderTemplate(templates, 'style-helpers.ts.hbs', {}),
115
+ 'utf-8',
116
+ );
117
+ await writeFile(
118
+ resolve(componentsDir, 'index.ts'),
119
+ await renderTemplate(templates, 'components-index.ts.hbs', {}),
120
+ 'utf-8',
121
+ );
122
+
123
+ for (const name of ['extensions.tsx', 'sample-types.ts'] as const) {
124
+ const src = resolve(srcComponentsDir, name);
125
+ let body = await readFile(src, 'utf-8');
126
+ if (name === 'sample-types.ts') {
127
+ body = body.replace("from './core.js'", "from './types.js'");
128
+ }
129
+ await writeFile(resolve(componentsDir, name), body, 'utf-8');
130
+ }
131
+
132
+ const handlersSrc = resolve(root, 'src/runtime/handlers.ts');
133
+ const handlersBody = (await readFile(handlersSrc, 'utf-8')).replace(
134
+ /export async function handleAppAction\(action:\s*\{[\s\S]*?\}\)/,
135
+ 'export async function handleAppAction(action: DispatchableAction)',
136
+ );
137
+ await writeFile(
138
+ resolve(runtimeDir, 'handlers.ts'),
139
+ await renderTemplate(templates, 'handlers.ts.hbs', { handlersBody }),
140
+ 'utf-8',
141
+ );
142
+
143
+ await writeThemeCss(root, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
144
+ await writeFile(
145
+ resolve(runtimeDir, 'types.ts'),
146
+ await renderTemplate(templates, 'runtime-types.ts.hbs', {}),
147
+ 'utf-8',
148
+ );
149
+ await writeFile(
150
+ resolve(runtimeDir, 'dispatch.ts'),
151
+ await renderTemplate(templates, 'dispatch.ts.hbs', {}),
152
+ 'utf-8',
153
+ );
154
+
155
+ console.log(` components → ${componentsDir}`);
156
+ console.log(` theme → ${resolve(config.generated.reactDir, '../theme.css')}`);
157
+ }
@@ -2,9 +2,10 @@
2
2
  # Vocabulary: spec/ui-dsl.schema.json. Per-target maps: renderers/{target}/elements.yaml
3
3
 
4
4
  react:
5
- description: Production / preview React components via project componentsModule
6
- importFrom: '@view-contracts/components'
5
+ description: Production React via compile-time IR lowering + template-emitted runtime
6
+ importFrom: '../components/index.js'
7
7
  themeCss: theme.css
8
+ templates: renderers/react/templates
8
9
  outputs:
9
10
  screen:
10
11
  pattern: '{{projectRoot}}/generated/react/{{screenName}}.generated.tsx'