tosijs-ui 1.6.11 → 1.6.13

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,511 @@
1
+ /*
2
+ Build-time ePub (EPUB 3) generator for the doc system.
3
+
4
+ Walks the same extracted corpus the static site uses and emits a valid .epub:
5
+ one XHTML chapter per doc (in nav-tree order), a readable Contents page in the
6
+ spine (after the cover) plus the machine navigation (EPUB3 `nav.xhtml` +
7
+ EPUB2 `toc.ncx` fallback) that powers the reader's Contents drawer, a
8
+ customizable stylesheet, and the package document. Live examples are NOT executed — their fenced blocks
9
+ render as pretty-printed, force-wrapped code listings (a book has no JS).
10
+
11
+ The one ePub gotcha that breaks readers/validators is the zip layout: the
12
+ `mimetype` entry must be first and STORED (uncompressed). We get that for free
13
+ with the canonical `zip -X0` / `zip -Xr9D` two-step (see `zipEpub`).
14
+
15
+ Build-time only (Bun APIs + the `zip` CLI); never import from browser code.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+ import { renderDocMarkdown } from '../render';
20
+ import { buildSlugMap, pathForSlug } from '../routing';
21
+ import { buildNavTree } from '../nav-tree';
22
+ import { DEFAULT_BOOK_CSS, stripDocMeta, flatten, slugify } from '../book-html';
23
+ // Re-exported for back-compat (tosijs-ui/site's public surface + tests).
24
+ export { DEFAULT_BOOK_CSS, stripDocMeta };
25
+ // ── XML / XHTML helpers ─────────────────────────────────────────────────────
26
+ const VOID_ELEMENTS = [
27
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
28
+ 'param', 'source', 'track', 'wbr',
29
+ ];
30
+ export function escapeXml(s) {
31
+ return s
32
+ .replace(/&/g, '&')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;')
35
+ .replace(/"/g, '&quot;')
36
+ .replace(/'/g, '&#39;');
37
+ }
38
+ /**
39
+ * Best-effort normalize marked's HTML output to well-formed XHTML: self-close
40
+ * void elements and escape bare `&` that isn't already part of an entity.
41
+ */
42
+ export function toXhtml(html) {
43
+ let s = html;
44
+ for (const tag of VOID_ELEMENTS) {
45
+ s = s.replace(new RegExp(`<${tag}\\b([^>]*?)\\s*/?\\s*>`, 'gi'), (_m, attrs) => {
46
+ const a = attrs.replace(/\/\s*$/, '').trim();
47
+ return `<${tag}${a ? ' ' + a : ''}/>`;
48
+ });
49
+ }
50
+ // escape stray ampersands not part of a named/numeric entity
51
+ s = s.replace(/&(?!#?[a-zA-Z0-9]+;)/g, '&amp;');
52
+ return s;
53
+ }
54
+ // ── Robust HTML→XHTML via a real parser (happy-dom) ─────────────────────────
55
+ // marked's output (plus raw HTML in docs) routinely isn't well-formed XML —
56
+ // unquoted attributes (`size=256`), HTML named entities (`&trade;`), prose that
57
+ // looks like a tag (`Set<Foo>`). A real HTML parser fixes all of it; we walk the
58
+ // resulting DOM and re-emit strict XML. Falls back to the regex pass above when
59
+ // happy-dom isn't installed.
60
+ const VOID_SET = new Set(VOID_ELEMENTS);
61
+ function escapeXmlText(s) {
62
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
63
+ }
64
+ function escapeXmlAttr(s) {
65
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
66
+ }
67
+ function serializeXml(node) {
68
+ const TEXT = 3;
69
+ const CDATA = 4;
70
+ const COMMENT = 8;
71
+ const ELEMENT = 1;
72
+ switch (node.nodeType) {
73
+ case TEXT:
74
+ case CDATA:
75
+ return escapeXmlText(node.data ?? node.textContent ?? '');
76
+ case COMMENT:
77
+ return `<!--${String(node.data ?? '').replace(/--/g, '- -')}-->`;
78
+ case ELEMENT: {
79
+ const tag = String(node.tagName).toLowerCase();
80
+ // drop script/style (meaningless and unsafe in a book)
81
+ if (tag === 'script' || tag === 'style')
82
+ return '';
83
+ const attrs = Array.from(node.attributes ?? [])
84
+ .map((a) => ` ${a.name}="${escapeXmlAttr(String(a.value))}"`)
85
+ .join('');
86
+ const kids = Array.from(node.childNodes ?? []);
87
+ if (kids.length === 0 && VOID_SET.has(tag))
88
+ return `<${tag}${attrs}/>`;
89
+ return `<${tag}${attrs}>${kids.map(serializeXml).join('')}</${tag}>`;
90
+ }
91
+ default:
92
+ return '';
93
+ }
94
+ }
95
+ let parserOnce;
96
+ /** Lazily load happy-dom's Window (optional — book build only). */
97
+ function loadHtmlParser() {
98
+ return (parserOnce ??= import('happy-dom')
99
+ .then((m) => m.Window)
100
+ .catch(() => null));
101
+ }
102
+ /**
103
+ * Parse HTML in a real parser and re-emit strict XML, using a reused window. An
104
+ * optional `transform` runs on the parsed document before serialization (used to
105
+ * inject the per-example "run this live" links).
106
+ */
107
+ function htmlToXhtml(html, win, transform) {
108
+ win.document.body.innerHTML = html;
109
+ if (transform)
110
+ transform(win.document);
111
+ return Array.from(win.document.body.childNodes).map(serializeXml).join('');
112
+ }
113
+ // ── Live-example deep links ─────────────────────────────────────────────────
114
+ // Each example in the book gets a link to its anchored spot on the live site, so
115
+ // a reader is one tap from the real, interactive, editable version. The grouping
116
+ // + id derivation MUST match insertExamples (same rendered DOM, same rule) so the
117
+ // links line up: consecutive example-language <pre> siblings form one example;
118
+ // id = an author `data-example-id` override (```js#my-id) or the 1-based `example-N`.
119
+ const EXAMPLE_LANGS = new Set(['js', 'tjs', 'ts', 'html', 'css', 'test']);
120
+ // NB: traverse the DOM by hand (children / tagName / nextElementSibling) rather
121
+ // than querySelector — happy-dom's selector engine throws an internal error on a
122
+ // reused build-time window, even for a trivial selector like `pre`.
123
+ function exampleLangOf(pre) {
124
+ let code = null;
125
+ for (const c of Array.from(pre.children || [])) {
126
+ if (c.tagName === 'CODE') {
127
+ code = c;
128
+ break;
129
+ }
130
+ }
131
+ const cls = (code && code.getAttribute('class')) || '';
132
+ const m = cls.match(/language-([\w-]+)/);
133
+ return m && EXAMPLE_LANGS.has(m[1]) ? m[1] : null;
134
+ }
135
+ /** Collect example <pre> blocks in document order (any depth), no selectors. */
136
+ function collectExamplePres(el, out) {
137
+ for (const child of Array.from(el.children || [])) {
138
+ if (child.tagName === 'PRE') {
139
+ if (exampleLangOf(child))
140
+ out.push(child);
141
+ }
142
+ else {
143
+ collectExamplePres(child, out);
144
+ }
145
+ }
146
+ }
147
+ /** Prefix a root-relative path with basePath (mirrors generate-site's withBase). */
148
+ function withBase(basePath, p) {
149
+ if (!p || !basePath || basePath === '/' || /^(https?:)?\/\//.test(p))
150
+ return p;
151
+ return basePath.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
152
+ }
153
+ // `pageUrl` is the live-site URL of THIS doc (already including baseUrl, basePath
154
+ // and the correct root for README → '/'); each example link just appends `#id`.
155
+ function injectExampleLinks(doc, pageUrl) {
156
+ const pres = [];
157
+ collectExamplePres(doc.body, pres);
158
+ let ordinal = 0;
159
+ for (let i = 0; i < pres.length; i += 1) {
160
+ const group = [pres[i]];
161
+ while (i < pres.length - 1 &&
162
+ pres[i].nextElementSibling === pres[i + 1]) {
163
+ group.push(pres[i + 1]);
164
+ i += 1;
165
+ }
166
+ const overrideId = group
167
+ .map((p) => p.getAttribute('data-example-id'))
168
+ .find((v) => !!v);
169
+ const id = overrideId || `example-${ordinal + 1}`;
170
+ ordinal += 1;
171
+ const p = doc.createElement('p');
172
+ p.setAttribute('class', 'example-live-link');
173
+ const a = doc.createElement('a');
174
+ a.setAttribute('href', `${pageUrl}#${id}`);
175
+ a.textContent = '▶ Run this example live ↗';
176
+ p.appendChild(a);
177
+ group[0].parentNode.insertBefore(p, group[0]);
178
+ }
179
+ }
180
+ function xhtmlPage(title, bodyHtml) {
181
+ return `<?xml version="1.0" encoding="utf-8"?>
182
+ <!DOCTYPE html>
183
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
184
+ <head>
185
+ <meta charset="utf-8"/>
186
+ <title>${escapeXml(title)}</title>
187
+ <link rel="stylesheet" type="text/css" href="style.css"/>
188
+ </head>
189
+ <body>
190
+ ${bodyHtml}
191
+ </body>
192
+ </html>
193
+ `;
194
+ }
195
+ function containerXml() {
196
+ return `<?xml version="1.0" encoding="utf-8"?>
197
+ <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
198
+ <rootfiles>
199
+ <rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/>
200
+ </rootfiles>
201
+ </container>
202
+ `;
203
+ }
204
+ function packageOpf(meta, chapters, cover) {
205
+ const manifestItems = [
206
+ `<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
207
+ `<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`,
208
+ `<item id="css" href="style.css" media-type="text/css"/>`,
209
+ `<item id="toc-page" href="contents.xhtml" media-type="application/xhtml+xml"/>`,
210
+ ...(cover
211
+ ? [
212
+ `<item id="cover-image" href="${cover.file}" media-type="${cover.mediaType}" properties="cover-image"/>`,
213
+ `<item id="cover-page" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
214
+ ]
215
+ : []),
216
+ ...chapters.map((c) => `<item id="${c.id}" href="${c.href}" media-type="application/xhtml+xml"/>`),
217
+ ];
218
+ const spine = [
219
+ ...(cover ? [`<itemref idref="cover-page"/>`] : []),
220
+ `<itemref idref="toc-page"/>`,
221
+ ...chapters.map((c) => `<itemref idref="${c.id}"/>`),
222
+ ];
223
+ // EPUB2 cover fallback (older readers find the thumbnail via this meta).
224
+ const coverMeta = cover ? `\n <meta name="cover" content="cover-image"/>` : '';
225
+ return `<?xml version="1.0" encoding="utf-8"?>
226
+ <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">
227
+ <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
228
+ <dc:identifier id="book-id">${escapeXml(meta.identifier)}</dc:identifier>
229
+ <dc:title>${escapeXml(meta.title)}</dc:title>
230
+ <dc:language>${escapeXml(meta.language)}</dc:language>
231
+ <dc:creator>${escapeXml(meta.author)}</dc:creator>
232
+ <meta property="dcterms:modified">${meta.modified}</meta>${coverMeta}
233
+ </metadata>
234
+ <manifest>
235
+ ${manifestItems.join('\n ')}
236
+ </manifest>
237
+ <spine toc="ncx">
238
+ ${spine.join('\n ')}
239
+ </spine>
240
+ </package>
241
+ `;
242
+ }
243
+ function renderNavList(nodes, hrefFor) {
244
+ const items = nodes
245
+ .map((node) => {
246
+ const label = escapeXml(node.doc.title);
247
+ const link = `<a href="${hrefFor(node.doc)}">${label}</a>`;
248
+ const kids = node.children.length
249
+ ? `\n<ol>\n${renderNavList(node.children, hrefFor)}\n</ol>\n`
250
+ : '';
251
+ return `<li>${link}${kids}</li>`;
252
+ })
253
+ .join('\n');
254
+ return items;
255
+ }
256
+ /**
257
+ * A readable "Contents" page that sits IN the reading flow (spine), right after
258
+ * the cover. The EPUB3 `nav.xhtml` only powers the reader's Contents *drawer*;
259
+ * this is the visible TOC page a reader can actually page to (and what most
260
+ * people mean by "the book has no table of contents").
261
+ */
262
+ function tocPageXhtml(roots, hrefFor) {
263
+ const body = `<h1 class="toc-title">Contents</h1>
264
+ <ol class="toc">
265
+ ${renderNavList(roots, hrefFor)}
266
+ </ol>`;
267
+ return xhtmlPage('Contents', body);
268
+ }
269
+ function navXhtml(meta, roots, hrefFor) {
270
+ return `<?xml version="1.0" encoding="utf-8"?>
271
+ <!DOCTYPE html>
272
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en" lang="en">
273
+ <head><meta charset="utf-8"/><title>${escapeXml(meta.title)}</title></head>
274
+ <body>
275
+ <nav epub:type="toc" id="toc">
276
+ <h1>${escapeXml(meta.title)}</h1>
277
+ <ol>
278
+ ${renderNavList(roots, hrefFor)}
279
+ </ol>
280
+ </nav>
281
+ </body>
282
+ </html>
283
+ `;
284
+ }
285
+ function renderNavPoints(nodes, hrefFor, counter) {
286
+ return nodes
287
+ .map((node) => {
288
+ const id = `np-${++counter.n}`;
289
+ const kids = node.children.length
290
+ ? '\n' + renderNavPoints(node.children, hrefFor, counter)
291
+ : '';
292
+ return `<navPoint id="${id}" playOrder="${counter.n}">
293
+ <navLabel><text>${escapeXml(node.doc.title)}</text></navLabel>
294
+ <content src="${hrefFor(node.doc)}"/>${kids}
295
+ </navPoint>`;
296
+ })
297
+ .join('\n');
298
+ }
299
+ function tocNcx(meta, roots, hrefFor) {
300
+ return `<?xml version="1.0" encoding="utf-8"?>
301
+ <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
302
+ <head>
303
+ <meta name="dtb:uid" content="${escapeXml(meta.identifier)}"/>
304
+ <meta name="dtb:depth" content="2"/>
305
+ <meta name="dtb:totalPageCount" content="0"/>
306
+ <meta name="dtb:maxPageNumber" content="0"/>
307
+ </head>
308
+ <docTitle><text>${escapeXml(meta.title)}</text></docTitle>
309
+ <navMap>
310
+ ${renderNavPoints(roots, hrefFor, { n: 0 })}
311
+ </navMap>
312
+ </ncx>
313
+ `;
314
+ }
315
+ // ── Orchestration ───────────────────────────────────────────────────────────
316
+ /** Two-step zip that guarantees mimetype is first + STORED (the ePub gotcha). */
317
+ async function zipEpub(buildDir, outputAbs) {
318
+ const $ = Bun.$;
319
+ await $ `rm -f ${outputAbs}`.quiet();
320
+ // 1) mimetype first, stored (-0), no extra fields (-X)
321
+ await $ `zip -X0 ${outputAbs} mimetype`.cwd(buildDir).quiet();
322
+ // 2) everything else, deflated (-9), recursive (-r), no extra fields, no dir entries (-D)
323
+ await $ `zip -Xr9D ${outputAbs} META-INF OEBPS -x mimetype`.cwd(buildDir).quiet();
324
+ }
325
+ /** Rasterize an SVG to a PNG buffer via @resvg/resvg-js (optional dep). */
326
+ async function rasterizeSvg(svg, width) {
327
+ try {
328
+ const { Resvg } = (await import('@resvg/resvg-js'));
329
+ const resvg = new Resvg(svg, {
330
+ font: { loadSystemFonts: true, defaultFontFamily: 'Helvetica' },
331
+ fitTo: { mode: 'width', value: width },
332
+ });
333
+ return Buffer.from(resvg.render().asPng());
334
+ }
335
+ catch {
336
+ return null;
337
+ }
338
+ }
339
+ /** Greedy word-wrap into at most `maxLines` lines of ~`maxChars` each. */
340
+ function wrapTitle(title, maxChars, maxLines) {
341
+ const lines = [];
342
+ let current = '';
343
+ for (const word of title.split(/\s+/)) {
344
+ if (current && (current + ' ' + word).length > maxChars) {
345
+ lines.push(current);
346
+ current = word;
347
+ }
348
+ else {
349
+ current = current ? current + ' ' + word : word;
350
+ }
351
+ }
352
+ if (current)
353
+ lines.push(current);
354
+ return lines.slice(0, maxLines);
355
+ }
356
+ /** A 600×800 cover SVG: brand background, favicon, title, author. */
357
+ function coverSvg(title, author, faviconInner, bg) {
358
+ const W = 600;
359
+ const H = 800;
360
+ const titleLines = wrapTitle(title, 16, 4);
361
+ const fontSize = titleLines.length > 2 ? 44 : 56;
362
+ const block = (titleLines.length - 1) * fontSize * 1.2;
363
+ const startY = 540 - block / 2;
364
+ const titleText = titleLines
365
+ .map((line, i) => `<text x="${W / 2}" y="${startY + i * fontSize * 1.2}" text-anchor="middle" ` +
366
+ `font-family="Helvetica,Arial,sans-serif" font-size="${fontSize}" font-weight="bold" ` +
367
+ `fill="#ffffff">${escapeXml(line)}</text>`)
368
+ .join('\n ');
369
+ const icon = faviconInner
370
+ ? `<svg x="190" y="150" width="220" height="220" viewBox="0 0 48 48">${faviconInner}</svg>`
371
+ : '';
372
+ const authorText = author
373
+ ? `<text x="${W / 2}" y="710" text-anchor="middle" font-family="Helvetica,Arial,sans-serif" ` +
374
+ `font-size="26" fill="#ffffffcc">${escapeXml(author)}</text>`
375
+ : '';
376
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
377
+ <rect width="${W}" height="${H}" fill="${bg}"/>
378
+ ${icon}
379
+ ${titleText}
380
+ ${authorText}
381
+ </svg>`;
382
+ }
383
+ /** Cover page (first in spine) showing the cover image full-bleed. */
384
+ function coverPageXhtml(title, coverFile) {
385
+ return `<?xml version="1.0" encoding="utf-8"?>
386
+ <!DOCTYPE html>
387
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
388
+ <head>
389
+ <meta charset="utf-8"/>
390
+ <title>Cover</title>
391
+ <style>html,body{margin:0;padding:0;height:100%}
392
+ .cover{display:flex;align-items:center;justify-content:center;height:100vh}
393
+ .cover img{max-width:100%;max-height:100%}</style>
394
+ </head>
395
+ <body><div class="cover"><img src="${coverFile}" alt="${escapeXml(title)} cover"/></div></body>
396
+ </html>
397
+ `;
398
+ }
399
+ /**
400
+ * Resolve the cover: an explicit image (opts.cover) if given, else a generated
401
+ * one from the title + site favicon. Returns null if neither is available.
402
+ */
403
+ async function makeCover(config, opts, meta) {
404
+ if (opts.cover && fs.existsSync(opts.cover)) {
405
+ const ext = path.extname(opts.cover).toLowerCase();
406
+ const mediaType = ext === '.jpg' || ext === '.jpeg'
407
+ ? 'image/jpeg'
408
+ : ext === '.gif'
409
+ ? 'image/gif'
410
+ : 'image/png';
411
+ return { file: `cover${ext || '.png'}`, mediaType, data: fs.readFileSync(opts.cover) };
412
+ }
413
+ // Embed the favicon (svg) into the generated cover, if available.
414
+ let faviconInner = null;
415
+ const fav = config.favicon ?? '/favicon.svg';
416
+ if (fav.endsWith('.svg')) {
417
+ const favPath = path.resolve(config.outputDir ?? 'docs', fav.replace(/^\//, ''));
418
+ if (fs.existsSync(favPath)) {
419
+ faviconInner = fs
420
+ .readFileSync(favPath, 'utf8')
421
+ .replace(/<\?xml[^>]*\?>/, '')
422
+ .replace(/<svg[^>]*>/, '')
423
+ .replace(/<\/svg>\s*$/, '')
424
+ .trim();
425
+ }
426
+ }
427
+ const svg = coverSvg(meta.title, meta.author, faviconInner, opts.coverColor ?? '#1f2933');
428
+ const png = await rasterizeSvg(svg, 600);
429
+ if (!png)
430
+ return null;
431
+ return { file: 'cover.png', mediaType: 'image/png', data: png };
432
+ }
433
+ /**
434
+ * Build an EPUB 3 book from the extracted corpus. Returns the output path.
435
+ */
436
+ export async function buildEpub(config, opts = {}) {
437
+ const docsJson = opts.docsJson ?? config.docsJson ?? 'demo/docs.json';
438
+ const docs = JSON.parse(fs.readFileSync(docsJson, 'utf8')).filter((d) => !d.hidden);
439
+ const slugMap = buildSlugMap(docs);
440
+ const roots = buildNavTree(docs, slugMap);
441
+ const fileFor = (d) => `${slugMap[d.filename] || 'index'}.xhtml`;
442
+ const meta = {
443
+ title: opts.title ?? config.name,
444
+ author: opts.author ?? config.name,
445
+ language: opts.language ?? config.lang ?? 'en',
446
+ identifier: config.baseUrl || `urn:tosijs-book:${slugify(opts.title ?? config.name)}`,
447
+ modified: (opts.modified ?? new Date().toISOString()).replace(/\.\d+Z$/, 'Z'),
448
+ };
449
+ const css = opts.css ?? DEFAULT_BOOK_CSS + (opts.extraCss ? '\n' + opts.extraCss : '');
450
+ // Stage the book in a temp dir, then zip it.
451
+ const outDir = config.outputDir ?? 'docs';
452
+ const buildDir = path.resolve(outDir, '.epub-build');
453
+ fs.rmSync(buildDir, { recursive: true, force: true });
454
+ fs.mkdirSync(path.join(buildDir, 'META-INF'), { recursive: true });
455
+ fs.mkdirSync(path.join(buildDir, 'OEBPS'), { recursive: true });
456
+ fs.writeFileSync(path.join(buildDir, 'mimetype'), 'application/epub+zip');
457
+ fs.writeFileSync(path.join(buildDir, 'META-INF', 'container.xml'), containerXml());
458
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', 'style.css'), css);
459
+ // Prefer a real HTML parser (happy-dom) for strict XHTML; regex fallback.
460
+ const WindowClass = await loadHtmlParser();
461
+ const win = WindowClass ? new WindowClass() : null;
462
+ if (!win) {
463
+ console.warn('epub: happy-dom not available — falling back to regex XHTML (some chapters ' +
464
+ 'with raw HTML may not be strictly well-formed). `npm i -D happy-dom` to fix.');
465
+ }
466
+ // One XHTML chapter per doc, in spine order. When a baseUrl is configured, each
467
+ // example links back to its anchor on the live site.
468
+ const baseUrl = config.baseUrl?.replace(/\/+$/, '');
469
+ const chapters = [];
470
+ for (const node of flatten(roots)) {
471
+ const doc = node.doc;
472
+ // The doc's live-site URL, matching generate-site's canonical link: README
473
+ // maps to '/' (not '/index/'), others to '/slug/', with basePath applied.
474
+ const pageUrl = baseUrl + withBase(config.basePath, pathForSlug(slugMap[doc.filename] ?? ''));
475
+ const html = renderDocMarkdown(stripDocMeta(doc.text));
476
+ // happy-dom occasionally throws on exotic content (e.g. an internal selector
477
+ // bug); fall back to the regex pass for that doc rather than aborting.
478
+ let bodyHtml;
479
+ try {
480
+ const transform = win && baseUrl
481
+ ? (d) => injectExampleLinks(d, pageUrl)
482
+ : undefined;
483
+ bodyHtml = win ? htmlToXhtml(html, win, transform) : toXhtml(html);
484
+ }
485
+ catch {
486
+ bodyHtml = toXhtml(html);
487
+ }
488
+ const file = fileFor(doc);
489
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', file), xhtmlPage(doc.title, bodyHtml));
490
+ chapters.push({
491
+ id: `ch-${slugMap[doc.filename] || 'index'}`,
492
+ href: file,
493
+ title: doc.title,
494
+ });
495
+ }
496
+ // Cover (explicit image, or generated from the title + favicon).
497
+ const cover = await makeCover(config, opts, meta);
498
+ if (cover) {
499
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', cover.file), cover.data);
500
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', 'cover.xhtml'), coverPageXhtml(meta.title, cover.file));
501
+ }
502
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', 'package.opf'), packageOpf(meta, chapters, cover));
503
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', 'contents.xhtml'), tocPageXhtml(roots, fileFor));
504
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', 'nav.xhtml'), navXhtml(meta, roots, fileFor));
505
+ fs.writeFileSync(path.join(buildDir, 'OEBPS', 'toc.ncx'), tocNcx(meta, roots, fileFor));
506
+ const output = path.resolve(opts.output ?? path.join(outDir, `${slugify(meta.title)}.epub`));
507
+ await zipEpub(buildDir, output);
508
+ fs.rmSync(buildDir, { recursive: true, force: true });
509
+ console.log(`epub: ${output} (${chapters.length} chapters)`);
510
+ return output;
511
+ }
@@ -6,3 +6,7 @@ export { extractDocs, saveDocsJSON } from './docs';
6
6
  export type { Doc } from './docs';
7
7
  export { generateSite } from './generate-site';
8
8
  export type { GenerateSiteConfig } from './generate-site';
9
+ export { buildEpub, DEFAULT_BOOK_CSS } from './epub';
10
+ export type { BuildEpubOptions, BookMeta } from './epub';
11
+ export { buildPdf } from './pdf';
12
+ export type { BuildPdfOptions } from './pdf';
@@ -8,3 +8,5 @@ export { buildSite } from './orchestrator';
8
8
  export { devServer } from './dev-server';
9
9
  export { extractDocs, saveDocsJSON } from './docs';
10
10
  export { generateSite } from './generate-site';
11
+ export { buildEpub, DEFAULT_BOOK_CSS } from './epub';
12
+ export { buildPdf } from './pdf';
@@ -20,6 +20,7 @@ import { ensureSections } from './sections';
20
20
  import { generateLlmsTxt } from './make-llms-txt';
21
21
  import { generateSite } from './generate-site';
22
22
  import { findOutputDirOverlap } from './output-guard';
23
+ import { buildEpub } from './epub';
23
24
  // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
24
25
  // before interpolating into the require-shim detector below.
25
26
  const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -217,6 +218,12 @@ export async function buildSite(config) {
217
218
  },
218
219
  }, null, 2) + '\n');
219
220
  }
221
+ // Emit the ePub into the output dir on every build, so it stays in sync with
222
+ // the corpus and survives the `rm -rf <outputDir>` at the top of the NEXT build
223
+ // (otherwise a dev rebuild would silently drop it). Cheap (~0.4s).
224
+ if (config.epub) {
225
+ await buildEpub(config, typeof config.epub === 'object' ? config.epub : {});
226
+ }
220
227
  console.timeEnd('build');
221
228
  return true;
222
229
  }
@@ -0,0 +1,17 @@
1
+ import type { SiteConfig } from './site-config';
2
+ export interface BuildPdfOptions {
3
+ /** corpus path; default config.docsJson ?? 'demo/docs.json' */
4
+ docsJson?: string;
5
+ /** output .pdf path; default `${outputDir}/${slug(name)}.pdf` */
6
+ output?: string;
7
+ /** book title; default config.name */
8
+ title?: string;
9
+ /** override the whole stylesheet */
10
+ css?: string;
11
+ /** extra CSS appended to the default (ignored if `css` is set) */
12
+ extraCss?: string;
13
+ /** page format, default 'A4' */
14
+ format?: 'A4' | 'Letter';
15
+ }
16
+ /** Build a PDF of the doc site from the extracted corpus. Returns the path. */
17
+ export declare function buildPdf(config: SiteConfig, opts?: BuildPdfOptions): Promise<string>;
@@ -0,0 +1,48 @@
1
+ /*
2
+ Headless PDF generator for the doc system — prints the shared "book" HTML
3
+ (book-html.ts, the same one the doc-browser's Print button uses) to PDF via
4
+ headless Chromium (Playwright). For interactive use, prefer the in-browser Print
5
+ button — this exists for automated/CI PDF generation.
6
+
7
+ Build-time only; requires `playwright` (a dev dependency here).
8
+ */
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import { buildBookHtml, DEFAULT_BOOK_CSS, PRINT_CSS } from '../book-html';
12
+ function slugify(s) {
13
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
14
+ }
15
+ /** Build a PDF of the doc site from the extracted corpus. Returns the path. */
16
+ export async function buildPdf(config, opts = {}) {
17
+ const docsJson = opts.docsJson ?? config.docsJson ?? 'demo/docs.json';
18
+ const docs = JSON.parse(fs.readFileSync(docsJson, 'utf8'));
19
+ const title = opts.title ?? config.name;
20
+ const css = opts.css ??
21
+ `${DEFAULT_BOOK_CSS}\n${PRINT_CSS}${opts.extraCss ? '\n' + opts.extraCss : ''}`;
22
+ const html = buildBookHtml(docs, { title, css, lang: config.lang });
23
+ let chromium;
24
+ try {
25
+ ;
26
+ ({ chromium } = await import('playwright'));
27
+ }
28
+ catch {
29
+ throw new Error('PDF build needs Playwright. Install it (`npm i -D playwright && npx playwright install chromium`).');
30
+ }
31
+ const output = path.resolve(opts.output ?? path.join(config.outputDir ?? 'docs', `${slugify(title)}.pdf`));
32
+ const browser = await chromium.launch();
33
+ try {
34
+ const page = await browser.newPage();
35
+ await page.setContent(html, { waitUntil: 'load' });
36
+ await page.pdf({
37
+ path: output,
38
+ format: opts.format ?? 'A4',
39
+ printBackground: true,
40
+ displayHeaderFooter: false,
41
+ });
42
+ }
43
+ finally {
44
+ await browser.close();
45
+ }
46
+ console.log(`pdf: ${output}`);
47
+ return output;
48
+ }
@@ -109,6 +109,21 @@ export interface SiteConfig {
109
109
  * the file contents.
110
110
  */
111
111
  llmsTxt?: boolean | ((docs: Doc[]) => string);
112
+ /**
113
+ * Emit an ePub of the whole doc site into the output dir on every build (so it
114
+ * stays in sync with the corpus and is served alongside the static pages, e.g.
115
+ * for a "Download ePub" link). `true` uses defaults; pass options to customize.
116
+ * Default false. Requires `happy-dom` (dev dep) + the `zip` CLI.
117
+ */
118
+ epub?: boolean | {
119
+ author?: string;
120
+ title?: string;
121
+ css?: string;
122
+ /** cover image path; omit to generate one from the title + favicon */
123
+ cover?: string;
124
+ /** background color for the generated cover, default '#1f2933' */
125
+ coverColor?: string;
126
+ };
112
127
  /** served web-root output dir, default 'docs' */
113
128
  outputDir?: string;
114
129
  /** dev-server port, default 8787 */