@pyreon/connector-document 0.11.1 → 0.11.3
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 +8 -7
- package/src/__tests__/cssValueParser.test.ts +136 -0
- package/src/__tests__/extractDocumentTree.test.ts +153 -0
- package/src/__tests__/resolveStyles.test.ts +124 -0
- package/src/cssValueParser.ts +110 -0
- package/src/extractDocumentTree.ts +181 -0
- package/src/index.ts +10 -0
- package/src/resolveStyles.ts +101 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/connector-document",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/pyreon/pyreon",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"!lib/**/*.map",
|
|
25
25
|
"!lib/analysis",
|
|
26
26
|
"README.md",
|
|
27
|
-
"LICENSE"
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"src"
|
|
28
29
|
],
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">= 22"
|
|
@@ -43,13 +44,13 @@
|
|
|
43
44
|
"typecheck": "tsc --noEmit"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
|
-
"@pyreon/core": "^0.11.
|
|
47
|
-
"@pyreon/document": "^0.11.
|
|
47
|
+
"@pyreon/core": "^0.11.3",
|
|
48
|
+
"@pyreon/document": "^0.11.3"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
|
-
"@pyreon/core": "^0.11.
|
|
51
|
-
"@pyreon/document": "^0.11.
|
|
51
|
+
"@pyreon/core": "^0.11.3",
|
|
52
|
+
"@pyreon/document": "^0.11.3",
|
|
52
53
|
"@vitus-labs/tools-rolldown": "^1.15.4",
|
|
53
|
-
"@pyreon/typescript": "^0.11.
|
|
54
|
+
"@pyreon/typescript": "^0.11.3"
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
parseBoxModel,
|
|
4
|
+
parseCssDimension,
|
|
5
|
+
parseFontWeight,
|
|
6
|
+
parseLineHeight,
|
|
7
|
+
} from "../cssValueParser"
|
|
8
|
+
|
|
9
|
+
describe("parseCssDimension", () => {
|
|
10
|
+
it("passes through numbers", () => {
|
|
11
|
+
expect(parseCssDimension(14)).toBe(14)
|
|
12
|
+
expect(parseCssDimension(0)).toBe(0)
|
|
13
|
+
expect(parseCssDimension(-5)).toBe(-5)
|
|
14
|
+
expect(parseCssDimension(1.5)).toBe(1.5)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("parses px values", () => {
|
|
18
|
+
expect(parseCssDimension("14px")).toBe(14)
|
|
19
|
+
expect(parseCssDimension("0px")).toBe(0)
|
|
20
|
+
expect(parseCssDimension("-5px")).toBe(-5)
|
|
21
|
+
expect(parseCssDimension("1.5px")).toBe(1.5)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("parses rem values", () => {
|
|
25
|
+
expect(parseCssDimension("1rem")).toBe(16)
|
|
26
|
+
expect(parseCssDimension("1.5rem")).toBe(24)
|
|
27
|
+
expect(parseCssDimension("0.5rem")).toBe(8)
|
|
28
|
+
expect(parseCssDimension("2rem", 20)).toBe(40)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("parses em values", () => {
|
|
32
|
+
expect(parseCssDimension("1em")).toBe(16)
|
|
33
|
+
expect(parseCssDimension("2em")).toBe(32)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("parses pt values", () => {
|
|
37
|
+
expect(parseCssDimension("12pt")).toBeCloseTo(16)
|
|
38
|
+
expect(parseCssDimension("9pt")).toBeCloseTo(12)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("parses bare number strings", () => {
|
|
42
|
+
expect(parseCssDimension("14")).toBe(14)
|
|
43
|
+
expect(parseCssDimension("0")).toBe(0)
|
|
44
|
+
expect(parseCssDimension("-5")).toBe(-5)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("returns undefined for unresolvable values", () => {
|
|
48
|
+
expect(parseCssDimension("auto")).toBeUndefined()
|
|
49
|
+
expect(parseCssDimension("100%")).toBeUndefined()
|
|
50
|
+
expect(parseCssDimension("calc(100% - 20px)")).toBeUndefined()
|
|
51
|
+
expect(parseCssDimension("var(--spacing)")).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("trims whitespace", () => {
|
|
55
|
+
expect(parseCssDimension(" 14px ")).toBe(14)
|
|
56
|
+
expect(parseCssDimension(" 1rem ")).toBe(16)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("parseBoxModel", () => {
|
|
61
|
+
it("returns undefined for null/undefined", () => {
|
|
62
|
+
expect(parseBoxModel(undefined)).toBeUndefined()
|
|
63
|
+
expect(parseBoxModel(null as any)).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("passes through numbers", () => {
|
|
67
|
+
expect(parseBoxModel(8)).toBe(8)
|
|
68
|
+
expect(parseBoxModel(0)).toBe(0)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("parses single value", () => {
|
|
72
|
+
expect(parseBoxModel("8px")).toBe(8)
|
|
73
|
+
expect(parseBoxModel("1rem")).toBe(16)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("parses two values (vertical horizontal)", () => {
|
|
77
|
+
expect(parseBoxModel("8px 16px")).toEqual([8, 16])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("parses three values (top horizontal bottom)", () => {
|
|
81
|
+
expect(parseBoxModel("8px 16px 12px")).toEqual([8, 16, 12, 16])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("parses four values", () => {
|
|
85
|
+
expect(parseBoxModel("8px 16px 12px 4px")).toEqual([8, 16, 12, 4])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("returns undefined when any part is unresolvable", () => {
|
|
89
|
+
expect(parseBoxModel("8px auto")).toBeUndefined()
|
|
90
|
+
expect(parseBoxModel("8px 16px auto 4px")).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe("parseFontWeight", () => {
|
|
95
|
+
it("passes through numbers", () => {
|
|
96
|
+
expect(parseFontWeight(400)).toBe(400)
|
|
97
|
+
expect(parseFontWeight(700)).toBe(700)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("handles string keywords", () => {
|
|
101
|
+
expect(parseFontWeight("normal")).toBe("normal")
|
|
102
|
+
expect(parseFontWeight("bold")).toBe("bold")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("parses numeric strings", () => {
|
|
106
|
+
expect(parseFontWeight("400")).toBe(400)
|
|
107
|
+
expect(parseFontWeight("700")).toBe(700)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("returns undefined for null/undefined", () => {
|
|
111
|
+
expect(parseFontWeight(undefined)).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("returns undefined for unrecognized values", () => {
|
|
115
|
+
expect(parseFontWeight("lighter")).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("parseLineHeight", () => {
|
|
120
|
+
it("passes through numbers (unitless)", () => {
|
|
121
|
+
expect(parseLineHeight(1.5)).toBe(1.5)
|
|
122
|
+
expect(parseLineHeight(2)).toBe(2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("parses px values", () => {
|
|
126
|
+
expect(parseLineHeight("24px")).toBe(24)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("returns undefined for 'normal'", () => {
|
|
130
|
+
expect(parseLineHeight("normal")).toBeUndefined()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("returns undefined for null/undefined", () => {
|
|
134
|
+
expect(parseLineHeight(undefined)).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import type { DocumentMarker } from "../extractDocumentTree"
|
|
3
|
+
import { extractDocumentTree } from "../extractDocumentTree"
|
|
4
|
+
|
|
5
|
+
// Helper: create a mock VNode
|
|
6
|
+
const vnode = (
|
|
7
|
+
type: string | ((...args: any[]) => any),
|
|
8
|
+
props: Record<string, any> = {},
|
|
9
|
+
children: unknown[] = [],
|
|
10
|
+
) => ({ type, props, children })
|
|
11
|
+
|
|
12
|
+
// Helper: create a document-marked component function
|
|
13
|
+
const docComponent = (docType: string, render?: (...args: any[]) => any) => {
|
|
14
|
+
const fn = render ?? ((props: any) => vnode("div", props, props.children ? [props.children] : []))
|
|
15
|
+
;(fn as any)._documentType = docType
|
|
16
|
+
return fn as ((...args: any[]) => any) & DocumentMarker
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("extractDocumentTree", () => {
|
|
20
|
+
it("extracts a simple document node", () => {
|
|
21
|
+
const Heading = docComponent("heading")
|
|
22
|
+
const tree = vnode(
|
|
23
|
+
Heading,
|
|
24
|
+
{ $rocketstyle: { fontSize: 24, fontWeight: "bold" }, _documentProps: { level: 1 } },
|
|
25
|
+
["Hello World"],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const result = extractDocumentTree(tree)
|
|
29
|
+
|
|
30
|
+
expect(result.type).toBe("heading")
|
|
31
|
+
expect(result.props).toEqual({ level: 1 })
|
|
32
|
+
expect(result.children).toEqual(["Hello World"])
|
|
33
|
+
expect(result.styles).toEqual({ fontSize: 24, fontWeight: "bold" })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("extracts nested document nodes", () => {
|
|
37
|
+
const Section = docComponent("section")
|
|
38
|
+
const Text = docComponent("text")
|
|
39
|
+
|
|
40
|
+
const tree = vnode(Section, { $rocketstyle: { padding: 16 } }, [
|
|
41
|
+
vnode(Text, { $rocketstyle: { fontSize: 14, color: "#333" } }, ["Paragraph one"]),
|
|
42
|
+
vnode(Text, { $rocketstyle: { fontSize: 14, color: "#333" } }, ["Paragraph two"]),
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
const result = extractDocumentTree(tree)
|
|
46
|
+
|
|
47
|
+
expect(result.type).toBe("section")
|
|
48
|
+
expect(result.styles).toEqual({ padding: 16 })
|
|
49
|
+
expect(result.children).toHaveLength(2)
|
|
50
|
+
expect((result.children[0] as any).type).toBe("text")
|
|
51
|
+
expect((result.children[1] as any).type).toBe("text")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("flattens transparent wrappers", () => {
|
|
55
|
+
const Section = docComponent("section")
|
|
56
|
+
const Text = docComponent("text")
|
|
57
|
+
|
|
58
|
+
// A plain div wrapper (no _documentType) should be transparent
|
|
59
|
+
const tree = vnode(Section, {}, [
|
|
60
|
+
vnode("div", {}, [vnode(Text, { $rocketstyle: { fontSize: 14 } }, ["Hello"])]),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const result = extractDocumentTree(tree)
|
|
64
|
+
|
|
65
|
+
expect(result.type).toBe("section")
|
|
66
|
+
expect(result.children).toHaveLength(1)
|
|
67
|
+
expect((result.children[0] as any).type).toBe("text")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("handles string children", () => {
|
|
71
|
+
const Text = docComponent("text")
|
|
72
|
+
const tree = vnode(Text, {}, ["Hello", " ", "World"])
|
|
73
|
+
|
|
74
|
+
const result = extractDocumentTree(tree)
|
|
75
|
+
|
|
76
|
+
expect(result.children).toEqual(["Hello", " ", "World"])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("handles number children", () => {
|
|
80
|
+
const Text = docComponent("text")
|
|
81
|
+
const tree = vnode(Text, {}, [42])
|
|
82
|
+
|
|
83
|
+
const result = extractDocumentTree(tree)
|
|
84
|
+
|
|
85
|
+
expect(result.children).toEqual(["42"])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("skips null and boolean children", () => {
|
|
89
|
+
const Section = docComponent("section")
|
|
90
|
+
const tree = vnode(Section, {}, [null, false, true, "visible"])
|
|
91
|
+
|
|
92
|
+
const result = extractDocumentTree(tree)
|
|
93
|
+
|
|
94
|
+
expect(result.children).toEqual(["visible"])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("resolves reactive getter children", () => {
|
|
98
|
+
const Text = docComponent("text")
|
|
99
|
+
const tree = vnode(Text, {}, [() => "dynamic text"])
|
|
100
|
+
|
|
101
|
+
const result = extractDocumentTree(tree)
|
|
102
|
+
|
|
103
|
+
expect(result.children).toEqual(["dynamic text"])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("omits styles when includeStyles is false", () => {
|
|
107
|
+
const Heading = docComponent("heading")
|
|
108
|
+
const tree = vnode(Heading, { $rocketstyle: { fontSize: 24 } }, ["Hello"])
|
|
109
|
+
|
|
110
|
+
const result = extractDocumentTree(tree, { includeStyles: false })
|
|
111
|
+
|
|
112
|
+
expect(result.styles).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("wraps in document node when root has no _documentType", () => {
|
|
116
|
+
const tree = vnode("div", {}, ["raw text"])
|
|
117
|
+
|
|
118
|
+
const result = extractDocumentTree(tree)
|
|
119
|
+
|
|
120
|
+
expect(result.type).toBe("document")
|
|
121
|
+
expect(result.children).toEqual(["raw text"])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("handles component functions without _documentType by calling them", () => {
|
|
125
|
+
const Text = docComponent("text")
|
|
126
|
+
const Wrapper = (props: any) =>
|
|
127
|
+
vnode(Text, { $rocketstyle: { fontSize: 14 } }, [props.children])
|
|
128
|
+
|
|
129
|
+
const tree = vnode(Wrapper, {}, ["wrapped text"])
|
|
130
|
+
|
|
131
|
+
const result = extractDocumentTree(tree)
|
|
132
|
+
|
|
133
|
+
expect(result.type).toBe("text")
|
|
134
|
+
expect(result.children).toEqual(["wrapped text"])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("handles function passed directly", () => {
|
|
138
|
+
const Text = docComponent("text")
|
|
139
|
+
const template = () => vnode(Text, { $rocketstyle: { fontSize: 14 } }, ["Hello"])
|
|
140
|
+
|
|
141
|
+
const result = extractDocumentTree(template)
|
|
142
|
+
|
|
143
|
+
expect(result.type).toBe("text")
|
|
144
|
+
expect(result.children).toEqual(["Hello"])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("creates empty document for null input", () => {
|
|
148
|
+
const result = extractDocumentTree(null)
|
|
149
|
+
|
|
150
|
+
expect(result.type).toBe("document")
|
|
151
|
+
expect(result.children).toEqual([])
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { resolveStyles } from "../resolveStyles"
|
|
3
|
+
|
|
4
|
+
describe("resolveStyles", () => {
|
|
5
|
+
it("resolves typography properties", () => {
|
|
6
|
+
const result = resolveStyles({
|
|
7
|
+
fontSize: "14px",
|
|
8
|
+
fontFamily: "system-ui, sans-serif",
|
|
9
|
+
fontWeight: "bold",
|
|
10
|
+
fontStyle: "italic",
|
|
11
|
+
textDecoration: "underline",
|
|
12
|
+
color: "#333333",
|
|
13
|
+
textAlign: "center",
|
|
14
|
+
lineHeight: 1.5,
|
|
15
|
+
letterSpacing: "0.5px",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(result).toEqual({
|
|
19
|
+
fontSize: 14,
|
|
20
|
+
fontFamily: "system-ui, sans-serif",
|
|
21
|
+
fontWeight: "bold",
|
|
22
|
+
fontStyle: "italic",
|
|
23
|
+
textDecoration: "underline",
|
|
24
|
+
color: "#333333",
|
|
25
|
+
textAlign: "center",
|
|
26
|
+
lineHeight: 1.5,
|
|
27
|
+
letterSpacing: 0.5,
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("resolves box model properties", () => {
|
|
32
|
+
const result = resolveStyles({
|
|
33
|
+
padding: "8px 16px",
|
|
34
|
+
margin: "12px",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual({
|
|
38
|
+
padding: [8, 16],
|
|
39
|
+
margin: 12,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("resolves border properties", () => {
|
|
44
|
+
const result = resolveStyles({
|
|
45
|
+
borderRadius: "4px",
|
|
46
|
+
borderWidth: "1px",
|
|
47
|
+
borderColor: "#dddddd",
|
|
48
|
+
borderStyle: "solid",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(result).toEqual({
|
|
52
|
+
borderRadius: 4,
|
|
53
|
+
borderWidth: 1,
|
|
54
|
+
borderColor: "#dddddd",
|
|
55
|
+
borderStyle: "solid",
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("resolves sizing properties", () => {
|
|
60
|
+
const result = resolveStyles({
|
|
61
|
+
width: "200px",
|
|
62
|
+
height: 100,
|
|
63
|
+
maxWidth: "100%",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
width: 200,
|
|
68
|
+
height: 100,
|
|
69
|
+
maxWidth: "100%",
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("handles numeric values directly", () => {
|
|
74
|
+
const result = resolveStyles({
|
|
75
|
+
fontSize: 14,
|
|
76
|
+
padding: 8,
|
|
77
|
+
opacity: 0.5,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
fontSize: 14,
|
|
82
|
+
padding: 8,
|
|
83
|
+
opacity: 0.5,
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("ignores irrelevant CSS properties", () => {
|
|
88
|
+
const result = resolveStyles({
|
|
89
|
+
fontSize: 14,
|
|
90
|
+
transition: "all 0.2s",
|
|
91
|
+
cursor: "pointer",
|
|
92
|
+
display: "flex",
|
|
93
|
+
position: "relative",
|
|
94
|
+
transform: "translateX(10px)",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(result).toEqual({ fontSize: 14 })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("skips invalid values", () => {
|
|
101
|
+
const result = resolveStyles({
|
|
102
|
+
fontStyle: "oblique",
|
|
103
|
+
textDecoration: "overline",
|
|
104
|
+
borderStyle: "none",
|
|
105
|
+
textAlign: "start",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual({})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("returns empty object for empty input", () => {
|
|
112
|
+
expect(resolveStyles({})).toEqual({})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("handles backgroundColor", () => {
|
|
116
|
+
const result = resolveStyles({ backgroundColor: "#4f46e5" })
|
|
117
|
+
expect(result).toEqual({ backgroundColor: "#4f46e5" })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("converts rem values using rootSize", () => {
|
|
121
|
+
const result = resolveStyles({ fontSize: "1.5rem" }, 20)
|
|
122
|
+
expect(result).toEqual({ fontSize: 30 })
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const PX_RE = /^(-?\d+(?:\.\d+)?)px$/
|
|
2
|
+
const REM_RE = /^(-?\d+(?:\.\d+)?)rem$/
|
|
3
|
+
const EM_RE = /^(-?\d+(?:\.\d+)?)em$/
|
|
4
|
+
const PT_RE = /^(-?\d+(?:\.\d+)?)pt$/
|
|
5
|
+
const NUMBER_RE = /^-?\d+(?:\.\d+)?$/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ROOT_SIZE = 16
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a CSS dimension value to a number.
|
|
11
|
+
*
|
|
12
|
+
* - `14` → `14`
|
|
13
|
+
* - `'14px'` → `14`
|
|
14
|
+
* - `'1.5rem'` → `24` (with rootSize=16)
|
|
15
|
+
* - `'12pt'` → `16` (pt × 1.333)
|
|
16
|
+
* - `'auto'` → `undefined`
|
|
17
|
+
*/
|
|
18
|
+
export function parseCssDimension(
|
|
19
|
+
value: string | number | null | undefined,
|
|
20
|
+
rootSize = DEFAULT_ROOT_SIZE,
|
|
21
|
+
): number | undefined {
|
|
22
|
+
if (value == null) return undefined
|
|
23
|
+
if (typeof value === "number") return value
|
|
24
|
+
if (typeof value !== "string") return undefined
|
|
25
|
+
|
|
26
|
+
const trimmed = value.trim()
|
|
27
|
+
|
|
28
|
+
const pxMatch = PX_RE.exec(trimmed)
|
|
29
|
+
if (pxMatch?.[1]) return Number.parseFloat(pxMatch[1])
|
|
30
|
+
|
|
31
|
+
const remMatch = REM_RE.exec(trimmed)
|
|
32
|
+
if (remMatch?.[1]) return Number.parseFloat(remMatch[1]) * rootSize
|
|
33
|
+
|
|
34
|
+
const emMatch = EM_RE.exec(trimmed)
|
|
35
|
+
if (emMatch?.[1]) return Number.parseFloat(emMatch[1]) * rootSize
|
|
36
|
+
|
|
37
|
+
const ptMatch = PT_RE.exec(trimmed)
|
|
38
|
+
if (ptMatch?.[1]) return Number.parseFloat(ptMatch[1]) * (4 / 3)
|
|
39
|
+
|
|
40
|
+
if (NUMBER_RE.test(trimmed)) return Number.parseFloat(trimmed)
|
|
41
|
+
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type BoxModelResult = number | [number, number] | [number, number, number, number] | undefined
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a CSS padding/margin shorthand to document tuple format.
|
|
49
|
+
*
|
|
50
|
+
* - `8` → `8`
|
|
51
|
+
* - `'8px'` → `8`
|
|
52
|
+
* - `'8px 16px'` → `[8, 16]`
|
|
53
|
+
* - `'8px 16px 8px 16px'` → `[8, 16, 8, 16]`
|
|
54
|
+
* - `'8px 16px 12px'` → `[8, 16, 12, 16]` (CSS 3-value shorthand)
|
|
55
|
+
*/
|
|
56
|
+
export function parseBoxModel(
|
|
57
|
+
value: string | number | undefined,
|
|
58
|
+
rootSize = DEFAULT_ROOT_SIZE,
|
|
59
|
+
): BoxModelResult {
|
|
60
|
+
if (value == null) return undefined
|
|
61
|
+
if (typeof value === "number") return value
|
|
62
|
+
|
|
63
|
+
const parts = value
|
|
64
|
+
.trim()
|
|
65
|
+
.split(/\s+/)
|
|
66
|
+
.map((p) => parseCssDimension(p, rootSize))
|
|
67
|
+
|
|
68
|
+
const nums = parts.filter((p): p is number => p != null)
|
|
69
|
+
if (nums.length !== parts.length) return undefined
|
|
70
|
+
|
|
71
|
+
if (nums.length === 1) return nums[0]
|
|
72
|
+
if (nums.length === 2) return [nums[0], nums[1]] as [number, number]
|
|
73
|
+
if (nums.length === 3)
|
|
74
|
+
return [nums[0], nums[1], nums[2], nums[1]] as [number, number, number, number]
|
|
75
|
+
if (nums.length === 4)
|
|
76
|
+
return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number]
|
|
77
|
+
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse a CSS font-weight value.
|
|
83
|
+
*/
|
|
84
|
+
export function parseFontWeight(
|
|
85
|
+
value: string | number | undefined,
|
|
86
|
+
): "normal" | "bold" | number | undefined {
|
|
87
|
+
if (value == null) return undefined
|
|
88
|
+
if (typeof value === "number") return value
|
|
89
|
+
if (value === "normal" || value === "bold") return value
|
|
90
|
+
const num = Number.parseInt(value, 10)
|
|
91
|
+
if (!Number.isNaN(num)) return num
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a CSS line-height value to a unitless number.
|
|
97
|
+
*/
|
|
98
|
+
export function parseLineHeight(
|
|
99
|
+
value: string | number | undefined,
|
|
100
|
+
rootSize = DEFAULT_ROOT_SIZE,
|
|
101
|
+
): number | undefined {
|
|
102
|
+
if (value == null) return undefined
|
|
103
|
+
if (typeof value === "number") return value
|
|
104
|
+
if (value === "normal") return undefined
|
|
105
|
+
|
|
106
|
+
const dim = parseCssDimension(value, rootSize)
|
|
107
|
+
if (dim != null) return dim
|
|
108
|
+
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { resolveStyles } from "./resolveStyles"
|
|
2
|
+
import type { DocChild, DocNode, NodeType } from "./types"
|
|
3
|
+
|
|
4
|
+
/** Marker interface: components with _documentType are extractable. */
|
|
5
|
+
export interface DocumentMarker {
|
|
6
|
+
_documentType: NodeType
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ExtractOptions {
|
|
10
|
+
/** Root font size for rem→px conversion. Default: 16. */
|
|
11
|
+
rootSize?: number
|
|
12
|
+
/** Include resolved styles from $rocketstyle. Default: true. */
|
|
13
|
+
includeStyles?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type VNodeLike = {
|
|
17
|
+
type: string | ((...args: any[]) => any)
|
|
18
|
+
props: Record<string, any>
|
|
19
|
+
children: unknown[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isVNode(value: unknown): value is VNodeLike {
|
|
23
|
+
return value != null && typeof value === "object" && "type" in value && "props" in value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDocumentType(fn: unknown): NodeType | undefined {
|
|
27
|
+
if (typeof fn !== "function") return undefined
|
|
28
|
+
const meta = (fn as any).meta
|
|
29
|
+
if (meta?._documentType) return meta._documentType as NodeType
|
|
30
|
+
// Fallback: check directly on function (non-rocketstyle components)
|
|
31
|
+
if ("_documentType" in fn) return (fn as any)._documentType as NodeType
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function flattenChildren(children: unknown[]): unknown[] {
|
|
36
|
+
const result: unknown[] = []
|
|
37
|
+
for (const child of children) {
|
|
38
|
+
if (Array.isArray(child)) {
|
|
39
|
+
result.push(...flattenChildren(child))
|
|
40
|
+
} else if (typeof child === "function") {
|
|
41
|
+
// Reactive getter — call to resolve
|
|
42
|
+
const resolved = child()
|
|
43
|
+
if (Array.isArray(resolved)) {
|
|
44
|
+
result.push(...flattenChildren(resolved))
|
|
45
|
+
} else {
|
|
46
|
+
result.push(resolved)
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
result.push(child)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function extractChildren(children: unknown[], options: ExtractOptions): DocChild[] {
|
|
56
|
+
const flat = flattenChildren(children)
|
|
57
|
+
const result: DocChild[] = []
|
|
58
|
+
|
|
59
|
+
for (const child of flat) {
|
|
60
|
+
if (child == null || child === false || child === true) continue
|
|
61
|
+
|
|
62
|
+
if (typeof child === "string") {
|
|
63
|
+
result.push(child)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof child === "number") {
|
|
68
|
+
result.push(String(child))
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isVNode(child)) {
|
|
73
|
+
const extracted = extractNode(child, options)
|
|
74
|
+
if (Array.isArray(extracted)) {
|
|
75
|
+
result.push(...extracted)
|
|
76
|
+
} else if (extracted != null) {
|
|
77
|
+
result.push(extracted)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocChild[] | null {
|
|
86
|
+
const { type, props, children } = vnode
|
|
87
|
+
const includeStyles = options.includeStyles !== false
|
|
88
|
+
const rootSize = options.rootSize ?? 16
|
|
89
|
+
|
|
90
|
+
// Component function with _documentType marker (via .statics() or direct)
|
|
91
|
+
const docType = getDocumentType(type)
|
|
92
|
+
if (docType) {
|
|
93
|
+
const docProps: Record<string, unknown> = {}
|
|
94
|
+
|
|
95
|
+
// Extract document-specific props from _documentProps
|
|
96
|
+
if (props._documentProps && typeof props._documentProps === "object") {
|
|
97
|
+
Object.assign(docProps, props._documentProps)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve styles from $rocketstyle
|
|
101
|
+
const styles =
|
|
102
|
+
includeStyles && props.$rocketstyle
|
|
103
|
+
? resolveStyles(props.$rocketstyle as Record<string, unknown>, rootSize)
|
|
104
|
+
: undefined
|
|
105
|
+
|
|
106
|
+
// Recurse into children
|
|
107
|
+
const docChildren = extractChildren(children ?? [], options)
|
|
108
|
+
|
|
109
|
+
const node: DocNode = {
|
|
110
|
+
type: docType,
|
|
111
|
+
props: docProps,
|
|
112
|
+
children: docChildren,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (styles && Object.keys(styles).length > 0) {
|
|
116
|
+
node.styles = styles
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return node
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Component function WITHOUT _documentType — call it to get its VNode output
|
|
123
|
+
if (typeof type === "function") {
|
|
124
|
+
const mergedProps = { ...props }
|
|
125
|
+
if (children && children.length > 0) {
|
|
126
|
+
mergedProps.children = children.length === 1 ? children[0] : children
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const result = type(mergedProps)
|
|
130
|
+
|
|
131
|
+
if (isVNode(result)) {
|
|
132
|
+
return extractNode(result, options)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The component returned a primitive or null
|
|
136
|
+
if (typeof result === "string") return [result]
|
|
137
|
+
if (typeof result === "number") return [String(result)]
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// DOM element (string type like 'div', 'span') — transparent, extract children
|
|
142
|
+
if (typeof type === "string") {
|
|
143
|
+
const docChildren = extractChildren(children ?? [], options)
|
|
144
|
+
// If there's text content in the DOM element, collect it
|
|
145
|
+
if (docChildren.length > 0) return docChildren
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Walk a Pyreon VNode tree and extract a `DocNode` tree for `@pyreon/document`.
|
|
154
|
+
*
|
|
155
|
+
* For each VNode whose component has a `_documentType` marker:
|
|
156
|
+
* 1. Read `_documentType` → `DocNode.type`
|
|
157
|
+
* 2. Read `_documentProps` → `DocNode.props`
|
|
158
|
+
* 3. Read `$rocketstyle` → `resolveStyles()` → `DocNode.styles`
|
|
159
|
+
* 4. Recurse into children
|
|
160
|
+
*
|
|
161
|
+
* VNodes without `_documentType` are transparent — their children
|
|
162
|
+
* are flattened into the parent's children list.
|
|
163
|
+
*/
|
|
164
|
+
export function extractDocumentTree(vnode: unknown, options: ExtractOptions = {}): DocNode {
|
|
165
|
+
if (isVNode(vnode)) {
|
|
166
|
+
const result = extractNode(vnode, options)
|
|
167
|
+
if (result && !Array.isArray(result)) return result
|
|
168
|
+
|
|
169
|
+
// Wrap loose children in a document node
|
|
170
|
+
const children = Array.isArray(result) ? result : []
|
|
171
|
+
return { type: "document", props: {}, children }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// If passed a component function directly, call it
|
|
175
|
+
if (typeof vnode === "function") {
|
|
176
|
+
const result = (vnode as () => unknown)()
|
|
177
|
+
return extractDocumentTree(result, options)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { type: "document", props: {}, children: [] }
|
|
181
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
parseBoxModel,
|
|
3
|
+
parseCssDimension,
|
|
4
|
+
parseFontWeight,
|
|
5
|
+
parseLineHeight,
|
|
6
|
+
} from "./cssValueParser"
|
|
7
|
+
export type { DocumentMarker, ExtractOptions } from "./extractDocumentTree"
|
|
8
|
+
export { extractDocumentTree } from "./extractDocumentTree"
|
|
9
|
+
export { resolveStyles } from "./resolveStyles"
|
|
10
|
+
export type { DocChild, DocNode, NodeType, ResolvedStyles } from "./types"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseBoxModel,
|
|
3
|
+
parseCssDimension,
|
|
4
|
+
parseFontWeight,
|
|
5
|
+
parseLineHeight,
|
|
6
|
+
} from "./cssValueParser"
|
|
7
|
+
import type { ResolvedStyles } from "./types"
|
|
8
|
+
|
|
9
|
+
const TEXT_ALIGN_VALUES = new Set(["left", "center", "right", "justify"])
|
|
10
|
+
const FONT_STYLE_VALUES = new Set(["normal", "italic"])
|
|
11
|
+
const TEXT_DECORATION_VALUES = new Set(["none", "underline", "line-through"])
|
|
12
|
+
const BORDER_STYLE_VALUES = new Set(["solid", "dashed", "dotted"])
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a rocketstyle `$rocketstyle` theme object into a `ResolvedStyles`
|
|
16
|
+
* object compatible with `@pyreon/document`.
|
|
17
|
+
*
|
|
18
|
+
* Only extracts properties that `ResolvedStyles` supports — everything else
|
|
19
|
+
* (transitions, cursor, display, etc.) is silently ignored.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveStyles(rocketstyle: Record<string, unknown>, rootSize = 16): ResolvedStyles {
|
|
22
|
+
const styles: ResolvedStyles = {}
|
|
23
|
+
|
|
24
|
+
// Typography
|
|
25
|
+
const fontSize = parseCssDimension(rocketstyle.fontSize as string | number, rootSize)
|
|
26
|
+
if (fontSize != null) styles.fontSize = fontSize
|
|
27
|
+
|
|
28
|
+
if (typeof rocketstyle.fontFamily === "string") styles.fontFamily = rocketstyle.fontFamily
|
|
29
|
+
|
|
30
|
+
const fontWeight = parseFontWeight(rocketstyle.fontWeight as string | number | undefined)
|
|
31
|
+
if (fontWeight != null) styles.fontWeight = fontWeight
|
|
32
|
+
|
|
33
|
+
if (typeof rocketstyle.fontStyle === "string" && FONT_STYLE_VALUES.has(rocketstyle.fontStyle))
|
|
34
|
+
styles.fontStyle = rocketstyle.fontStyle as "normal" | "italic"
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
typeof rocketstyle.textDecoration === "string" &&
|
|
38
|
+
TEXT_DECORATION_VALUES.has(rocketstyle.textDecoration)
|
|
39
|
+
)
|
|
40
|
+
styles.textDecoration = rocketstyle.textDecoration as "none" | "underline" | "line-through"
|
|
41
|
+
|
|
42
|
+
if (typeof rocketstyle.color === "string") styles.color = rocketstyle.color
|
|
43
|
+
|
|
44
|
+
if (typeof rocketstyle.backgroundColor === "string")
|
|
45
|
+
styles.backgroundColor = rocketstyle.backgroundColor
|
|
46
|
+
|
|
47
|
+
if (typeof rocketstyle.textAlign === "string" && TEXT_ALIGN_VALUES.has(rocketstyle.textAlign))
|
|
48
|
+
styles.textAlign = rocketstyle.textAlign as "left" | "center" | "right" | "justify"
|
|
49
|
+
|
|
50
|
+
const lineHeight = parseLineHeight(
|
|
51
|
+
rocketstyle.lineHeight as string | number | undefined,
|
|
52
|
+
rootSize,
|
|
53
|
+
)
|
|
54
|
+
if (lineHeight != null) styles.lineHeight = lineHeight
|
|
55
|
+
|
|
56
|
+
const letterSpacing = parseCssDimension(rocketstyle.letterSpacing as string | number, rootSize)
|
|
57
|
+
if (letterSpacing != null) styles.letterSpacing = letterSpacing
|
|
58
|
+
|
|
59
|
+
// Box model
|
|
60
|
+
const padding = parseBoxModel(rocketstyle.padding as string | number | undefined, rootSize)
|
|
61
|
+
if (padding != null) styles.padding = padding
|
|
62
|
+
|
|
63
|
+
const margin = parseBoxModel(rocketstyle.margin as string | number | undefined, rootSize)
|
|
64
|
+
if (margin != null) styles.margin = margin
|
|
65
|
+
|
|
66
|
+
// Border
|
|
67
|
+
const borderRadius = parseCssDimension(rocketstyle.borderRadius as string | number, rootSize)
|
|
68
|
+
if (borderRadius != null) styles.borderRadius = borderRadius
|
|
69
|
+
|
|
70
|
+
const borderWidth = parseCssDimension(rocketstyle.borderWidth as string | number, rootSize)
|
|
71
|
+
if (borderWidth != null) styles.borderWidth = borderWidth
|
|
72
|
+
|
|
73
|
+
if (typeof rocketstyle.borderColor === "string") styles.borderColor = rocketstyle.borderColor
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
typeof rocketstyle.borderStyle === "string" &&
|
|
77
|
+
BORDER_STYLE_VALUES.has(rocketstyle.borderStyle)
|
|
78
|
+
)
|
|
79
|
+
styles.borderStyle = rocketstyle.borderStyle as "solid" | "dashed" | "dotted"
|
|
80
|
+
|
|
81
|
+
// Sizing
|
|
82
|
+
if (rocketstyle.width != null) {
|
|
83
|
+
const w = parseCssDimension(rocketstyle.width as string | number, rootSize)
|
|
84
|
+
styles.width = w ?? (rocketstyle.width as string)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (rocketstyle.height != null) {
|
|
88
|
+
const h = parseCssDimension(rocketstyle.height as string | number, rootSize)
|
|
89
|
+
styles.height = h ?? (rocketstyle.height as string)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (rocketstyle.maxWidth != null) {
|
|
93
|
+
const mw = parseCssDimension(rocketstyle.maxWidth as string | number, rootSize)
|
|
94
|
+
styles.maxWidth = mw ?? (rocketstyle.maxWidth as string)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Opacity
|
|
98
|
+
if (typeof rocketstyle.opacity === "number") styles.opacity = rocketstyle.opacity
|
|
99
|
+
|
|
100
|
+
return styles
|
|
101
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { DocChild, DocNode, NodeType, ResolvedStyles } from "@pyreon/document"
|