@pyreon/document-primitives 0.12.10 → 0.12.11

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.
@@ -1,6 +1,6 @@
1
1
  import type { DocumentMarker } from '@pyreon/connector-document'
2
2
  import { describe, expect, it } from 'vitest'
3
- import { createDocumentExport } from '../useDocumentExport'
3
+ import { createDocumentExport, extractDocNode } from '../useDocumentExport'
4
4
 
5
5
  // Mock VNode
6
6
  const vnode = (
@@ -90,3 +90,226 @@ describe('createDocumentExport', () => {
90
90
  expect(tree.children).toEqual([])
91
91
  })
92
92
  })
93
+
94
+ describe('extractDocNode (one-step alias)', () => {
95
+ // The one-step form. Equivalent to
96
+ // `createDocumentExport(templateFn).getDocNode()` but without
97
+ // the wrapper-object indirection. This is the form most
98
+ // consumers should use.
99
+
100
+ it('extracts a tree from a template function in one call', () => {
101
+ const tree = extractDocNode(() =>
102
+ vnode(DocDocument, { _documentProps: { title: 'Test' } }, [
103
+ vnode(DocHeading, { _documentProps: { level: 1 } }, ['Hello']),
104
+ ]),
105
+ )
106
+
107
+ expect(tree.type).toBe('document')
108
+ expect(tree.props.title).toBe('Test')
109
+ expect(tree.children).toHaveLength(1)
110
+ const heading = tree.children[0] as { type: string; props: Record<string, unknown> }
111
+ expect(heading.type).toBe('heading')
112
+ expect(heading.props.level).toBe(1)
113
+ })
114
+
115
+ it('respects extraction options (includeStyles: false)', () => {
116
+ const tree = extractDocNode(
117
+ () => vnode(DocHeading, { $rocketstyle: { fontSize: 24 } }, ['Hello']),
118
+ { includeStyles: false },
119
+ )
120
+ expect(tree.styles).toBeUndefined()
121
+ })
122
+
123
+ it('returns the same tree shape as createDocumentExport().getDocNode()', () => {
124
+ // Equivalence guarantee — the two-step form delegates to the
125
+ // one-step form internally, so they MUST produce identical
126
+ // output for identical input.
127
+ const template = () =>
128
+ vnode(DocText, { $rocketstyle: { fontSize: 14 } }, ['Hello'])
129
+
130
+ const oneStep = extractDocNode(template)
131
+ const twoStep = createDocumentExport(template).getDocNode()
132
+
133
+ expect(oneStep).toEqual(twoStep)
134
+ })
135
+
136
+ it('is idempotent — calling extractDocNode twice on the same template produces equivalent results', () => {
137
+ // The framework fix in PR #197 changed extractDocumentTree to
138
+ // CALL the component function for documentType vnodes when
139
+ // _documentProps is not directly on the JSX vnode. The fix is
140
+ // correct because rocketstyle's attrs HOC is meant to be pure
141
+ // setup with no observable side effects on the second call.
142
+ // This test locks in that purity assumption: extracting the
143
+ // same template twice produces structurally equivalent doc
144
+ // nodes.
145
+ //
146
+ // If a future change to a primitive accidentally introduces a
147
+ // side effect in its attrs callback (e.g. a counter, a log
148
+ // line, a stateful import), the second extraction would still
149
+ // SUCCEED but produce different output — and this test would
150
+ // catch the regression. The three extractions should be deeply
151
+ // equal under any change to the primitive's setup path.
152
+ const template = () =>
153
+ vnode(
154
+ DocDocument,
155
+ { _documentProps: { title: 'Idempotent', author: 'Test' } },
156
+ [
157
+ vnode(DocHeading, { _documentProps: { level: 1 } }, ['Hello']),
158
+ vnode(DocText, { $rocketstyle: { fontSize: 14 } }, ['World']),
159
+ ],
160
+ )
161
+
162
+ const first = extractDocNode(template)
163
+ const second = extractDocNode(template)
164
+ const third = extractDocNode(template)
165
+
166
+ expect(first).toEqual(second)
167
+ expect(second).toEqual(third)
168
+ })
169
+
170
+ it('resolves function values in _documentProps each call (D1+D2 integration)', () => {
171
+ // The combined contract: extractDocNode is the one-step form,
172
+ // and it benefits from the same function-value resolution
173
+ // that extractDocumentTree does. Each call sees the live
174
+ // value of any accessor in _documentProps.
175
+ let counter = 0
176
+ const template = () =>
177
+ vnode(
178
+ DocDocument,
179
+ {
180
+ _documentProps: {
181
+ title: () => `Export ${++counter}`,
182
+ author: 'Plain string still works',
183
+ },
184
+ },
185
+ [],
186
+ )
187
+
188
+ const first = extractDocNode(template)
189
+ const second = extractDocNode(template)
190
+
191
+ expect(first.props.title).toBe('Export 1')
192
+ expect(first.props.author).toBe('Plain string still works')
193
+ expect(second.props.title).toBe('Export 2')
194
+ })
195
+ })
196
+
197
+ describe('DocDocument reactive metadata (D1 integration)', () => {
198
+ // The end-to-end contract: a real DocDocument primitive (NOT a
199
+ // mock vnode) accepts accessor functions for title/author/subject,
200
+ // stores them in _documentProps, and the export pipeline reads
201
+ // the LIVE values at extraction time.
202
+ //
203
+ // This test mounts DocDocument as if from JSX (via h()) and
204
+ // extracts the resulting tree, then mutates the closure-captured
205
+ // state and re-extracts to prove the second extraction sees the
206
+ // updated value. The previous tests use mock vnodes — this one
207
+ // uses the real primitive.
208
+
209
+ // Long timeout: this test dynamic-imports @pyreon/test-utils,
210
+ // @pyreon/core, and DocDocument inside the test body. Each
211
+ // dynamic import triggers Vite's transform pipeline (JSX
212
+ // compilation, rocketstyle wrapping, etc.) which takes 5+
213
+ // seconds on slow CI runners on first hit. The default 5000ms
214
+ // timeout fails reliably on CI.
215
+ it('DocDocument with accessor title produces live values across multiple extractions', { timeout: 30_000 }, async () => {
216
+ // Use happy-dom + initTestConfig like the rest of the test suite
217
+ const { initTestConfig } = await import('@pyreon/test-utils')
218
+ const { h } = await import('@pyreon/core')
219
+ const cleanup = initTestConfig()
220
+ try {
221
+ // Real DocDocument from the package (not the mock above)
222
+ const RealDocDocument = (await import('../primitives/DocDocument')).default
223
+
224
+ // Closure state that the accessor reads from. Mutating it
225
+ // between extractions simulates a signal change.
226
+ let currentName = 'Aisha'
227
+ const titleAccessor = () => `${currentName} — Resume`
228
+ const authorAccessor = () => currentName
229
+
230
+ // Build the template via h() so DocDocument's attrs callback
231
+ // runs (storing the accessor functions in _documentProps).
232
+ const template = () =>
233
+ h(
234
+ RealDocDocument as never,
235
+ { title: titleAccessor, author: authorAccessor } as never,
236
+ )
237
+
238
+ const first = extractDocNode(template)
239
+ expect(first.type).toBe('document')
240
+ expect(first.props.title).toBe('Aisha — Resume')
241
+ expect(first.props.author).toBe('Aisha')
242
+
243
+ // Mutate the closure-captured name. The next extraction
244
+ // should see the new value because extractDocumentTree calls
245
+ // the function fresh.
246
+ currentName = 'Marcus'
247
+ const second = extractDocNode(template)
248
+ expect(second.props.title).toBe('Marcus — Resume')
249
+ expect(second.props.author).toBe('Marcus')
250
+
251
+ // And once more for good measure
252
+ currentName = 'Priya'
253
+ const third = extractDocNode(template)
254
+ expect(third.props.title).toBe('Priya — Resume')
255
+ expect(third.props.author).toBe('Priya')
256
+ } finally {
257
+ cleanup()
258
+ }
259
+ })
260
+
261
+ it('DocDocument with plain string title still works (backward compat)', { timeout: 30_000 }, async () => {
262
+ const { initTestConfig } = await import('@pyreon/test-utils')
263
+ const { h } = await import('@pyreon/core')
264
+ const cleanup = initTestConfig()
265
+ try {
266
+ const RealDocDocument = (await import('../primitives/DocDocument')).default
267
+
268
+ const tree = extractDocNode(() =>
269
+ h(
270
+ RealDocDocument as never,
271
+ { title: 'Static title', author: 'Static author' } as never,
272
+ ),
273
+ )
274
+
275
+ expect(tree.props.title).toBe('Static title')
276
+ expect(tree.props.author).toBe('Static author')
277
+ } finally {
278
+ cleanup()
279
+ }
280
+ })
281
+
282
+ it('DocDocument subject prop also accepts both string and accessor (full prop coverage)', { timeout: 30_000 }, async () => {
283
+ // The widening covered all three metadata props (title, author,
284
+ // subject). The previous tests only exercise title and author —
285
+ // this test fills the coverage gap so a typo in the subject
286
+ // type widening would be caught.
287
+ const { initTestConfig } = await import('@pyreon/test-utils')
288
+ const { h } = await import('@pyreon/core')
289
+ const cleanup = initTestConfig()
290
+ try {
291
+ const RealDocDocument = (await import('../primitives/DocDocument')).default
292
+
293
+ // Plain string subject
294
+ const plainTree = extractDocNode(() =>
295
+ h(RealDocDocument as never, { subject: 'Q4 Report' } as never),
296
+ )
297
+ expect(plainTree.props.subject).toBe('Q4 Report')
298
+
299
+ // Accessor subject — value resolved at extraction time
300
+ let topic = 'Initial topic'
301
+ const accessorTree1 = extractDocNode(() =>
302
+ h(RealDocDocument as never, { subject: () => topic } as never),
303
+ )
304
+ expect(accessorTree1.props.subject).toBe('Initial topic')
305
+
306
+ topic = 'Updated topic'
307
+ const accessorTree2 = extractDocNode(() =>
308
+ h(RealDocDocument as never, { subject: () => topic } as never),
309
+ )
310
+ expect(accessorTree2.props.subject).toBe('Updated topic')
311
+ } finally {
312
+ cleanup()
313
+ }
314
+ })
315
+ })
package/src/index.ts CHANGED
@@ -34,4 +34,4 @@ export type { DocumentTheme } from './theme'
34
34
  export { documentTheme } from './theme'
