@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.
- package/lib/index.d.ts +115 -64
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +128 -14
- package/lib/index.js.map +1 -1
- package/package.json +16 -14
- package/src/DocumentPreview.ts +0 -2
- package/src/__tests__/reactivity.test.ts +415 -0
- package/src/__tests__/useDocumentExport.test.ts +224 -1
- package/src/index.ts +1 -1
- package/src/primitives/DocButton.ts +1 -1
- package/src/primitives/DocCode.ts +1 -1
- package/src/primitives/DocColumn.ts +4 -6
- package/src/primitives/DocDivider.ts +0 -2
- package/src/primitives/DocDocument.ts +56 -8
- package/src/primitives/DocHeading.ts +1 -1
- package/src/primitives/DocImage.ts +0 -2
- package/src/primitives/DocLink.ts +1 -1
- package/src/primitives/DocList.ts +1 -1
- package/src/primitives/DocListItem.ts +1 -1
- package/src/primitives/DocPage.ts +0 -2
- package/src/primitives/DocPageBreak.ts +1 -1
- package/src/primitives/DocQuote.ts +4 -6
- package/src/primitives/DocRow.ts +9 -4
- package/src/primitives/DocSection.ts +1 -1
- package/src/primitives/DocSpacer.ts +1 -1
- package/src/primitives/DocTable.ts +33 -12
- package/src/primitives/DocText.ts +1 -5
- package/src/useDocumentExport.ts +44 -7
|
@@ -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
|
|
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
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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 {
|
|
@@ -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
|
|
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
|
|
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
|
|
10
|
+
.attrs(() => ({
|
|
11
11
|
tag: 'li',
|
|
12
12
|
_documentProps: {},
|
|
13
13
|
}))
|
|
@@ -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
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
package/src/primitives/DocRow.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
}))
|
package/src/useDocumentExport.ts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
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
|
}
|