@uniweb/press 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Internal Paged.js format adapter.
3
+ *
4
+ * Consumes the compile-pipeline output for the `pagedjs` format — a
5
+ * passthrough shape { sections: string[], metadata } — and produces a
6
+ * complete HTML document wired to the Paged.js polyfill.
7
+ *
8
+ * Unlike the docx/typst adapters, Paged.js does NOT walk an IR. The rendered
9
+ * HTML *is* the target format; walking JSX → HTML → IR → HTML would
10
+ * round-trip and lose nested spans, inline styles, and data-* attributes the
11
+ * foundation intentionally put there for CSS Paged Media to pick up.
12
+ *
13
+ * Two modes:
14
+ * - 'html' (Phase 1): return a text/html Blob. The caller loads it in a
15
+ * browser context (new tab or iframe). Paged.js paginates on
16
+ * DOMContentLoaded, the user prints to PDF.
17
+ * - 'server' (Phase 3 — stub): POST the HTML to a backend that runs
18
+ * headless Chromium + Paged.js and returns a PDF.
19
+ *
20
+ * This module is internal. It is NOT listed in package.json's exports field;
21
+ * consumers reach it only via the dynamic import inside adapters/dispatch.js.
22
+ *
23
+ * Entry point: compilePagedjs(compiledInput, options) → Promise<Blob>
24
+ */
25
+
26
+ // ============================================================================
27
+ // Public API
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Compile passthrough output into a Paged.js-ready HTML Blob (html mode)
32
+ * or a PDF Blob (server mode).
33
+ *
34
+ * @param {Object} input - Output of compileOutputs(store, 'pagedjs').
35
+ * @param {string[]} input.sections - Rendered HTML strings in registration
36
+ * order, one per body fragment.
37
+ * @param {Object|null} [input.metadata] - Plain data object from the
38
+ * `role: 'metadata'` registration (title, author, isbn, …).
39
+ * @param {Object} [options]
40
+ * @param {'html'|'server'} [options.mode='html']
41
+ * @param {Object} [options.meta] - Additional metadata to merge over the
42
+ * `metadata` registration (options.meta wins).
43
+ * @param {string} [options.stylesheet] - Foundation-supplied CSS string.
44
+ * When omitted, DEFAULT_STYLESHEET is used.
45
+ * @param {string} [options.polyfillUrl] - Override Paged.js polyfill URL.
46
+ * @param {string} [options.endpoint] - Server-mode endpoint.
47
+ * @returns {Promise<Blob>}
48
+ */
49
+ export async function compilePagedjs(input, options = {}) {
50
+ const { mode = 'html', meta = {}, stylesheet, polyfillUrl, ...rest } = options
51
+
52
+ const sections = (input && input.sections) || []
53
+ const body = sections.join('\n')
54
+ const resolvedMeta = { ...(input?.metadata || {}), ...meta }
55
+ const doc = emitDocument({
56
+ body,
57
+ meta: resolvedMeta,
58
+ stylesheet,
59
+ polyfillUrl,
60
+ })
61
+
62
+ if (mode === 'html') {
63
+ return new Blob([doc], { type: 'text/html; charset=utf-8' })
64
+ }
65
+
66
+ if (mode === 'server') {
67
+ return compileServerSide(doc, rest)
68
+ }
69
+
70
+ throw new Error(
71
+ `pagedjs adapter: unknown mode "${mode}". ` +
72
+ `Valid modes: 'html' (returns paged-ready HTML), 'server' (POSTs HTML to endpoint, receives PDF).`,
73
+ )
74
+ }
75
+
76
+ // ============================================================================
77
+ // Server mode
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Server mode: POST the assembled HTML to an endpoint that runs headless
82
+ * Chromium + Paged.js and returns a PDF. The endpoint is provided via
83
+ * options.endpoint; the default matches the (future) Vite dev plugin at
84
+ * /__press/pagedjs/compile.
85
+ *
86
+ * Wire protocol: multipart/form-data with one field named `document.html`
87
+ * whose value is a Blob of text/html. Response is application/pdf.
88
+ */
89
+ async function compileServerSide(htmlDoc, options = {}) {
90
+ const endpoint = options.endpoint || '/__press/pagedjs/compile'
91
+
92
+ const form = new FormData()
93
+ form.append(
94
+ 'document.html',
95
+ new Blob([htmlDoc], { type: 'text/html' }),
96
+ 'document.html',
97
+ )
98
+
99
+ let res
100
+ try {
101
+ res = await fetch(endpoint, { method: 'POST', body: form })
102
+ } catch (err) {
103
+ throw new Error(
104
+ `pagedjs adapter (server mode): request to ${endpoint} failed. ` +
105
+ `Is the dev server (or your compile endpoint) running? ` +
106
+ `Original error: ${err.message || err}`,
107
+ )
108
+ }
109
+
110
+ if (!res.ok) {
111
+ const text = await res.text().catch(() => '(no body)')
112
+ throw new Error(
113
+ `pagedjs adapter (server mode): ${endpoint} returned ${res.status} ${res.statusText}.\n${text}`,
114
+ )
115
+ }
116
+
117
+ const blob = await res.blob()
118
+ return blob.type === 'application/pdf'
119
+ ? blob
120
+ : new Blob([await blob.arrayBuffer()], { type: 'application/pdf' })
121
+ }
122
+
123
+ // ============================================================================
124
+ // HTML document assembly
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Build the full HTML document. Exported for tests and for foundations that
129
+ * want to inspect the emitted document before handing it to a browser.
130
+ */
131
+ export function emitDocument({ body = '', meta = {}, stylesheet, polyfillUrl } = {}) {
132
+ const lang = meta.language ?? 'en'
133
+ const title = meta.title ?? 'Book'
134
+ const css = stylesheet || DEFAULT_STYLESHEET
135
+ const polyfill = polyfillUrl || DEFAULT_POLYFILL_URL
136
+
137
+ return `<!doctype html>
138
+ <html lang="${escapeAttr(lang)}">
139
+ <head>
140
+ <meta charset="utf-8" />
141
+ <title>${escapeHtml(title)}</title>
142
+ ${emitMetaTags(meta)} <style>${css}</style>
143
+ <script src="${escapeAttr(polyfill)}" defer></script>
144
+ </head>
145
+ <body>
146
+ ${emitMetadataBlock(meta)}${body}
147
+ </body>
148
+ </html>`
149
+ }
150
+
151
+ /**
152
+ * Emit a small set of standard `<meta>` tags for common metadata fields
153
+ * (author, description, subject). Kept minimal; not all registered fields
154
+ * translate to HTML meta tags cleanly, so the full set lives in the hidden
155
+ * metadata block below.
156
+ */
157
+ function emitMetaTags(meta) {
158
+ const out = []
159
+ if (meta?.author) {
160
+ out.push(` <meta name="author" content="${escapeAttr(meta.author)}" />`)
161
+ }
162
+ if (meta?.description) {
163
+ out.push(
164
+ ` <meta name="description" content="${escapeAttr(meta.description)}" />`,
165
+ )
166
+ }
167
+ if (meta?.subject) {
168
+ out.push(
169
+ ` <meta name="subject" content="${escapeAttr(meta.subject)}" />`,
170
+ )
171
+ }
172
+ return out.length ? out.join('\n') + '\n' : ''
173
+ }
174
+
175
+ /**
176
+ * Emit a hidden `<div data-pagedjs-metadata>` block with inline `<span>`
177
+ * elements for each metadata field. CSS `string-set: var-name content()`
178
+ * can then pull the values into `@page` margin boxes via `string()`.
179
+ *
180
+ * The block is hidden by default in the DEFAULT_STYLESHEET (and any
181
+ * reasonable replacement should do the same) but remains in the DOM so
182
+ * Paged.js / authors can reference it.
183
+ */
184
+ function emitMetadataBlock(meta) {
185
+ if (!meta || typeof meta !== 'object') return ''
186
+ const entries = Object.entries(meta).filter(
187
+ ([, v]) => v != null && typeof v !== 'object',
188
+ )
189
+ if (!entries.length) return ''
190
+ const spans = entries
191
+ .map(
192
+ ([key, value]) =>
193
+ ` <span data-field="${escapeAttr(key)}">${escapeHtml(String(value))}</span>`,
194
+ )
195
+ .join('\n')
196
+ return ` <div data-pagedjs-metadata hidden>\n${spans}\n </div>\n`
197
+ }
198
+
199
+ // ============================================================================
200
+ // Escape helpers
201
+ // ============================================================================
202
+
203
+ function escapeHtml(s) {
204
+ return String(s)
205
+ .replace(/&/g, '&amp;')
206
+ .replace(/</g, '&lt;')
207
+ .replace(/>/g, '&gt;')
208
+ }
209
+
210
+ function escapeAttr(s) {
211
+ return String(s)
212
+ .replace(/&/g, '&amp;')
213
+ .replace(/"/g, '&quot;')
214
+ .replace(/</g, '&lt;')
215
+ .replace(/>/g, '&gt;')
216
+ }
217
+
218
+ // ============================================================================
219
+ // Defaults
220
+ // ============================================================================
221
+
222
+ /**
223
+ * Pinned Paged.js polyfill URL. Pinning the version keeps renders stable
224
+ * across rebuilds — bump deliberately when validating a new Paged.js release.
225
+ */
226
+ export const DEFAULT_POLYFILL_URL =
227
+ 'https://unpkg.com/pagedjs@0.4.3/dist/paged.polyfill.js'
228
+
229
+ /**
230
+ * Minimal CSS Paged Media stylesheet — enough to produce a recognizable
231
+ * book PDF without a custom foundation stylesheet. Foundations override by
232
+ * passing `options.stylesheet`.
233
+ */
234
+ export const DEFAULT_STYLESHEET = `
235
+ /* Hidden metadata block — the DOM carries it for CSS string-set / string() */
236
+ [data-pagedjs-metadata] { display: none; }
237
+
238
+ /* Page geometry — 6×9 US trade size */
239
+ @page {
240
+ size: 6in 9in;
241
+ margin: 0.75in 0.5in 0.75in 0.75in;
242
+ }
243
+ @page :left { margin: 0.75in 0.75in 0.75in 0.5in; }
244
+ @page :right { margin: 0.75in 0.5in 0.75in 0.75in; }
245
+
246
+ /* Named string for running headers */
247
+ h1 { string-set: chapter content(); }
248
+ @page :left { @top-left { content: string(chapter); font-size: 9pt; color: #666; } }
249
+ @page :right { @top-right { content: string(chapter); font-size: 9pt; color: #666; } }
250
+
251
+ /* Page number footer */
252
+ @page :left { @bottom-left { content: counter(page); font-size: 9pt; } }
253
+ @page :right { @bottom-right { content: counter(page); font-size: 9pt; } }
254
+
255
+ /* Chapter openers: start on recto */
256
+ h1 {
257
+ break-before: recto;
258
+ page: chapter-opener;
259
+ font-size: 22pt;
260
+ margin-top: 2in;
261
+ }
262
+ @page chapter-opener {
263
+ @top-left { content: none; }
264
+ @top-right { content: none; }
265
+ }
266
+
267
+ /* Body typography */
268
+ body {
269
+ font-family: Georgia, "Times New Roman", serif;
270
+ font-size: 11pt;
271
+ line-height: 1.45;
272
+ hyphens: auto;
273
+ }
274
+ p { margin: 0 0 0.75em; text-indent: 1.25em; }
275
+ p.lead, p:first-child { text-indent: 0; }
276
+ h2 { font-size: 14pt; margin: 1.5em 0 0.5em; page-break-after: avoid; }
277
+ h3 { font-size: 12pt; margin: 1.2em 0 0.4em; page-break-after: avoid; }
278
+ code { font-family: ui-monospace, Menlo, monospace; font-size: 0.92em; }
279
+ pre {
280
+ font-size: 9.5pt;
281
+ padding: 0.8em;
282
+ background: #f5f5f5;
283
+ overflow: hidden;
284
+ page-break-inside: avoid;
285
+ }
286
+ blockquote {
287
+ border-left: 3px solid #ccc;
288
+ margin: 1em 0;
289
+ padding: 0 0 0 1em;
290
+ color: #444;
291
+ font-style: italic;
292
+ }
293
+ ul, ol { margin: 0.5em 0 0.75em 1.5em; }
294
+ figure { break-inside: avoid; }
295
+ `
@@ -98,7 +98,20 @@ async function compileServerSide(bundle, options = {}) {
98
98
 
99
99
  const form = new FormData()
100
100
  for (const [name, contents] of Object.entries(bundle)) {
101
- form.append(name, new Blob([contents], { type: 'text/plain' }), name)
101
+ if (contents instanceof Blob) {
102
+ form.append(name, contents, name)
103
+ } else if (
104
+ contents instanceof Uint8Array ||
105
+ contents instanceof ArrayBuffer
106
+ ) {
107
+ form.append(
108
+ name,
109
+ new Blob([contents], { type: 'application/octet-stream' }),
110
+ name,
111
+ )
112
+ } else {
113
+ form.append(name, new Blob([contents], { type: 'text/plain' }), name)
114
+ }
102
115
  }
103
116
 
104
117
  let res
@@ -145,6 +158,7 @@ export function buildBundle(input, options = {}) {
145
158
  preamble = DEFAULT_PREAMBLE,
146
159
  template = DEFAULT_TEMPLATE,
147
160
  meta: metaOverride = {},
161
+ assets = {}, // extra bundle files (path → Uint8Array / Blob / string)
148
162
  } = options
149
163
 
150
164
  // Merge: metadata role (document-level) + options.meta (call-site override)
@@ -156,6 +170,7 @@ export function buildBundle(input, options = {}) {
156
170
  'content.typ': emitContent(sections),
157
171
  'preamble.typ': preamble,
158
172
  'template.typ': template,
173
+ ...assets,
159
174
  }
160
175
  }
161
176
 
@@ -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
+ }