view-contracts 0.3.0 → 0.3.3
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.
- package/README.md +46 -34
- package/cli-contract.yaml +1 -38
- package/dist/view-contracts.bundle.mjs +205 -264
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +1 -38
- package/package.json +3 -4
- package/renderers/react/README.md +1 -0
- package/renderers/react/defaults/theme.yaml +5 -20
- package/renderers/react/element-config.meta.json +2 -0
- package/renderers/react/elementMap.ts +4 -2
- package/renderers/react/elements.yaml +56 -0
- package/renderers/react/lowering/lowerToJsx.ts +121 -2
- package/renderers/react/renderComponents.ts +23 -8
- package/renderers/react/renderRuntime.ts +14 -88
- package/renderers/react/routes.yaml +1 -1
- package/renderers/react/runtime/style.ts +21 -17
- package/renderers/react/runtime/theme.css +23 -41
- package/renderers/react/runtime/types.ts +20 -43
- package/renderers/react/syncGeneratedRuntime.ts +77 -0
- package/renderers/react/templates/components-index.ts.hbs +1 -1
- package/spec/ui-dsl-extensions.meta.json +62 -0
- package/src/generated/schema/allowed-components.ts +1 -1
- package/src/generated/schema/dsl-enums.ts +1 -1
- package/src/generated/schema/dsl-properties.ts +1 -1
- package/src/generated/schema/index.ts +1 -1
- package/src/generated/schema/lowering-style-properties.ts +1 -1
- package/src/generated/schema/react-renderer-element-config.ts +3 -1
- package/src/generated/schema/schema-document.ts +1 -1
package/docs/cli-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Contract-first UI design toolchain. Compiles restricted view-contract TSX to language-neutral View IR and renders platform targets (React, SwiftUI, Compose).
|
|
4
4
|
|
|
5
|
-
**Version:** 0.3.
|
|
5
|
+
**Version:** 0.3.3
|
|
6
6
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
@@ -13,7 +13,6 @@ Contract-first UI design toolchain. Compiles restricted view-contract TSX to lan
|
|
|
13
13
|
- [build](#view-contracts-build)
|
|
14
14
|
- [contracts.generate](#view-contracts-contracts-generate)
|
|
15
15
|
- [contracts.check](#view-contracts-contracts-check)
|
|
16
|
-
- [catalog.build](#view-contracts-catalog-build)
|
|
17
16
|
|
|
18
17
|
---
|
|
19
18
|
|
|
@@ -201,39 +200,3 @@ view-contracts contracts check -c view-contracts.config.yaml
|
|
|
201
200
|
**Exit 1:** Lint failed.
|
|
202
201
|
|
|
203
202
|
---
|
|
204
|
-
|
|
205
|
-
### catalog.build
|
|
206
|
-
|
|
207
|
-
Build static catalog HTML for design review.
|
|
208
|
-
|
|
209
|
-
Renders all registered views with mock ViewModels into a single HTML file for design review, screenshot baselines, and accessibility checks.
|
|
210
|
-
|
|
211
|
-
**Usage:**
|
|
212
|
-
|
|
213
|
-
```
|
|
214
|
-
view-contracts catalog build
|
|
215
|
-
```
|
|
216
|
-
```
|
|
217
|
-
view-contracts catalog build -c view-contracts.config.yaml
|
|
218
|
-
```
|
|
219
|
-
```
|
|
220
|
-
view-contracts catalog build -o dist/catalog.html
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
#### Options
|
|
224
|
-
|
|
225
|
-
| Option | Aliases | Required | Default | Description |
|
|
226
|
-
|---|---|---|---|---|
|
|
227
|
-
| `--config` | -c | No | | Path to view-contracts.config.yaml (discovered upward from cwd when omitted). |
|
|
228
|
-
| `--output` | -o | No | | Output HTML path (defaults to dist/catalog.html next to bindings). |
|
|
229
|
-
|
|
230
|
-
#### Exit Codes
|
|
231
|
-
|
|
232
|
-
**Exit 0:** Catalog built.
|
|
233
|
-
|
|
234
|
-
- **Generated files:**
|
|
235
|
-
- `dist/catalog.html` (text/html) *(optional)*
|
|
236
|
-
|
|
237
|
-
**Exit 1:** Build failed.
|
|
238
|
-
|
|
239
|
-
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "view-contracts",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"generate": "cli-contracts generate",
|
|
27
|
-
"dev": "npm run
|
|
27
|
+
"dev": "npm run dev:both --prefix examples/admin-dashboard",
|
|
28
28
|
"dev:preview": "npm run dev:preview --prefix examples/admin-dashboard",
|
|
29
|
-
"dev:app": "npm run dev:app --prefix examples/admin-dashboard",
|
|
29
|
+
"dev:app": "npm run build:views && npm run dev:app --prefix examples/admin-dashboard",
|
|
30
30
|
"dev:both": "npm run build:views && npm run dev:both --prefix examples/admin-dashboard",
|
|
31
31
|
"check:both": "npm run check:both --prefix examples/admin-dashboard",
|
|
32
32
|
"serve:built": "npm run serve:built --prefix examples/admin-dashboard",
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"bundle": "node esbuild.bundle.mjs",
|
|
37
37
|
"bundle:min": "node esbuild.bundle.mjs --minify",
|
|
38
38
|
"build:views": "tsx src/cli.ts build -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
39
|
-
"build:catalog": "tsx src/cli.ts catalog build -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
40
39
|
"build:example": "vite build --config examples/admin-dashboard/.vite/vite.config.ts",
|
|
41
40
|
"compile": "tsx src/cli.ts compile -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
42
41
|
"render:react": "tsx src/cli.ts render react -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
@@ -12,6 +12,7 @@ Per-target mapping for view-contracts UI DSL → React.
|
|
|
12
12
|
|
|
13
13
|
Vocabulary (elements/properties) is defined in `spec/ui-dsl.schema.json` (from `ui-dsl-*.csv`).
|
|
14
14
|
Preview vocabulary stubs are generated to `src/generated/preview/vocabulary.tsx`.
|
|
15
|
+
React `elements.yaml` now covers the full UI DSL vocabulary (33/33 elements).
|
|
15
16
|
Sample-only preview helpers (Sidebar, Card, etc.) are **not** part of the vocabulary — they live in project `src/components/extensions.tsx`.
|
|
16
17
|
|
|
17
18
|
Generic renderer utilities (template engine, YAML element-map validation) live under `src/renderer/`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Default theme variants —
|
|
1
|
+
# Default theme variants — generic vocabulary only (no sample-specific layouts).
|
|
2
2
|
variants:
|
|
3
3
|
Button:
|
|
4
4
|
primary:
|
|
@@ -16,26 +16,11 @@ variants:
|
|
|
16
16
|
Text:
|
|
17
17
|
muted:
|
|
18
18
|
foreground: "{ui.text-muted}"
|
|
19
|
-
Card:
|
|
20
|
-
default:
|
|
21
|
-
background: "{ui.surface}"
|
|
22
|
-
Badge:
|
|
23
|
-
default:
|
|
24
|
-
background: "{ui.surface-muted}"
|
|
25
19
|
primary:
|
|
26
|
-
|
|
20
|
+
foreground: "{ui.primary}"
|
|
27
21
|
success:
|
|
28
|
-
|
|
22
|
+
foreground: "{ui.success}"
|
|
29
23
|
warning:
|
|
30
|
-
|
|
24
|
+
foreground: "{ui.warning}"
|
|
31
25
|
danger:
|
|
32
|
-
|
|
33
|
-
Alert:
|
|
34
|
-
default:
|
|
35
|
-
background: "{ui.surface}"
|
|
36
|
-
success:
|
|
37
|
-
background: "{ui.success-weak}"
|
|
38
|
-
warning:
|
|
39
|
-
background: "{ui.warning-weak}"
|
|
40
|
-
danger:
|
|
41
|
-
background: "{ui.danger-weak}"
|
|
26
|
+
foreground: "{ui.danger}"
|
|
@@ -40,16 +40,18 @@ export function isLowerableVocabularyElement(
|
|
|
40
40
|
return ALLOWED_COMPONENTS.has(name) && !allowlistExtra.includes(name) && name in map.elements;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export async function
|
|
43
|
+
export async function validateElementMapCoversVocabulary(customPath?: string): Promise<void> {
|
|
44
44
|
const [schema, map] = await Promise.all([loadUiDslSchema(), loadReactElementMap(customPath)]);
|
|
45
45
|
const missing = Object.keys(schema.elements).filter((name) => !(name in map.elements));
|
|
46
46
|
if (missing.length > 0) {
|
|
47
|
-
|
|
47
|
+
throw new Error(
|
|
48
48
|
`${SOURCE_LABEL}: ${missing.length} DSL element(s) have no React HTML map yet: ${missing.join(', ')}`,
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
export const assertElementMapCoversImplementedVocabulary = validateElementMapCoversVocabulary;
|
|
54
|
+
|
|
53
55
|
export function isVocabularyElement(name: string, map: ReactElementMapFile, allowlistExtra: string[] = []): boolean {
|
|
54
56
|
return name in map.elements && !allowlistExtra.includes(name);
|
|
55
57
|
}
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
# (allowlistExtra) and stay as React component imports — not listed here.
|
|
4
4
|
|
|
5
5
|
elements:
|
|
6
|
+
App:
|
|
7
|
+
tag: div
|
|
8
|
+
className: vc-app
|
|
6
9
|
Page:
|
|
7
10
|
tag: main
|
|
8
11
|
className: vc-page
|
|
9
12
|
ariaLabel: true
|
|
13
|
+
SafeArea:
|
|
14
|
+
tag: div
|
|
15
|
+
className: vc-safe-area
|
|
10
16
|
Box:
|
|
11
17
|
tag: div
|
|
12
18
|
className: vc-box
|
|
@@ -26,6 +32,10 @@ elements:
|
|
|
26
32
|
tag: div
|
|
27
33
|
className: vc-stack
|
|
28
34
|
ariaLabel: true
|
|
35
|
+
ScrollArea:
|
|
36
|
+
tag: div
|
|
37
|
+
className: vc-scroll-area
|
|
38
|
+
scrollContainer: true
|
|
29
39
|
Spacer:
|
|
30
40
|
tag: div
|
|
31
41
|
className: vc-spacer
|
|
@@ -43,6 +53,14 @@ elements:
|
|
|
43
53
|
Text:
|
|
44
54
|
tag: p
|
|
45
55
|
className: vc-text
|
|
56
|
+
Image:
|
|
57
|
+
tag: img
|
|
58
|
+
className: vc-image
|
|
59
|
+
void: true
|
|
60
|
+
Icon:
|
|
61
|
+
tag: span
|
|
62
|
+
className: vc-icon
|
|
63
|
+
ariaHidden: true
|
|
46
64
|
Link:
|
|
47
65
|
tag: a
|
|
48
66
|
className: vc-link
|
|
@@ -51,11 +69,20 @@ elements:
|
|
|
51
69
|
tag: button
|
|
52
70
|
className: vc-button
|
|
53
71
|
actionEvent: onClick
|
|
72
|
+
IconButton:
|
|
73
|
+
tag: button
|
|
74
|
+
className: vc-icon-button
|
|
75
|
+
actionEvent: onClick
|
|
76
|
+
ariaLabel: true
|
|
54
77
|
Form:
|
|
55
78
|
tag: form
|
|
56
79
|
className: vc-form
|
|
57
80
|
actionEvent: onSubmit
|
|
58
81
|
ariaLabel: true
|
|
82
|
+
Field:
|
|
83
|
+
tag: div
|
|
84
|
+
className: vc-field-container
|
|
85
|
+
fieldContainer: true
|
|
59
86
|
TextInput:
|
|
60
87
|
tag: input
|
|
61
88
|
className: vc-text-input
|
|
@@ -76,6 +103,35 @@ elements:
|
|
|
76
103
|
tag: label
|
|
77
104
|
className: vc-checkbox
|
|
78
105
|
checkbox: true
|
|
106
|
+
Radio:
|
|
107
|
+
tag: label
|
|
108
|
+
className: vc-radio
|
|
109
|
+
Switch:
|
|
110
|
+
tag: label
|
|
111
|
+
className: vc-switch
|
|
112
|
+
Modal:
|
|
113
|
+
tag: dialog
|
|
114
|
+
className: vc-modal
|
|
115
|
+
defaultRole: dialog
|
|
116
|
+
Drawer:
|
|
117
|
+
tag: aside
|
|
118
|
+
className: vc-drawer
|
|
119
|
+
defaultRole: dialog
|
|
120
|
+
Popover:
|
|
121
|
+
tag: div
|
|
122
|
+
className: vc-popover
|
|
123
|
+
defaultRole: dialog
|
|
124
|
+
Tooltip:
|
|
125
|
+
tag: span
|
|
126
|
+
className: vc-tooltip
|
|
127
|
+
defaultRole: tooltip
|
|
128
|
+
Progress:
|
|
129
|
+
tag: progress
|
|
130
|
+
className: vc-progress
|
|
131
|
+
void: true
|
|
132
|
+
Spinner:
|
|
133
|
+
tag: div
|
|
134
|
+
className: vc-spinner
|
|
79
135
|
List:
|
|
80
136
|
tag: ul
|
|
81
137
|
className: vc-list
|
|
@@ -96,9 +96,40 @@ function extraFlexStyle(entry: ElementMapEntry, props: Record<string, IrValue>):
|
|
|
96
96
|
return `, ${parts.join(', ')}`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function scrollOverflowLiteral(mode: string): { overflowX: string; overflowY: string } {
|
|
100
|
+
if (mode === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
|
|
101
|
+
if (mode === 'both') return { overflowX: 'auto', overflowY: 'auto' };
|
|
102
|
+
if (mode === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
|
|
103
|
+
return { overflowX: 'hidden', overflowY: 'auto' };
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
function styleAttr(entry: ElementMapEntry, props: Record<string, IrValue>): string {
|
|
100
107
|
const base = collectStyleProps(props);
|
|
101
108
|
const flex = extraFlexStyle(entry, props);
|
|
109
|
+
if (entry.scrollContainer) {
|
|
110
|
+
const scrollLit = propLiteral(props, 'scroll');
|
|
111
|
+
const axisLit = propLiteral(props, 'axis');
|
|
112
|
+
const literalMode = typeof scrollLit === 'string'
|
|
113
|
+
? scrollLit
|
|
114
|
+
: typeof axisLit === 'string'
|
|
115
|
+
? (axisLit === 'horizontal' ? 'horizontal' : 'vertical')
|
|
116
|
+
: undefined;
|
|
117
|
+
if (literalMode) {
|
|
118
|
+
const { overflowX, overflowY } = scrollOverflowLiteral(literalMode);
|
|
119
|
+
return `style={{ ...commonStyle(${base})${flex}, overflowX: '${overflowX}', overflowY: '${overflowY}' }}`;
|
|
120
|
+
}
|
|
121
|
+
const scroll = propExpr(props, 'scroll');
|
|
122
|
+
const axis = propExpr(props, 'axis');
|
|
123
|
+
const modeExpr = scroll
|
|
124
|
+
?? (axis ? `(${axis} === 'horizontal' ? 'horizontal' : 'vertical')` : `'vertical'`);
|
|
125
|
+
return `style={{ ...commonStyle(${base})${flex}, ...(() => {
|
|
126
|
+
const __scroll = ${modeExpr};
|
|
127
|
+
if (__scroll === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' };
|
|
128
|
+
if (__scroll === 'both') return { overflowX: 'auto', overflowY: 'auto' };
|
|
129
|
+
if (__scroll === 'none') return { overflowX: 'hidden', overflowY: 'hidden' };
|
|
130
|
+
return { overflowX: 'hidden', overflowY: 'auto' };
|
|
131
|
+
})() }}`;
|
|
132
|
+
}
|
|
102
133
|
if (entry.flexGrowProp && props[entry.flexGrowProp]) {
|
|
103
134
|
const grow = propExpr(props, entry.flexGrowProp);
|
|
104
135
|
return `style={{ ...commonStyle(${base})${flex}, flexGrow: ${grow} ?? 1 }}`;
|
|
@@ -207,6 +238,53 @@ ${indent(level + 1)}<span>{${label}}</span>
|
|
|
207
238
|
${indent(level)}</label>`;
|
|
208
239
|
}
|
|
209
240
|
|
|
241
|
+
function emitRadio(props: Record<string, IrValue>, level: number): string {
|
|
242
|
+
const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
|
|
243
|
+
const value = propExpr(props, 'value') ?? '""';
|
|
244
|
+
const label = propExpr(props, 'label') ?? '""';
|
|
245
|
+
const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
|
|
246
|
+
const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
|
|
247
|
+
return `${indent(level)}<label className="vc-radio">
|
|
248
|
+
${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checked} ${disabled} />
|
|
249
|
+
${indent(level + 1)}<span>{${label}}</span>
|
|
250
|
+
${indent(level)}</label>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function emitSwitch(props: Record<string, IrValue>, level: number): string {
|
|
254
|
+
const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
|
|
255
|
+
const label = propExpr(props, 'label') ?? '""';
|
|
256
|
+
const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
|
|
257
|
+
const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
|
|
258
|
+
return `${indent(level)}<label className="vc-switch">
|
|
259
|
+
${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checked} ${disabled} />
|
|
260
|
+
${indent(level + 1)}<span>{${label}}</span>
|
|
261
|
+
${indent(level)}</label>`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function emitIcon(node: IrComponentNode, entry: ElementMapEntry, level: number): string {
|
|
265
|
+
const props = node.props;
|
|
266
|
+
const hasAlt = props.alt !== undefined;
|
|
267
|
+
if (hasAlt) {
|
|
268
|
+
const attrs: string[] = [
|
|
269
|
+
classNameAttr(node.name, entry, props),
|
|
270
|
+
styleAttr(entry, props),
|
|
271
|
+
`src={${propExpr(props, 'name') ?? '""'}}`,
|
|
272
|
+
`alt={${propExpr(props, 'alt') ?? '""'}}`,
|
|
273
|
+
];
|
|
274
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
275
|
+
return `${indent(level)}<img ${attrs.join(' ')} />`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const attrs: string[] = [
|
|
279
|
+
classNameAttr(node.name, entry, props),
|
|
280
|
+
styleAttr(entry, props),
|
|
281
|
+
`aria-hidden`,
|
|
282
|
+
];
|
|
283
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
284
|
+
if (props.name) attrs.push(`data-icon={${propExpr(props, 'name')}}`);
|
|
285
|
+
return `${indent(level)}<span ${attrs.join(' ')} />`;
|
|
286
|
+
}
|
|
287
|
+
|
|
210
288
|
function emitSelectOptions(props: Record<string, IrValue>, level: number): string {
|
|
211
289
|
const options = props.options;
|
|
212
290
|
if (!options || typeof options !== 'object' || !('kind' in options)) return '';
|
|
@@ -225,13 +303,25 @@ function emitVoidInput(
|
|
|
225
303
|
level: number,
|
|
226
304
|
): string {
|
|
227
305
|
const attrs: string[] = [classNameAttr(elementName, entry, props), styleAttr(entry, props)];
|
|
306
|
+
if (entry.ariaHidden) attrs.push('aria-hidden');
|
|
307
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
308
|
+
if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
|
|
309
|
+
else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
|
|
310
|
+
if (entry.ariaLabel) {
|
|
311
|
+
if (props.ariaLabel) attrs.push(`aria-label={${propExpr(props, 'ariaLabel')}}`);
|
|
312
|
+
else if (props.label) attrs.push(`aria-label={${propExpr(props, 'label')}}`);
|
|
313
|
+
else if (props.title) attrs.push(`aria-label={${propExpr(props, 'title')}}`);
|
|
314
|
+
}
|
|
228
315
|
if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
|
|
229
316
|
if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
|
|
230
317
|
if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
|
|
231
318
|
if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
|
|
232
319
|
if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
|
|
233
320
|
if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
|
|
234
|
-
if (props.
|
|
321
|
+
if (entry.tag === 'img' && props.src) attrs.push(`src={${propExpr(props, 'src')}}`);
|
|
322
|
+
if (entry.tag === 'img' && props.alt) attrs.push(`alt={${propExpr(props, 'alt')}}`);
|
|
323
|
+
if (entry.tag === 'progress' && props.value) attrs.push(`value={${propExpr(props, 'value')}}`);
|
|
324
|
+
if (props.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}}`);
|
|
235
325
|
return `${indent(level)}<${entry.tag} ${attrs.join(' ')} />`;
|
|
236
326
|
}
|
|
237
327
|
|
|
@@ -322,6 +412,35 @@ function lowerVocabularyElement(
|
|
|
322
412
|
return visibilityGuard(emitCheckbox(props, level), props, level);
|
|
323
413
|
}
|
|
324
414
|
|
|
415
|
+
if (node.name === 'Radio') {
|
|
416
|
+
return visibilityGuard(emitRadio(props, level), props, level);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (node.name === 'Switch') {
|
|
420
|
+
return visibilityGuard(emitSwitch(props, level), props, level);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (node.name === 'Icon') {
|
|
424
|
+
return visibilityGuard(emitIcon(node, entry, level), props, level);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (node.name === 'Spinner') {
|
|
428
|
+
const attrs = [
|
|
429
|
+
classNameAttr(node.name, entry, props),
|
|
430
|
+
styleAttr(entry, props),
|
|
431
|
+
'aria-busy="true"',
|
|
432
|
+
];
|
|
433
|
+
if (props.id) attrs.push(`id={${propExpr(props, 'id')}}`);
|
|
434
|
+
if (props.role) attrs.push(`role={${propExpr(props, 'role')}}`);
|
|
435
|
+
else if (entry.defaultRole) attrs.push(`role="${entry.defaultRole}"`);
|
|
436
|
+
return visibilityGuard(`${indent(level)}<div ${attrs.join(' ')} />`, props, level);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (entry.fieldContainer) {
|
|
440
|
+
const inner = childContent || `${indent(level + 1)}<div />`;
|
|
441
|
+
return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
|
|
442
|
+
}
|
|
443
|
+
|
|
325
444
|
if (entry.fieldWrap) {
|
|
326
445
|
const tag = entry.tag;
|
|
327
446
|
let inner: string;
|
|
@@ -354,7 +473,7 @@ function lowerVocabularyElement(
|
|
|
354
473
|
|
|
355
474
|
const tag = entry.tag;
|
|
356
475
|
if (entry.void) {
|
|
357
|
-
return visibilityGuard(
|
|
476
|
+
return visibilityGuard(emitVoidInput(node.name, entry, props, level), props, level);
|
|
358
477
|
}
|
|
359
478
|
|
|
360
479
|
const open = emitOpenTag(node.name, entry, props, tag);
|
|
@@ -2,10 +2,18 @@ import type { ViewIrDocument } from '../../src/ir/types.js';
|
|
|
2
2
|
import { loadReactElementMap } from './elementMap.js';
|
|
3
3
|
import { collectExtensionComponents, createNodeRenderer } from './lowering/lowerToJsx.js';
|
|
4
4
|
|
|
5
|
+
export interface PreviewImportSpecifiers {
|
|
6
|
+
dispatch: string;
|
|
7
|
+
styleHelpers: string;
|
|
8
|
+
contracts: string;
|
|
9
|
+
extensions: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export interface RenderReactOptions {
|
|
6
13
|
componentsImportFrom: string;
|
|
7
14
|
allowlistExtra?: string[];
|
|
8
15
|
elementMapPath?: string;
|
|
16
|
+
previewImports?: PreviewImportSpecifiers;
|
|
9
17
|
}
|
|
10
18
|
|
|
11
19
|
export async function renderReactComponent(
|
|
@@ -28,20 +36,27 @@ export async function renderReactComponent(
|
|
|
28
36
|
collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
|
|
29
37
|
const extensionImports = [...extensions].sort();
|
|
30
38
|
|
|
39
|
+
const preview = options.previewImports;
|
|
40
|
+
const dispatchModule = preview?.dispatch ?? '../runtime/dispatch.js';
|
|
41
|
+
const styleModule = preview?.styleHelpers ?? '../components/style-helpers.js';
|
|
42
|
+
const contractsModule = preview?.contracts ?? '../contracts';
|
|
43
|
+
const componentsModule = preview?.extensions ?? options.componentsImportFrom;
|
|
44
|
+
|
|
31
45
|
const imports: string[] = [
|
|
32
46
|
`import type { ReactElement } from 'react';`,
|
|
33
|
-
`import { dispatchAction } from '
|
|
34
|
-
`import { commonStyle, mapAlign, mapJustify } from '
|
|
35
|
-
`import type { ${doc.viewModel}, ActionDescriptor } from '
|
|
47
|
+
`import { dispatchAction } from '${dispatchModule}';`,
|
|
48
|
+
`import { commonStyle, mapAlign, mapJustify } from '${styleModule}';`,
|
|
49
|
+
`import type { ${doc.viewModel}, ActionDescriptor } from '${contractsModule}';`,
|
|
36
50
|
];
|
|
37
51
|
if (extensionImports.length > 0) {
|
|
38
|
-
imports.push(`import { ${extensionImports.join(', ')} } from '${
|
|
52
|
+
imports.push(`import { ${extensionImports.join(', ')} } from '${componentsModule}';`);
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
* DO NOT EDIT — regenerate with: view-contracts render react
|
|
44
|
-
|
|
55
|
+
const headerComment = preview
|
|
56
|
+
? `/**\n * Preview module — compiled live from ${doc.source}\n * Same lowering as generated/; not written to disk.\n */`
|
|
57
|
+
: `/**\n * Auto-generated React component from ${doc.source}\n * DO NOT EDIT — regenerate with: view-contracts render react\n */`;
|
|
58
|
+
|
|
59
|
+
return `${headerComment}
|
|
45
60
|
${imports.join('\n')}
|
|
46
61
|
|
|
47
62
|
export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
|
|
@@ -1,25 +1,12 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, stat,
|
|
1
|
+
import { readFile, writeFile, mkdir, stat, unlink } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import type { ViewContractsConfig } from '../../src/config.js';
|
|
4
|
-
import { projectRoot } from '../../src/config.js';
|
|
5
|
-
import {
|
|
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';
|
|
4
|
+
import { projectRoot, requireDesignPaths } from '../../src/config.js';
|
|
5
|
+
import { buildThemeCss } from '../../src/design/buildThemeCss.js';
|
|
6
|
+
import { loadValidateDesign } from '../../src/design/index.js';
|
|
15
7
|
import { renderTemplate } from '../../src/renderer/template.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
const toolchainRoot = packageRoot();
|
|
19
|
-
|
|
20
|
-
function toolchainSchemaDir(): string {
|
|
21
|
-
return resolve(toolchainRoot, 'src/generated/schema');
|
|
22
|
-
}
|
|
8
|
+
import { reactTemplatesDir } from './paths.js';
|
|
9
|
+
import { rewriteExtensionImportsForGenerated, syncGeneratedRuntime } from './syncGeneratedRuntime.js';
|
|
23
10
|
|
|
24
11
|
async function pathExists(target: string): Promise<boolean> {
|
|
25
12
|
try {
|
|
@@ -30,53 +17,18 @@ async function pathExists(target: string): Promise<boolean> {
|
|
|
30
17
|
}
|
|
31
18
|
}
|
|
32
19
|
|
|
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
20
|
async function writeThemeCss(
|
|
61
|
-
|
|
21
|
+
configPath: string,
|
|
62
22
|
themeOut: string,
|
|
63
23
|
config: ViewContractsConfig,
|
|
64
24
|
templatesDir?: string,
|
|
65
25
|
): Promise<void> {
|
|
66
|
-
const
|
|
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
|
-
}
|
|
26
|
+
const css = await buildThemeCss(config, configPath);
|
|
77
27
|
const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
|
|
78
28
|
await writeFile(themeOut, content, 'utf-8');
|
|
79
29
|
|
|
30
|
+
const { tokensPath, themePath } = requireDesignPaths(config);
|
|
31
|
+
const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
|
|
80
32
|
const themeDir = resolve(config.generated.reactDir, '../theme');
|
|
81
33
|
await mkdir(themeDir, { recursive: true });
|
|
82
34
|
await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
|
|
@@ -99,32 +51,16 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
|
|
|
99
51
|
? resolve(root, config.rendererTemplatesDir)
|
|
100
52
|
: undefined;
|
|
101
53
|
const templates = reactTemplatesDir(templatesDir);
|
|
102
|
-
const componentsDir = resolve(config.generated.reactDir, '../components');
|
|
103
|
-
const runtimeDir = resolve(config.generated.reactDir, '../runtime');
|
|
104
54
|
const srcComponentsDir = resolve(root, 'src/components');
|
|
105
55
|
|
|
106
|
-
|
|
107
|
-
await mkdir(runtimeDir, { recursive: true });
|
|
108
|
-
|
|
56
|
+
const { componentsDir, runtimeDir } = await syncGeneratedRuntime(configPath, config, templatesDir);
|
|
109
57
|
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
58
|
|
|
123
59
|
for (const name of ['extensions.tsx', 'sample-types.ts'] as const) {
|
|
124
60
|
const src = resolve(srcComponentsDir, name);
|
|
125
61
|
let body = await readFile(src, 'utf-8');
|
|
126
|
-
if (name === '
|
|
127
|
-
body = body
|
|
62
|
+
if (name === 'extensions.tsx') {
|
|
63
|
+
body = rewriteExtensionImportsForGenerated(body);
|
|
128
64
|
}
|
|
129
65
|
await writeFile(resolve(componentsDir, name), body, 'utf-8');
|
|
130
66
|
}
|
|
@@ -140,17 +76,7 @@ export async function renderReactRuntime(configPath: string, config: ViewContrac
|
|
|
140
76
|
'utf-8',
|
|
141
77
|
);
|
|
142
78
|
|
|
143
|
-
await writeThemeCss(
|
|
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
|
-
);
|
|
79
|
+
await writeThemeCss(configPath, resolve(config.generated.reactDir, '../theme.css'), config, templatesDir);
|
|
154
80
|
|
|
155
81
|
console.log(` components → ${componentsDir}`);
|
|
156
82
|
console.log(` theme → ${resolve(config.generated.reactDir, '../theme.css')}`);
|