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