@zseven-w/pen-core 0.6.0 → 0.7.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.
- package/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +98 -106
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZSeven—W
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,80 +1,232 @@
|
|
|
1
1
|
# @zseven-w/pen-core
|
|
2
2
|
|
|
3
|
-
Core document operations for [OpenPencil](https://github.com/
|
|
3
|
+
Core document operations for [OpenPencil](https://github.com/ZSeven-W/openpencil) — tree manipulation, layout engine, design variables, boolean path operations, 3-way merge, and more.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install @zseven-w/pen-core
|
|
9
|
+
# or
|
|
10
|
+
bun add @zseven-w/pen-core
|
|
9
11
|
```
|
|
10
12
|
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
`pen-core` is the foundation layer of the OpenPencil stack. It provides pure functions for every document operation — tree CRUD, auto-layout computation, variable resolution, SVG path booleans, and document normalization. All operations are immutable (structural sharing) and framework-free.
|
|
16
|
+
|
|
11
17
|
## Features
|
|
12
18
|
|
|
13
19
|
### Document Tree Operations
|
|
14
20
|
|
|
15
21
|
Create, query, and mutate the document tree:
|
|
16
22
|
|
|
17
|
-
```
|
|
23
|
+
```typescript
|
|
18
24
|
import {
|
|
19
25
|
createEmptyDocument,
|
|
20
26
|
findNodeInTree,
|
|
27
|
+
findParentInTree,
|
|
21
28
|
insertNodeInTree,
|
|
22
29
|
removeNodeFromTree,
|
|
23
30
|
updateNodeInTree,
|
|
24
|
-
deepCloneNode,
|
|
25
31
|
flattenNodes,
|
|
26
|
-
|
|
32
|
+
isDescendantOf,
|
|
33
|
+
getNodeBounds,
|
|
34
|
+
} from '@zseven-w/pen-core';
|
|
35
|
+
|
|
36
|
+
const doc = createEmptyDocument();
|
|
37
|
+
const node = findNodeInTree(doc.children, 'node-id');
|
|
38
|
+
const parent = findParentInTree(doc.children, 'node-id');
|
|
39
|
+
const flat = flattenNodes(doc.children); // all nodes in flat array
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Node Cloning
|
|
43
|
+
|
|
44
|
+
Deep clone nodes with optional ID regeneration:
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { deepCloneNode, cloneNodeWithNewIds, cloneNodesWithNewIds } from '@zseven-w/pen-core';
|
|
27
48
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
49
|
+
const clone = deepCloneNode(node); // preserve IDs
|
|
50
|
+
const fresh = cloneNodeWithNewIds(node); // new IDs for all descendants
|
|
51
|
+
const batch = cloneNodesWithNewIds([a, b, c]); // batch clone
|
|
30
52
|
```
|
|
31
53
|
|
|
32
54
|
### Multi-Page Support
|
|
33
55
|
|
|
34
|
-
```
|
|
35
|
-
import {
|
|
56
|
+
```typescript
|
|
57
|
+
import {
|
|
58
|
+
getActivePage,
|
|
59
|
+
getActivePageChildren,
|
|
60
|
+
setActivePageChildren,
|
|
61
|
+
migrateToPages,
|
|
62
|
+
ensureDocumentNodeIds,
|
|
63
|
+
} from '@zseven-w/pen-core';
|
|
64
|
+
|
|
65
|
+
// Migrate a single-page document to multi-page format
|
|
66
|
+
const multiPageDoc = migrateToPages(doc);
|
|
36
67
|
```
|
|
37
68
|
|
|
38
69
|
### Layout Engine
|
|
39
70
|
|
|
40
|
-
|
|
71
|
+
Flexbox-like auto-layout computation supporting `fill_container`, `fit_content`, gap, padding, and alignment:
|
|
41
72
|
|
|
42
|
-
```
|
|
43
|
-
import {
|
|
73
|
+
```typescript
|
|
74
|
+
import {
|
|
75
|
+
computeLayoutPositions,
|
|
76
|
+
inferLayout,
|
|
77
|
+
fitContentWidth,
|
|
78
|
+
fitContentHeight,
|
|
79
|
+
resolvePadding,
|
|
80
|
+
getNodeWidth,
|
|
81
|
+
getNodeHeight,
|
|
82
|
+
isNodeVisible,
|
|
83
|
+
} from '@zseven-w/pen-core';
|
|
84
|
+
|
|
85
|
+
// Compute absolute positions for all children in a layout container
|
|
86
|
+
const positions = computeLayoutPositions(frame, frame.children);
|
|
87
|
+
|
|
88
|
+
// Infer layout direction from child arrangement
|
|
89
|
+
const layout = inferLayout(children); // 'horizontal' | 'vertical' | 'none'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Text Measurement
|
|
93
|
+
|
|
94
|
+
Estimate text dimensions for layout without a browser DOM:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import {
|
|
98
|
+
estimateTextWidth,
|
|
99
|
+
estimateTextWidthPrecise,
|
|
100
|
+
estimateTextHeight,
|
|
101
|
+
resolveTextContent,
|
|
102
|
+
hasCjkText,
|
|
103
|
+
defaultLineHeight,
|
|
104
|
+
} from '@zseven-w/pen-core';
|
|
105
|
+
|
|
106
|
+
const width = estimateTextWidth('Hello World', 16, 400); // font size, weight
|
|
107
|
+
const height = estimateTextHeight(textNode, containerWidth);
|
|
108
|
+
const isCjk = hasCjkText('こんにちは'); // true
|
|
44
109
|
```
|
|
45
110
|
|
|
46
111
|
### Design Variables
|
|
47
112
|
|
|
48
|
-
Resolve `$variable` references against theme axes:
|
|
113
|
+
Resolve `$variable` references against document variables and theme axes:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import {
|
|
117
|
+
isVariableRef,
|
|
118
|
+
resolveVariableRef,
|
|
119
|
+
resolveNodeForCanvas,
|
|
120
|
+
replaceVariableRefsInTree,
|
|
121
|
+
getDefaultTheme,
|
|
122
|
+
} from '@zseven-w/pen-core';
|
|
123
|
+
|
|
124
|
+
isVariableRef('$primary'); // true
|
|
125
|
+
|
|
126
|
+
// Resolve all $refs in a node tree for rendering
|
|
127
|
+
const resolved = resolveNodeForCanvas(node, doc.variables, doc.themes);
|
|
49
128
|
|
|
50
|
-
|
|
51
|
-
|
|
129
|
+
// Rename $old-name → $new-name across entire tree
|
|
130
|
+
replaceVariableRefsInTree(children, 'old-name', 'new-name');
|
|
52
131
|
```
|
|
53
132
|
|
|
54
133
|
### Boolean Path Operations
|
|
55
134
|
|
|
56
135
|
Union, subtract, intersect, and exclude paths via Paper.js:
|
|
57
136
|
|
|
58
|
-
```
|
|
59
|
-
import { executeBooleanOp, BooleanOpType } from '@zseven-w/pen-core'
|
|
137
|
+
```typescript
|
|
138
|
+
import { executeBooleanOp, canBooleanOp, BooleanOpType } from '@zseven-w/pen-core';
|
|
139
|
+
|
|
140
|
+
if (canBooleanOp(selectedNodes)) {
|
|
141
|
+
const result = executeBooleanOp(selectedNodes, BooleanOpType.Union);
|
|
142
|
+
}
|
|
60
143
|
```
|
|
61
144
|
|
|
62
|
-
###
|
|
145
|
+
### Document Normalization
|
|
63
146
|
|
|
64
|
-
|
|
147
|
+
Sanitize and fix documents from external sources (Figma imports, AI generation):
|
|
65
148
|
|
|
66
|
-
```
|
|
67
|
-
import {
|
|
149
|
+
```typescript
|
|
150
|
+
import { normalizePenDocument } from '@zseven-w/pen-core';
|
|
151
|
+
|
|
152
|
+
const cleaned = normalizePenDocument(rawDoc);
|
|
153
|
+
// Fixes: fill type "color" → "solid", gradient stop position → offset,
|
|
154
|
+
// sizing strings, padding arrays. Preserves $variable refs.
|
|
68
155
|
```
|
|
69
156
|
|
|
70
|
-
###
|
|
157
|
+
### Layout Normalization
|
|
158
|
+
|
|
159
|
+
Repair AI-generated layout issues:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import {
|
|
163
|
+
normalizeTreeLayout,
|
|
164
|
+
unwrapFakePhoneMockups,
|
|
165
|
+
stripRedundantSectionFills,
|
|
166
|
+
normalizeStrokeFillSchema,
|
|
167
|
+
} from '@zseven-w/pen-core';
|
|
168
|
+
|
|
169
|
+
// Infer missing layout modes, strip child x/y in layout containers
|
|
170
|
+
normalizeTreeLayout(rootNode);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 3-Way Document Merge
|
|
174
|
+
|
|
175
|
+
Diff and merge document trees for collaborative editing and git integration:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { diffDocuments, mergeDocuments } from '@zseven-w/pen-core';
|
|
179
|
+
|
|
180
|
+
// One-direction diff: base → current
|
|
181
|
+
const patches = diffDocuments(base, current);
|
|
182
|
+
// patches: NodePatch[] — add, remove, modify, move
|
|
71
183
|
|
|
72
|
-
|
|
184
|
+
// 3-way merge: base + ours + theirs
|
|
185
|
+
const result = mergeDocuments(base, ours, theirs);
|
|
186
|
+
// result: { document, conflicts }
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Design.md Parser
|
|
190
|
+
|
|
191
|
+
Parse and generate design specification documents:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import {
|
|
195
|
+
parseDesignMd,
|
|
196
|
+
generateDesignMd,
|
|
197
|
+
designMdColorsToVariables,
|
|
198
|
+
extractDesignMdFromDocument,
|
|
199
|
+
} from '@zseven-w/pen-core';
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Path Anchors
|
|
203
|
+
|
|
204
|
+
Convert between anchor point representation and SVG path data:
|
|
73
205
|
|
|
74
|
-
```
|
|
75
|
-
import {
|
|
206
|
+
```typescript
|
|
207
|
+
import {
|
|
208
|
+
anchorsToPathData,
|
|
209
|
+
pathDataToAnchors,
|
|
210
|
+
getPathBoundsFromAnchors,
|
|
211
|
+
inferPathAnchorPointType,
|
|
212
|
+
} from '@zseven-w/pen-core';
|
|
76
213
|
```
|
|
77
214
|
|
|
215
|
+
## API Reference
|
|
216
|
+
|
|
217
|
+
| Category | Key Functions |
|
|
218
|
+
| ------------- | ---------------------------------------------------------------------------------------------- |
|
|
219
|
+
| **Tree CRUD** | `findNodeInTree`, `insertNodeInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes` |
|
|
220
|
+
| **Cloning** | `deepCloneNode`, `cloneNodeWithNewIds`, `cloneNodesWithNewIds` |
|
|
221
|
+
| **Pages** | `getActivePage`, `migrateToPages`, `ensureDocumentNodeIds` |
|
|
222
|
+
| **Layout** | `computeLayoutPositions`, `inferLayout`, `fitContentWidth`, `fitContentHeight` |
|
|
223
|
+
| **Text** | `estimateTextWidth`, `estimateTextHeight`, `hasCjkText` |
|
|
224
|
+
| **Variables** | `resolveVariableRef`, `resolveNodeForCanvas`, `replaceVariableRefsInTree` |
|
|
225
|
+
| **Boolean** | `executeBooleanOp`, `canBooleanOp` |
|
|
226
|
+
| **Normalize** | `normalizePenDocument`, `normalizeTreeLayout` |
|
|
227
|
+
| **Merge** | `diffDocuments`, `mergeDocuments` |
|
|
228
|
+
| **IDs** | `generateId` |
|
|
229
|
+
|
|
78
230
|
## License
|
|
79
231
|
|
|
80
|
-
MIT
|
|
232
|
+
[MIT](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zseven-w/pen-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Core document operations, tree utils, variables, layout engine for OpenPencil",
|
|
5
|
+
"homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-core",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/ZSeven-W/openpencil/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "ZSeven-W",
|
|
12
|
+
"email": "xkayshen@gmail.com"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/ZSeven-W/openpencil.git",
|
|
17
|
+
"directory": "packages/pen-core"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
5
22
|
"type": "module",
|
|
6
23
|
"exports": {
|
|
7
24
|
".": {
|
|
@@ -9,14 +26,11 @@
|
|
|
9
26
|
"import": "./src/index.ts"
|
|
10
27
|
}
|
|
11
28
|
},
|
|
12
|
-
"files": [
|
|
13
|
-
"src"
|
|
14
|
-
],
|
|
15
29
|
"scripts": {
|
|
16
30
|
"typecheck": "tsc --noEmit"
|
|
17
31
|
},
|
|
18
32
|
"dependencies": {
|
|
19
|
-
"@zseven-w/pen-types": "0.
|
|
33
|
+
"@zseven-w/pen-types": "0.7.1",
|
|
20
34
|
"nanoid": "^5.1.6",
|
|
21
35
|
"paper": "^0.12.18"
|
|
22
36
|
},
|
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildEllipseArcPath, isArcEllipse } from '../arc-path'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildEllipseArcPath, isArcEllipse } from '../arc-path';
|
|
3
3
|
|
|
4
4
|
describe('arc-path', () => {
|
|
5
5
|
describe('isArcEllipse', () => {
|
|
6
6
|
it('returns false for full circle with no inner radius', () => {
|
|
7
|
-
expect(isArcEllipse(0, 360, 0)).toBe(false)
|
|
8
|
-
})
|
|
7
|
+
expect(isArcEllipse(0, 360, 0)).toBe(false);
|
|
8
|
+
});
|
|
9
9
|
|
|
10
10
|
it('returns true for partial sweep', () => {
|
|
11
|
-
expect(isArcEllipse(0, 180, 0)).toBe(true)
|
|
12
|
-
})
|
|
11
|
+
expect(isArcEllipse(0, 180, 0)).toBe(true);
|
|
12
|
+
});
|
|
13
13
|
|
|
14
14
|
it('returns true for inner radius (donut)', () => {
|
|
15
|
-
expect(isArcEllipse(0, 360, 0.5)).toBe(true)
|
|
16
|
-
})
|
|
17
|
-
})
|
|
15
|
+
expect(isArcEllipse(0, 360, 0.5)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
18
|
|
|
19
19
|
describe('buildEllipseArcPath', () => {
|
|
20
20
|
it('builds a full circle path', () => {
|
|
21
|
-
const path = buildEllipseArcPath(100, 100, 0, 360, 0)
|
|
22
|
-
expect(path).toContain('M')
|
|
23
|
-
expect(path).toContain('A')
|
|
24
|
-
expect(path).toContain('Z')
|
|
25
|
-
})
|
|
21
|
+
const path = buildEllipseArcPath(100, 100, 0, 360, 0);
|
|
22
|
+
expect(path).toContain('M');
|
|
23
|
+
expect(path).toContain('A');
|
|
24
|
+
expect(path).toContain('Z');
|
|
25
|
+
});
|
|
26
26
|
|
|
27
27
|
it('builds a pie slice path', () => {
|
|
28
|
-
const path = buildEllipseArcPath(100, 100, 0, 90, 0)
|
|
29
|
-
expect(path).toContain('M50 50') // center point for pie
|
|
30
|
-
expect(path).toContain('Z')
|
|
31
|
-
})
|
|
28
|
+
const path = buildEllipseArcPath(100, 100, 0, 90, 0);
|
|
29
|
+
expect(path).toContain('M50 50'); // center point for pie
|
|
30
|
+
expect(path).toContain('Z');
|
|
31
|
+
});
|
|
32
32
|
|
|
33
33
|
it('builds a donut path with inner radius', () => {
|
|
34
|
-
const path = buildEllipseArcPath(100, 100, 0, 360, 0.5)
|
|
34
|
+
const path = buildEllipseArcPath(100, 100, 0, 360, 0.5);
|
|
35
35
|
// Should have inner arc commands
|
|
36
|
-
expect(path.split('A').length).toBeGreaterThanOrEqual(3) // outer + inner arcs
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
-
})
|
|
36
|
+
expect(path.split('A').length).toBeGreaterThanOrEqual(3); // outer + inner arcs
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sanitizeName, nodeTreeToSummary } from '../index';
|
|
3
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
4
|
+
|
|
5
|
+
describe('sanitizeName', () => {
|
|
6
|
+
it('converts kebab-case to PascalCase', () => {
|
|
7
|
+
expect(sanitizeName('hero-section')).toBe('HeroSection');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('strips non-alphanumeric characters', () => {
|
|
11
|
+
expect(sanitizeName('Card #1 (main)')).toBe('Card1Main');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('handles empty string', () => {
|
|
15
|
+
expect(sanitizeName('')).toBe('');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('nodeTreeToSummary', () => {
|
|
20
|
+
it('renders single node', () => {
|
|
21
|
+
const nodes: PenNode[] = [
|
|
22
|
+
{ id: 'n1', type: 'frame', name: 'Hero', width: 800, height: 600 } as PenNode,
|
|
23
|
+
];
|
|
24
|
+
const result = nodeTreeToSummary(nodes);
|
|
25
|
+
expect(result).toContain('[n1]');
|
|
26
|
+
expect(result).toContain('frame');
|
|
27
|
+
expect(result).toContain('"Hero"');
|
|
28
|
+
expect(result).toContain('800x600');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders nested children with indentation', () => {
|
|
32
|
+
const nodes: PenNode[] = [
|
|
33
|
+
{
|
|
34
|
+
id: 'p',
|
|
35
|
+
type: 'frame',
|
|
36
|
+
name: 'Parent',
|
|
37
|
+
width: 100,
|
|
38
|
+
height: 100,
|
|
39
|
+
children: [{ id: 'c', type: 'rectangle', name: 'Child', width: 50, height: 50 } as PenNode],
|
|
40
|
+
} as unknown as PenNode,
|
|
41
|
+
];
|
|
42
|
+
const result = nodeTreeToSummary(nodes);
|
|
43
|
+
expect(result).toContain('[1 children]');
|
|
44
|
+
expect(result).toContain(' - [c]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns empty string for empty array', () => {
|
|
48
|
+
expect(nodeTreeToSummary([])).toBe('');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseDesignMd, generateDesignMd, designMdColorsToVariables } from '../design-md-parser';
|
|
3
|
+
|
|
4
|
+
describe('parseDesignMd', () => {
|
|
5
|
+
it('should parse a basic design.md with color palette', () => {
|
|
6
|
+
const md = `# My App\n\n## Color Palette\n- primary: #3B82F6 (main brand)\n- secondary: #10B981 (accents)\n\n## Typography\nFont family: Inter\nHeadings: 700 weight\nBody: 400 weight\nScale: 1.25\n`;
|
|
7
|
+
const result = parseDesignMd(md);
|
|
8
|
+
expect(result.projectName).toBe('My App');
|
|
9
|
+
expect(result.colorPalette).toHaveLength(2);
|
|
10
|
+
expect(result.colorPalette![0]).toMatchObject({ name: 'primary', hex: '#3B82F6' });
|
|
11
|
+
expect(result.typography?.fontFamily).toBe('Inter');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return empty spec for empty input', () => {
|
|
15
|
+
const result = parseDesignMd('');
|
|
16
|
+
expect(result.projectName).toBeUndefined();
|
|
17
|
+
expect(result.colorPalette).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should handle fuzzy section headers', () => {
|
|
21
|
+
const md = `# Test\n\n## Visual Theme & Atmosphere\nMinimal, clean\n\n## Colour Roles\n- bg: #FFFFFF (background)\n`;
|
|
22
|
+
const result = parseDesignMd(md);
|
|
23
|
+
expect(result.visualTheme).toContain('Minimal');
|
|
24
|
+
expect(result.colorPalette).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('generateDesignMd', () => {
|
|
29
|
+
it('should roundtrip parse → generate → parse', () => {
|
|
30
|
+
const md = `# Roundtrip\n\n## Color Palette\n- primary: #FF0000 (brand)\n- text: #333333 (body text)\n\n## Typography\nFont family: Roboto\nHeadings: 700 weight\nBody: 400 weight\nScale: 1.2\n`;
|
|
31
|
+
const parsed = parseDesignMd(md);
|
|
32
|
+
const generated = generateDesignMd(parsed);
|
|
33
|
+
const reparsed = parseDesignMd(generated);
|
|
34
|
+
expect(reparsed.colorPalette).toHaveLength(parsed.colorPalette!.length);
|
|
35
|
+
expect(reparsed.typography?.fontFamily).toBe(parsed.typography?.fontFamily);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('designMdColorsToVariables', () => {
|
|
40
|
+
it('should convert colors to variable definitions', () => {
|
|
41
|
+
const colors = [
|
|
42
|
+
{ name: 'primary', hex: '#3B82F6', role: 'main brand' },
|
|
43
|
+
{ name: 'bg', hex: '#FFFFFF', role: 'background' },
|
|
44
|
+
];
|
|
45
|
+
const vars = designMdColorsToVariables(colors);
|
|
46
|
+
expect(vars['primary']).toMatchObject({ type: 'color', value: '#3B82F6' });
|
|
47
|
+
expect(vars['bg']).toMatchObject({ type: 'color', value: '#FFFFFF' });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { cssFontFamily } from '../font-utils'
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cssFontFamily } from '../font-utils';
|
|
3
3
|
|
|
4
4
|
describe('cssFontFamily', () => {
|
|
5
5
|
it('quotes multi-word font names', () => {
|
|
6
|
-
expect(cssFontFamily('Noto Sans SC')).toBe('"Noto Sans SC"')
|
|
7
|
-
})
|
|
6
|
+
expect(cssFontFamily('Noto Sans SC')).toBe('"Noto Sans SC"');
|
|
7
|
+
});
|
|
8
8
|
|
|
9
9
|
it('does not quote generic families', () => {
|
|
10
|
-
expect(cssFontFamily('sans-serif')).toBe('sans-serif')
|
|
11
|
-
expect(cssFontFamily('monospace')).toBe('monospace')
|
|
12
|
-
expect(cssFontFamily('system-ui')).toBe('system-ui')
|
|
13
|
-
})
|
|
10
|
+
expect(cssFontFamily('sans-serif')).toBe('sans-serif');
|
|
11
|
+
expect(cssFontFamily('monospace')).toBe('monospace');
|
|
12
|
+
expect(cssFontFamily('system-ui')).toBe('system-ui');
|
|
13
|
+
});
|
|
14
14
|
|
|
15
15
|
it('handles comma-separated lists', () => {
|
|
16
|
-
expect(cssFontFamily('Inter, sans-serif')).toBe('"Inter", sans-serif')
|
|
17
|
-
})
|
|
16
|
+
expect(cssFontFamily('Inter, sans-serif')).toBe('"Inter", sans-serif');
|
|
17
|
+
});
|
|
18
18
|
|
|
19
19
|
it('preserves already-quoted names', () => {
|
|
20
|
-
expect(cssFontFamily('"Noto Sans SC"')).toBe('"Noto Sans SC"')
|
|
21
|
-
})
|
|
20
|
+
expect(cssFontFamily('"Noto Sans SC"')).toBe('"Noto Sans SC"');
|
|
21
|
+
});
|
|
22
22
|
|
|
23
23
|
it('handles -apple-system', () => {
|
|
24
|
-
expect(cssFontFamily('-apple-system')).toBe('-apple-system')
|
|
25
|
-
})
|
|
26
|
-
})
|
|
24
|
+
expect(cssFontFamily('-apple-system')).toBe('-apple-system');
|
|
25
|
+
});
|
|
26
|
+
});
|