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.
@@ -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.0
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.0",
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 build:views && npm run dev:both --prefix examples/admin-dashboard",
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 — component appearance via .vc-{element}--{variant} rules.
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
- background: "{ui.primary-weak}"
20
+ foreground: "{ui.primary}"
27
21
  success:
28
- background: "{ui.success-weak}"
22
+ foreground: "{ui.success}"
29
23
  warning:
30
- background: "{ui.warning-weak}"
24
+ foreground: "{ui.warning}"
31
25
  danger:
32
- background: "{ui.danger-weak}"
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}"
@@ -17,6 +17,8 @@
17
17
  "sizeClass": { "type": "string" },
18
18
  "optionsProp": { "type": "string" },
19
19
  "checkbox": { "type": "boolean" },
20
+ "fieldContainer": { "type": "boolean" },
21
+ "scrollContainer": { "type": "boolean" },
20
22
  "defaultRole": { "type": "string" }
21
23
  }
22
24
  }
@@ -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 assertElementMapCoversImplementedVocabulary(customPath?: string): Promise<void> {
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
- console.warn(
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.rows) attrs.push(`rows={${propExpr(props, 'rows') ?? '4'}`);
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(`${indent(level)}${emitOpenTag(node.name, entry, props, tag)} />`, props, level);
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 '../runtime/dispatch.js';`,
34
- `import { commonStyle, mapAlign, mapJustify } from '../components/style-helpers.js';`,
35
- `import type { ${doc.viewModel}, ActionDescriptor } from '../contracts';`,
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 '${options.componentsImportFrom}';`);
52
+ imports.push(`import { ${extensionImports.join(', ')} } from '${componentsModule}';`);
39
53
  }
40
54
 
41
- return `/**
42
- * Auto-generated React component from ${doc.source}
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, cp, unlink } from 'node:fs/promises';
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 { 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';
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 { reactRuntimeDir, reactTemplatesDir } from './paths.js';
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
- root: string,
21
+ configPath: string,
62
22
  themeOut: string,
63
23
  config: ViewContractsConfig,
64
24
  templatesDir?: string,
65
25
  ): 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
- }
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
- await mkdir(componentsDir, { recursive: true });
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 === 'sample-types.ts') {
127
- body = body.replace("from './core.js'", "from './types.js'");
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(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
- );
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')}`);
@@ -10,7 +10,7 @@ react:
10
10
  screen:
11
11
  pattern: '{{projectRoot}}/generated/react/{{screenName}}.generated.tsx'
12
12
  preview:
13
- entry: views/preview.entry.tsx
13
+ entry: .vite/preview-main.tsx
14
14
  generatedEntry: src/main.tsx
15
15
 
16
16
  swiftui: