view-contracts 0.3.5 → 0.4.1
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 +40 -0
- package/dist/preview-style-helpers.js +2 -0
- package/dist/preview-style-helpers.js.map +7 -0
- package/dist/preview.mjs +152 -0
- package/dist/preview.mjs.map +7 -0
- package/dist/view-contracts.bundle.mjs +457 -248
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +36 -0
- package/package.json +28 -1
- package/renderers/compose/renderRuntime.ts +40 -0
- package/renderers/react/defaults/theme.yaml +52 -0
- package/renderers/react/defaults/tokens.yaml +76 -1
- 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 +21 -40
- package/renderers/react/paths.ts +5 -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/runtime/structural.css +38 -0
- package/renderers/react/style/foldLiterals.ts +25 -4
- 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 +761 -0
- package/renderers/swiftui/lowering/modifiers.ts +221 -0
- package/renderers/swiftui/renderComponents.ts +79 -0
- package/renderers/swiftui/renderRuntime.ts +139 -0
- package/renderers/swiftui/renderTheme.ts +274 -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/renderers/react/runtime/theme.css +0 -65
- 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,221 @@
|
|
|
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
|
+
'700': '.fontWeight(.bold)',
|
|
53
|
+
'600': '.fontWeight(.semibold)',
|
|
54
|
+
'500': '.fontWeight(.medium)',
|
|
55
|
+
'400': '.fontWeight(.regular)',
|
|
56
|
+
};
|
|
57
|
+
return map[value] ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert merged DSL visual props to a single SwiftUI modifier chain.
|
|
62
|
+
*
|
|
63
|
+
* SwiftUI modifier order for fill elements:
|
|
64
|
+
* foreground / font → padding → frame(fill) → background → clipShape → shadow → overlay → frame(fixed) → opacity
|
|
65
|
+
*
|
|
66
|
+
* For non-fill elements:
|
|
67
|
+
* foreground / font → padding → background → clipShape → shadow → overlay → frame → opacity
|
|
68
|
+
*
|
|
69
|
+
* The key insight: .frame(maxWidth: .infinity) MUST come BEFORE .background()
|
|
70
|
+
* so the background fills the expanded area. Otherwise the background only
|
|
71
|
+
* covers the natural content width.
|
|
72
|
+
*/
|
|
73
|
+
export function visualPropSetToModifiers(merged: VisualPropSet): string[] {
|
|
74
|
+
const modifiers: string[] = [];
|
|
75
|
+
|
|
76
|
+
// 1. Text appearance (inherited, applies to child Text views)
|
|
77
|
+
const foreground = merged.foreground;
|
|
78
|
+
if (typeof foreground === 'string') {
|
|
79
|
+
const color = parseHexColor(foreground);
|
|
80
|
+
modifiers.push(color ? `.foregroundStyle(${color})` : `.foregroundStyle(Color(${JSON.stringify(foreground)}))`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sizeValue = merged.size ?? merged.fontSize;
|
|
84
|
+
if (sizeValue !== undefined && sizeValue !== '') {
|
|
85
|
+
const fontSize = swiftFontSize(sizeValue);
|
|
86
|
+
if (fontSize) modifiers.push(fontSize);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fontWeight = swiftFontWeight(merged.fontWeight ?? merged.weight ?? '');
|
|
90
|
+
if (fontWeight) modifiers.push(fontWeight);
|
|
91
|
+
|
|
92
|
+
// 2. Padding (expands the "content area" that background fills)
|
|
93
|
+
const paddingRaw = merged.padding ?? '';
|
|
94
|
+
const paddingParsed = parsePaddingShorthand(paddingRaw);
|
|
95
|
+
if (paddingParsed) {
|
|
96
|
+
if (paddingParsed.kind === 'uniform') {
|
|
97
|
+
modifiers.push(`.padding(${paddingParsed.value})`);
|
|
98
|
+
} else {
|
|
99
|
+
modifiers.push(`.padding(.vertical, ${paddingParsed.vertical})`);
|
|
100
|
+
modifiers.push(`.padding(.horizontal, ${paddingParsed.horizontal})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Pre-compute frame/size values needed to determine fill vs fixed
|
|
105
|
+
const width = merged.width;
|
|
106
|
+
const height = merged.height;
|
|
107
|
+
const minHeight = merged.minHeight;
|
|
108
|
+
const maxWidth = merged.maxWidth;
|
|
109
|
+
const maxHeight = merged.maxHeight;
|
|
110
|
+
const margin = merged.margin;
|
|
111
|
+
const hasCenteringMargin = margin === '0 auto';
|
|
112
|
+
|
|
113
|
+
const needsFillWidth = width === '100%' || width === 'fill' || maxWidth === 'fill';
|
|
114
|
+
const needsFillHeight = height === '100%' || height === 'fill' || minHeight === '100vh';
|
|
115
|
+
|
|
116
|
+
// 2.5. Fill frame constraints — BEFORE background so bg fills the expanded area
|
|
117
|
+
// alignment: .leading/.top/.topLeading ensures content stays at top-left (matching CSS block behavior)
|
|
118
|
+
const maxWidthPx = maxWidth === 'fill' ? null : (maxWidth !== undefined ? parsePx(maxWidth) : null);
|
|
119
|
+
|
|
120
|
+
if (needsFillWidth && needsFillHeight) {
|
|
121
|
+
if (maxWidthPx !== null) {
|
|
122
|
+
modifiers.push(`.frame(maxWidth: ${maxWidthPx}, maxHeight: .infinity, alignment: .topLeading)`);
|
|
123
|
+
} else {
|
|
124
|
+
modifiers.push('.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)');
|
|
125
|
+
}
|
|
126
|
+
} else if (needsFillWidth) {
|
|
127
|
+
if (maxWidthPx !== null) {
|
|
128
|
+
modifiers.push(`.frame(maxWidth: ${maxWidthPx}, alignment: .leading)`);
|
|
129
|
+
} else {
|
|
130
|
+
modifiers.push('.frame(maxWidth: .infinity, alignment: .leading)');
|
|
131
|
+
}
|
|
132
|
+
} else if (needsFillHeight) {
|
|
133
|
+
modifiers.push('.frame(maxHeight: .infinity, alignment: .top)');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!needsFillHeight && minHeight === '100vh') {
|
|
137
|
+
modifiers.push('.frame(maxHeight: .infinity, alignment: .top)');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. Background (fills the frame area — now expanded for fill elements)
|
|
141
|
+
const background = merged.background;
|
|
142
|
+
if (typeof background === 'string' && background !== 'transparent') {
|
|
143
|
+
const color = parseHexColor(background);
|
|
144
|
+
modifiers.push(color ? `.background(${color})` : `.background(Color(${JSON.stringify(background)}))`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4. Clip shape (clips background + content to rounded rect)
|
|
148
|
+
const radius = parsePx(merged.radius ?? '');
|
|
149
|
+
if (radius !== null) modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${radius}))`);
|
|
150
|
+
|
|
151
|
+
// 5. Shadow (rendered on the clipped shape)
|
|
152
|
+
if (merged.shadow === true || merged.shadow === 'true') {
|
|
153
|
+
modifiers.push('.shadow(color: Color.black.opacity(0.08), radius: 4, x: 0, y: 2)');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 6. Border overlay
|
|
157
|
+
const border = merged.border;
|
|
158
|
+
const borderColor = merged.borderColor;
|
|
159
|
+
if (border || borderColor) {
|
|
160
|
+
const borderWidth = parsePx(border ?? '1px') ?? 1;
|
|
161
|
+
const colorExpr = typeof borderColor === 'string' ? parseHexColor(borderColor) ?? 'Color.gray' : 'Color.gray';
|
|
162
|
+
modifiers.push(`.overlay(RoundedRectangle(cornerRadius: ${radius ?? 0}).stroke(${colorExpr}, lineWidth: ${borderWidth}))`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 7. Non-fill frame constraints (fixed sizes, maxWidth cap without fill)
|
|
166
|
+
if (!needsFillWidth) {
|
|
167
|
+
if (typeof width === 'number') {
|
|
168
|
+
modifiers.push(`.frame(width: ${width})`);
|
|
169
|
+
} else if (width !== undefined && width !== 'wrap') {
|
|
170
|
+
const px = parsePx(width);
|
|
171
|
+
if (px !== null) modifiers.push(`.frame(width: ${px})`);
|
|
172
|
+
}
|
|
173
|
+
if (maxWidth !== undefined && maxWidth !== 'fill') {
|
|
174
|
+
const px = parsePx(maxWidth);
|
|
175
|
+
if (px !== null) modifiers.push(`.frame(maxWidth: ${px})`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!needsFillHeight) {
|
|
180
|
+
if (typeof height === 'number') {
|
|
181
|
+
modifiers.push(`.frame(height: ${height})`);
|
|
182
|
+
} else if (height !== undefined && height !== 'wrap') {
|
|
183
|
+
const px = parsePx(height);
|
|
184
|
+
if (px !== null) modifiers.push(`.frame(height: ${px})`);
|
|
185
|
+
}
|
|
186
|
+
if (minHeight !== undefined && minHeight !== '100vh') {
|
|
187
|
+
if (typeof minHeight === 'number') {
|
|
188
|
+
modifiers.push(`.frame(minHeight: ${minHeight})`);
|
|
189
|
+
} else {
|
|
190
|
+
const px = parsePx(minHeight);
|
|
191
|
+
if (px !== null) modifiers.push(`.frame(minHeight: ${px})`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (maxHeight !== undefined && maxHeight !== 'fill') {
|
|
195
|
+
const px = parsePx(maxHeight);
|
|
196
|
+
if (px !== null) modifiers.push(`.frame(maxHeight: ${px})`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// margin: 0 auto → center within parent by expanding outer frame (positioning, not sizing)
|
|
201
|
+
if (hasCenteringMargin) {
|
|
202
|
+
modifiers.push('.frame(maxWidth: .infinity, alignment: .center)');
|
|
203
|
+
} else if (typeof margin === 'number') {
|
|
204
|
+
modifiers.push(`.padding(${margin})`);
|
|
205
|
+
} else if (margin !== undefined) {
|
|
206
|
+
const px = parsePx(margin);
|
|
207
|
+
if (px !== null) modifiers.push(`.padding(${px})`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 8. Opacity (last — affects everything)
|
|
211
|
+
const opacity = merged.opacity;
|
|
212
|
+
if (typeof opacity === 'number' && opacity < 1) modifiers.push(`.opacity(${opacity})`);
|
|
213
|
+
|
|
214
|
+
return modifiers;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function applyModifiers(body: string, modifiers: string[], indent: number): string {
|
|
218
|
+
if (modifiers.length === 0) return body;
|
|
219
|
+
const pad = ' '.repeat(indent);
|
|
220
|
+
return `${body}\n${modifiers.map((mod) => `${pad}${mod}`).join('\n')}`;
|
|
221
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ViewIrDocument } from '../../src/ir/types.js';
|
|
2
|
+
import type { ThemeIR, TokenIR } from '../../src/design/types.js';
|
|
3
|
+
import { loadSwiftUIElementMap } from './elementMap.js';
|
|
4
|
+
import { lowerIrToSwiftBody, type SwiftUIRenderContext } from './lowering/lowerToSwiftUI.js';
|
|
5
|
+
|
|
6
|
+
export interface RenderSwiftOptions {
|
|
7
|
+
theme: ThemeIR;
|
|
8
|
+
tokens: TokenIR;
|
|
9
|
+
allowlistExtra?: string[];
|
|
10
|
+
elementMapPath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tsTypeToSwift(type: string): string {
|
|
14
|
+
const arrayMatch = type.match(/^(\w+)\[\]$/);
|
|
15
|
+
if (arrayMatch) return `[${tsTypeToSwift(arrayMatch[1]!)}]`;
|
|
16
|
+
if (type === 'string') return 'String';
|
|
17
|
+
if (type === 'number') return 'Double';
|
|
18
|
+
if (type === 'boolean') return 'Bool';
|
|
19
|
+
return type;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function partialPropsLines(doc: ViewIrDocument): string {
|
|
23
|
+
if (!doc.propsType) return '';
|
|
24
|
+
const match = doc.propsType.match(/^\{\s*([^}]+)\s*\}$/);
|
|
25
|
+
if (!match) return ` let props: ${doc.propsType}\n`;
|
|
26
|
+
const inner = match[1].trim();
|
|
27
|
+
const lines = inner.split(',').map((part) => {
|
|
28
|
+
const colon = part.indexOf(':');
|
|
29
|
+
const name = colon >= 0 ? part.slice(0, colon).trim() : part.trim();
|
|
30
|
+
const rawType = colon >= 0 ? part.slice(colon + 1).trim() : 'Any';
|
|
31
|
+
const type = tsTypeToSwift(rawType);
|
|
32
|
+
return ` let ${name}: ${type}`;
|
|
33
|
+
});
|
|
34
|
+
return `${lines.join('\n')}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function propsRootFromDoc(doc: ViewIrDocument): string | undefined {
|
|
38
|
+
if (!doc.propsType) return undefined;
|
|
39
|
+
const match = doc.propsType.match(/^\{\s*([^}]+)\s*\}$/);
|
|
40
|
+
if (!match) return undefined;
|
|
41
|
+
const first = match[1].split(',')[0]!.split(':')[0]!.trim();
|
|
42
|
+
return first;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function renderSwiftComponent(
|
|
46
|
+
doc: ViewIrDocument,
|
|
47
|
+
options: RenderSwiftOptions,
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
const map = await loadSwiftUIElementMap(options.elementMapPath);
|
|
50
|
+
const allowlistExtra = options.allowlistExtra ?? [];
|
|
51
|
+
const structName = doc.isPartial ? doc.exportName : doc.exportName.replace(/View$/, '') || doc.exportName;
|
|
52
|
+
|
|
53
|
+
const ctx: SwiftUIRenderContext = {
|
|
54
|
+
map,
|
|
55
|
+
theme: options.theme,
|
|
56
|
+
tokens: options.tokens,
|
|
57
|
+
allowlistExtra,
|
|
58
|
+
propsRoot: propsRootFromDoc(doc),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const body = lowerIrToSwiftBody(doc.root, ctx, 4);
|
|
62
|
+
const propsLines = partialPropsLines(doc);
|
|
63
|
+
const vmLine = !doc.isPartial && doc.viewModel ? ` let vm: ${doc.viewModel}\n` : '';
|
|
64
|
+
|
|
65
|
+
const header = `/**
|
|
66
|
+
* Auto-generated SwiftUI view from ${doc.source}
|
|
67
|
+
* DO NOT EDIT — prototype SwiftUI lowering (#21)
|
|
68
|
+
*/
|
|
69
|
+
import SwiftUI
|
|
70
|
+
|
|
71
|
+
struct ${structName}: View {
|
|
72
|
+
${vmLine}${propsLines} var body: some View {
|
|
73
|
+
${body}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
return header;
|
|
79
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { ViewBindingsFile, ViewContractsConfig } from '../../src/config.js';
|
|
4
|
+
import { packageRoot } from '../../src/lib/packageRoot.js';
|
|
5
|
+
import { renderTemplate } from '../../src/renderer/template.js';
|
|
6
|
+
import type { ThemeIR, TokenIR } from '../../src/design/types.js';
|
|
7
|
+
import type { ViewIrDocument } from '../../src/ir/types.js';
|
|
8
|
+
import { renderSwiftTheme } from './renderTheme.js';
|
|
9
|
+
import { renderSwiftComponent } from './renderComponents.js';
|
|
10
|
+
import { loadPreviewMockYaml, previewMockFilePath, pathExists } from '../../src/preview/previewMock.js';
|
|
11
|
+
|
|
12
|
+
const FLOW_LAYOUT_SWIFT = `import SwiftUI
|
|
13
|
+
|
|
14
|
+
/// CSS flex-wrap equivalent: wraps children to next line when horizontal space runs out.
|
|
15
|
+
struct FlowLayout: Layout {
|
|
16
|
+
var spacing: CGFloat = 8
|
|
17
|
+
|
|
18
|
+
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
19
|
+
let containerWidth = proposal.width ?? .infinity
|
|
20
|
+
var x: CGFloat = 0
|
|
21
|
+
var y: CGFloat = 0
|
|
22
|
+
var rowHeight: CGFloat = 0
|
|
23
|
+
|
|
24
|
+
for subview in subviews {
|
|
25
|
+
let size = subview.sizeThatFits(.unspecified)
|
|
26
|
+
if x + size.width > containerWidth && x > 0 {
|
|
27
|
+
y += rowHeight + spacing
|
|
28
|
+
x = 0
|
|
29
|
+
rowHeight = 0
|
|
30
|
+
}
|
|
31
|
+
x += size.width + spacing
|
|
32
|
+
rowHeight = max(rowHeight, size.height)
|
|
33
|
+
}
|
|
34
|
+
return CGSize(width: containerWidth, height: y + rowHeight)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
38
|
+
var x = bounds.minX
|
|
39
|
+
var y = bounds.minY
|
|
40
|
+
var rowHeight: CGFloat = 0
|
|
41
|
+
|
|
42
|
+
for subview in subviews {
|
|
43
|
+
let size = subview.sizeThatFits(.unspecified)
|
|
44
|
+
if x + size.width > bounds.maxX && x > bounds.minX {
|
|
45
|
+
y += rowHeight + spacing
|
|
46
|
+
x = bounds.minX
|
|
47
|
+
rowHeight = 0
|
|
48
|
+
}
|
|
49
|
+
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
|
50
|
+
x += size.width + spacing
|
|
51
|
+
rowHeight = max(rowHeight, size.height)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
function swiftuiTemplatesDir(): string {
|
|
58
|
+
return resolve(packageRoot(), 'renderers/swiftui/templates');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function renderSwiftUIRuntime(
|
|
62
|
+
configPath: string,
|
|
63
|
+
config: ViewContractsConfig,
|
|
64
|
+
bindings: ViewBindingsFile,
|
|
65
|
+
design?: { theme: ThemeIR; tokens: TokenIR },
|
|
66
|
+
compiledDocs?: { screens: Map<string, ViewIrDocument>; partials: ViewIrDocument[] },
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const swiftuiDir = resolve(config.generated.reactDir, '../swiftui');
|
|
69
|
+
await mkdir(swiftuiDir, { recursive: true });
|
|
70
|
+
|
|
71
|
+
if (design) {
|
|
72
|
+
await writeFile(resolve(swiftuiDir, 'VcTheme.swift'), renderSwiftTheme(design.theme, design.tokens), 'utf-8');
|
|
73
|
+
} else {
|
|
74
|
+
await writeFile(
|
|
75
|
+
resolve(swiftuiDir, 'VcTheme.swift'),
|
|
76
|
+
await renderTemplate(swiftuiTemplatesDir(), 'VcTheme.swift.hbs', {}),
|
|
77
|
+
'utf-8',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// FlowLayout helper (CSS flex-wrap equivalent)
|
|
82
|
+
await writeFile(resolve(swiftuiDir, 'FlowLayout.swift'), FLOW_LAYOUT_SWIFT, 'utf-8');
|
|
83
|
+
|
|
84
|
+
if (compiledDocs && design) {
|
|
85
|
+
for (const partial of compiledDocs.partials) {
|
|
86
|
+
try {
|
|
87
|
+
const swift = await renderSwiftComponent(partial, { theme: design.theme, tokens: design.tokens });
|
|
88
|
+
const out = resolve(swiftuiDir, `${partial.exportName}.generated.swift`);
|
|
89
|
+
await writeFile(out, swift, 'utf-8');
|
|
90
|
+
console.log(` swiftui/${partial.exportName} → ${out}`);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn(` swiftui/${partial.exportName}: skipped (${(err as Error).message})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const view of bindings.views) {
|
|
97
|
+
const doc = compiledDocs.screens.get(view.screenName);
|
|
98
|
+
if (doc) {
|
|
99
|
+
try {
|
|
100
|
+
const swift = await renderSwiftComponent(doc, { theme: design.theme, tokens: design.tokens });
|
|
101
|
+
const out = resolve(swiftuiDir, `${view.screenName}.generated.swift`);
|
|
102
|
+
await writeFile(out, swift, 'utf-8');
|
|
103
|
+
console.log(` swiftui/${view.screenName} → ${out}`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn(` swiftui/${view.screenName}: skipped (${(err as Error).message})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const view of bindings.views) {
|
|
112
|
+
if (view.targets.includes('swiftui')) {
|
|
113
|
+
const out = resolve(swiftuiDir, `${view.screenName}.swift`);
|
|
114
|
+
await writeFile(
|
|
115
|
+
out,
|
|
116
|
+
await renderTemplate(swiftuiTemplatesDir(), 'Screen.swift.hbs', {
|
|
117
|
+
screenName: view.screenName,
|
|
118
|
+
}),
|
|
119
|
+
'utf-8',
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Generate SampleData JSON from mock.yaml (SSoT)
|
|
125
|
+
for (const view of bindings.views) {
|
|
126
|
+
const mockPath = previewMockFilePath(configPath, config, view.screenName);
|
|
127
|
+
if (await pathExists(mockPath)) {
|
|
128
|
+
try {
|
|
129
|
+
const mockData = await loadPreviewMockYaml(mockPath);
|
|
130
|
+
const json = JSON.stringify(mockData, null, 2) + '\n';
|
|
131
|
+
const out = resolve(swiftuiDir, `${view.screenName}.SampleData.json`);
|
|
132
|
+
await writeFile(out, json, 'utf-8');
|
|
133
|
+
console.log(` swiftui/${view.screenName}.SampleData.json → ${out}`);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn(` swiftui/${view.screenName}.SampleData.json: skipped (${(err as Error).message})`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|