view-contracts 0.1.0 → 0.2.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 (31) hide show
  1. package/dist/view-contracts.bundle.mjs +148 -153
  2. package/dist/view-contracts.bundle.mjs.map +4 -4
  3. package/package.json +10 -4
  4. package/renderers/react/README.md +19 -3
  5. package/renderers/react/element-config.meta.json +23 -0
  6. package/renderers/react/elementMap.ts +55 -0
  7. package/renderers/react/elements.yaml +87 -0
  8. package/renderers/react/index.ts +2 -0
  9. package/renderers/react/lowering/lowerToJsx.ts +397 -0
  10. package/renderers/react/paths.ts +10 -0
  11. package/renderers/react/renderComponents.ts +65 -0
  12. package/renderers/react/renderRuntime.ts +134 -0
  13. package/renderers/react/routes.yaml +3 -2
  14. package/{src/renderer → renderers}/react/runtime/actionRuntime.ts +3 -3
  15. package/renderers/react/runtime/dispatch.ts +21 -0
  16. package/renderers/react/runtime/index.ts +5 -0
  17. package/{src/renderer → renderers}/react/runtime/style.ts +35 -8
  18. package/renderers/react/runtime/types.ts +63 -0
  19. package/renderers/react/templates/components-index.ts.hbs +5 -0
  20. package/renderers/react/templates/dispatch.ts.hbs +22 -0
  21. package/renderers/react/templates/handlers.ts.hbs +4 -0
  22. package/renderers/react/templates/runtime-types.ts.hbs +5 -0
  23. package/renderers/react/templates/style-helpers.ts.hbs +2 -0
  24. package/renderers/react/templates/theme.css.hbs +2 -0
  25. package/src/generated/schema/lowering-style-properties.ts +36 -0
  26. package/src/generated/schema/react-renderer-element-config.ts +26 -0
  27. package/src/renderer/react/runtime/components.tsx +0 -216
  28. package/src/renderer/react/runtime/index.ts +0 -4
  29. package/src/renderer/react/runtime/shared.tsx +0 -53
  30. package/src/renderer/react/runtime/types.ts +0 -46
  31. /package/{src/renderer → renderers}/react/runtime/theme.css +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -12,7 +12,7 @@
12
12
  "files": [
13
13
  "dist/view-contracts.bundle.mjs",
14
14
  "dist/view-contracts.bundle.mjs.map",
15
- "src/renderer/react/runtime",
15
+ "renderers/react/runtime",
16
16
  "src/generated/schema",
17
17
  "spec",
18
18
  "renderers",
@@ -43,7 +43,10 @@
43
43
  "contracts:check": "tsx src/cli.ts contracts check -c examples/admin-dashboard/view-contracts.config.yaml",
44
44
  "schema:generate": "tsx scripts/schema-generate.ts",
45
45
  "cli-contracts:validate": "cli-contracts validate",
46
- "test:ci": "node dist/view-contracts.bundle.mjs --version && node dist/view-contracts.bundle.mjs --help",
46
+ "test": "vitest run",
47
+ "test:unit": "vitest run",
48
+ "test:smoke": "node dist/view-contracts.bundle.mjs --version && node dist/view-contracts.bundle.mjs --help",
49
+ "test:ci": "npm run test:unit && npm run build:publish && npm run test:smoke",
47
50
  "version": "node -e \"const fs=require('fs');const f='cli-contract.yaml';fs.writeFileSync(f,fs.readFileSync(f,'utf8').replace(/^ version: .*/m,' version: '+process.env.npm_package_version))\" && git add cli-contract.yaml",
48
51
  "prepublishOnly": "npm run build:publish && npm run test:ci"
49
52
  },
@@ -73,6 +76,7 @@
73
76
  },
