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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview vocabulary stubs — React components for authoring/preview only.
|
|
3
|
+
* Production apps use compile-time HTML lowering (lowerToJsx), not these stubs.
|
|
4
|
+
*
|
|
5
|
+
* All element names and style property lists come from dsl-catalog (SSoT).
|
|
6
|
+
*/
|
|
7
|
+
import type { FormEvent, ReactElement, ReactNode } from 'react';
|
|
8
|
+
import { dispatchPreviewAction } from '../runtime/actionRuntime.js';
|
|
9
|
+
import { isHidden, toneClass } from '../runtime/style.js';
|
|
10
|
+
import { foldLiteralsToCss } from '../style/foldLiterals.js';
|
|
11
|
+
import { ELEMENT_CATALOG, LOWERING_STYLE_PROPERTIES } from '../../../src/schema/dsl-catalog.js';
|
|
12
|
+
import type { Action } from '../runtime/types.js';
|
|
13
|
+
|
|
14
|
+
function componentToKebab(name: string): string {
|
|
15
|
+
return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function previewClassName(elementName: string, props: Record<string, unknown>): string {
|
|
19
|
+
const kebab = componentToKebab(elementName);
|
|
20
|
+
const parts = [`vc-${kebab}`];
|
|
21
|
+
if (typeof props.level === 'number') {
|
|
22
|
+
parts.push(`vc-${kebab}-${props.level}`);
|
|
23
|
+
}
|
|
24
|
+
const variant = props.variant ?? props.tone;
|
|
25
|
+
if (typeof variant === 'string') {
|
|
26
|
+
parts.push(`vc-${kebab}--${variant}`);
|
|
27
|
+
if (props.tone) parts.push(toneClass(variant));
|
|
28
|
+
}
|
|
29
|
+
return parts.join(' ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function previewStyleProps(props: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
const out: Record<string, unknown> = {};
|
|
34
|
+
for (const [key, value] of Object.entries(props)) {
|
|
35
|
+
if (LOWERING_STYLE_PROPERTIES.has(key)) out[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function vocabularyStub(elementName: string) {
|
|
41
|
+
return function VocabularyElement(props: Record<string, unknown>): ReactElement | null {
|
|
42
|
+
if (isHidden(props)) return null;
|
|
43
|
+
const { children, action, onClick, onSubmit, label } = props;
|
|
44
|
+
const clickAction = (action ?? onClick) as Action | undefined;
|
|
45
|
+
const submitAction = (action ?? onSubmit) as Action | undefined;
|
|
46
|
+
const onClickHandler = clickAction
|
|
47
|
+
? () => { void dispatchPreviewAction(clickAction); }
|
|
48
|
+
: undefined;
|
|
49
|
+
const onSubmitHandler = submitAction
|
|
50
|
+
? (event: FormEvent<HTMLDivElement>) => {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
void dispatchPreviewAction(submitAction);
|
|
53
|
+
}
|
|
54
|
+
: undefined;
|
|
55
|
+
const content = (children as ReactNode) ?? (typeof label === 'string' ? label : null);
|
|
56
|
+
const inlineStyle = foldLiteralsToCss(previewStyleProps(props));
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-vc-element={elementName}
|
|
60
|
+
className={previewClassName(elementName, props)}
|
|
61
|
+
style={Object.keys(inlineStyle).length > 0 ? inlineStyle : undefined}
|
|
62
|
+
onClick={onClickHandler}
|
|
63
|
+
onSubmit={onSubmitHandler}
|
|
64
|
+
>
|
|
65
|
+
{content}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** All vocabulary stubs keyed by element name, derived from ELEMENT_CATALOG. */
|
|
72
|
+
export const vocabularyStubs: Record<string, ReturnType<typeof vocabularyStub>> =
|
|
73
|
+
Object.fromEntries(
|
|
74
|
+
Object.keys(ELEMENT_CATALOG).map((name) => [name, vocabularyStub(name)]),
|
|
75
|
+
);
|
|
@@ -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
|
+
}
|
|
@@ -20,13 +20,6 @@ button, input, textarea, select { font: inherit; }
|
|
|
20
20
|
.vc-link { color: var(--ui-primary); text-decoration: none; }
|
|
21
21
|
.vc-link:hover { text-decoration: underline; }
|
|
22
22
|
|
|
23
|
-
.ui-tone-default { color: var(--ui-text); }
|
|
24
|
-
.ui-tone-muted { color: var(--ui-text-muted); }
|
|
25
|
-
.ui-tone-primary { color: var(--ui-primary); }
|
|
26
|
-
.ui-tone-success { color: var(--ui-success); }
|
|
27
|
-
.ui-tone-warning { color: var(--ui-warning); }
|
|
28
|
-
.ui-tone-danger { color: var(--ui-danger); }
|
|
29
|
-
|
|
30
23
|
.vc-button { border: 0; cursor: pointer; }
|
|
31
24
|
|
|
32
25
|
.vc-form { display: flex; flex-direction: column; }
|
|
@@ -34,19 +27,13 @@ button, input, textarea, select { font: inherit; }
|
|
|
34
27
|
.vc-field-label { font-size: 13px; font-weight: 700; color: var(--ui-text); }
|
|
35
28
|
.vc-field-help { font-size: 12px; color: var(--ui-text-muted); }
|
|
36
29
|
.vc-field-error { font-size: 12px; color: var(--ui-danger); }
|
|
37
|
-
.vc-text-input, .vc-text-area { width: 100%;
|
|
30
|
+
.vc-text-input, .vc-text-area { width: 100%; outline: none; }
|
|
38
31
|
.vc-text-input:focus, .vc-text-area:focus { border-color: var(--ui-primary); box-shadow: 0 0 0 3px var(--ui-primary-weak); }
|
|
39
32
|
.vc-field-sm { min-height: 34px; padding: 6px 10px; }
|
|
40
|
-
.vc-field-md
|
|
33
|
+
.vc-field-md { min-height: 42px; }
|
|
41
34
|
.vc-field-lg { min-height: 50px; padding: 12px 14px; }
|
|
42
35
|
.vc-select {
|
|
43
36
|
width: 100%;
|
|
44
|
-
min-height: 42px;
|
|
45
|
-
padding: 9px 36px 9px 12px;
|
|
46
|
-
border: 1px solid var(--ui-border);
|
|
47
|
-
border-radius: 10px;
|
|
48
|
-
background-color: #fff;
|
|
49
|
-
color: var(--ui-text);
|
|
50
37
|
outline: none;
|
|
51
38
|
appearance: none;
|
|
52
39
|
-webkit-appearance: none;
|
|
@@ -58,8 +45,13 @@ button, input, textarea, select { font: inherit; }
|
|
|
58
45
|
background-size: 16px;
|
|
59
46
|
}
|
|
60
47
|
.vc-select:focus { border-color: var(--ui-primary); box-shadow: 0 0 0 3px var(--ui-primary-weak); }
|
|
61
|
-
.vc-
|
|
62
|
-
.vc-text-area { padding: 10px 12px; resize: vertical; }
|
|
48
|
+
.vc-text-area { resize: vertical; }
|
|
63
49
|
.vc-checkbox { display: inline-flex; align-items: center; gap: 8px; }
|
|
64
50
|
.vc-list { margin: 0; padding-left: 20px; }
|
|
65
51
|
.vc-list-item { margin: 0; padding: 2px 0; }
|
|
52
|
+
|
|
53
|
+
.vc-table { width: 100%; display: flex; flex-direction: column; }
|
|
54
|
+
.vc-table-header { display: flex; background: var(--ui-surface-muted, #f8f9fa); }
|
|
55
|
+
.vc-table-column { text-align: left; padding: 12px 14px; font-size: 12px; font-weight: 600; color: var(--ui-text-muted, #6b7280); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
56
|
+
.vc-table-row { display: flex; align-items: center; border-top: 1px solid var(--ui-border, #e5e7eb); }
|
|
57
|
+
.vc-table-cell { padding: 14px; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
@@ -116,6 +116,17 @@ export function foldLiteralsToCss(literals: Record<string, unknown>): FoldedCss
|
|
|
116
116
|
if (mapped) style.justifyContent = mapped;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
if (typeof literals.display === 'string') style.display = literals.display;
|
|
120
|
+
if (typeof literals.gridTemplateColumns === 'string') style.gridTemplateColumns = literals.gridTemplateColumns;
|
|
121
|
+
if (typeof literals.flexDirection === 'string') style.flexDirection = literals.flexDirection;
|
|
122
|
+
if (typeof literals.alignItems === 'string') style.alignItems = literals.alignItems;
|
|
123
|
+
|
|
124
|
+
const borderColor = literals.borderColor;
|
|
125
|
+
if (typeof borderColor === 'string') style.borderColor = tokenRefToCssVar(borderColor) ?? borderColor;
|
|
126
|
+
|
|
127
|
+
const flex = literals.flex;
|
|
128
|
+
if (typeof flex === 'number') style.flex = flex;
|
|
129
|
+
|
|
119
130
|
return style;
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -1,15 +1,53 @@
|
|
|
1
|
-
# SwiftUI
|
|
1
|
+
# SwiftUI Renderer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Compiles View IR into native SwiftUI code at build time.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Capabilities
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
7
|
+
- **Full DSL element coverage** — all core elements (Box, Stack, Row, Table, Form, Button, Text, etc.) map to SwiftUI views via `elements.yaml`
|
|
8
|
+
- **Theme codegen** — `VcTheme.swift` is generated from the design token / theme IR, supporting static and dynamic variant resolution
|
|
9
|
+
- **Modifier cascade** — DSL visual properties are folded at compile time into SwiftUI modifier chains following the correct order: foreground/font → padding → background → clipShape → shadow → overlay → frame → opacity
|
|
10
|
+
- **Padding shorthand** — `"24px 18px"` style shorthand is parsed into `.padding(.vertical, N)` + `.padding(.horizontal, N)`
|
|
11
|
+
- **Dynamic variants** — `IrConcat` expressions (e.g. `` `badge-${row.statusTone}` ``) are lowered to runtime `VcTheme.resolve()` calls with a pre-built theme dictionary
|
|
12
|
+
- **Table layout** — `Table` / `TableHeader` / `TableRow` / `TableColumn` / `TableCell` with fixed widths, flex columns, and configurable `separator` / `separatorColor`
|
|
13
|
+
- **justify="between"** — `Row` elements with `justify="between"` emit `Spacer()` between children
|
|
14
|
+
- **Mock data SSoT** — `mock.yaml` is converted to `SampleData.json` and bundled into the Xcode project; Swift ViewModels decode via `JSONDecoder` (no hand-written sample data)
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
Theme is emitted at build time via `src/design/emit/swiftui.ts` and `templates/theme/*.hbs` — same Token IR + Theme IR as React `theme.css`, not runtime JSON.
|
|
16
|
+
## Files
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
| File | Role |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `elements.yaml` | DSL element → SwiftUI view mapping |
|
|
21
|
+
| `elementMap.ts` | TypeScript loader for elements.yaml |
|
|
22
|
+
| `renderRuntime.ts` | Build orchestrator (theme, components, sample data JSON) |
|
|
23
|
+
| `renderComponents.ts` | Entry point for per-component Swift file generation |
|
|
24
|
+
| `renderTheme.ts` | `VcTheme.swift` generator (variant dictionary + `applyVcStyle`) |
|
|
25
|
+
| `lowering/lowerToSwiftUI.ts` | IR → SwiftUI body code |
|
|
26
|
+
| `lowering/modifiers.ts` | Visual property set → SwiftUI modifier chain |
|
|
27
|
+
| `templates/` | Handlebars templates for boilerplate Swift files |
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Generated files are placed in `<project>/generated/swiftui/` during `view-contracts build`:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
generated/swiftui/
|
|
35
|
+
├── VcTheme.swift # Theme + variant dictionary
|
|
36
|
+
├── AdminDashboard.generated.swift # Screen view
|
|
37
|
+
├── KpiCard.generated.swift # Partial views
|
|
38
|
+
├── ...
|
|
39
|
+
└── AdminDashboard.SampleData.json # Mock data from YAML SSoT
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## iOS Preview App
|
|
43
|
+
|
|
44
|
+
See `examples/admin-dashboard/ios-preview/` for a working SwiftUI preview app:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd examples/admin-dashboard/ios-preview
|
|
48
|
+
xcodegen generate # generates .xcodeproj from project.yml
|
|
49
|
+
# Open VcPreview.xcodeproj in Xcode, or build from CLI:
|
|
50
|
+
xcodebuild -scheme VcPreview -destination 'platform=iOS Simulator,...' build
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The preview app loads `SampleData.json` from the bundle and decodes it into typed `Codable` ViewModels.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { packageRoot } from '../../src/lib/packageRoot.js';
|
|
3
|
+
import { loadYamlFile } from '../../src/renderer/elementMapValidation.js';
|
|
4
|
+
|
|
5
|
+
export interface SwiftUIElementConfig {
|
|
6
|
+
container?: 'group' | 'vstack' | 'hstack' | 'navstack' | 'scrollview' | 'field' | 'cellgroup' | 'tablestack';
|
|
7
|
+
leaf?: 'text' | 'button' | 'textfield' | 'texteditor' | 'picker' | 'spacer' | 'divider' | 'image' | 'progressview' | 'toggle';
|
|
8
|
+
spacingProp?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SwiftUIElementMap {
|
|
12
|
+
elements: Record<string, SwiftUIElementConfig>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SOURCE_LABEL = 'renderers/swiftui/elements.yaml';
|
|
16
|
+
|
|
17
|
+
let cachedMap: SwiftUIElementMap | null = null;
|
|
18
|
+
|
|
19
|
+
function validateSwiftUIElementMap(map: SwiftUIElementMap): void {
|
|
20
|
+
for (const [name, entry] of Object.entries(map.elements)) {
|
|
21
|
+
const hasContainer = entry.container !== undefined && entry.container !== null;
|
|
22
|
+
const hasLeaf = entry.leaf !== undefined && entry.leaf !== null;
|
|
23
|
+
if (hasContainer === hasLeaf) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`${SOURCE_LABEL}: element "${name}" must have exactly one of "container" or "leaf"`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if ((entry.container === 'vstack' || entry.container === 'hstack') && entry.spacingProp === undefined) {
|
|
29
|
+
// spacingProp is optional for hstack/vstack containers without a DSL gap prop (e.g. TableHeader)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function loadSwiftUIElementMap(customPath?: string): Promise<SwiftUIElementMap> {
|
|
35
|
+
if (!customPath && cachedMap) return cachedMap;
|
|
36
|
+
const mapPath = customPath ?? resolve(packageRoot(), 'renderers/swiftui/elements.yaml');
|
|
37
|
+
const raw = await loadYamlFile<SwiftUIElementMap>(mapPath);
|
|
38
|
+
validateSwiftUIElementMap(raw);
|
|
39
|
+
if (!customPath) cachedMap = raw;
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isSwiftUIVocabulary(name: string, map: SwiftUIElementMap, allowlistExtra: string[] = []): boolean {
|
|
44
|
+
return name in map.elements && !allowlistExtra.includes(name);
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# SwiftUI renderer element map — maps DSL elements to SwiftUI views.
|
|
2
|
+
|
|
3
|
+
elements:
|
|
4
|
+
Page:
|
|
5
|
+
container: navstack
|
|
6
|
+
Box:
|
|
7
|
+
container: group
|
|
8
|
+
Stack:
|
|
9
|
+
container: vstack
|
|
10
|
+
spacingProp: gap
|
|
11
|
+
Row:
|
|
12
|
+
container: hstack
|
|
13
|
+
spacingProp: gap
|
|
14
|
+
Column:
|
|
15
|
+
container: vstack
|
|
16
|
+
spacingProp: gap
|
|
17
|
+
Text:
|
|
18
|
+
leaf: text
|
|
19
|
+
Button:
|
|
20
|
+
leaf: button
|
|
21
|
+
Form:
|
|
22
|
+
container: vstack
|
|
23
|
+
spacingProp: gap
|
|
24
|
+
Field:
|
|
25
|
+
container: field
|
|
26
|
+
TextInput:
|
|
27
|
+
leaf: textfield
|
|
28
|
+
TextArea:
|
|
29
|
+
leaf: texteditor
|
|
30
|
+
Select:
|
|
31
|
+
leaf: picker
|
|
32
|
+
Table:
|
|
33
|
+
container: tablestack
|
|
34
|
+
TableHeader:
|
|
35
|
+
container: hstack
|
|
36
|
+
TableColumn:
|
|
37
|
+
container: cellgroup
|
|
38
|
+
TableRow:
|
|
39
|
+
container: hstack
|
|
40
|
+
TableCell:
|
|
41
|
+
container: cellgroup
|
|
42
|
+
Spacer:
|
|
43
|
+
leaf: spacer
|
|
44
|
+
Divider:
|
|
45
|
+
leaf: divider
|
|
46
|
+
Image:
|
|
47
|
+
leaf: image
|
|
48
|
+
Icon:
|
|
49
|
+
leaf: image
|
|
50
|
+
ScrollArea:
|
|
51
|
+
container: scrollview
|
|
52
|
+
List:
|
|
53
|
+
container: vstack
|
|
54
|
+
spacingProp: gap
|
|
55
|
+
ListItem:
|
|
56
|
+
container: group
|
|
57
|
+
Link:
|
|
58
|
+
leaf: text
|
|
59
|
+
Modal:
|
|
60
|
+
container: group
|
|
61
|
+
Progress:
|
|
62
|
+
leaf: progressview
|
|
63
|
+
Checkbox:
|
|
64
|
+
leaf: toggle
|
|
65
|
+
Switch:
|
|
66
|
+
leaf: toggle
|
|
67
|
+
Radio:
|
|
68
|
+
leaf: picker
|
|
69
|
+
IconButton:
|
|
70
|
+
leaf: button
|
|
71
|
+
Badge:
|
|
72
|
+
leaf: text
|
|
73
|
+
Chip:
|
|
74
|
+
leaf: text
|
|
75
|
+
Avatar:
|
|
76
|
+
leaf: image
|
|
77
|
+
Card:
|
|
78
|
+
container: group
|
|
79
|
+
SafeArea:
|
|
80
|
+
container: group
|
|
81
|
+
App:
|
|
82
|
+
container: group
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { loadSwiftUIElementMap, isSwiftUIVocabulary } from './elementMap.js';
|
|
2
|
+
export type { SwiftUIElementConfig, SwiftUIElementMap } from './elementMap.js';
|
|
3
|
+
export { renderSwiftComponent } from './renderComponents.js';
|
|
4
|
+
export { mergeVisualProps } from '../../src/renderer/style/mergeVisualProps.js';
|
|
5
|
+
export { visualPropSetToModifiers } from './lowering/modifiers.js';
|