@zseven-w/pen-core 0.5.2 → 0.7.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.
Files changed (45) hide show
  1. package/README.md +18 -9
  2. package/package.json +5 -5
  3. package/src/__tests__/arc-path.test.ts +23 -23
  4. package/src/__tests__/codegen-utils.test.ts +50 -0
  5. package/src/__tests__/design-md-parser.test.ts +49 -0
  6. package/src/__tests__/font-utils.test.ts +15 -15
  7. package/src/__tests__/layout-engine.test.ts +169 -83
  8. package/src/__tests__/merge-helpers.test.ts +143 -0
  9. package/src/__tests__/node-diff.test.ts +139 -0
  10. package/src/__tests__/node-helpers.test.ts +19 -19
  11. package/src/__tests__/node-merge.test.ts +425 -0
  12. package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
  13. package/src/__tests__/normalize-tree-layout.test.ts +294 -0
  14. package/src/__tests__/normalize.test.ts +119 -80
  15. package/src/__tests__/path-anchors.test.ts +98 -0
  16. package/src/__tests__/resolve-variables-recursive.test.ts +173 -0
  17. package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
  18. package/src/__tests__/text-measure.test.ts +84 -79
  19. package/src/__tests__/tree-utils.test.ts +133 -102
  20. package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
  21. package/src/__tests__/variables.test.ts +68 -65
  22. package/src/arc-path.ts +35 -35
  23. package/src/boolean-ops.ts +158 -111
  24. package/src/constants.ts +36 -36
  25. package/src/design-md-parser.ts +363 -0
  26. package/src/font-utils.ts +30 -15
  27. package/src/id.ts +1 -1
  28. package/src/index.ts +47 -13
  29. package/src/layout/engine.ts +255 -230
  30. package/src/layout/normalize-tree.ts +140 -0
  31. package/src/layout/strip-redundant-section-fills.ts +155 -0
  32. package/src/layout/text-measure.ts +133 -105
  33. package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
  34. package/src/merge/index.ts +16 -0
  35. package/src/merge/merge-helpers.ts +113 -0
  36. package/src/merge/node-diff.ts +123 -0
  37. package/src/merge/node-merge.ts +651 -0
  38. package/src/node-helpers.ts +18 -5
  39. package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
  40. package/src/normalize.ts +79 -79
  41. package/src/path-anchors.ts +331 -0
  42. package/src/sync-lock.ts +3 -3
  43. package/src/tree-utils.ts +180 -158
  44. package/src/variables/replace-refs.ts +63 -60
  45. package/src/variables/resolve.ts +107 -102
