@uniweb/press 0.3.0 → 0.4.1
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/README.md +87 -90
- package/package.json +2 -4
- package/src/adapters/dispatch.js +55 -10
- package/src/adapters/docx.js +40 -13
- package/src/adapters/epub.js +624 -0
- package/src/adapters/pagedjs.js +295 -0
- package/src/adapters/typst.js +16 -1
- package/src/assets/fetch.js +155 -0
- package/src/compileRegistrations.js +289 -1
- package/src/index.js +1 -0
- package/src/ir/attributes.js +18 -0
- package/src/ir/compile.js +93 -25
- package/src/useDocumentCompile.js +31 -1
- package/src/vite-plugin-typst.js +7 -3
|
@@ -26,7 +26,7 @@ import { createElement } from 'react'
|
|
|
26
26
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
27
27
|
import DocumentProvider, { createStore } from './DocumentProvider.jsx'
|
|
28
28
|
import { compileOutputs } from './ir/compile.js'
|
|
29
|
-
import { runCompile } from './adapters/dispatch.js'
|
|
29
|
+
import { runCompile, getAdapterDescriptor } from './adapters/dispatch.js'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* @param {any} elements - React element or array of elements. Whatever you
|
|
@@ -67,9 +67,48 @@ export function compileRegistrations(elements, format, options = {}) {
|
|
|
67
67
|
),
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
+
warnIfEmptyRegistrations(store, format)
|
|
70
71
|
return compileOutputs(store, format)
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Emit a one-time console warning when a compile finds no registrations
|
|
76
|
+
* under the adapter's `consumes` key. Catches the commonest cause of an
|
|
77
|
+
* "empty body" compile: foundation section components registered for a
|
|
78
|
+
* different input shape than the one the selected format reads from.
|
|
79
|
+
*
|
|
80
|
+
* The warning names the input-shape key (what the foundation should
|
|
81
|
+
* register under) rather than the output format, because that's where the
|
|
82
|
+
* fix lives. Silent when at least one output is present. Guarded with a
|
|
83
|
+
* Set so the same missed key only warns once per page.
|
|
84
|
+
*
|
|
85
|
+
* Unknown formats skip the warning — runCompile's "Unsupported document
|
|
86
|
+
* format" error is the right signal, not a misleading "0 sections" note.
|
|
87
|
+
*/
|
|
88
|
+
const _emptyKeyWarned = new Set()
|
|
89
|
+
function warnIfEmptyRegistrations(store, format) {
|
|
90
|
+
const desc = getAdapterDescriptor(format)
|
|
91
|
+
if (!desc) return
|
|
92
|
+
const key = desc.consumes
|
|
93
|
+
if (_emptyKeyWarned.has(key)) return
|
|
94
|
+
const outputs = (store.getOutputs && store.getOutputs(key)) || []
|
|
95
|
+
if (outputs.length > 0) return
|
|
96
|
+
_emptyKeyWarned.add(key)
|
|
97
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
98
|
+
const note =
|
|
99
|
+
key === format
|
|
100
|
+
? `compileSubtree('${format}') found 0 registered sections. ` +
|
|
101
|
+
`Did any section component call useDocumentOutput(block, '${format}', ...)?`
|
|
102
|
+
: `compileSubtree('${format}') found 0 sections registered under input key '${key}'. ` +
|
|
103
|
+
`Sections should call useDocumentOutput(block, '${key}', ...) ` +
|
|
104
|
+
`(the output format '${format}' reads fragments registered under '${key}').`
|
|
105
|
+
console.warn(
|
|
106
|
+
`@uniweb/press: ${note} ` +
|
|
107
|
+
`Sections registered for a different input key do not cross-register.`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
73
112
|
/**
|
|
74
113
|
* Convenience: aggregate + dispatch the adapter in one call. Mirrors
|
|
75
114
|
* `useDocumentCompile().compile(format, options)` but for off-screen
|
|
@@ -103,3 +142,252 @@ export async function compileSubtree(elements, format, options = {}) {
|
|
|
103
142
|
const compiled = compileRegistrations(elements, format, { basePath })
|
|
104
143
|
return runCompile(format, compiled, adapterOptions)
|
|
105
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Full-document compile via a foundation's declared outputs.
|
|
148
|
+
*
|
|
149
|
+
* Where `compileSubtree` is the low-level "I already decided the tree,
|
|
150
|
+
* give me bytes" primitive, `compileDocument` is the high-level "this
|
|
151
|
+
* website, compiled as a <format> document through this foundation"
|
|
152
|
+
* convenience. It handles three concerns compileSubtree doesn't:
|
|
153
|
+
*
|
|
154
|
+
* 1. Block selection — defaults to every page's bodyBlocks; a host
|
|
155
|
+
* can narrow the scope with `rootPath` (only pages under that
|
|
156
|
+
* route prefix).
|
|
157
|
+
* 2. Foundation-supplied adapter options — looks up
|
|
158
|
+
* `foundation.outputs[format].getOptions(website, ...)` and
|
|
159
|
+
* merges its return into the options passed to compileSubtree.
|
|
160
|
+
* That's how a book foundation supplies preamble, template, meta,
|
|
161
|
+
* and asset bytes without the host knowing anything about them.
|
|
162
|
+
* 3. Format aliasing — an output can declare `via: '<press-format>'`
|
|
163
|
+
* to route compile through a different Press adapter. A foundation
|
|
164
|
+
* can expose `outputs.pdf = { via: 'typst', ... }` so hosts ask
|
|
165
|
+
* for 'pdf' while Press produces a typst source bundle; the host
|
|
166
|
+
* (e.g., unipress) finishes the compile with the typst binary.
|
|
167
|
+
*
|
|
168
|
+
* Two call shapes:
|
|
169
|
+
*
|
|
170
|
+
* - Tree mode: `compileDocument(<tree>, { format, ... })`.
|
|
171
|
+
* The first argument is a React element. Treated as a direct
|
|
172
|
+
* passthrough to compileSubtree — for hosts that already decided
|
|
173
|
+
* which blocks to include and assembled the tree themselves. The
|
|
174
|
+
* outputs lookup is skipped in this mode (no foundation needed).
|
|
175
|
+
*
|
|
176
|
+
* - Website mode: `compileDocument(website, { format, foundation, rootPath, ... })`.
|
|
177
|
+
* The first argument is a Website instance (or any object with a
|
|
178
|
+
* `pages` array — we duck-type). Gathers blocks, builds the tree
|
|
179
|
+
* via `globalThis.uniweb.childBlockRenderer({ blocks })`, looks up
|
|
180
|
+
* `outputs[format]` on the foundation, calls its `getOptions`, and
|
|
181
|
+
* routes through compileSubtree with the resulting adapterOptions
|
|
182
|
+
* and basePath.
|
|
183
|
+
*
|
|
184
|
+
* The foundation argument accepts either the raw source shape (an
|
|
185
|
+
* object with `.outputs`) or the built-module shape (`.default.capabilities.outputs`).
|
|
186
|
+
* Browser Download buttons typically pass the source shape via a local
|
|
187
|
+
* relative import; headless hosts like unipress pass the built module
|
|
188
|
+
* they imported via URL.
|
|
189
|
+
*
|
|
190
|
+
* @param {import('react').ReactElement | Object} treeOrWebsite
|
|
191
|
+
* @param {Object} [options]
|
|
192
|
+
* @param {string} options.format - Output format name as declared on
|
|
193
|
+
* the foundation (e.g. 'typst', 'pdf', 'epub', 'pagedjs').
|
|
194
|
+
* @param {Object} [options.foundation] - Foundation module, source or
|
|
195
|
+
* built. Required for website mode; ignored in tree mode.
|
|
196
|
+
* @param {string} [options.rootPath] - Limit block gathering to pages
|
|
197
|
+
* whose route is or lives under this prefix. Only meaningful in
|
|
198
|
+
* website mode.
|
|
199
|
+
* @param {Object} [options.adapterOptions] - Caller-supplied adapter
|
|
200
|
+
* options. Merged on top of whatever the foundation's getOptions
|
|
201
|
+
* returns, so hosts can override.
|
|
202
|
+
* @param {string} [options.basePath] - DocumentProvider basePath. In
|
|
203
|
+
* website mode, defaults to `website.basePath` if unset.
|
|
204
|
+
* @param {(src: string) => Promise<Uint8Array>} [options.loadAsset] -
|
|
205
|
+
* Host-supplied byte loader for config-level asset paths (cover
|
|
206
|
+
* images, banners, logos referenced from document.yml/site.yml).
|
|
207
|
+
* Called by foundation getOptions implementations to fetch bytes
|
|
208
|
+
* without environment branching. Hosts running in Node (unipress)
|
|
209
|
+
* should pass an fs-based implementation; the default uses fetch
|
|
210
|
+
* (browser only).
|
|
211
|
+
* @returns {Promise<Blob>}
|
|
212
|
+
*/
|
|
213
|
+
export async function compileDocument(treeOrWebsite, options = {}) {
|
|
214
|
+
const isElement =
|
|
215
|
+
treeOrWebsite !== null &&
|
|
216
|
+
typeof treeOrWebsite === 'object' &&
|
|
217
|
+
// React elements have a $$typeof symbol; duck-typing avoids needing
|
|
218
|
+
// `import { isValidElement } from 'react'` (minor, but keeps this
|
|
219
|
+
// file free of React imports it doesn't otherwise need).
|
|
220
|
+
Boolean(treeOrWebsite.$$typeof)
|
|
221
|
+
|
|
222
|
+
if (isElement) {
|
|
223
|
+
const { format, ...rest } = options
|
|
224
|
+
if (!format) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"compileDocument: 'format' is required (tree mode).",
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
return compileSubtree(treeOrWebsite, format, rest)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const website = treeOrWebsite
|
|
233
|
+
const {
|
|
234
|
+
format,
|
|
235
|
+
foundation,
|
|
236
|
+
rootPath,
|
|
237
|
+
adapterOptions: overrideAdapterOptions = {},
|
|
238
|
+
basePath: basePathOverride,
|
|
239
|
+
loadAsset: hostLoadAsset,
|
|
240
|
+
...rest
|
|
241
|
+
} = options
|
|
242
|
+
|
|
243
|
+
// Host-supplied loadAsset takes precedence; otherwise default to a
|
|
244
|
+
// browser-fetch implementation that uses website.assets[src].url when
|
|
245
|
+
// available and falls back to fetching the original src. Hosts running
|
|
246
|
+
// in Node (unipress) supply their own fs-based loader.
|
|
247
|
+
const loadAsset = hostLoadAsset ?? createDefaultLoadAsset(website)
|
|
248
|
+
|
|
249
|
+
if (!format) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
"compileDocument: 'format' is required (website mode).",
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
if (!website || !Array.isArray(website.pages)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
'compileDocument: first argument must be either a React element ' +
|
|
257
|
+
'(tree mode) or a Website (website mode: expected object with ' +
|
|
258
|
+
'a pages array).',
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const outputs = resolveFoundationOutputs(foundation)
|
|
263
|
+
const outputSpec = outputs?.[format]
|
|
264
|
+
if (!outputSpec) {
|
|
265
|
+
const declared = outputs ? Object.keys(outputs).join(', ') || '(none)' : '(no outputs declaration)'
|
|
266
|
+
throw new Error(
|
|
267
|
+
`compileDocument: foundation has no outputs.${format} declaration. ` +
|
|
268
|
+
`Declared outputs: ${declared}. ` +
|
|
269
|
+
"Add outputs[format] = { getOptions, extension?, via? } to the foundation's default export.",
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const pressFormat = outputSpec.via ?? format
|
|
274
|
+
const foundationAdapterOptions = outputSpec.getOptions
|
|
275
|
+
? await outputSpec.getOptions(website, { format, rootPath, loadAsset, ...rest })
|
|
276
|
+
: {}
|
|
277
|
+
|
|
278
|
+
const mergedAdapterOptions = {
|
|
279
|
+
...foundationAdapterOptions.adapterOptions,
|
|
280
|
+
...overrideAdapterOptions,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const blocks = gatherBlocks(website, rootPath)
|
|
284
|
+
const renderer = globalThis.uniweb?.childBlockRenderer
|
|
285
|
+
if (typeof renderer !== 'function') {
|
|
286
|
+
throw new Error(
|
|
287
|
+
'compileDocument: globalThis.uniweb.childBlockRenderer is not ' +
|
|
288
|
+
'installed. Either call initPrerender (headless) or mount a ' +
|
|
289
|
+
'Uniweb runtime (browser) before compileDocument, or pass a ' +
|
|
290
|
+
'pre-built tree (tree mode).',
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
const tree = renderer({ blocks })
|
|
294
|
+
|
|
295
|
+
return compileSubtree(tree, pressFormat, {
|
|
296
|
+
basePath: basePathOverride ?? website?.basePath,
|
|
297
|
+
...foundationAdapterOptions,
|
|
298
|
+
adapterOptions: mergedAdapterOptions,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveFoundationOutputs(foundation) {
|
|
303
|
+
if (!foundation) return null
|
|
304
|
+
if (foundation.outputs) return foundation.outputs
|
|
305
|
+
if (foundation.default?.capabilities?.outputs)
|
|
306
|
+
return foundation.default.capabilities.outputs
|
|
307
|
+
if (foundation.default?.outputs) return foundation.default.outputs
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Default loadAsset for the browser. Resolves a config-time asset
|
|
313
|
+
* reference (the original `src` string as it appears in document.yml /
|
|
314
|
+
* site.yml — e.g. `assets/front.png`) into a `Uint8Array` of bytes.
|
|
315
|
+
*
|
|
316
|
+
* Hosts running in Node (unipress) should pass their own loadAsset via
|
|
317
|
+
* compileDocument({ loadAsset }) — this default uses `fetch`, which in a
|
|
318
|
+
* Node-side compile has no server to reach.
|
|
319
|
+
*
|
|
320
|
+
* Lookup order:
|
|
321
|
+
* 1. data: URL → decode in place.
|
|
322
|
+
* 2. Asset manifest hit (`website.assets[src]`) — fetch the resolved
|
|
323
|
+
* URL form. The framework's content-collector populates this for
|
|
324
|
+
* config-level asset paths.
|
|
325
|
+
* 3. Fall back to fetching the original src directly. Works when src
|
|
326
|
+
* is already a URL or a server-relative path.
|
|
327
|
+
*/
|
|
328
|
+
function createDefaultLoadAsset(website) {
|
|
329
|
+
return async function loadAsset(src) {
|
|
330
|
+
if (!src) return null
|
|
331
|
+
if (typeof src !== 'string') return null
|
|
332
|
+
|
|
333
|
+
// data: URL — decode in place
|
|
334
|
+
if (src.startsWith('data:')) {
|
|
335
|
+
const comma = src.indexOf(',')
|
|
336
|
+
if (comma === -1) return null
|
|
337
|
+
const meta = src.slice(5, comma)
|
|
338
|
+
const data = src.slice(comma + 1)
|
|
339
|
+
if (meta.includes(';base64')) {
|
|
340
|
+
const bin = atob(data)
|
|
341
|
+
const buf = new Uint8Array(bin.length)
|
|
342
|
+
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i)
|
|
343
|
+
return buf
|
|
344
|
+
}
|
|
345
|
+
return new TextEncoder().encode(decodeURIComponent(data))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (typeof fetch !== 'function') {
|
|
349
|
+
throw new Error(
|
|
350
|
+
"loadAsset: cannot load '" +
|
|
351
|
+
src +
|
|
352
|
+
"' — no fetch available in this environment. " +
|
|
353
|
+
'Pass a host-supplied loadAsset via compileDocument({ loadAsset }).',
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Construct a URL from manifest-resolved info or the original src.
|
|
358
|
+
// External URLs and absolute paths pass through; relative paths get
|
|
359
|
+
// a leading slash + basePath prefix so they hit the dev/prod server.
|
|
360
|
+
let url = src
|
|
361
|
+
if (!/^https?:\/\//i.test(url) && !url.startsWith('data:')) {
|
|
362
|
+
const prefixed =
|
|
363
|
+
(website?.basePath || '') +
|
|
364
|
+
(url.startsWith('/') ? url : '/' + url)
|
|
365
|
+
url =
|
|
366
|
+
typeof window !== 'undefined' && window.location?.origin
|
|
367
|
+
? window.location.origin + prefixed
|
|
368
|
+
: prefixed
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const res = await fetch(url)
|
|
372
|
+
if (!res.ok) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
'loadAsset: fetch failed for ' + url + ' (' + res.status + ')',
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function gatherBlocks(website, rootPath) {
|
|
382
|
+
const pages = website.pages || []
|
|
383
|
+
const scoped =
|
|
384
|
+
rootPath && typeof rootPath === 'string'
|
|
385
|
+
? pages.filter(
|
|
386
|
+
(p) =>
|
|
387
|
+
p.route === rootPath ||
|
|
388
|
+
(typeof p.route === 'string' &&
|
|
389
|
+
p.route.startsWith(rootPath + '/')),
|
|
390
|
+
)
|
|
391
|
+
: pages
|
|
392
|
+
return scoped.flatMap((page) => page.bodyBlocks || [])
|
|
393
|
+
}
|
package/src/index.js
CHANGED
package/src/ir/attributes.js
CHANGED
|
@@ -30,6 +30,22 @@ const asEmptyObject = () => ({})
|
|
|
30
30
|
/** Transform used by boolean presence attributes like data-page-break-before. */
|
|
31
31
|
const asTrue = () => true
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Coerce a numeric attribute value to an integer.
|
|
35
|
+
*
|
|
36
|
+
* HTML attributes are always strings; numeric docx-library options that
|
|
37
|
+
* flow through IR (e.g. EMU offsets on floating anchors) must be numbers,
|
|
38
|
+
* or the library's type guards silently fall back to a different code
|
|
39
|
+
* path — floating positioning, for example, treats a string `offset` as
|
|
40
|
+
* missing and reverts to `align` instead. Unparseable values pass through
|
|
41
|
+
* unchanged so non-numeric strings still reach the adapter's own
|
|
42
|
+
* conversion layer.
|
|
43
|
+
*/
|
|
44
|
+
const toInt = (v) => {
|
|
45
|
+
const n = parseInt(v, 10)
|
|
46
|
+
return Number.isFinite(n) ? n : v
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
/**
|
|
34
50
|
* Explicit attribute → IR path mapping. Entries are listed in the same
|
|
35
51
|
* order as the legacy switch for ease of cross-reference.
|
|
@@ -88,6 +104,7 @@ export const attributeMap = {
|
|
|
88
104
|
},
|
|
89
105
|
'data-floating-horizontalposition-offset': {
|
|
90
106
|
path: ['floating', 'horizontalPosition', 'offset'],
|
|
107
|
+
transform: toInt,
|
|
91
108
|
},
|
|
92
109
|
'data-floating-verticalposition-relative': {
|
|
93
110
|
path: ['floating', 'verticalPosition', 'relative'],
|
|
@@ -97,6 +114,7 @@ export const attributeMap = {
|
|
|
97
114
|
},
|
|
98
115
|
'data-floating-verticalposition-offset': {
|
|
99
116
|
path: ['floating', 'verticalPosition', 'offset'],
|
|
117
|
+
transform: toInt,
|
|
100
118
|
},
|
|
101
119
|
|
|
102
120
|
// Page breaks — maps to DocxParagraph({ pageBreakBefore: true })
|
package/src/ir/compile.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Bridge between the React registration layer (DocumentProvider +
|
|
3
|
-
* useDocumentOutput) and the format adapters (docx, xlsx,
|
|
3
|
+
* useDocumentOutput) and the format adapters (docx, xlsx, typst, pagedjs).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* { header, footer, sections }.
|
|
5
|
+
* Dispatch is table-driven via the adapter descriptor in
|
|
6
|
+
* src/adapters/dispatch.js — each adapter declares:
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* consumes — the store key whose fragments feed this adapter.
|
|
9
|
+
* Foundations register under this key. Multiple adapters can
|
|
10
|
+
* share the same key (e.g. pagedjs and future EPUB both read
|
|
11
|
+
* 'html'), so a foundation writes one registration and gets
|
|
12
|
+
* both formats.
|
|
13
|
+
* ir — whether fragments are walked to IR (true: docx/typst) or
|
|
14
|
+
* passed through as-is (false: xlsx data objects, pagedjs
|
|
15
|
+
* HTML strings).
|
|
10
16
|
*
|
|
11
17
|
* useDocumentCompile is the typical caller; custom adapter authors can
|
|
12
18
|
* invoke compileOutputs() directly via the @uniweb/press/ir subpath.
|
|
@@ -14,31 +20,51 @@
|
|
|
14
20
|
|
|
15
21
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
16
22
|
import { htmlToIR } from './parser.js'
|
|
23
|
+
import { getAdapterDescriptor } from '../adapters/dispatch.js'
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* Compile all registered outputs for a given format into the shape the
|
|
20
27
|
* format adapter's compile function expects.
|
|
21
28
|
*
|
|
22
29
|
* @param {Object} store - The DocumentProvider store (from DocumentContext).
|
|
23
|
-
* @param {string} format - Format identifier ('docx', 'xlsx',
|
|
24
|
-
* @returns {Object} Adapter input shape:
|
|
25
|
-
*
|
|
26
|
-
*
|
|
30
|
+
* @param {string} format - Format identifier ('docx', 'xlsx', 'typst', 'pagedjs', …).
|
|
31
|
+
* @returns {Object} Adapter input shape. Two shapes today:
|
|
32
|
+
* - IR-based (docx/typst): { sections: IR[][], header, footer, metadata,
|
|
33
|
+
* headerFirstPageOnly, footerFirstPageOnly }
|
|
34
|
+
* - Passthrough (xlsx): { sections: Object[] }
|
|
35
|
+
* - Passthrough (pagedjs): { sections: string[], metadata }
|
|
27
36
|
*/
|
|
28
37
|
export function compileOutputs(store, format) {
|
|
29
|
-
const
|
|
38
|
+
const desc = getAdapterDescriptor(format)
|
|
39
|
+
// Unknown format: surface the empty shape and let runCompile throw the
|
|
40
|
+
// descriptive error. Callers that bypass runCompile (IR-subpath users)
|
|
41
|
+
// get an empty IR-shape output — safe default.
|
|
42
|
+
if (!desc) {
|
|
43
|
+
return { sections: [], header: null, footer: null, metadata: null }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const outputs = store.getOutputs(desc.consumes) || []
|
|
30
47
|
|
|
31
|
-
if (
|
|
32
|
-
return
|
|
48
|
+
if (desc.ir) {
|
|
49
|
+
return compileToIR(outputs, store)
|
|
33
50
|
}
|
|
34
51
|
|
|
35
|
-
//
|
|
36
|
-
|
|
52
|
+
// Passthrough branch — differs only in whether body fragments are
|
|
53
|
+
// rendered through React first.
|
|
54
|
+
//
|
|
55
|
+
// - 'html' input shape: fragments are JSX; render each one to a static
|
|
56
|
+
// HTML string (preserves semantic HTML + data-* attributes the
|
|
57
|
+
// target adapter may pick up, e.g. CSS Paged Media selectors).
|
|
58
|
+
// - Any other non-IR shape (xlsx, future data exports): fragments are
|
|
59
|
+
// plain values; keep them as-is.
|
|
60
|
+
if (desc.consumes === 'html') {
|
|
61
|
+
return compileHtmlPassthrough(outputs, store)
|
|
62
|
+
}
|
|
63
|
+
return compileDataPassthrough(outputs)
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
/**
|
|
40
|
-
*
|
|
41
|
-
* JSX fragments → renderToStaticMarkup → htmlToIR → grouped by role.
|
|
67
|
+
* IR branch — JSX → renderToStaticMarkup → htmlToIR → grouped by role.
|
|
42
68
|
*
|
|
43
69
|
* Four roles:
|
|
44
70
|
* - body (default): IR appended to `sections`.
|
|
@@ -47,6 +73,12 @@ export function compileOutputs(store, format) {
|
|
|
47
73
|
* consume this for document-level properties (title, author, isbn, …).
|
|
48
74
|
* Fragments for the metadata role are plain data objects, not JSX.
|
|
49
75
|
*
|
|
76
|
+
* Header and footer registrations accept an `applyTo` option. When set
|
|
77
|
+
* to `'first'`, the output carries `headerFirstPageOnly` /
|
|
78
|
+
* `footerFirstPageOnly` booleans so format adapters can emit a
|
|
79
|
+
* different-first-page section layout. Other `applyTo` values (`'all'`
|
|
80
|
+
* default, `'odd'`, `'even'`) produce no flag.
|
|
81
|
+
*
|
|
50
82
|
* Registered fragments are React element trees captured during the live
|
|
51
83
|
* page render. renderToStaticMarkup renders them afresh here, outside
|
|
52
84
|
* the React tree the page rendered in — so any context the builders
|
|
@@ -57,10 +89,12 @@ export function compileOutputs(store, format) {
|
|
|
57
89
|
* builders fall back to context defaults and emit, for example,
|
|
58
90
|
* un-prefixed URLs that the docx adapter's fetch() then 404s on.
|
|
59
91
|
*/
|
|
60
|
-
function
|
|
92
|
+
function compileToIR(outputs, store) {
|
|
61
93
|
let metadata = null
|
|
62
94
|
let header = null
|
|
63
95
|
let footer = null
|
|
96
|
+
let headerFirstPageOnly = false
|
|
97
|
+
let footerFirstPageOnly = false
|
|
64
98
|
const sections = []
|
|
65
99
|
|
|
66
100
|
const wrap = store.wrapWithProviders || ((x) => x)
|
|
@@ -81,11 +115,12 @@ function compileHtmlBased(outputs, store) {
|
|
|
81
115
|
|
|
82
116
|
switch (role) {
|
|
83
117
|
case 'header':
|
|
84
|
-
// Last header wins (or merge — for now, last wins).
|
|
85
118
|
header = ir
|
|
119
|
+
if (options.applyTo === 'first') headerFirstPageOnly = true
|
|
86
120
|
break
|
|
87
121
|
case 'footer':
|
|
88
122
|
footer = ir
|
|
123
|
+
if (options.applyTo === 'first') footerFirstPageOnly = true
|
|
89
124
|
break
|
|
90
125
|
default:
|
|
91
126
|
sections.push(ir)
|
|
@@ -93,17 +128,50 @@ function compileHtmlBased(outputs, store) {
|
|
|
93
128
|
}
|
|
94
129
|
}
|
|
95
130
|
|
|
96
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
sections,
|
|
133
|
+
header,
|
|
134
|
+
footer,
|
|
135
|
+
metadata,
|
|
136
|
+
headerFirstPageOnly,
|
|
137
|
+
footerFirstPageOnly,
|
|
138
|
+
}
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
142
|
+
* HTML passthrough branch — JSX → renderToStaticMarkup → HTML string.
|
|
143
|
+
* Consumed by Paged.js and any future HTML-string adapter (EPUB, etc.).
|
|
144
|
+
*
|
|
145
|
+
* Only `body` and `metadata` roles are consumed. Header/footer for
|
|
146
|
+
* HTML-string adapters are typically handled by the adapter's own
|
|
147
|
+
* wrapping mechanism (for Paged.js: CSS `@page` margin boxes declared in
|
|
148
|
+
* the stylesheet, e.g. `@top-right { content: string(chapter); }`), not
|
|
149
|
+
* as registered fragments.
|
|
102
150
|
*/
|
|
103
|
-
function
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
151
|
+
function compileHtmlPassthrough(outputs, store) {
|
|
152
|
+
const wrap = store.wrapWithProviders || ((x) => x)
|
|
153
|
+
let metadata = null
|
|
154
|
+
const sections = []
|
|
155
|
+
|
|
156
|
+
for (const { fragment, options } of outputs) {
|
|
157
|
+
const role = options.role || 'body'
|
|
158
|
+
if (role === 'metadata') {
|
|
159
|
+
metadata = fragment
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (role !== 'body') continue
|
|
163
|
+
const html = renderToStaticMarkup(wrap(fragment))
|
|
164
|
+
sections.push(html)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { sections, metadata }
|
|
168
|
+
}
|
|
107
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Plain-data passthrough (xlsx, future JSON exports). Fragments are not
|
|
172
|
+
* JSX — they're domain objects the adapter defines. Collect in order.
|
|
173
|
+
*/
|
|
174
|
+
function compileDataPassthrough(outputs) {
|
|
175
|
+
const sections = outputs.map(({ fragment }) => fragment).filter(Boolean)
|
|
108
176
|
return { sections }
|
|
109
177
|
}
|
|
@@ -20,7 +20,36 @@
|
|
|
20
20
|
import { useCallback, useContext, useState } from 'react'
|
|
21
21
|
import { DocumentContext } from './DocumentContext.js'
|
|
22
22
|
import { compileOutputs } from './ir/compile.js'
|
|
23
|
-
import { runCompile } from './adapters/dispatch.js'
|
|
23
|
+
import { runCompile, getAdapterDescriptor } from './adapters/dispatch.js'
|
|
24
|
+
|
|
25
|
+
// One-time per-input-key warning guard, shared across every
|
|
26
|
+
// useDocumentCompile call in a page. Same behaviour as the off-screen
|
|
27
|
+
// compileSubtree pathway — keyed on the adapter's `consumes` input shape,
|
|
28
|
+
// not the output format, so a missed 'html' registration warns once total
|
|
29
|
+
// even across multiple output formats (pagedjs + future epub) that share it.
|
|
30
|
+
const _emptyKeyWarned = new Set()
|
|
31
|
+
function warnIfEmptyRegistrations(store, format) {
|
|
32
|
+
const desc = getAdapterDescriptor(format)
|
|
33
|
+
if (!desc) return
|
|
34
|
+
const key = desc.consumes
|
|
35
|
+
if (_emptyKeyWarned.has(key)) return
|
|
36
|
+
const outputs = (store.getOutputs && store.getOutputs(key)) || []
|
|
37
|
+
if (outputs.length > 0) return
|
|
38
|
+
_emptyKeyWarned.add(key)
|
|
39
|
+
if (typeof console !== 'undefined' && console.warn) {
|
|
40
|
+
const note =
|
|
41
|
+
key === format
|
|
42
|
+
? `compile('${format}') found 0 registered sections. ` +
|
|
43
|
+
`Did any section component call useDocumentOutput(block, '${format}', ...)?`
|
|
44
|
+
: `compile('${format}') found 0 sections registered under input key '${key}'. ` +
|
|
45
|
+
`Sections should call useDocumentOutput(block, '${key}', ...) ` +
|
|
46
|
+
`(the output format '${format}' reads fragments registered under '${key}').`
|
|
47
|
+
console.warn(
|
|
48
|
+
`@uniweb/press: ${note} ` +
|
|
49
|
+
`Sections registered for a different input key do not cross-register.`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
24
53
|
|
|
25
54
|
/**
|
|
26
55
|
* @returns {{ compile: (format: string, documentOptions?: Object) => Promise<Blob>, isCompiling: boolean }}
|
|
@@ -38,6 +67,7 @@ export function useDocumentCompile() {
|
|
|
38
67
|
}
|
|
39
68
|
setIsCompiling(true)
|
|
40
69
|
try {
|
|
70
|
+
warnIfEmptyRegistrations(store, format)
|
|
41
71
|
const compiled = compileOutputs(store, format)
|
|
42
72
|
return await runCompile(format, compiled, documentOptions)
|
|
43
73
|
} finally {
|
package/src/vite-plugin-typst.js
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { spawn } from 'node:child_process'
|
|
29
|
-
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
|
|
29
|
+
import { mkdtemp, writeFile, readFile, rm, mkdir } from 'node:fs/promises'
|
|
30
30
|
import { tmpdir } from 'node:os'
|
|
31
|
-
import { join } from 'node:path'
|
|
31
|
+
import { join, dirname } from 'node:path'
|
|
32
32
|
import { Readable } from 'node:stream'
|
|
33
33
|
|
|
34
34
|
const DEFAULT_PATH = '/__press/typst/compile'
|
|
@@ -142,7 +142,11 @@ async function compileBundleWithTypst(files, { binary, extraArgs }) {
|
|
|
142
142
|
typeof contents === 'string'
|
|
143
143
|
? Buffer.from(contents, 'utf8')
|
|
144
144
|
: Buffer.from(contents.buffer, contents.byteOffset, contents.byteLength)
|
|
145
|
-
|
|
145
|
+
const filePath = join(dir, name)
|
|
146
|
+
// Support nested paths like `covers/front.png` — ensure the
|
|
147
|
+
// parent directory exists before writing.
|
|
148
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
149
|
+
await writeFile(filePath, buf)
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
const inputPath = join(dir, 'main.typ')
|