@zseven-w/pen-codegen 0.0.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 +55 -0
- package/package.json +25 -0
- package/src/__tests__/codegen.test.ts +89 -0
- package/src/compose-generator.ts +807 -0
- package/src/css-variables-generator.ts +138 -0
- package/src/flutter-generator.ts +581 -0
- package/src/html-generator.ts +403 -0
- package/src/index.ts +26 -0
- package/src/react-generator.ts +401 -0
- package/src/react-native-generator.ts +569 -0
- package/src/svelte-generator.ts +296 -0
- package/src/swiftui-generator.ts +754 -0
- package/src/vue-generator.ts +308 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { PenDocument, PenNode, ContainerProps, TextNode } from '@zseven-w/pen-types'
|
|
2
|
+
import { getActivePageChildren } from '@zseven-w/pen-core'
|
|
3
|
+
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
|
|
4
|
+
import { isVariableRef } from '@zseven-w/pen-core'
|
|
5
|
+
import { variableNameToCSS } from './css-variables-generator.js'
|
|
6
|
+
import { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts PenDocument nodes to React + Tailwind code.
|
|
10
|
+
* $variable references are output as var(--name) CSS custom properties.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Convert a `$variable` ref to `var(--name)`, or return the raw value. */
|
|
14
|
+
function varOrLiteral(value: string): string {
|
|
15
|
+
if (isVariableRef(value)) {
|
|
16
|
+
return `var(${variableNameToCSS(value.slice(1))})`
|
|
17
|
+
}
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function indent(depth: number): string {
|
|
22
|
+
return ' '.repeat(depth)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function kebabToPascal(name: string): string {
|
|
26
|
+
return name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fillToTailwind(fills: PenFill[] | undefined): string[] {
|
|
30
|
+
if (!fills || fills.length === 0) return []
|
|
31
|
+
const fill = fills[0]
|
|
32
|
+
if (fill.type === 'solid') {
|
|
33
|
+
return [`bg-[${varOrLiteral(fill.color)}]`]
|
|
34
|
+
}
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fillToTextColor(fills: PenFill[] | undefined): string[] {
|
|
39
|
+
if (!fills || fills.length === 0) return []
|
|
40
|
+
const fill = fills[0]
|
|
41
|
+
if (fill.type === 'solid') {
|
|
42
|
+
return [`text-[${varOrLiteral(fill.color)}]`]
|
|
43
|
+
}
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function strokeToTailwind(stroke: PenStroke | undefined): string[] {
|
|
48
|
+
if (!stroke) return []
|
|
49
|
+
const classes: string[] = []
|
|
50
|
+
if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
|
|
51
|
+
classes.push('border', `border-[${varOrLiteral(stroke.thickness)}]`)
|
|
52
|
+
} else {
|
|
53
|
+
const thickness = typeof stroke.thickness === 'number'
|
|
54
|
+
? stroke.thickness
|
|
55
|
+
: stroke.thickness[0]
|
|
56
|
+
classes.push('border', `border-[${thickness}px]`)
|
|
57
|
+
}
|
|
58
|
+
if (stroke.fill && stroke.fill.length > 0) {
|
|
59
|
+
const sf = stroke.fill[0]
|
|
60
|
+
if (sf.type === 'solid') {
|
|
61
|
+
classes.push(`border-[${varOrLiteral(sf.color)}]`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return classes
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function effectsToTailwind(effects: PenEffect[] | undefined): string[] {
|
|
68
|
+
if (!effects || effects.length === 0) return []
|
|
69
|
+
const classes: string[] = []
|
|
70
|
+
for (const effect of effects) {
|
|
71
|
+
if (effect.type === 'shadow') {
|
|
72
|
+
const s = effect as ShadowEffect
|
|
73
|
+
classes.push(`shadow-[${s.offsetX}px_${s.offsetY}px_${s.blur}px_${s.spread}px_${s.color}]`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return classes
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cornerRadiusToTailwind(
|
|
80
|
+
cr: number | [number, number, number, number] | undefined,
|
|
81
|
+
): string[] {
|
|
82
|
+
if (cr === undefined) return []
|
|
83
|
+
if (typeof cr === 'number') {
|
|
84
|
+
if (cr === 0) return []
|
|
85
|
+
return [`rounded-[${cr}px]`]
|
|
86
|
+
}
|
|
87
|
+
const [tl, tr, br, bl] = cr
|
|
88
|
+
if (tl === tr && tr === br && br === bl) {
|
|
89
|
+
return tl === 0 ? [] : [`rounded-[${tl}px]`]
|
|
90
|
+
}
|
|
91
|
+
return [`rounded-[${tl}px_${tr}px_${br}px_${bl}px]`]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function layoutToTailwind(node: ContainerProps): string[] {
|
|
95
|
+
const classes: string[] = []
|
|
96
|
+
if (node.layout === 'vertical') {
|
|
97
|
+
classes.push('flex', 'flex-col')
|
|
98
|
+
} else if (node.layout === 'horizontal') {
|
|
99
|
+
classes.push('flex', 'flex-row')
|
|
100
|
+
}
|
|
101
|
+
if (node.gap !== undefined) {
|
|
102
|
+
if (typeof node.gap === 'string' && isVariableRef(node.gap)) {
|
|
103
|
+
classes.push(`gap-[${varOrLiteral(node.gap)}]`)
|
|
104
|
+
} else if (typeof node.gap === 'number' && node.gap > 0) {
|
|
105
|
+
classes.push(`gap-[${node.gap}px]`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (node.padding !== undefined) {
|
|
109
|
+
if (typeof node.padding === 'string' && isVariableRef(node.padding)) {
|
|
110
|
+
classes.push(`p-[${varOrLiteral(node.padding)}]`)
|
|
111
|
+
} else if (typeof node.padding === 'number') {
|
|
112
|
+
classes.push(`p-[${node.padding}px]`)
|
|
113
|
+
} else if (Array.isArray(node.padding)) {
|
|
114
|
+
if (node.padding.length === 2) {
|
|
115
|
+
classes.push(`py-[${node.padding[0]}px]`, `px-[${node.padding[1]}px]`)
|
|
116
|
+
} else if (node.padding.length === 4) {
|
|
117
|
+
classes.push(
|
|
118
|
+
`pt-[${node.padding[0]}px]`,
|
|
119
|
+
`pr-[${node.padding[1]}px]`,
|
|
120
|
+
`pb-[${node.padding[2]}px]`,
|
|
121
|
+
`pl-[${node.padding[3]}px]`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (node.justifyContent) {
|
|
127
|
+
const jcMap: Record<string, string> = {
|
|
128
|
+
start: 'justify-start',
|
|
129
|
+
center: 'justify-center',
|
|
130
|
+
end: 'justify-end',
|
|
131
|
+
space_between: 'justify-between',
|
|
132
|
+
space_around: 'justify-around',
|
|
133
|
+
}
|
|
134
|
+
if (jcMap[node.justifyContent]) classes.push(jcMap[node.justifyContent])
|
|
135
|
+
}
|
|
136
|
+
if (node.alignItems) {
|
|
137
|
+
const aiMap: Record<string, string> = {
|
|
138
|
+
start: 'items-start',
|
|
139
|
+
center: 'items-center',
|
|
140
|
+
end: 'items-end',
|
|
141
|
+
}
|
|
142
|
+
if (aiMap[node.alignItems]) classes.push(aiMap[node.alignItems])
|
|
143
|
+
}
|
|
144
|
+
if (node.clipContent) {
|
|
145
|
+
classes.push('overflow-hidden')
|
|
146
|
+
}
|
|
147
|
+
return classes
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sizeToTailwind(
|
|
151
|
+
width: number | string | undefined,
|
|
152
|
+
height: number | string | undefined,
|
|
153
|
+
): string[] {
|
|
154
|
+
const classes: string[] = []
|
|
155
|
+
if (typeof width === 'number') classes.push(`w-[${width}px]`)
|
|
156
|
+
if (typeof height === 'number') classes.push(`h-[${height}px]`)
|
|
157
|
+
return classes
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function opacityToTailwind(opacity: number | string | undefined): string[] {
|
|
161
|
+
if (opacity === undefined || opacity === 1) return []
|
|
162
|
+
if (typeof opacity === 'string' && isVariableRef(opacity)) {
|
|
163
|
+
return [`opacity-[${varOrLiteral(opacity)}]`]
|
|
164
|
+
}
|
|
165
|
+
if (typeof opacity === 'number') {
|
|
166
|
+
const pct = Math.round(opacity * 100)
|
|
167
|
+
return [`opacity-[${pct}%]`]
|
|
168
|
+
}
|
|
169
|
+
return []
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function textTag(node: TextNode): string {
|
|
173
|
+
const size = node.fontSize ?? 16
|
|
174
|
+
if (size >= 32) return 'h1'
|
|
175
|
+
if (size >= 24) return 'h2'
|
|
176
|
+
if (size >= 20) return 'h3'
|
|
177
|
+
return 'p'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getTextContent(node: TextNode): string {
|
|
181
|
+
if (typeof node.content === 'string') return node.content
|
|
182
|
+
return node.content.map((s) => s.text).join('')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function textToTailwind(node: TextNode): string[] {
|
|
186
|
+
const classes: string[] = []
|
|
187
|
+
if (node.fontSize) classes.push(`text-[${node.fontSize}px]`)
|
|
188
|
+
if (node.fontWeight) {
|
|
189
|
+
const w = typeof node.fontWeight === 'number' ? node.fontWeight : parseInt(node.fontWeight, 10)
|
|
190
|
+
if (!isNaN(w)) classes.push(`font-[${w}]`)
|
|
191
|
+
}
|
|
192
|
+
if (node.fontStyle === 'italic') classes.push('italic')
|
|
193
|
+
if (node.textAlign) {
|
|
194
|
+
const taMap: Record<string, string> = {
|
|
195
|
+
left: 'text-left',
|
|
196
|
+
center: 'text-center',
|
|
197
|
+
right: 'text-right',
|
|
198
|
+
justify: 'text-justify',
|
|
199
|
+
}
|
|
200
|
+
if (taMap[node.textAlign]) classes.push(taMap[node.textAlign])
|
|
201
|
+
}
|
|
202
|
+
if (node.fontFamily) classes.push(`font-['${node.fontFamily.replace(/\s/g, '_')}']`)
|
|
203
|
+
if (node.lineHeight) classes.push(`leading-[${node.lineHeight}]`)
|
|
204
|
+
if (node.letterSpacing) classes.push(`tracking-[${node.letterSpacing}px]`)
|
|
205
|
+
if (node.textAlignVertical === 'middle') classes.push('align-middle')
|
|
206
|
+
else if (node.textAlignVertical === 'bottom') classes.push('align-bottom')
|
|
207
|
+
if (node.textGrowth === 'auto') classes.push('whitespace-nowrap')
|
|
208
|
+
else if (node.textGrowth === 'fixed-width-height') classes.push('overflow-hidden')
|
|
209
|
+
if (node.underline) classes.push('underline')
|
|
210
|
+
if (node.strikethrough) classes.push('line-through')
|
|
211
|
+
return classes
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function generateNodeJSX(node: PenNode, depth: number): string {
|
|
215
|
+
const pad = indent(depth)
|
|
216
|
+
const classes: string[] = []
|
|
217
|
+
|
|
218
|
+
// Position
|
|
219
|
+
if (node.x !== undefined || node.y !== undefined) {
|
|
220
|
+
classes.push('absolute')
|
|
221
|
+
if (node.x !== undefined) classes.push(`left-[${node.x}px]`)
|
|
222
|
+
if (node.y !== undefined) classes.push(`top-[${node.y}px]`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Opacity
|
|
226
|
+
classes.push(...opacityToTailwind(node.opacity))
|
|
227
|
+
|
|
228
|
+
// Rotation
|
|
229
|
+
if (node.rotation) {
|
|
230
|
+
classes.push(`rotate-[${node.rotation}deg]`)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
switch (node.type) {
|
|
234
|
+
case 'frame':
|
|
235
|
+
case 'rectangle':
|
|
236
|
+
case 'group': {
|
|
237
|
+
classes.push(
|
|
238
|
+
...sizeToTailwind(node.width, node.height),
|
|
239
|
+
...fillToTailwind(node.fill),
|
|
240
|
+
...strokeToTailwind(node.stroke),
|
|
241
|
+
...cornerRadiusToTailwind(node.cornerRadius),
|
|
242
|
+
...effectsToTailwind(node.effects),
|
|
243
|
+
...layoutToTailwind(node),
|
|
244
|
+
)
|
|
245
|
+
const childNodes = node.children ?? []
|
|
246
|
+
if (childNodes.length === 0) {
|
|
247
|
+
return `${pad}<div className="${classes.join(' ')}" />`
|
|
248
|
+
}
|
|
249
|
+
const childrenJSX = childNodes
|
|
250
|
+
.map((c) => generateNodeJSX(c, depth + 1))
|
|
251
|
+
.join('\n')
|
|
252
|
+
const comment = node.name ? `${pad}{/* ${node.name} */}\n` : ''
|
|
253
|
+
return `${comment}${pad}<div className="${classes.join(' ')}">\n${childrenJSX}\n${pad}</div>`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
case 'ellipse': {
|
|
257
|
+
if (isArcEllipse(node.startAngle, node.sweepAngle, node.innerRadius)) {
|
|
258
|
+
const w = typeof node.width === 'number' ? node.width : 100
|
|
259
|
+
const h = typeof node.height === 'number' ? node.height : 100
|
|
260
|
+
const d = buildEllipseArcPath(w, h, node.startAngle ?? 0, node.sweepAngle ?? 360, node.innerRadius ?? 0)
|
|
261
|
+
const fill = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : '#000'
|
|
262
|
+
classes.push(...effectsToTailwind(node.effects))
|
|
263
|
+
const cls = classes.length > 0 ? ` className="${classes.join(' ')}"` : ''
|
|
264
|
+
return `${pad}<svg${cls} width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><path d="${d}" fill="${fill}" /></svg>`
|
|
265
|
+
}
|
|
266
|
+
classes.push(
|
|
267
|
+
'rounded-full',
|
|
268
|
+
...sizeToTailwind(node.width, node.height),
|
|
269
|
+
...fillToTailwind(node.fill),
|
|
270
|
+
...strokeToTailwind(node.stroke),
|
|
271
|
+
...effectsToTailwind(node.effects),
|
|
272
|
+
)
|
|
273
|
+
return `${pad}<div className="${classes.join(' ')}" />`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'text': {
|
|
277
|
+
const tag = textTag(node)
|
|
278
|
+
classes.push(
|
|
279
|
+
...sizeToTailwind(node.width, node.height),
|
|
280
|
+
...fillToTextColor(node.fill),
|
|
281
|
+
...textToTailwind(node),
|
|
282
|
+
...effectsToTailwind(node.effects),
|
|
283
|
+
)
|
|
284
|
+
const text = escapeJSX(getTextContent(node))
|
|
285
|
+
return `${pad}<${tag} className="${classes.join(' ')}">${text}</${tag}>`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
case 'line': {
|
|
289
|
+
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
|
|
290
|
+
classes.push(`w-[${w}px]`)
|
|
291
|
+
if (node.stroke) {
|
|
292
|
+
const thickness = typeof node.stroke.thickness === 'number'
|
|
293
|
+
? node.stroke.thickness
|
|
294
|
+
: typeof node.stroke.thickness === 'string' ? node.stroke.thickness : node.stroke.thickness[0]
|
|
295
|
+
if (typeof thickness === 'string' && isVariableRef(thickness)) {
|
|
296
|
+
classes.push(`border-t-[${varOrLiteral(thickness)}]`)
|
|
297
|
+
} else {
|
|
298
|
+
classes.push(`border-t-[${thickness}px]`)
|
|
299
|
+
}
|
|
300
|
+
if (node.stroke.fill && node.stroke.fill.length > 0) {
|
|
301
|
+
const sf = node.stroke.fill[0]
|
|
302
|
+
if (sf.type === 'solid') {
|
|
303
|
+
classes.push(`border-[${varOrLiteral(sf.color)}]`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return `${pad}<hr className="${classes.join(' ')}" />`
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
case 'polygon':
|
|
311
|
+
case 'path': {
|
|
312
|
+
// For complex shapes, output an SVG inline
|
|
313
|
+
classes.push(...sizeToTailwind(node.width, node.height))
|
|
314
|
+
if (node.type === 'path') {
|
|
315
|
+
const w = typeof node.width === 'number' ? node.width : 100
|
|
316
|
+
const h = typeof node.height === 'number' ? node.height : 100
|
|
317
|
+
const fillColor = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor'
|
|
318
|
+
return `${pad}<svg className="${classes.join(' ')}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
|
|
319
|
+
}
|
|
320
|
+
classes.push(...fillToTailwind(node.fill))
|
|
321
|
+
return `${pad}<div className="${classes.join(' ')}" />`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'image': {
|
|
325
|
+
classes.push(...sizeToTailwind(node.width, node.height))
|
|
326
|
+
if (node.cornerRadius) classes.push(...cornerRadiusToTailwind(node.cornerRadius))
|
|
327
|
+
classes.push(...effectsToTailwind(node.effects))
|
|
328
|
+
const fit = node.objectFit === 'fit' ? 'object-contain' : node.objectFit === 'crop' ? 'object-cover' : 'object-fill'
|
|
329
|
+
classes.push(fit)
|
|
330
|
+
const src = node.src
|
|
331
|
+
return `${pad}<img className="${classes.join(' ')}" src="${src}" alt="${node.name ?? 'image'}" />`
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case 'icon_font': {
|
|
335
|
+
const size = typeof node.width === 'number' ? node.width : 24
|
|
336
|
+
const color = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor'
|
|
337
|
+
const iconComp = kebabToPascal(node.iconFontName || 'circle')
|
|
338
|
+
return `${pad}<${iconComp} size={${size}} color="${color}" />`
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case 'ref':
|
|
342
|
+
return `${pad}{/* Ref: ${node.ref} */}`
|
|
343
|
+
|
|
344
|
+
default:
|
|
345
|
+
return `${pad}{/* Unknown node */}`
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function escapeJSX(text: string): string {
|
|
350
|
+
return text
|
|
351
|
+
.replace(/&/g, '&')
|
|
352
|
+
.replace(/</g, '<')
|
|
353
|
+
.replace(/>/g, '>')
|
|
354
|
+
.replace(/{/g, '{')
|
|
355
|
+
.replace(/}/g, '}')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function generateReactCode(
|
|
359
|
+
nodes: PenNode[],
|
|
360
|
+
componentName = 'GeneratedDesign',
|
|
361
|
+
): string {
|
|
362
|
+
if (nodes.length === 0) {
|
|
363
|
+
return `export function ${componentName}() {\n return <div className="relative" />\n}\n`
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Find bounding box for the root wrapper
|
|
367
|
+
let maxW = 0
|
|
368
|
+
let maxH = 0
|
|
369
|
+
for (const node of nodes) {
|
|
370
|
+
const x = node.x ?? 0
|
|
371
|
+
const y = node.y ?? 0
|
|
372
|
+
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
|
373
|
+
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
|
374
|
+
maxW = Math.max(maxW, x + w)
|
|
375
|
+
maxH = Math.max(maxH, y + h)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const wrapperClasses = ['relative']
|
|
379
|
+
if (maxW > 0) wrapperClasses.push(`w-[${maxW}px]`)
|
|
380
|
+
if (maxH > 0) wrapperClasses.push(`h-[${maxH}px]`)
|
|
381
|
+
|
|
382
|
+
const childrenJSX = nodes
|
|
383
|
+
.map((n) => generateNodeJSX(n, 2))
|
|
384
|
+
.join('\n')
|
|
385
|
+
|
|
386
|
+
return `export function ${componentName}() {
|
|
387
|
+
return (
|
|
388
|
+
<div className="${wrapperClasses.join(' ')}">
|
|
389
|
+
${childrenJSX}
|
|
390
|
+
</div>
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
`
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function generateReactFromDocument(doc: PenDocument, activePageId?: string | null): string {
|
|
397
|
+
const children = activePageId !== undefined
|
|
398
|
+
? getActivePageChildren(doc, activePageId)
|
|
399
|
+
: doc.children
|
|
400
|
+
return generateReactCode(children, 'GeneratedDesign')
|
|
401
|
+
}
|