@@ -0,0 +1,363 @@
1
+ import type { DesignMdSpec, DesignMdColor, DesignMdTypography } from '@zseven-w/pen-types';
2
+ import type { PenDocument } from '@zseven-w/pen-types';
3
+ import type { VariableDefinition } from '@zseven-w/pen-types';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Section header patterns (fuzzy matching)
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const SECTION_PATTERNS: Record<string, RegExp> = {
10
+ visualTheme: /visual\s*theme|atmosphere|mood/i,
11
+ colorPalette: /colou?r\s*(palette|roles?|system)?/i,
12
+ typography: /typography|type\s*rules?|fonts?/i,
13
+ componentStyles: /component\s*(styl|pattern)|button|card|input/i,
14
+ layoutPrinciples: /layout\s*(principle|rule|system)?|grid|whitespace/i,
15
+ generationNotes: /generation\s*notes?|design\s*system\s*notes?|stitch|prompting/i,
16
+ };
17
+
18
+ interface ParsedSection {
19
+ key: string;
20
+ title: string;
21
+ content: string;
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Parse markdown into sections
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function splitSections(markdown: string): { projectName?: string; sections: ParsedSection[] } {
29
+ const lines = markdown.split('\n');
30
+ let projectName: string | undefined;
31
+
32
+ // Extract project name from H1
33
+ const h1Match = markdown.match(/^#\s+(?:Design\s+System:\s*)?(.+)$/m);
34
+ if (h1Match) projectName = h1Match[1].trim();
35
+
36
+ const sections: ParsedSection[] = [];
37
+ let currentTitle = '';
38
+ let currentLines: string[] = [];
39
+ let currentKey = '';
40
+
41
+ for (const line of lines) {
42
+ const h2Match = line.match(/^##\s+(?:\d+\.\s*)?(.+)$/);
43
+ if (h2Match) {
44
+ // Flush previous section
45
+ if (currentKey) {
46
+ sections.push({
47
+ key: currentKey,
48
+ title: currentTitle,
49
+ content: currentLines.join('\n').trim(),
50
+ });
51
+ }
52
+ currentTitle = h2Match[1].trim();
53
+ currentLines = [];
54
+ currentKey = matchSectionKey(currentTitle);
55
+ } else {
56
+ currentLines.push(line);
57
+ }
58
+ }
59
+
60
+ // Flush last section
61
+ if (currentKey) {
62
+ sections.push({
63
+ key: currentKey,
64
+ title: currentTitle,
65
+ content: currentLines.join('\n').trim(),
66
+ });
67
+ }
68
+
69
+ return { projectName, sections };
70
+ }
71
+
72
+ function matchSectionKey(title: string): string {
73
+ for (const [key, pattern] of Object.entries(SECTION_PATTERNS)) {
74
+ if (pattern.test(title)) return key;
75
+ }
76
+ // Fallback: try to match common section names loosely
77
+ const lower = title.toLowerCase();
78
+ if (lower.includes('theme') || lower.includes('vibe') || lower.includes('aesthetic'))
79
+ return 'visualTheme';
80
+ if (lower.includes('color') || lower.includes('palette') || lower.includes('colour'))
81
+ return 'colorPalette';
82
+ if (lower.includes('type') || lower.includes('font') || lower.includes('typo'))
83
+ return 'typography';
84
+ if (lower.includes('component') || lower.includes('style') || lower.includes('element'))
85
+ return 'componentStyles';
86
+ if (lower.includes('layout') || lower.includes('spacing') || lower.includes('grid'))
87
+ return 'layoutPrinciples';
88
+ if (lower.includes('note') || lower.includes('generation') || lower.includes('usage'))
89
+ return 'generationNotes';
90
+ return 'unknown';
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Color extraction
95
+ // ---------------------------------------------------------------------------
96
+
97
+ const HEX_COLOR_RE = /#([0-9A-Fa-f]{6})\b/;
98
+
99
+ function parseColors(content: string): DesignMdColor[] {
100
+ const colors: DesignMdColor[] = [];
101
+ const lines = content.split('\n');
102
+
103
+ for (const line of lines) {
104
+ // Skip JSON/code artifacts
105
+ if (line.trimStart().startsWith('{') || line.trimStart().startsWith('`')) continue;
106
+
107
+ const hexMatch = line.match(HEX_COLOR_RE);
108
+ if (!hexMatch) continue;
109
+
110
+ const hex = `#${hexMatch[1].toUpperCase()}`;
111
+
112
+ // Try pattern: **Name** (hex) — role OR - **Name** (#hex) – role
113
+ const namedMatch = line.match(/\*\*([^*]+)\*\*\s*\(?#?[0-9A-Fa-f]{6}\)?\s*[–—-]\s*(.+)/);
114
+ if (namedMatch) {
115
+ colors.push({ name: namedMatch[1].trim(), hex, role: namedMatch[2].trim() });
116
+ continue;
117
+ }
118
+
119
+ // Try pattern: - Name (#hex) — role OR Name (#hex): role
120
+ const dashMatch = line.match(/[-*]\s*(.+?)\s*\(#?[0-9A-Fa-f]{6}\)\s*[–—:-]\s*(.+)/);
121
+ if (dashMatch) {
122
+ colors.push({ name: dashMatch[1].trim(), hex, role: dashMatch[2].trim() });
123
+ continue;
124
+ }
125
+
126
+ // Try pattern: - name: #hex (role) OR - name: #hex — role
127
+ const colonHexMatch = line.match(/[-*]\s*([^:#(]+?)\s*:\s*#?[0-9A-Fa-f]{6}\s*\(?([^)]*)\)?/);
128
+ if (colonHexMatch) {
129
+ colors.push({
130
+ name: colonHexMatch[1].trim(),
131
+ hex,
132
+ role: colonHexMatch[2].trim(),
133
+ });
134
+ continue;
135
+ }
136
+
137
+ // Fallback: just grab the hex and surrounding text
138
+ const before = line
139
+ .substring(0, hexMatch.index)
140
+ .replace(/[-*#\s]+$/, '')
141
+ .trim();
142
+ const after = line
143
+ .substring((hexMatch.index ?? 0) + hexMatch[0].length)
144
+ .replace(/^[)\s–—:-]+/, '')
145
+ .trim();
146
+ colors.push({
147
+ name: before || hex,
148
+ hex,
149
+ role: after || '',
150
+ });
151
+ }
152
+
153
+ return colors;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Typography extraction
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function parseTypography(content: string): DesignMdTypography {
161
+ const typo: DesignMdTypography = {};
162
+
163
+ // Font family
164
+ const fontMatch = content.match(/(?:font\s*family|primary\s*font)[:\s]*\*?\*?([^*\n]+)/i);
165
+ if (fontMatch) typo.fontFamily = fontMatch[1].trim();
166
+
167
+ // Try to extract heading/body descriptions
168
+ const headingMatch = content.match(/(?:heading|display|h1)[^:]*:\s*([^\n]+)/i);
169
+ if (headingMatch) typo.headings = headingMatch[1].trim();
170
+
171
+ const bodyMatch = content.match(/(?:body\s*text|paragraph)[^:]*:\s*([^\n]+)/i);
172
+ if (bodyMatch) typo.body = bodyMatch[1].trim();
173
+
174
+ // Keep full content as scale description
175
+ typo.scale = content;
176
+
177
+ return typo;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Public API
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** Parse a design.md markdown string into a structured DesignMdSpec. */
185
+ export function parseDesignMd(markdown: string): DesignMdSpec {
186
+ const { projectName, sections } = splitSections(markdown);
187
+
188
+ const spec: DesignMdSpec = { raw: markdown, projectName, colorPalette: [] };
189
+
190
+ for (const section of sections) {
191
+ switch (section.key) {
192
+ case 'visualTheme':
193
+ spec.visualTheme = section.content;
194
+ break;
195
+ case 'colorPalette':
196
+ spec.colorPalette = parseColors(section.content);
197
+ break;
198
+ case 'typography':
199
+ spec.typography = parseTypography(section.content);
200
+ break;
201
+ case 'componentStyles':
202
+ spec.componentStyles = section.content;
203
+ break;
204
+ case 'layoutPrinciples':
205
+ spec.layoutPrinciples = section.content;
206
+ break;
207
+ case 'generationNotes':
208
+ spec.generationNotes = section.content;
209
+ break;
210
+ case 'unknown':
211
+ // Append to componentStyles as catch-all
212
+ spec.componentStyles = spec.componentStyles
213
+ ? spec.componentStyles + '\n\n' + section.content
214
+ : section.content;
215
+ break;
216
+ }
217
+ }
218
+
219
+ // Fallback: if no sections were parsed at all, try to extract colors
220
+ // and typography from the entire markdown
221
+ if (!spec.colorPalette || spec.colorPalette.length === 0) {
222
+ const colors = parseColors(markdown);
223
+ if (colors.length > 0) spec.colorPalette = colors;
224
+ }
225
+ if (!spec.typography) {
226
+ const typo = parseTypography(markdown);
227
+ if (typo.fontFamily) spec.typography = typo;
228
+ }
229
+ // If still nothing parsed, store the whole text as visual theme
230
+ if (!spec.visualTheme && !spec.colorPalette && !spec.componentStyles && sections.length === 0) {
231
+ spec.visualTheme = markdown.trim();
232
+ }
233
+
234
+ return spec;
235
+ }
236
+
237
+ /** Generate a design.md markdown string from a DesignMdSpec. */
238
+ export function generateDesignMd(spec: DesignMdSpec): string {
239
+ // If raw was set and nothing was structurally changed, return as-is
240
+ if (spec.raw) return spec.raw;
241
+
242
+ const lines: string[] = [];
243
+
244
+ lines.push(`# Design System: ${spec.projectName ?? 'Untitled'}`);
245
+ lines.push('');
246
+
247
+ if (spec.visualTheme) {
248
+ lines.push('## 1. Visual Theme & Atmosphere');
249
+ lines.push(spec.visualTheme);
250
+ lines.push('');
251
+ }
252
+
253
+ if (spec.colorPalette?.length) {
254
+ lines.push('## 2. Color Palette & Roles');
255
+ lines.push('');
256
+ for (const c of spec.colorPalette) {
257
+ lines.push(`- **${c.name}** (${c.hex}) — ${c.role}`);
258
+ }
259
+ lines.push('');
260
+ }
261
+
262
+ if (spec.typography) {
263
+ lines.push('## 3. Typography Rules');
264
+ if (spec.typography.fontFamily) {
265
+ lines.push(`**Primary Font Family:** ${spec.typography.fontFamily}`);
266
+ }
267
+ if (spec.typography.scale) {
268
+ lines.push(spec.typography.scale);
269
+ }
270
+ lines.push('');
271
+ }
272
+
273
+ if (spec.componentStyles) {
274
+ lines.push('## 4. Component Stylings');
275
+ lines.push(spec.componentStyles);
276
+ lines.push('');
277
+ }
278
+
279
+ if (spec.layoutPrinciples) {
280
+ lines.push('## 5. Layout Principles');
281
+ lines.push(spec.layoutPrinciples);
282
+ lines.push('');
283
+ }
284
+
285
+ if (spec.generationNotes) {
286
+ lines.push('## 6. Design System Notes');
287
+ lines.push(spec.generationNotes);
288
+ lines.push('');
289
+ }
290
+
291
+ return lines.join('\n');
292
+ }
293
+
294
+ /** Auto-extract a DesignMdSpec from an existing PenDocument. */
295
+ export function extractDesignMdFromDocument(doc: PenDocument): DesignMdSpec {
296
+ const colors: DesignMdColor[] = [];
297
+
298
+ // Extract colors from document variables
299
+ if (doc.variables) {
300
+ for (const [name, def] of Object.entries(doc.variables)) {
301
+ if (def.type !== 'color') continue;
302
+ const value =
303
+ typeof def.value === 'string'
304
+ ? def.value
305
+ : Array.isArray(def.value)
306
+ ? String(def.value[0]?.value ?? '')
307
+ : String(def.value);
308
+ if (/^#[0-9A-Fa-f]{6,8}$/.test(value)) {
309
+ colors.push({
310
+ name: name.replace(/^[$]/, ''),
311
+ hex: value.substring(0, 7).toUpperCase(),
312
+ role: `Design variable $${name}`,
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ // Collect font families from text nodes
319
+ const fonts = new Set<string>();
320
+ const collectFonts = (nodes: { fontFamily?: string; children?: unknown[] }[]) => {
321
+ for (const n of nodes) {
322
+ if ('fontFamily' in n && typeof n.fontFamily === 'string') fonts.add(n.fontFamily);
323
+ if ('children' in n && Array.isArray(n.children))
324
+ collectFonts(n.children as { fontFamily?: string; children?: unknown[] }[]);
325
+ }
326
+ };
327
+ collectFonts(doc.children as { fontFamily?: string; children?: unknown[] }[]);
328
+ if (doc.pages) {
329
+ for (const page of doc.pages) {
330
+ collectFonts(page.children as { fontFamily?: string; children?: unknown[] }[]);
331
+ }
332
+ }
333
+
334
+ const typography: DesignMdTypography = {};
335
+ if (fonts.size > 0) typography.fontFamily = [...fonts].join(', ');
336
+
337
+ const spec: DesignMdSpec = {
338
+ raw: '',
339
+ projectName: doc.name ?? 'Untitled',
340
+ colorPalette: colors.length > 0 ? colors : undefined,
341
+ typography: fonts.size > 0 ? typography : undefined,
342
+ };
343
+
344
+ // Generate the markdown and set as raw
345
+ spec.raw = generateDesignMd(spec);
346
+
347
+ return spec;
348
+ }
349
+
350
+ /** Convert design.md colors to document variables. */
351
+ export function designMdColorsToVariables(
352
+ colors: DesignMdColor[],
353
+ ): Record<string, VariableDefinition> {
354
+ const vars: Record<string, VariableDefinition> = {};
355
+ for (const color of colors) {
356
+ const key = color.name
357
+ .toLowerCase()
358
+ .replace(/[^a-z0-9]+/g, '-')
359
+ .replace(/^-|-$/g, '');
360
+ vars[key] = { type: 'color', value: color.hex };
361
+ }
362
+ return vars;
363
+ }
package/src/font-utils.ts CHANGED
@@ -3,21 +3,36 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  const GENERIC_FAMILIES = new Set([
6
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
7
- 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
8
- '-apple-system', 'blinkmacsystemfont',
9
- ])
6
+ 'serif',
7
+ 'sans-serif',
8
+ 'monospace',
9
+ 'cursive',
10
+ 'fantasy',
11
+ 'system-ui',
12
+ 'ui-serif',
13
+ 'ui-sans-serif',
14
+ 'ui-monospace',
15
+ 'ui-rounded',
16
+ '-apple-system',
17
+ 'blinkmacsystemfont',
18
+ ]);
10
19
 
11
20
  export function cssFontFamily(family: string): string {
12
- return family.split(',').map(f => {
13
- const trimmed = f.trim()
14
- if (!trimmed) return trimmed
15
- // Already quoted
16
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
17
- (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed
18
- // Generic families must not be quoted
19
- if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed
20
- // Quote everything else (safe even for single-word names)
21
- return `"${trimmed}"`
22
- }).join(', ')
21
+ return family
22
+ .split(',')
23
+ .map((f) => {
24
+ const trimmed = f.trim();
25
+ if (!trimmed) return trimmed;
26
+ // Already quoted
27
+ if (
28
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
29
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
30
+ )
31
+ return trimmed;
32
+ // Generic families must not be quoted
33
+ if (GENERIC_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
34
+ // Quote everything else (safe even for single-word names)
35
+ return `"${trimmed}"`;
36
+ })
37
+ .join(', ');
23
38
  }
package/src/id.ts CHANGED
@@ -1 +1 @@
1
- export { nanoid as generateId } from 'nanoid'
1
+ export { nanoid as generateId } from 'nanoid';
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // ID generation
2
- export { generateId } from './id.js'
2
+ export { generateId } from './id.js';
3
3
 
4
4
  // Tree utilities
5
5
  export {
@@ -26,7 +26,8 @@ export {
26
26
  deepCloneNode,
27
27
  cloneNodeWithNewIds,
28
28
  cloneNodesWithNewIds,
29
- } from './tree-utils.js'
29
+ nodeTreeToSummary,
30
+ } from './tree-utils.js';
30
31
 
31
32
  // Variables
32
33
  export {
@@ -36,11 +37,11 @@ export {
36
37
  resolveColorRef,
37
38
  resolveNumericRef,
38
39
  resolveNodeForCanvas,
39
- } from './variables/resolve.js'
40
- export { replaceVariableRefsInTree } from './variables/replace-refs.js'
40
+ } from './variables/resolve.js';
41
+ export { replaceVariableRefsInTree } from './variables/replace-refs.js';
41
42
 
42
43
  // Normalization
43
- export { normalizePenDocument } from './normalize.js'
44
+ export { normalizePenDocument } from './normalize.js';
44
45
 
45
46
  // Layout
46
47
  export {
@@ -55,7 +56,11 @@ export {
55
56
  getNodeWidth,
56
57
  getNodeHeight,
57
58
  computeLayoutPositions,
58
- } from './layout/engine.js'
59
+ } from './layout/engine.js';
60
+ export { normalizeTreeLayout } from './layout/normalize-tree.js';
61
+ export { unwrapFakePhoneMockups } from './layout/unwrap-fake-phone-mockup.js';
62
+ export { stripRedundantSectionFills } from './layout/strip-redundant-section-fills.js';
63
+ export { normalizeStrokeFillSchema } from './normalize/normalize-stroke-fill-schema.js';
59
64
 
60
65
  // Text measurement
61
66
  export {
@@ -75,7 +80,7 @@ export {
75
80
  type WrappedLineCounter,
76
81
  setWrappedLineCounter,
77
82
  estimateTextHeight,
78
- } from './layout/text-measure.js'
83
+ } from './layout/text-measure.js';
79
84
 
80
85
  // Constants
81
86
  export {
@@ -115,19 +120,48 @@ export {
115
120
  GUIDE_COLOR,
116
121
  GUIDE_LINE_WIDTH,
117
122
  GUIDE_DASH,
118
- } from './constants.js'
123
+ } from './constants.js';
119
124
 
120
125
  // Sync lock
121
- export { isFabricSyncLocked, setFabricSyncLock } from './sync-lock.js'
126
+ export { isFabricSyncLocked, setFabricSyncLock } from './sync-lock.js';
122
127
 
123
128
  // Arc path
124
- export { buildEllipseArcPath, isArcEllipse } from './arc-path.js'
129
+ export { buildEllipseArcPath, isArcEllipse } from './arc-path.js';
130
+ export {
131
+ anchorsToPathData,
132
+ getPathBoundsFromAnchors,
133
+ inferPathAnchorPointType,
134
+ pathDataToAnchors,
135
+ type PathBounds,
136
+ type PathAnchorParseResult,
137
+ } from './path-anchors.js';
125
138
 
126
139
  // Boolean operations
127
- export { type BooleanOpType, canBooleanOp, executeBooleanOp } from './boolean-ops.js'
140
+ export { type BooleanOpType, canBooleanOp, executeBooleanOp } from './boolean-ops.js';
128
141
 
129
142
  // Font utilities
130
- export { cssFontFamily } from './font-utils.js'
143
+ export { cssFontFamily } from './font-utils.js';
131
144
 
132
145
  // Node helpers
133
- export { isBadgeOverlayNode } from './node-helpers.js'
146
+ export { isBadgeOverlayNode, sanitizeName } from './node-helpers.js';
147
+
148
+ // Design-MD parser
149
+ export {
150
+ parseDesignMd,
151
+ generateDesignMd,
152
+ designMdColorsToVariables,
153
+ extractDesignMdFromDocument,
154
+ } from './design-md-parser.js';
155
+
156
+ // --- Merge module ---
157
+ export type { NodePatch } from './merge/node-diff.js';
158
+ export { diffDocuments } from './merge/node-diff.js';
159
+ export type {
160
+ MergeInput,
161
+ MergeResult,
162
+ NodeConflict,
163
+ NodeConflictReason,
164
+ DocFieldConflict,
165
+ DocFieldName,
166
+ } from './merge/node-merge.js';
167
+ export { mergeDocuments } from './merge/node-merge.js';