view-contracts 0.3.4 → 0.3.5

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.3
5
+ **Version:** 0.3.5
6
6
 
7
7
  ## Table of Contents
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -10,6 +10,7 @@ import type {
10
10
  IrNode,
11
11
  IrTextNode,
12
12
  IrValue,
13
+ IrViewRefNode,
13
14
  } from '../../../src/ir/types.js';
14
15
 
15
16
  function indent(level: number): string {
@@ -309,7 +310,7 @@ function visibilityGuard(inner: string, props: Record<string, IrValue>, level: n
309
310
  return `${indent(level)}{${vis} !== false ? (\n${inner}\n${indent(level)}) : null}`;
310
311
  }
311
312
 
312
- type NodeRenderer = (node: IrComponentNode, level: number, itemScope?: string) => string;
313
+ type NodeRenderer = (node: IrNode, level: number, itemScope?: string) => string;
313
314
 
314
315
  function renderChildren(
315
316
  children: IrNode[],
@@ -331,15 +332,14 @@ function renderChildren(
331
332
  if (child.kind === 'fragment') {
332
333
  return renderChildren(child.children, level, renderNested, itemScope);
333
334
  }
334
- if (child.kind === 'component') return renderNested(child, level, itemScope);
335
- return '';
335
+ return renderNested(child, level, itemScope);
336
336
  }).filter(Boolean);
337
337
  return parts.join('\n');
338
338
  }
339
339
 