74
77
  "dependencies": {
75
78
  "commander": "^14.0.3",
79
+ "handlebars": "^4.7.9",
76
80
  "js-yaml": "^4.1.1",
77
81
  "micro-contracts": "^0.17.10",
78
82
  "react": "^19.2.0",
@@ -80,6 +84,7 @@
80
84
  "typescript": "^5.9.3"
81
85
  },
82
86
  "devDependencies": {
87
+ "@types/handlebars": "^4.1.0",
83
88
  "@types/js-yaml": "^4.0.9",
84
89
  "@types/node": "^22.19.21",
85
90
  "@types/react": "^19.2.17",
@@ -89,6 +94,7 @@
89
94
  "cli-contracts": "^0.33.13",
90
95
  "esbuild": "^0.28.0",
91
96
  "tsx": "^4.21.0",
92
- "vite": "^6.4.1"
97
+ "vite": "^6.4.1",
98
+ "vitest": "^4.1.9"
93
99
  }
94
100
  }
@@ -3,8 +3,24 @@
3
3
  Per-target mapping for view-contracts UI DSL → React.
4
4
 
5
5
  - `routes.yaml` — generated output file routing
6
- - `elements.yaml` — element → React component (hand-maintained)
7
- - `properties.yaml` — property style/attr (hand-maintained)
6
+ - `elements.yaml` — vocabulary element → HTML tag (SSoT for compile-time lowering)
7
+ - `element-config.meta.json` — shape of `elements.yaml` entries (drives schema codegen)
8
+ - `templates/*.hbs` — Handlebars templates for generated runtime files (dispatch, types, theme, etc.)
9
+ - `renderComponents.ts` / `renderRuntime.ts` — compile-time codegen (IR → JSX, runtime bundle)
10
+ - `lowering/lowerToJsx.ts` — element-map-driven JSX emission
11
+ - `runtime/` — preview/toolchain primitives (style, dispatch) — **not** copied to `generated/`
8
12
 
9
13
  Vocabulary (elements/properties) is defined in `spec/ui-dsl.schema.json` (from `ui-dsl-*.csv`).
10
- Sample-only preview helpers (Sidebar, Card, etc.) are **not** part of the vocabulary.
14
+ Preview vocabulary stubs are generated to `src/generated/preview/vocabulary.tsx`.
15
+ Sample-only preview helpers (Sidebar, Card, etc.) are **not** part of the vocabulary — they live in project `src/components/extensions.tsx`.
16
+
17
+ Generic renderer utilities (template engine, YAML element-map validation) live under `src/renderer/`.
18
+
19
+ ## Compile-time lowering
20
+
21
+ `renderComponents.ts` reads `elements.yaml` and emits native HTML/JSX into `generated/react/*.generated.tsx`.
22
+ `renderers/react/runtime/` is **not** copied to `generated/`.
23
+
24
+ ## Template customization
25
+
26
+ Set `rendererTemplatesDir` in `view-contracts.config.yaml` to override default templates.
@@ -0,0 +1,23 @@
1
+ {
2
+ "description": "Per-element fields allowed in renderers/react/elements.yaml (React HTML lowering config — not UI DSL vocabulary).",
3
+ "fields": {
4
+ "tag": { "type": "string", "required": true },
5
+ "className": { "type": "string" },
6
+ "ariaLabel": { "type": "boolean" },
7
+ "ariaHidden": { "type": "boolean" },
8
+ "flex": { "type": "enum", "values": ["row", "column"] },
9
+ "wrapProp": { "type": "string" },
10
+ "flexGrowProp": { "type": "string" },
11
+ "void": { "type": "boolean" },
12
+ "levelProp": { "type": "string" },
13
+ "defaultLevel": { "type": "number" },
14
+ "actionEvent": { "type": "enum", "values": ["onClick", "onSubmit"] },
15
+ "variantClass": { "type": "boolean" },
16
+ "fieldWrap": { "type": "boolean" },
17
+ "inputType": { "type": "string" },
18
+ "sizeClass": { "type": "string" },
19
+ "optionsProp": { "type": "string" },
20
+ "checkbox": { "type": "boolean" },
21
+ "defaultRole": { "type": "string" }
22
+ }
23
+ }
@@ -0,0 +1,55 @@
1
+ import { resolve } from 'node:path';
2
+ import { ALLOWED_COMPONENTS } from '../../src/generated/schema/allowed-components.js';
3
+ import type { ReactElementMapFile, ReactRendererElementConfig } from '../../src/generated/schema/react-renderer-element-config.js';
4
+ import { packageRoot } from '../../src/lib/packageRoot.js';
5
+ import {
6
+ loadYamlFile,
7
+ validateMapEntryFields,
8
+ validateVocabularyElementNames,
9
+ } from '../../src/renderer/elementMapValidation.js';
10
+ import { loadUiDslSchema } from '../../src/schema/loadSchema.js';
11
+
12
+ export type { ReactElementMapFile, ReactRendererElementConfig };
13
+ export type ReactElementMap = ReactElementMapFile;
14
+ export type ElementMapEntry = ReactRendererElementConfig;
15
+
16
+ const SOURCE_LABEL = 'renderers/react/elements.yaml';
17
+
18
+ let cachedMap: ReactElementMapFile | null = null;
19
+
20
+ function validateElementMap(map: ReactElementMapFile): void {
21
+ validateVocabularyElementNames(Object.keys(map.elements), ALLOWED_COMPONENTS, SOURCE_LABEL);
22
+ validateMapEntryFields(map.elements, ['tag'], SOURCE_LABEL);
23
+ }
24
+
25
+ export async function loadReactElementMap(customPath?: string): Promise<ReactElementMapFile> {
26
+ if (!customPath && cachedMap) return cachedMap;
27
+ const mapPath = customPath ?? resolve(packageRoot(), 'renderers/react/elements.yaml');
28
+ const raw = await loadYamlFile<ReactElementMapFile>(mapPath);
29
+ validateElementMap(raw);
30
+ if (!customPath) cachedMap = raw;
31
+ return raw;
32
+ }
33
+
34
+ /** DSL vocabulary element with a React HTML lowering entry (excludes project extensions). */
35
+ export function isLowerableVocabularyElement(
36
+ name: string,
37
+ map: ReactElementMapFile,
38
+ allowlistExtra: string[] = [],
39
+ ): boolean {
40
+ return ALLOWED_COMPONENTS.has(name) && !allowlistExtra.includes(name) && name in map.elements;
41
+ }
42
+
43
+ export async function assertElementMapCoversImplementedVocabulary(customPath?: string): Promise<void> {
44
+ const [schema, map] = await Promise.all([loadUiDslSchema(), loadReactElementMap(customPath)]);
45
+ const missing = Object.keys(schema.elements).filter((name) => !(name in map.elements));
46
+ if (missing.length > 0) {
47
+ console.warn(
48
+ `${SOURCE_LABEL}: ${missing.length} DSL element(s) have no React HTML map yet: ${missing.join(', ')}`,
49
+ );
50
+ }
51
+ }
52
+
53
+ export function isVocabularyElement(name: string, map: ReactElementMapFile, allowlistExtra: string[] = []): boolean {
54
+ return name in map.elements && !allowlistExtra.includes(name);
55
+ }
@@ -0,0 +1,87 @@
1
+ # React renderer element → HTML lowering map (SSoT).
2
+ # Vocabulary from spec/ui-dsl.schema.json. Extension components are project-specific
3
+ # (allowlistExtra) and stay as React component imports — not listed here.
4
+
5
+ elements:
6
+ Page:
7
+ tag: main
8
+ className: ui-page
9
+ ariaLabel: true
10
+ Box:
11
+ tag: div
12
+ className: ui-box
13
+ ariaLabel: true
14
+ Column:
15
+ tag: div
16
+ className: ui-column
17
+ flex: column
18
+ ariaLabel: true
19
+ Row:
20
+ tag: div
21
+ className: ui-row
22
+ flex: row
23
+ wrapProp: wrap
24
+ ariaLabel: true
25
+ Stack:
26
+ tag: div
27
+ className: ui-stack
28
+ ariaLabel: true
29
+ Spacer:
30
+ tag: div
31
+ className: ui-spacer
32
+ ariaHidden: true
33
+ flexGrowProp: weight
34
+ Divider:
35
+ tag: hr
36
+ className: ui-divider
37
+ void: true
38
+ Heading:
39
+ tag: dynamic-heading
40
+ className: ui-heading
41
+ levelProp: level
42
+ defaultLevel: 2
43
+ Text:
44
+ tag: p
45
+ className: ui-text
46
+ Link:
47
+ tag: a
48
+ className: ui-link
49
+ actionEvent: onClick
50
+ Button:
51
+ tag: button
52
+ className: ui-button
53
+ variantClass: true
54
+ actionEvent: onClick
55
+ Form:
56
+ tag: form
57
+ className: ui-form
58
+ actionEvent: onSubmit
59
+ ariaLabel: true
60
+ TextInput:
61
+ tag: input
62
+ className: ui-input
63
+ fieldWrap: true
64
+ inputType: text
65
+ sizeClass: ui-field
66
+ TextArea:
67
+ tag: textarea
68
+ className: ui-textarea
69
+ fieldWrap: true
70
+ Select:
71
+ tag: select
72
+ className: ui-select
73
+ fieldWrap: true
74
+ sizeClass: ui-field
75
+ optionsProp: options
76
+ Checkbox:
77
+ tag: label
78
+ className: ui-check
79
+ checkbox: true
80
+ List:
81
+ tag: ul
82
+ className: ui-list
83
+ defaultRole: list
84
+ ListItem:
85
+ tag: li
86
+ className: ui-list-item
87
+ defaultRole: listitem
@@ -0,0 +1,2 @@
1
+ export { renderReactFromIr, renderReactComponent } from './renderComponents.js';
2
+ export { renderReactRuntime } from './renderRuntime.js';
@@ -0,0 +1,397 @@
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 classNameAttr(entry: ElementMapEntry, props: Record<string, IrValue>, headingLevel?: number): string {
123
+ const base = entry.className ?? '';
124
+ if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
125
+ return `className="ui-heading ui-heading-${headingLevel}"`;
126
+ }
127
+ if (entry.variantClass) {
128
+ const variantLit = propLiteral(props, 'variant');
129
+ if (typeof variantLit === 'string') {
130
+ return `className="${base} ui-button-${variantLit}"`;
131
+ }
132
+ const variant = propExpr(props, 'variant');
133
+ if (variant) return `className={\`${base} ui-button-\${${variant} ?? 'secondary'}\`}`;
134
+ return `className="${base} ui-button-secondary"`;
135
+ }
136
+ if (entry.wrapProp && propLiteral(props, entry.wrapProp) === true) {
137
+ return `className="${base} ui-wrap"`;
138
+ }
139
+ if (entry.sizeClass && props.size) {
140
+ const size = propExpr(props, 'size');
141
+ return `className={\`${entry.className} ${entry.sizeClass}-\${${size} ?? 'md'}\`}`;
142
+ }
143
+ return `className="${base}"`;
144
+ }
145
+
146
+ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: number): string {
147
+ const labelLit = propLiteral(props, 'label');
148
+ const helpLit = propLiteral(props, 'help');
149
+ const errorLit = propLiteral(props, 'error');
150
+ const label = propExpr(props, 'label');
151
+ const help = propExpr(props, 'help');
152
+ const error = propExpr(props, 'error');
153
+ const lines = [`${indent(level)}<label className="ui-field">`];
154
+ if (typeof labelLit === 'string') {
155
+ lines.push(`${indent(level + 1)}<span className="ui-field-label">{${JSON.stringify(labelLit)}}</span>`);
156
+ } else if (label) {
157
+ lines.push(`${indent(level + 1)}{${label} ? <span className="ui-field-label">{${label}}</span> : null}`);
158
+ }
159
+ lines.push(`${indent(level + 1)}${inner}`);
160
+ if (typeof helpLit === 'string') {
161
+ lines.push(`${indent(level + 1)}<span className="ui-field-help">{${JSON.stringify(helpLit)}}</span>`);
162
+ } else if (help) {
163
+ lines.push(`${indent(level + 1)}{${help} ? <span className="ui-field-help">{${help}}</span> : null}`);
164
+ }
165
+ if (typeof errorLit === 'string') {
166
+ lines.push(`${indent(level + 1)}<span className="ui-field-error">{${JSON.stringify(errorLit)}}</span>`);
167
+ } else if (error) {
168
+ lines.push(`${indent(level + 1)}{${error} ? <span className="ui-field-error">{${error}}</span> : null}`);
169
+ }
170
+ lines.push(`${indent(level)}</label>`);
171
+ return lines.join('\n');
172
+ }
173
+
174
+ function emitCheckbox(props: Record<string, IrValue>, level: number): string {
175
+ const name = propExpr(props, 'name') ?? '""';
176
+ const label = propExpr(props, 'label') ?? '""';
177
+ const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
178
+ const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
179
+ return `${indent(level)}<label className="ui-check">
180
+ ${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
181
+ ${indent(level + 1)}<span>{${label}}</span>
182
+ ${indent(level)}</label>`;
183
+ }
184
+
185
+ function emitSelectOptions(props: Record<string, IrValue>, level: number): string {
186
+ const options = props.options;
187
+ if (!options || typeof options !== 'object' || !('kind' in options)) return '';
188
+ if (options.kind === 'literal' && Array.isArray((options as IrLiteral).value)) {
189
+ const items = (options as IrLiteral).value as Array<{ label: string; value: string }>;
190
+ 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');
191
+ }
192
+ const expr = renderValue(options, 0);
193
+ return `${indent(level + 1)}{${expr}.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}`;
194
+ }
195
+
196
+ function emitVoidInput(entry: ElementMapEntry, props: Record<string, IrValue>, level: number): string {
197
+ const attrs: string[] = [classNameAttr(entry, props), styleAttr(entry, props)];
198
+ if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
199
+ if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
200
+ if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
201
+ if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
202
+ if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
203
+ if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
204
+ if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}`);
205
+ return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
206
+ }
207
+
208
+ function emitOpenTag(entry: ElementMapEntry, props: Record<string, IrValue>, tag: string, headingLevel?: number): string {
209
+ const attrs: string[] = [];
210
+ if (entry.ariaHidden) attrs.push('aria-hidden');
211
+ if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
212
+ if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
213
+ else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
214
+ if (entry.ariaLabel) {
215
+ if (props.ariaLabel) attrs.push(`aria-label={${propExpr(props, 'ariaLabel')}}`);
216
+ else if (props.label) attrs.push(`aria-label={${propExpr(props, 'label')}}`);
217
+ else if (props.title) attrs.push(`aria-label={${propExpr(props, 'title')}}`);
218
+ }
219
+ if (entry.actionEvent) {
220
+ const handler = actionHandler(props, entry.actionEvent);
221
+ if (handler) attrs.push(handler);
222
+ }
223
+ if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
224
+ if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
225
+ if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
226
+ attrs.push(classNameAttr(entry, props, headingLevel));
227
+ attrs.push(styleAttr(entry, props));
228
+ return `<${tag} ${attrs.join(' ')}`;
229
+ }
230
+
231
+ function visibilityGuard(inner: string, props: Record<string, IrValue>, level: number): string {
232
+ if (props.visible === undefined) return inner;
233
+ const vis = propExpr(props, 'visible');
234
+ return `${indent(level)}{${vis} !== false ? (\n${inner}\n${indent(level)}) : null}`;
235
+ }
236
+
237
+ type NodeRenderer = (node: IrComponentNode, level: number, itemScope?: string) => string;
238
+
239
+ function renderChildren(
240
+ children: IrNode[],
241
+ level: number,
242
+ renderNested: NodeRenderer,
243
+ itemScope?: string,
244
+ ): string {
245
+ if (children.length === 0) return '';
246
+ const vmRef = itemScope ?? 'vm';
247
+ const parts = children.map((child) => {
248
+ if (child.kind === 'text') {
249
+ const text = (child as IrTextNode).value;
250
+ return text ? `${indent(level)}{${JSON.stringify(text)}}` : '';
251
+ }
252
+ if (child.kind === 'binding') {
253
+ return `${indent(level)}{${bindingExpr(child, vmRef)}}`;
254
+ }
255
+ if (child.kind === 'map') return renderMap(child as IrMapNode, level, renderNested);
256
+ if (child.kind === 'fragment') {
257
+ return renderChildren(child.children, level, renderNested, itemScope);
258
+ }
259
+ if (child.kind === 'component') return renderNested(child, level, itemScope);
260
+ return '';
261
+ }).filter(Boolean);
262
+ return parts.join('\n');
263
+ }
264
+
265
+ function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer): string {
266
+ const item = node.item;
267
+ let body = renderNested(node.body as IrComponentNode, level + 1, item);
268
+ if (node.key) {
269
+ const keyAttr = ` key={${bindingExpr(node.key, item)}}`;
270
+ body = body.replace(/^( +)<(\w+)/, `$1<$2${keyAttr}`);
271
+ }
272
+ return `${indent(level)}{${bindingExpr(node.source)}.map((${item}) => (\n${body}\n${indent(level)}))}`;
273
+ }
274
+
275
+ function lowerVocabularyElement(
276
+ node: IrComponentNode,
277
+ entry: ElementMapEntry,
278
+ level: number,
279
+ itemScope: string | undefined,
280
+ renderNested: NodeRenderer,
281
+ ): string {
282
+ const props = node.props;
283
+ const childContent = renderChildren(node.children, level + 1, renderNested, itemScope);
284
+
285
+ if (entry.checkbox) {
286
+ return visibilityGuard(emitCheckbox(props, level), props, level);
287
+ }
288
+
289
+ if (entry.fieldWrap) {
290
+ const tag = entry.tag;
291
+ let inner: string;
292
+ if (entry.optionsProp && props[entry.optionsProp]) {
293
+ const open = emitOpenTag(entry, props, tag);
294
+ inner = `${indent(level + 1)}${open}>\n${emitSelectOptions(props, level + 2)}\n${indent(level + 1)}</${tag}>`;
295
+ } else {
296
+ inner = emitVoidInput(entry, props, level + 1);
297
+ }
298
+ return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
299
+ }
300
+
301
+ if (entry.tag === 'dynamic-heading') {
302
+ const levelLit = propLiteral(props, 'level');
303
+ if (typeof levelLit === 'number') {
304
+ const clamped = Math.min(6, Math.max(1, levelLit));
305
+ const tag = `h${clamped}`;
306
+ const open = emitOpenTag(entry, props, tag, clamped);
307
+ const body = childContent
308
+ ? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
309
+ : `${indent(level)}${open} />`;
310
+ return visibilityGuard(body, props, level);
311
+ }
312
+ const levelExpr = propExpr(props, 'level') ?? '2';
313
+ const inner = childContent
314
+ ? `${indent(level + 1)}{(() => { const __level = Math.min(6, Math.max(1, ${levelExpr} ?? 2)); const Tag = ('h' + __level) as keyof JSX.IntrinsicElements; return <Tag className={\`ui-heading ui-heading-\${__level}\`} style={commonStyle(${collectStyleProps(props)})}>{/* children below */}</Tag>; })()}`
315
+ : '';
316
+ return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(entry, props, 2)} ${styleAttr(entry, props)} />`, props, level);
317
+ }
318
+
319
+ const tag = entry.tag;
320
+ if (entry.void) {
321
+ return visibilityGuard(`${indent(level)}${emitOpenTag(entry, props, tag)} />`, props, level);
322
+ }
323
+
324
+ const open = emitOpenTag(entry, props, tag);
325
+ let body: string;
326
+ if (!childContent && entry.optionsProp && props[entry.optionsProp]) {
327
+ body = `${indent(level)}${open}>\n${emitSelectOptions(props, level + 1)}\n${indent(level)}</${tag}>`;
328
+ } else if (!childContent) {
329
+ body = `${indent(level)}${open} />`;
330
+ } else {
331
+ body = `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`;
332
+ }
333
+ return visibilityGuard(body, props, level);
334
+ }
335
+
336
+ function renderExtensionProps(props: Record<string, IrValue>, itemScope?: string): string {
337
+ const vmRef = itemScope ?? 'vm';
338
+ return Object.entries(props).map(([key, value]) => {
339
+ if (key === 'action' && typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'action') {
340
+ return `${key}={${renderAction(value as IrAction, true)}}`;
341
+ }
342
+ if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
343
+ return `${key}={${bindingExpr(value as IrBinding, vmRef)}}`;
344
+ }
345
+ if (key === 'children') return '';
346
+ return `${key}={${renderValue(value, 0, key === 'columns')}}`;
347
+ }).join(' ');
348
+ }
349
+
350
+ function renderExtensionElement(
351
+ node: IrComponentNode,
352
+ level: number,
353
+ itemScope: string | undefined,
354
+ renderNested: NodeRenderer,
355
+ ): string {
356
+ const props = renderExtensionProps(node.props, itemScope);
357
+ const propStr = props ? ` ${props}` : '';
358
+ const childContent = renderChildren(node.children, level + 1, renderNested, itemScope);
359
+ if (!childContent) {
360
+ return `${indent(level)}<${node.name}${propStr} />`;
361
+ }
362
+ return `${indent(level)}<${node.name}${propStr}>\n${childContent}\n${indent(level)}</${node.name}>`;
363
+ }
364
+
365
+ function isVocabulary(name: string, map: ReactElementMap, allowlistExtra: string[]): boolean {
366
+ return name in map.elements && !allowlistExtra.includes(name);
367
+ }
368
+
369
+ export function collectExtensionComponents(
370
+ node: IrNode,
371
+ map: ReactElementMap,
372
+ allowlistExtra: string[],
373
+ names: Set<string>,
374
+ ): void {
375
+ if (node.kind === 'component') {
376
+ if (!isVocabulary(node.name, map, allowlistExtra)) names.add(node.name);
377
+ for (const child of node.children) collectExtensionComponents(child, map, allowlistExtra, names);
378
+ return;
379
+ }
380
+ if (node.kind === 'map') collectExtensionComponents(node.body, map, allowlistExtra, names);
381
+ if (node.kind === 'fragment') {
382
+ for (const child of node.children) collectExtensionComponents(child, map, allowlistExtra, names);
383
+ }
384
+ }
385
+
386
+ export function createNodeRenderer(
387
+ map: ReactElementMap,
388
+ allowlistExtra: string[],
389
+ ): NodeRenderer {
390
+ const renderNested: NodeRenderer = (node, level, itemScope) => {
391
+ if (isVocabulary(node.name, map, allowlistExtra)) {
392
+ return lowerVocabularyElement(node, map.elements[node.name]!, level, itemScope, renderNested);
393
+ }
394
+ return renderExtensionElement(node, level, itemScope, renderNested);
395
+ };
396
+ return renderNested;
397
+ }
@@ -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
+ }