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,542 @@
|
|
|
1
|
+
import { LOWERING_STYLE_PROPERTIES } from '../../../src/schema/dsl-catalog.js';
|
|
2
|
+
import type { ThemeIR, TokenIR } from '../../../src/design/types.js';
|
|
3
|
+
import { mergeVisualProps, type VisualPropSet } from '../../../src/renderer/style/mergeVisualProps.js';
|
|
4
|
+
import type {
|
|
5
|
+
IrBinding,
|
|
6
|
+
IrComponentNode,
|
|
7
|
+
IrConcat,
|
|
8
|
+
IrLiteral,
|
|
9
|
+
IrMapNode,
|
|
10
|
+
IrNode,
|
|
11
|
+
IrTextNode,
|
|
12
|
+
IrValue,
|
|
13
|
+
IrViewRefNode,
|
|
14
|
+
} from '../../../src/ir/types.js';
|
|
15
|
+
import type { SwiftUIElementConfig, SwiftUIElementMap } from '../elementMap.js';
|
|
16
|
+
import { applyModifiers, visualPropSetToModifiers } from './modifiers.js';
|
|
17
|
+
|
|
18
|
+
export interface SwiftUIRenderContext {
|
|
19
|
+
map: SwiftUIElementMap;
|
|
20
|
+
theme: ThemeIR;
|
|
21
|
+
tokens: TokenIR;
|
|
22
|
+
allowlistExtra: string[];
|
|
23
|
+
/** Root prop name for partial scope (e.g. "item" for KpiCard). */
|
|
24
|
+
propsRoot?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function indent(level: number): string {
|
|
28
|
+
return ' '.repeat(level);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function propLiteral(props: Record<string, IrValue>, key: string): unknown | undefined {
|
|
32
|
+
const value = props[key];
|
|
33
|
+
if (value === null || value === undefined) return undefined;
|
|
34
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
|
|
35
|
+
if (typeof value === 'object' && 'kind' in value && value.kind === 'literal') {
|
|
36
|
+
return (value as IrLiteral).value;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectInstanceVisualLiterals(props: Record<string, IrValue>): Record<string, unknown> {
|
|
42
|
+
const literals: Record<string, unknown> = {};
|
|
43
|
+
for (const [key, value] of Object.entries(props)) {
|
|
44
|
+
if (key === 'variant' || key === 'id') continue;
|
|
45
|
+
if (!LOWERING_STYLE_PROPERTIES.has(key)) continue;
|
|
46
|
+
const lit = propLiteral(props, key);
|
|
47
|
+
if (lit !== undefined) literals[key] = lit;
|
|
48
|
+
}
|
|
49
|
+
return literals;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isDynamicVariant(props: Record<string, IrValue>): boolean {
|
|
53
|
+
const v = props.variant;
|
|
54
|
+
if (v === null || v === undefined) return false;
|
|
55
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return false;
|
|
56
|
+
if (typeof v === 'object' && 'kind' in v) {
|
|
57
|
+
if (v.kind === 'literal') return false;
|
|
58
|
+
return v.kind === 'binding' || v.kind === 'concat';
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dynamicVariantSwiftExpr(props: Record<string, IrValue>, ctx: SwiftUIRenderContext, itemScope?: string): string | null {
|
|
64
|
+
const v = props.variant;
|
|
65
|
+
if (!v || typeof v !== 'object' || !('kind' in v)) return null;
|
|
66
|
+
if (v.kind === 'binding') return bindingExpr(v as IrBinding, ctx, itemScope);
|
|
67
|
+
if (v.kind === 'concat') {
|
|
68
|
+
const concat = v as IrConcat;
|
|
69
|
+
const parts = concat.parts.map(p => {
|
|
70
|
+
if (typeof p === 'string') return p;
|
|
71
|
+
return `\\(${bindingExpr(p, ctx, itemScope)})`;
|
|
72
|
+
}).join('');
|
|
73
|
+
return `"${parts}"`;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mergedModifiersForProps(
|
|
79
|
+
elementName: string,
|
|
80
|
+
props: Record<string, IrValue>,
|
|
81
|
+
ctx: SwiftUIRenderContext,
|
|
82
|
+
itemScope?: string,
|
|
83
|
+
): string[] {
|
|
84
|
+
const entry = ctx.map.elements[elementName];
|
|
85
|
+
const variantLit = propLiteral(props, 'variant');
|
|
86
|
+
const instanceLiterals = collectInstanceVisualLiterals(props);
|
|
87
|
+
if (entry?.spacingProp && entry.spacingProp in instanceLiterals) {
|
|
88
|
+
delete instanceLiterals[entry.spacingProp];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dynamic = isDynamicVariant(props);
|
|
92
|
+
|
|
93
|
+
if (dynamic) {
|
|
94
|
+
// Dynamic variant: base+variant are resolved at runtime via theme dictionary.
|
|
95
|
+
// Compile-time applies only instance literals (which override variant at runtime).
|
|
96
|
+
const variantExpr = dynamicVariantSwiftExpr(props, ctx, itemScope)!;
|
|
97
|
+
const runtimeLookup = `.applyVcStyle(VcTheme.resolve(${JSON.stringify(elementName)}, variant: ${variantExpr}))`;
|
|
98
|
+
|
|
99
|
+
const instanceOnly = mergeVisualProps({
|
|
100
|
+
elementName,
|
|
101
|
+
theme: { defaults: {}, variants: {} },
|
|
102
|
+
tokens: ctx.tokens,
|
|
103
|
+
instanceLiterals,
|
|
104
|
+
});
|
|
105
|
+
const instanceModifiers = visualPropSetToModifiers(instanceOnly);
|
|
106
|
+
|
|
107
|
+
// Order: runtime lookup (base+variant) → instance overrides
|
|
108
|
+
return [runtimeLookup, ...instanceModifiers];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const merged = mergeVisualProps({
|
|
112
|
+
elementName,
|
|
113
|
+
theme: ctx.theme,
|
|
114
|
+
tokens: ctx.tokens,
|
|
115
|
+
variantLiteral: typeof variantLit === 'string' ? variantLit : undefined,
|
|
116
|
+
instanceLiterals,
|
|
117
|
+
});
|
|
118
|
+
return visualPropSetToModifiers(merged);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function bindingExpr(binding: IrBinding, ctx: SwiftUIRenderContext, itemScope?: string): string {
|
|
122
|
+
const root = binding.scope ?? itemScope ?? ctx.propsRoot ?? 'vm';
|
|
123
|
+
return binding.path ? `${root}.${binding.path}` : root;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderTextContent(children: IrNode[], ctx: SwiftUIRenderContext, itemScope?: string): string | null {
|
|
127
|
+
for (const child of children) {
|
|
128
|
+
if (child.kind === 'text') return JSON.stringify((child as IrTextNode).value);
|
|
129
|
+
if (child.kind === 'binding') return bindingExpr(child as IrBinding, ctx, itemScope);
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type NodeRenderer = (node: IrNode, level: number, itemScope?: string) => string;
|
|
135
|
+
|
|
136
|
+
function renderChildren(
|
|
137
|
+
children: IrNode[],
|
|
138
|
+
level: number,
|
|
139
|
+
renderNested: NodeRenderer,
|
|
140
|
+
ctx: SwiftUIRenderContext,
|
|
141
|
+
itemScope?: string,
|
|
142
|
+
): string {
|
|
143
|
+
const parts = children.map((child) => {
|
|
144
|
+
if (child.kind === 'map') return renderMap(child, level, renderNested, ctx);
|
|
145
|
+
if (child.kind === 'text') return `${indent(level)}Text(${JSON.stringify((child as IrTextNode).value)})`;
|
|
146
|
+
if (child.kind === 'binding') return `${indent(level)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
|
|
147
|
+
return renderNested(child, level, itemScope);
|
|
148
|
+
}).filter(Boolean);
|
|
149
|
+
return parts.join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderMap(node: IrMapNode, level: number, renderNested: NodeRenderer, ctx: SwiftUIRenderContext): string {
|
|
153
|
+
const item = node.item;
|
|
154
|
+
const body = renderNested(node.body, level + 2, item);
|
|
155
|
+
const source = bindingExpr(node.source, ctx, item);
|
|
156
|
+
return `${indent(level)}ForEach(${source}, id: \\.id) { ${item} in\n${body}\n${indent(level)}}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderLeaf(
|
|
160
|
+
node: IrComponentNode,
|
|
161
|
+
entry: SwiftUIElementConfig,
|
|
162
|
+
level: number,
|
|
163
|
+
itemScope: string | undefined,
|
|
164
|
+
ctx: SwiftUIRenderContext,
|
|
165
|
+
modifiers: string[],
|
|
166
|
+
): string {
|
|
167
|
+
const pad = indent(level);
|
|
168
|
+
|
|
169
|
+
if (entry.leaf === 'text') {
|
|
170
|
+
const content = renderTextContent(node.children, ctx, itemScope);
|
|
171
|
+
if (!content) return `${pad}Text("")`;
|
|
172
|
+
const textLine = `${pad}Text(${content})`;
|
|
173
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
174
|
+
return [textLine, ...modLines].join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (entry.leaf === 'button') {
|
|
178
|
+
const label = propLiteral(node.props, 'label');
|
|
179
|
+
const textContent = renderTextContent(node.children, ctx, itemScope);
|
|
180
|
+
const title = textContent ?? (typeof label === 'string' ? JSON.stringify(label) : '"Button"');
|
|
181
|
+
const line = `${pad}Button(${title}) {}`;
|
|
182
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
183
|
+
return [line, ...modLines].join('\n');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (entry.leaf === 'textfield') {
|
|
187
|
+
const placeholder = propLiteral(node.props, 'placeholder');
|
|
188
|
+
const name = propLiteral(node.props, 'name');
|
|
189
|
+
const ph = typeof placeholder === 'string' ? JSON.stringify(placeholder) : '""';
|
|
190
|
+
const binding = typeof name === 'string' ? name : 'text';
|
|
191
|
+
const line = `${pad}TextField(${ph}, text: .constant(""))`;
|
|
192
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
193
|
+
return [line, ...modLines].join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (entry.leaf === 'texteditor') {
|
|
197
|
+
const line = `${pad}TextEditor(text: .constant(""))`;
|
|
198
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
199
|
+
return [line, ...modLines].join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (entry.leaf === 'picker') {
|
|
203
|
+
const label = propLiteral(node.props, 'label') ?? propLiteral(node.props, 'name') ?? '';
|
|
204
|
+
const line = `${pad}Picker(${JSON.stringify(label)}, selection: .constant("")) { Text("--") }`;
|
|
205
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
206
|
+
return [line, ...modLines].join('\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (entry.leaf === 'spacer') {
|
|
210
|
+
return `${pad}Spacer()`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (entry.leaf === 'divider') {
|
|
214
|
+
return `${pad}Divider()`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (entry.leaf === 'image') {
|
|
218
|
+
const src = propLiteral(node.props, 'src') ?? propLiteral(node.props, 'name');
|
|
219
|
+
const systemName = typeof src === 'string' ? src : 'photo';
|
|
220
|
+
const line = `${pad}Image(systemName: ${JSON.stringify(systemName)})`;
|
|
221
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
222
|
+
return [line, ...modLines].join('\n');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (entry.leaf === 'progressview') {
|
|
226
|
+
const line = `${pad}ProgressView()`;
|
|
227
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
228
|
+
return [line, ...modLines].join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (entry.leaf === 'toggle') {
|
|
232
|
+
const label = propLiteral(node.props, 'label') ?? '';
|
|
233
|
+
const line = `${pad}Toggle(${JSON.stringify(label)}, isOn: .constant(false))`;
|
|
234
|
+
const modLines = modifiers.map((mod) => `${pad}${mod}`);
|
|
235
|
+
return [line, ...modLines].join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return `${pad}Text("/* unsupported leaf: ${node.name} */")`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderContainer(
|
|
242
|
+
node: IrComponentNode,
|
|
243
|
+
entry: SwiftUIElementConfig,
|
|
244
|
+
level: number,
|
|
245
|
+
itemScope: string | undefined,
|
|
246
|
+
renderNested: NodeRenderer,
|
|
247
|
+
ctx: SwiftUIRenderContext,
|
|
248
|
+
modifiers: string[],
|
|
249
|
+
): string {
|
|
250
|
+
const childBlock = renderChildren(node.children, level + 2, renderNested, ctx, itemScope);
|
|
251
|
+
|
|
252
|
+
if (entry.container === 'vstack') {
|
|
253
|
+
const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
|
|
254
|
+
const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
|
|
255
|
+
const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: ${spacing}) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
256
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (entry.container === 'hstack') {
|
|
260
|
+
const spacingLit = entry.spacingProp ? propLiteral(node.props, entry.spacingProp) : undefined;
|
|
261
|
+
const spacing = typeof spacingLit === 'number' ? spacingLit : 0;
|
|
262
|
+
const alignLit = propLiteral(node.props, 'align');
|
|
263
|
+
const alignment = alignLit === 'center' ? '.center' : '.top';
|
|
264
|
+
const heightLit = propLiteral(node.props, 'height');
|
|
265
|
+
const justifyLit = propLiteral(node.props, 'justify');
|
|
266
|
+
const flexLit = propLiteral(node.props, 'flex');
|
|
267
|
+
const isTableElement = node.name === 'TableHeader' || node.name === 'TableRow';
|
|
268
|
+
const shouldFill = isTableElement || justifyLit !== undefined || typeof flexLit === 'number';
|
|
269
|
+
const frameMods: string[] = [];
|
|
270
|
+
if (shouldFill) frameMods.push('.frame(maxWidth: .infinity, alignment: .leading)');
|
|
271
|
+
if (typeof heightLit === 'number') frameMods.push(`.frame(height: ${heightLit})`);
|
|
272
|
+
|
|
273
|
+
let block = childBlock;
|
|
274
|
+
if (justifyLit === 'between') {
|
|
275
|
+
const childLines = node.children.map((child) => {
|
|
276
|
+
if (child.kind === 'map') return renderMap(child, level + 2, renderNested, ctx);
|
|
277
|
+
if (child.kind === 'text') return `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
|
|
278
|
+
if (child.kind === 'binding') return `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
|
|
279
|
+
return renderNested(child, level + 2, itemScope);
|
|
280
|
+
}).filter(Boolean);
|
|
281
|
+
if (childLines.length >= 2) {
|
|
282
|
+
const first = childLines[0]!;
|
|
283
|
+
const rest = childLines.slice(1);
|
|
284
|
+
block = [first, `${indent(level + 2)}Spacer()`, ...rest].join('\n');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const inner = `${indent(level + 1)}HStack(alignment: ${alignment}, spacing: ${spacing}) {\n${block}\n${indent(level + 1)}}`;
|
|
289
|
+
return applyModifiers(inner, [...modifiers, ...frameMods], level + 1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (entry.container === 'group') {
|
|
293
|
+
const inner = childBlock
|
|
294
|
+
? `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`
|
|
295
|
+
: `${indent(level + 1)}VStack { EmptyView() }`;
|
|
296
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (entry.container === 'navstack') {
|
|
300
|
+
const inner = childBlock;
|
|
301
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity)'], level + 1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (entry.container === 'scrollview') {
|
|
305
|
+
const inner = `${indent(level + 1)}ScrollView {\n${childBlock}\n${indent(level + 1)}}`;
|
|
306
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (entry.container === 'field') {
|
|
310
|
+
const label = propLiteral(node.props, 'label');
|
|
311
|
+
const labelView = typeof label === 'string' ? `\n${indent(level + 2)}Text(${JSON.stringify(label)})\n${indent(level + 2)} .font(.caption)\n${indent(level + 2)} .foregroundStyle(.secondary)` : '';
|
|
312
|
+
const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: 4) {${labelView}\n${childBlock}\n${indent(level + 1)}}`;
|
|
313
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (entry.container === 'tablestack') {
|
|
317
|
+
const separatorLit = propLiteral(node.props, 'separator');
|
|
318
|
+
const separatorColorLit = propLiteral(node.props, 'separatorColor');
|
|
319
|
+
const hasSeparator = typeof separatorLit === 'number' && separatorLit > 0;
|
|
320
|
+
|
|
321
|
+
const childParts = node.children.map((child) => {
|
|
322
|
+
if (child.kind === 'map') return renderMap(child, level + 2, renderNested, ctx);
|
|
323
|
+
return renderNested(child, level + 2, itemScope);
|
|
324
|
+
}).filter(Boolean);
|
|
325
|
+
|
|
326
|
+
let body: string[];
|
|
327
|
+
if (hasSeparator) {
|
|
328
|
+
const resolved = resolvedVisualProps(node.name, node.props, ctx);
|
|
329
|
+
const colorStr = resolved.separatorColor ?? separatorColorLit;
|
|
330
|
+
let colorExpr = 'Color.gray.opacity(0.2)';
|
|
331
|
+
if (typeof colorStr === 'string') {
|
|
332
|
+
const hex = colorStr.match(/^#([0-9a-fA-F]{6})$/);
|
|
333
|
+
if (hex) {
|
|
334
|
+
const r = parseInt(hex[1]!.slice(0, 2), 16) / 255;
|
|
335
|
+
const g = parseInt(hex[1]!.slice(2, 4), 16) / 255;
|
|
336
|
+
const b = parseInt(hex[1]!.slice(4, 6), 16) / 255;
|
|
337
|
+
colorExpr = `Color(red: ${r}, green: ${g}, blue: ${b})`;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const thickness = separatorLit as number;
|
|
341
|
+
const divLine = thickness === 1
|
|
342
|
+
? `${indent(level + 2)}Divider().overlay(${colorExpr})`
|
|
343
|
+
: `${indent(level + 2)}Rectangle().fill(${colorExpr}).frame(height: ${thickness})`;
|
|
344
|
+
body = childParts.flatMap((part, i) =>
|
|
345
|
+
i < childParts.length - 1 ? [part, divLine] : [part],
|
|
346
|
+
);
|
|
347
|
+
} else {
|
|
348
|
+
body = childParts;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const inner = `${indent(level + 1)}VStack(alignment: .leading, spacing: 0) {\n${body.join('\n')}\n${indent(level + 1)}}`;
|
|
352
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (entry.container === 'cellgroup') {
|
|
356
|
+
const widthLit = propLiteral(node.props, 'width');
|
|
357
|
+
const flexLit = propLiteral(node.props, 'flex');
|
|
358
|
+
const alignLit = propLiteral(node.props, 'align');
|
|
359
|
+
const swiftAlign = alignLit === 'end' ? '.trailing' : alignLit === 'center' ? '.center' : '.leading';
|
|
360
|
+
const isFlex = typeof flexLit === 'number';
|
|
361
|
+
const cellMods = [...modifiers, '.padding(.horizontal, 4)', '.lineLimit(1)'];
|
|
362
|
+
if (typeof widthLit === 'number') {
|
|
363
|
+
cellMods.push(`.frame(width: ${widthLit}, alignment: ${swiftAlign})`);
|
|
364
|
+
} else if (isFlex) {
|
|
365
|
+
cellMods.push(`.frame(maxWidth: .infinity, alignment: ${swiftAlign})`);
|
|
366
|
+
}
|
|
367
|
+
const inner = childBlock
|
|
368
|
+
? `${indent(level + 1)}VStack(alignment: ${swiftAlign}) {\n${childBlock}\n${indent(level + 1)}}`
|
|
369
|
+
: `${indent(level + 1)}VStack { EmptyView() }`;
|
|
370
|
+
return applyModifiers(inner, cellMods, level + 1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const inner = childBlock
|
|
374
|
+
? `${indent(level + 1)}Group {\n${childBlock}\n${indent(level + 1)}}`
|
|
375
|
+
: `${indent(level + 1)}Group { EmptyView() }`;
|
|
376
|
+
return applyModifiers(inner, modifiers, level + 1);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
function resolvedVisualProps(
|
|
381
|
+
elementName: string,
|
|
382
|
+
props: Record<string, IrValue>,
|
|
383
|
+
ctx: SwiftUIRenderContext,
|
|
384
|
+
): VisualPropSet {
|
|
385
|
+
const variantLit = propLiteral(props, 'variant');
|
|
386
|
+
const instanceLiterals = collectInstanceVisualLiterals(props);
|
|
387
|
+
return mergeVisualProps({
|
|
388
|
+
elementName,
|
|
389
|
+
theme: ctx.theme,
|
|
390
|
+
tokens: ctx.tokens,
|
|
391
|
+
variantLiteral: typeof variantLit === 'string' ? variantLit : undefined,
|
|
392
|
+
instanceLiterals,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function parseGridColumns(gridTemplateColumns: string | number | boolean | undefined): { kind: 'fixed-columns'; widths: string[] } | { kind: 'adaptive'; minWidth: number } | null {
|
|
397
|
+
if (!gridTemplateColumns || typeof gridTemplateColumns !== 'string') return null;
|
|
398
|
+
const adaptiveMatch = gridTemplateColumns.match(/repeat\(auto-fit,\s*minmax\((\d+)px/);
|
|
399
|
+
if (adaptiveMatch) return { kind: 'adaptive', minWidth: Number(adaptiveMatch[1]) };
|
|
400
|
+
const parts = gridTemplateColumns.trim().split(/\s+/);
|
|
401
|
+
if (parts.length >= 2) return { kind: 'fixed-columns', widths: parts };
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function renderComponent(
|
|
406
|
+
node: IrComponentNode,
|
|
407
|
+
entry: SwiftUIElementConfig,
|
|
408
|
+
level: number,
|
|
409
|
+
itemScope: string | undefined,
|
|
410
|
+
renderNested: NodeRenderer,
|
|
411
|
+
ctx: SwiftUIRenderContext,
|
|
412
|
+
): string {
|
|
413
|
+
const modifiers = mergedModifiersForProps(node.name, node.props, ctx, itemScope);
|
|
414
|
+
|
|
415
|
+
if (entry.leaf) {
|
|
416
|
+
return renderLeaf(node, entry, level, itemScope, ctx, modifiers);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (entry.container === 'group' && !isDynamicVariant(node.props)) {
|
|
420
|
+
const resolved = resolvedVisualProps(node.name, node.props, ctx);
|
|
421
|
+
const display = resolved.display;
|
|
422
|
+
const gridCols = resolved.gridTemplateColumns;
|
|
423
|
+
const flexDir = resolved.flexDirection;
|
|
424
|
+
|
|
425
|
+
if (display === 'grid' && gridCols) {
|
|
426
|
+
const parsed = parseGridColumns(gridCols);
|
|
427
|
+
if (parsed?.kind === 'adaptive') {
|
|
428
|
+
return renderGridAdaptive(node, level, itemScope, renderNested, ctx, modifiers, parsed.minWidth);
|
|
429
|
+
}
|
|
430
|
+
if (parsed?.kind === 'fixed-columns') {
|
|
431
|
+
return renderGridFixedColumns(node, level, itemScope, renderNested, ctx, modifiers, parsed.widths);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (display === 'flex' && flexDir === 'row') {
|
|
436
|
+
const childBlock = renderChildren(node.children, level + 2, renderNested, ctx, itemScope);
|
|
437
|
+
const gapLit = propLiteral(node.props, 'gap');
|
|
438
|
+
const gap = typeof gapLit === 'number' ? gapLit : (typeof resolved.gap === 'number' ? resolved.gap : 0);
|
|
439
|
+
const inner = `${indent(level + 1)}HStack(alignment: .top, spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
440
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, alignment: .leading)'], level + 1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (entry.container) {
|
|
445
|
+
return renderContainer(node, entry, level, itemScope, renderNested, ctx, modifiers);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
throw new Error(`Unsupported SwiftUI element config for ${node.name}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function renderGridAdaptive(
|
|
452
|
+
node: IrComponentNode, level: number, itemScope: string | undefined,
|
|
453
|
+
renderNested: NodeRenderer, ctx: SwiftUIRenderContext, modifiers: string[], minWidth: number,
|
|
454
|
+
): string {
|
|
455
|
+
const gapLit = propLiteral(node.props, 'gap');
|
|
456
|
+
const gap = typeof gapLit === 'number' ? gapLit : 16;
|
|
457
|
+
const childBlock = renderChildren(node.children, level + 2, renderNested, ctx, itemScope);
|
|
458
|
+
const inner = `${indent(level + 1)}LazyVGrid(columns: [GridItem(.adaptive(minimum: ${minWidth}))], spacing: ${gap}) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
459
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity)'], level + 1);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function resolveBackgroundColor(child: IrNode, ctx: SwiftUIRenderContext): string | null {
|
|
463
|
+
if (child.kind !== 'component') return null;
|
|
464
|
+
const resolved = resolvedVisualProps(child.name, (child as IrComponentNode).props, ctx);
|
|
465
|
+
const bg = resolved.background;
|
|
466
|
+
if (typeof bg !== 'string' || bg === 'transparent') return null;
|
|
467
|
+
const hex = bg.match(/^#([0-9a-fA-F]{6})$/);
|
|
468
|
+
if (hex) {
|
|
469
|
+
const r = parseInt(hex[1]!.slice(0, 2), 16) / 255;
|
|
470
|
+
const g = parseInt(hex[1]!.slice(2, 4), 16) / 255;
|
|
471
|
+
const b = parseInt(hex[1]!.slice(4, 6), 16) / 255;
|
|
472
|
+
return `Color(red: ${r}, green: ${g}, blue: ${b})`;
|
|
473
|
+
}
|
|
474
|
+
return `Color(${JSON.stringify(bg)})`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function renderGridFixedColumns(
|
|
478
|
+
node: IrComponentNode, level: number, itemScope: string | undefined,
|
|
479
|
+
renderNested: NodeRenderer, ctx: SwiftUIRenderContext, modifiers: string[], widths: string[],
|
|
480
|
+
): string {
|
|
481
|
+
const children = node.children;
|
|
482
|
+
const parts: string[] = [];
|
|
483
|
+
for (let i = 0; i < children.length; i++) {
|
|
484
|
+
const child = children[i]!;
|
|
485
|
+
let rendered = '';
|
|
486
|
+
if (child.kind === 'map') rendered = renderMap(child, level + 2, renderNested, ctx);
|
|
487
|
+
else if (child.kind === 'text') rendered = `${indent(level + 2)}Text(${JSON.stringify((child as IrTextNode).value)})`;
|
|
488
|
+
else if (child.kind === 'binding') rendered = `${indent(level + 2)}Text(${bindingExpr(child as IrBinding, ctx, itemScope)})`;
|
|
489
|
+
else rendered = renderNested(child, level + 2, itemScope);
|
|
490
|
+
if (!rendered) continue;
|
|
491
|
+
const w = widths[i];
|
|
492
|
+
const pxMatch = w?.match(/^(\d+)px$/);
|
|
493
|
+
if (pxMatch) {
|
|
494
|
+
rendered += `\n${indent(level + 2)}.frame(width: ${Number(pxMatch[1])}, alignment: .topLeading)`;
|
|
495
|
+
rendered += `\n${indent(level + 2)}.frame(maxHeight: .infinity)`;
|
|
496
|
+
const bgColor = resolveBackgroundColor(child, ctx);
|
|
497
|
+
if (bgColor) rendered += `\n${indent(level + 2)}.background(${bgColor})`;
|
|
498
|
+
} else if (w === '1fr') {
|
|
499
|
+
rendered = `${indent(level + 2)}ScrollView {\n${rendered}\n${indent(level + 2)}}`;
|
|
500
|
+
rendered += `\n${indent(level + 2)}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)`;
|
|
501
|
+
}
|
|
502
|
+
parts.push(rendered);
|
|
503
|
+
}
|
|
504
|
+
const childBlock = parts.join('\n');
|
|
505
|
+
const inner = `${indent(level + 1)}HStack(spacing: 0) {\n${childBlock}\n${indent(level + 1)}}`;
|
|
506
|
+
return applyModifiers(inner, [...modifiers, '.frame(maxWidth: .infinity, maxHeight: .infinity)'], level + 1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function renderViewRef(node: IrViewRefNode, level: number, itemScope: string | undefined, ctx: SwiftUIRenderContext): string {
|
|
510
|
+
const props = Object.entries(node.props).map(([key, value]) => {
|
|
511
|
+
if (typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'binding') {
|
|
512
|
+
return `${key}: ${bindingExpr(value as IrBinding, ctx, itemScope)}`;
|
|
513
|
+
}
|
|
514
|
+
return `${key}: ${JSON.stringify(value)}`;
|
|
515
|
+
});
|
|
516
|
+
const argStr = props.length > 0 ? `(${props.join(', ')})` : '()';
|
|
517
|
+
return `${indent(level)}${node.name}${argStr}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function createSwiftUINodeRenderer(ctx: SwiftUIRenderContext): NodeRenderer {
|
|
521
|
+
const renderNested: NodeRenderer = (node, level, itemScope) => {
|
|
522
|
+
if (node.kind === 'viewRef') {
|
|
523
|
+
return renderViewRef(node, level, itemScope, ctx);
|
|
524
|
+
}
|
|
525
|
+
if (node.kind !== 'component') {
|
|
526
|
+
return '';
|
|
527
|
+
}
|
|
528
|
+
if (!ctx.map.elements[node.name]) {
|
|
529
|
+
throw new Error(`Element "${node.name}" is not in SwiftUI element map (prototype subset)`);
|
|
530
|
+
}
|
|
531
|
+
return renderComponent(node, ctx.map.elements[node.name]!, level, itemScope, renderNested, ctx);
|
|
532
|
+
};
|
|
533
|
+
return renderNested;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function lowerIrToSwiftBody(root: IrNode, ctx: SwiftUIRenderContext, level = 2): string {
|
|
537
|
+
const render = createSwiftUINodeRenderer(ctx);
|
|
538
|
+
if (root.kind !== 'component') {
|
|
539
|
+
throw new Error('SwiftUI lowering root must be a component node');
|
|
540
|
+
}
|
|
541
|
+
return render(root, level);
|
|
542
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { VisualPropSet } from '../../../src/renderer/style/mergeVisualProps.js';
|
|
2
|
+
|
|
3
|
+
function parsePx(value: string | number | boolean): number | null {
|
|
4
|
+
if (typeof value === 'number') return value;
|
|
5
|
+
if (typeof value === 'boolean') return null;
|
|
6
|
+
const match = value.match(/^([\d.]+)px$/);
|
|
7
|
+
return match ? Number(match[1]) : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseHexColor(value: string): string | null {
|
|
11
|
+
const match = value.match(/^#([0-9a-fA-F]{6})$/);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
const hex = match[1]!;
|
|
14
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
15
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
16
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
17
|
+
return `Color(red: ${r / 255}, green: ${g / 255}, blue: ${b / 255})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type PaddingResult =
|
|
21
|
+
| { kind: 'uniform'; value: number }
|
|
22
|
+
| { kind: 'shorthand'; vertical: number; horizontal: number };
|
|
23
|
+
|
|
24
|
+
function parsePaddingShorthand(value: string | number | boolean): PaddingResult | null {
|
|
25
|
+
const single = parsePx(value);
|
|
26
|
+
if (single !== null) return { kind: 'uniform', value: single };
|
|
27
|
+
if (typeof value !== 'string') return null;
|
|
28
|
+
const parts = value.trim().split(/\s+/);
|
|
29
|
+
if (parts.length === 2) {
|
|
30
|
+
const v = parsePx(parts[0]!);
|
|
31
|
+
const h = parsePx(parts[1]!);
|
|
32
|
+
if (v !== null && h !== null) return { kind: 'shorthand', vertical: v, horizontal: h };
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function swiftFontSize(value: string | number | boolean): string | null {
|
|
38
|
+
if (value === '' || value === null || value === undefined) return null;
|
|
39
|
+
const px = parsePx(value);
|
|
40
|
+
if (px !== null) return `.font(.system(size: ${px}))`;
|
|
41
|
+
if (typeof value === 'string' && !value.endsWith('px')) return `.font(.system(size: ${JSON.stringify(value)}))`;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function swiftFontWeight(value: string | number | boolean): string | null {
|
|
46
|
+
if (typeof value !== 'string') return null;
|
|
47
|
+
const map: Record<string, string> = {
|
|
48
|
+
bold: '.fontWeight(.bold)',
|
|
49
|
+
semibold: '.fontWeight(.semibold)',
|
|
50
|
+
medium: '.fontWeight(.medium)',
|
|
51
|
+
regular: '.fontWeight(.regular)',
|
|
52
|
+
};
|
|
53
|
+
return map[value] ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert merged DSL visual props to a single SwiftUI modifier chain.
|
|
58
|
+
*
|
|
59
|
+
* Correct SwiftUI order:
|
|
60
|
+
* foreground / font → padding → background → clipShape → shadow → overlay → frame → opacity
|
|
61
|
+
*/
|
|
62
|
+
export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
|
|
63
|
+
const modifiers: string[] = [];
|
|
64
|
+
|
|
65
|
+
// 1. Text appearance (inherited, applies to child Text views)
|
|
66
|
+
const foreground = merged.foreground;
|
|
67
|
+
if (typeof foreground === 'string') {
|
|
68
|
+
const color = parseHexColor(foreground);
|
|
69
|
+
modifiers.push(color ? `.foregroundStyle(${color})` : `.foregroundStyle(Color(${JSON.stringify(foreground)}))`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sizeValue = merged.size ?? merged.fontSize;
|
|
73
|
+
if (sizeValue !== undefined && sizeValue !== '') {
|
|
74
|
+
const fontSize = swiftFontSize(sizeValue);
|
|
75
|
+
if (fontSize) modifiers.push(fontSize);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fontWeight = swiftFontWeight(merged.fontWeight ?? merged.weight ?? '');
|
|
79
|
+
if (fontWeight) modifiers.push(fontWeight);
|
|
80
|
+
|
|
81
|
+
// 2. Padding (expands the "content area" that background fills)
|
|
82
|
+
const paddingRaw = merged.padding ?? '';
|
|
83
|
+
const paddingParsed = parsePaddingShorthand(paddingRaw);
|
|
84
|
+
if (paddingParsed) {
|
|
85
|
+
if (paddingParsed.kind === 'uniform') {
|
|
86
|
+
modifiers.push(`.padding(${paddingParsed.value})`);
|
|
87
|
+
} else {
|
|
88
|
+
modifiers.push(`.padding(.vertical, ${paddingParsed.vertical})`);
|
|
89
|
+
modifiers.push(`.padding(.horizontal, ${paddingParsed.horizontal})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Background (fills the padded area)
|
|
94
|
+
const background = merged.background;
|
|
95
|
+
if (typeof background === 'string' && background !== 'transparent') {
|
|
96
|
+
const color = parseHexColor(background);
|
|
97
|
+
modifiers.push(color ? `.background(${color})` : `.background(Color(${JSON.stringify(background)}))`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 4. Clip shape (clips background + content to rounded rect)
|
|
101
|
+
const radius = parsePx(merged.radius ?? '');
|
|
102
|
+
if (radius !== null) modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${radius}))`);
|
|
103
|
+
|
|
104
|
+
// 5. Shadow (rendered on the clipped shape)
|
|
105
|
+
if (merged.shadow === true || merged.shadow === 'true') {
|
|
106
|
+
modifiers.push('.shadow(color: Color.black.opacity(0.08), radius: 4, x: 0, y: 2)');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 6. Border overlay
|
|
110
|
+
const border = merged.border;
|
|
111
|
+
const borderColor = merged.borderColor;
|
|
112
|
+
if (border || borderColor) {
|
|
113
|
+
const borderWidth = parsePx(border ?? '1px') ?? 1;
|
|
114
|
+
const colorExpr = typeof borderColor === 'string' ? parseHexColor(borderColor) ?? 'Color.gray' : 'Color.gray';
|
|
115
|
+
modifiers.push(`.overlay(RoundedRectangle(cornerRadius: ${radius ?? 0}).stroke(${colorExpr}, lineWidth: ${borderWidth}))`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 7. Frame constraints
|
|
119
|
+
const minHeight = merged.minHeight;
|
|
120
|
+
if (minHeight !== undefined) {
|
|
121
|
+
const px = parsePx(minHeight);
|
|
122
|
+
if (px !== null) modifiers.push(`.frame(minHeight: ${px})`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const maxWidth = merged.maxWidth;
|
|
126
|
+
if (maxWidth !== undefined) {
|
|
127
|
+
const px = parsePx(maxWidth);
|
|
128
|
+
if (px !== null) modifiers.push(`.frame(maxWidth: ${px})`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 8. Opacity (last — affects everything)
|
|
132
|
+
const opacity = merged.opacity;
|
|
133
|
+
if (typeof opacity === 'number' && opacity < 1) modifiers.push(`.opacity(${opacity})`);
|
|
134
|
+
|
|
135
|
+
return modifiers;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function applyModifiers(body: string, modifiers: string[], indent: number): string {
|
|
139
|
+
if (modifiers.length === 0) return body;
|
|
140
|
+
const pad = ' '.repeat(indent);
|
|
141
|
+
return `${body}\n${modifiers.map((mod) => `${pad}${mod}`).join('\n')}`;
|
|
142
|
+
}
|