@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.
@@ -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, '&amp;')
352
+ .replace(/</g, '&lt;')
353
+ .replace(/>/g, '&gt;')
354
+ .replace(/{/g, '&#123;')
355
+ .replace(/}/g, '&#125;')
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
+ }