@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,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
+ }