@zseven-w/pen-codegen 0.5.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-codegen",
3
- "version": "0.5.2",
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.5.2",
20
- "@zseven-w/pen-core": "0.5.2"
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
+ }
@@ -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,
@@ -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,
@@ -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'
@@ -355,6 +355,7 @@ function escapeJSX(text: string): string {
355
355
  .replace(/}/g, '&#125;')
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,
@@ -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)
@@ -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
+ }
@@ -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)