@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.
@@ -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
@@ -15,4 +15,5 @@ export { triggerDownload } from './triggerDownload.js'
15
15
  export {
16
16
  compileRegistrations,
17
17
  compileSubtree,
18
+ compileDocument,
18
19
  } from './compileRegistrations.js'
@@ -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, pdf).
3
+ * useDocumentOutput) and the format adapters (docx, xlsx, typst, pagedjs).
4
4
  *
5
- * For docx (and any HTML-based format): registered JSX fragments are
6
- * statically rendered to HTML, parsed to IR, and grouped into
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
- * For xlsx: registered data objects are collected as-is (no IR conversion).
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', etc.).
24
- * @returns {Object} Adapter input shape:
25
- * For docx: { sections: IR[][], header: IR[]|null, footer: IR[]|null }
26
- * For xlsx: { sections: Object[] } (raw data objects)
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 outputs = store.getOutputs(format)
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 (format === 'xlsx') {
32
- return compileXlsx(outputs)
48
+ if (desc.ir) {
49
+ return compileToIR(outputs, store)
33
50
  }
34
51
 
35
- // Default: HTML-based formats (docx, pdf)
36
- return compileHtmlBased(outputs, store)
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
- * Compile HTML-based format outputs (docx, pdf, typst).
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 compileHtmlBased(outputs, store) {
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 { sections, header, footer, metadata }
131
+ return {
132
+ sections,
133
+ header,
134
+ footer,
135
+ metadata,
136
+ headerFirstPageOnly,
137
+ footerFirstPageOnly,
138
+ }
97
139
  }
98
140
 
99
141
  /**
100
- * Compile xlsx format outputs. Fragments are plain data objects
101
- * (no HTML/IR conversion needed).
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 compileXlsx(outputs) {
104
- const sections = outputs
105
- .map(({ fragment }) => fragment)
106
- .filter(Boolean)
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 {
@@ -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
- await writeFile(join(dir, name), buf)
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')