view-contracts 0.3.3 → 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/README.md +6 -6
- package/cli-contract.yaml +1 -1
- package/dist/view-contracts.bundle.mjs +169 -153
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +1 -1
- package/package.json +1 -1
- package/renderers/NATIVE_THEME_EMIT.md +73 -0
- package/renderers/compose/README.md +7 -0
- package/renderers/compose/templates/Screen.kt.hbs +3 -5
- package/renderers/compose/templates/VcTheme.kt.hbs +7 -13
- package/renderers/react/defaults/theme.yaml +7 -0
- package/renderers/react/defaults/tokens.yaml +11 -0
- package/renderers/react/lowering/foldStyle.ts +87 -0
- package/renderers/react/lowering/lowerToJsx.ts +58 -69
- package/renderers/react/renderComponents.ts +180 -24
- package/renderers/react/renderRuntime.ts +36 -43
- package/renderers/react/routes.yaml +2 -2
- package/renderers/react/runtime/index.ts +1 -1
- package/renderers/react/runtime/style.ts +3 -55
- package/renderers/react/runtime/theme.css +1 -1
- package/renderers/react/runtime/types.ts +0 -24
- package/renderers/react/style/foldLiterals.ts +129 -0
- package/renderers/react/syncGeneratedRuntime.ts +35 -47
- package/renderers/react/templates/components-index.ts.hbs +2 -2
- package/renderers/react/templates/runtime.ts.hbs +10 -0
- package/renderers/react/templates/style-helpers.ts.hbs +2 -2
- package/renderers/swiftui/README.md +7 -0
- package/renderers/swiftui/templates/Screen.swift.hbs +3 -5
- package/renderers/swiftui/templates/VcTheme.swift.hbs +8 -16
- package/spec/ui-dsl.schema.json +1331 -345
- 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 +9 -2
- package/src/generated/schema/react-renderer-element-config.ts +1 -1
- package/src/generated/schema/schema-document.ts +1 -1
|
@@ -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;
|
|
@@ -10,24 +16,71 @@ export interface PreviewImportSpecifiers {
|
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export interface RenderReactOptions {
|
|
13
|
-
|
|
19
|
+
runtimeImportFrom: string;
|
|
20
|
+
extensionsImportFrom: string;
|
|
21
|
+
contractsImportFrom: string;
|
|
14
22
|
allowlistExtra?: string[];
|
|
15
23
|
elementMapPath?: string;
|
|
16
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;
|
|
29
|
+
/** @deprecated Use runtimeImportFrom */
|
|
30
|
+
componentsImportFrom?: string;
|
|
17
31
|
}
|
|
18
32
|
|
|
19
|
-
|
|
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(
|
|
20
77
|
doc: ViewIrDocument,
|
|
21
78
|
options: RenderReactOptions,
|
|
22
79
|
): Promise<string> {
|
|
23
80
|
const map = await loadReactElementMap(options.elementMapPath);
|
|
24
81
|
const allowlistExtra = options.allowlistExtra ?? [];
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const root = doc.root.kind === 'component' ? doc.root : null;
|
|
28
|
-
if (!root) {
|
|
29
|
-
throw new Error(`View IR root must be a component node (${doc.source})`);
|
|
30
|
-
}
|
|
82
|
+
const componentName = componentExportName(doc);
|
|
83
|
+
const root = requireComponentRoot(doc.root, doc.source);
|
|
31
84
|
|
|
32
85
|
const renderNode = createNodeRenderer(map, allowlistExtra);
|
|
33
86
|
const body = renderNode(root, 2);
|
|
@@ -36,37 +89,76 @@ export async function renderReactComponent(
|
|
|
36
89
|
collectExtensionComponents(doc.root, map, allowlistExtra, extensions);
|
|
37
90
|
const extensionImports = [...extensions].sort();
|
|
38
91
|
|
|
92
|
+
const viewRefs = new Set<string>();
|
|
93
|
+
collectViewRefComponents(doc.root, viewRefs);
|
|
94
|
+
const viewRefImports = [...viewRefs].sort();
|
|
95
|
+
|
|
39
96
|
const preview = options.previewImports;
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
97
|
+
const runtimeModule = preview?.dispatch ?? options.runtimeImportFrom;
|
|
98
|
+
const contractsModule = preview?.contracts ?? options.contractsImportFrom;
|
|
99
|
+
const extensionsModule = preview?.extensions ?? options.extensionsImportFrom;
|
|
100
|
+
|
|
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
|
+
}
|
|
109
|
+
|
|
110
|
+
const imports: string[] = [`import type { ReactElement } from 'react';`];
|
|
111
|
+
imports.push(`import { dispatchAction } from '${runtimeModule}';`);
|
|
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}';`);
|
|
44
118
|
|
|
45
|
-
const imports: string[] = [
|
|
46
|
-
`import type { ReactElement } from 'react';`,
|
|
47
|
-
`import { dispatchAction } from '${dispatchModule}';`,
|
|
48
|
-
`import { commonStyle, mapAlign, mapJustify } from '${styleModule}';`,
|
|
49
|
-
`import type { ${doc.viewModel}, ActionDescriptor } from '${contractsModule}';`,
|
|
50
|
-
];
|
|
51
119
|
if (extensionImports.length > 0) {
|
|
52
|
-
imports.push(`import { ${extensionImports.join(', ')} } from '${
|
|
120
|
+
imports.push(`import { ${extensionImports.join(', ')} } from '${extensionsModule}';`);
|
|
121
|
+
}
|
|
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}';`);
|
|
53
128
|
}
|
|
54
129
|
|
|
55
130
|
const headerComment = preview
|
|
56
131
|
? `/**\n * Preview module — compiled live from ${doc.source}\n * Same lowering as generated/; not written to disk.\n */`
|
|
57
132
|
: `/**\n * Auto-generated React component from ${doc.source}\n * DO NOT EDIT — regenerate with: view-contracts render react\n */`;
|
|
58
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
|
+
|
|
59
146
|
return `${headerComment}
|
|
60
147
|
${imports.join('\n')}
|
|
61
148
|
|
|
62
149
|
export function ${componentName}({ vm }: { vm: ${doc.viewModel} }): ReactElement {
|
|
63
|
-
|
|
64
|
-
${body}
|
|
65
|
-
);
|
|
150
|
+
${formatComponentReturn(body)}
|
|
66
151
|
}
|
|
67
152
|
`;
|
|
68
153
|
}
|
|
69
154
|
|
|
155
|
+
export async function renderReactComponent(
|
|
156
|
+
doc: ViewIrDocument,
|
|
157
|
+
options: RenderReactOptions,
|
|
158
|
+
): Promise<string> {
|
|
159
|
+
return buildRenderedModule(doc, options);
|
|
160
|
+
}
|
|
161
|
+
|
|
70
162
|
export async function renderReactFromIr(
|
|
71
163
|
doc: ViewIrDocument,
|
|
72
164
|
outputPath: string,
|
|
@@ -74,7 +166,71 @@ export async function renderReactFromIr(
|
|
|
74
166
|
): Promise<void> {
|
|
75
167
|
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
76
168
|
const { dirname } = await import('node:path');
|
|
77
|
-
const content = await
|
|
169
|
+
const content = await buildRenderedModule(doc, options);
|
|
78
170
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
79
171
|
await writeFile(outputPath, content, 'utf-8');
|
|
80
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
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve } from 'node:path';
|
|
1
|
+
import { writeFile, stat, unlink, rm } from 'node:fs/promises';
|
|
2
|
+
import { relative, resolve } from 'node:path';
|
|
3
3
|
import type { ViewContractsConfig } from '../../src/config.js';
|
|
4
|
-
import { projectRoot
|
|
4
|
+
import { projectRoot } from '../../src/config.js';
|
|
5
5
|
import { buildThemeCss } from '../../src/design/buildThemeCss.js';
|
|
6
|
-
import { loadValidateDesign } from '../../src/design/index.js';
|
|
7
6
|
import { renderTemplate } from '../../src/renderer/template.js';
|
|
8
7
|
import { reactTemplatesDir } from './paths.js';
|
|
9
|
-
import {
|
|
8
|
+
import { removeLegacyReactRuntimeLayout, writeGeneratedRuntimeBundle } from './syncGeneratedRuntime.js';
|
|
10
9
|
|
|
11
10
|
async function pathExists(target: string): Promise<boolean> {
|
|
12
11
|
try {
|
|
@@ -26,58 +25,52 @@ async function writeThemeCss(
|
|
|
26
25
|
const css = await buildThemeCss(config, configPath);
|
|
27
26
|
const content = await renderTemplate(reactTemplatesDir(templatesDir), 'theme.css.hbs', { themeCss: css });
|
|
28
27
|
await writeFile(themeOut, content, 'utf-8');
|
|
29
|
-
|
|
30
|
-
const { tokensPath, themePath } = requireDesignPaths(config);
|
|
31
|
-
const { tokenIR, themeIR } = await loadValidateDesign(tokensPath, themePath);
|
|
32
|
-
const themeDir = resolve(config.generated.reactDir, '../theme');
|
|
33
|
-
await mkdir(themeDir, { recursive: true });
|
|
34
|
-
await writeFile(resolve(themeDir, 'tokens.json'), `${JSON.stringify(tokenIR, null, 2)}\n`, 'utf-8');
|
|
35
|
-
await writeFile(resolve(themeDir, 'theme.json'), `${JSON.stringify(themeIR, null, 2)}\n`, 'utf-8');
|
|
36
28
|
}
|
|
37
29
|
|
|
38
|
-
async function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
async function removeLegacyGeneratedRootArtifacts(reactDir: string): Promise<void> {
|
|
31
|
+
const generatedRoot = resolve(reactDir, '..');
|
|
32
|
+
for (const name of ['theme.css', 'contracts.ts', 'view-bridge.generated.ts'] as const) {
|
|
33
|
+
const legacy = resolve(generatedRoot, name);
|
|
34
|
+
if (await pathExists(legacy)) {
|
|
35
|
+
await unlink(legacy);
|
|
43
36
|
}
|
|
44
37
|
}
|
|
45
38
|
}
|
|
46
39
|
|
|
40
|
+
async function writeExtensionsReExport(configPath: string, config: ViewContractsConfig): Promise<string> {
|
|
41
|
+
const root = projectRoot(configPath);
|
|
42
|
+
const reactDir = config.generated.reactDir;
|
|
43
|
+
const extensionsSrc = resolve(root, 'src/components/extensions.tsx');
|
|
44
|
+
let rel = relative(reactDir, extensionsSrc).replace(/\\/g, '/');
|
|
45
|
+
if (!rel.startsWith('.')) rel = `./${rel}`;
|
|
46
|
+
const importPath = rel.replace(/\.tsx?$/, '.js');
|
|
47
|
+
const outPath = resolve(reactDir, 'extensions.generated.ts');
|
|
48
|
+
await writeFile(
|
|
49
|
+
outPath,
|
|
50
|
+
`/** Auto-generated extension re-export — DO NOT EDIT */\nexport * from '${importPath}';\n`,
|
|
51
|
+
'utf-8',
|
|
52
|
+
);
|
|
53
|
+
return outPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
/** Emit standalone generated runtime (no preview vocabulary copy; no @view-contracts imports). */
|
|
48
57
|
export async function renderReactRuntime(configPath: string, config: ViewContractsConfig): Promise<void> {
|
|
49
58
|
const root = projectRoot(configPath);
|
|
50
59
|
const templatesDir = config.rendererTemplatesDir
|
|
51
60
|
? resolve(root, config.rendererTemplatesDir)
|
|
52
61
|
: undefined;
|
|
53
|
-
const
|
|
54
|
-
const srcComponentsDir = resolve(root, 'src/components');
|
|
62
|
+
const reactDir = config.generated.reactDir;
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
await
|
|
64
|
+
await removeLegacyGeneratedRootArtifacts(reactDir);
|
|
65
|
+
await removeLegacyReactRuntimeLayout(reactDir);
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
let body = await readFile(src, 'utf-8');
|
|
62
|
-
if (name === 'extensions.tsx') {
|
|
63
|
-
body = rewriteExtensionImportsForGenerated(body);
|
|
64
|
-
}
|
|
65
|
-
await writeFile(resolve(componentsDir, name), body, 'utf-8');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const handlersSrc = resolve(root, 'src/runtime/handlers.ts');
|
|
69
|
-
const handlersBody = (await readFile(handlersSrc, 'utf-8')).replace(
|
|
70
|
-
/export async function handleAppAction\(action:\s*\{[\s\S]*?\}\)/,
|
|
71
|
-
'export async function handleAppAction(action: DispatchableAction)',
|
|
72
|
-
);
|
|
73
|
-
await writeFile(
|
|
74
|
-
resolve(runtimeDir, 'handlers.ts'),
|
|
75
|
-
await renderTemplate(templates, 'handlers.ts.hbs', { handlersBody }),
|
|
76
|
-
'utf-8',
|
|
77
|
-
);
|
|
67
|
+
const { runtimePath } = await writeGeneratedRuntimeBundle(configPath, config, templatesDir);
|
|
68
|
+
const extensionsPath = await writeExtensionsReExport(configPath, config);
|
|
78
69
|
|
|
79
|
-
|
|
70
|
+
const themeOut = resolve(reactDir, 'theme.css');
|
|
71
|
+
await writeThemeCss(configPath, themeOut, config, templatesDir);
|
|
80
72
|
|
|
81
|
-
console.log(`
|
|
82
|
-
console.log(`
|
|
73
|
+
console.log(` react/runtime → ${runtimePath}`);
|
|
74
|
+
console.log(` react/extensions → ${extensionsPath}`);
|
|
75
|
+
console.log(` react/theme → ${themeOut}`);
|
|
83
76
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
react:
|
|
5
5
|
description: Production React via compile-time IR lowering + template-emitted runtime
|
|
6
|
-
importFrom: '
|
|
7
|
-
themeCss: theme.css
|
|
6
|
+
importFrom: './runtime.js'
|
|
7
|
+
themeCss: react/theme.css
|
|
8
8
|
templates: renderers/react/templates
|
|
9
9
|
outputs:
|
|
10
10
|
screen:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Preview/toolchain React runtime primitives — not copied to generated/. */
|
|
2
2
|
export * from './types.js';
|
|
3
|
-
export {
|
|
3
|
+
export { isHidden, toneClass, extensionClassName, resolveExtensionVariant } from './style.js';
|
|
4
4
|
export { registerPreviewActions, clearPreviewActions, dispatchPreviewAction } from './actionRuntime.js';
|
|
5
5
|
export { registerActionHandler, dispatchAction } from './dispatch.js';
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import type { LoweredStyleInput } from './types.js';
|
|
3
|
-
|
|
4
|
-
/** Map flex alignment shorthand (from lowering) to CSS align-items / align-self values. */
|
|
1
|
+
/** Map flex alignment shorthand to CSS align-items / align-self values. */
|
|
5
2
|
export function mapAlign(value?: string): string | undefined {
|
|
6
3
|
if (!value) return undefined;
|
|
7
4
|
if (value === 'start') return 'flex-start';
|
|
@@ -9,7 +6,7 @@ export function mapAlign(value?: string): string | undefined {
|
|
|
9
6
|
return value;
|
|
10
7
|
}
|
|
11
8
|
|
|
12
|
-
/** Map flex justification shorthand
|
|
9
|
+
/** Map flex justification shorthand to CSS justify-content values. */
|
|
13
10
|
export function mapJustify(value?: string): string | undefined {
|
|
14
11
|
if (!value) return undefined;
|
|
15
12
|
if (value === 'start') return 'flex-start';
|
|
@@ -20,56 +17,7 @@ export function mapJustify(value?: string): string | undefined {
|
|
|
20
17
|
return value;
|
|
21
18
|
}
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
return `${value}px`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const PLACE_ITEMS: Record<string, string> = {
|
|
28
|
-
topStart: 'start start',
|
|
29
|
-
top: 'center start',
|
|
30
|
-
topEnd: 'end start',
|
|
31
|
-
start: 'start center',
|
|
32
|
-
center: 'center center',
|
|
33
|
-
end: 'end center',
|
|
34
|
-
bottomStart: 'start end',
|
|
35
|
-
bottom: 'center end',
|
|
36
|
-
bottomEnd: 'end end',
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
function fontSizeStyle(size: number | string | undefined): string | undefined {
|
|
40
|
-
if (size === undefined) return undefined;
|
|
41
|
-
return typeof size === 'number' ? len(size) : size;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function commonStyle(props: LoweredStyleInput): CSSProperties {
|
|
45
|
-
const style: CSSProperties = {};
|
|
46
|
-
if (typeof props.width === 'number') style.width = len(props.width);
|
|
47
|
-
if (typeof props.minWidth === 'number') style.minWidth = len(props.minWidth);
|
|
48
|
-
if (typeof props.maxWidth === 'number') style.maxWidth = len(props.maxWidth);
|
|
49
|
-
if (typeof props.height === 'number') style.height = len(props.height);
|
|
50
|
-
if (typeof props.minHeight === 'number') style.minHeight = len(props.minHeight);
|
|
51
|
-
if (typeof props.maxHeight === 'number') style.maxHeight = len(props.maxHeight);
|
|
52
|
-
if (props.aspectRatio !== undefined) style.aspectRatio = String(props.aspectRatio);
|
|
53
|
-
if (typeof props.weight === 'number') style.flexGrow = props.weight;
|
|
54
|
-
if (typeof props.padding === 'number') style.padding = len(props.padding);
|
|
55
|
-
if (typeof props.margin === 'number') style.margin = len(props.margin);
|
|
56
|
-
if (typeof props.gap === 'number') style.gap = len(props.gap);
|
|
57
|
-
if (props.textAlign) {
|
|
58
|
-
style.textAlign = props.textAlign === 'start' ? 'left' : props.textAlign === 'end' ? 'right' : props.textAlign as CSSProperties['textAlign'];
|
|
59
|
-
}
|
|
60
|
-
if (props.contentAlign) style.placeItems = PLACE_ITEMS[props.contentAlign];
|
|
61
|
-
if (props.overflow) style.overflow = props.overflow as CSSProperties['overflow'];
|
|
62
|
-
if (props.selfAlign) {
|
|
63
|
-
style.alignSelf = props.selfAlign === 'start' ? 'flex-start' : props.selfAlign === 'end' ? 'flex-end' : props.selfAlign;
|
|
64
|
-
}
|
|
65
|
-
const fontSize = fontSizeStyle(props.size);
|
|
66
|
-
if (fontSize) style.fontSize = fontSize;
|
|
67
|
-
const fontWeight = typeof props.weight === 'string' ? props.weight : props.fontWeight;
|
|
68
|
-
if (fontWeight) style.fontWeight = fontWeight;
|
|
69
|
-
return style;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Preview vocabulary helper — lowered production JSX does not use visible. */
|
|
20
|
+
/** Preview vocabulary helper — production lowered JSX uses visibility guards instead. */
|
|
73
21
|
export function isHidden(props: { visible?: boolean; hidden?: boolean }): boolean {
|
|
74
22
|
return props.hidden === true || props.visible === false;
|
|
75
23
|
}
|
|
@@ -27,7 +27,7 @@ button, input, textarea, select { font: inherit; }
|
|
|
27
27
|
.ui-tone-warning { color: var(--ui-warning); }
|
|
28
28
|
.ui-tone-danger { color: var(--ui-danger); }
|
|
29
29
|
|
|
30
|
-
.vc-button { border: 0;
|
|
30
|
+
.vc-button { border: 0; cursor: pointer; }
|
|
31
31
|
|
|
32
32
|
.vc-form { display: flex; flex-direction: column; }
|
|
33
33
|
.vc-field { display: flex; flex-direction: column; gap: 6px; }
|
|
@@ -10,30 +10,6 @@ export type DispatchableAction = ViewAction;
|
|
|
10
10
|
/** Action binding from lowered views or hand-written extensions. */
|
|
11
11
|
export type Action = ViewAction;
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
* Style props passed to commonStyle() by the React lowering pass.
|
|
15
|
-
* Numeric dimensions are logical units; commonStyle maps them to px.
|
|
16
|
-
*/
|
|
17
|
-
export interface LoweredStyleInput {
|
|
18
|
-
width?: number | string;
|
|
19
|
-
minWidth?: number | string;
|
|
20
|
-
maxWidth?: number | string;
|
|
21
|
-
height?: number | string;
|
|
22
|
-
minHeight?: number | string;
|
|
23
|
-
maxHeight?: number | string;
|
|
24
|
-
aspectRatio?: number | string;
|
|
25
|
-
weight?: number | string;
|
|
26
|
-
padding?: number | string;
|
|
27
|
-
margin?: number;
|
|
28
|
-
gap?: number | string;
|
|
29
|
-
textAlign?: string;
|
|
30
|
-
contentAlign?: string;
|
|
31
|
-
selfAlign?: string;
|
|
32
|
-
overflow?: string;
|
|
33
|
-
size?: number | string;
|
|
34
|
-
fontWeight?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
13
|
export interface Option {
|
|
38
14
|
label: string;
|
|
39
15
|
value: string;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure compile-time style folding from literal DSL prop values.
|
|
3
|
+
* Used by lowering (static JSX) and preview vocabulary stubs only.
|
|
4
|
+
*/
|
|
5
|
+
import { mapAlign, mapJustify } from '../runtime/style.js';
|
|
6
|
+
|
|
7
|
+
export type FoldedCss = Record<string, string | number>;
|
|
8
|
+
|
|
9
|
+
function len(value: number): string {
|
|
10
|
+
return `${value}px`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PLACE_ITEMS: Record<string, string> = {
|
|
14
|
+
topStart: 'start start',
|
|
15
|
+
top: 'center start',
|
|
16
|
+
topEnd: 'end start',
|
|
17
|
+
start: 'start center',
|
|
18
|
+
center: 'center center',
|
|
19
|
+
end: 'end center',
|
|
20
|
+
bottomStart: 'start end',
|
|
21
|
+
bottom: 'center end',
|
|
22
|
+
bottomEnd: 'end end',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SHADOW_PRESETS: Record<string, string> = {
|
|
26
|
+
none: 'none',
|
|
27
|
+
sm: 'var(--ui-shadow-sm, 0 1px 2px rgba(0,0,0,0.05))',
|
|
28
|
+
md: 'var(--ui-shadow)',
|
|
29
|
+
lg: 'var(--ui-shadow-lg, 0 10px 15px rgba(0,0,0,0.1))',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function tokenRefToCssVar(value: string): string | undefined {
|
|
33
|
+
const match = value.match(/^\{([^}]+)\}$/);
|
|
34
|
+
if (!match) return undefined;
|
|
35
|
+
return `var(--${match[1]!.replace(/\./g, '-')})`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function foldLiteralsToCss(literals: Record<string, unknown>): FoldedCss {
|
|
39
|
+
const style: FoldedCss = {};
|
|
40
|
+
const width = literals.width;
|
|
41
|
+
if (typeof width === 'number') style.width = len(width);
|
|
42
|
+
else if (typeof width === 'string' && width !== 'wrap' && width !== 'fill') style.width = width;
|
|
43
|
+
|
|
44
|
+
if (typeof literals.minWidth === 'number') style.minWidth = len(literals.minWidth);
|
|
45
|
+
if (typeof literals.maxWidth === 'number') style.maxWidth = len(literals.maxWidth);
|
|
46
|
+
if (typeof literals.height === 'number') style.height = len(literals.height);
|
|
47
|
+
if (typeof literals.minHeight === 'number') style.minHeight = len(literals.minHeight);
|
|
48
|
+
if (typeof literals.maxHeight === 'number') style.maxHeight = len(literals.maxHeight);
|
|
49
|
+
if (literals.aspectRatio !== undefined) style.aspectRatio = String(literals.aspectRatio);
|
|
50
|
+
|
|
51
|
+
const weight = literals.weight;
|
|
52
|
+
if (typeof weight === 'number') style.flexGrow = weight;
|
|
53
|
+
|
|
54
|
+
if (typeof literals.padding === 'number') style.padding = len(literals.padding);
|
|
55
|
+
if (typeof literals.margin === 'number') style.margin = len(literals.margin);
|
|
56
|
+
if (typeof literals.gap === 'number') style.gap = len(literals.gap);
|
|
57
|
+
|
|
58
|
+
const textAlign = literals.textAlign;
|
|
59
|
+
if (typeof textAlign === 'string') {
|
|
60
|
+
style.textAlign = textAlign === 'start' ? 'left' : textAlign === 'end' ? 'right' : textAlign;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const contentAlign = literals.contentAlign;
|
|
64
|
+
if (typeof contentAlign === 'string' && contentAlign in PLACE_ITEMS) {
|
|
65
|
+
style.placeItems = PLACE_ITEMS[contentAlign]!;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof literals.overflow === 'string') style.overflow = literals.overflow;
|
|
69
|
+
|
|
70
|
+
const selfAlign = literals.selfAlign;
|
|
71
|
+
if (typeof selfAlign === 'string') {
|
|
72
|
+
style.alignSelf = selfAlign === 'start' ? 'flex-start' : selfAlign === 'end' ? 'flex-end' : selfAlign;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const size = literals.size;
|
|
76
|
+
if (typeof size === 'number') style.fontSize = len(size);
|
|
77
|
+
else if (typeof size === 'string') style.fontSize = size;
|
|
78
|
+
|
|
79
|
+
const fontWeight = typeof weight === 'string' ? weight : literals.fontWeight;
|
|
80
|
+
if (typeof fontWeight === 'string') style.fontWeight = fontWeight;
|
|
81
|
+
|
|
82
|
+
const background = literals.background;
|
|
83
|
+
if (typeof background === 'string') style.background = tokenRefToCssVar(background) ?? background;
|
|
84
|
+
|
|
85
|
+
const foreground = literals.foreground;
|
|
86
|
+
if (typeof foreground === 'string') style.color = tokenRefToCssVar(foreground) ?? foreground;
|
|
87
|
+
|
|
88
|
+
const radius = literals.radius;
|
|
89
|
+
if (typeof radius === 'number') style.borderRadius = len(radius);
|
|
90
|
+
else if (typeof radius === 'string') style.borderRadius = tokenRefToCssVar(radius) ?? radius;
|
|
91
|
+
|
|
92
|
+
const shadow = literals.shadow;
|
|
93
|
+
if (shadow === true) style.boxShadow = 'var(--ui-shadow)';
|
|
94
|
+
else if (shadow === false) style.boxShadow = 'none';
|
|
95
|
+
else if (typeof shadow === 'string') {
|
|
96
|
+
style.boxShadow = SHADOW_PRESETS[shadow] ?? tokenRefToCssVar(shadow) ?? shadow;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const border = literals.border;
|
|
100
|
+
if (typeof border === 'string') style.border = tokenRefToCssVar(border) ?? border;
|
|
101
|
+
|
|
102
|
+
if (typeof literals.opacity === 'number') style.opacity = literals.opacity;
|
|
103
|
+
|
|
104
|
+
const clip = literals.clip;
|
|
105
|
+
if (typeof clip === 'string') style.overflow = clip === 'hidden' ? 'hidden' : clip;
|
|
106
|
+
|
|
107
|
+
const align = literals.align;
|
|
108
|
+
if (typeof align === 'string') {
|
|
109
|
+
const mapped = mapAlign(align);
|
|
110
|
+
if (mapped) style.alignItems = mapped;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const justify = literals.justify;
|
|
114
|
+
if (typeof justify === 'string') {
|
|
115
|
+
const mapped = mapJustify(justify);
|
|
116
|
+
if (mapped) style.justifyContent = mapped;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return style;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function foldedStyleToJsx(style: FoldedCss): string {
|
|
123
|
+
const entries = Object.entries(style);
|
|
124
|
+
if (entries.length === 0) return '';
|
|
125
|
+
const body = entries
|
|
126
|
+
.map(([key, value]) => (typeof value === 'number' ? `${key}: ${value}` : `${key}: ${JSON.stringify(value)}`))
|
|
127
|
+
.join(', ');
|
|
128
|
+
return `style={{ ${body} }}`;
|
|
129
|
+
}
|