@pyreon/connector-document 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +15 -7
- package/package.json +10 -9
- package/src/__tests__/extractDocumentTree.test.ts +225 -29
- package/src/extractDocumentTree.ts +69 -60
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
package/lib/index.js
CHANGED
|
@@ -191,13 +191,21 @@ function extractNode(vnode, options) {
|
|
|
191
191
|
let extractedFromCall = null;
|
|
192
192
|
if (props._documentProps && typeof props._documentProps === "object") rawDocProps = props._documentProps;
|
|
193
193
|
else if (typeof type === "function") {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
194
|
+
const rsAttrs = type.__rs_attrs;
|
|
195
|
+
if (rsAttrs && rsAttrs.length > 0) {
|
|
196
|
+
const mergedProps = { ...props };
|
|
197
|
+
if (children && children.length > 0) mergedProps.children = children.length === 1 ? children[0] : children;
|
|
198
|
+
const attrsResult = rsAttrs.reduce((acc, fn) => Object.assign(acc, fn(mergedProps)), {});
|
|
199
|
+
if (attrsResult._documentProps && typeof attrsResult._documentProps === "object") rawDocProps = attrsResult._documentProps;
|
|
200
|
+
} else {
|
|
201
|
+
const mergedProps = { ...props };
|
|
202
|
+
if (children && children.length > 0) mergedProps.children = children.length === 1 ? children[0] : children;
|
|
203
|
+
const result = type(mergedProps);
|
|
204
|
+
if (isVNode(result)) {
|
|
205
|
+
extractedFromCall = result;
|
|
206
|
+
const innerProps = result.props;
|
|
207
|
+
if (innerProps?._documentProps && typeof innerProps._documentProps === "object") rawDocProps = innerProps._documentProps;
|
|
208
|
+
}
|
|
201
209
|
}
|
|
202
210
|
}
|
|
203
211
|
const docProps = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/connector-document",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Bridge between @pyreon/pyreon styled components and @pyreon/document rendering",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"lib",
|
|
13
|
+
"!lib/**/*.map",
|
|
13
14
|
"!lib/analysis",
|
|
14
15
|
"README.md",
|
|
15
16
|
"LICENSE",
|
|
@@ -41,17 +42,17 @@
|
|
|
41
42
|
"typecheck": "tsc --noEmit"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
|
-
"@pyreon/core": "^0.
|
|
45
|
-
"@pyreon/document": "^0.
|
|
46
|
-
"@pyreon/reactivity": "^0.
|
|
47
|
-
"@pyreon/test-utils": "^0.13.
|
|
48
|
-
"@pyreon/typescript": "^0.
|
|
45
|
+
"@pyreon/core": "^0.15.0",
|
|
46
|
+
"@pyreon/document": "^0.15.0",
|
|
47
|
+
"@pyreon/reactivity": "^0.15.0",
|
|
48
|
+
"@pyreon/test-utils": "^0.13.2",
|
|
49
|
+
"@pyreon/typescript": "^0.15.0",
|
|
49
50
|
"@vitest/browser-playwright": "^4.1.4",
|
|
50
|
-
"@vitus-labs/tools-rolldown": "^
|
|
51
|
+
"@vitus-labs/tools-rolldown": "^2.3.0"
|
|
51
52
|
},
|
|
52
53
|
"peerDependencies": {
|
|
53
|
-
"@pyreon/core": "^0.
|
|
54
|
-
"@pyreon/document": "^0.
|
|
54
|
+
"@pyreon/core": "^0.15.0",
|
|
55
|
+
"@pyreon/document": "^0.15.0"
|
|
55
56
|
},
|
|
56
57
|
"engines": {
|
|
57
58
|
"node": ">= 22"
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
1
2
|
import { describe, expect, it } from 'vitest'
|
|
2
3
|
import type { DocumentMarker } from '../extractDocumentTree'
|
|
3
4
|
import { extractDocumentTree } from '../extractDocumentTree'
|
|
4
5
|
|
|
5
|
-
// Helper:
|
|
6
|
-
|
|
6
|
+
// Helper: build a real VNode via @pyreon/core's h(). The third arg
|
|
7
|
+
// is an array (kept for parity with the prior mock helper) and
|
|
8
|
+
// spreads into h()'s varargs. All tests below run real-h() VNodes
|
|
9
|
+
// through the extraction pipeline — see PR #197 for why mock-only
|
|
10
|
+
// tests masked a silent metadata drop in the real attrs HOC path.
|
|
11
|
+
const node = (
|
|
7
12
|
type: string | ((...args: any[]) => any),
|
|
8
13
|
props: Record<string, any> = {},
|
|
9
14
|
children: unknown[] = [],
|
|
10
|
-
) => (
|
|
15
|
+
) => h(type as any, props, ...(children as any[])) as any
|
|
11
16
|
|
|
12
17
|
// Helper: create a document-marked component function
|
|
13
18
|
const docComponent = (docType: string, render?: (...args: any[]) => any) => {
|
|
14
|
-
const fn = render ?? ((props: any) =>
|
|
19
|
+
const fn = render ?? ((props: any) => node('div', props, props.children ? [props.children] : []))
|
|
15
20
|
;(fn as any)._documentType = docType
|
|
16
21
|
return fn as ((...args: any[]) => any) & DocumentMarker
|
|
17
22
|
}
|
|
@@ -19,7 +24,7 @@ const docComponent = (docType: string, render?: (...args: any[]) => any) => {
|
|
|
19
24
|
describe('extractDocumentTree', () => {
|
|
20
25
|
it('extracts a simple document node', () => {
|
|
21
26
|
const Heading = docComponent('heading')
|
|
22
|
-
const tree =
|
|
27
|
+
const tree = node(
|
|
23
28
|
Heading,
|
|
24
29
|
{ $rocketstyle: { fontSize: 24, fontWeight: 'bold' }, _documentProps: { level: 1 } },
|
|
25
30
|
['Hello World'],
|
|
@@ -37,9 +42,9 @@ describe('extractDocumentTree', () => {
|
|
|
37
42
|
const Section = docComponent('section')
|
|
38
43
|
const Text = docComponent('text')
|
|
39
44
|
|
|
40
|
-
const tree =
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
const tree = node(Section, { $rocketstyle: { padding: 16 } }, [
|
|
46
|
+
node(Text, { $rocketstyle: { fontSize: 14, color: '#333' } }, ['Paragraph one']),
|
|
47
|
+
node(Text, { $rocketstyle: { fontSize: 14, color: '#333' } }, ['Paragraph two']),
|
|
43
48
|
])
|
|
44
49
|
|
|
45
50
|
const result = extractDocumentTree(tree)
|
|
@@ -56,8 +61,8 @@ describe('extractDocumentTree', () => {
|
|
|
56
61
|
const Text = docComponent('text')
|
|
57
62
|
|
|
58
63
|
// A plain div wrapper (no _documentType) should be transparent
|
|
59
|
-
const tree =
|
|
60
|
-
|
|
64
|
+
const tree = node(Section, {}, [
|
|
65
|
+
node('div', {}, [node(Text, { $rocketstyle: { fontSize: 14 } }, ['Hello'])]),
|
|
61
66
|
])
|
|
62
67
|
|
|
63
68
|
const result = extractDocumentTree(tree)
|
|
@@ -69,7 +74,7 @@ describe('extractDocumentTree', () => {
|
|
|
69
74
|
|
|
70
75
|
it('handles string children', () => {
|
|
71
76
|
const Text = docComponent('text')
|
|
72
|
-
const tree =
|
|
77
|
+
const tree = node(Text, {}, ['Hello', ' ', 'World'])
|
|
73
78
|
|
|
74
79
|
const result = extractDocumentTree(tree)
|
|
75
80
|
|
|
@@ -78,7 +83,7 @@ describe('extractDocumentTree', () => {
|
|
|
78
83
|
|
|
79
84
|
it('handles number children', () => {
|
|
80
85
|
const Text = docComponent('text')
|
|
81
|
-
const tree =
|
|
86
|
+
const tree = node(Text, {}, [42])
|
|
82
87
|
|
|
83
88
|
const result = extractDocumentTree(tree)
|
|
84
89
|
|
|
@@ -87,7 +92,7 @@ describe('extractDocumentTree', () => {
|
|
|
87
92
|
|
|
88
93
|
it('skips null and boolean children', () => {
|
|
89
94
|
const Section = docComponent('section')
|
|
90
|
-
const tree =
|
|
95
|
+
const tree = node(Section, {}, [null, false, true, 'visible'])
|
|
91
96
|
|
|
92
97
|
const result = extractDocumentTree(tree)
|
|
93
98
|
|
|
@@ -96,7 +101,7 @@ describe('extractDocumentTree', () => {
|
|
|
96
101
|
|
|
97
102
|
it('resolves reactive getter children', () => {
|
|
98
103
|
const Text = docComponent('text')
|
|
99
|
-
const tree =
|
|
104
|
+
const tree = node(Text, {}, [() => 'dynamic text'])
|
|
100
105
|
|
|
101
106
|
const result = extractDocumentTree(tree)
|
|
102
107
|
|
|
@@ -105,7 +110,7 @@ describe('extractDocumentTree', () => {
|
|
|
105
110
|
|
|
106
111
|
it('omits styles when includeStyles is false', () => {
|
|
107
112
|
const Heading = docComponent('heading')
|
|
108
|
-
const tree =
|
|
113
|
+
const tree = node(Heading, { $rocketstyle: { fontSize: 24 } }, ['Hello'])
|
|
109
114
|
|
|
110
115
|
const result = extractDocumentTree(tree, { includeStyles: false })
|
|
111
116
|
|
|
@@ -113,7 +118,7 @@ describe('extractDocumentTree', () => {
|
|
|
113
118
|
})
|
|
114
119
|
|
|
115
120
|
it('wraps in document node when root has no _documentType', () => {
|
|
116
|
-
const tree =
|
|
121
|
+
const tree = node('div', {}, ['raw text'])
|
|
117
122
|
|
|
118
123
|
const result = extractDocumentTree(tree)
|
|
119
124
|
|
|
@@ -124,9 +129,9 @@ describe('extractDocumentTree', () => {
|
|
|
124
129
|
it('handles component functions without _documentType by calling them', () => {
|
|
125
130
|
const Text = docComponent('text')
|
|
126
131
|
const Wrapper = (props: any) =>
|
|
127
|
-
|
|
132
|
+
node(Text, { $rocketstyle: { fontSize: 14 } }, [props.children])
|
|
128
133
|
|
|
129
|
-
const tree =
|
|
134
|
+
const tree = node(Wrapper, {}, ['wrapped text'])
|
|
130
135
|
|
|
131
136
|
const result = extractDocumentTree(tree)
|
|
132
137
|
|
|
@@ -136,7 +141,7 @@ describe('extractDocumentTree', () => {
|
|
|
136
141
|
|
|
137
142
|
it('handles function passed directly', () => {
|
|
138
143
|
const Text = docComponent('text')
|
|
139
|
-
const template = () =>
|
|
144
|
+
const template = () => node(Text, { $rocketstyle: { fontSize: 14 } }, ['Hello'])
|
|
140
145
|
|
|
141
146
|
const result = extractDocumentTree(template)
|
|
142
147
|
|
|
@@ -165,7 +170,7 @@ describe('extractDocumentTree', () => {
|
|
|
165
170
|
|
|
166
171
|
it('calls function values in _documentProps and stores the result', () => {
|
|
167
172
|
const Document = docComponent('document')
|
|
168
|
-
const tree =
|
|
173
|
+
const tree = node(
|
|
169
174
|
Document,
|
|
170
175
|
{
|
|
171
176
|
_documentProps: {
|
|
@@ -193,7 +198,7 @@ describe('extractDocumentTree', () => {
|
|
|
193
198
|
// two extractDocumentTree calls on the same vnode.
|
|
194
199
|
let counter = 0
|
|
195
200
|
const Document = docComponent('document')
|
|
196
|
-
const tree =
|
|
201
|
+
const tree = node(
|
|
197
202
|
Document,
|
|
198
203
|
{
|
|
199
204
|
_documentProps: {
|
|
@@ -214,7 +219,7 @@ describe('extractDocumentTree', () => {
|
|
|
214
219
|
|
|
215
220
|
it('mixes function and plain values in the same _documentProps object', () => {
|
|
216
221
|
const Image = docComponent('image')
|
|
217
|
-
const tree =
|
|
222
|
+
const tree = node(
|
|
218
223
|
Image,
|
|
219
224
|
{
|
|
220
225
|
_documentProps: {
|
|
@@ -242,7 +247,7 @@ describe('extractDocumentTree', () => {
|
|
|
242
247
|
// change must not break them. This test mirrors the shape
|
|
243
248
|
// of DocHeading's existing _documentProps.
|
|
244
249
|
const Heading = docComponent('heading')
|
|
245
|
-
const tree =
|
|
250
|
+
const tree = node(
|
|
246
251
|
Heading,
|
|
247
252
|
{ _documentProps: { level: 1 } },
|
|
248
253
|
['Hello'],
|
|
@@ -284,7 +289,7 @@ describe('extractDocumentTree', () => {
|
|
|
284
289
|
// takes user props, returns a vnode with _documentProps
|
|
285
290
|
// populated by the "attrs callback".
|
|
286
291
|
const DocDocLike = ((userProps: { title?: string; author?: string }) =>
|
|
287
|
-
|
|
292
|
+
node('div', {
|
|
288
293
|
_documentProps: {
|
|
289
294
|
...(userProps.title ? { title: userProps.title } : {}),
|
|
290
295
|
...(userProps.author ? { author: userProps.author } : {}),
|
|
@@ -293,7 +298,7 @@ describe('extractDocumentTree', () => {
|
|
|
293
298
|
;(DocDocLike as any)._documentType = 'document'
|
|
294
299
|
|
|
295
300
|
// The JSX vnode has user props but NO _documentProps directly
|
|
296
|
-
const jsxVnode =
|
|
301
|
+
const jsxVnode = node(DocDocLike, { title: 'My Doc', author: 'Alice' }, [])
|
|
297
302
|
|
|
298
303
|
const result = extractDocumentTree(jsxVnode)
|
|
299
304
|
|
|
@@ -308,14 +313,14 @@ describe('extractDocumentTree', () => {
|
|
|
308
313
|
// callback) gets the live value resolved at extraction time.
|
|
309
314
|
let liveTitle = 'First'
|
|
310
315
|
const DocDocLike = ((userProps: { title?: () => string }) =>
|
|
311
|
-
|
|
316
|
+
node('div', {
|
|
312
317
|
_documentProps: {
|
|
313
318
|
title: userProps.title, // store the accessor as-is
|
|
314
319
|
},
|
|
315
320
|
})) as ((...args: any[]) => any) & DocumentMarker
|
|
316
321
|
;(DocDocLike as any)._documentType = 'document'
|
|
317
322
|
|
|
318
|
-
const jsxVnode =
|
|
323
|
+
const jsxVnode = node(DocDocLike, { title: () => liveTitle }, [])
|
|
319
324
|
|
|
320
325
|
const first = extractDocumentTree(jsxVnode)
|
|
321
326
|
expect(first.props.title).toBe('First')
|
|
@@ -334,11 +339,11 @@ describe('extractDocumentTree', () => {
|
|
|
334
339
|
let componentCalled = false
|
|
335
340
|
const DocDocLike = (() => {
|
|
336
341
|
componentCalled = true
|
|
337
|
-
return
|
|
342
|
+
return node('div', { _documentProps: { title: 'from-call' } })
|
|
338
343
|
}) as ((...args: any[]) => any) & DocumentMarker
|
|
339
344
|
;(DocDocLike as any)._documentType = 'document'
|
|
340
345
|
|
|
341
|
-
const jsxVnode =
|
|
346
|
+
const jsxVnode = node(
|
|
342
347
|
DocDocLike,
|
|
343
348
|
{ _documentProps: { title: 'from-jsx' } },
|
|
344
349
|
[],
|
|
@@ -350,3 +355,194 @@ describe('extractDocumentTree', () => {
|
|
|
350
355
|
})
|
|
351
356
|
})
|
|
352
357
|
})
|
|
358
|
+
|
|
359
|
+
// ─── Real h() round-trip (parallel to the mock-vnode tests above) ────────
|
|
360
|
+
//
|
|
361
|
+
// This is the exact file PR #197 fixed — `extractDocumentTree` was
|
|
362
|
+
// silently dropping metadata from real rocketstyle primitives because
|
|
363
|
+
// the existing tests ONLY used the local `node(...)` helper that
|
|
364
|
+
// hardcodes `{ type, props, children }` literals. The mock path
|
|
365
|
+
// worked; the real pipeline (where `_documentProps` is only attached
|
|
366
|
+
// AFTER the attrs HOC runs) didn't. The `audit_test_environment` tool
|
|
367
|
+
// from PR #311 flagged this file HIGH (27 mock-helper call-sites, 0
|
|
368
|
+
// real `h()` calls, no `@pyreon/core` import).
|
|
369
|
+
//
|
|
370
|
+
// This block adds a parallel: the same tree shapes built via real
|
|
371
|
+
// `h(...)` from `@pyreon/core`. The mock `node()` helper returns a
|
|
372
|
+
// hand-built object literal; real `h()` returns whatever the current
|
|
373
|
+
// Pyreon VNode shape is — if the two ever drift, only the real-`h()`
|
|
374
|
+
// path catches it. The mock tests above stay as the fast unit-test
|
|
375
|
+
// path; these are the safety net.
|
|
376
|
+
|
|
377
|
+
describe('extractDocumentTree — real h() round-trip', () => {
|
|
378
|
+
it('extracts a simple document node built via real h()', () => {
|
|
379
|
+
const Heading = docComponent('heading')
|
|
380
|
+
const tree = h(
|
|
381
|
+
Heading,
|
|
382
|
+
{ $rocketstyle: { fontSize: 24, fontWeight: 'bold' }, _documentProps: { level: 1 } },
|
|
383
|
+
'Hello World',
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
const result = extractDocumentTree(tree)
|
|
387
|
+
expect(result.type).toBe('heading')
|
|
388
|
+
expect(result.props).toEqual({ level: 1 })
|
|
389
|
+
expect(result.children).toEqual(['Hello World'])
|
|
390
|
+
expect(result.styles).toEqual({ fontSize: 24, fontWeight: 'bold' })
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('extracts nested document nodes through real h() trees', () => {
|
|
394
|
+
const Section = docComponent('section')
|
|
395
|
+
const Text = docComponent('text')
|
|
396
|
+
|
|
397
|
+
const tree = h(
|
|
398
|
+
Section,
|
|
399
|
+
{ $rocketstyle: { padding: 16 } },
|
|
400
|
+
h(Text, { $rocketstyle: { fontSize: 14 } }, 'Paragraph one'),
|
|
401
|
+
h(Text, { $rocketstyle: { fontSize: 14 } }, 'Paragraph two'),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const result = extractDocumentTree(tree)
|
|
405
|
+
expect(result.type).toBe('section')
|
|
406
|
+
expect(result.styles).toEqual({ padding: 16 })
|
|
407
|
+
expect(result.children).toHaveLength(2)
|
|
408
|
+
const child0 = result.children[0] as { type: string; children: unknown[] }
|
|
409
|
+
const child1 = result.children[1] as { type: string; children: unknown[] }
|
|
410
|
+
expect(child0.type).toBe('text')
|
|
411
|
+
expect(child0.children).toEqual(['Paragraph one'])
|
|
412
|
+
expect(child1.type).toBe('text')
|
|
413
|
+
expect(child1.children).toEqual(['Paragraph two'])
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('transparent wrappers (no _documentType) are flattened by the extractor', () => {
|
|
417
|
+
const Section = docComponent('section')
|
|
418
|
+
const Text = docComponent('text')
|
|
419
|
+
// A plain `div` wrapper nested inside a section — no document
|
|
420
|
+
// marker on the div — should be invisible to the extractor.
|
|
421
|
+
// Consumers sprinkle layout containers without breaking the
|
|
422
|
+
// extraction pipeline.
|
|
423
|
+
const tree = h(
|
|
424
|
+
Section,
|
|
425
|
+
{},
|
|
426
|
+
h('div', {}, h(Text, { $rocketstyle: { fontSize: 14 } }, 'Hello')),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const result = extractDocumentTree(tree)
|
|
430
|
+
expect(result.type).toBe('section')
|
|
431
|
+
expect(result.children).toHaveLength(1)
|
|
432
|
+
const child0 = result.children[0] as { type: string; children: unknown[] }
|
|
433
|
+
expect(child0.type).toBe('text')
|
|
434
|
+
expect(child0.children).toEqual(['Hello'])
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('component is INVOKED during extraction (the PR #197 fix)', () => {
|
|
438
|
+
// The fix in PR #197: when a docComponent has attrs-HOC-style
|
|
439
|
+
// post-processing, extractDocumentTree must call the component to
|
|
440
|
+
// see its post-attrs VNode. With real `h()` the contract is the
|
|
441
|
+
// same — the component function on `vnode.type` must be invoked
|
|
442
|
+
// so that attrs-populated `_documentProps` surface correctly.
|
|
443
|
+
let callCount = 0
|
|
444
|
+
const Enriched = docComponent('heading', (props: any) => {
|
|
445
|
+
callCount++
|
|
446
|
+
// Mimic an attrs HOC that stamps post-attrs metadata onto a
|
|
447
|
+
// child VNode instead of the outer wrapper (the exact shape
|
|
448
|
+
// PR #197 discovered).
|
|
449
|
+
return h('div', { ...props, _documentProps: { level: 2 } }, props.children)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
const tree = h(Enriched, {}, 'After attrs')
|
|
453
|
+
const result = extractDocumentTree(tree)
|
|
454
|
+
expect(callCount).toBeGreaterThan(0)
|
|
455
|
+
expect(result.type).toBe('heading')
|
|
456
|
+
expect(result.props.level).toBe(2)
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
// ─── T3.1 hoisted-attrs fast path (Path C) ────────────────────────────
|
|
461
|
+
//
|
|
462
|
+
// Real rocketstyle primitives now expose `__rs_attrs` — the accumulated
|
|
463
|
+
// `.attrs()` callback chain — on the component function itself.
|
|
464
|
+
// `extractDocumentTree` runs that chain DIRECTLY instead of invoking
|
|
465
|
+
// the full component, so `_documentProps` resolution doesn't pay for
|
|
466
|
+
// the styled wrapper / dimension resolution / JSX tree creation.
|
|
467
|
+
//
|
|
468
|
+
// The test below mimics rocketstyle's exposed surface (no real
|
|
469
|
+
// `@pyreon/rocketstyle` import needed — the contract is just "if
|
|
470
|
+
// `__rs_attrs` is present on the component function, use it"). A
|
|
471
|
+
// counter-spied "render" function asserts the body is NEVER called when
|
|
472
|
+
// the fast path is taken.
|
|
473
|
+
|
|
474
|
+
describe('extractDocumentTree — T3.1 hoisted-attrs fast path', () => {
|
|
475
|
+
it('uses __rs_attrs without invoking the component function (Path C)', () => {
|
|
476
|
+
let callCount = 0
|
|
477
|
+
// Mimic a rocketstyle primitive: function with _documentType static
|
|
478
|
+
// AND __rs_attrs static (the hoisted attrs chain). The body is what
|
|
479
|
+
// would be the styled wrapper; we count its invocations.
|
|
480
|
+
const FakeRocketDoc = ((props: any) => {
|
|
481
|
+
callCount++
|
|
482
|
+
return h('div', props, props.children)
|
|
483
|
+
}) as ((p: any) => any) & DocumentMarker
|
|
484
|
+
;(FakeRocketDoc as any)._documentType = 'document'
|
|
485
|
+
;(FakeRocketDoc as any).__rs_attrs = [
|
|
486
|
+
(props: { title?: string; author?: string }) => ({
|
|
487
|
+
_documentProps: {
|
|
488
|
+
...(props.title ? { title: props.title } : {}),
|
|
489
|
+
...(props.author ? { author: props.author } : {}),
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
const tree = h(FakeRocketDoc, { title: 'My Doc', author: 'Alice' })
|
|
495
|
+
const result = extractDocumentTree(tree)
|
|
496
|
+
|
|
497
|
+
expect(result.type).toBe('document')
|
|
498
|
+
expect(result.props.title).toBe('My Doc')
|
|
499
|
+
expect(result.props.author).toBe('Alice')
|
|
500
|
+
// The architectural assertion — the component body must NOT run.
|
|
501
|
+
expect(callCount).toBe(0)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('also resolves accessor function values from __rs_attrs (composes with D1 fix)', () => {
|
|
505
|
+
let liveTitle = 'First'
|
|
506
|
+
let callCount = 0
|
|
507
|
+
const FakeRocketDoc = ((props: any) => {
|
|
508
|
+
callCount++
|
|
509
|
+
return h('div', props, props.children)
|
|
510
|
+
}) as ((p: any) => any) & DocumentMarker
|
|
511
|
+
;(FakeRocketDoc as any)._documentType = 'document'
|
|
512
|
+
;(FakeRocketDoc as any).__rs_attrs = [
|
|
513
|
+
(props: { title?: () => string }) => ({
|
|
514
|
+
_documentProps: { title: props.title },
|
|
515
|
+
}),
|
|
516
|
+
]
|
|
517
|
+
|
|
518
|
+
const jsx = h(FakeRocketDoc, { title: () => liveTitle })
|
|
519
|
+
const first = extractDocumentTree(jsx)
|
|
520
|
+
expect(first.props.title).toBe('First')
|
|
521
|
+
|
|
522
|
+
liveTitle = 'Second'
|
|
523
|
+
const second = extractDocumentTree(jsx)
|
|
524
|
+
expect(second.props.title).toBe('Second')
|
|
525
|
+
|
|
526
|
+
// Two extractions, zero component invocations.
|
|
527
|
+
expect(callCount).toBe(0)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('falls back to Path B (full component invocation) when __rs_attrs is absent', () => {
|
|
531
|
+
// Non-rocketstyle docComponents (test fixtures, hand-rolled HOCs)
|
|
532
|
+
// don't have __rs_attrs and must still work via the legacy Path B.
|
|
533
|
+
let callCount = 0
|
|
534
|
+
const PlainDoc = ((props: any) => {
|
|
535
|
+
callCount++
|
|
536
|
+
return h('div', { ...props, _documentProps: { level: 3 } }, props.children)
|
|
537
|
+
}) as ((p: any) => any) & DocumentMarker
|
|
538
|
+
;(PlainDoc as any)._documentType = 'heading'
|
|
539
|
+
// Note: NO __rs_attrs here — Path B should fire
|
|
540
|
+
|
|
541
|
+
const tree = h(PlainDoc, {}, 'Body')
|
|
542
|
+
const result = extractDocumentTree(tree)
|
|
543
|
+
|
|
544
|
+
expect(result.type).toBe('heading')
|
|
545
|
+
expect(result.props.level).toBe(3)
|
|
546
|
+
expect(callCount).toBeGreaterThan(0)
|
|
547
|
+
})
|
|
548
|
+
})
|
|
@@ -92,63 +92,41 @@ function extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocCh
|
|
|
92
92
|
if (docType) {
|
|
93
93
|
// ── _documentProps resolution ────────────────────────────────────
|
|
94
94
|
//
|
|
95
|
-
//
|
|
95
|
+
// Three paths to find `_documentProps` on a documentType vnode,
|
|
96
|
+
// tried in order:
|
|
96
97
|
//
|
|
97
|
-
// (A) **Pre-resolved on the JSX vnode itself** — used by
|
|
98
|
-
//
|
|
99
|
-
//
|
|
98
|
+
// (A) **Pre-resolved on the JSX vnode itself** — used by test
|
|
99
|
+
// fixtures that hand-construct vnodes with `_documentProps`
|
|
100
|
+
// baked in. Cheapest path; tried first.
|
|
100
101
|
//
|
|
101
|
-
// (
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// `_documentProps
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// function and read from THAT vnode's props.
|
|
102
|
+
// (C) **Hoisted-attrs fast path (T3.1, PR #321)** — when the
|
|
103
|
+
// component is a real rocketstyle primitive, it exposes
|
|
104
|
+
// `__rs_attrs` (the accumulated `.attrs()` callback chain)
|
|
105
|
+
// as a typed static. We run the chain DIRECTLY with the
|
|
106
|
+
// JSX vnode's props — `chain.reduce(Object.assign, {})` —
|
|
107
|
+
// and read `_documentProps` from the result. No styled
|
|
108
|
+
// wrapper invocation, no JSX tree creation, no dimension
|
|
109
|
+
// resolution. This is the production path for every real
|
|
110
|
+
// Pyreon doc-primitive (DocDocument, DocHeading, etc.).
|
|
111
111
|
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
112
|
+
// (B) **Full component invocation (legacy fallback)** — only
|
|
113
|
+
// fires when neither A nor C applies. Used by hand-rolled
|
|
114
|
+
// test fixtures that mark a function with `_documentType`
|
|
115
|
+
// but don't go through rocketstyle (so `__rs_attrs` is
|
|
116
|
+
// absent). Calls the component with the JSX props and
|
|
117
|
+
// reads `_documentProps` from the post-call vnode.
|
|
118
|
+
//
|
|
119
|
+
// Why three paths instead of one: (A) is for test fixtures that
|
|
120
|
+
// hardcode `_documentProps` directly on the JSX vnode — a pattern
|
|
121
|
+
// that pre-dates the attrs HOC. (C) is the real-world path. (B)
|
|
122
|
+
// is what (C) replaced — kept so non-rocketstyle fixtures still
|
|
123
|
+
// work. See PR #197 for the original metadata-drop bug and
|
|
124
|
+
// PR #321 (T3.1) for the architectural fast path.
|
|
116
125
|
//
|
|
117
126
|
// **Function values in _documentProps are resolved at this
|
|
118
127
|
// point** — primitives like DocDocument can store accessor
|
|
119
128
|
// thunks (`() => string`) for reactive metadata, and the
|
|
120
129
|
// export pipeline reads the LIVE value on each extraction.
|
|
121
|
-
// See PR #197 for the original use case (resume builder).
|
|
122
|
-
//
|
|
123
|
-
// ── Architectural note ──────────────────────────────────────────
|
|
124
|
-
//
|
|
125
|
-
// Path B is a workaround. The architecturally cleaner fix is to
|
|
126
|
-
// have rocketstyle's `.statics()` mechanism hoist `_documentProps`
|
|
127
|
-
// (or its accessor functions) directly onto the component
|
|
128
|
-
// function — so `extractNode` could read it via
|
|
129
|
-
// `(type as { _documentProps?: ... })._documentProps` without
|
|
130
|
-
// ever invoking the component.
|
|
131
|
-
//
|
|
132
|
-
// That would require teaching rocketstyle that `.statics()`
|
|
133
|
-
// values can be derived from `.attrs()` callbacks. It's a
|
|
134
|
-
// bigger change in `@pyreon/rocketstyle/src/utils/statics.ts`
|
|
135
|
-
// and was deemed out of scope for PR #197. The current
|
|
136
|
-
// workaround works because:
|
|
137
|
-
//
|
|
138
|
-
// 1. rocketstyle's attrs HOC is meant to be PURE setup —
|
|
139
|
-
// no observable side effects on the second call.
|
|
140
|
-
// 2. The idempotence test in
|
|
141
|
-
// `document-primitives/src/__tests__/useDocumentExport.test.ts`
|
|
142
|
-
// locks in the purity assumption: extracting twice produces
|
|
143
|
-
// structurally equivalent doc nodes.
|
|
144
|
-
// 3. Path A is tried first, so existing fast-path tests don't
|
|
145
|
-
// pay the component-invocation cost.
|
|
146
|
-
//
|
|
147
|
-
// If a future primitive accidentally introduces a side effect
|
|
148
|
-
// in its setup body, the idempotence test catches it. If
|
|
149
|
-
// performance becomes a concern (extractDocumentTree is called
|
|
150
|
-
// per export, not per render — so this is unlikely), the
|
|
151
|
-
// architectural fix in rocketstyle becomes worth doing.
|
|
152
130
|
|
|
153
131
|
let rawDocProps: Record<string, unknown> | undefined
|
|
154
132
|
let extractedFromCall: VNodeLike | null = null
|
|
@@ -157,17 +135,48 @@ function extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocCh
|
|
|
157
135
|
if (props._documentProps && typeof props._documentProps === 'object') {
|
|
158
136
|
rawDocProps = props._documentProps as Record<string, unknown>
|
|
159
137
|
} else if (typeof type === 'function') {
|
|
160
|
-
// Path
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
138
|
+
// ── Path C (T3.1 fast path) ─────────────────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// Rocketstyle exposes the accumulated `.attrs()` callback chain
|
|
141
|
+
// as `__rs_attrs` on the component function. Run the chain
|
|
142
|
+
// directly with the JSX vnode's props to get the post-attrs
|
|
143
|
+
// result — no full component invocation, no styling work, no
|
|
144
|
+
// wrapped JSX tree creation. Just the user-supplied attrs
|
|
145
|
+
// callback(s) folded into a single props object.
|
|
146
|
+
//
|
|
147
|
+
// This eliminates the per-export cost of Path B for every real
|
|
148
|
+
// rocketstyle primitive (DocDocument, DocHeading, etc.). The
|
|
149
|
+
// idempotence assumption is now structural rather than implicit:
|
|
150
|
+
// we never call the component, so it cannot have side effects
|
|
151
|
+
// that affect the second extraction.
|
|
152
|
+
const rsAttrs = (type as { __rs_attrs?: Array<(p: Record<string, unknown>) => Record<string, unknown>> }).__rs_attrs
|
|
153
|
+
if (rsAttrs && rsAttrs.length > 0) {
|
|
154
|
+
const mergedProps = { ...props }
|
|
155
|
+
if (children && children.length > 0) {
|
|
156
|
+
mergedProps.children = children.length === 1 ? children[0] : children
|
|
157
|
+
}
|
|
158
|
+
const attrsResult = rsAttrs.reduce<Record<string, unknown>>(
|
|
159
|
+
(acc, fn) => Object.assign(acc, fn(mergedProps)),
|
|
160
|
+
{},
|
|
161
|
+
)
|
|
162
|
+
if (attrsResult._documentProps && typeof attrsResult._documentProps === 'object') {
|
|
163
|
+
rawDocProps = attrsResult._documentProps as Record<string, unknown>
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Path B (fallback for non-rocketstyle docComponents):
|
|
167
|
+
// invoke the component to get the post-attrs vnode. Used by
|
|
168
|
+
// hand-rolled test fixtures that don't go through rocketstyle.
|
|
169
|
+
const mergedProps = { ...props }
|
|
170
|
+
if (children && children.length > 0) {
|
|
171
|
+
mergedProps.children = children.length === 1 ? children[0] : children
|
|
172
|
+
}
|
|
173
|
+
const result = (type as (p: Record<string, unknown>) => unknown)(mergedProps)
|
|
174
|
+
if (isVNode(result)) {
|
|
175
|
+
extractedFromCall = result
|
|
176
|
+
const innerProps = (result as { props?: Record<string, unknown> }).props
|
|
177
|
+
if (innerProps?._documentProps && typeof innerProps._documentProps === 'object') {
|
|
178
|
+
rawDocProps = innerProps._documentProps as Record<string, unknown>
|
|
179
|
+
}
|
|
171
180
|
}
|
|
172
181
|
}
|
|
173
182
|
}
|
package/lib/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/cssValueParser.ts","../../src/extractDocumentTree.ts","../../src/resolveStyles.ts"],"mappings":";;;;;;AAiBA;;;;;AAyBC;iBAzBe,iBAAA,CACd,KAAA,sCACA,QAAA;AAAA,KAyBG,cAAA;;;AAWL;;;;;;;iBAAgB,aAAA,CACd,KAAA,+BACA,QAAA,YACC,cAAA;;AAyBH;;iBAAgB,eAAA,CACd,KAAA;;;AAaF;iBAAgB,eAAA,CACd,KAAA,+BACA,QAAA;;;;UC/Fe,cAAA;EACf,aAAA,EAAe,QAAA;AAAA;AAAA,UAGA,cAAA;EDUf;ECRA,QAAA;EDkCG;EChCH,aAAA;AAAA;;;AD2CF;;;;;;;;;AA4BA;iBC0KgB,mBAAA,CAAoB,KAAA,WAAgB,OAAA,GAAS,cAAA,GAAsB,OAAA;;;;;AD5OnF;;;;;iBEGgB,aAAA,CAAc,WAAA,EAAa,MAAA,mBAAyB,QAAA,YAAgB,cAAA"}
|
package/lib/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/cssValueParser.ts","../src/resolveStyles.ts","../src/extractDocumentTree.ts"],"sourcesContent":["const PX_RE = /^(-?\\d+(?:\\.\\d+)?)px$/\nconst REM_RE = /^(-?\\d+(?:\\.\\d+)?)rem$/\nconst EM_RE = /^(-?\\d+(?:\\.\\d+)?)em$/\nconst PT_RE = /^(-?\\d+(?:\\.\\d+)?)pt$/\nconst NUMBER_RE = /^-?\\d+(?:\\.\\d+)?$/\n\nconst DEFAULT_ROOT_SIZE = 16\n\n/**\n * Parse a CSS dimension value to a number.\n *\n * - `14` → `14`\n * - `'14px'` → `14`\n * - `'1.5rem'` → `24` (with rootSize=16)\n * - `'12pt'` → `16` (pt × 1.333)\n * - `'auto'` → `undefined`\n */\nexport function parseCssDimension(\n value: string | number | null | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (typeof value !== 'string') return undefined\n\n const trimmed = value.trim()\n\n const pxMatch = PX_RE.exec(trimmed)\n if (pxMatch?.[1]) return Number.parseFloat(pxMatch[1])\n\n const remMatch = REM_RE.exec(trimmed)\n if (remMatch?.[1]) return Number.parseFloat(remMatch[1]) * rootSize\n\n const emMatch = EM_RE.exec(trimmed)\n if (emMatch?.[1]) return Number.parseFloat(emMatch[1]) * rootSize\n\n const ptMatch = PT_RE.exec(trimmed)\n if (ptMatch?.[1]) return Number.parseFloat(ptMatch[1]) * (4 / 3)\n\n if (NUMBER_RE.test(trimmed)) return Number.parseFloat(trimmed)\n\n return undefined\n}\n\ntype BoxModelResult = number | [number, number] | [number, number, number, number] | undefined\n\n/**\n * Parse a CSS padding/margin shorthand to document tuple format.\n *\n * - `8` → `8`\n * - `'8px'` → `8`\n * - `'8px 16px'` → `[8, 16]`\n * - `'8px 16px 8px 16px'` → `[8, 16, 8, 16]`\n * - `'8px 16px 12px'` → `[8, 16, 12, 16]` (CSS 3-value shorthand)\n */\nexport function parseBoxModel(\n value: string | number | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): BoxModelResult {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n\n const parts = value\n .trim()\n .split(/\\s+/)\n .map((p) => parseCssDimension(p, rootSize))\n\n const nums = parts.filter((p): p is number => p != null)\n if (nums.length !== parts.length) return undefined\n\n if (nums.length === 1) return nums[0]\n if (nums.length === 2) return [nums[0], nums[1]] as [number, number]\n if (nums.length === 3)\n return [nums[0], nums[1], nums[2], nums[1]] as [number, number, number, number]\n if (nums.length === 4)\n return [nums[0], nums[1], nums[2], nums[3]] as [number, number, number, number]\n\n return undefined\n}\n\n/**\n * Parse a CSS font-weight value.\n */\nexport function parseFontWeight(\n value: string | number | undefined,\n): 'normal' | 'bold' | number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (value === 'normal' || value === 'bold') return value\n const num = Number.parseInt(value, 10)\n if (!Number.isNaN(num)) return num\n return undefined\n}\n\n/**\n * Parse a CSS line-height value to a unitless number.\n */\nexport function parseLineHeight(\n value: string | number | undefined,\n rootSize = DEFAULT_ROOT_SIZE,\n): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return value\n if (value === 'normal') return undefined\n\n const dim = parseCssDimension(value, rootSize)\n if (dim != null) return dim\n\n return undefined\n}\n","import {\n parseBoxModel,\n parseCssDimension,\n parseFontWeight,\n parseLineHeight,\n} from './cssValueParser'\nimport type { ResolvedStyles } from './types'\n\nconst TEXT_ALIGN_VALUES = new Set(['left', 'center', 'right', 'justify'])\nconst FONT_STYLE_VALUES = new Set(['normal', 'italic'])\nconst TEXT_DECORATION_VALUES = new Set(['none', 'underline', 'line-through'])\nconst BORDER_STYLE_VALUES = new Set(['solid', 'dashed', 'dotted'])\n\n/**\n * Convert a rocketstyle `$rocketstyle` theme object into a `ResolvedStyles`\n * object compatible with `@pyreon/document`.\n *\n * Only extracts properties that `ResolvedStyles` supports — everything else\n * (transitions, cursor, display, etc.) is silently ignored.\n */\nexport function resolveStyles(rocketstyle: Record<string, unknown>, rootSize = 16): ResolvedStyles {\n const styles: ResolvedStyles = {}\n\n // Typography\n const fontSize = parseCssDimension(rocketstyle.fontSize as string | number, rootSize)\n if (fontSize != null) styles.fontSize = fontSize\n\n if (typeof rocketstyle.fontFamily === 'string') styles.fontFamily = rocketstyle.fontFamily\n\n const fontWeight = parseFontWeight(rocketstyle.fontWeight as string | number | undefined)\n if (fontWeight != null) styles.fontWeight = fontWeight\n\n if (typeof rocketstyle.fontStyle === 'string' && FONT_STYLE_VALUES.has(rocketstyle.fontStyle))\n styles.fontStyle = rocketstyle.fontStyle as 'normal' | 'italic'\n\n if (\n typeof rocketstyle.textDecoration === 'string' &&\n TEXT_DECORATION_VALUES.has(rocketstyle.textDecoration)\n )\n styles.textDecoration = rocketstyle.textDecoration as 'none' | 'underline' | 'line-through'\n\n if (typeof rocketstyle.color === 'string') styles.color = rocketstyle.color\n\n if (typeof rocketstyle.backgroundColor === 'string')\n styles.backgroundColor = rocketstyle.backgroundColor\n\n if (typeof rocketstyle.textAlign === 'string' && TEXT_ALIGN_VALUES.has(rocketstyle.textAlign))\n styles.textAlign = rocketstyle.textAlign as 'left' | 'center' | 'right' | 'justify'\n\n const lineHeight = parseLineHeight(\n rocketstyle.lineHeight as string | number | undefined,\n rootSize,\n )\n if (lineHeight != null) styles.lineHeight = lineHeight\n\n const letterSpacing = parseCssDimension(rocketstyle.letterSpacing as string | number, rootSize)\n if (letterSpacing != null) styles.letterSpacing = letterSpacing\n\n // Box model\n const padding = parseBoxModel(rocketstyle.padding as string | number | undefined, rootSize)\n if (padding != null) styles.padding = padding\n\n const margin = parseBoxModel(rocketstyle.margin as string | number | undefined, rootSize)\n if (margin != null) styles.margin = margin\n\n // Border\n const borderRadius = parseCssDimension(rocketstyle.borderRadius as string | number, rootSize)\n if (borderRadius != null) styles.borderRadius = borderRadius\n\n const borderWidth = parseCssDimension(rocketstyle.borderWidth as string | number, rootSize)\n if (borderWidth != null) styles.borderWidth = borderWidth\n\n if (typeof rocketstyle.borderColor === 'string') styles.borderColor = rocketstyle.borderColor\n\n if (\n typeof rocketstyle.borderStyle === 'string' &&\n BORDER_STYLE_VALUES.has(rocketstyle.borderStyle)\n )\n styles.borderStyle = rocketstyle.borderStyle as 'solid' | 'dashed' | 'dotted'\n\n // Sizing\n if (rocketstyle.width != null) {\n const w = parseCssDimension(rocketstyle.width as string | number, rootSize)\n styles.width = w ?? (rocketstyle.width as string)\n }\n\n if (rocketstyle.height != null) {\n const h = parseCssDimension(rocketstyle.height as string | number, rootSize)\n styles.height = h ?? (rocketstyle.height as string)\n }\n\n if (rocketstyle.maxWidth != null) {\n const mw = parseCssDimension(rocketstyle.maxWidth as string | number, rootSize)\n styles.maxWidth = mw ?? (rocketstyle.maxWidth as string)\n }\n\n // Opacity\n if (typeof rocketstyle.opacity === 'number') styles.opacity = rocketstyle.opacity\n\n return styles\n}\n","import { resolveStyles } from './resolveStyles'\nimport type { DocChild, DocNode, NodeType } from './types'\n\n/** Marker interface: components with _documentType are extractable. */\nexport interface DocumentMarker {\n _documentType: NodeType\n}\n\nexport interface ExtractOptions {\n /** Root font size for rem→px conversion. Default: 16. */\n rootSize?: number\n /** Include resolved styles from $rocketstyle. Default: true. */\n includeStyles?: boolean\n}\n\ntype VNodeLike = {\n type: string | ((...args: any[]) => any)\n props: Record<string, any>\n children: unknown[]\n}\n\nfunction isVNode(value: unknown): value is VNodeLike {\n return value != null && typeof value === 'object' && 'type' in value && 'props' in value\n}\n\nfunction getDocumentType(fn: unknown): NodeType | undefined {\n if (typeof fn !== 'function') return undefined\n const meta = (fn as any).meta\n if (meta?._documentType) return meta._documentType as NodeType\n // Fallback: check directly on function (non-rocketstyle components)\n if ('_documentType' in fn) return (fn as any)._documentType as NodeType\n return undefined\n}\n\nfunction flattenChildren(children: unknown[]): unknown[] {\n const result: unknown[] = []\n for (const child of children) {\n if (Array.isArray(child)) {\n result.push(...flattenChildren(child))\n } else if (typeof child === 'function') {\n // Reactive getter — call to resolve\n const resolved = child()\n if (Array.isArray(resolved)) {\n result.push(...flattenChildren(resolved))\n } else {\n result.push(resolved)\n }\n } else {\n result.push(child)\n }\n }\n return result\n}\n\nfunction extractChildren(children: unknown[], options: ExtractOptions): DocChild[] {\n const flat = flattenChildren(children)\n const result: DocChild[] = []\n\n for (const child of flat) {\n if (child == null || child === false || child === true) continue\n\n if (typeof child === 'string') {\n result.push(child)\n continue\n }\n\n if (typeof child === 'number') {\n result.push(String(child))\n continue\n }\n\n if (isVNode(child)) {\n const extracted = extractNode(child, options)\n if (Array.isArray(extracted)) {\n result.push(...extracted)\n } else if (extracted != null) {\n result.push(extracted)\n }\n }\n }\n\n return result\n}\n\nfunction extractNode(vnode: VNodeLike, options: ExtractOptions): DocNode | DocChild[] | null {\n const { type, props, children } = vnode\n const includeStyles = options.includeStyles !== false\n const rootSize = options.rootSize ?? 16\n\n // Component function with _documentType marker (via .statics() or direct)\n const docType = getDocumentType(type)\n if (docType) {\n // ── _documentProps resolution ────────────────────────────────────\n //\n // Two paths to find _documentProps on a documentType vnode:\n //\n // (A) **Pre-resolved on the JSX vnode itself** — used by\n // test fixtures that hand-construct vnodes without\n // going through rocketstyle. Less common in real usage.\n //\n // (B) **Post-attrs result of calling the component** — the\n // real-world path. When a real `DocDocument` (or any\n // rocketstyle primitive with `.statics({ _documentType })`)\n // is rendered via JSX, the JSX vnode's `props` are the\n // USER-PROVIDED props (e.g. `{ title, author }`) — NOT\n // `_documentProps`. The rocketstyle attrs HOC adds\n // `_documentProps` to the wrapped component's vnode by\n // running the `.attrs()` callback during invocation. To\n // see the post-attrs result, we must CALL the component\n // function and read from THAT vnode's props.\n //\n // We try path (A) first because mock-vnode tests rely on it\n // and we don't want to invoke component functions when we\n // don't have to. If path (A) yields no _documentProps, we\n // fall back to path (B) and call the component.\n //\n // **Function values in _documentProps are resolved at this\n // point** — primitives like DocDocument can store accessor\n // thunks (`() => string`) for reactive metadata, and the\n // export pipeline reads the LIVE value on each extraction.\n // See PR #197 for the original use case (resume builder).\n //\n // ── Architectural note ──────────────────────────────────────────\n //\n // Path B is a workaround. The architecturally cleaner fix is to\n // have rocketstyle's `.statics()` mechanism hoist `_documentProps`\n // (or its accessor functions) directly onto the component\n // function — so `extractNode` could read it via\n // `(type as { _documentProps?: ... })._documentProps` without\n // ever invoking the component.\n //\n // That would require teaching rocketstyle that `.statics()`\n // values can be derived from `.attrs()` callbacks. It's a\n // bigger change in `@pyreon/rocketstyle/src/utils/statics.ts`\n // and was deemed out of scope for PR #197. The current\n // workaround works because:\n //\n // 1. rocketstyle's attrs HOC is meant to be PURE setup —\n // no observable side effects on the second call.\n // 2. The idempotence test in\n // `document-primitives/src/__tests__/useDocumentExport.test.ts`\n // locks in the purity assumption: extracting twice produces\n // structurally equivalent doc nodes.\n // 3. Path A is tried first, so existing fast-path tests don't\n // pay the component-invocation cost.\n //\n // If a future primitive accidentally introduces a side effect\n // in its setup body, the idempotence test catches it. If\n // performance becomes a concern (extractDocumentTree is called\n // per export, not per render — so this is unlikely), the\n // architectural fix in rocketstyle becomes worth doing.\n\n let rawDocProps: Record<string, unknown> | undefined\n let extractedFromCall: VNodeLike | null = null\n\n // Path A: pre-resolved on the JSX vnode (test fixtures)\n if (props._documentProps && typeof props._documentProps === 'object') {\n rawDocProps = props._documentProps as Record<string, unknown>\n } else if (typeof type === 'function') {\n // Path B: invoke the component to get the post-attrs vnode\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n const result = (type as (p: Record<string, unknown>) => unknown)(mergedProps)\n if (isVNode(result)) {\n extractedFromCall = result\n const innerProps = (result as { props?: Record<string, unknown> }).props\n if (innerProps?._documentProps && typeof innerProps._documentProps === 'object') {\n rawDocProps = innerProps._documentProps as Record<string, unknown>\n }\n }\n }\n\n // Resolve function values (accessors) at extraction time\n const docProps: Record<string, unknown> = {}\n if (rawDocProps) {\n for (const [key, value] of Object.entries(rawDocProps)) {\n docProps[key] = typeof value === 'function' ? (value as () => unknown)() : value\n }\n }\n\n // Resolve styles from $rocketstyle. Look on the JSX vnode props\n // first; if the call result has its own $rocketstyle (because the\n // post-attrs vnode carries it down), use that as a fallback.\n const stylesSource =\n props.$rocketstyle ??\n (extractedFromCall as { props?: Record<string, unknown> } | null)?.props?.$rocketstyle\n const styles =\n includeStyles && stylesSource\n ? resolveStyles(stylesSource as Record<string, unknown>, rootSize)\n : undefined\n\n // Children: prefer the JSX vnode's children (the user-supplied\n // tree). The post-attrs call might wrap children in additional\n // styled elements that aren't part of the document tree.\n const docChildren = extractChildren(children ?? [], options)\n\n const node: DocNode = {\n type: docType,\n props: docProps,\n children: docChildren,\n }\n\n if (styles && Object.keys(styles).length > 0) {\n node.styles = styles\n }\n\n return node\n }\n\n // Component function WITHOUT _documentType — call it to get its VNode output\n if (typeof type === 'function') {\n const mergedProps = { ...props }\n if (children && children.length > 0) {\n mergedProps.children = children.length === 1 ? children[0] : children\n }\n\n const result = type(mergedProps)\n\n if (isVNode(result)) {\n return extractNode(result, options)\n }\n\n // The component returned a primitive or null\n if (typeof result === 'string') return [result]\n if (typeof result === 'number') return [String(result)]\n return null\n }\n\n // DOM element (string type like 'div', 'span') — transparent, extract children\n if (typeof type === 'string') {\n const docChildren = extractChildren(children ?? [], options)\n // If there's text content in the DOM element, collect it\n if (docChildren.length > 0) return docChildren\n return null\n }\n\n return null\n}\n\n/**\n * Walk a Pyreon VNode tree and extract a `DocNode` tree for `@pyreon/document`.\n *\n * For each VNode whose component has a `_documentType` marker:\n * 1. Read `_documentType` → `DocNode.type`\n * 2. Read `_documentProps` → `DocNode.props`\n * 3. Read `$rocketstyle` → `resolveStyles()` → `DocNode.styles`\n * 4. Recurse into children\n *\n * VNodes without `_documentType` are transparent — their children\n * are flattened into the parent's children list.\n */\nexport function extractDocumentTree(vnode: unknown, options: ExtractOptions = {}): DocNode {\n if (isVNode(vnode)) {\n const result = extractNode(vnode, options)\n if (result && !Array.isArray(result)) return result\n\n // Wrap loose children in a document node\n const children = Array.isArray(result) ? result : []\n return { type: 'document', props: {}, children }\n }\n\n // If passed a component function directly, call it\n if (typeof vnode === 'function') {\n const result = (vnode as () => unknown)()\n return extractDocumentTree(result, options)\n }\n\n return { type: 'document', props: {}, children: [] }\n}\n"],"mappings":"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"}
|