view-contracts 0.4.5 → 0.5.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/cli-contract.yaml +1 -1
- package/dist/preview.mjs +266 -153
- package/dist/preview.mjs.map +4 -4
- package/dist/view-contracts.bundle.mjs +308 -195
- package/dist/view-contracts.bundle.mjs.map +4 -4
- package/docs/cli-reference.md +1 -1
- package/package.json +1 -1
- package/renderers/compose/README.md +7 -8
- package/renderers/compose/element-config.meta.json +20 -0
- package/renderers/compose/elementMap.ts +44 -0
- package/renderers/compose/elements.yaml +82 -0
- package/renderers/compose/index.ts +5 -0
- package/renderers/compose/lowering/lowerToCompose.ts +1062 -0
- package/renderers/compose/lowering/modifiers.ts +449 -0
- package/renderers/compose/properties.yaml +34 -0
- package/renderers/compose/renderComponents.ts +118 -0
- package/renderers/compose/renderRuntime.ts +78 -13
- package/renderers/compose/renderTheme.ts +304 -0
- package/renderers/compose/routes.yaml +7 -0
- package/renderers/compose/templates/Screen.kt.hbs +3 -4
- package/renderers/react/routes.yaml +3 -2
- package/renderers/registry.ts +12 -1
|
@@ -0,0 +1,449 @@
|
|
|
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(${r}, ${g}, ${b})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseRgbaColor(value: string): string | null {
|
|
21
|
+
const match = value.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const r = Number(match[1]) / 255;
|
|
24
|
+
const g = Number(match[2]) / 255;
|
|
25
|
+
const b = Number(match[3]) / 255;
|
|
26
|
+
const a = Number(match[4]);
|
|
27
|
+
return `Color(${r}f, ${g}f, ${b}f, ${a}f)`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function composeColorExpr(value: string): string | null {
|
|
31
|
+
if (value === 'transparent') return 'Color.Transparent';
|
|
32
|
+
return parseHexColor(value) ?? parseRgbaColor(value) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type PaddingResult =
|
|
36
|
+
| { kind: 'uniform'; value: number }
|
|
37
|
+
| { kind: 'shorthand'; vertical: number; horizontal: number };
|
|
38
|
+
|
|
39
|
+
function parsePaddingShorthand(value: string | number | boolean): PaddingResult | null {
|
|
40
|
+
const single = parsePx(value);
|
|
41
|
+
if (single !== null) return { kind: 'uniform', value: single };
|
|
42
|
+
if (typeof value !== 'string') return null;
|
|
43
|
+
const parts = value.trim().split(/\s+/);
|
|
44
|
+
if (parts.length === 2) {
|
|
45
|
+
const v = parsePx(parts[0]!);
|
|
46
|
+
const h = parsePx(parts[1]!);
|
|
47
|
+
if (v !== null && h !== null) return { kind: 'shorthand', vertical: v, horizontal: h };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function composeFontWeight(value: string | number | boolean): string | null {
|
|
53
|
+
if (typeof value !== 'string') return null;
|
|
54
|
+
const map: Record<string, string> = {
|
|
55
|
+
bold: 'FontWeight.Bold',
|
|
56
|
+
semibold: 'FontWeight.SemiBold',
|
|
57
|
+
medium: 'FontWeight.Medium',
|
|
58
|
+
regular: 'FontWeight.Normal',
|
|
59
|
+
'700': 'FontWeight.Bold',
|
|
60
|
+
'600': 'FontWeight.SemiBold',
|
|
61
|
+
'500': 'FontWeight.Medium',
|
|
62
|
+
'400': 'FontWeight.Normal',
|
|
63
|
+
};
|
|
64
|
+
return map[value] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function composeTextAlign(value: string | number | boolean): string | null {
|
|
68
|
+
if (typeof value !== 'string') return null;
|
|
69
|
+
const map: Record<string, string> = {
|
|
70
|
+
start: 'TextAlign.Start',
|
|
71
|
+
center: 'TextAlign.Center',
|
|
72
|
+
end: 'TextAlign.End',
|
|
73
|
+
};
|
|
74
|
+
return map[value] ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function composeSelfAlign(value: string | number | boolean): string | null {
|
|
78
|
+
if (typeof value !== 'string') return null;
|
|
79
|
+
const map: Record<string, string> = {
|
|
80
|
+
start: 'Alignment.Start',
|
|
81
|
+
center: 'Alignment.CenterVertically',
|
|
82
|
+
end: 'Alignment.Bottom',
|
|
83
|
+
stretch: 'Alignment.CenterVertically',
|
|
84
|
+
};
|
|
85
|
+
return map[value] ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function composeContentAlignment(value: string): string {
|
|
89
|
+
const map: Record<string, string> = {
|
|
90
|
+
topStart: 'Alignment.TopStart',
|
|
91
|
+
top: 'Alignment.TopCenter',
|
|
92
|
+
topEnd: 'Alignment.TopEnd',
|
|
93
|
+
start: 'Alignment.CenterStart',
|
|
94
|
+
center: 'Alignment.Center',
|
|
95
|
+
end: 'Alignment.CenterEnd',
|
|
96
|
+
bottomStart: 'Alignment.BottomStart',
|
|
97
|
+
bottom: 'Alignment.BottomCenter',
|
|
98
|
+
bottomEnd: 'Alignment.BottomEnd',
|
|
99
|
+
};
|
|
100
|
+
return map[value] ?? 'Alignment.TopStart';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface VisualPropModifierOptions {
|
|
104
|
+
/** When true, text appearance props target the Text composable directly. */
|
|
105
|
+
isLeaf?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert merged DSL visual props to Compose modifier / text-behavior tags.
|
|
110
|
+
*
|
|
111
|
+
* Order for Compose (outside-in, opposite of SwiftUI):
|
|
112
|
+
* foreground/font → fillMax frame → shadow → background → clip → border → fixed sizes → padding → margin → opacity → text behavior
|
|
113
|
+
*/
|
|
114
|
+
export function visualPropSetToModifiers(
|
|
115
|
+
merged: VisualPropSet,
|
|
116
|
+
options: VisualPropModifierOptions = {},
|
|
117
|
+
): string[] {
|
|
118
|
+
const mods: string[] = [];
|
|
119
|
+
const isLeaf = options.isLeaf ?? false;
|
|
120
|
+
|
|
121
|
+
// 1. Text appearance (inherited or leaf-local)
|
|
122
|
+
const foreground = merged.foreground;
|
|
123
|
+
if (typeof foreground === 'string') {
|
|
124
|
+
const color = composeColorExpr(foreground);
|
|
125
|
+
if (color) {
|
|
126
|
+
mods.push(isLeaf ? `@textStyle(color = ${color})` : `@contentColor(${color})`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sizeValue = merged.size ?? merged.fontSize;
|
|
131
|
+
if (sizeValue !== undefined && sizeValue !== '') {
|
|
132
|
+
const px = typeof sizeValue === 'number' ? sizeValue : parsePx(sizeValue);
|
|
133
|
+
if (px !== null) {
|
|
134
|
+
mods.push(isLeaf ? `@textStyle(fontSize = ${px}.sp)` : `@inheritTextStyle(fontSize = ${px}.sp)`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fontWeight = composeFontWeight(merged.fontWeight ?? merged.weight ?? '');
|
|
139
|
+
if (fontWeight) {
|
|
140
|
+
mods.push(isLeaf ? `@textStyle(fontWeight = ${fontWeight})` : `@inheritTextStyle(fontWeight = ${fontWeight})`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const width = merged.width;
|
|
144
|
+
const height = merged.height;
|
|
145
|
+
const minWidth = merged.minWidth;
|
|
146
|
+
const minHeight = merged.minHeight;
|
|
147
|
+
const maxWidth = merged.maxWidth;
|
|
148
|
+
const maxHeight = merged.maxHeight;
|
|
149
|
+
const margin = merged.margin;
|
|
150
|
+
const hasCenteringMargin = margin === '0 auto';
|
|
151
|
+
|
|
152
|
+
const needsFillWidth = width === '100%' || width === 'fill' || maxWidth === 'fill';
|
|
153
|
+
const needsFillHeight = height === '100%' || height === 'fill' || minHeight === '100vh';
|
|
154
|
+
const maxWidthPx = maxWidth === 'fill' ? null : (maxWidth !== undefined ? parsePx(maxWidth) : null);
|
|
155
|
+
|
|
156
|
+
// 2. Fill frame constraints — BEFORE background so bg fills the expanded area
|
|
157
|
+
let addedFillHeight = false;
|
|
158
|
+
if (needsFillWidth && needsFillHeight) {
|
|
159
|
+
if (maxWidthPx !== null) {
|
|
160
|
+
mods.push(`.widthIn(max = ${maxWidthPx}.dp).fillMaxHeight()`);
|
|
161
|
+
} else {
|
|
162
|
+
mods.push('.fillMaxWidth().fillMaxHeight()');
|
|
163
|
+
}
|
|
164
|
+
addedFillHeight = true;
|
|
165
|
+
} else if (needsFillWidth) {
|
|
166
|
+
if (maxWidthPx !== null) {
|
|
167
|
+
mods.push(`.widthIn(max = ${maxWidthPx}.dp)`);
|
|
168
|
+
} else {
|
|
169
|
+
mods.push('.fillMaxWidth()');
|
|
170
|
+
}
|
|
171
|
+
} else if (needsFillHeight) {
|
|
172
|
+
mods.push('.fillMaxHeight()');
|
|
173
|
+
addedFillHeight = true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!addedFillHeight && minHeight === '100vh') {
|
|
177
|
+
mods.push('.fillMaxHeight()');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const radiusPx = parsePx(merged.radius ?? '');
|
|
181
|
+
|
|
182
|
+
// 3. Shadow (outermost decoration in Compose)
|
|
183
|
+
if (merged.shadow === true || merged.shadow === 'true') {
|
|
184
|
+
mods.push(`.shadow(elevation = 4.dp, shape = RoundedCornerShape(${radiusPx ?? 0}.dp), clip = false, ambientColor = Color.Black.copy(alpha = 0.08f), spotColor = Color.Black.copy(alpha = 0.08f))`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. Background
|
|
188
|
+
const background = merged.background;
|
|
189
|
+
if (typeof background === 'string' && background !== 'transparent') {
|
|
190
|
+
const color = composeColorExpr(background);
|
|
191
|
+
if (color && color !== 'Color.Transparent') {
|
|
192
|
+
mods.push(`.background(${color}, RoundedCornerShape(${radiusPx ?? 0}.dp))`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 5. Clip shape
|
|
197
|
+
const clipMode = merged.clip;
|
|
198
|
+
if (clipMode !== 'none' && radiusPx !== null) {
|
|
199
|
+
mods.push(`.clip(RoundedCornerShape(${radiusPx}.dp))`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 6. Border
|
|
203
|
+
const border = merged.border;
|
|
204
|
+
const borderColor = merged.borderColor;
|
|
205
|
+
if (border || borderColor) {
|
|
206
|
+
const borderWidth = parsePx(border ?? '1px') ?? 1;
|
|
207
|
+
const colorExpr = typeof borderColor === 'string' ? composeColorExpr(borderColor) ?? 'Color.Gray' : 'Color.Gray';
|
|
208
|
+
mods.push(`.border(${borderWidth}.dp, ${colorExpr}, RoundedCornerShape(${radiusPx ?? 0}.dp))`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 7. Non-fill frame constraints
|
|
212
|
+
if (!needsFillWidth) {
|
|
213
|
+
if (typeof width === 'number') {
|
|
214
|
+
mods.push(`.width(${width}.dp)`);
|
|
215
|
+
} else if (width !== undefined && width !== 'wrap') {
|
|
216
|
+
const px = parsePx(width);
|
|
217
|
+
if (px !== null) mods.push(`.width(${px}.dp)`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof minWidth === 'number') {
|
|
220
|
+
mods.push(`.widthIn(min = ${minWidth}.dp)`);
|
|
221
|
+
} else if (minWidth !== undefined) {
|
|
222
|
+
const px = parsePx(minWidth);
|
|
223
|
+
if (px !== null) mods.push(`.widthIn(min = ${px}.dp)`);
|
|
224
|
+
}
|
|
225
|
+
if (maxWidth !== undefined && maxWidth !== 'fill') {
|
|
226
|
+
const px = parsePx(maxWidth);
|
|
227
|
+
if (px !== null) mods.push(`.widthIn(max = ${px}.dp)`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!needsFillHeight) {
|
|
232
|
+
if (typeof height === 'number') {
|
|
233
|
+
mods.push(`.height(${height}.dp)`);
|
|
234
|
+
} else if (height !== undefined && height !== 'wrap') {
|
|
235
|
+
const px = parsePx(height);
|
|
236
|
+
if (px !== null) mods.push(`.height(${px}.dp)`);
|
|
237
|
+
}
|
|
238
|
+
if (minHeight !== undefined && minHeight !== '100vh') {
|
|
239
|
+
if (typeof minHeight === 'number') {
|
|
240
|
+
mods.push(`.heightIn(min = ${minHeight}.dp)`);
|
|
241
|
+
} else {
|
|
242
|
+
const px = parsePx(minHeight);
|
|
243
|
+
if (px !== null) mods.push(`.heightIn(min = ${px}.dp)`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (maxHeight !== undefined && maxHeight !== 'fill') {
|
|
247
|
+
const px = parsePx(maxHeight);
|
|
248
|
+
if (px !== null) mods.push(`.heightIn(max = ${px}.dp)`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (typeof merged.aspectRatio === 'number') {
|
|
253
|
+
mods.push(`.aspectRatio(${merged.aspectRatio}f)`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (typeof merged.weight === 'number') {
|
|
257
|
+
mods.push(`.weight(${merged.weight}f)`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 8. Padding (innermost in Compose)
|
|
261
|
+
const paddingRaw = merged.padding ?? '';
|
|
262
|
+
const paddingParsed = parsePaddingShorthand(paddingRaw);
|
|
263
|
+
if (paddingParsed) {
|
|
264
|
+
if (paddingParsed.kind === 'uniform') {
|
|
265
|
+
mods.push(`.padding(${paddingParsed.value}.dp)`);
|
|
266
|
+
} else {
|
|
267
|
+
mods.push(`.padding(horizontal = ${paddingParsed.horizontal}.dp, vertical = ${paddingParsed.vertical}.dp)`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 9. margin: 0 auto → center within parent
|
|
272
|
+
if (hasCenteringMargin) {
|
|
273
|
+
mods.push('@wrapContentCenter()');
|
|
274
|
+
} else if (typeof margin === 'number') {
|
|
275
|
+
mods.push(`.padding(${margin}.dp)`);
|
|
276
|
+
} else if (margin !== undefined && typeof margin === 'string') {
|
|
277
|
+
const px = parsePx(margin);
|
|
278
|
+
if (px !== null) mods.push(`.padding(${px}.dp)`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 8. Opacity
|
|
282
|
+
const opacity = merged.opacity;
|
|
283
|
+
if (typeof opacity === 'number' && opacity < 1) mods.push(`.alpha(${opacity}f)`);
|
|
284
|
+
|
|
285
|
+
// Text truncation / alignment (consumed by formatTextComposable or cellgroup)
|
|
286
|
+
const textAlign = composeTextAlign(merged.textAlign ?? '');
|
|
287
|
+
if (textAlign) mods.push(`@textAlign(${textAlign})`);
|
|
288
|
+
|
|
289
|
+
const lineLimit = merged.lineLimit;
|
|
290
|
+
if (lineLimit !== undefined && lineLimit !== 'none' && lineLimit !== '') {
|
|
291
|
+
const lines = typeof lineLimit === 'number' ? lineLimit : parsePx(lineLimit);
|
|
292
|
+
if (lines !== null) mods.push(`@textMaxLines(${lines})`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const textOverflow = merged.textOverflow;
|
|
296
|
+
if (textOverflow === 'ellipsis') {
|
|
297
|
+
mods.push('@textOverflow(TextOverflow.Ellipsis)');
|
|
298
|
+
} else if (textOverflow === 'clip') {
|
|
299
|
+
mods.push('@textOverflow(TextOverflow.Clip)');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const selfAlign = composeSelfAlign(merged.selfAlign ?? '');
|
|
303
|
+
if (selfAlign) mods.push(`.align(${selfAlign})`);
|
|
304
|
+
|
|
305
|
+
return mods;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const NON_MODIFIER_TAGS = [
|
|
309
|
+
'@textStyle(',
|
|
310
|
+
'@contentColor(',
|
|
311
|
+
'@inheritTextStyle(',
|
|
312
|
+
'@wrapContentCenter()',
|
|
313
|
+
'@textMaxLines(',
|
|
314
|
+
'@textOverflow(',
|
|
315
|
+
'@textAlign(',
|
|
316
|
+
'@textFillMaxWidth()',
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
export function stripLayoutFrameModifiers(mods: string[]): string[] {
|
|
320
|
+
return mods.filter((m) =>
|
|
321
|
+
!m.includes('.width(')
|
|
322
|
+
&& !m.includes('.widthIn(')
|
|
323
|
+
&& !m.includes('.height(')
|
|
324
|
+
&& !m.includes('.heightIn(')
|
|
325
|
+
&& !m.includes('.weight(')
|
|
326
|
+
&& m !== '.fillMaxWidth()'
|
|
327
|
+
&& m !== '.fillMaxHeight()'
|
|
328
|
+
&& !m.startsWith('.fillMaxWidth().')
|
|
329
|
+
&& !m.endsWith('.fillMaxWidth()')
|
|
330
|
+
&& !m.includes('.fillMaxHeight()'),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function stripBackgroundModifiers(mods: string[]): string[] {
|
|
335
|
+
return mods.filter((m) => !m.includes('.background('));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function stripPaddingModifiers(mods: string[]): string[] {
|
|
339
|
+
return mods.filter((m) => !m.includes('.padding('));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function stripClipModifiers(mods: string[]): string[] {
|
|
343
|
+
return mods.filter((m) => !m.includes('.clip(RoundedCornerShape('));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function extractPaddingModifiers(mods: string[]): string[] {
|
|
347
|
+
return mods.filter((m) => m.includes('.padding('));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function extractClipRadiusDp(mods: string[]): number | null {
|
|
351
|
+
const clip = mods.find((m) => m.includes('.clip(RoundedCornerShape('));
|
|
352
|
+
const match = clip?.match(/RoundedCornerShape\((\d+)\.dp\)/);
|
|
353
|
+
return match ? Number(match[1]) : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Convert extracted `.padding(...)` modifiers to a Compose PaddingValues expression. */
|
|
357
|
+
export function paddingModifiersToPaddingValues(mods: string[]): string | null {
|
|
358
|
+
if (mods.length === 0) return null;
|
|
359
|
+
const mod = mods[0]!;
|
|
360
|
+
const horizontalVertical = mod.match(/\.padding\(horizontal = (\d+)\.dp, vertical = (\d+)\.dp\)/);
|
|
361
|
+
if (horizontalVertical) {
|
|
362
|
+
return `PaddingValues(horizontal = ${horizontalVertical[1]}.dp, vertical = ${horizontalVertical[2]}.dp)`;
|
|
363
|
+
}
|
|
364
|
+
const uniform = mod.match(/\.padding\((\d+)\.dp\)/);
|
|
365
|
+
if (uniform) {
|
|
366
|
+
return `PaddingValues(${uniform[1]}.dp)`;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function hasStyledContainerModifiers(mods: string[]): boolean {
|
|
372
|
+
return mods.some((m) => m.startsWith('@runtimeStyle(') || m.includes('.background('));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function hasFillMaxHeight(modifiers: string[]): boolean {
|
|
376
|
+
return modifiers.some((m) => m.includes('.fillMaxHeight()'));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function dedupeModifiers(mods: string[]): string[] {
|
|
380
|
+
const seen = new Set<string>();
|
|
381
|
+
const out: string[] = [];
|
|
382
|
+
for (const mod of mods) {
|
|
383
|
+
if (seen.has(mod)) continue;
|
|
384
|
+
seen.add(mod);
|
|
385
|
+
out.push(mod);
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function applyModifiers(base: string, modifiers: string[]): string {
|
|
391
|
+
if (modifiers.length === 0) return base;
|
|
392
|
+
return modifiers
|
|
393
|
+
.filter((m) => !NON_MODIFIER_TAGS.some((tag) => m.startsWith(tag) || m === tag))
|
|
394
|
+
.map((m) => (m.startsWith('@runtimeStyle(')
|
|
395
|
+
? `.applyVcStyle(${m.replace(/^@runtimeStyle\(/, '').replace(/\)$/, '')})`
|
|
396
|
+
: m))
|
|
397
|
+
.reduce((acc, mod) => `${acc}${mod}`, base);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function extractTextStyleModifiers(modifiers: string[]): string[] {
|
|
401
|
+
const parts = modifiers
|
|
402
|
+
.filter((m) => m.startsWith('@textStyle('))
|
|
403
|
+
.map((m) => m.replace(/^@textStyle\(/, '').replace(/\)$/, ''));
|
|
404
|
+
const textAlign = modifiers.find((m) => m.startsWith('@textAlign('));
|
|
405
|
+
if (textAlign) {
|
|
406
|
+
const value = textAlign.replace(/^@textAlign\(/, '').replace(/\)$/, '');
|
|
407
|
+
parts.push(`textAlign = ${value}`);
|
|
408
|
+
}
|
|
409
|
+
return parts;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function extractTextMaxLines(modifiers: string[]): number | null {
|
|
413
|
+
const match = modifiers.find((m) => m.startsWith('@textMaxLines('));
|
|
414
|
+
if (!match) return null;
|
|
415
|
+
const value = match.replace(/^@textMaxLines\(/, '').replace(/\)$/, '');
|
|
416
|
+
const parsed = Number(value);
|
|
417
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function extractTextOverflow(modifiers: string[]): string | null {
|
|
421
|
+
const match = modifiers.find((m) => m.startsWith('@textOverflow('));
|
|
422
|
+
if (!match) return null;
|
|
423
|
+
return match.replace(/^@textOverflow\(/, '').replace(/\)$/, '');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function hasTextFillMaxWidth(modifiers: string[]): boolean {
|
|
427
|
+
return modifiers.includes('@textFillMaxWidth()');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function extractContentColor(modifiers: string[]): string | null {
|
|
431
|
+
const match = modifiers.find((m) => m.startsWith('@contentColor('));
|
|
432
|
+
if (!match) return null;
|
|
433
|
+
return match.replace(/^@contentColor\(/, '').replace(/\)$/, '');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function extractInheritedTextStyleParts(modifiers: string[]): string[] {
|
|
437
|
+
return modifiers
|
|
438
|
+
.filter((m) => m.startsWith('@inheritTextStyle('))
|
|
439
|
+
.map((m) => m.replace(/^@inheritTextStyle\(/, '').replace(/\)$/, ''));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function hasWrapContentCenter(modifiers: string[]): boolean {
|
|
443
|
+
return modifiers.includes('@wrapContentCenter()');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function extractApplyVcStyleExpr(modifierExpr: string): string | null {
|
|
447
|
+
const match = modifierExpr.match(/\.applyVcStyle\((VcTheme\.resolve\([^)]+\))\)/);
|
|
448
|
+
return match?.[1] ?? null;
|
|
449
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Compose-specific property lowering hints.
|
|
2
|
+
# These rules are descriptive metadata for renderer maintenance.
|
|
3
|
+
|
|
4
|
+
properties:
|
|
5
|
+
width:
|
|
6
|
+
lowering: modifier.fillMaxWidth | modifier.width
|
|
7
|
+
height:
|
|
8
|
+
lowering: modifier.fillMaxHeight | modifier.height
|
|
9
|
+
maxWidth:
|
|
10
|
+
lowering: modifier.widthIn(max)
|
|
11
|
+
minHeight:
|
|
12
|
+
lowering: modifier.heightIn(min)
|
|
13
|
+
padding:
|
|
14
|
+
lowering: modifier.padding
|
|
15
|
+
margin:
|
|
16
|
+
lowering: wrapper Box + contentAlignment
|
|
17
|
+
background:
|
|
18
|
+
lowering: modifier.background
|
|
19
|
+
foreground:
|
|
20
|
+
lowering: text color/style
|
|
21
|
+
radius:
|
|
22
|
+
lowering: rounded corner shape
|
|
23
|
+
border:
|
|
24
|
+
lowering: modifier.border
|
|
25
|
+
borderColor:
|
|
26
|
+
lowering: modifier.border color
|
|
27
|
+
shadow:
|
|
28
|
+
lowering: modifier.shadow
|
|
29
|
+
opacity:
|
|
30
|
+
lowering: modifier.alpha
|
|
31
|
+
fontSize:
|
|
32
|
+
lowering: TextStyle.fontSize
|
|
33
|
+
fontWeight:
|
|
34
|
+
lowering: TextStyle.fontWeight
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ViewIrDocument } from '../../src/ir/types.js';
|
|
2
|
+
import type { ThemeIR, TokenIR } from '../../src/design/types.js';
|
|
3
|
+
import { loadComposeElementMap } from './elementMap.js';
|
|
4
|
+
import { lowerIrToComposeBody, type ComposeRenderContext } from './lowering/lowerToCompose.js';
|
|
5
|
+
|
|
6
|
+
export interface RenderComposeOptions {
|
|
7
|
+
theme: ThemeIR;
|
|
8
|
+
tokens: TokenIR;
|
|
9
|
+
allowlistExtra?: string[];
|
|
10
|
+
elementMapPath?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function tsTypeToKotlin(type: string): string {
|
|
14
|
+
const arrayMatch = type.match(/^(\w+)\[\]$/);
|
|
15
|
+
if (arrayMatch) return `List<${tsTypeToKotlin(arrayMatch[1]!)}>`;
|
|
16
|
+
if (type === 'string') return 'String';
|
|
17
|
+
if (type === 'number') return 'Double';
|
|
18
|
+
if (type === 'boolean') return 'Boolean';
|
|
19
|
+
return type;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function partialPropsSignature(doc: ViewIrDocument): string {
|
|
23
|
+
if (!doc.propsType) return '';
|
|
24
|
+
const match = doc.propsType.match(/^\{\s*([^}]+)\s*\}$/);
|
|
25
|
+
if (!match) return `props: ${doc.propsType}`;
|
|
26
|
+
const inner = match[1].trim();
|
|
27
|
+
return 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 = tsTypeToKotlin(rawType);
|
|
32
|
+
return `${name}: ${type}`;
|
|
33
|
+
}).join(', ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function propsRootFromDoc(doc: ViewIrDocument): string | undefined {
|
|
37
|
+
if (!doc.propsType) return undefined;
|
|
38
|
+
const match = doc.propsType.match(/^\{\s*([^}]+)\s*\}$/);
|
|
39
|
+
if (!match) return undefined;
|
|
40
|
+
const first = match[1].split(',')[0]!.split(':')[0]!.trim();
|
|
41
|
+
return first;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function renderComposeComponent(
|
|
45
|
+
doc: ViewIrDocument,
|
|
46
|
+
options: RenderComposeOptions,
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const map = await loadComposeElementMap(options.elementMapPath);
|
|
49
|
+
const allowlistExtra = options.allowlistExtra ?? [];
|
|
50
|
+
const functionName = doc.isPartial ? doc.exportName : `${doc.exportName.replace(/View$/, '') || doc.exportName}Screen`;
|
|
51
|
+
|
|
52
|
+
const ctx: ComposeRenderContext = {
|
|
53
|
+
map,
|
|
54
|
+
theme: options.theme,
|
|
55
|
+
tokens: options.tokens,
|
|
56
|
+
allowlistExtra,
|
|
57
|
+
propsRoot: propsRootFromDoc(doc),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const body = lowerIrToComposeBody(doc.root, ctx, 2);
|
|
61
|
+
const params = doc.isPartial
|
|
62
|
+
? partialPropsSignature(doc)
|
|
63
|
+
: (doc.viewModel ? `vm: ${doc.viewModel}` : '');
|
|
64
|
+
|
|
65
|
+
const packageName = 'generated.compose';
|
|
66
|
+
|
|
67
|
+
return `/**
|
|
68
|
+
* Auto-generated Compose view from ${doc.source}
|
|
69
|
+
* DO NOT EDIT — generated by view-contracts.
|
|
70
|
+
*/
|
|
71
|
+
@file:OptIn(ExperimentalLayoutApi::class)
|
|
72
|
+
|
|
73
|
+
package ${packageName}
|
|
74
|
+
|
|
75
|
+
import androidx.compose.foundation.background
|
|
76
|
+
import androidx.compose.foundation.border
|
|
77
|
+
import androidx.compose.foundation.layout.*
|
|
78
|
+
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
79
|
+
import androidx.compose.foundation.lazy.grid.GridCells
|
|
80
|
+
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
81
|
+
import androidx.compose.foundation.lazy.grid.items as gridItems
|
|
82
|
+
import androidx.compose.foundation.lazy.items as lazyItems
|
|
83
|
+
import androidx.compose.foundation.rememberScrollState
|
|
84
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
85
|
+
import androidx.compose.foundation.text.BasicTextField
|
|
86
|
+
import androidx.compose.foundation.verticalScroll
|
|
87
|
+
import androidx.compose.material.icons.Icons
|
|
88
|
+
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
89
|
+
import androidx.compose.material.icons.filled.Image
|
|
90
|
+
import androidx.compose.material3.*
|
|
91
|
+
import androidx.compose.runtime.*
|
|
92
|
+
import androidx.compose.runtime.CompositionLocalProvider
|
|
93
|
+
import androidx.compose.material3.LocalContentColor
|
|
94
|
+
import androidx.compose.material3.LocalTextStyle
|
|
95
|
+
import androidx.compose.ui.Alignment
|
|
96
|
+
import androidx.compose.ui.Modifier
|
|
97
|
+
import androidx.compose.ui.draw.alpha
|
|
98
|
+
import androidx.compose.ui.draw.clip
|
|
99
|
+
import androidx.compose.ui.draw.shadow
|
|
100
|
+
import androidx.compose.ui.graphics.Color
|
|
101
|
+
import androidx.compose.ui.text.TextStyle
|
|
102
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
103
|
+
import androidx.compose.ui.text.style.TextAlign
|
|
104
|
+
import androidx.compose.ui.text.style.TextOverflow
|
|
105
|
+
import androidx.compose.ui.unit.dp
|
|
106
|
+
import androidx.compose.ui.unit.sp
|
|
107
|
+
import generated.compose.components.*
|
|
108
|
+
import generated.compose.VcButton
|
|
109
|
+
import generated.compose.VcTheme
|
|
110
|
+
import generated.compose.applyVcStyle
|
|
111
|
+
import generated.compose.asTextStyle
|
|
112
|
+
|
|
113
|
+
@Composable
|
|
114
|
+
fun ${functionName}(${params}) {
|
|
115
|
+
${body}
|
|
116
|
+
}
|
|
117
|
+
`;
|
|
118
|
+
}
|