@uniweb/press 0.3.1 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/press",
3
- "version": "0.3.1",
3
+ "version": "0.4.2",
4
4
  "description": "Press your React components into downloadable files — Word, Excel, PDF. Registration-based pattern with lazy-loaded format adapters.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -40,9 +40,10 @@ const ADAPTERS = {
40
40
  docx: { load: () => import('./docx.js'), consumes: 'docx', ir: true },
41
41
  xlsx: { load: () => import('./xlsx.js'), consumes: 'xlsx', ir: false },
42
42
  typst: { load: () => import('./typst.js'), consumes: 'typst', ir: true },
43
- // Paged.js consumes 'html' — an input shape also targeted by future
44
- // HTML-string adapters (EPUB). Foundations register once under 'html'.
43
+ // Paged.js consumes 'html' — an input shape shared with EPUB below.
44
+ // Foundations register once under 'html' and both adapters read it.
45
45
  pagedjs: { load: () => import('./pagedjs.js'), consumes: 'html', ir: false },
46
+ epub: { load: () => import('./epub.js'), consumes: 'html', ir: false },
46
47
  }
47
48
 
48
49
  /**
@@ -79,6 +80,7 @@ export async function runCompile(format, compiledInput, documentOptions = {}) {
79
80
  adapter.compileXlsx ||
80
81
  adapter.compileTypst ||
81
82
  adapter.compilePagedjs ||
83
+ adapter.compileEpub ||
82
84
  adapter.compilePdf
83
85
  if (!compileFn) {
84
86
  throw new Error(
@@ -44,6 +44,7 @@ import {
44
44
  Bookmark,
45
45
  FootnoteReferenceRun,
46
46
  } from 'docx'
47
+ import { fetchAsset } from '../assets/fetch.js'
47
48
 
48
49
  // ============================================================================
49
50
  // Public API
@@ -128,8 +129,13 @@ export async function buildDocument(input, options = {}) {
128
129
  if (footer) collectFootnotes(footer, footnotesState)
129
130
 
130
131
  // Flatten all blocks' IR trees into one array of section children.
131
- // Use async conversion for image support.
132
- const children = await convertChildren(sections.flat())
132
+ // Use async conversion for image support. The host-supplied loadAsset
133
+ // (when present) is threaded down to irToImageParagraph → fetchImageData
134
+ // → fetchAsset, so config-level paths like `assets/diagram.png` resolve
135
+ // to bytes via the framework's content-collector manifest the same way
136
+ // the typst path does.
137
+ const { loadAsset } = options
138
+ const children = await convertChildren(sections.flat(), loadAsset)
133
139
 
134
140
  const sectionOptions = {
135
141
  properties: {
@@ -143,7 +149,7 @@ export async function buildDocument(input, options = {}) {
143
149
  }
144
150
 
145
151
  if (header) {
146
- const headerChildren = await convertChildren(header)
152
+ const headerChildren = await convertChildren(header, loadAsset)
147
153
  const headerObj = new Header({ children: headerChildren })
148
154
  const defaultHeaderObj = createDefaultHeaderFooter(true)
149
155
 
@@ -159,7 +165,7 @@ export async function buildDocument(input, options = {}) {
159
165
  }
160
166
 
161
167
  if (footer) {
162
- const footerChildren = await convertChildren(footer)
168
+ const footerChildren = await convertChildren(footer, loadAsset)
163
169
  const footerObj = new Footer({ children: footerChildren })
164
170
  const defaultFooterObj = createDefaultHeaderFooter(false)
165
171
 
@@ -279,10 +285,14 @@ export function createDefaultHeaderFooter(isHeader) {
279
285
 
280
286
  /**
281
287
  * Convert an array of IR nodes to section children, handling async
282
- * image fetches via Promise.all.
288
+ * image fetches via Promise.all. `loadAsset` (when provided) is forwarded
289
+ * to image-emitting paths so host-supplied byte loaders take precedence
290
+ * over the URL-based fetch fallback.
283
291
  */
