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.
package/docs/cli-reference.md
CHANGED
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
-
):
|
|
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 {
|
|
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
|
-
|
|
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
|
|
30
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|