prev-cli 0.24.16 → 0.24.19
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/dist/cli.js +2 -1
- package/package.json +5 -2
- package/src/jsx/adapters/html.test.ts +191 -0
- package/src/jsx/adapters/html.ts +404 -0
- package/src/jsx/adapters/react.test.ts +172 -0
- package/src/jsx/adapters/react.tsx +346 -0
- package/src/jsx/define-component.ts +129 -0
- package/src/jsx/index.ts +47 -0
- package/src/jsx/jsx-runtime.test.ts +63 -0
- package/src/jsx/jsx-runtime.ts +117 -0
- package/src/jsx/migrate.test.ts +72 -0
- package/src/jsx/migrate.ts +451 -0
- package/src/jsx/schemas/index.ts +4 -0
- package/src/jsx/schemas/primitives.ts +107 -0
- package/src/jsx/schemas/tokens.ts +60 -0
- package/src/jsx/validation.ts +77 -0
- package/src/jsx/vnode.ts +159 -0
- package/src/primitives/index.ts +8 -0
- package/src/primitives/migrate.test.ts +317 -0
- package/src/primitives/migrate.ts +364 -0
- package/src/primitives/parser.test.ts +265 -0
- package/src/primitives/parser.ts +442 -0
- package/src/primitives/template-parser.test.ts +297 -0
- package/src/primitives/template-parser.ts +374 -0
- package/src/primitives/template-renderer.test.ts +359 -0
- package/src/primitives/template-renderer.ts +497 -0
- package/src/primitives/tokens.css +82 -0
- package/src/primitives/types.ts +248 -0
- package/src/tokens/defaults.test.ts +137 -0
- package/src/tokens/defaults.ts +77 -0
- package/src/tokens/defaults.yaml +76 -0
- package/src/tokens/resolver.test.ts +229 -0
- package/src/tokens/resolver.ts +173 -0
- package/src/tokens/utils.test.ts +172 -0
- package/src/tokens/utils.ts +104 -0
- package/src/tokens/validation.test.ts +118 -0
- package/src/tokens/validation.ts +226 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// src/primitives/migrate.test.ts
|
|
2
|
+
import { test, expect, describe } from 'bun:test'
|
|
3
|
+
import { migrateLayout, migrateScreenConfig } from './migrate'
|
|
4
|
+
|
|
5
|
+
describe('migrateLayout', () => {
|
|
6
|
+
describe('ComponentRef', () => {
|
|
7
|
+
test('migrates ComponentRef to string ref', () => {
|
|
8
|
+
const result = migrateLayout({
|
|
9
|
+
type: 'ComponentRef',
|
|
10
|
+
ref: 'components/button',
|
|
11
|
+
})
|
|
12
|
+
expect(result.success).toBe(true)
|
|
13
|
+
expect(result.template?.root).toBe('components/button')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('warns about ComponentRef with props', () => {
|
|
17
|
+
const result = migrateLayout({
|
|
18
|
+
type: 'ComponentRef',
|
|
19
|
+
ref: 'components/button',
|
|
20
|
+
props: { disabled: true },
|
|
21
|
+
})
|
|
22
|
+
expect(result.success).toBe(true)
|
|
23
|
+
expect(result.warnings).toHaveLength(1)
|
|
24
|
+
expect(result.warnings[0]).toContain('props')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('Slot', () => {
|
|
29
|
+
test('migrates Slot to $slot primitive', () => {
|
|
30
|
+
const result = migrateLayout({
|
|
31
|
+
type: 'Slot',
|
|
32
|
+
name: 'main',
|
|
33
|
+
})
|
|
34
|
+
expect(result.success).toBe(true)
|
|
35
|
+
expect(result.template?.root).toBe('$slot(main)')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('extracts slot default content', () => {
|
|
39
|
+
const result = migrateLayout({
|
|
40
|
+
type: 'Slot',
|
|
41
|
+
name: 'content',
|
|
42
|
+
default: [
|
|
43
|
+
{
|
|
44
|
+
type: 'ComponentRef',
|
|
45
|
+
ref: 'components/placeholder',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
})
|
|
49
|
+
expect(result.success).toBe(true)
|
|
50
|
+
expect(result.template?.root).toBe('$slot(content)')
|
|
51
|
+
expect(result.slots?.content?.default).toBe('components/placeholder')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('Stack/VStack', () => {
|
|
56
|
+
test('migrates Stack to $col', () => {
|
|
57
|
+
const result = migrateLayout({
|
|
58
|
+
type: 'Stack',
|
|
59
|
+
})
|
|
60
|
+
expect(result.success).toBe(true)
|
|
61
|
+
expect(result.template?.root).toBe('$col')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('migrates VStack to $col', () => {
|
|
65
|
+
const result = migrateLayout({
|
|
66
|
+
type: 'VStack',
|
|
67
|
+
})
|
|
68
|
+
expect(result.success).toBe(true)
|
|
69
|
+
expect(result.template?.root).toBe('$col')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('migrates Stack with gap', () => {
|
|
73
|
+
const result = migrateLayout({
|
|
74
|
+
type: 'Stack',
|
|
75
|
+
gap: 'lg',
|
|
76
|
+
})
|
|
77
|
+
expect(result.success).toBe(true)
|
|
78
|
+
expect(result.template?.root).toBe('$col(gap:lg)')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('migrates Stack with CSS gap value', () => {
|
|
82
|
+
const result = migrateLayout({
|
|
83
|
+
type: 'Stack',
|
|
84
|
+
gap: '16px',
|
|
85
|
+
})
|
|
86
|
+
expect(result.success).toBe(true)
|
|
87
|
+
expect(result.template?.root).toBe('$col(gap:md)')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('migrates Stack with children', () => {
|
|
91
|
+
const result = migrateLayout({
|
|
92
|
+
type: 'Stack',
|
|
93
|
+
children: [
|
|
94
|
+
{ type: 'ComponentRef', ref: 'components/header' },
|
|
95
|
+
{ type: 'ComponentRef', ref: 'components/footer' },
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
expect(result.success).toBe(true)
|
|
99
|
+
const root = result.template?.root
|
|
100
|
+
expect(typeof root).toBe('object')
|
|
101
|
+
if (typeof root === 'object' && root !== null && 'children' in root) {
|
|
102
|
+
expect(Object.keys(root.children || {})).toHaveLength(2)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('HStack', () => {
|
|
108
|
+
test('migrates HStack to $row', () => {
|
|
109
|
+
const result = migrateLayout({
|
|
110
|
+
type: 'HStack',
|
|
111
|
+
})
|
|
112
|
+
expect(result.success).toBe(true)
|
|
113
|
+
expect(result.template?.root).toBe('$row')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('migrates HStack with gap and align', () => {
|
|
117
|
+
const result = migrateLayout({
|
|
118
|
+
type: 'HStack',
|
|
119
|
+
gap: 'sm',
|
|
120
|
+
align: 'center',
|
|
121
|
+
})
|
|
122
|
+
expect(result.success).toBe(true)
|
|
123
|
+
expect(result.template?.root).toContain('$row')
|
|
124
|
+
expect(result.template?.root).toContain('gap:sm')
|
|
125
|
+
expect(result.template?.root).toContain('align:center')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('Box/Container', () => {
|
|
130
|
+
test('migrates Box to $box', () => {
|
|
131
|
+
const result = migrateLayout({
|
|
132
|
+
type: 'Box',
|
|
133
|
+
})
|
|
134
|
+
expect(result.success).toBe(true)
|
|
135
|
+
expect(result.template?.root).toBe('$box')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('migrates Container to $box', () => {
|
|
139
|
+
const result = migrateLayout({
|
|
140
|
+
type: 'Container',
|
|
141
|
+
})
|
|
142
|
+
expect(result.success).toBe(true)
|
|
143
|
+
expect(result.template?.root).toBe('$box')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('migrates Box with padding', () => {
|
|
147
|
+
const result = migrateLayout({
|
|
148
|
+
type: 'Box',
|
|
149
|
+
padding: 'lg',
|
|
150
|
+
})
|
|
151
|
+
expect(result.success).toBe(true)
|
|
152
|
+
expect(result.template?.root).toBe('$box(padding:lg)')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('migrates Box with bg', () => {
|
|
156
|
+
const result = migrateLayout({
|
|
157
|
+
type: 'Box',
|
|
158
|
+
bg: 'surface',
|
|
159
|
+
})
|
|
160
|
+
expect(result.success).toBe(true)
|
|
161
|
+
expect(result.template?.root).toBe('$box(bg:surface)')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('migrates Box with radius', () => {
|
|
165
|
+
const result = migrateLayout({
|
|
166
|
+
type: 'Box',
|
|
167
|
+
radius: 'md',
|
|
168
|
+
})
|
|
169
|
+
expect(result.success).toBe(true)
|
|
170
|
+
expect(result.template?.root).toBe('$box(radius:md)')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('Spacer', () => {
|
|
175
|
+
test('migrates Spacer without size', () => {
|
|
176
|
+
const result = migrateLayout({
|
|
177
|
+
type: 'Spacer',
|
|
178
|
+
})
|
|
179
|
+
expect(result.success).toBe(true)
|
|
180
|
+
expect(result.template?.root).toBe('$spacer')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('migrates Spacer with token size', () => {
|
|
184
|
+
const result = migrateLayout({
|
|
185
|
+
type: 'Spacer',
|
|
186
|
+
size: 'xl',
|
|
187
|
+
})
|
|
188
|
+
expect(result.success).toBe(true)
|
|
189
|
+
expect(result.template?.root).toBe('$spacer(xl)')
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('Text', () => {
|
|
194
|
+
test('migrates Text with content', () => {
|
|
195
|
+
const result = migrateLayout({
|
|
196
|
+
type: 'Text',
|
|
197
|
+
content: 'label',
|
|
198
|
+
})
|
|
199
|
+
expect(result.success).toBe(true)
|
|
200
|
+
expect(result.template?.root).toBe('$text(label)')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('migrates Text with literal children', () => {
|
|
204
|
+
const result = migrateLayout({
|
|
205
|
+
type: 'Text',
|
|
206
|
+
children: 'Hello World',
|
|
207
|
+
})
|
|
208
|
+
expect(result.success).toBe(true)
|
|
209
|
+
expect(result.template?.root).toBe('$text("Hello World")')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('migrates Text with size and weight', () => {
|
|
213
|
+
const result = migrateLayout({
|
|
214
|
+
type: 'Text',
|
|
215
|
+
content: 'title',
|
|
216
|
+
size: 'lg',
|
|
217
|
+
weight: 'bold',
|
|
218
|
+
})
|
|
219
|
+
expect(result.success).toBe(true)
|
|
220
|
+
expect(result.template?.root).toContain('$text')
|
|
221
|
+
expect(result.template?.root).toContain('size:lg')
|
|
222
|
+
expect(result.template?.root).toContain('weight:bold')
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('multiple root nodes', () => {
|
|
227
|
+
test('wraps multiple nodes in $col', () => {
|
|
228
|
+
const result = migrateLayout([
|
|
229
|
+
{ type: 'ComponentRef', ref: 'components/header' },
|
|
230
|
+
{ type: 'ComponentRef', ref: 'components/footer' },
|
|
231
|
+
])
|
|
232
|
+
expect(result.success).toBe(true)
|
|
233
|
+
const root = result.template?.root
|
|
234
|
+
expect(typeof root).toBe('object')
|
|
235
|
+
if (typeof root === 'object' && root !== null && 'type' in root) {
|
|
236
|
+
expect(root.type).toBe('$col')
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe('complex nested layout', () => {
|
|
242
|
+
test('migrates complex layout', () => {
|
|
243
|
+
const result = migrateLayout({
|
|
244
|
+
type: 'Stack',
|
|
245
|
+
gap: 'lg',
|
|
246
|
+
children: [
|
|
247
|
+
{ type: 'ComponentRef', ref: 'components/header' },
|
|
248
|
+
{
|
|
249
|
+
type: 'HStack',
|
|
250
|
+
gap: 'md',
|
|
251
|
+
children: [
|
|
252
|
+
{ type: 'ComponentRef', ref: 'components/sidebar' },
|
|
253
|
+
{
|
|
254
|
+
type: 'Box',
|
|
255
|
+
padding: 'lg',
|
|
256
|
+
children: [{ type: 'Slot', name: 'main' }],
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
{ type: 'ComponentRef', ref: 'components/footer' },
|
|
261
|
+
],
|
|
262
|
+
})
|
|
263
|
+
expect(result.success).toBe(true)
|
|
264
|
+
expect(result.errors).toHaveLength(0)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('error handling', () => {
|
|
269
|
+
test('rejects null layout', () => {
|
|
270
|
+
const result = migrateLayout(null)
|
|
271
|
+
expect(result.success).toBe(false)
|
|
272
|
+
expect(result.errors[0]).toContain('must be an object')
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('rejects non-object layout', () => {
|
|
276
|
+
const result = migrateLayout('invalid')
|
|
277
|
+
expect(result.success).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('warns about unknown node types', () => {
|
|
281
|
+
const result = migrateLayout({
|
|
282
|
+
type: 'CustomComponent',
|
|
283
|
+
})
|
|
284
|
+
expect(result.success).toBe(true) // Still succeeds with warning
|
|
285
|
+
expect(result.warnings).toHaveLength(1)
|
|
286
|
+
expect(result.warnings[0]).toContain('Unknown node type')
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('migrateScreenConfig', () => {
|
|
292
|
+
test('migrates react layout by default', () => {
|
|
293
|
+
const result = migrateScreenConfig({
|
|
294
|
+
react: {
|
|
295
|
+
type: 'ComponentRef',
|
|
296
|
+
ref: 'components/home',
|
|
297
|
+
},
|
|
298
|
+
html: {
|
|
299
|
+
type: 'ComponentRef',
|
|
300
|
+
ref: 'components/home-html',
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
expect(result.success).toBe(true)
|
|
304
|
+
expect(result.template?.root).toBe('components/home')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('falls back to first renderer if no react', () => {
|
|
308
|
+
const result = migrateScreenConfig({
|
|
309
|
+
svelte: {
|
|
310
|
+
type: 'ComponentRef',
|
|
311
|
+
ref: 'components/home-svelte',
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
expect(result.success).toBe(true)
|
|
315
|
+
expect(result.template?.root).toBe('components/home-svelte')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// src/primitives/migrate.ts
|
|
2
|
+
// Migration tool to convert layoutByRenderer to template format
|
|
3
|
+
|
|
4
|
+
import type { Template, TemplateNode, Slots } from './types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Legacy layout node types from layoutByRenderer
|
|
8
|
+
*/
|
|
9
|
+
interface LegacyLayoutNode {
|
|
10
|
+
type: string
|
|
11
|
+
ref?: string
|
|
12
|
+
name?: string
|
|
13
|
+
props?: Record<string, unknown>
|
|
14
|
+
children?: LegacyLayoutNode[]
|
|
15
|
+
gap?: string
|
|
16
|
+
align?: string
|
|
17
|
+
padding?: string
|
|
18
|
+
direction?: string
|
|
19
|
+
default?: LegacyLayoutNode[]
|
|
20
|
+
[key: string]: unknown
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Migration result
|
|
25
|
+
*/
|
|
26
|
+
export interface MigrationResult {
|
|
27
|
+
success: boolean
|
|
28
|
+
template?: Template
|
|
29
|
+
slots?: Slots
|
|
30
|
+
errors: string[]
|
|
31
|
+
warnings: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Migrate a layoutByRenderer tree to template format
|
|
36
|
+
*
|
|
37
|
+
* Converts:
|
|
38
|
+
* - Stack/HStack/VStack to $col/$row
|
|
39
|
+
* - Box/Container to $box
|
|
40
|
+
* - ComponentRef to component refs
|
|
41
|
+
* - Slot to $slot
|
|
42
|
+
*/
|
|
43
|
+
export function migrateLayout(
|
|
44
|
+
layout: unknown,
|
|
45
|
+
_rendererName: string = 'react'
|
|
46
|
+
): MigrationResult {
|
|
47
|
+
const errors: string[] = []
|
|
48
|
+
const warnings: string[] = []
|
|
49
|
+
const slots: Slots = {}
|
|
50
|
+
|
|
51
|
+
if (!layout || typeof layout !== 'object') {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
errors: ['Layout must be an object or array'],
|
|
55
|
+
warnings: [],
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Handle array at root level
|
|
61
|
+
const layoutNodes = Array.isArray(layout) ? layout : [layout]
|
|
62
|
+
|
|
63
|
+
// If single node, use it directly as root
|
|
64
|
+
// If multiple nodes, wrap in $col
|
|
65
|
+
let root: TemplateNode
|
|
66
|
+
|
|
67
|
+
if (layoutNodes.length === 1) {
|
|
68
|
+
root = migrateSingleNode(layoutNodes[0] as LegacyLayoutNode, 'root', slots, errors, warnings)
|
|
69
|
+
} else {
|
|
70
|
+
// Multiple root nodes - wrap in $col
|
|
71
|
+
const children: Record<string, TemplateNode> = {}
|
|
72
|
+
layoutNodes.forEach((node, index) => {
|
|
73
|
+
const nodeId = `child-${index}`
|
|
74
|
+
children[nodeId] = migrateSingleNode(node as LegacyLayoutNode, nodeId, slots, errors, warnings)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
root = {
|
|
78
|
+
type: '$col',
|
|
79
|
+
children,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const template: Template = { root }
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
success: errors.length === 0,
|
|
87
|
+
template,
|
|
88
|
+
slots: Object.keys(slots).length > 0 ? slots : undefined,
|
|
89
|
+
errors,
|
|
90
|
+
warnings,
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
errors: [`Migration failed: ${err instanceof Error ? err.message : String(err)}`],
|
|
96
|
+
warnings,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Migrate a single layout node
|
|
103
|
+
*/
|
|
104
|
+
function migrateSingleNode(
|
|
105
|
+
node: LegacyLayoutNode,
|
|
106
|
+
nodeId: string,
|
|
107
|
+
slots: Slots,
|
|
108
|
+
errors: string[],
|
|
109
|
+
warnings: string[]
|
|
110
|
+
): TemplateNode {
|
|
111
|
+
if (!node || typeof node !== 'object') {
|
|
112
|
+
errors.push(`Invalid node at ${nodeId}`)
|
|
113
|
+
return `<!-- invalid: ${nodeId} -->`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const nodeType = node.type
|
|
117
|
+
|
|
118
|
+
// ComponentRef -> component ref string
|
|
119
|
+
if (nodeType === 'ComponentRef' && node.ref) {
|
|
120
|
+
if (node.props && Object.keys(node.props).length > 0) {
|
|
121
|
+
warnings.push(`ComponentRef at ${nodeId} has props that cannot be migrated to template format`)
|
|
122
|
+
}
|
|
123
|
+
return node.ref
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Slot -> $slot
|
|
127
|
+
if (nodeType === 'Slot' && node.name) {
|
|
128
|
+
// Track slot defaults for migration
|
|
129
|
+
if (node.default && Array.isArray(node.default) && node.default.length > 0) {
|
|
130
|
+
// Convert default content to slot mapping
|
|
131
|
+
const defaultContent = migrateSingleNode(node.default[0], `${nodeId}-default`, slots, errors, warnings)
|
|
132
|
+
if (typeof defaultContent === 'string') {
|
|
133
|
+
if (!slots[node.name]) {
|
|
134
|
+
slots[node.name] = {}
|
|
135
|
+
}
|
|
136
|
+
slots[node.name].default = defaultContent
|
|
137
|
+
} else {
|
|
138
|
+
warnings.push(`Slot "${node.name}" default content is complex; manual migration required`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return `$slot(${node.name})`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Stack/VStack -> $col
|
|
145
|
+
if (nodeType === 'Stack' || nodeType === 'VStack') {
|
|
146
|
+
const props = buildLayoutProps(node, false)
|
|
147
|
+
const primitive = props ? `$col(${props})` : '$col'
|
|
148
|
+
|
|
149
|
+
if (node.children && Array.isArray(node.children) && node.children.length > 0) {
|
|
150
|
+
const children: Record<string, TemplateNode> = {}
|
|
151
|
+
node.children.forEach((child, index) => {
|
|
152
|
+
const childId = `${nodeId}-${index}`
|
|
153
|
+
children[childId] = migrateSingleNode(child, childId, slots, errors, warnings)
|
|
154
|
+
})
|
|
155
|
+
return { type: primitive, children }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return primitive
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// HStack -> $row
|
|
162
|
+
if (nodeType === 'HStack') {
|
|
163
|
+
const props = buildLayoutProps(node, true)
|
|
164
|
+
const primitive = props ? `$row(${props})` : '$row'
|
|
165
|
+
|
|
166
|
+
if (node.children && Array.isArray(node.children) && node.children.length > 0) {
|
|
167
|
+
const children: Record<string, TemplateNode> = {}
|
|
168
|
+
node.children.forEach((child, index) => {
|
|
169
|
+
const childId = `${nodeId}-${index}`
|
|
170
|
+
children[childId] = migrateSingleNode(child, childId, slots, errors, warnings)
|
|
171
|
+
})
|
|
172
|
+
return { type: primitive, children }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return primitive
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Box/Container -> $box
|
|
179
|
+
if (nodeType === 'Box' || nodeType === 'Container') {
|
|
180
|
+
const props = buildBoxProps(node)
|
|
181
|
+
const primitive = props ? `$box(${props})` : '$box'
|
|
182
|
+
|
|
183
|
+
if (node.children && Array.isArray(node.children) && node.children.length > 0) {
|
|
184
|
+
const children: Record<string, TemplateNode> = {}
|
|
185
|
+
node.children.forEach((child, index) => {
|
|
186
|
+
const childId = `${nodeId}-${index}`
|
|
187
|
+
children[childId] = migrateSingleNode(child, childId, slots, errors, warnings)
|
|
188
|
+
})
|
|
189
|
+
return { type: primitive, children }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return primitive
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Spacer -> $spacer
|
|
196
|
+
if (nodeType === 'Spacer') {
|
|
197
|
+
if (node.size) {
|
|
198
|
+
const tokenSize = mapToToken(node.size as string, 'spacing')
|
|
199
|
+
return tokenSize ? `$spacer(${tokenSize})` : '$spacer'
|
|
200
|
+
}
|
|
201
|
+
return '$spacer'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Text -> $text
|
|
205
|
+
if (nodeType === 'Text') {
|
|
206
|
+
const props: string[] = []
|
|
207
|
+
if (node.content) {
|
|
208
|
+
props.push(String(node.content))
|
|
209
|
+
} else if (node.children && typeof node.children === 'string') {
|
|
210
|
+
props.push(`"${node.children}"`)
|
|
211
|
+
}
|
|
212
|
+
if (node.size) {
|
|
213
|
+
const tokenSize = mapToToken(node.size as string, 'size')
|
|
214
|
+
if (tokenSize) props.push(`size:${tokenSize}`)
|
|
215
|
+
}
|
|
216
|
+
if (node.weight) {
|
|
217
|
+
const tokenWeight = mapToToken(node.weight as string, 'weight')
|
|
218
|
+
if (tokenWeight) props.push(`weight:${tokenWeight}`)
|
|
219
|
+
}
|
|
220
|
+
return props.length > 0 ? `$text(${props.join(' ')})` : '$text(content)'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Unknown type - warn and convert to comment
|
|
224
|
+
warnings.push(`Unknown node type "${nodeType}" at ${nodeId}; manual migration required`)
|
|
225
|
+
return `<!-- unknown: ${nodeType} -->`
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Build layout props string for $col/$row
|
|
230
|
+
*/
|
|
231
|
+
function buildLayoutProps(node: LegacyLayoutNode, _isRow: boolean): string {
|
|
232
|
+
const props: string[] = []
|
|
233
|
+
|
|
234
|
+
if (node.gap) {
|
|
235
|
+
const tokenGap = mapToToken(node.gap, 'spacing')
|
|
236
|
+
if (tokenGap) props.push(`gap:${tokenGap}`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (node.align) {
|
|
240
|
+
const tokenAlign = mapToToken(node.align, 'align')
|
|
241
|
+
if (tokenAlign) props.push(`align:${tokenAlign}`)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (node.padding) {
|
|
245
|
+
const tokenPadding = mapToToken(node.padding, 'spacing')
|
|
246
|
+
if (tokenPadding) props.push(`padding:${tokenPadding}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return props.join(' ')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Build box props string for $box
|
|
254
|
+
*/
|
|
255
|
+
function buildBoxProps(node: LegacyLayoutNode): string {
|
|
256
|
+
const props: string[] = []
|
|
257
|
+
|
|
258
|
+
if (node.padding) {
|
|
259
|
+
const tokenPadding = mapToToken(node.padding as string, 'spacing')
|
|
260
|
+
if (tokenPadding) props.push(`padding:${tokenPadding}`)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (node.bg || node.background) {
|
|
264
|
+
const bg = (node.bg || node.background) as string
|
|
265
|
+
const tokenBg = mapToToken(bg, 'background')
|
|
266
|
+
if (tokenBg) props.push(`bg:${tokenBg}`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (node.radius || node.borderRadius) {
|
|
270
|
+
const radius = (node.radius || node.borderRadius) as string
|
|
271
|
+
const tokenRadius = mapToToken(radius, 'radius')
|
|
272
|
+
if (tokenRadius) props.push(`radius:${tokenRadius}`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return props.join(' ')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Map CSS values to design tokens
|
|
280
|
+
*/
|
|
281
|
+
function mapToToken(
|
|
282
|
+
value: string | number,
|
|
283
|
+
type: 'spacing' | 'align' | 'background' | 'radius' | 'size' | 'weight'
|
|
284
|
+
): string | null {
|
|
285
|
+
const str = String(value).toLowerCase().trim()
|
|
286
|
+
|
|
287
|
+
// Already a token name
|
|
288
|
+
const tokenNames: Record<string, string[]> = {
|
|
289
|
+
spacing: ['none', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'],
|
|
290
|
+
align: ['start', 'center', 'end', 'stretch', 'between'],
|
|
291
|
+
background: ['transparent', 'surface', 'muted', 'accent'],
|
|
292
|
+
radius: ['none', 'sm', 'md', 'lg', 'full'],
|
|
293
|
+
size: ['xs', 'sm', 'md', 'lg', 'xl'],
|
|
294
|
+
weight: ['normal', 'medium', 'bold'],
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (tokenNames[type]?.includes(str)) {
|
|
298
|
+
return str
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Map common CSS values to tokens
|
|
302
|
+
const spacingMap: Record<string, string> = {
|
|
303
|
+
'0': 'none',
|
|
304
|
+
'0px': 'none',
|
|
305
|
+
'4px': 'xs',
|
|
306
|
+
'0.25rem': 'xs',
|
|
307
|
+
'8px': 'sm',
|
|
308
|
+
'0.5rem': 'sm',
|
|
309
|
+
'16px': 'md',
|
|
310
|
+
'1rem': 'md',
|
|
311
|
+
'24px': 'lg',
|
|
312
|
+
'1.5rem': 'lg',
|
|
313
|
+
'32px': 'xl',
|
|
314
|
+
'2rem': 'xl',
|
|
315
|
+
'48px': '2xl',
|
|
316
|
+
'3rem': '2xl',
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const alignMap: Record<string, string> = {
|
|
320
|
+
'flex-start': 'start',
|
|
321
|
+
'flex-end': 'end',
|
|
322
|
+
'space-between': 'between',
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const radiusMap: Record<string, string> = {
|
|
326
|
+
'0': 'none',
|
|
327
|
+
'4px': 'sm',
|
|
328
|
+
'0.25rem': 'sm',
|
|
329
|
+
'8px': 'md',
|
|
330
|
+
'0.5rem': 'md',
|
|
331
|
+
'16px': 'lg',
|
|
332
|
+
'1rem': 'lg',
|
|
333
|
+
'9999px': 'full',
|
|
334
|
+
'50%': 'full',
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (type === 'spacing' && spacingMap[str]) {
|
|
338
|
+
return spacingMap[str]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (type === 'align' && alignMap[str]) {
|
|
342
|
+
return alignMap[str]
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (type === 'radius' && radiusMap[str]) {
|
|
346
|
+
return radiusMap[str]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Can't map - return null
|
|
350
|
+
return null
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Migrate all layouts from a screen config
|
|
355
|
+
*/
|
|
356
|
+
export function migrateScreenConfig(
|
|
357
|
+
layoutByRenderer: Record<string, unknown>
|
|
358
|
+
): MigrationResult {
|
|
359
|
+
// Prefer 'react' layout, fall back to first available
|
|
360
|
+
const rendererName = 'react' in layoutByRenderer ? 'react' : Object.keys(layoutByRenderer)[0]
|
|
361
|
+
const layout = layoutByRenderer[rendererName]
|
|
362
|
+
|
|
363
|
+
return migrateLayout(layout, rendererName)
|
|
364
|
+
}
|