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.
- package/CHANGELOG.md +78 -0
- package/README.md +0 -1
- package/dist/doc-browser.js +35 -0
- package/dist/doc-system/book-html.d.ts +26 -0
- package/dist/doc-system/book-html.js +129 -0
- package/dist/doc-system/doc-system.js +38 -0
- package/dist/doc-system/render.d.ts +0 -2
- package/dist/doc-system/render.js +31 -9
- package/dist/doc-system/site/epub.d.ts +50 -0
- package/dist/doc-system/site/epub.js +511 -0
- package/dist/doc-system/site/index.d.ts +4 -0
- package/dist/doc-system/site/index.js +2 -0
- package/dist/doc-system/site/orchestrator.js +7 -0
- package/dist/doc-system/site/pdf.d.ts +17 -0
- package/dist/doc-system/site/pdf.js +48 -0
- package/dist/doc-system/site/site-config.d.ts +15 -0
- package/dist/float.js +33 -22
- package/dist/iife.js +172 -89
- package/dist/iife.js.map +13 -12
- package/dist/live-example/insert-examples.js +8 -0
- package/dist/menu.js +32 -15
- package/dist/size-break.js +2 -2
- package/dist/tooltip.js +14 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +40 -1
|
@@ -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, '<')
|
|
34
|
+
.replace(/>/g, '>')
|
|
35
|
+
.replace(/"/g, '"')
|
|
36
|
+
.replace(/'/g, ''');
|
|
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, '&');
|
|
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 (`™`), 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
63
|
+
}
|
|
64
|
+
function escapeXmlAttr(s) {
|
|
65
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
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 */
|