@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/connector-document",
3
- "version": "0.11.1",
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.1",
47
- "@pyreon/document": "^0.11.1"
47
+ "@pyreon/core": "^0.11.3",
48
+ "@pyreon/document": "^0.11.3"
48
49
  },
49
50
  "devDependencies": {
50
- "@pyreon/core": "^0.11.1",
51
- "@pyreon/document": "^0.11.1",
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.1"
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"