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