35
35
  // Export helper
36
36
  export type { DocumentExport, DocumentExportOptions } from './useDocumentExport'
37
- export { createDocumentExport } from './useDocumentExport'
37
+ export { createDocumentExport, extractDocNode } from './useDocumentExport'
@@ -29,7 +29,7 @@ const DocButton = rocketstyle({
29
29
  },
30
30
  })
31
31
  .statics({ _documentType: 'button' as const })
32
- .attrs<{ href?: string; tag: string; _documentProps: { href: string } }>((props) => ({
32
+ .attrs<{ href?: string }>((props) => ({
33
33
  tag: 'a',
34
34
  _documentProps: { href: props.href ?? '#' },
35
35
  }))
@@ -10,7 +10,7 @@ const DocCode = rocketstyle()({ name: 'DocCode', component: Text })
10
10
  borderRadius: 4,
11
11
  })
12
12
  .statics({ _documentType: 'code' as const })
13
- .attrs<{ language?: string; tag: string; _documentProps: Record<string, unknown> }>((props) => ({
13
+ .attrs<{ language?: string }>((props) => ({
14
14
  tag: 'pre',
15
15
  _documentProps: props.language ? { language: props.language } : {},
16
16
  }))
@@ -3,11 +3,9 @@ import rocketstyle from '@pyreon/rocketstyle'
3
3
 
4
4
  const DocColumn = rocketstyle()({ name: 'DocColumn', component: Element })
5
5
  .statics({ _documentType: 'column' as const })
6
- .attrs<{ width?: number | string; tag: string; _documentProps: Record<string, unknown> }>(
7
- (props) => ({
8
- tag: 'div',
9
- _documentProps: props.width != null ? { width: props.width } : {},
10
- }),
11
- )
6
+ .attrs<{ width?: number | string }>((props) => ({
7
+ tag: 'div',
8
+ _documentProps: props.width != null ? { width: props.width } : {},
9
+ }))
12
10
 
13
11
  export default DocColumn
@@ -10,8 +10,6 @@ const DocDivider = rocketstyle()({ name: 'DocDivider', component: Element })
10
10
  .attrs<{
11
11
  color?: string
12
12
  thickness?: number
13
- tag: string
14
- _documentProps: Record<string, unknown>
15
13
  }>((props) => ({
16
14
  tag: 'hr',
17
15
  _documentProps: {
@@ -1,20 +1,68 @@
1
1
  import { Element } from '@pyreon/elements'
2
2
  import rocketstyle from '@pyreon/rocketstyle'
3
3
 
4
+ /**
5
+ * Root document container with metadata for the export pipeline.
6
+ *
7
+ * The `title`, `author`, and `subject` props each accept either a
8
+ * plain `string` or a `() => string` accessor. Accessors are
9
+ * resolved by `extractDocumentTree` at export time, so consumers
10
+ * can pass live signal accessors without capturing values at
11
+ * component mount.
12
+ *
13
+ * **Why accessors are needed**: rocketstyle's `.attrs()` callback
14
+ * runs ONCE at component mount (see
15
+ * `packages/ui-system/rocketstyle/src/hoc/rocketstyleAttrsHoc.ts`
16
+ * line 38: ".attrs() callbacks run once at mount"). If `title` were
17
+ * `string`-only and a consumer wanted to bind it to a live signal,
18
+ * they'd have to capture the initial value at template setup time
19
+ * — meaning the export metadata would be permanently stale relative
20
+ * to the live UI state.
21
+ *
22
+ * Storing the accessor in `_documentProps` and resolving it at
23
+ * extraction time means every `extractDocumentTree` call (one per
24
+ * export click) reads the live value. Plain string values still
25
+ * work as before — `extractDocumentTree` only calls the value if
26
+ * it's a function.
27
+ *
28
+ * @example Plain string
29
+ * ```tsx
30
+ * <DocDocument title="My Report" author="Alice">
31
+ * ...
32
+ * </DocDocument>
33
+ * ```
34
+ *
35
+ * @example Reactive accessor (recommended for templates that drive
36
+ * a live preview AND export the same tree)
37
+ * ```tsx
38
+ * function MyTemplate({ resume }: { resume: () => Resume }) {
39
+ * return (
40
+ * <DocDocument
41
+ * title={() => `${resume().name} — Resume`}
42
+ * author={() => resume().name}
43
+ * >
44
+ * ...
45
+ * </DocDocument>
46
+ * )
47
+ * }
48
+ * ```
49
+ */
4
50
  const DocDocument = rocketstyle()({ name: 'DocDocument', component: Element })
5
51
  .statics({ _documentType: 'document' as const })
6
52
  .attrs<{
7
- title?: string
8
- author?: string
9
- subject?: string
10
- tag: string
11
- _documentProps: Record<string, unknown>
53
+ title?: string | (() => string)
54
+ author?: string | (() => string)
55
+ subject?: string | (() => string)
12
56
  }>((props) => ({
13
57
  tag: 'div',
14
58
  _documentProps: {
15
- ...(props.title ? { title: props.title } : {}),
16
- ...(props.author ? { author: props.author } : {}),
17
- ...(props.subject ? { subject: props.subject } : {}),
59
+ // Pass accessor functions through unmodified extractDocumentTree
60
+ // resolves them at export time. Plain strings pass through too.
61
+ // Empty / nullish values are omitted entirely so they don't
62
+ // appear as `title: undefined` in the export metadata.
63
+ ...(props.title != null ? { title: props.title } : {}),
64
+ ...(props.author != null ? { author: props.author } : {}),
65
+ ...(props.subject != null ? { subject: props.subject } : {}),
18
66
  },
19
67
  }))
20
68
 
@@ -21,7 +21,7 @@ const DocHeading = rocketstyle({
21
21
  h6: { fontSize: 14, lineHeight: 1.5 },
22
22
  })
23
23
  .statics({ _documentType: 'heading' as const })
24
- .attrs<{ level?: string; tag: string; _documentProps: { level: number } }>((props) => {
24
+ .attrs<{ level?: string }>((props) => {
25
25
  const lvl = props.level ?? 'h1'
26
26
  const num = Number.parseInt(String(lvl).replace('h', ''), 10) || 1
27
27
  return {
@@ -9,8 +9,6 @@ const DocImage = rocketstyle()({ name: 'DocImage', component: Element })
9
9
  width?: number | string
10
10
  height?: number | string
11
11
  caption?: string
12
- tag: string
13
- _documentProps: Record<string, unknown>
14
12
  }>((props) => ({
15
13
  tag: 'img',
16
14
  _documentProps: {
@@ -7,7 +7,7 @@ const DocLink = rocketstyle()({ name: 'DocLink', component: Text })
7
7
  textDecoration: 'underline',
8
8
  })
9
9
  .statics({ _documentType: 'link' as const })
10
- .attrs<{ href?: string; tag: string; _documentProps: { href: string } }>((props) => ({
10
+ .attrs<{ href?: string }>((props) => ({
11
11
  tag: 'a',
12
12
  _documentProps: { href: props.href ?? '#' },
13
13
  }))
@@ -7,7 +7,7 @@ const DocList = rocketstyle()({ name: 'DocList', component: Element })
7
7
  paddingLeft: 20,
8
8
  })
9
9
  .statics({ _documentType: 'list' as const })
10
- .attrs<{ ordered?: boolean; tag: string; _documentProps: Record<string, unknown> }>((props) => ({
10
+ .attrs<{ ordered?: boolean }>((props) => ({
11
11
  tag: props.ordered ? 'ol' : 'ul',
12
12
  _documentProps: props.ordered ? { ordered: props.ordered } : {},
13
13
  }))
@@ -7,7 +7,7 @@ const DocListItem = rocketstyle()({ name: 'DocListItem', component: Text })
7
7
  lineHeight: 1.5,
8
8
  })
9
9
  .statics({ _documentType: 'list-item' as const })
10
- .attrs<{ tag: string; _documentProps: Record<string, unknown> }>((_props) => ({
10
+ .attrs(() => ({
11
11
  tag: 'li',
12
12
  _documentProps: {},
13
13
  }))
@@ -10,8 +10,6 @@ const DocPage = rocketstyle()({ name: 'DocPage', component: Element })
10
10
  .attrs<{
11
11
  size?: string
12
12
  orientation?: string
13
- tag: string
14
- _documentProps: Record<string, unknown>
15
13
  }>((props) => ({
16
14
  tag: 'div',
17
15
  _documentProps: {
@@ -3,7 +3,7 @@ import rocketstyle from '@pyreon/rocketstyle'
3
3
 
4
4
  const DocPageBreak = rocketstyle()({ name: 'DocPageBreak', component: Element })
5
5
  .statics({ _documentType: 'page-break' as const })
6
- .attrs<{ tag: string; _documentProps: Record<string, unknown> }>((_props) => ({
6
+ .attrs(() => ({
7
7
  tag: 'div',
8
8
  _documentProps: {},
9
9
  }))
@@ -9,11 +9,9 @@ const DocQuote = rocketstyle()({ name: 'DocQuote', component: Element })
9
9
  color: '#666666',
10
10
  })
11
11
  .statics({ _documentType: 'quote' as const })
12
- .attrs<{ borderColor?: string; tag: string; _documentProps: Record<string, unknown> }>(
13
- (props) => ({
14
- tag: 'blockquote',
15
- _documentProps: props.borderColor ? { borderColor: props.borderColor } : {},
16
- }),
17
- )
12
+ .attrs<{ borderColor?: string }>((props) => ({
13
+ tag: 'blockquote',
14
+ _documentProps: props.borderColor ? { borderColor: props.borderColor } : {},
15
+ }))
18
16
 
19
17
  export default DocQuote
@@ -1,13 +1,18 @@
1
1
  import { Element } from '@pyreon/elements'
2
2
  import rocketstyle from '@pyreon/rocketstyle'
3
3
 
4
+ /**
5
+ * Horizontal row of inline children. Per project conventions, layout
6
+ * props (`direction`, `gap`) live in `.attrs()`, not `.theme()`. The
7
+ * Element base accepts `direction: 'inline' | 'rows' | 'reverseInline'
8
+ * | 'reverseRows'` — `'row'` is not a valid value.
9
+ */
4
10
  const DocRow = rocketstyle()({ name: 'DocRow', component: Element })
5
- .theme({
6
- direction: 'row',
7
- })
8
11
  .statics({ _documentType: 'row' as const })
9
- .attrs<{ tag: string; _documentProps: Record<string, unknown> }>((_props) => ({
12
+ .attrs(() => ({
10
13
  tag: 'div',
14
+ direction: 'inline' as const,
15
+ gap: 8,
11
16
  _documentProps: {},
12
17
  }))
13
18
 
@@ -15,7 +15,7 @@ const DocSection = rocketstyle({
15
15
  row: { direction: 'row' },
16
16
  })
17
17
  .statics({ _documentType: 'section' as const })
18
- .attrs<{ direction?: string; tag: string; _documentProps: { direction: string } }>((props) => ({
18
+ .attrs<{ direction?: string }>((props) => ({
19
19
  tag: 'div',
20
20
  _documentProps: { direction: props.direction ?? 'column' },
21
21
  }))
@@ -3,7 +3,7 @@ import rocketstyle from '@pyreon/rocketstyle'
3
3
 
4
4
  const DocSpacer = rocketstyle()({ name: 'DocSpacer', component: Element })
5
5
  .statics({ _documentType: 'spacer' as const })
6
- .attrs<{ height?: number; tag: string; _documentProps: { height: number } }>((props) => ({
6
+ .attrs<{ height?: number }>((props) => ({
7
7
  tag: 'div',
8
8
  _documentProps: { height: props.height ?? 16 },
9
9
  }))
@@ -1,6 +1,24 @@
1
1
  import { Element } from '@pyreon/elements'
2
2
  import rocketstyle from '@pyreon/rocketstyle'
3
3
 
4
+ /**
5
+ * Tabular data primitive.
6
+ *
7
+ * The `columns`, `rows`, `headerStyle`, `striped`, `bordered`, and
8
+ * `caption` props are document-export metadata — they belong in
9
+ * `_documentProps` only and must NOT be forwarded to the rendered
10
+ * `<table>` element. The `filter` option on `.attrs()` strips them
11
+ * from the props that flow into the DOM.
12
+ *
13
+ * Why this matters: HTMLTableElement's `rows` property is a
14
+ * read-only `HTMLCollection` of `<tr>` elements. If `rows` were
15
+ * forwarded as a DOM attr, the runtime would call
16
+ * `el.rows = [...]` and crash with
17
+ * `TypeError: Cannot set property rows of [object Object] which has
18
+ * only a getter`. Same family for `columns` (`HTMLTableColElement`'s
19
+ * column collection on parent table). Filtering them at the
20
+ * rocketstyle layer keeps the DOM render path clean.
21
+ */
4
22
  const DocTable = rocketstyle({
5
23
  dimensions: {
6
24
  variants: 'variant',
@@ -19,18 +37,21 @@ const DocTable = rocketstyle({
19
37
  striped?: boolean
20
38
  bordered?: boolean
21
39
  caption?: string
22
- tag: string
23
- _documentProps: Record<string, unknown>
24
- }>((props) => ({
25
- tag: 'table',
26
- _documentProps: {
27
- columns: props.columns ?? [],
28
- rows: props.rows ?? [],
29
- ...(props.headerStyle ? { headerStyle: props.headerStyle } : {}),
30
- ...(props.striped ? { striped: props.striped } : {}),
31
- ...(props.bordered ? { bordered: props.bordered } : {}),
32
- ...(props.caption ? { caption: props.caption } : {}),
40
+ }>(
41
+ (props) => ({
42
+ tag: 'table',
43
+ _documentProps: {
44
+ columns: props.columns ?? [],
45
+ rows: props.rows ?? [],
46
+ ...(props.headerStyle ? { headerStyle: props.headerStyle } : {}),
47
+ ...(props.striped ? { striped: props.striped } : {}),
48
+ ...(props.bordered ? { bordered: props.bordered } : {}),
49
+ ...(props.caption ? { caption: props.caption } : {}),
50
+ },
51
+ }),
52
+ {
53
+ filter: ['columns', 'rows', 'headerStyle', 'striped', 'bordered', 'caption'],
33
54
  },
34
- }))
55
+ )
35
56
 
36
57
  export default DocTable
@@ -23,11 +23,7 @@ const DocText = rocketstyle({
23
23
  bold: { fontWeight: 'bold' },
24
24
  })
25
25
  .statics({ _documentType: 'text' as const })
26
- // .attrs(
27
- // (props: any) =>
28
- // ({
29
- // tag: "p",
30
- .attrs<{ tag: string; _documentProps: Record<string, unknown> }>((_props) => ({
26
+ .attrs(() => ({
31
27
  tag: 'p',
32
28
  _documentProps: {},
33
29
  }))
@@ -13,6 +13,45 @@ export interface DocumentExport {
13
13
  getDocNode: () => DocNode
14
14
  }
15
15
 
16
+ /**
17
+ * One-step helper: extract a DocNode tree from a template function.
18
+ *
19
+ * Equivalent to `createDocumentExport(templateFn).getDocNode()` but
20
+ * without the wrapper-object indirection. Use this when you just
21
+ * need the tree to feed into `@pyreon/document`'s `render()` or
22
+ * `download()` — which is the only thing the wrapper object was
23
+ * ever used for in practice.
24
+ *
25
+ * ```ts
26
+ * import { extractDocNode } from '@pyreon/document-primitives'
27
+ * import { download } from '@pyreon/document'
28
+ *
29
+ * function ResumeTemplate({ resume }: { resume: () => Resume }) {
30
+ * return (
31
+ * <DocDocument title={() => `${resume().name} — Resume`}>
32
+ * <DocPage>...</DocPage>
33
+ * </DocDocument>
34
+ * )
35
+ * }
36
+ *
37
+ * // Export click handler:
38
+ * const tree = extractDocNode(() => <ResumeTemplate resume={store.resume} />)
39
+ * await download(tree, 'resume.pdf')
40
+ * ```
41
+ *
42
+ * The two-step `createDocumentExport` form is still exported for
43
+ * backward compatibility and for callers that want to pass the
44
+ * helper object around (e.g. to wrapper components that take a
45
+ * `DocumentExport` instance). New code should prefer this one-step
46
+ * form unless you specifically need the helper object.
47
+ */
48
+ export function extractDocNode(
49
+ templateFn: () => unknown,
50
+ options: DocumentExportOptions = {},
51
+ ): DocNode {
52
+ return extractDocumentTree(templateFn(), options)
53
+ }
54
+
16
55
  /**
17
56
  * Create a document export helper from a template function.
18
57
  *
@@ -31,17 +70,15 @@ export interface DocumentExport {
31
70
  * // Pass to @pyreon/document's render() for any format
32
71
  * ```
33
72
  *
34
- * When @pyreon/document is published, this will also expose
35
- * convenience methods like toPdf(), toDocx(), download(), etc.
73
+ * **Most consumers should use `extractDocNode(templateFn)` instead**
74
+ * it's the same operation in one call without the wrapper
75
+ * object. `createDocumentExport` is kept for callers that want to
76
+ * pass the helper object around.
36
77
  */
37
78
  export function createDocumentExport(
38
79
  templateFn: () => unknown,
39
80
  options: DocumentExportOptions = {},
40
81
  ): DocumentExport {
41
- const getDocNode = (): DocNode => {
42
- const vnode = templateFn()
43
- return extractDocumentTree(vnode, options)
44
- }
45
-
82
+ const getDocNode = (): DocNode => extractDocNode(templateFn, options)
46
83
  return { getDocNode }
47
84
  }