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.
Files changed (45) hide show
  1. package/README.md +10 -3
  2. package/cli-contract.yaml +41 -1
  3. package/dist/preview-style-helpers.js +2 -0
  4. package/dist/preview-style-helpers.js.map +7 -0
  5. package/dist/preview.mjs +153 -0
  6. package/dist/preview.mjs.map +7 -0
  7. package/dist/view-contracts.bundle.mjs +402 -237
  8. package/dist/view-contracts.bundle.mjs.map +4 -4
  9. package/docs/cli-reference.md +37 -1
  10. package/package.json +28 -1
  11. package/renderers/compose/renderRuntime.ts +40 -0
  12. package/renderers/react/defaults/theme.yaml +23 -0
  13. package/renderers/react/defaults/tokens.yaml +15 -0
  14. package/renderers/react/elementMap.ts +30 -6
  15. package/renderers/react/elements.yaml +17 -5
  16. package/renderers/react/lowering/foldStyle.ts +13 -1
  17. package/renderers/react/lowering/lowerToJsx.ts +58 -40
  18. package/renderers/react/paths.ts +1 -10
  19. package/renderers/react/preview/components.ts +23 -0
  20. package/renderers/react/preview/index.ts +3 -0
  21. package/renderers/react/preview/start.tsx +42 -0
  22. package/renderers/react/preview/style-helpers.ts +5 -0
  23. package/renderers/react/preview/vocabulary.tsx +75 -0
  24. package/renderers/react/renderComponents.ts +169 -14
  25. package/renderers/react/runtime/theme.css +9 -17
  26. package/renderers/react/style/foldLiterals.ts +11 -0
  27. package/renderers/swiftui/README.md +48 -10
  28. package/renderers/swiftui/elementMap.ts +45 -0
  29. package/renderers/swiftui/elements.yaml +82 -0
  30. package/renderers/swiftui/index.ts +5 -0
  31. package/renderers/swiftui/lowering/lowerToSwiftUI.ts +542 -0
  32. package/renderers/swiftui/lowering/modifiers.ts +142 -0
  33. package/renderers/swiftui/renderComponents.ts +79 -0
  34. package/renderers/swiftui/renderRuntime.ts +91 -0
  35. package/renderers/swiftui/renderTheme.ts +208 -0
  36. package/spec/ui-dsl.linter-rules.json +0 -7
  37. package/spec/ui-dsl.meta.json +5 -0
  38. package/spec/ui-dsl.schema.json +333 -259
  39. package/src/generated/schema/allowed-components.ts +0 -36
  40. package/src/generated/schema/dsl-enums.ts +0 -19
  41. package/src/generated/schema/dsl-properties.ts +0 -64
  42. package/src/generated/schema/index.ts +0 -4
  43. package/src/generated/schema/lowering-style-properties.ts +0 -35
  44. package/src/generated/schema/react-renderer-element-config.ts +0 -27
  45. 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 { 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
+ }
@@ -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%; border: 1px solid var(--ui-border); border-radius: 10px; background: #fff; color: var(--ui-text); outline: none; }
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, .vc-text-input { min-height: 42px; padding: 9px 12px; }
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-field-lg { min-height: 50px; padding: 12px 14px; }
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 renderer scaffold
1
+ # SwiftUI Renderer
2
2
 
3
- **Not implemented yet** beyond theme / screen stubs. View IR lowering and modifier emission are future work.
3
+ Compiles View IR into native SwiftUI code at build time.
4
4
 
5
- SwiftUI `ViewModifier` chains are **compositional** (not CSS-style override). view-contracts may later introduce a separate **modifier pipeline** concept (wrapper-style transforms, analogous to nested HTML layout shells) — that is **out of scope for the current phase**. When styling is implemented, defaults + variant + instance props will be **folded at compile time** into resolved values, not chained for cascade-like override. See [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) § Native renderers.
5
+ ## Capabilities
6
6
 
7
- - Maps: `elements.yaml`, `properties.yaml` (hand-maintained under this directory)
8
- - Routes: `routes.yaml`
9
- - Templates: `templates/`
10
- - Theme codegen spec: [../NATIVE_THEME_EMIT.md](../NATIVE_THEME_EMIT.md)
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
- Implement SwiftUI code generation by reading View IR + renderer maps.
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
- Extend vocabulary via `spec/ui-dsl.schema.json` (from `ui-dsl-*.csv`), then update renderer maps.
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';