340
340
  function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer): string {
341
341
  const item = node.item;
342
- let body = renderNested(node.body as IrComponentNode, level + 1, item);
342
+ let body = renderNested(node.body, level + 1, item);
343
343
  if (node.key) {
344
344
  const keyAttr = ` key={${bindingExpr(node.key, item)}}`;
345
345
  body = body.replace(/^( +)<(\w+)/, `$1<$2${keyAttr}`);
@@ -440,6 +440,22 @@ function lowerVocabularyElement(
440
440
  return visibilityGuard(body, props, level);
441
441
  }
442
442
 
443
+ function renderViewRefProps(props: Record<string, IrValue>, itemScope?: string): string {
444
+ const vmRef = itemScope ?? 'vm';
445
+ return Object.entries(props).map(([key, value]) => {
446
+ if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
447
+ return `${key}={${bindingExpr(value as IrBinding, vmRef)}}`;
448
+ }
449
+ return `${key}={${renderValue(value, 0)}}`;
450
+ }).join(' ');
451
+ }
452
+
453
+ function renderViewRefElement(node: IrViewRefNode, level: number, itemScope?: string): string {
454
+ const props = renderViewRefProps(node.props, itemScope);
455
+ const propStr = props ? ` ${props}` : '';
456
+ return `${indent(level)}<${node.name}${propStr} />`;
457
+ }
458
+
443
459
  function renderExtensionProps(props: Record<string, IrValue>, itemScope?: string): string {
444
460
  const vmRef = itemScope ?? 'vm';
445
461
  return Object.entries(props).map(([key, value]) => {
@@ -473,6 +489,21 @@ function isVocabulary(name: string, map: ReactElementMap, allowlistExtra: string
473
489
  return name in map.elements && !allowlistExtra.includes(name);
474
490
  }
475
491
 
492
+ export function collectViewRefComponents(node: IrNode, names: Set<string>): void {
493
+ if (node.kind === 'viewRef') {
494
+ names.add(node.name);
495
+ return;
496
+ }
497
+ if (node.kind === 'component') {
498
+ for (const child of node.children) collectViewRefComponents(child, names);
499
+ return;
500
+ }
501
+ if (node.kind === 'map') collectViewRefComponents(node.body, names);
502
+ if (node.kind === 'fragment') {
503
+ for (const child of node.children) collectViewRefComponents(child, names);
504
+ }
505
+ }
506
+
476
507
  export function collectExtensionComponents(
477
508
  node: IrNode,
478
509
  map: ReactElementMap,
@@ -493,12 +524,18 @@ export function collectExtensionComponents(
493
524
  export function createNodeRenderer(
494
525
  map: ReactElementMap,
495
526
  allowlistExtra: string[],
496
- ): NodeRenderer {
527
+ ): (node: IrComponentNode, level: number, itemScope?: string) => string {
497
528
  const renderNested: NodeRenderer = (node, level, itemScope) => {
529
+ if (node.kind === 'viewRef') {
530
+ return renderViewRefElement(node, level, itemScope);
531
+ }
532
+ if (node.kind !== 'component') {
533
+ return '';
534
+ }
498
535
  if (isVocabulary(node.name, map, allowlistExtra)) {
499
536
  return lowerVocabularyElement(node, map.elements[node.name]!, level, itemScope, renderNested);
500
537
  }
501
538
  return renderExtensionElement(node, level, itemScope, renderNested);
502
539
  };
503
- return renderNested;
540
+ return (node, level, itemScope) => renderNested(node, level, itemScope);
504
541
  }
@@ -1,6 +1,12 @@
1
1
  import type { ViewIrDocument } from '../../src/ir/types.js';
2
+ import { resolve } from 'node:path';
2
3
  import { loadReactElementMap } from './elementMap.js';
3
- import { collectExtensionComponents, createNodeRenderer } from './lowering/lowerToJsx.js';
4
+ import {
5
+ collectExtensionComponents,
6
+ collectViewRefComponents,
7
+ createNodeRenderer,
8
+ } from './lowering/lowerToJsx.js';
9
+ import type { IrComponentNode, IrNode } from '../../src/ir/types.js';
4
10
 
5
11
  export interface PreviewImportSpecifiers {
6
12
  dispatch: string;
@@ -16,22 +22,65 @@ export interface RenderReactOptions {
16
22
  allowlistExtra?: string[];
17
23
  elementMapPath?: string;
18
24
  previewImports?: PreviewImportSpecifiers;
25
+ /** When set, viewRef nodes import from these paths instead of sibling .generated.js files. */
26
+ viewRefImportFrom?: Map<string, string>;
27
+ /** When true, omit viewRef import lines (partials defined in the same module). */
28
+ inlineViewRefs?: boolean;
19
29
  /** @deprecated Use runtimeImportFrom */
20
30
  componentsImportFrom?: string;
21
31
  }
22
32
 
23
- export async function renderReactComponent(
33
+ function componentExportName(doc: ViewIrDocument): string {
34
+ if (doc.isPartial) return doc.exportName;
35
+ const baseName = doc.exportName.replace(/View$/, '') || doc.exportName;
36
+ return baseName.charAt(0).toUpperCase() + baseName.slice(1);
37
+ }
38
+
39
+ function requireComponentRoot(root: IrNode, source: string): IrComponentNode {
40
+ if (root.kind !== 'component') {
41
+ throw new Error(`View IR root must be a component node (${source})`);
42
+ }
43
+ return root;
44
+ }
45
+
46
+ function extractContractTypeNames(propsType: string | undefined): string[] {
47
+ if (!propsType) return [];
48
+ const matches = propsType.match(/\b[A-Z][A-Za-z0-9]*ViewModel\b/g);
49
+ return matches ? [...new Set(matches)] : [];
50
+ }
51
+
52
+ function partialFunctionParams(doc: ViewIrDocument): string {
53
+ if (!doc.propsType) return '';
54
+ const match = doc.propsType.match(/^\{\s*([^}]+)\s*\}$/);
55
+ if (!match) return `props: ${doc.propsType}`;
56
+ const inner = match[1].trim();
57
+ const propNames = inner.split(',').map((part) => part.split(':')[0]!.trim());
58
+ return `{ ${propNames.join(', ')} }: ${doc.propsType}`;
59
+ }
60
+
61
+ /** Root-level `{cond ? ...}` from visibilityGuard is invalid inside `return (...)` — wrap in fragment. */
62
+ function formatComponentReturn(body: string): string {
63
+ const trimmed = body.trimStart();
64
+ if (trimmed.startsWith('{')) {
65
+ return `return (\n <>${body}\n </>\n );`;
66
+ }
67
+ return `return (\n${body}\n );`;
68
+ }
69
+
70
+ function stripModulePreamble(source: string): string {
71
+ let rest = source.replace(/^\/\*\*[\s\S]*?\*\/\s*/, '');
72
+ rest = rest.replace(/^import\s[^\n]+\n/gm, '');
73
+ return rest.trimStart();
74
+ }
75
+
76
+ async function buildRenderedModule(
24
77
  doc: ViewIrDocument,
25
78
  options: RenderReactOptions,
26
79
  ): Promise<string> {
27
80
  const map = await loadReactElementMap(options.elementMapPath);
28
81
  const allowlistExtra = options.allowlistExtra ?? [];
29
- const baseName = doc.exportName.replace(/View$/, '') || doc.exportName;
30
- const componentName = baseName.charAt(0).toUpperCase() + baseName.slice(1);
31
- const root = doc.root.kind === 'component' ? doc.root : null;
32
- if (!root) {
33
- throw new Error(`View IR root must be a component node (${doc.source})`);
34
- }
82
+ const componentName = componentExportName(doc);
83
+ const root = requireComponentRoot(doc.root, doc.source);
35
84
 
36
85
  const renderNode = createNodeRenderer(map, allowlistExtra);
37
86
  const body = renderNode(root, 2);
@@ -40,34 +89,76 @@ export async function renderReactComponent(
40
89
  collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
41
90
  const extensionImports = [...extensions].sort();
42
91
 
92
+ const viewRefs = new Set<string>();
93
+ collectViewRefComponents(doc.root, viewRefs);
94
+ const viewRefImports = [...viewRefs].sort();
95
+
43
96
  const preview = options.previewImports;
44
97
  const runtimeModule = preview?.dispatch ?? options.runtimeImportFrom;
45
98
  const contractsModule = preview?.contracts ?? options.contractsImportFrom;
46
99
  const extensionsModule = preview?.extensions ?? options.extensionsImportFrom;
47
100
 
48
- const imports: string[] = [`import type { ReactElement } from 'react';`];
101
+ const contractTypes = new Set<string>();
102
+ if (doc.isPartial) {
103
+ for (const typeName of extractContractTypeNames(doc.propsType)) {
104
+ contractTypes.add(typeName);
105
+ }
106
+ } else {
107
+ contractTypes.add(doc.viewModel);
108
+ }
49
109
 
110
+ const imports: string[] = [`import type { ReactElement } from 'react';`];
50
111
  imports.push(`import { dispatchAction } from '${runtimeModule}';`);
51
- imports.push(`import type { ${doc.viewModel}, ActionDescriptor } from '${contractsModule}';`);
112
+
113
+ const typeImports = [...contractTypes].sort();
114
+ const typeImportList = typeImports.length > 0
115
+ ? `${typeImports.join(', ')}, ActionDescriptor`
116
+ : 'ActionDescriptor';
117
+ imports.push(`import type { ${typeImportList} } from '${contractsModule}';`);
118
+
52
119
  if (extensionImports.length > 0) {
53
120
  imports.push(`import { ${extensionImports.join(', ')} } from '${extensionsModule}';`);
54
121
  }
55
122
 
123
+ for (const refName of viewRefImports) {
124
+ if (options.inlineViewRefs) continue;
125
+ const importFrom = options.viewRefImportFrom?.get(refName)
126
+ ?? `./${refName}.generated.js`;
127
+ imports.push(`import { ${refName} } from '${importFrom}';`);
128
+ }
129
+
56
130
  const headerComment = preview
57
131
  ? `/**\n * Preview module — compiled live from ${doc.source}\n * Same lowering as generated/; not written to disk.\n */`
58
132
  : `/**\n * Auto-generated React component from ${doc.source}\n * DO NOT EDIT — regenerate with: view-contracts render react\n */`;
59
133
 
134
+ if (doc.isPartial) {
135
+ const fnParams = partialFunctionParams(doc);
136
+
137
+ return `${headerComment}
138
+ ${imports.join('\n')}
139
+
140
+ export function ${componentName}(${fnParams}): ReactElement {
141
+ ${formatComponentReturn(body)}
142
+ }
143
+ `;
144
+ }
145
+
60
146
  return `${headerComment}
61
147
  ${imports.join('\n')}
62
148
 
63
149
  export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
64
- return (
65
- ${body}
66
- );
150
+ ${formatComponentReturn(body)}
67
151
  }
68
152
  `;
69
153
  }
70
154
 
155
+ export async function renderReactComponent(
156
+ doc: ViewIrDocument,
157
+ options: RenderReactOptions,
158
+ ): Promise<string> {
159
+ return buildRenderedModule(doc, options);
160
+ }
161
+
71
162
  export async function renderReactFromIr(
72
163
  doc: ViewIrDocument,
73
164
  outputPath: string,
@@ -75,7 +166,71 @@ export async function renderReactFromIr(
75
166
  ): Promise<void> {
76
167
  const { writeFile, mkdir } = await import('node:fs/promises');
77
168
  const { dirname } = await import('node:path');
78
- const content = await renderReactComponent(doc, options);
169
+ const content = await buildRenderedModule(doc, options);
79
170
  await mkdir(dirname(outputPath), { recursive: true });
80
171
  await writeFile(outputPath, content, 'utf-8');
81
172
  }
173
+
174
+ /** Render screen + partial modules for preview (single file, no cross-imports). */
175
+ export async function renderReactPreviewBundle(
176
+ screen: ViewIrDocument,
177
+ partials: ViewIrDocument[],
178
+ options: RenderReactOptions,
179
+ ): Promise<string> {
180
+ const inlineOptions: RenderReactOptions = { ...options, inlineViewRefs: true };
181
+
182
+ const partialModules = await Promise.all(
183
+ partials.map((partial) => renderReactComponent(partial, inlineOptions)),
184
+ );
185
+ const screenModule = await renderReactComponent(screen, inlineOptions);
186
+
187
+ const stripImports = stripModulePreamble;
188
+
189
+ const map = await loadReactElementMap(options.elementMapPath);
190
+ const allowlistExtra = options.allowlistExtra ?? [];
191
+ const extensions = new Set<string>();
192
+ collectExtensionComponents(screen.root, map, allowlistExtra, extensions);
193
+ for (const partial of partials) {
194
+ collectExtensionComponents(partial.root, map, allowlistExtra, extensions);
195
+ }
196
+
197
+ const preview = options.previewImports;
198
+ const runtimeModule = preview?.dispatch ?? options.runtimeImportFrom;
199
+ const contractsModule = preview?.contracts ?? options.contractsImportFrom;
200
+ const extensionsModule = preview?.extensions ?? options.extensionsImportFrom;
201
+
202
+ const contractTypes = new Set<string>([screen.viewModel]);
203
+ for (const partial of partials) {
204
+ for (const typeName of extractContractTypeNames(partial.propsType)) {
205
+ contractTypes.add(typeName);
206
+ }
207
+ }
208
+
209
+ const imports: string[] = [
210
+ `import type { ReactElement } from 'react';`,
211
+ `import { dispatchAction } from '${runtimeModule}';`,
212
+ `import type { ${[...contractTypes].sort().join(', ')}, ActionDescriptor } from '${contractsModule}';`,
213
+ ];
214
+ const extensionImports = [...extensions].sort();
215
+ if (extensionImports.length > 0) {
216
+ imports.push(`import { ${extensionImports.join(', ')} } from '${extensionsModule}';`);
217
+ }
218
+
219
+ const headerComment = preview
220
+ ? `/**\n * Preview module — compiled live from ${screen.source}\n * Same lowering as generated/; not written to disk.\n */`
221
+ : '';
222
+
223
+ const partialBodies = partialModules.map(stripImports);
224
+ const screenBody = stripImports(screenModule);
225
+
226
+ return `${headerComment}
227
+ ${imports.join('\n')}
228
+
229
+ ${partialBodies.join('\n\n')}
230
+
231
+ ${screenBody}`;
232
+ }
233
+
234
+ export function partialReactOutputPath(reactDir: string, partial: ViewIrDocument): string {
235
+ return resolve(reactDir, 'components', `${partial.exportName}.generated.tsx`);
236
+ }