284
- async function convertChildren(nodes) {
285
- const results = await Promise.all(nodes.map(irToSectionChildrenAsync))
292
+ async function convertChildren(nodes, loadAsset) {
293
+ const results = await Promise.all(
294
+ nodes.map((node) => irToSectionChildrenAsync(node, loadAsset)),
295
+ )
286
296
  return results.flat()
287
297
  }
288
298
 
@@ -293,12 +303,12 @@ async function convertChildren(nodes) {
293
303
  /**
294
304
  * Convert an IR node into section-level docx children (async for images).
295
305
  */
296
- async function irToSectionChildrenAsync(node) {
306
+ async function irToSectionChildrenAsync(node, loadAsset) {
297
307
  switch (node.type) {
298
308
  case 'table':
299
309
  return [await irToTableAsync(node)]
300
310
  case 'image':
301
- return [await irToImageParagraph(node)]
311
+ return [await irToImageParagraph(node, loadAsset)]
302
312
  case 'tableOfContents':
303
313
  return [irToTableOfContents(node)]
304
314
  case 'webOnly':
@@ -803,12 +813,12 @@ function inferImageType(src, data) {
803
813
  * valid type). Do not simplify the altText spread — `name: ''` looks
804
814
  * like a no-op but Word-for-Mac rejects the file without it.
805
815
  */
806
- async function irToImageParagraph(node) {
816
+ async function irToImageParagraph(node, loadAsset) {
807
817
  try {
808
818
  const src = node.src || ''
809
819
  if (!src) return new DocxParagraph({})
810
820
 
811
- const imageData = await fetchImageData(src)
821
+ const imageData = await fetchImageData(src, loadAsset)
812
822
 
813
823
  const width = toInt(node.transformation?.width) ?? 400
814
824
  const height = toInt(node.transformation?.height) ?? 300
@@ -838,15 +848,15 @@ async function irToImageParagraph(node) {
838
848
  }
839
849
 
840
850
  /**
841
- * Fetch image data from a URL and return as ArrayBuffer.
842
- * Works in browser (via fetch + blob) and Node (via fetch + arrayBuffer).
851
+ * Fetch image bytes for a docx ImageRun.
852
+ *
853
+ * Thin wrapper over the shared src/assets/fetch.js helper — docx wants
854
+ * a raw ArrayBuffer (Uint8Array.buffer works too; the library accepts
855
+ * either). The shared helper is what EPUB and any future asset-embedding
856
+ * adapter use; see principle 6 ("Extract shared logic when a second
857
+ * adapter needs it") in docs/architecture/principles.md.
843
858
  */
844
- async function fetchImageData(url) {
845
- const response = await fetch(url)
846
-
847
- if (!response.ok) {
848
- throw new Error(`HTTP error fetching image: ${response.status}`)
849
- }
850
-
851
- return response.arrayBuffer()
859
+ async function fetchImageData(url, loadAsset) {
860
+ const { bytes } = await fetchAsset(url, { loadAsset })
861
+ return bytes
852
862
  }
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Internal EPUB3 format adapter.
3
+ *
4
+ * Consumes the 'html' passthrough shape shared with the Paged.js adapter —
5
+ * `{ sections: string[], metadata }` — and produces a valid, reflowable
6
+ * EPUB3 Blob. Each registered section becomes one chapter XHTML file in
7
+ * the EPUB spine.
8
+ *
9
+ * Input-shape sharing: a foundation that wired `useDocumentOutput(block,
10
+ * 'html', fragment)` for Paged.js gets EPUB for free — the adapter
11
+ * descriptor declares `consumes: 'html', ir: false` (see
12
+ * src/adapters/dispatch.js).
13
+ *
14
+ * Three EPUB envelope invariants this adapter upholds — the analogues of
15
+ * the three docx image invariants. All three must hold or e-readers reject
16
+ * the file:
17
+ *
18
+ * 1. `mimetype` is the FIRST entry in the ZIP and stored uncompressed
19
+ * (compression method 0). Readers sniff the first ~60 bytes.
20
+ * 2. Every item in `<spine>` appears in `<manifest>` and vice versa.
21
+ * Mismatches trigger reader errors ("malformed package").
22
+ * 3. Image paths in XHTML exactly match manifest paths including
23
+ * extension and case (XHTML paths are relative to `OEBPS/`).
24
+ *
25
+ * This module is internal. It is NOT listed in package.json's exports
26
+ * field; consumers reach it only via the dynamic import inside
27
+ * adapters/dispatch.js, which keeps jszip + the emitter out of the main
28
+ * bundle until compile('epub') runs.
29
+ */
30
+
31
+ import JSZip from 'jszip'
32
+ import { parseFragment } from 'parse5'
33
+ import { fetchAssets } from '../assets/fetch.js'
34
+
35
+ // ============================================================================
36
+ // Public API
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Compile passthrough HTML output into an EPUB3 Blob.
41
+ *
42
+ * @param {Object} input - Output of compileOutputs(store, 'epub').
43
+ * @param {string[]} input.sections - Rendered HTML strings in registration
44
+ * order, one per body fragment. Each becomes one spine chapter.
45
+ * @param {Object|null} [input.metadata] - Plain data object from the
46
+ * `role: 'metadata'` registration (title, author, language, isbn, …).
47
+ * @param {Object} [options]
48
+ * @param {Object} [options.meta] - Additional metadata merged over the
49
+ * `metadata` registration (options.meta wins).
50
+ * @param {string} [options.stylesheet] - Foundation-supplied CSS string
51
+ * written to `OEBPS/styles.css`. When omitted, DEFAULT_STYLESHEET is used.
52
+ * @param {string} [options.identifier] - EPUB `dc:identifier`. When omitted,
53
+ * a UUID is generated at compile time.
54
+ * @param {string} [options.cover] - URL of the cover image. When set, the
55
+ * fetched bytes are embedded in OEBPS/images/, referenced in the manifest
56
+ * with `properties="cover-image"` (EPUB3) and pointed at by a legacy
57
+ * `<meta name="cover">` hint (Kindle KF8 compatibility).
58
+ * @returns {Promise<Blob>}
59
+ */
60
+ export async function compileEpub(input, options = {}) {
61
+ const { sections = [], metadata = null } = input || {}
62
+ const { meta = {}, stylesheet, identifier, cover, loadAsset } = options
63
+
64
+ const resolvedMeta = { ...(metadata || {}), ...meta }
65
+ const css = stylesheet || DEFAULT_STYLESHEET
66
+ const id = identifier || resolvedMeta.identifier || generateUuid()
67
+ const coverUrl = cover || resolvedMeta.cover || resolvedMeta.coverImage
68
+
69
+ // Walk each HTML fragment, collect image URLs, capture a chapter title.
70
+ // The parsed tree is kept so we can re-serialize as XHTML after
71
+ // rewriting <img src> to manifest-relative paths.
72
+ const chapters = sections.map((html, i) => buildChapter(html, i))
73
+
74
+ // Deduplicate image URLs across all chapters — and the cover, if any —
75
+ // and fetch them in parallel. Failed fetches are logged and the original
76
+ // URL is left in place; the EPUB still opens but may show broken images.
77
+ // The host-supplied `loadAsset` (when present) is tried before fetch —
78
+ // browser hosts wrap fetch; Node hosts (unipress) wrap node:fs so config-
79
+ // level paths like `assets/cover.jpg` resolve to bytes via the manifest
80
+ // the framework's content collector populated.
81
+ const imageUrls = new Set()
82
+ for (const ch of chapters) {
83
+ for (const url of ch.images) imageUrls.add(url)
84
+ }
85
+ if (coverUrl) imageUrls.add(coverUrl)
86
+ const fetched = imageUrls.size
87
+ ? await fetchAssets(imageUrls, { loadAsset })
88
+ : new Map()
89
+
90
+ // Assign a manifest-relative path to every successfully-fetched image,
91
+ // keyed by URL. Hash-based filenames dedupe identical images byte-for-byte.
92
+ const imagesManifest = []
93
+ const urlToPath = new Map()
94
+ const seenPaths = new Set()
95
+ let coverImageId = null
96
+ for (const [url, result] of fetched) {
97
+ if (result.error) {
98
+ console.warn(`@uniweb/press epub: failed to fetch image ${url}: ${result.error.message}`)
99
+ continue
100
+ }
101
+ const filename = `${result.hash}.${result.ext}`
102
+ const path = `images/${filename}`
103
+ urlToPath.set(url, path)
104
+ const imgId = `img-${result.hash}`
105
+ if (!seenPaths.has(path)) {
106
+ seenPaths.add(path)
107
+ imagesManifest.push({
108
+ id: imgId,
109
+ path,
110
+ mime: result.mime || 'application/octet-stream',
111
+ bytes: result.bytes,
112
+ isCover: url === coverUrl,
113
+ })
114
+ }
115
+ if (url === coverUrl) coverImageId = imgId
116
+ }
117
+
118
+ // Second pass over chapters — rewrite <img src> to manifest paths and
119
+ // serialize each as XHTML. Chapter paths live under `OEBPS/chapters/`
120
+ // so images (under `OEBPS/images/`) resolve via `../images/...`.
121
+ const chapterManifest = chapters.map((ch, i) => {
122
+ rewriteImageSrcs(ch.tree, urlToPath, '../')
123
+ const xhtml = wrapChapterXhtml({
124
+ title: ch.title || resolvedMeta.title || `Chapter ${i + 1}`,
125
+ language: resolvedMeta.language || 'en',
126
+ body: serializeXhtml(ch.tree),
127
+ stylesheetHref: '../styles.css',
128
+ })
129
+ return {
130
+ id: `ch-${pad(i + 1)}`,
131
+ path: `chapters/ch-${pad(i + 1)}.xhtml`,
132
+ title: ch.title || `Chapter ${i + 1}`,
133
+ xhtml,
134
+ }
135
+ })
136
+
137
+ // When a cover image was provided and fetched successfully, synthesize a
138
+ // cover-page XHTML (epub:type="cover") that precedes all content chapters
139
+ // in the spine. Some readers (iBooks, Kobo) show the manifest cover-image
140
+ // only on the shelf; an in-book cover page guarantees readers that don't
141
+ // render a shelf cover still see one when they open the book.
142
+ const coverImage = coverImageId
143
+ ? imagesManifest.find((img) => img.id === coverImageId)
144
+ : null
145
+ const coverChapter = coverImage
146
+ ? {
147
+ id: 'cover-page',
148
+ path: 'chapters/cover.xhtml',
149
+ title: resolvedMeta.coverTitle || 'Cover',
150
+ xhtml: buildCoverXhtml({
151
+ language: resolvedMeta.language || 'en',
152
+ title: resolvedMeta.title || 'Cover',
153
+ imagePath: `../${coverImage.path}`,
154
+ alt: resolvedMeta.coverAlt || resolvedMeta.title || 'Cover',
155
+ }),
156
+ isCover: true,
157
+ }
158
+ : null
159
+ const spineChapters = coverChapter
160
+ ? [coverChapter, ...chapterManifest]
161
+ : chapterManifest
162
+
163
+ // Assemble the ZIP. mimetype must be first and STORED uncompressed —
164
+ // invariant #1. Everything else is DEFLATEd for size; a 200–400KB book
165
+ // typically halves when XHTML/OPF/NCX are deflated. JSZip's default
166
+ // when generateAsync is called without per-file overrides is STORE, so
167
+ // pass compression at the generate level AND keep the per-file STORE
168
+ // on mimetype.
169
+ const zip = new JSZip()
170
+ zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })
171
+ zip.file('META-INF/container.xml', buildContainerXml())
172
+
173
+ const oebps = zip.folder('OEBPS')
174
+ oebps.file('content.opf', buildOpf({
175
+ id,
176
+ meta: resolvedMeta,
177
+ chapters: spineChapters,
178
+ images: imagesManifest,
179
+ coverImageId,
180
+ coverPageId: coverChapter ? coverChapter.id : null,
181
+ }))
182
+ oebps.file('nav.xhtml', buildNav({
183
+ language: resolvedMeta.language || 'en',
184
+ title: resolvedMeta.title || 'Contents',
185
+ chapters: chapterManifest,
186
+ coverPage: coverChapter,
187
+ }))
188
+ oebps.file('toc.ncx', buildNcx({
189
+ id,
190
+ title: resolvedMeta.title || 'Book',
191
+ chapters: spineChapters,
192
+ }))
193
+ oebps.file('styles.css', css)
194
+
195
+ for (const ch of spineChapters) {
196
+ oebps.file(ch.path, ch.xhtml)
197
+ }
198
+ for (const img of imagesManifest) {
199
+ oebps.file(img.path, img.bytes)
200
+ }
201
+
202
+ const blob = await zip.generateAsync({
203
+ type: 'blob',
204
+ mimeType: 'application/epub+zip',
205
+ compression: 'DEFLATE',
206
+ compressionOptions: { level: 6 },
207
+ })
208
+ return blob
209
+ }
210
+
211
+ // ============================================================================
212
+ // Chapter parsing
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Parse one registered HTML string into a chapter descriptor:
217
+ * - tree: parse5 fragment root (we re-serialize it as XHTML later)
218
+ * - title: first heading text, used for nav + <title>
219
+ * - images: set of <img src> URLs found while walking
220
+ */
221
+ function buildChapter(html, index) {
222
+ const tree = parseFragment(html || '')
223
+ const state = { title: '', images: new Set() }
224
+ walkForChapterMeta(tree, state)
225
+ return {
226
+ tree,
227
+ title: state.title,
228
+ images: state.images,
229
+ index,
230
+ }
231
+ }
232
+
233
+ function walkForChapterMeta(node, state) {
234
+ const children = node.childNodes || []
235
+ for (const child of children) {
236
+ if (child.nodeName === '#text') continue
237
+ const tag = (child.tagName || '').toLowerCase()
238
+ if (!state.title && (tag === 'h1' || tag === 'h2' || tag === 'h3')) {
239
+ state.title = collectText(child).trim()
240
+ }
241
+ if (tag === 'img') {
242
+ const src = getAttr(child, 'src')
243
+ if (src && !/^data:/i.test(src)) state.images.add(src)
244
+ }
245
+ if (child.childNodes) walkForChapterMeta(child, state)
246
+ }
247
+ }
248
+
249
+ function rewriteImageSrcs(node, urlToPath, pathPrefix) {
250
+ const children = node.childNodes || []
251
+ for (const child of children) {
252
+ if (child.nodeName === '#text') continue
253
+ const tag = (child.tagName || '').toLowerCase()
254
+ if (tag === 'img') {
255
+ const src = getAttr(child, 'src')
256
+ const rewritten = src && urlToPath.get(src)
257
+ if (rewritten) setAttr(child, 'src', pathPrefix + rewritten)
258
+ }
259
+ if (child.childNodes) rewriteImageSrcs(child, urlToPath, pathPrefix)
260
+ }
261
+ }
262
+
263
+ function collectText(node) {
264
+ if (node.nodeName === '#text') return node.value || ''
265
+ let out = ''
266
+ for (const child of node.childNodes || []) out += collectText(child)
267
+ return out
268
+ }
269
+
270
+ function getAttr(node, name) {
271
+ for (const attr of node.attrs || []) {
272
+ if (attr.name === name) return attr.value
273
+ }
274
+ return null
275
+ }
276
+
277
+ function setAttr(node, name, value) {
278
+ if (!node.attrs) node.attrs = []
279
+ for (const attr of node.attrs) {
280
+ if (attr.name === name) {
281
+ attr.value = value
282
+ return
283
+ }
284
+ }
285
+ node.attrs.push({ name, value })
286
+ }
287
+
288
+ // ============================================================================
289
+ // XHTML serializer
290
+ // ============================================================================
291
+
292
+ /**
293
+ * EPUB3 readers parse chapters as XML, not tag-soup HTML. The serializer
294
+ * emits well-formed XHTML: void elements self-close, attributes are always
295
+ * quoted, text content is entity-escaped, and tag names are lowercase.
296
+ *
297
+ * parse5's built-in serializer emits HTML (`<br>`, not `<br/>`), so we
298
+ * walk the tree manually instead.
299
+ */
300
+ const VOID_ELEMENTS = new Set([
301
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
302
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
303
+ ])
304
+
305
+ export function serializeXhtml(node) {
306
+ if (node.nodeName === '#text') return escapeXmlText(node.value || '')
307
+ if (node.nodeName === '#document-fragment') {
308
+ return (node.childNodes || []).map(serializeXhtml).join('')
309
+ }
310
+ const tag = (node.tagName || '').toLowerCase()
311
+ if (!tag) {
312
+ // Unknown container: spread children.
313
+ return (node.childNodes || []).map(serializeXhtml).join('')
314
+ }
315
+ const attrs = serializeAttrs(node.attrs || [])
316
+ if (VOID_ELEMENTS.has(tag)) {
317
+ return `<${tag}${attrs}/>`
318
+ }
319
+ const inner = (node.childNodes || []).map(serializeXhtml).join('')
320
+ return `<${tag}${attrs}>${inner}</${tag}>`
321
+ }
322
+
323
+ function serializeAttrs(attrs) {
324
+ if (!attrs.length) return ''
325
+ let out = ''
326
+ for (const attr of attrs) {
327
+ const name = attr.name
328
+ const value = attr.value == null ? '' : String(attr.value)
329
+ out += ` ${name}="${escapeXmlAttr(value)}"`
330
+ }
331
+ return out
332
+ }
333
+
334
+ function escapeXmlText(s) {
335
+ return String(s)
336
+ .replace(/&/g, '&amp;')
337
+ .replace(/</g, '&lt;')
338
+ .replace(/>/g, '&gt;')
339
+ }
340
+
341
+ function escapeXmlAttr(s) {
342
+ return String(s)
343
+ .replace(/&/g, '&amp;')
344
+ .replace(/"/g, '&quot;')
345
+ .replace(/</g, '&lt;')
346
+ .replace(/>/g, '&gt;')
347
+ }
348
+
349
+ // ============================================================================
350
+ // EPUB envelope builders
351
+ // ============================================================================
352
+
353
+ function buildContainerXml() {
354
+ return `<?xml version="1.0" encoding="UTF-8"?>
355
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
356
+ <rootfiles>
357
+ <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
358
+ </rootfiles>
359
+ </container>
360
+ `
361
+ }
362
+
363
+ /**
364
+ * EPUB3 package document. Declares metadata, manifest (every file under
365
+ * OEBPS), and spine (ordered reading sequence).
366
+ *
367
+ * Cover handling is intentionally dual: EPUB3 readers pick up the cover
368
+ * from `properties="cover-image"` on the manifest item; Amazon's KF8
369
+ * converter (used by "Send to Kindle" and older kindlegen) still wants a
370
+ * legacy `<meta name="cover" content="<image-id>"/>` hint in metadata.
371
+ * Emitting both is cheap and covers the whole ecosystem.
372
+ */
373
+ export function buildOpf({ id, meta, chapters, images, coverImageId, coverPageId }) {
374
+ const title = meta.title || 'Untitled'
375
+ const language = meta.language || 'en'
376
+ const author = meta.author
377
+ const publisher = meta.publisher
378
+ const description = meta.description
379
+ const subject = meta.subject
380
+ const rights = meta.rights
381
+ const modified = meta.date || new Date().toISOString().slice(0, 19) + 'Z'
382
+
383
+ const metaLines = [
384
+ ` <dc:identifier id="pub-id">${escapeXmlText(id)}</dc:identifier>`,
385
+ ` <dc:title>${escapeXmlText(title)}</dc:title>`,
386
+ ` <dc:language>${escapeXmlText(language)}</dc:language>`,
387
+ ]
388
+ if (author) metaLines.push(` <dc:creator>${escapeXmlText(author)}</dc:creator>`)
389
+ if (publisher) metaLines.push(` <dc:publisher>${escapeXmlText(publisher)}</dc:publisher>`)
390
+ if (description) metaLines.push(` <dc:description>${escapeXmlText(description)}</dc:description>`)
391
+ if (subject) metaLines.push(` <dc:subject>${escapeXmlText(subject)}</dc:subject>`)
392
+ if (rights) metaLines.push(` <dc:rights>${escapeXmlText(rights)}</dc:rights>`)
393
+ metaLines.push(` <meta property="dcterms:modified">${escapeXmlText(modified)}</meta>`)
394
+ // Legacy Kindle/KF8 cover hint — Amazon's converter still reads this.
395
+ if (coverImageId) {
396
+ metaLines.push(
397
+ ` <meta name="cover" content="${escapeXmlAttr(coverImageId)}"/>`,
398
+ )
399
+ }
400
+
401
+ const manifestLines = [
402
+ ` <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
403
+ ` <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`,
404
+ ` <item id="css" href="styles.css" media-type="text/css"/>`,
405
+ ]
406
+ for (const ch of chapters) {
407
+ manifestLines.push(
408
+ ` <item id="${escapeXmlAttr(ch.id)}" href="${escapeXmlAttr(ch.path)}" media-type="application/xhtml+xml"/>`,
409
+ )
410
+ }
411
+ for (const img of images) {
412
+ // EPUB3 cover hint — readers use this to pick the shelf thumbnail.
413
+ const coverProp = img.id === coverImageId ? ' properties="cover-image"' : ''
414
+ manifestLines.push(
415
+ ` <item id="${escapeXmlAttr(img.id)}" href="${escapeXmlAttr(img.path)}" media-type="${escapeXmlAttr(img.mime)}"${coverProp}/>`,
416
+ )
417
+ }
418
+
419
+ const spineLines = chapters.map((ch) => {
420
+ // Cover page should not linger in the reading flow for left-to-right
421
+ // navigation — `linear="no"` lets readers jump straight to content.
422
+ const attrs = ch.id === coverPageId
423
+ ? ` idref="${escapeXmlAttr(ch.id)}" linear="no"`
424
+ : ` idref="${escapeXmlAttr(ch.id)}"`
425
+ return ` <itemref${attrs}/>`
426
+ })
427
+
428
+ return `<?xml version="1.0" encoding="UTF-8"?>
429
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="${escapeXmlAttr(language)}">
430
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
431
+ ${metaLines.join('\n')}
432
+ </metadata>
433
+ <manifest>
434
+ ${manifestLines.join('\n')}
435
+ </manifest>
436
+ <spine toc="ncx">
437
+ ${spineLines.join('\n')}
438
+ </spine>
439
+ </package>
440
+ `
441
+ }
442
+
443
+ /**
444
+ * EPUB3 navigation document. Primary TOC — replaces the NCX for EPUB3
445
+ * readers but NCX is still emitted for EPUB2 compatibility.
446
+ *
447
+ * Two nav elements are emitted:
448
+ * - `epub:type="toc"`: the reading-order chapter list (required).
449
+ * - `epub:type="landmarks"`: quick-jump entries for cover, TOC, and
450
+ * bodymatter start (optional, but assistive-tech readers and many
451
+ * e-readers use it to offer "Go to cover" / "Go to beginning").
452
+ */
453
+ export function buildNav({ language, title, chapters, coverPage }) {
454
+ const items = chapters
455
+ .map(
456
+ (ch) =>
457
+ ` <li><a href="${escapeXmlAttr(ch.path)}">${escapeXmlText(ch.title)}</a></li>`,
458
+ )
459
+ .join('\n')
460
+
461
+ const landmarkLines = []
462
+ if (coverPage) {
463
+ landmarkLines.push(
464
+ ` <li><a epub:type="cover" href="${escapeXmlAttr(coverPage.path)}">Cover</a></li>`,
465
+ )
466
+ }
467
+ landmarkLines.push(` <li><a epub:type="toc" href="nav.xhtml#toc">Table of Contents</a></li>`)
468
+ if (chapters.length) {
469
+ landmarkLines.push(
470
+ ` <li><a epub:type="bodymatter" href="${escapeXmlAttr(chapters[0].path)}">Start of Content</a></li>`,
471
+ )
472
+ }
473
+ const landmarks = ` <nav epub:type="landmarks" id="landmarks" hidden="hidden">
474
+ <h2>Landmarks</h2>
475
+ <ol>
476
+ ${landmarkLines.join('\n')}
477
+ </ol>
478
+ </nav>`
479
+
480
+ return `<?xml version="1.0" encoding="UTF-8"?>
481
+ <!DOCTYPE html>
482
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${escapeXmlAttr(language)}">
483
+ <head>
484
+ <meta charset="utf-8"/>
485
+ <title>${escapeXmlText(title)}</title>
486
+ </head>
487
+ <body>
488
+ <nav epub:type="toc" id="toc">
489
+ <h1>${escapeXmlText(title)}</h1>
490
+ <ol>
491
+ ${items}
492
+ </ol>
493
+ </nav>
494
+ ${landmarks}
495
+ </body>
496
+ </html>
497
+ `
498
+ }
499
+
500
+ /**
501
+ * EPUB2 NCX navigation. Optional in EPUB3 but helpful for older e-readers.
502
+ */
503
+ export function buildNcx({ id, title, chapters }) {
504
+ const navPoints = chapters
505
+ .map(
506
+ (ch, i) => ` <navPoint id="${escapeXmlAttr(ch.id)}" playOrder="${i + 1}">
507
+ <navLabel><text>${escapeXmlText(ch.title)}</text></navLabel>
508
+ <content src="${escapeXmlAttr(ch.path)}"/>
509
+ </navPoint>`,
510
+ )
511
+ .join('\n')
512
+
513
+ return `<?xml version="1.0" encoding="UTF-8"?>
514
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
515
+ <head>
516
+ <meta name="dtb:uid" content="${escapeXmlAttr(id)}"/>
517
+ <meta name="dtb:depth" content="1"/>
518
+ </head>
519
+ <docTitle><text>${escapeXmlText(title)}</text></docTitle>
520
+ <navMap>
521
+ ${navPoints}
522
+ </navMap>
523
+ </ncx>
524
+ `
525
+ }
526
+
527
+ /**
528
+ * Build the cover-page XHTML. Uses `epub:type="cover"` so assistive tech
529
+ * and the landmarks nav can identify it. The image scales to the viewport
530
+ * via inline SVG-style CSS so it works on both phone-sized readers and
531
+ * tablet-sized ones without the foundation needing to override styles.
532
+ */
533
+ export function buildCoverXhtml({ language, title, imagePath, alt }) {
534
+ return `<?xml version="1.0" encoding="UTF-8"?>
535
+ <!DOCTYPE html>
536
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${escapeXmlAttr(language)}">
537
+ <head>
538
+ <meta charset="utf-8"/>
539
+ <title>${escapeXmlText(title)}</title>
540
+ <style type="text/css">
541
+ body { margin: 0; padding: 0; text-align: center; }
542
+ img { max-width: 100%; max-height: 100vh; height: auto; }
543
+ </style>
544
+ </head>
545
+ <body epub:type="cover">
546
+ <div><img src="${escapeXmlAttr(imagePath)}" alt="${escapeXmlAttr(alt)}"/></div>
547
+ </body>
548
+ </html>
549
+ `
550
+ }
551
+
552
+ /**
553
+ * Wrap chapter body HTML in a valid XHTML document.
554
+ */
555
+ export function wrapChapterXhtml({ title, language, body, stylesheetHref }) {
556
+ const css = stylesheetHref
557
+ ? ` <link rel="stylesheet" type="text/css" href="${escapeXmlAttr(stylesheetHref)}"/>\n`
558
+ : ''
559
+ return `<?xml version="1.0" encoding="UTF-8"?>
560
+ <!DOCTYPE html>
561
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${escapeXmlAttr(language)}">
562
+ <head>
563
+ <meta charset="utf-8"/>
564
+ <title>${escapeXmlText(title)}</title>
565
+ ${css} </head>
566
+ <body>
567
+ ${body}
568
+ </body>
569
+ </html>
570
+ `
571
+ }
572
+
573
+ // ============================================================================
574
+ // Helpers
575
+ // ============================================================================
576
+
577
+ function pad(n) {
578
+ return String(n).padStart(2, '0')
579
+ }
580
+
581
+ function generateUuid() {
582
+ if (globalThis.crypto?.randomUUID) {
583
+ return 'urn:uuid:' + globalThis.crypto.randomUUID()
584
+ }
585
+ // RFC 4122 v4-ish fallback — 32 hex chars with dashes at the right places.
586
+ const rand = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0')
587
+ return `urn:uuid:${rand()}${rand()}-${rand()}-4${rand().slice(1)}-a${rand().slice(1)}-${rand()}${rand()}${rand()}`
588
+ }
589
+
590
+ // ============================================================================
591
+ // Defaults
592
+ // ============================================================================
593
+
594
+ export const DEFAULT_STYLESHEET = `/* Minimal EPUB3 stylesheet — foundations can override via options.stylesheet. */
595
+ body {
596
+ font-family: Georgia, "Times New Roman", serif;
597
+ line-height: 1.5;
598
+ margin: 0 5%;
599
+ }
600
+ h1, h2, h3, h4, h5, h6 {
601
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
602
+ line-height: 1.2;
603
+ page-break-after: avoid;
604
+ }
605
+ h1 { font-size: 1.8em; margin-top: 2em; }
606
+ h2 { font-size: 1.4em; margin-top: 1.5em; }
607
+ h3 { font-size: 1.15em; margin-top: 1em; }
608
+ p { margin: 0 0 0.8em; text-indent: 1.25em; }
609
+ p:first-child, p.lead { text-indent: 0; }
610
+ img { max-width: 100%; height: auto; }
611
+ figure { margin: 1em 0; text-align: center; }
612
+ figcaption { font-size: 0.9em; color: #555; }
613
+ blockquote {
614
+ margin: 1em 1.5em;
615
+ padding-left: 1em;
616
+ border-left: 3px solid #ccc;
617
+ color: #444;
618
+ }
619
+ code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.92em; }
620
+ pre {
621
+ font-family: ui-monospace, Menlo, Consolas, monospace;
622
+ font-size: 0.9em;
623
+ background: #f5f5f5;
624
+ padding: 0.75em;
625
+ overflow: auto;
626
+ white-space: pre-wrap;
627
+ }
628
+ `
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Asset fetching for format adapters — internal.
3
+ *
4
+ * Adapters that embed binary assets (images today; fonts, audio in the
5
+ * future) share this helper rather than each inlining a `fetch()` call.
6
+ * Extracted when the second adapter (EPUB) needed asset bytes — principle
7
+ * 6 in docs/architecture/principles.md. The shape is deliberately minimal:
8
+ * docx embeds one image at a time, EPUB collects many URLs and writes them
9
+ * into the ZIP keyed by hash, and both paths flow through the same API.
10
+ *
11
+ * Internal: not in package.json exports. Reach it via adapter imports.
12
+ *
13
+ * Two entry points:
14
+ * fetchAsset(url, options) — one URL, returns { bytes, mime, hash, ext }.
15
+ * Throws on network/HTTP failure; callers that
16
+ * want soft failure wrap in try/catch.
17
+ * fetchAssets(urls, options) — many URLs, Promise.allSettled so one
18
+ * failure doesn't kill the batch. Returns
19
+ * Map<url, { bytes, mime, hash, ext } | { error }>.
20
+ */
21
+
22
+ /**
23
+ * Fetch a single asset URL and return normalized bytes + metadata.
24
+ *
25
+ * Resolution order:
26
+ * 1. `options.loadAsset(url)` — host-supplied byte loader. Browser hosts
27
+ * typically wrap `fetch`; Node hosts (unipress) wrap `node:fs`. When
28
+ * present, tried first; on throw or null/undefined return, falls
29
+ * through to `fetch`. This is how foundations stay environment-
30
+ * agnostic — they declare `book.covers.front: assets/front.jpg` and
31
+ * the host's loadAsset turns that into bytes the same way the host
32
+ * handles every other config-level asset.
33
+ * 2. `fetch()` (or `options.fetch` for tests) — the legacy URL-based
34
+ * path. Required for absolute http(s) URLs and as a fallback for
35
+ * anything `loadAsset` can't handle.
36
+ *
37
+ * @param {string} url
38
+ * @param {Object} [options]
39
+ * @param {(src: string) => Promise<Uint8Array | null>} [options.loadAsset]
40
+ * - host-supplied byte loader. See above.
41
+ * @param {typeof fetch} [options.fetch] - override for tests / node-fetch.
42
+ * @returns {Promise<{ bytes: Uint8Array, mime: string, hash: string, ext: string }>}
43
+ */
44
+ export async function fetchAsset(url, options = {}) {
45
+ const { loadAsset } = options
46
+
47
+ if (typeof loadAsset === 'function') {
48
+ try {
49
+ const bytes = await loadAsset(url)
50
+ if (bytes && bytes.length > 0) {
51
+ const mime = detectMime(url, bytes)
52
+ const ext = extForMime(mime) || extFromUrl(url) || 'bin'
53
+ const hash = await hashContent(bytes)
54
+ return { bytes, mime, hash, ext }
55
+ }
56
+ } catch {
57
+ // Fall through to fetch — loadAsset may not handle every URL
58
+ // shape (e.g., absolute http(s) URLs in a Node host).
59
+ }
60
+ }
61
+
62
+ const fetchImpl = options.fetch || globalThis.fetch
63
+ if (typeof fetchImpl !== 'function') {
64
+ throw new Error(
65
+ 'fetchAsset: no fetch() available. Pass options.fetch / options.loadAsset or run in an environment that provides globalThis.fetch.',
66
+ )
67
+ }
68
+ const res = await fetchImpl(url)
69
+ if (!res.ok) {
70
+ throw new Error(`fetchAsset: HTTP ${res.status} ${res.statusText || ''} for ${url}`.trim())
71
+ }
72
+ const buf = await res.arrayBuffer()
73
+ const bytes = new Uint8Array(buf)
74
+ const headerMime = typeof res.headers?.get === 'function'
75
+ ? (res.headers.get('content-type') || '').split(';')[0].trim()
76
+ : ''
77
+ const mime = headerMime || detectMime(url, bytes)
78
+ const ext = extForMime(mime) || extFromUrl(url) || 'bin'
79
+ const hash = await hashContent(bytes)
80
+ return { bytes, mime, hash, ext }
81
+ }
82
+
83
+ /**
84
+ * Fetch many URLs at once. Deduplicates by URL. Uses Promise.allSettled
85
+ * so a single failure doesn't abort the rest — failed entries carry an
86
+ * `error` field instead of bytes.
87
+ *
88
+ * @param {Iterable<string>} urls
89
+ * @param {Object} [options] - forwarded to fetchAsset.
90
+ * @returns {Promise<Map<string, { bytes: Uint8Array, mime: string, hash: string, ext: string } | { error: Error }>>}
91
+ */
92
+ export async function fetchAssets(urls, options = {}) {
93
+ const unique = Array.from(new Set(urls))
94
+ const results = await Promise.allSettled(
95
+ unique.map((url) => fetchAsset(url, options)),
96
+ )
97
+ const out = new Map()
98
+ unique.forEach((url, i) => {
99
+ const r = results[i]
100
+ if (r.status === 'fulfilled') {
101
+ out.set(url, r.value)
102
+ } else {
103
+ out.set(url, { error: r.reason instanceof Error ? r.reason : new Error(String(r.reason)) })
104
+ }
105
+ })
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Detect the MIME type of a byte buffer by magic bytes. Falls back to
111
+ * extension sniffing on the URL. Returns an empty string when neither
112
+ * path yields an answer.
113
+ */
114
+ export function detectMime(url, bytes) {
115
+ if (bytes && bytes.length >= 4) {
116
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'
117
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
118
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
119
+ if (bytes[0] === 0x42 && bytes[1] === 0x4d) return 'image/bmp'
120
+ // SVG detection: "<?xml" or "<svg" after trimming BOM/whitespace.
121
+ const head = String.fromCharCode(...bytes.slice(0, Math.min(256, bytes.length))).trimStart()
122
+ if (head.startsWith('<?xml') || head.startsWith('<svg')) return 'image/svg+xml'
123
+ }
124
+ const ext = extFromUrl(url)
125
+ return ext ? mimeForExt(ext) : ''
126
+ }
127
+
128
+ /**
129
+ * SHA-256 hex digest of a byte buffer. Uses SubtleCrypto when available
130
+ * (browsers, modern Node), falls back to a short FNV-1a hash otherwise —
131
+ * good enough for de-duplicating asset filenames inside one compile.
132
+ */
133
+ export async function hashContent(bytes) {
134
+ const subtle = globalThis.crypto?.subtle
135
+ if (subtle && typeof subtle.digest === 'function') {
136
+ const buf = await subtle.digest('SHA-256', bytes)
137
+ return bytesToHex(new Uint8Array(buf)).slice(0, 16)
138
+ }
139
+ // FNV-1a fallback.
140
+ let h = 0x811c9dc5
141
+ for (let i = 0; i < bytes.length; i++) {
142
+ h ^= bytes[i]
143
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
144
+ }
145
+ return h.toString(16).padStart(8, '0')
146
+ }
147
+
148
+ function bytesToHex(bytes) {
149
+ let s = ''
150
+ for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0')
151
+ return s
152
+ }
153
+
154
+ function extFromUrl(url) {
155
+ if (!url) return ''
156
+ const path = String(url).split(/[?#]/)[0]
157
+ const m = path.match(/\.([a-zA-Z0-9]+)$/)
158
+ return m ? m[1].toLowerCase() : ''
159
+ }
160
+
161
+ const EXT_MIME = {
162
+ png: 'image/png',
163
+ jpg: 'image/jpeg',
164
+ jpeg: 'image/jpeg',
165
+ gif: 'image/gif',
166
+ bmp: 'image/bmp',
167
+ svg: 'image/svg+xml',
168
+ webp: 'image/webp',
169
+ }
170
+
171
+ const MIME_EXT = {
172
+ 'image/png': 'png',
173
+ 'image/jpeg': 'jpg',
174
+ 'image/gif': 'gif',
175
+ 'image/bmp': 'bmp',
176
+ 'image/svg+xml': 'svg',
177
+ 'image/webp': 'webp',
178
+ }
179
+
180
+ function mimeForExt(ext) {
181
+ return EXT_MIME[ext] || ''
182
+ }
183
+
184
+ function extForMime(mime) {
185
+ return MIME_EXT[mime] || ''
186
+ }
@@ -142,3 +142,252 @@ export async function compileSubtree(elements, format, options = {}) {
142
142
  const compiled = compileRegistrations(elements, format, { basePath })
143
143
  return runCompile(format, compiled, adapterOptions)
144
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'