@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,403 @@
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, generateCSSVariables } from './css-variables-generator.js'
6
+ import { buildEllipseArcPath, isArcEllipse } from '@zseven-w/pen-core'
7
+
8
+ /**
9
+ * Converts PenDocument nodes to HTML + CSS.
10
+ * $variable references are output as var(--name) CSS custom properties.
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
+ let classCounter = 0
21
+
22
+ function resetClassCounter() {
23
+ classCounter = 0
24
+ }
25
+
26
+ function nextClassName(prefix: string): string {
27
+ classCounter++
28
+ return `${prefix}-${classCounter}`
29
+ }
30
+
31
+ function indent(depth: number): string {
32
+ return ' '.repeat(depth)
33
+ }
34
+
35
+ function fillToCSS(fills: PenFill[] | undefined): Record<string, string> {
36
+ if (!fills || fills.length === 0) return {}
37
+ const fill = fills[0]
38
+ if (fill.type === 'solid') {
39
+ return { background: varOrLiteral(fill.color) }
40
+ }
41
+ if (fill.type === 'linear_gradient') {
42
+ if (!fill.stops?.length) return {}
43
+ const angle = fill.angle ?? 180
44
+ const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ')
45
+ return { background: `linear-gradient(${angle}deg, ${stops})` }
46
+ }
47
+ if (fill.type === 'radial_gradient') {
48
+ if (!fill.stops?.length) return {}
49
+ const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ')
50
+ return { background: `radial-gradient(circle, ${stops})` }
51
+ }
52
+ return {}
53
+ }
54
+
55
+ function strokeToCSS(stroke: PenStroke | undefined): Record<string, string> {
56
+ if (!stroke) return {}
57
+ const css: Record<string, string> = {}
58
+ if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
59
+ css['border-width'] = varOrLiteral(stroke.thickness)
60
+ } else {
61
+ const thickness = typeof stroke.thickness === 'number'
62
+ ? stroke.thickness
63
+ : stroke.thickness[0]
64
+ css['border-width'] = `${thickness}px`
65
+ }
66
+ css['border-style'] = 'solid'
67
+ if (stroke.fill && stroke.fill.length > 0) {
68
+ const sf = stroke.fill[0]
69
+ if (sf.type === 'solid') {
70
+ css['border-color'] = varOrLiteral(sf.color)
71
+ }
72
+ }
73
+ return css
74
+ }
75
+
76
+ function effectsToCSS(effects: PenEffect[] | undefined): Record<string, string> {
77
+ if (!effects || effects.length === 0) return {}
78
+ const shadows: string[] = []
79
+ for (const effect of effects) {
80
+ if (effect.type === 'shadow') {
81
+ const s = effect as ShadowEffect
82
+ const inset = s.inner ? 'inset ' : ''
83
+ shadows.push(`${inset}${s.offsetX}px ${s.offsetY}px ${s.blur}px ${s.spread}px ${s.color}`)
84
+ }
85
+ }
86
+ if (shadows.length > 0) {
87
+ return { 'box-shadow': shadows.join(', ') }
88
+ }
89
+ return {}
90
+ }
91
+
92
+ function cornerRadiusToCSS(
93
+ cr: number | [number, number, number, number] | undefined,
94
+ ): Record<string, string> {
95
+ if (cr === undefined) return {}
96
+ if (typeof cr === 'number') {
97
+ return cr === 0 ? {} : { 'border-radius': `${cr}px` }
98
+ }
99
+ return { 'border-radius': `${cr[0]}px ${cr[1]}px ${cr[2]}px ${cr[3]}px` }
100
+ }
101
+
102
+ function layoutToCSS(node: ContainerProps): Record<string, string> {
103
+ const css: Record<string, string> = {}
104
+ if (node.layout === 'vertical') {
105
+ css.display = 'flex'
106
+ css['flex-direction'] = 'column'
107
+ } else if (node.layout === 'horizontal') {
108
+ css.display = 'flex'
109
+ css['flex-direction'] = 'row'
110
+ }
111
+ if (node.gap !== undefined) {
112
+ if (typeof node.gap === 'string' && isVariableRef(node.gap)) {
113
+ css.gap = varOrLiteral(node.gap)
114
+ } else if (typeof node.gap === 'number') {
115
+ css.gap = `${node.gap}px`
116
+ }
117
+ }
118
+ if (node.padding !== undefined) {
119
+ if (typeof node.padding === 'string' && isVariableRef(node.padding)) {
120
+ css.padding = varOrLiteral(node.padding)
121
+ } else if (typeof node.padding === 'number') {
122
+ css.padding = `${node.padding}px`
123
+ } else if (Array.isArray(node.padding)) {
124
+ css.padding = node.padding.map((p) => `${p}px`).join(' ')
125
+ }
126
+ }
127
+ if (node.justifyContent) {
128
+ const map: Record<string, string> = {
129
+ start: 'flex-start',
130
+ center: 'center',
131
+ end: 'flex-end',
132
+ space_between: 'space-between',
133
+ space_around: 'space-around',
134
+ }
135
+ css['justify-content'] = map[node.justifyContent] ?? node.justifyContent
136
+ }
137
+ if (node.alignItems) {
138
+ const map: Record<string, string> = {
139
+ start: 'flex-start',
140
+ center: 'center',
141
+ end: 'flex-end',
142
+ }
143
+ css['align-items'] = map[node.alignItems] ?? node.alignItems
144
+ }
145
+ if (node.clipContent) {
146
+ css.overflow = 'hidden'
147
+ }
148
+ return css
149
+ }
150
+
151
+ interface CSSRule {
152
+ className: string
153
+ properties: Record<string, string>
154
+ }
155
+
156
+ function getTextContent(node: TextNode): string {
157
+ if (typeof node.content === 'string') return node.content
158
+ return node.content.map((s) => s.text).join('')
159
+ }
160
+
161
+ function escapeHTML(text: string): string {
162
+ return text
163
+ .replace(/&/g, '&amp;')
164
+ .replace(/</g, '&lt;')
165
+ .replace(/>/g, '&gt;')
166
+ .replace(/"/g, '&quot;')
167
+ }
168
+
169
+ function generateNodeHTML(
170
+ node: PenNode,
171
+ depth: number,
172
+ rules: CSSRule[],
173
+ ): string {
174
+ const pad = indent(depth)
175
+ const css: Record<string, string> = {}
176
+
177
+ // Position
178
+ if (node.x !== undefined || node.y !== undefined) {
179
+ css.position = 'absolute'
180
+ if (node.x !== undefined) css.left = `${node.x}px`
181
+ if (node.y !== undefined) css.top = `${node.y}px`
182
+ }
183
+
184
+ // Opacity
185
+ if (node.opacity !== undefined && node.opacity !== 1) {
186
+ if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
187
+ css.opacity = varOrLiteral(node.opacity)
188
+ } else if (typeof node.opacity === 'number') {
189
+ css.opacity = String(node.opacity)
190
+ }
191
+ }
192
+
193
+ // Rotation
194
+ if (node.rotation) {
195
+ css.transform = `rotate(${node.rotation}deg)`
196
+ }
197
+
198
+ switch (node.type) {
199
+ case 'frame':
200
+ case 'rectangle':
201
+ case 'group': {
202
+ if (typeof node.width === 'number') css.width = `${node.width}px`
203
+ if (typeof node.height === 'number') css.height = `${node.height}px`
204
+ Object.assign(css, fillToCSS(node.fill))
205
+ Object.assign(css, strokeToCSS(node.stroke))
206
+ Object.assign(css, cornerRadiusToCSS(node.cornerRadius))
207
+ Object.assign(css, effectsToCSS(node.effects))
208
+ Object.assign(css, layoutToCSS(node))
209
+
210
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
211
+ rules.push({ className, properties: css })
212
+
213
+ const children = node.children ?? []
214
+ if (children.length === 0) {
215
+ return `${pad}<div class="${className}"></div>`
216
+ }
217
+ const childrenHTML = children
218
+ .map((c) => generateNodeHTML(c, depth + 1, rules))
219
+ .join('\n')
220
+ return `${pad}<div class="${className}">\n${childrenHTML}\n${pad}</div>`
221
+ }
222
+
223
+ case 'ellipse': {
224
+ if (isArcEllipse(node.startAngle, node.sweepAngle, node.innerRadius)) {
225
+ const w = typeof node.width === 'number' ? node.width : 100
226
+ const h = typeof node.height === 'number' ? node.height : 100
227
+ const d = buildEllipseArcPath(w, h, node.startAngle ?? 0, node.sweepAngle ?? 360, node.innerRadius ?? 0)
228
+ const fill = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : '#000'
229
+ Object.assign(css, effectsToCSS(node.effects))
230
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'arc')
231
+ rules.push({ className, properties: css })
232
+ return `${pad}<svg class="${className}" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><path d="${d}" fill="${fill}" /></svg>`
233
+ }
234
+ if (typeof node.width === 'number') css.width = `${node.width}px`
235
+ if (typeof node.height === 'number') css.height = `${node.height}px`
236
+ css['border-radius'] = '50%'
237
+ Object.assign(css, fillToCSS(node.fill))
238
+ Object.assign(css, strokeToCSS(node.stroke))
239
+ Object.assign(css, effectsToCSS(node.effects))
240
+
241
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'ellipse')
242
+ rules.push({ className, properties: css })
243
+ return `${pad}<div class="${className}"></div>`
244
+ }
245
+
246
+ case 'text': {
247
+ if (typeof node.width === 'number') css.width = `${node.width}px`
248
+ if (typeof node.height === 'number') css.height = `${node.height}px`
249
+ if (node.fill) {
250
+ const fill = node.fill[0]
251
+ if (fill?.type === 'solid') css.color = varOrLiteral(fill.color)
252
+ }
253
+ if (node.fontSize) css['font-size'] = `${node.fontSize}px`
254
+ if (node.fontWeight) css['font-weight'] = String(node.fontWeight)
255
+ if (node.fontStyle === 'italic') css['font-style'] = 'italic'
256
+ if (node.textAlign) css['text-align'] = node.textAlign
257
+ if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif`
258
+ if (node.lineHeight) css['line-height'] = String(node.lineHeight)
259
+ if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px`
260
+ if (node.textAlignVertical === 'middle') css['vertical-align'] = 'middle'
261
+ else if (node.textAlignVertical === 'bottom') css['vertical-align'] = 'bottom'
262
+ if (node.textGrowth === 'auto') css['white-space'] = 'nowrap'
263
+ else if (node.textGrowth === 'fixed-width-height') css.overflow = 'hidden'
264
+ if (node.underline) css['text-decoration'] = 'underline'
265
+ if (node.strikethrough) css['text-decoration'] = 'line-through'
266
+ Object.assign(css, effectsToCSS(node.effects))
267
+
268
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'text')
269
+ rules.push({ className, properties: css })
270
+
271
+ const size = node.fontSize ?? 16
272
+ const tag = size >= 32 ? 'h1' : size >= 24 ? 'h2' : size >= 20 ? 'h3' : 'p'
273
+ const text = escapeHTML(getTextContent(node))
274
+ return `${pad}<${tag} class="${className}">${text}</${tag}>`
275
+ }
276
+
277
+ case 'line': {
278
+ const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
279
+ css.width = `${w}px`
280
+ if (node.stroke) {
281
+ const thickness = typeof node.stroke.thickness === 'number'
282
+ ? node.stroke.thickness
283
+ : node.stroke.thickness[0]
284
+ css['border-top-width'] = `${thickness}px`
285
+ css['border-top-style'] = 'solid'
286
+ if (node.stroke.fill && node.stroke.fill.length > 0) {
287
+ const sf = node.stroke.fill[0]
288
+ if (sf.type === 'solid') css['border-top-color'] = varOrLiteral(sf.color)
289
+ }
290
+ }
291
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line')
292
+ rules.push({ className, properties: css })
293
+ return `${pad}<hr class="${className}" />`
294
+ }
295
+
296
+ case 'polygon':
297
+ case 'path': {
298
+ if (typeof node.width === 'number') css.width = `${node.width}px`
299
+ if (typeof node.height === 'number') css.height = `${node.height}px`
300
+ Object.assign(css, fillToCSS(node.fill))
301
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
302
+ rules.push({ className, properties: css })
303
+ if (node.type === 'path') {
304
+ const w = typeof node.width === 'number' ? node.width : 100
305
+ const h = typeof node.height === 'number' ? node.height : 100
306
+ const fillColor = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor'
307
+ return `${pad}<svg class="${className}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
308
+ }
309
+ return `${pad}<div class="${className}"></div>`
310
+ }
311
+
312
+ case 'image': {
313
+ if (typeof node.width === 'number') css.width = `${node.width}px`
314
+ if (typeof node.height === 'number') css.height = `${node.height}px`
315
+ const fit = node.objectFit === 'fit' ? 'contain' : node.objectFit === 'crop' ? 'cover' : 'fill'
316
+ css['object-fit'] = fit
317
+ Object.assign(css, cornerRadiusToCSS(node.cornerRadius))
318
+ Object.assign(css, effectsToCSS(node.effects))
319
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'image')
320
+ rules.push({ className, properties: css })
321
+ return `${pad}<img class="${className}" src="${node.src}" alt="${escapeHTML(node.name ?? 'image')}" />`
322
+ }
323
+
324
+ case 'icon_font': {
325
+ const size = typeof node.width === 'number' ? node.width : 24
326
+ css.width = `${size}px`
327
+ css.height = `${size}px`
328
+ if (node.fill?.[0]?.type === 'solid') css.color = varOrLiteral(node.fill[0].color)
329
+ const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'icon')
330
+ rules.push({ className, properties: css })
331
+ return `${pad}<i class="${className}" data-lucide="${escapeHTML(node.iconFontName ?? 'circle')}"></i>`
332
+ }
333
+
334
+ case 'ref':
335
+ return `${pad}<!-- Ref: ${node.ref} -->`
336
+
337
+ default:
338
+ return `${pad}<!-- Unknown node -->`
339
+ }
340
+ }
341
+
342
+ function cssRulesToString(rules: CSSRule[]): string {
343
+ return rules
344
+ .map((r) => {
345
+ const props = Object.entries(r.properties)
346
+ .map(([k, v]) => ` ${k}: ${v};`)
347
+ .join('\n')
348
+ return `.${r.className} {\n${props}\n}`
349
+ })
350
+ .join('\n\n')
351
+ }
352
+
353
+ export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string } {
354
+ resetClassCounter()
355
+ const rules: CSSRule[] = []
356
+
357
+ if (nodes.length === 0) {
358
+ return {
359
+ html: '<div class="container"></div>',
360
+ css: '.container {\n position: relative;\n}',
361
+ }
362
+ }
363
+
364
+ // Compute wrapper size
365
+ let maxW = 0
366
+ let maxH = 0
367
+ for (const node of nodes) {
368
+ const x = node.x ?? 0
369
+ const y = node.y ?? 0
370
+ const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
371
+ const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
372
+ maxW = Math.max(maxW, x + w)
373
+ maxH = Math.max(maxH, y + h)
374
+ }
375
+
376
+ const containerCSS: Record<string, string> = { position: 'relative' }
377
+ if (maxW > 0) containerCSS.width = `${maxW}px`
378
+ if (maxH > 0) containerCSS.height = `${maxH}px`
379
+ rules.push({ className: 'container', properties: containerCSS })
380
+
381
+ const childrenHTML = nodes
382
+ .map((n) => generateNodeHTML(n, 1, rules))
383
+ .join('\n')
384
+
385
+ const html = `<div class="container">\n${childrenHTML}\n</div>`
386
+ const css = cssRulesToString(rules)
387
+
388
+ return { html, css }
389
+ }
390
+
391
+ export function generateHTMLFromDocument(doc: PenDocument, activePageId?: string | null): { html: string; css: string } {
392
+ const children = activePageId !== undefined
393
+ ? getActivePageChildren(doc, activePageId)
394
+ : doc.children
395
+ const result = generateHTMLCode(children)
396
+ const varsCSS = doc.variables && Object.keys(doc.variables).length > 0
397
+ ? generateCSSVariables(doc)
398
+ : ''
399
+ return {
400
+ html: result.html,
401
+ css: varsCSS ? `${varsCSS}\n${result.css}` : result.css,
402
+ }
403
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ // CSS Variables
2
+ export { variableNameToCSS, generateCSSVariables } from './css-variables-generator.js'
3
+
4
+ // React + Tailwind
5
+ export { generateReactCode, generateReactFromDocument } from './react-generator.js'
6
+
7
+ // HTML + CSS
8
+ export { generateHTMLCode, generateHTMLFromDocument } from './html-generator.js'
9
+
10
+ // Vue 3
11
+ export { generateVueCode, generateVueFromDocument } from './vue-generator.js'
12
+
13
+ // Svelte
14
+ export { generateSvelteCode, generateSvelteFromDocument } from './svelte-generator.js'
15
+
16
+ // Flutter / Dart
17
+ export { generateFlutterCode, generateFlutterFromDocument } from './flutter-generator.js'
18
+
19
+ // SwiftUI
20
+ export { generateSwiftUICode, generateSwiftUIFromDocument } from './swiftui-generator.js'
21
+
22
+ // Android Jetpack Compose
23
+ export { generateComposeCode, generateComposeFromDocument } from './compose-generator.js'
24
+
25
+ // React Native
26
+ export { generateReactNativeCode, generateReactNativeFromDocument } from './react-native-generator.js'