view-contracts 0.3.4 → 0.4.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.
- package/README.md +10 -3
- package/cli-contract.yaml +41 -1
- package/dist/preview-style-helpers.js +2 -0
- package/dist/preview-style-helpers.js.map +7 -0
- package/dist/preview.mjs +153 -0
- package/dist/preview.mjs.map +7 -0
- package/dist/view-contracts.bundle.mjs +402 -237
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +37 -1
- package/package.json +28 -1
- package/renderers/compose/renderRuntime.ts +40 -0
- package/renderers/react/defaults/theme.yaml +23 -0
- package/renderers/react/defaults/tokens.yaml +15 -0
- package/renderers/react/elementMap.ts +30 -6
- package/renderers/react/elements.yaml +17 -5
- package/renderers/react/lowering/foldStyle.ts +13 -1
- package/renderers/react/lowering/lowerToJsx.ts +58 -40
- package/renderers/react/paths.ts +1 -10
- package/renderers/react/preview/components.ts +23 -0
- package/renderers/react/preview/index.ts +3 -0
- package/renderers/react/preview/start.tsx +42 -0
- package/renderers/react/preview/style-helpers.ts +5 -0
- package/renderers/react/preview/vocabulary.tsx +75 -0
- package/renderers/react/renderComponents.ts +169 -14
- package/renderers/react/runtime/theme.css +9 -17
- package/renderers/react/style/foldLiterals.ts +11 -0
- package/renderers/swiftui/README.md +48 -10
- package/renderers/swiftui/elementMap.ts +45 -0
- package/renderers/swiftui/elements.yaml +82 -0
- package/renderers/swiftui/index.ts +5 -0
- package/renderers/swiftui/lowering/lowerToSwiftUI.ts +542 -0
- package/renderers/swiftui/lowering/modifiers.ts +142 -0
- package/renderers/swiftui/renderComponents.ts +79 -0
- package/renderers/swiftui/renderRuntime.ts +91 -0
- package/renderers/swiftui/renderTheme.ts +208 -0
- package/spec/ui-dsl.linter-rules.json +0 -7
- package/spec/ui-dsl.meta.json +5 -0
- package/spec/ui-dsl.schema.json +333 -259
- package/src/generated/schema/allowed-components.ts +0 -36
- package/src/generated/schema/dsl-enums.ts +0 -19
- package/src/generated/schema/dsl-properties.ts +0 -64
- package/src/generated/schema/index.ts +0 -4
- package/src/generated/schema/lowering-style-properties.ts +0 -35
- package/src/generated/schema/react-renderer-element-config.ts +0 -27
- package/src/generated/schema/schema-document.ts +0 -33
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.5
|
|
6
6
|
|
|
7
7
|
## Table of Contents
|
|
8
8
|
|
|
@@ -13,6 +13,7 @@ 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
|
+
- [preview](#view-contracts-preview)
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
@@ -200,3 +201,38 @@ view-contracts contracts check -c view-contracts.config.yaml
|
|
|
200
201
|
**Exit 1:** Lint failed.
|
|
201
202
|
|
|
202
203
|
---
|
|
204
|
+
|
|
205
|
+
### preview
|
|
206
|
+
|
|
207
|
+
Start DSL preview HTTP server.
|
|
208
|
+
|
|
209
|
+
Compiles views/*.view.tsx on demand and serves a React preview over HTTP. No Vite setup required in the consumer project.
|
|
210
|
+
|
|
211
|
+
**Usage:**
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
view-contracts preview
|
|
215
|
+
```
|
|
216
|
+
```
|
|
217
|
+
view-contracts preview -c view-contracts.config.yaml
|
|
218
|
+
```
|
|
219
|
+
```
|
|
220
|
+
view-contracts preview --port 3000 --screen AdminDashboard
|
|
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
|
+
| `--port` | -p | No | | HTTP listen port. |
|
|
229
|
+
| `--host` | | No | | HTTP listen host. |
|
|
230
|
+
| `--screen` | | No | | Screen name from view-bindings.yaml (default first react target). |
|
|
231
|
+
|
|
232
|
+
#### Exit Codes
|
|
233
|
+
|
|
234
|
+
**Exit 0:** Server stopped normally.
|
|
235
|
+
|
|
236
|
+
**Exit 1:** Startup or compile failed.
|
|
237
|
+
|
|
238
|
+
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "view-contracts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -9,9 +9,18 @@
|
|
|
9
9
|
"bin": {
|
|
10
10
|
"view-contracts": "dist/view-contracts.bundle.mjs"
|
|
11
11
|
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./dist/view-contracts.bundle.mjs",
|
|
14
|
+
"./preview": "./dist/preview.mjs",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
12
17
|
"files": [
|
|
13
18
|
"dist/view-contracts.bundle.mjs",
|
|
14
19
|
"dist/view-contracts.bundle.mjs.map",
|
|
20
|
+
"dist/preview.mjs",
|
|
21
|
+
"dist/preview.mjs.map",
|
|
22
|
+
"dist/preview-style-helpers.js",
|
|
23
|
+
"dist/preview-style-helpers.js.map",
|
|
15
24
|
"renderers/react/runtime",
|
|
16
25
|
"renderers/react/defaults",
|
|
17
26
|
"src/generated/schema",
|
|
@@ -36,6 +45,7 @@
|
|
|
36
45
|
"bundle": "node esbuild.bundle.mjs",
|
|
37
46
|
"bundle:min": "node esbuild.bundle.mjs --minify",
|
|
38
47
|
"build:views": "tsx src/cli.ts build -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
48
|
+
"render:swiftui-prototype": "tsx scripts/render-swiftui-prototype.ts",
|
|
39
49
|
"build:example": "vite build --config examples/admin-dashboard/.vite/vite.config.ts",
|
|
40
50
|
"compile": "tsx src/cli.ts compile -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
41
51
|
"render:react": "tsx src/cli.ts render react -c examples/admin-dashboard/view-contracts.config.yaml",
|
|
@@ -74,8 +84,25 @@
|
|
|
74
84
|
"engines": {
|
|
75
85
|
"node": ">=20.0.0"
|
|
76
86
|
},
|
|
87
|
+
"peerDependencies": {
|
|
88
|
+
"vite": "^5.0.0 || ^6.0.0",
|
|
89
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
90
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
91
|
+
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"vite": {
|
|
94
|
+
"optional": true
|
|
95
|
+
},
|
|
96
|
+
"react": {
|
|
97
|
+
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"react-dom": {
|
|
100
|
+
"optional": true
|
|
101
|
+
}
|
|
102
|
+
},
|
|
77
103
|
"dependencies": {
|
|
78
104
|
"commander": "^14.0.3",
|
|
105
|
+
"esbuild": "^0.28.0",
|
|
79
106
|
"handlebars": "^4.7.9",
|
|
80
107
|
"js-yaml": "^4.1.1",
|
|
81
108
|
"micro-contracts": "^0.17.10",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { ViewBindingsFile, ViewContractsConfig } from '../../src/config.js';
|
|
4
|
+
import { packageRoot } from '../../src/lib/packageRoot.js';
|
|
5
|
+
import { renderTemplate } from '../../src/renderer/template.js';
|
|
6
|
+
|
|
7
|
+
function composeTemplatesDir(): string {
|
|
8
|
+
return resolve(packageRoot(), 'renderers/compose/templates');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function renderComposeRuntime(
|
|
12
|
+
config: ViewContractsConfig,
|
|
13
|
+
bindings: ViewBindingsFile,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const composeDir = resolve(config.generated.reactDir, '../compose');
|
|
16
|
+
await mkdir(composeDir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
await writeFile(
|
|
19
|
+
resolve(composeDir, 'VcTheme.kt'),
|
|
20
|
+
await renderTemplate(composeTemplatesDir(), 'VcTheme.kt.hbs', {
|
|
21
|
+
packageName: 'generated.compose',
|
|
22
|
+
}),
|
|
23
|
+
'utf-8',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
for (const view of bindings.views) {
|
|
27
|
+
if (view.targets.includes('compose')) {
|
|
28
|
+
const out = resolve(composeDir, `${view.screenName}Screen.kt`);
|
|
29
|
+
await writeFile(
|
|
30
|
+
out,
|
|
31
|
+
await renderTemplate(composeTemplatesDir(), 'Screen.kt.hbs', {
|
|
32
|
+
screenName: view.screenName,
|
|
33
|
+
packageName: 'generated.compose',
|
|
34
|
+
}),
|
|
35
|
+
'utf-8',
|
|
36
|
+
);
|
|
37
|
+
console.log(` compose → ${out}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -6,6 +6,29 @@ defaults:
|
|
|
6
6
|
radius: "{ui.space.radius-md}"
|
|
7
7
|
padding: "{ui.space.button-padding}"
|
|
8
8
|
fontWeight: "{ui.layout.font-bold}"
|
|
9
|
+
TextInput:
|
|
10
|
+
background: "{ui.surface}"
|
|
11
|
+
foreground: "{ui.text}"
|
|
12
|
+
radius: "{ui.space.radius-md}"
|
|
13
|
+
padding: "{ui.space.input-padding}"
|
|
14
|
+
border: "{ui.layout.border-1}"
|
|
15
|
+
borderColor: "{ui.border}"
|
|
16
|
+
minHeight: "{ui.space.input-min-height}"
|
|
17
|
+
TextArea:
|
|
18
|
+
background: "{ui.surface}"
|
|
19
|
+
foreground: "{ui.text}"
|
|
20
|
+
radius: "{ui.space.radius-md}"
|
|
21
|
+
padding: "{ui.space.textarea-padding}"
|
|
22
|
+
border: "{ui.layout.border-1}"
|
|
23
|
+
borderColor: "{ui.border}"
|
|
24
|
+
Select:
|
|
25
|
+
background: "{ui.surface}"
|
|
26
|
+
foreground: "{ui.text}"
|
|
27
|
+
radius: "{ui.space.radius-md}"
|
|
28
|
+
padding: "{ui.space.select-padding}"
|
|
29
|
+
border: "{ui.layout.border-1}"
|
|
30
|
+
borderColor: "{ui.border}"
|
|
31
|
+
minHeight: "{ui.space.input-min-height}"
|
|
9
32
|
variants:
|
|
10
33
|
Button:
|
|
11
34
|
primary:
|
|
@@ -61,6 +61,9 @@ ui:
|
|
|
61
61
|
font-bold:
|
|
62
62
|
$type: layout
|
|
63
63
|
$value: "700"
|
|
64
|
+
border-1:
|
|
65
|
+
$type: layout
|
|
66
|
+
$value: "1px solid"
|
|
64
67
|
space:
|
|
65
68
|
radius-md:
|
|
66
69
|
$type: dimension
|
|
@@ -68,3 +71,15 @@ ui:
|
|
|
68
71
|
button-padding:
|
|
69
72
|
$type: dimension
|
|
70
73
|
$value: "10px 14px"
|
|
74
|
+
input-padding:
|
|
75
|
+
$type: dimension
|
|
76
|
+
$value: "9px 12px"
|
|
77
|
+
input-min-height:
|
|
78
|
+
$type: dimension
|
|
79
|
+
$value: "42px"
|
|
80
|
+
select-padding:
|
|
81
|
+
$type: dimension
|
|
82
|
+
$value: "9px 36px 9px 12px"
|
|
83
|
+
textarea-padding:
|
|
84
|
+
$type: dimension
|
|
85
|
+
$value: "10px 12px"
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
-
import { ALLOWED_COMPONENTS } from '../../src/
|
|
3
|
-
import
|
|
2
|
+
import { ALLOWED_COMPONENTS } from '../../src/schema/dsl-catalog.js';
|
|
3
|
+
import { ELEMENT_CATALOG } from '../../src/schema/dsl-catalog.js';
|
|
4
4
|
import { packageRoot } from '../../src/lib/packageRoot.js';
|
|
5
5
|
import {
|
|
6
6
|
loadYamlFile,
|
|
7
7
|
validateMapEntryFields,
|
|
8
8
|
validateVocabularyElementNames,
|
|
9
9
|
} from '../../src/renderer/elementMapValidation.js';
|
|
10
|
-
import { loadUiDslSchema } from '../../src/schema/loadSchema.js';
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
/** Shape of entries in renderers/react/elements.yaml (renderer-owned SSoT). */
|
|
12
|
+
export interface ReactRendererElementConfig {
|
|
13
|
+
tag: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
ariaLabel?: boolean;
|
|
16
|
+
ariaHidden?: boolean;
|
|
17
|
+
flex?: 'row' | 'column';
|
|
18
|
+
wrapProp?: string;
|
|
19
|
+
flexGrowProp?: string;
|
|
20
|
+
void?: boolean;
|
|
21
|
+
actionEvent?: 'onClick' | 'onSubmit';
|
|
22
|
+
fieldWrap?: boolean;
|
|
23
|
+
inputType?: string;
|
|
24
|
+
sizeClass?: string;
|
|
25
|
+
optionsProp?: string;
|
|
26
|
+
checkbox?: boolean;
|
|
27
|
+
fieldContainer?: boolean;
|
|
28
|
+
scrollContainer?: boolean;
|
|
29
|
+
defaultRole?: string;
|
|
30
|
+
tableCell?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReactElementMapFile {
|
|
34
|
+
elements: Record<string, ReactRendererElementConfig>;
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
export type ReactElementMap = ReactElementMapFile;
|
|
14
38
|
export type ElementMapEntry = ReactRendererElementConfig;
|
|
15
39
|
|
|
@@ -41,8 +65,8 @@ export function isLowerableVocabularyElement(
|
|
|
41
65
|
}
|
|
42
66
|
|
|
43
67
|
export async function validateElementMapCoversVocabulary(customPath?: string): Promise<void> {
|
|
44
|
-
const
|
|
45
|
-
const missing = Object.keys(
|
|
68
|
+
const map = await loadReactElementMap(customPath);
|
|
69
|
+
const missing = Object.keys(ELEMENT_CATALOG).filter((name) => !(name in map.elements));
|
|
46
70
|
if (missing.length > 0) {
|
|
47
71
|
throw new Error(
|
|
48
72
|
`${SOURCE_LABEL}: ${missing.length} DSL element(s) have no React HTML map yet: ${missing.join(', ')}`,
|
|
@@ -45,11 +45,6 @@ elements:
|
|
|
45
45
|
tag: hr
|
|
46
46
|
className: vc-divider
|
|
47
47
|
void: true
|
|
48
|
-
Heading:
|
|
49
|
-
tag: dynamic-heading
|
|
50
|
-
className: vc-heading
|
|
51
|
-
levelProp: level
|
|
52
|
-
defaultLevel: 2
|
|
53
48
|
Text:
|
|
54
49
|
tag: p
|
|
55
50
|
className: vc-text
|
|
@@ -140,3 +135,20 @@ elements:
|
|
|
140
135
|
tag: li
|
|
141
136
|
className: vc-list-item
|
|
142
137
|
defaultRole: listitem
|
|
138
|
+
Table:
|
|
139
|
+
tag: table
|
|
140
|
+
className: vc-table
|
|
141
|
+
TableHeader:
|
|
142
|
+
tag: thead
|
|
143
|
+
className: vc-table-header
|
|
144
|
+
TableColumn:
|
|
145
|
+
tag: th
|
|
146
|
+
className: vc-table-column
|
|
147
|
+
tableCell: true
|
|
148
|
+
TableRow:
|
|
149
|
+
tag: tr
|
|
150
|
+
className: vc-table-row
|
|
151
|
+
TableCell:
|
|
152
|
+
tag: td
|
|
153
|
+
className: vc-table-cell
|
|
154
|
+
tableCell: true
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Compile-time style folding for React lowering.
|
|
3
3
|
* Production generated JSX must not call runtime style helpers.
|
|
4
4
|
*/
|
|
5
|
-
import { LOWERING_STYLE_PROPERTIES } from '../../../src/
|
|
5
|
+
import { LOWERING_STYLE_PROPERTIES } from '../../../src/schema/dsl-catalog.js';
|
|
6
6
|
import type { IrLiteral, IrValue } from '../../../src/ir/types.js';
|
|
7
7
|
import type { ElementMapEntry } from '../elementMap.js';
|
|
8
8
|
import { foldLiteralsToCss, foldedStyleToJsx, type FoldedCss } from '../style/foldLiterals.js';
|
|
@@ -57,6 +57,18 @@ export function styleAttrForElement(entry: ElementMapEntry, props: Record<string
|
|
|
57
57
|
let folded = foldLiteralsToCss(literals);
|
|
58
58
|
folded = { ...folded, ...foldFlexContainer(entry) };
|
|
59
59
|
|
|
60
|
+
if (entry.tableCell) {
|
|
61
|
+
if ('alignItems' in folded) {
|
|
62
|
+
const ai = String(folded.alignItems);
|
|
63
|
+
folded.textAlign = ai === 'flex-end' ? 'right' : ai === 'center' ? 'center' : 'left';
|
|
64
|
+
delete folded.alignItems;
|
|
65
|
+
}
|
|
66
|
+
if ('width' in folded && !('flex' in folded)) {
|
|
67
|
+
folded.flexShrink = 0;
|
|
68
|
+
folded.flexGrow = 0;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
if (entry.scrollContainer) {
|
|
61
73
|
const scrollLit = propLiteral(props, 'scroll');
|
|
62
74
|
const axisLit = propLiteral(props, 'axis');
|
|
@@ -4,12 +4,14 @@ import type {
|
|
|
4
4
|
IrAction,
|
|
5
5
|
IrBinding,
|
|
6
6
|
IrComponentNode,
|
|
7
|
+
IrConcat,
|
|
7
8
|
IrFragmentNode,
|
|
8
9
|
IrLiteral,
|
|
9
10
|
IrMapNode,
|
|
10
11
|
IrNode,
|
|
11
12
|
IrTextNode,
|
|
12
13
|
IrValue,
|
|
14
|
+
IrViewRefNode,
|
|
13
15
|
} from '../../../src/ir/types.js';
|
|
14
16
|
|
|
15
17
|
function indent(level: number): string {
|
|
@@ -21,6 +23,14 @@ function bindingExpr(binding: IrBinding, vmRef = 'vm'): string {
|
|
|
21
23
|
return binding.path ? `${root}.${binding.path}` : root;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function concatSegments(concat: IrConcat): string {
|
|
27
|
+
return concat.parts.map(p => typeof p === 'string' ? p : `\${${bindingExpr(p)}}`).join('');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderConcat(concat: IrConcat): string {
|
|
31
|
+
return `\`${concatSegments(concat)}\``;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
function renderAction(action: IrAction, cast = false): string {
|
|
25
35
|
const suffix = cast ? ' as ActionDescriptor' : '';
|
|
26
36
|
if (!action.params || Object.keys(action.params).length === 0) {
|
|
@@ -61,6 +71,7 @@ export function renderValue(value: IrValue, level: number, castActions = false):
|
|
|
61
71
|
if (value.kind === 'binding') return bindingExpr(value as IrBinding);
|
|
62
72
|
if (value.kind === 'action') return renderAction(value as IrAction, castActions);
|
|
63
73
|
if (value.kind === 'literal') return renderLiteralValue((value as IrLiteral).value, level, castActions);
|
|
74
|
+
if (value.kind === 'concat') return renderConcat(value as IrConcat);
|
|
64
75
|
}
|
|
65
76
|
return 'undefined';
|
|
66
77
|
}
|
|
@@ -107,6 +118,10 @@ function variantClassSuffix(elementName: string, props: Record<string, IrValue>)
|
|
|
107
118
|
if (typeof variantLit === 'string') {
|
|
108
119
|
return ` vc-${componentToKebab(elementName)}--${variantLit}`;
|
|
109
120
|
}
|
|
121
|
+
const variantVal = props.variant;
|
|
122
|
+
if (variantVal && typeof variantVal === 'object' && 'kind' in variantVal && variantVal.kind === 'concat') {
|
|
123
|
+
return ` vc-${componentToKebab(elementName)}--${concatSegments(variantVal as IrConcat)}`;
|
|
124
|
+
}
|
|
110
125
|
const variant = propExpr(props, 'variant');
|
|
111
126
|
if (variant) return ` vc-${componentToKebab(elementName)}--\${${variant}}`;
|
|
112
127
|
return undefined;
|
|
@@ -116,19 +131,8 @@ function classNameAttr(
|
|
|
116
131
|
elementName: string,
|
|
117
132
|
entry: ElementMapEntry,
|
|
118
133
|
props: Record<string, IrValue>,
|
|
119
|
-
headingLevel?: number,
|
|
120
134
|
): string {
|
|
121
135
|
const base = entry.className ?? '';
|
|
122
|
-
if (entry.tag === 'dynamic-heading' && headingLevel !== undefined) {
|
|
123
|
-
const levelClass = `${base} vc-heading-${headingLevel}`;
|
|
124
|
-
const variantSuffix = variantClassSuffix(elementName, props);
|
|
125
|
-
if (!variantSuffix) return `className="${levelClass}"`;
|
|
126
|
-
const variantLit = propLiteral(props, 'variant');
|
|
127
|
-
if (typeof variantLit === 'string') {
|
|
128
|
-
return `className="${levelClass}${variantSuffix}"`;
|
|
129
|
-
}
|
|
130
|
-
return `className={\`${levelClass}${variantSuffix}\`}`;
|
|
131
|
-
}
|
|
132
136
|
const variantSuffix = variantClassSuffix(elementName, props);
|
|
133
137
|
if (variantSuffix) {
|
|
134
138
|
const variantLit = propLiteral(props, 'variant');
|
|
@@ -279,7 +283,6 @@ function emitOpenTag(
|
|
|
279
283
|
entry: ElementMapEntry,
|
|
280
284
|
props: Record<string, IrValue>,
|
|
281
285
|
tag: string,
|
|
282
|
-
headingLevel?: number,
|
|
283
286
|
): string {
|
|
284
287
|
const attrs: string[] = [];
|
|
285
288
|
if (entry.ariaHidden) attrs.push('aria-hidden');
|
|
@@ -298,7 +301,7 @@ function emitOpenTag(
|
|
|
298
301
|
if (entry.tag === 'button' && props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
|
|
299
302
|
if (entry.tag === 'a' && props.href) attrs.push(`href={${propExpr(props, 'href')}}`);
|
|
300
303
|
if (entry.tag === 'button') attrs.push(`type={${propExpr(props, 'type') ?? '"button"'}}`);
|
|
301
|
-
attrs.push(classNameAttr(elementName, entry, props
|
|
304
|
+
attrs.push(classNameAttr(elementName, entry, props));
|
|
302
305
|
pushStyleAttr(attrs, entry, props);
|
|
303
306
|
return `<${tag} ${attrs.join(' ')}`;
|
|
304
307
|
}
|
|
@@ -309,7 +312,7 @@ function visibilityGuard(inner: string, props: Record<string, IrValue>, level: n
|
|
|
309
312
|
return `${indent(level)}{${vis} !== false ? (\n${inner}\n${indent(level)}) : null}`;
|
|
310
313
|
}
|
|
311
314
|
|
|
312
|
-
type NodeRenderer = (node:
|
|
315
|
+
type NodeRenderer = (node: IrNode, level: number, itemScope?: string) => string;
|
|
313
316
|
|
|
314
317
|
function renderChildren(
|
|
315
318
|
children: IrNode[],
|
|
@@ -331,15 +334,14 @@ function renderChildren(
|
|
|
331
334
|
if (child.kind === 'fragment') {
|
|
332
335
|
return renderChildren(child.children, level, renderNested, itemScope);
|
|
333
336
|
}
|
|
334
|
-
|
|
335
|
-
return '';
|
|
337
|
+
return renderNested(child, level, itemScope);
|
|
336
338
|
}).filter(Boolean);
|
|
337
339
|
return parts.join('\n');
|
|
338
340
|
}
|
|
339
341
|
|
|
340
342
|
function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer): string {
|
|
341
343
|
const item = node.item;
|
|
342
|
-
let body = renderNested(node.body
|
|
344
|
+
let body = renderNested(node.body, level + 1, item);
|
|
343
345
|
if (node.key) {
|
|
344
346
|
const keyAttr = ` key={${bindingExpr(node.key, item)}}`;
|
|
345
347
|
body = body.replace(/^( +)<(\w+)/, `$1<$2${keyAttr}`);
|
|
@@ -402,27 +404,6 @@ function lowerVocabularyElement(
|
|
|
402
404
|
return visibilityGuard(emitFieldWrap(inner, props, level), props, level);
|
|
403
405
|
}
|
|
404
406
|
|
|
405
|
-
if (entry.tag === 'dynamic-heading') {
|
|
406
|
-
const levelLit = propLiteral(props, 'level');
|
|
407
|
-
if (typeof levelLit === 'number') {
|
|
408
|
-
const clamped = Math.min(6, Math.max(1, levelLit));
|
|
409
|
-
const tag = `h${clamped}`;
|
|
410
|
-
const open = emitOpenTag(node.name, entry, props, tag, clamped);
|
|
411
|
-
const body = childContent
|
|
412
|
-
? `${indent(level)}${open}>\n${childContent}\n${indent(level)}</${tag}>`
|
|
413
|
-
: `${indent(level)}${open} />`;
|
|
414
|
-
return visibilityGuard(body, props, level);
|
|
415
|
-
}
|
|
416
|
-
const levelExpr = propExpr(props, 'level') ?? '2';
|
|
417
|
-
const foldedStyle = foldedStyleToJsx(foldLiteralsToCss(collectLiteralStyleProps(props)));
|
|
418
|
-
const styleFragment = foldedStyle ? ` ${foldedStyle}` : '';
|
|
419
|
-
const inner = childContent
|
|
420
|
-
? `${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}\`}${styleFragment}>{/* children below */}</Tag>; })()}`
|
|
421
|
-
: '';
|
|
422
|
-
const fallbackStyle = styleAttrForElement(entry, props);
|
|
423
|
-
return visibilityGuard(inner || `${indent(level)}<h2 ${classNameAttr(node.name, entry, props, 2)}${fallbackStyle ? ` ${fallbackStyle}` : ''} />`, props, level);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
407
|
const tag = entry.tag;
|
|
427
408
|
if (entry.void) {
|
|
428
409
|
return visibilityGuard(emitVoidInput(node.name, entry, props, level), props, level);
|
|
@@ -440,6 +421,22 @@ function lowerVocabularyElement(
|
|
|
440
421
|
return visibilityGuard(body, props, level);
|
|
441
422
|
}
|
|
442
423
|
|
|
424
|
+
function renderViewRefProps(props: Record<string, IrValue>, itemScope?: string): string {
|
|
425
|
+
const vmRef = itemScope ?? 'vm';
|
|
426
|
+
return Object.entries(props).map(([key, value]) => {
|
|
427
|
+
if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
|
|
428
|
+
return `${key}={${bindingExpr(value as IrBinding, vmRef)}}`;
|
|
429
|
+
}
|
|
430
|
+
return `${key}={${renderValue(value, 0)}}`;
|
|
431
|
+
}).join(' ');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderViewRefElement(node: IrViewRefNode, level: number, itemScope?: string): string {
|
|
435
|
+
const props = renderViewRefProps(node.props, itemScope);
|
|
436
|
+
const propStr = props ? ` ${props}` : '';
|
|
437
|
+
return `${indent(level)}<${node.name}${propStr} />`;
|
|
438
|
+
}
|
|
439
|
+
|
|
443
440
|
function renderExtensionProps(props: Record<string, IrValue>, itemScope?: string): string {
|
|
444
441
|
const vmRef = itemScope ?? 'vm';
|
|
445
442
|
return Object.entries(props).map(([key, value]) => {
|
|
@@ -473,6 +470,21 @@ function isVocabulary(name: string, map: ReactElementMap, allowlistExtra: string
|
|
|
473
470
|
return name in map.elements && !allowlistExtra.includes(name);
|
|
474
471
|
}
|
|
475
472
|
|
|
473
|
+
export function collectViewRefComponents(node: IrNode, names: Set<string>): void {
|
|
474
|
+
if (node.kind === 'viewRef') {
|
|
475
|
+
names.add(node.name);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (node.kind === 'component') {
|
|
479
|
+
for (const child of node.children) collectViewRefComponents(child, names);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (node.kind === 'map') collectViewRefComponents(node.body, names);
|
|
483
|
+
if (node.kind === 'fragment') {
|
|
484
|
+
for (const child of node.children) collectViewRefComponents(child, names);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
476
488
|
export function collectExtensionComponents(
|
|
477
489
|
node: IrNode,
|
|
478
490
|
map: ReactElementMap,
|
|
@@ -493,12 +505,18 @@ export function collectExtensionComponents(
|
|
|
493
505
|
export function createNodeRenderer(
|
|
494
506
|
map: ReactElementMap,
|
|
495
507
|
allowlistExtra: string[],
|
|
496
|
-
):
|
|
508
|
+
): (node: IrComponentNode, level: number, itemScope?: string) => string {
|
|
497
509
|
const renderNested: NodeRenderer = (node, level, itemScope) => {
|
|
510
|
+
if (node.kind === 'viewRef') {
|
|
511
|
+
return renderViewRefElement(node, level, itemScope);
|
|
512
|
+
}
|
|
513
|
+
if (node.kind !== 'component') {
|
|
514
|
+
return '';
|
|
515
|
+
}
|
|
498
516
|
if (isVocabulary(node.name, map, allowlistExtra)) {
|
|
499
517
|
return lowerVocabularyElement(node, map.elements[node.name]!, level, itemScope, renderNested);
|
|
500
518
|
}
|
|
501
519
|
return renderExtensionElement(node, level, itemScope, renderNested);
|
|
502
520
|
};
|
|
503
|
-
return renderNested;
|
|
521
|
+
return (node, level, itemScope) => renderNested(node, level, itemScope);
|
|
504
522
|
}
|
package/renderers/react/paths.ts
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
export { reactTemplatesDir, reactRuntimeDir } from '../../src/lib/rendererPaths.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React preview composition root.
|
|
3
|
+
* Wires vocabulary stubs + renderer runtime + project extensions.
|
|
4
|
+
* This file belongs to the React renderer, NOT to core src/.
|
|
5
|
+
*/
|
|
6
|
+
import { vocabularyStubs } from './vocabulary.js';
|
|
7
|
+
|
|
8
|
+
export const {
|
|
9
|
+
App, Box, Button, Checkbox, Column, Divider, Drawer,
|
|
10
|
+
Field, Form, Heading, Icon, IconButton, Image, Link,
|
|
11
|
+
List, ListItem, Modal, Page, Popover, Progress, Radio,
|
|
12
|
+
Row, SafeArea, ScrollArea, Select, Spacer, Spinner,
|
|
13
|
+
Stack, Switch, Table, TableCell, TableColumn, TableHeader,
|
|
14
|
+
TableRow, Text, TextArea, TextInput, Tooltip,
|
|
15
|
+
} = vocabularyStubs;
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
isHidden,
|
|
19
|
+
toneClass,
|
|
20
|
+
registerPreviewActions,
|
|
21
|
+
clearPreviewActions,
|
|
22
|
+
dispatchPreviewAction,
|
|
23
|
+
} from '../runtime/index.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from 'react';
|
|
2
|
+
import type { ActionHandlers } from '@view-contracts/core';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import ReactDOM from 'react-dom/client';
|
|
5
|
+
import '@view-contracts/theme';
|
|
6
|
+
import { registerPreviewActions } from '../runtime/actionRuntime.js';
|
|
7
|
+
import { PreviewShell } from '../../../src/preview/shell.js';
|
|
8
|
+
|
|
9
|
+
export interface PreviewOptions<TVm> {
|
|
10
|
+
View: ComponentType<{ vm: TVm; actions?: ActionHandlers }>;
|
|
11
|
+
vm: TVm;
|
|
12
|
+
actions?: ActionHandlers;
|
|
13
|
+
onAction?: ActionHandlers;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Mount a view-contract screen for design preview (views/ + view-contracts packages only). */
|
|
17
|
+
export function startPreview<TVm>(options: PreviewOptions<TVm>): void {
|
|
18
|
+
const actions = options.actions ?? {};
|
|
19
|
+
if (options.onAction) {
|
|
20
|
+
registerPreviewActions({ ...actions, ...options.onAction });
|
|
21
|
+
} else {
|
|
22
|
+
registerPreviewActions(actions);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const View = options.View;
|
|
26
|
+
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
|
27
|
+
<React.StrictMode>
|
|
28
|
+
<PreviewShell>
|
|
29
|
+
<View vm={options.vm} actions={actions} />
|
|
30
|
+
</PreviewShell>
|
|
31
|
+
</React.StrictMode>,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PreviewApp<TVm>(props: PreviewOptions<TVm>): ReactElement {
|
|
36
|
+
const View = props.View;
|
|
37
|
+
return (
|
|
38
|
+
<PreviewShell>
|
|
39
|
+
<View vm={props.vm} actions={props.actions} />
|
|
40
|
+
</PreviewShell>
|
|
41
|
+
);
|
|
42
|
+
}
|