@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,296 @@
|
|
|
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 Svelte component code.
|
|
10
|
+
* Generates a single .svelte file with markup + scoped <style>.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function varOrLiteral(value: string): string {
|
|
14
|
+
if (isVariableRef(value)) return `var(${variableNameToCSS(value.slice(1))})`
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function indent(depth: number): string {
|
|
19
|
+
return ' '.repeat(depth)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// CSS helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function fillToCSS(fills: PenFill[] | undefined): Record<string, string> {
|
|
27
|
+
if (!fills || fills.length === 0) return {}
|
|
28
|
+
const fill = fills[0]
|
|
29
|
+
if (fill.type === 'solid') return { background: varOrLiteral(fill.color) }
|
|
30
|
+
if (fill.type === 'linear_gradient') {
|
|
31
|
+
if (!fill.stops?.length) return {}
|
|
32
|
+
const angle = fill.angle ?? 180
|
|
33
|
+
const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ')
|
|
34
|
+
return { background: `linear-gradient(${angle}deg, ${stops})` }
|
|
35
|
+
}
|
|
36
|
+
if (fill.type === 'radial_gradient') {
|
|
37
|
+
if (!fill.stops?.length) return {}
|
|
38
|
+
const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ')
|
|
39
|
+
return { background: `radial-gradient(circle, ${stops})` }
|
|
40
|
+
}
|
|
41
|
+
return {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function strokeToCSS(stroke: PenStroke | undefined): Record<string, string> {
|
|
45
|
+
if (!stroke) return {}
|
|
46
|
+
const css: Record<string, string> = {}
|
|
47
|
+
if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
|
|
48
|
+
css['border-width'] = varOrLiteral(stroke.thickness)
|
|
49
|
+
} else {
|
|
50
|
+
const t = typeof stroke.thickness === 'number' ? stroke.thickness : stroke.thickness[0]
|
|
51
|
+
css['border-width'] = `${t}px`
|
|
52
|
+
}
|
|
53
|
+
css['border-style'] = 'solid'
|
|
54
|
+
if (stroke.fill?.[0]?.type === 'solid') css['border-color'] = varOrLiteral(stroke.fill[0].color)
|
|
55
|
+
return css
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function effectsToCSS(effects: PenEffect[] | undefined): Record<string, string> {
|
|
59
|
+
if (!effects || effects.length === 0) return {}
|
|
60
|
+
const shadows: string[] = []
|
|
61
|
+
for (const effect of effects) {
|
|
62
|
+
if (effect.type === 'shadow') {
|
|
63
|
+
const s = effect as ShadowEffect
|
|
64
|
+
shadows.push(`${s.inner ? 'inset ' : ''}${s.offsetX}px ${s.offsetY}px ${s.blur}px ${s.spread}px ${s.color}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return shadows.length > 0 ? { 'box-shadow': shadows.join(', ') } : {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cornerRadiusToCSS(cr: number | [number, number, number, number] | undefined): Record<string, string> {
|
|
71
|
+
if (cr === undefined) return {}
|
|
72
|
+
if (typeof cr === 'number') return cr === 0 ? {} : { 'border-radius': `${cr}px` }
|
|
73
|
+
return { 'border-radius': `${cr[0]}px ${cr[1]}px ${cr[2]}px ${cr[3]}px` }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function layoutToCSS(node: ContainerProps): Record<string, string> {
|
|
77
|
+
const css: Record<string, string> = {}
|
|
78
|
+
if (node.layout === 'vertical') { css.display = 'flex'; css['flex-direction'] = 'column' }
|
|
79
|
+
else if (node.layout === 'horizontal') { css.display = 'flex'; css['flex-direction'] = 'row' }
|
|
80
|
+
if (node.gap !== undefined) {
|
|
81
|
+
css.gap = typeof node.gap === 'string' && isVariableRef(node.gap)
|
|
82
|
+
? varOrLiteral(node.gap)
|
|
83
|
+
: typeof node.gap === 'number' ? `${node.gap}px` : ''
|
|
84
|
+
}
|
|
85
|
+
if (node.padding !== undefined) {
|
|
86
|
+
if (typeof node.padding === 'string' && isVariableRef(node.padding)) css.padding = varOrLiteral(node.padding)
|
|
87
|
+
else if (typeof node.padding === 'number') css.padding = `${node.padding}px`
|
|
88
|
+
else if (Array.isArray(node.padding)) css.padding = node.padding.map((p) => `${p}px`).join(' ')
|
|
89
|
+
}
|
|
90
|
+
if (node.justifyContent) {
|
|
91
|
+
const map: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end', space_between: 'space-between', space_around: 'space-around' }
|
|
92
|
+
css['justify-content'] = map[node.justifyContent] ?? node.justifyContent
|
|
93
|
+
}
|
|
94
|
+
if (node.alignItems) {
|
|
95
|
+
const map: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end' }
|
|
96
|
+
css['align-items'] = map[node.alignItems] ?? node.alignItems
|
|
97
|
+
}
|
|
98
|
+
if (node.clipContent) css.overflow = 'hidden'
|
|
99
|
+
return css
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Node → markup + CSS rules
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
interface CSSRule { className: string; properties: Record<string, string> }
|
|
107
|
+
|
|
108
|
+
let classCounter = 0
|
|
109
|
+
function resetClassCounter() { classCounter = 0 }
|
|
110
|
+
function nextClassName(prefix: string): string { return `${prefix}-${++classCounter}` }
|
|
111
|
+
|
|
112
|
+
function escapeHTML(text: string): string {
|
|
113
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getTextContent(node: TextNode): string {
|
|
117
|
+
if (typeof node.content === 'string') return node.content
|
|
118
|
+
return node.content.map((s) => s.text).join('')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function generateNodeMarkup(node: PenNode, depth: number, rules: CSSRule[]): string {
|
|
122
|
+
const pad = indent(depth)
|
|
123
|
+
const css: Record<string, string> = {}
|
|
124
|
+
|
|
125
|
+
if (node.x !== undefined || node.y !== undefined) {
|
|
126
|
+
css.position = 'absolute'
|
|
127
|
+
if (node.x !== undefined) css.left = `${node.x}px`
|
|
128
|
+
if (node.y !== undefined) css.top = `${node.y}px`
|
|
129
|
+
}
|
|
130
|
+
if (node.opacity !== undefined && node.opacity !== 1) {
|
|
131
|
+
if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) css.opacity = varOrLiteral(node.opacity)
|
|
132
|
+
else if (typeof node.opacity === 'number') css.opacity = String(node.opacity)
|
|
133
|
+
}
|
|
134
|
+
if (node.rotation) css.transform = `rotate(${node.rotation}deg)`
|
|
135
|
+
|
|
136
|
+
switch (node.type) {
|
|
137
|
+
case 'frame':
|
|
138
|
+
case 'rectangle':
|
|
139
|
+
case 'group': {
|
|
140
|
+
if (typeof node.width === 'number') css.width = `${node.width}px`
|
|
141
|
+
if (typeof node.height === 'number') css.height = `${node.height}px`
|
|
142
|
+
Object.assign(css, fillToCSS(node.fill), strokeToCSS(node.stroke), cornerRadiusToCSS(node.cornerRadius), effectsToCSS(node.effects), layoutToCSS(node))
|
|
143
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
|
|
144
|
+
rules.push({ className, properties: css })
|
|
145
|
+
const children = node.children ?? []
|
|
146
|
+
if (children.length === 0) return `${pad}<div class="${className}" />`
|
|
147
|
+
const childrenMarkup = children.map((c) => generateNodeMarkup(c, depth + 1, rules)).join('\n')
|
|
148
|
+
return `${pad}<div class="${className}">\n${childrenMarkup}\n${pad}</div>`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case 'ellipse': {
|
|
152
|
+
if (isArcEllipse(node.startAngle, node.sweepAngle, node.innerRadius)) {
|
|
153
|
+
const w = typeof node.width === 'number' ? node.width : 100
|
|
154
|
+
const h = typeof node.height === 'number' ? node.height : 100
|
|
155
|
+
const d = buildEllipseArcPath(w, h, node.startAngle ?? 0, node.sweepAngle ?? 360, node.innerRadius ?? 0)
|
|
156
|
+
const fill = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : '#000'
|
|
157
|
+
Object.assign(css, effectsToCSS(node.effects))
|
|
158
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'arc')
|
|
159
|
+
rules.push({ className, properties: css })
|
|
160
|
+
return `${pad}<svg class="${className}" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><path d="${d}" fill="${fill}" /></svg>`
|
|
161
|
+
}
|
|
162
|
+
if (typeof node.width === 'number') css.width = `${node.width}px`
|
|
163
|
+
if (typeof node.height === 'number') css.height = `${node.height}px`
|
|
164
|
+
css['border-radius'] = '50%'
|
|
165
|
+
Object.assign(css, fillToCSS(node.fill), strokeToCSS(node.stroke), effectsToCSS(node.effects))
|
|
166
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'ellipse')
|
|
167
|
+
rules.push({ className, properties: css })
|
|
168
|
+
return `${pad}<div class="${className}" />`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case 'text': {
|
|
172
|
+
if (typeof node.width === 'number') css.width = `${node.width}px`
|
|
173
|
+
if (typeof node.height === 'number') css.height = `${node.height}px`
|
|
174
|
+
if (node.fill) { const f = node.fill[0]; if (f?.type === 'solid') css.color = varOrLiteral(f.color) }
|
|
175
|
+
if (node.fontSize) css['font-size'] = `${node.fontSize}px`
|
|
176
|
+
if (node.fontWeight) css['font-weight'] = String(node.fontWeight)
|
|
177
|
+
if (node.fontStyle === 'italic') css['font-style'] = 'italic'
|
|
178
|
+
if (node.textAlign) css['text-align'] = node.textAlign
|
|
179
|
+
if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif`
|
|
180
|
+
if (node.lineHeight) css['line-height'] = String(node.lineHeight)
|
|
181
|
+
if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px`
|
|
182
|
+
if (node.underline) css['text-decoration'] = 'underline'
|
|
183
|
+
if (node.strikethrough) css['text-decoration'] = 'line-through'
|
|
184
|
+
Object.assign(css, effectsToCSS(node.effects))
|
|
185
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'text')
|
|
186
|
+
rules.push({ className, properties: css })
|
|
187
|
+
const size = node.fontSize ?? 16
|
|
188
|
+
const tag = size >= 32 ? 'h1' : size >= 24 ? 'h2' : size >= 20 ? 'h3' : 'p'
|
|
189
|
+
return `${pad}<${tag} class="${className}">${escapeHTML(getTextContent(node))}</${tag}>`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case 'line': {
|
|
193
|
+
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
|
|
194
|
+
css.width = `${w}px`
|
|
195
|
+
if (node.stroke) {
|
|
196
|
+
const t = typeof node.stroke.thickness === 'number' ? node.stroke.thickness : node.stroke.thickness[0]
|
|
197
|
+
css['border-top-width'] = `${t}px`
|
|
198
|
+
css['border-top-style'] = 'solid'
|
|
199
|
+
if (node.stroke.fill?.[0]?.type === 'solid') css['border-top-color'] = varOrLiteral(node.stroke.fill[0].color)
|
|
200
|
+
}
|
|
201
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line')
|
|
202
|
+
rules.push({ className, properties: css })
|
|
203
|
+
return `${pad}<hr class="${className}" />`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'polygon':
|
|
207
|
+
case 'path': {
|
|
208
|
+
if (typeof node.width === 'number') css.width = `${node.width}px`
|
|
209
|
+
if (typeof node.height === 'number') css.height = `${node.height}px`
|
|
210
|
+
Object.assign(css, fillToCSS(node.fill))
|
|
211
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
|
|
212
|
+
rules.push({ className, properties: css })
|
|
213
|
+
if (node.type === 'path') {
|
|
214
|
+
const w = typeof node.width === 'number' ? node.width : 100
|
|
215
|
+
const h = typeof node.height === 'number' ? node.height : 100
|
|
216
|
+
const fillColor = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor'
|
|
217
|
+
return `${pad}<svg class="${className}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
|
|
218
|
+
}
|
|
219
|
+
return `${pad}<div class="${className}" />`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'image': {
|
|
223
|
+
if (typeof node.width === 'number') css.width = `${node.width}px`
|
|
224
|
+
if (typeof node.height === 'number') css.height = `${node.height}px`
|
|
225
|
+
css['object-fit'] = node.objectFit ?? 'fill'
|
|
226
|
+
Object.assign(css, cornerRadiusToCSS(node.cornerRadius))
|
|
227
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'image')
|
|
228
|
+
rules.push({ className, properties: css })
|
|
229
|
+
return `${pad}<img class="${className}" src="${escapeHTML(node.src)}" alt="${escapeHTML(node.name ?? '')}" />`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'icon_font': {
|
|
233
|
+
const size = typeof node.width === 'number' ? node.width : 24
|
|
234
|
+
css.width = `${size}px`
|
|
235
|
+
css.height = `${size}px`
|
|
236
|
+
if (node.fill?.[0]?.type === 'solid') css.color = varOrLiteral(node.fill[0].color)
|
|
237
|
+
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'icon')
|
|
238
|
+
rules.push({ className, properties: css })
|
|
239
|
+
return `${pad}<i class="${className}" data-lucide="${escapeHTML(node.iconFontName ?? 'circle')}" />`
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case 'ref':
|
|
243
|
+
return `${pad}<!-- Ref: ${node.ref} -->`
|
|
244
|
+
|
|
245
|
+
default:
|
|
246
|
+
return `${pad}<!-- Unknown node -->`
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function cssRulesToString(rules: CSSRule[]): string {
|
|
251
|
+
return rules.map((r) => {
|
|
252
|
+
const props = Object.entries(r.properties).map(([k, v]) => ` ${k}: ${v};`).join('\n')
|
|
253
|
+
return `.${r.className} {\n${props}\n}`
|
|
254
|
+
}).join('\n\n')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Public API
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
export function generateSvelteCode(nodes: PenNode[]): string {
|
|
262
|
+
resetClassCounter()
|
|
263
|
+
const rules: CSSRule[] = []
|
|
264
|
+
|
|
265
|
+
let maxW = 0, maxH = 0
|
|
266
|
+
for (const node of nodes) {
|
|
267
|
+
const x = node.x ?? 0, y = node.y ?? 0
|
|
268
|
+
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
|
269
|
+
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
|
270
|
+
maxW = Math.max(maxW, x + w)
|
|
271
|
+
maxH = Math.max(maxH, y + h)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const containerCSS: Record<string, string> = { position: 'relative' }
|
|
275
|
+
if (maxW > 0) containerCSS.width = `${maxW}px`
|
|
276
|
+
if (maxH > 0) containerCSS.height = `${maxH}px`
|
|
277
|
+
rules.push({ className: 'container', properties: containerCSS })
|
|
278
|
+
|
|
279
|
+
const markup = nodes.length === 0
|
|
280
|
+
? '<div class="container" />'
|
|
281
|
+
: `<div class="container">\n${nodes.map((n) => generateNodeMarkup(n, 1, rules)).join('\n')}\n</div>`
|
|
282
|
+
|
|
283
|
+
const css = cssRulesToString(rules)
|
|
284
|
+
|
|
285
|
+
return `${markup}
|
|
286
|
+
|
|
287
|
+
<style>
|
|
288
|
+
${css}
|
|
289
|
+
</style>
|
|
290
|
+
`
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function generateSvelteFromDocument(doc: PenDocument, activePageId?: string | null): string {
|
|
294
|
+
const children = activePageId !== undefined ? getActivePageChildren(doc, activePageId) : doc.children
|
|
295
|
+
return generateSvelteCode(children)
|
|
296
|
+
}
|