@zseven-w/pen-codegen 0.0.1 → 0.6.0
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/package.json +3 -3
- package/src/__tests__/utils.test.ts +61 -0
- package/src/codegen-types.ts +112 -0
- package/src/compose-generator.ts +2 -0
- package/src/flutter-generator.ts +2 -0
- package/src/html-generator.ts +2 -0
- package/src/index.ts +21 -0
- package/src/react-generator.ts +2 -0
- package/src/react-native-generator.ts +2 -0
- package/src/svelte-generator.ts +2 -0
- package/src/swiftui-generator.ts +2 -0
- package/src/utils.ts +41 -0
- package/src/vue-generator.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zseven-w/pen-codegen",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Multi-platform code generators for OpenPencil designs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"typecheck": "tsc --noEmit"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@zseven-w/pen-types": "0.0
|
|
20
|
-
"@zseven-w/pen-core": "0.0
|
|
19
|
+
"@zseven-w/pen-types": "0.6.0",
|
|
20
|
+
"@zseven-w/pen-core": "0.6.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"typescript": "^5.7.2"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { varOrLiteral, sanitizeName, nodeTreeToSummary } from '../utils'
|
|
3
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
4
|
+
|
|
5
|
+
describe('varOrLiteral', () => {
|
|
6
|
+
it('returns CSS var() for variable references', () => {
|
|
7
|
+
expect(varOrLiteral('$primary-color')).toBe('var(--primary-color)')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns raw value for non-variable strings', () => {
|
|
11
|
+
expect(varOrLiteral('#ff0000')).toBe('#ff0000')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('handles variable names with spaces', () => {
|
|
15
|
+
expect(varOrLiteral('$Primary Color')).toBe('var(--primary-color)')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('sanitizeName', () => {
|
|
20
|
+
it('converts kebab-case to PascalCase', () => {
|
|
21
|
+
expect(sanitizeName('hero-section')).toBe('HeroSection')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('converts space-separated to PascalCase', () => {
|
|
25
|
+
expect(sanitizeName('my cool component')).toBe('MyCoolComponent')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('handles single word', () => {
|
|
29
|
+
expect(sanitizeName('navbar')).toBe('Navbar')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('strips invalid characters', () => {
|
|
33
|
+
expect(sanitizeName('hello@world#123')).toBe('Helloworld123')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('nodeTreeToSummary', () => {
|
|
38
|
+
it('produces lightweight summary with node IDs', () => {
|
|
39
|
+
const nodes: PenNode[] = [
|
|
40
|
+
{
|
|
41
|
+
id: '1', type: 'frame', name: 'Header',
|
|
42
|
+
x: 0, y: 0, width: 1200, height: 80,
|
|
43
|
+
children: [
|
|
44
|
+
{ id: '1-1', type: 'text', name: 'Title', x: 0, y: 0, width: 200, height: 24 } as PenNode,
|
|
45
|
+
],
|
|
46
|
+
} as PenNode,
|
|
47
|
+
]
|
|
48
|
+
const summary = nodeTreeToSummary(nodes)
|
|
49
|
+
expect(summary).toContain('[1]')
|
|
50
|
+
expect(summary).toContain('Header')
|
|
51
|
+
expect(summary).toContain('frame')
|
|
52
|
+
expect(summary).toContain('1200')
|
|
53
|
+
expect(summary).toContain('[1-1]')
|
|
54
|
+
expect(summary).toContain('Title')
|
|
55
|
+
expect(summary.length).toBeLessThan(500)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty string for empty array', () => {
|
|
59
|
+
expect(nodeTreeToSummary([])).toBe('')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
2
|
+
|
|
3
|
+
// === Canonical framework type ===
|
|
4
|
+
|
|
5
|
+
export type Framework = 'react' | 'vue' | 'svelte' | 'html' | 'flutter' | 'swiftui' | 'compose' | 'react-native'
|
|
6
|
+
|
|
7
|
+
export const FRAMEWORKS: Framework[] = ['react', 'vue', 'svelte', 'html', 'flutter', 'swiftui', 'compose', 'react-native']
|
|
8
|
+
|
|
9
|
+
// === Step 1 output: AI planner returns this (no node data, minimal tokens) ===
|
|
10
|
+
|
|
11
|
+
export interface PlannedChunk {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
nodeIds: string[]
|
|
15
|
+
role: string
|
|
16
|
+
suggestedComponentName: string
|
|
17
|
+
dependencies: string[]
|
|
18
|
+
exposedSlots?: string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CodePlanFromAI {
|
|
22
|
+
chunks: PlannedChunk[]
|
|
23
|
+
sharedStyles: { name: string; description: string }[]
|
|
24
|
+
rootLayout: { direction: string; gap: number; responsive: boolean }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// === Runtime: hydrated with node data + execution order ===
|
|
28
|
+
|
|
29
|
+
export interface ExecutableChunk extends PlannedChunk {
|
|
30
|
+
nodes: PenNode[]
|
|
31
|
+
order: number
|
|
32
|
+
depContracts: ChunkContract[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CodeExecutionPlan {
|
|
36
|
+
chunks: ExecutableChunk[]
|
|
37
|
+
sharedStyles: { name: string; description: string }[]
|
|
38
|
+
rootLayout: { direction: string; gap: number; responsive: boolean }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// === Chunk contract: structured metadata output from each chunk ===
|
|
42
|
+
|
|
43
|
+
export interface ChunkContract {
|
|
44
|
+
chunkId: string
|
|
45
|
+
componentName: string
|
|
46
|
+
exportedProps: PropDef[]
|
|
47
|
+
slots: SlotDef[]
|
|
48
|
+
cssClasses: string[]
|
|
49
|
+
cssVariables: string[]
|
|
50
|
+
imports: ImportDef[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PropDef {
|
|
54
|
+
name: string
|
|
55
|
+
type: string
|
|
56
|
+
required: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SlotDef {
|
|
60
|
+
name: string
|
|
61
|
+
description: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ImportDef {
|
|
65
|
+
source: string
|
|
66
|
+
specifiers: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// === Chunk generation output ===
|
|
70
|
+
|
|
71
|
+
export interface ChunkResult {
|
|
72
|
+
chunkId: string
|
|
73
|
+
code: string
|
|
74
|
+
contract: ChunkContract
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// === Progress events ===
|
|
78
|
+
|
|
79
|
+
export type ChunkStatus = 'pending' | 'running' | 'done' | 'degraded' | 'failed' | 'skipped'
|
|
80
|
+
|
|
81
|
+
export type CodeGenProgress =
|
|
82
|
+
| { step: 'planning'; status: 'running' | 'done' | 'failed'; plan?: CodePlanFromAI; error?: string }
|
|
83
|
+
| { step: 'chunk'; chunkId: string; name: string; status: ChunkStatus; result?: ChunkResult; error?: string }
|
|
84
|
+
| { step: 'assembly'; status: 'running' | 'done' | 'failed'; error?: string }
|
|
85
|
+
| { step: 'complete'; finalCode: string; degraded: boolean }
|
|
86
|
+
| { step: 'error'; message: string; chunkId?: string }
|
|
87
|
+
|
|
88
|
+
// === Contract validation ===
|
|
89
|
+
|
|
90
|
+
export interface ContractValidationResult {
|
|
91
|
+
valid: boolean
|
|
92
|
+
issues: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateContract(result: ChunkResult): ContractValidationResult {
|
|
96
|
+
const issues: string[] = []
|
|
97
|
+
const { contract, code } = result
|
|
98
|
+
|
|
99
|
+
// 1. componentName must be a valid PascalCase identifier (if provided)
|
|
100
|
+
if (contract.componentName && !/^[A-Z][a-zA-Z0-9]*$/.test(contract.componentName)) {
|
|
101
|
+
issues.push(`componentName "${contract.componentName}" is not a valid PascalCase identifier`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. componentName should appear in code (skip for SFC frameworks where name is implicit)
|
|
105
|
+
// Svelte/Vue SFC may have <script>, <template>, or just <style> with HTML
|
|
106
|
+
const isSFC = code.includes('<script') || code.includes('<template') || code.includes('<style')
|
|
107
|
+
if (contract.componentName && !isSFC && !code.includes(contract.componentName)) {
|
|
108
|
+
issues.push(`componentName "${contract.componentName}" not found in generated code`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { valid: issues.length === 0, issues }
|
|
112
|
+
}
|
package/src/compose-generator.ts
CHANGED
|
@@ -721,6 +721,7 @@ function generateImageCompose(node: ImageNode, depth: number): string {
|
|
|
721
721
|
return lines.join('\n')
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
724
725
|
export function generateComposeCode(
|
|
725
726
|
nodes: PenNode[],
|
|
726
727
|
composableName = 'GeneratedDesign',
|
|
@@ -796,6 +797,7 @@ ${childLines}
|
|
|
796
797
|
`
|
|
797
798
|
}
|
|
798
799
|
|
|
800
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
799
801
|
export function generateComposeFromDocument(
|
|
800
802
|
doc: PenDocument,
|
|
801
803
|
activePageId?: string | null,
|
package/src/flutter-generator.ts
CHANGED
|
@@ -514,6 +514,7 @@ function getHelperClasses(nodes: PenNode[]): string {
|
|
|
514
514
|
return helpers.join('\n\n')
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
517
518
|
export function generateFlutterCode(
|
|
518
519
|
nodes: PenNode[],
|
|
519
520
|
widgetName = 'GeneratedDesign',
|
|
@@ -570,6 +571,7 @@ ${childWidgets.map((c) => c + ',').join('\n')}
|
|
|
570
571
|
`
|
|
571
572
|
}
|
|
572
573
|
|
|
574
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
573
575
|
export function generateFlutterFromDocument(
|
|
574
576
|
doc: PenDocument,
|
|
575
577
|
activePageId?: string | null,
|
package/src/html-generator.ts
CHANGED
|
@@ -350,6 +350,7 @@ function cssRulesToString(rules: CSSRule[]): string {
|
|
|
350
350
|
.join('\n\n')
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
353
354
|
export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string } {
|
|
354
355
|
resetClassCounter()
|
|
355
356
|
const rules: CSSRule[] = []
|
|
@@ -388,6 +389,7 @@ export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string
|
|
|
388
389
|
return { html, css }
|
|
389
390
|
}
|
|
390
391
|
|
|
392
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
391
393
|
export function generateHTMLFromDocument(doc: PenDocument, activePageId?: string | null): { html: string; css: string } {
|
|
392
394
|
const children = activePageId !== undefined
|
|
393
395
|
? getActivePageChildren(doc, activePageId)
|
package/src/index.ts
CHANGED
|
@@ -24,3 +24,24 @@ export { generateComposeCode, generateComposeFromDocument } from './compose-gene
|
|
|
24
24
|
|
|
25
25
|
// React Native
|
|
26
26
|
export { generateReactNativeCode, generateReactNativeFromDocument } from './react-native-generator.js'
|
|
27
|
+
|
|
28
|
+
// Utilities
|
|
29
|
+
export { varOrLiteral, sanitizeName, nodeTreeToSummary, isVariableRef } from './utils.js'
|
|
30
|
+
|
|
31
|
+
// Types
|
|
32
|
+
export type {
|
|
33
|
+
Framework,
|
|
34
|
+
PlannedChunk,
|
|
35
|
+
CodePlanFromAI,
|
|
36
|
+
ExecutableChunk,
|
|
37
|
+
CodeExecutionPlan,
|
|
38
|
+
ChunkContract,
|
|
39
|
+
PropDef,
|
|
40
|
+
SlotDef,
|
|
41
|
+
ImportDef,
|
|
42
|
+
ChunkResult,
|
|
43
|
+
ChunkStatus,
|
|
44
|
+
CodeGenProgress,
|
|
45
|
+
ContractValidationResult,
|
|
46
|
+
} from './codegen-types.js'
|
|
47
|
+
export { FRAMEWORKS, validateContract } from './codegen-types.js'
|
package/src/react-generator.ts
CHANGED
|
@@ -355,6 +355,7 @@ function escapeJSX(text: string): string {
|
|
|
355
355
|
.replace(/}/g, '}')
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
358
359
|
export function generateReactCode(
|
|
359
360
|
nodes: PenNode[],
|
|
360
361
|
componentName = 'GeneratedDesign',
|
|
@@ -393,6 +394,7 @@ ${childrenJSX}
|
|
|
393
394
|
`
|
|
394
395
|
}
|
|
395
396
|
|
|
397
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
396
398
|
export function generateReactFromDocument(doc: PenDocument, activePageId?: string | null): string {
|
|
397
399
|
const children = activePageId !== undefined
|
|
398
400
|
? getActivePageChildren(doc, activePageId)
|
|
@@ -453,6 +453,7 @@ function polygonPoints(sides: number, w: number, h: number): string {
|
|
|
453
453
|
return points.join(' ')
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
456
457
|
export function generateReactNativeCode(
|
|
457
458
|
nodes: PenNode[],
|
|
458
459
|
componentName = 'GeneratedDesign',
|
|
@@ -558,6 +559,7 @@ function hasNodeType(nodes: PenNode[], type: string): boolean {
|
|
|
558
559
|
return false
|
|
559
560
|
}
|
|
560
561
|
|
|
562
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
561
563
|
export function generateReactNativeFromDocument(
|
|
562
564
|
doc: PenDocument,
|
|
563
565
|
activePageId?: string | null,
|
package/src/svelte-generator.ts
CHANGED
|
@@ -258,6 +258,7 @@ function cssRulesToString(rules: CSSRule[]): string {
|
|
|
258
258
|
// Public API
|
|
259
259
|
// ---------------------------------------------------------------------------
|
|
260
260
|
|
|
261
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
261
262
|
export function generateSvelteCode(nodes: PenNode[]): string {
|
|
262
263
|
resetClassCounter()
|
|
263
264
|
const rules: CSSRule[] = []
|
|
@@ -290,6 +291,7 @@ ${css}
|
|
|
290
291
|
`
|
|
291
292
|
}
|
|
292
293
|
|
|
294
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
293
295
|
export function generateSvelteFromDocument(doc: PenDocument, activePageId?: string | null): string {
|
|
294
296
|
const children = activePageId !== undefined ? getActivePageChildren(doc, activePageId) : doc.children
|
|
295
297
|
return generateSvelteCode(children)
|
package/src/swiftui-generator.ts
CHANGED
|
@@ -669,6 +669,7 @@ function generateImageSwiftUI(node: ImageNode, depth: number): string {
|
|
|
669
669
|
return renderWithModifiers(pad, `Image("${escapedSrc}")`, modifiers)
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
672
673
|
export function generateSwiftUICode(
|
|
673
674
|
nodes: PenNode[],
|
|
674
675
|
viewName = 'GeneratedView',
|
|
@@ -743,6 +744,7 @@ ${childLines}
|
|
|
743
744
|
`
|
|
744
745
|
}
|
|
745
746
|
|
|
747
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
746
748
|
export function generateSwiftUIFromDocument(
|
|
747
749
|
doc: PenDocument,
|
|
748
750
|
activePageId?: string | null,
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
2
|
+
import { isVariableRef } from '@zseven-w/pen-core'
|
|
3
|
+
import { variableNameToCSS } from './css-variables-generator.js'
|
|
4
|
+
|
|
5
|
+
// Re-export for convenience
|
|
6
|
+
export { variableNameToCSS } from './css-variables-generator.js'
|
|
7
|
+
export { isVariableRef } from '@zseven-w/pen-core'
|
|
8
|
+
|
|
9
|
+
export function varOrLiteral(value: string): string {
|
|
10
|
+
if (isVariableRef(value)) {
|
|
11
|
+
return `var(${variableNameToCSS(value.slice(1))})`
|
|
12
|
+
}
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sanitizeName(name: string): string {
|
|
17
|
+
return name
|
|
18
|
+
.replace(/[^a-zA-Z0-9\s-_]/g, '')
|
|
19
|
+
.split(/[\s\-_]+/)
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
22
|
+
.join('')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function nodeTreeToSummary(nodes: PenNode[], depth: number = 0): string {
|
|
26
|
+
if (nodes.length === 0) return ''
|
|
27
|
+
|
|
28
|
+
const indent = ' '.repeat(depth)
|
|
29
|
+
return nodes.map(node => {
|
|
30
|
+
const n = node as unknown as Record<string, unknown>
|
|
31
|
+
const dims = `${n.width ?? '?'}x${n.height ?? '?'}`
|
|
32
|
+
const childCount = (n.children as PenNode[] | undefined)?.length ?? 0
|
|
33
|
+
const role = n.role ? ` [${n.role}]` : ''
|
|
34
|
+
const line = `${indent}- [${node.id}] ${node.type} "${node.name ?? ''}" (${dims})${role}${childCount > 0 ? ` [${childCount} children]` : ''}`
|
|
35
|
+
const children = n.children as PenNode[] | undefined
|
|
36
|
+
if (children && children.length > 0) {
|
|
37
|
+
return line + '\n' + nodeTreeToSummary(children, depth + 1)
|
|
38
|
+
}
|
|
39
|
+
return line
|
|
40
|
+
}).join('\n')
|
|
41
|
+
}
|
package/src/vue-generator.ts
CHANGED
|
@@ -264,6 +264,7 @@ function cssRulesToString(rules: CSSRule[]): string {
|
|
|
264
264
|
// Public API
|
|
265
265
|
// ---------------------------------------------------------------------------
|
|
266
266
|
|
|
267
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
267
268
|
export function generateVueCode(nodes: PenNode[], componentName = 'GeneratedDesign'): string {
|
|
268
269
|
resetClassCounter()
|
|
269
270
|
const rules: CSSRule[] = []
|
|
@@ -302,6 +303,7 @@ ${css}
|
|
|
302
303
|
`
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
/** @deprecated Use AI code generation pipeline instead. Will be removed in v1.0.0. */
|
|
305
307
|
export function generateVueFromDocument(doc: PenDocument, activePageId?: string | null): string {
|
|
306
308
|
const children = activePageId !== undefined ? getActivePageChildren(doc, activePageId) : doc.children
|
|
307
309
|
return generateVueCode(children)
|