tosijs-ui 1.16.9 → 1.16.10
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 +20 -0
- package/dist/doc-system/book-html.d.ts +7 -1
- package/dist/doc-system/book-html.js +5 -0
- package/dist/doc-system/doc-system.js +11 -3
- package/dist/doc-system/print-window.d.ts +28 -0
- package/dist/doc-system/print-window.js +45 -0
- package/dist/doc-system/site/epub-volumes.d.ts +32 -0
- package/dist/doc-system/site/epub-volumes.js +48 -0
- package/dist/doc-system/site/epub.d.ts +5 -0
- package/dist/doc-system/site/epub.js +14 -7
- package/dist/doc-system/site/generate-site.d.ts +5 -0
- package/dist/doc-system/site/generate-site.js +1 -0
- package/dist/doc-system/site/index.d.ts +1 -1
- package/dist/doc-system/site/index.js +1 -1
- package/dist/doc-system/site/orchestrator.js +9 -2
- package/dist/doc-system/site/site-config.d.ts +5 -0
- package/dist/iife.js +82 -80
- package/dist/iife.js.map +6 -6
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.10
|
|
4
|
+
|
|
5
|
+
Print now opens with the book cover.
|
|
6
|
+
|
|
7
|
+
- **Print as PDF has a cover.** The first page is the default volume's cover: your
|
|
8
|
+
`epub.cover` image, or the one generated from the title and `coverIcon`. The title page and
|
|
9
|
+
contents follow it. Print checks the image loads before using it, so a site without `epub`,
|
|
10
|
+
a build that wrote no cover, or a corpus with no default volume prints as it did before.
|
|
11
|
+
`epub: { printCover: false }` leaves the cover out of Print and keeps it in the ePub.
|
|
12
|
+
- **The cover is a file you can link.** Each ePub build writes its cover beside the book
|
|
13
|
+
(`my-project-cover.png`; an explicit cover keeps its own type), and `/epub-volumes.json`
|
|
14
|
+
gains `coverFilename` and `coverUrl` for every volume whose cover the build wrote. A volume
|
|
15
|
+
without a cover has neither field.
|
|
16
|
+
- A WebP or SVG `epub.cover` is now declared with its own media type in the ePub; it was
|
|
17
|
+
labelled `image/png`.
|
|
18
|
+
- `attachCovers`, `printCoverManifestUrl` and `VOLUME_MANIFEST` are exported from
|
|
19
|
+
`tosijs-ui/site` for a build that does not go through `buildSite`.
|
|
20
|
+
- A configured `epub.cover` that does not exist now prints a warning; the build still
|
|
21
|
+
generates a cover in its place, as before.
|
|
22
|
+
|
|
3
23
|
## 1.16.9
|
|
4
24
|
|
|
5
25
|
Two sets of asks from tjs-lang for *The TJS Programming Language*, a book that teaches
|
|
@@ -9,7 +9,7 @@ export interface BookDoc {
|
|
|
9
9
|
hidden?: boolean;
|
|
10
10
|
}
|
|
11
11
|
export declare const DEFAULT_BOOK_CSS = "/* doc-system book stylesheet */\nhtml { font-size: 100%; }\nbody {\n font-family: Georgia, 'Times New Roman', serif;\n line-height: 1.5;\n margin: 0 1em;\n color: #1a1a1a;\n}\nh1, h2, h3, h4, h5, h6 {\n font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif;\n line-height: 1.2;\n margin: 1.4em 0 0.5em;\n}\nh1 { font-size: 1.8em; }\nh2 { font-size: 1.4em; }\nh3 { font-size: 1.2em; }\np { margin: 0.6em 0; }\na { color: #08c; text-decoration: none; }\nul, ol { margin: 0.6em 0; padding-left: 1.4em; }\nblockquote {\n margin: 0.8em 0;\n padding: 0 0 0 1em;\n border-left: 3px solid #ccc;\n color: #555;\n}\nimg { max-width: 100%; height: auto; }\ntable { border-collapse: collapse; margin: 0.8em 0; font-size: 0.85em; }\nth, td { border: 1px solid #ccc; padding: 0.3em 0.6em; text-align: left; }\ncode, pre {\n font-family: 'SF Mono', Menlo, Consolas, monospace;\n font-size: 0.8em;\n}\n:not(pre) > code {\n background: #f3f3f3;\n padding: 0.1em 0.3em;\n border-radius: 3px;\n}\npre {\n background: #f6f8fa;\n border: 1px solid #e1e4e8;\n border-radius: 4px;\n padding: 0.7em 0.9em;\n margin: 0.8em 0;\n white-space: pre-wrap;\n word-break: break-word;\n overflow-wrap: anywhere;\n}\npre code { background: none; padding: 0; }\n/*\nPrism token colours for the book \u2014 a LIGHT palette, deliberately distinct from the site's.\n\nThe site has TWO palettes, one per mode. A book has one background and it is light: the\npre rule above is #f6f8fa, an ePub reader may impose white or sepia, and paper is paper.\nReusing the site palette here would be light-on-light \u2014 invisible body text dressed as syntax\nhighlighting, which is the exact failure a nested diff hit in tosijs-ui#143.\n\nContrast-checked against #f6f8fa; the comment green is the weakest at ~4.6:1, still above\nWCAG AA for body text. Colours are literal rather than custom properties because this\nstylesheet ships inside an ePub, where custom properties are not reliably supported.\n\nNO BACKTICKS in this comment: it lives inside a JS template literal, so one would end the\nstring and drop the rest of the stylesheet into code. (It did. Same shape as a block comment\nending a doc comment early \u2014 punctuation that means something to the container.)\n*/\n.token.comment, .token.prolog, .token.cdata { color: #5c6f5c; font-style: italic; }\n.token.punctuation { color: #666; }\n.token.string, .token.char, .token.attr-value, .token.regex { color: #a03030; }\n.token.number, .token.boolean, .token.constant { color: #0b7285; }\n.token.keyword, .token.important, .token.atrule { color: #0a5bb5; }\n.token.function, .token.class-name { color: #7a4b00; }\n.token.operator, .token.entity, .token.url { color: #444; }\n.token.tag, .token.selector, .token.builtin { color: #0a6b52; }\n.token.attr-name, .token.property { color: #2a5db0; }\n.token.deleted { color: #b02020; }\n.token.inserted { color: #2a6b2a; }\n.token.bold { font-weight: bold; }\n.token.italic { font-style: italic; }\n/* the in-flow Contents page (ePub spine TOC) */\n.toc-title { text-align: center; }\nol.toc, ol.toc ol { list-style: none; padding-left: 0; }\nol.toc ol { padding-left: 1.2em; }\nol.toc li { margin: 0.3em 0; }\nol.toc > li > a { font-weight: bold; }\n/* per-example \"run this live\" deep link */\n.example-live-link { margin: 0.6em 0 0; font-size: 0.85em; }\n.example-live-link a { color: #08c; font-weight: bold; }\n/* footnotes / endnotes + wikilinks */\nsup.footnote-ref { font-size: 0.7em; line-height: 0; }\nsup.footnote-ref a { text-decoration: none; }\n.footnotes { margin-top: 2em; font-size: 0.9em; }\n.footnotes hr { border: none; border-top: 1px solid #ccc; margin: 1em 0; }\n.footnote-backref { text-decoration: none; }\na.wikilink { text-decoration: none; border-bottom: 1px dotted currentColor; }\n";
|
|
12
|
-
export declare const PRINT_CSS = "\n@page { margin: 18mm 16mm; }\n.book-title { text-align: center; margin: 30vh 0 1em; }\n.book-toc { page-break-after: always; }\n.book-toc h2 { page-break-before: avoid; }\n.book-toc ol { list-style: none; padding-left: 0; }\n.book-toc ol ol { padding-left: 1.2em; }\n.book-toc a { color: inherit; }\n.chapter { page-break-before: always; }\n";
|
|
12
|
+
export declare const PRINT_CSS = "\n@page { margin: 18mm 16mm; }\n.book-cover { display: block; margin: 0 auto; max-width: 100%; max-height: 100vh; page-break-after: always; break-inside: avoid; }\n.book-title { text-align: center; margin: 30vh 0 1em; }\n.book-toc { page-break-after: always; }\n.book-toc h2 { page-break-before: avoid; }\n.book-toc ol { list-style: none; padding-left: 0; }\n.book-toc ol ol { padding-left: 1.2em; }\n.book-toc a { color: inherit; }\n.chapter { page-break-before: always; }\n";
|
|
13
13
|
export declare function escapeHtml(s: string): string;
|
|
14
14
|
export declare function slugify(s: string): string;
|
|
15
15
|
/** Drop the `<!--{ … }-->` metadata directives the extractor leaves in the text. */
|
|
@@ -18,6 +18,12 @@ export declare function stripDocMeta(text: string): string;
|
|
|
18
18
|
export declare function flatten<T extends NavDoc>(nodes: NavNode<T>[]): NavNode<T>[];
|
|
19
19
|
export interface BookHtmlOptions {
|
|
20
20
|
title: string;
|
|
21
|
+
/**
|
|
22
|
+
* URL of a cover image, printed as the first page. The caller is responsible for it
|
|
23
|
+
* loading (`resolveBookCover` checks before passing it): a broken image here is a blank
|
|
24
|
+
* first page.
|
|
25
|
+
*/
|
|
26
|
+
cover?: string;
|
|
21
27
|
/** full stylesheet (defaults to DEFAULT_BOOK_CSS + PRINT_CSS) */
|
|
22
28
|
css?: string;
|
|
23
29
|
lang?: string;
|
|
@@ -108,6 +108,7 @@ a.wikilink { text-decoration: none; border-bottom: 1px dotted currentColor; }
|
|
|
108
108
|
// Print-only layout: page margins, a chapter per page, an avoid-break TOC.
|
|
109
109
|
export const PRINT_CSS = `
|
|
110
110
|
@page { margin: 18mm 16mm; }
|
|
111
|
+
.book-cover { display: block; margin: 0 auto; max-width: 100%; max-height: 100vh; page-break-after: always; break-inside: avoid; }
|
|
111
112
|
.book-title { text-align: center; margin: 30vh 0 1em; }
|
|
112
113
|
.book-toc { page-break-after: always; }
|
|
113
114
|
.book-toc h2 { page-break-before: avoid; }
|
|
@@ -119,6 +120,7 @@ export const PRINT_CSS = `
|
|
|
119
120
|
export function escapeHtml(s) {
|
|
120
121
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
121
122
|
}
|
|
123
|
+
const escapeAttr = (s) => escapeHtml(s).replace(/"/g, '"');
|
|
122
124
|
export function slugify(s) {
|
|
123
125
|
return s
|
|
124
126
|
.toLowerCase()
|
|
@@ -160,6 +162,9 @@ export function buildBookHtml(docs, opts) {
|
|
|
160
162
|
<style>${css}</style>
|
|
161
163
|
</head>
|
|
162
164
|
<body>
|
|
165
|
+
${opts.cover
|
|
166
|
+
? `<img class="book-cover" src="${escapeAttr(opts.cover)}" alt="${escapeAttr(opts.title)} cover"/>`
|
|
167
|
+
: ''}
|
|
163
168
|
<h1 class="book-title">${escapeHtml(opts.title)}</h1>
|
|
164
169
|
<nav class="book-toc"><h2>Contents</h2><ol>${tocHtml(roots)}</ol></nav>
|
|
165
170
|
${chapters}
|
|
@@ -62,7 +62,7 @@ import { createDocBrowser, } from '../doc-browser.js';
|
|
|
62
62
|
import { buildSlugMap, legacyQueryPath } from './routing.js';
|
|
63
63
|
import { buildBookHtml, slugify } from './book-html.js';
|
|
64
64
|
import { highlightBlocks } from './highlight.js';
|
|
65
|
-
import { printWhenReady } from './print-window.js';
|
|
65
|
+
import { printWhenReady, resolveBookCover } from './print-window.js';
|
|
66
66
|
import { docSystemStyleSpec } from './doc-system-styles.js';
|
|
67
67
|
import { icons } from '../icons.js';
|
|
68
68
|
import { popMenu } from '../menu.js';
|
|
@@ -288,8 +288,11 @@ export class TosiDocSystem extends withAttributes({
|
|
|
288
288
|
const menuItems = [];
|
|
289
289
|
// Print / ePub of the whole corpus, before a separator + the prefs.
|
|
290
290
|
let projectName = '';
|
|
291
|
+
let bookVolumes;
|
|
291
292
|
try {
|
|
292
|
-
|
|
293
|
+
const parsed = JSON.parse(this.config || '{}');
|
|
294
|
+
projectName = parsed.projectName || '';
|
|
295
|
+
bookVolumes = parsed.bookVolumes || undefined;
|
|
293
296
|
}
|
|
294
297
|
catch {
|
|
295
298
|
// ignore — fall through to document.title
|
|
@@ -298,12 +301,16 @@ export class TosiDocSystem extends withAttributes({
|
|
|
298
301
|
menuItems.push({
|
|
299
302
|
caption: 'Print as PDF',
|
|
300
303
|
icon: 'printer',
|
|
301
|
-
action: () => {
|
|
304
|
+
action: async () => {
|
|
305
|
+
// Opened synchronously, inside the click, or a popup blocker refuses it.
|
|
302
306
|
const win = window.open('', '_blank');
|
|
303
307
|
if (!win) {
|
|
304
308
|
window.alert('Allow pop-ups to print the documentation as a book.');
|
|
305
309
|
return;
|
|
306
310
|
}
|
|
311
|
+
// Checked before the book is written: a cover only if the build wrote one
|
|
312
|
+
// and it loads. See `resolveBookCover`.
|
|
313
|
+
const bookCover = await resolveBookCover(bookVolumes);
|
|
307
314
|
win.document.open();
|
|
308
315
|
/*
|
|
309
316
|
`autoPrint: false` — highlight BEFORE printing (review major M1).
|
|
@@ -319,6 +326,7 @@ export class TosiDocSystem extends withAttributes({
|
|
|
319
326
|
*/
|
|
320
327
|
win.document.write(buildBookHtml(this.corpus, {
|
|
321
328
|
title: bookTitle,
|
|
329
|
+
cover: bookCover,
|
|
322
330
|
autoPrint: false,
|
|
323
331
|
}));
|
|
324
332
|
win.document.close();
|
|
@@ -28,3 +28,31 @@ export interface PrintWhenReadyOptions {
|
|
|
28
28
|
* document.
|
|
29
29
|
*/
|
|
30
30
|
export declare function printWhenReady(win: PrintableWindow, highlight: () => Promise<unknown>, opts?: PrintWhenReadyOptions): Promise<void>;
|
|
31
|
+
export interface ResolveBookCoverOptions {
|
|
32
|
+
/** Injected for tests. */
|
|
33
|
+
fetchFn?: (url: string) => Promise<{
|
|
34
|
+
ok: boolean;
|
|
35
|
+
json: () => Promise<unknown>;
|
|
36
|
+
}>;
|
|
37
|
+
/** Resolves when the image at `url` loads, rejects when it does not. Injected for tests. */
|
|
38
|
+
loadImage?: (url: string) => Promise<unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* How long the whole lookup may take before Print goes ahead without a cover. The files
|
|
41
|
+
* are small and same-origin, so this only bites on a stalled connection.
|
|
42
|
+
*/
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The cover Print should open with, or `undefined` for none.
|
|
47
|
+
*
|
|
48
|
+
* Reads the default volume's `coverUrl` from the volume manifest the site build wrote, then
|
|
49
|
+
* loads the image before answering. Both steps are checks on purpose: the manifest lists a
|
|
50
|
+
* cover only when the build wrote one, and loading it here means the printed book never
|
|
51
|
+
* starts with a broken image and a page break — without an inline `onerror`, which a
|
|
52
|
+
* Content-Security-Policy would block in the popup.
|
|
53
|
+
*
|
|
54
|
+
* Any failure means no cover, and so does taking too long: the popup is already open and
|
|
55
|
+
* blank while this runs, and a request that never settles must not leave it that way. Print
|
|
56
|
+
* must still happen.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveBookCover(manifestUrl: string | undefined, opts?: ResolveBookCoverOptions): Promise<string | undefined>;
|
|
@@ -14,6 +14,7 @@ The two changes, so the shape is not rediscovered:
|
|
|
14
14
|
Both failure modes are silent in the worst way: a print dialog that opens over a half-rendered
|
|
15
15
|
page, or one that never opens at all. Neither throws, and you only find out by printing.
|
|
16
16
|
*/
|
|
17
|
+
import { DEFAULT_BOOK } from './book-target.js';
|
|
17
18
|
/**
|
|
18
19
|
* Highlight, then print — after the window has actually loaded.
|
|
19
20
|
*
|
|
@@ -48,3 +49,47 @@ export function printWhenReady(win, highlight, opts = {}) {
|
|
|
48
49
|
win.addEventListener('load', go, { once: true });
|
|
49
50
|
});
|
|
50
51
|
}
|
|
52
|
+
const loadImageInPage = (url) => new Promise((resolve, reject) => {
|
|
53
|
+
const img = new Image();
|
|
54
|
+
img.onload = resolve;
|
|
55
|
+
img.onerror = reject;
|
|
56
|
+
img.src = url;
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* The cover Print should open with, or `undefined` for none.
|
|
60
|
+
*
|
|
61
|
+
* Reads the default volume's `coverUrl` from the volume manifest the site build wrote, then
|
|
62
|
+
* loads the image before answering. Both steps are checks on purpose: the manifest lists a
|
|
63
|
+
* cover only when the build wrote one, and loading it here means the printed book never
|
|
64
|
+
* starts with a broken image and a page break — without an inline `onerror`, which a
|
|
65
|
+
* Content-Security-Policy would block in the popup.
|
|
66
|
+
*
|
|
67
|
+
* Any failure means no cover, and so does taking too long: the popup is already open and
|
|
68
|
+
* blank while this runs, and a request that never settles must not leave it that way. Print
|
|
69
|
+
* must still happen.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveBookCover(manifestUrl, opts = {}) {
|
|
72
|
+
if (!manifestUrl)
|
|
73
|
+
return Promise.resolve(undefined);
|
|
74
|
+
const { fetchFn = fetch, loadImage = loadImageInPage, timeoutMs = 3000, } = opts;
|
|
75
|
+
const lookup = async () => {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetchFn(manifestUrl);
|
|
78
|
+
if (!response.ok)
|
|
79
|
+
return undefined;
|
|
80
|
+
const volumes = (await response.json());
|
|
81
|
+
const cover = volumes.find((v) => v.book === DEFAULT_BOOK)?.coverUrl;
|
|
82
|
+
if (!cover)
|
|
83
|
+
return undefined;
|
|
84
|
+
await loadImage(cover);
|
|
85
|
+
return cover;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return Promise.race([
|
|
92
|
+
lookup(),
|
|
93
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), timeoutMs)),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Where the volume manifest is served. */
|
|
2
|
+
export declare const VOLUME_MANIFEST = "epub-volumes.json";
|
|
1
3
|
export interface EpubVolume {
|
|
2
4
|
/** the `book` value, or '' for the default volume */
|
|
3
5
|
book: string;
|
|
@@ -7,13 +9,38 @@ export interface EpubVolume {
|
|
|
7
9
|
filename: string;
|
|
8
10
|
/** served URL, honouring basePath */
|
|
9
11
|
url: string;
|
|
12
|
+
/**
|
|
13
|
+
* The cover image the build wrote beside the ePub, e.g. `my-project-cover.png`. Present
|
|
14
|
+
* only in the manifest, and only when that file exists: it is read back from the output
|
|
15
|
+
* dir after the ePubs are built (`attachCovers`), never predicted.
|
|
16
|
+
*/
|
|
17
|
+
coverFilename?: string;
|
|
18
|
+
/** served URL of that cover, honouring basePath — Print uses it as its first page */
|
|
19
|
+
coverUrl?: string;
|
|
10
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The image types a cover may be, by extension. One table: the ePub build labels its cover
|
|
23
|
+
* from it and `attachCovers` recognises a cover file by it.
|
|
24
|
+
*/
|
|
25
|
+
export declare const COVER_MEDIA_TYPES: Record<string, string>;
|
|
26
|
+
/** The name `buildEpub` gives a cover written beside `<stem>.epub`, minus its extension. */
|
|
27
|
+
export declare const coverStem: (epubFilename: string) => string;
|
|
28
|
+
/**
|
|
29
|
+
* Add `coverFilename`/`coverUrl` to each volume whose cover is among `files` (a listing of
|
|
30
|
+
* the output dir, taken AFTER the ePubs were built).
|
|
31
|
+
*
|
|
32
|
+
* Read back and not derived, because only the ePub build knows what it wrote: an explicit
|
|
33
|
+
* cover keeps its own type, a missing one falls back to a generated PNG, and a build without
|
|
34
|
+
* `@resvg/resvg-js` writes none. A predicted name was wrong in each of those cases.
|
|
35
|
+
*/
|
|
36
|
+
export declare function attachCovers(volumes: EpubVolume[], files: string[], basePath?: string): EpubVolume[];
|
|
11
37
|
export interface VolumeNamingConfig {
|
|
12
38
|
name?: string;
|
|
13
39
|
basePath?: string;
|
|
14
40
|
epub?: boolean | {
|
|
15
41
|
title?: string;
|
|
16
42
|
volumeTitles?: Record<string, string>;
|
|
43
|
+
printCover?: boolean;
|
|
17
44
|
};
|
|
18
45
|
}
|
|
19
46
|
/**
|
|
@@ -47,3 +74,8 @@ export declare const EPUB_DOWNLOADS_MARKER: RegExp;
|
|
|
47
74
|
* comment.
|
|
48
75
|
*/
|
|
49
76
|
export declare function renderEpubDownloads(text: string, volumes: EpubVolume[]): string;
|
|
77
|
+
/**
|
|
78
|
+
* The manifest URL a page should hand to Print so it can open with the cover, or
|
|
79
|
+
* `undefined` when it should not: no ePub is built, or `epub.printCover` is `false`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function printCoverManifestUrl(config: VolumeNamingConfig): string | undefined;
|
|
@@ -25,6 +25,43 @@ function slugify(text) {
|
|
|
25
25
|
.replace(/[^a-z0-9]+/g, '-')
|
|
26
26
|
.replace(/^-|-$/g, '');
|
|
27
27
|
}
|
|
28
|
+
/** Where the volume manifest is served. */
|
|
29
|
+
export const VOLUME_MANIFEST = 'epub-volumes.json';
|
|
30
|
+
/**
|
|
31
|
+
* The image types a cover may be, by extension. One table: the ePub build labels its cover
|
|
32
|
+
* from it and `attachCovers` recognises a cover file by it.
|
|
33
|
+
*/
|
|
34
|
+
export const COVER_MEDIA_TYPES = {
|
|
35
|
+
'.png': 'image/png',
|
|
36
|
+
'.jpg': 'image/jpeg',
|
|
37
|
+
'.jpeg': 'image/jpeg',
|
|
38
|
+
'.gif': 'image/gif',
|
|
39
|
+
'.webp': 'image/webp',
|
|
40
|
+
'.svg': 'image/svg+xml',
|
|
41
|
+
};
|
|
42
|
+
/** The name `buildEpub` gives a cover written beside `<stem>.epub`, minus its extension. */
|
|
43
|
+
export const coverStem = (epubFilename) => `${epubFilename.replace(/\.epub$/, '')}-cover`;
|
|
44
|
+
/**
|
|
45
|
+
* Add `coverFilename`/`coverUrl` to each volume whose cover is among `files` (a listing of
|
|
46
|
+
* the output dir, taken AFTER the ePubs were built).
|
|
47
|
+
*
|
|
48
|
+
* Read back and not derived, because only the ePub build knows what it wrote: an explicit
|
|
49
|
+
* cover keeps its own type, a missing one falls back to a generated PNG, and a build without
|
|
50
|
+
* `@resvg/resvg-js` writes none. A predicted name was wrong in each of those cases.
|
|
51
|
+
*/
|
|
52
|
+
export function attachCovers(volumes, files, basePath = '/') {
|
|
53
|
+
const base = basePath.replace(/\/+$/, '');
|
|
54
|
+
return volumes.map((volume) => {
|
|
55
|
+
const stem = coverStem(volume.filename);
|
|
56
|
+
// An image, by extension: a volume named "cover" writes `<stem>-cover.epub`, which is
|
|
57
|
+
// another volume's book and not this one's cover.
|
|
58
|
+
const coverFilename = files.find((f) => f.startsWith(`${stem}.`) &&
|
|
59
|
+
f.slice(stem.length).toLowerCase() in COVER_MEDIA_TYPES);
|
|
60
|
+
return coverFilename
|
|
61
|
+
? { ...volume, coverFilename, coverUrl: `${base}/${coverFilename}` }
|
|
62
|
+
: volume;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
28
65
|
/**
|
|
29
66
|
* The identity of one volume. `bookTarget` undefined means the default volume.
|
|
30
67
|
*
|
|
@@ -91,3 +128,14 @@ export function renderEpubDownloads(text, volumes) {
|
|
|
91
128
|
.join('\n');
|
|
92
129
|
return text.replace(EPUB_DOWNLOADS_MARKER, list);
|
|
93
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* The manifest URL a page should hand to Print so it can open with the cover, or
|
|
133
|
+
* `undefined` when it should not: no ePub is built, or `epub.printCover` is `false`.
|
|
134
|
+
*/
|
|
135
|
+
export function printCoverManifestUrl(config) {
|
|
136
|
+
if (!config.epub)
|
|
137
|
+
return undefined;
|
|
138
|
+
if (typeof config.epub === 'object' && config.epub.printCover === false)
|
|
139
|
+
return undefined;
|
|
140
|
+
return `${(config.basePath ?? '/').replace(/\/+$/, '')}/${VOLUME_MANIFEST}`;
|
|
141
|
+
}
|
|
@@ -54,6 +54,11 @@ export interface BuildEpubOptions {
|
|
|
54
54
|
coverIcon?: string;
|
|
55
55
|
/** background color for the generated cover, default '#1f2933' */
|
|
56
56
|
coverColor?: string;
|
|
57
|
+
/**
|
|
58
|
+
* `false` keeps the cover out of Print as PDF, which otherwise opens with the default
|
|
59
|
+
* volume's cover. The ePub's own cover is unaffected.
|
|
60
|
+
*/
|
|
61
|
+
printCover?: boolean;
|
|
57
62
|
}
|
|
58
63
|
export declare function escapeXml(s: string): string;
|
|
59
64
|
/**
|
|
@@ -21,7 +21,7 @@ import { highlightHtml } from '../highlight.js';
|
|
|
21
21
|
import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, } from '../routing.js';
|
|
22
22
|
import { buildNavTree } from '../nav-tree.js';
|
|
23
23
|
import { partitionByBook, placeInBook, bookTextOf, DEFAULT_BOOK, } from '../book-target.js';
|
|
24
|
-
import { epubVolumeIdentity } from './epub-volumes.js';
|
|
24
|
+
import { COVER_MEDIA_TYPES, coverStem, epubVolumeIdentity, } from './epub-volumes.js';
|
|
25
25
|
import { isLiveFence, languageOfClass } from '../example-policy.js';
|
|
26
26
|
import { DEFAULT_BOOK_CSS, stripDocMeta, flatten, slugify, } from '../book-html.js';
|
|
27
27
|
import { selectBookDocs } from '../book-manifest.js';
|
|
@@ -513,13 +513,12 @@ function loadSvgGlyph(absPath) {
|
|
|
513
513
|
* if neither is available.
|
|
514
514
|
*/
|
|
515
515
|
async function makeCover(config, opts, meta) {
|
|
516
|
+
if (opts.cover && !fs.existsSync(opts.cover)) {
|
|
517
|
+
console.warn(`epub: cover "${opts.cover}" not found — generating one from the title instead.`);
|
|
518
|
+
}
|
|
516
519
|
if (opts.cover && fs.existsSync(opts.cover)) {
|
|
517
520
|
const ext = path.extname(opts.cover).toLowerCase();
|
|
518
|
-
const mediaType = ext
|
|
519
|
-
? 'image/jpeg'
|
|
520
|
-
: ext === '.gif'
|
|
521
|
-
? 'image/gif'
|
|
522
|
-
: 'image/png';
|
|
521
|
+
const mediaType = COVER_MEDIA_TYPES[ext] ?? 'image/png';
|
|
523
522
|
return {
|
|
524
523
|
file: `cover${ext || '.png'}`,
|
|
525
524
|
mediaType,
|
|
@@ -786,6 +785,13 @@ export async function buildEpub(config, opts = {}) {
|
|
|
786
785
|
// on disk all derive from one function. A link cannot point at a name nothing wrote.
|
|
787
786
|
path.join(outDir, epubVolumeIdentity({ name: baseTitle, epub: { title: baseTitle } }, opts.bookTarget).filename));
|
|
788
787
|
await zipEpub(buildDir, output);
|
|
788
|
+
// The same cover, as a plain file beside the ePub, so Print can open with it. The site
|
|
789
|
+
// build reads the name back from the output dir (`attachCovers`); nothing predicts it.
|
|
790
|
+
const coverPath = cover
|
|
791
|
+
? path.join(path.dirname(output), coverStem(path.basename(output)) + path.extname(cover.file))
|
|
792
|
+
: null;
|
|
793
|
+
if (cover && coverPath)
|
|
794
|
+
fs.writeFileSync(coverPath, cover.data);
|
|
789
795
|
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
790
796
|
// Release the parser window: an unclosed happy-dom Window holds its whole
|
|
791
797
|
// document (every chapter parsed) plus its timers. Hygiene, not a proven win —
|
|
@@ -793,6 +799,7 @@ export async function buildEpub(config, opts = {}) {
|
|
|
793
799
|
// is still retained. buildEpub now runs in a child process (see orchestrator),
|
|
794
800
|
// which is what actually bounds it.
|
|
795
801
|
win?.close?.();
|
|
796
|
-
console.log(`epub: ${output} (${chapters.length} chapters)`
|
|
802
|
+
console.log(`epub: ${output} (${chapters.length} chapters)` +
|
|
803
|
+
(coverPath ? `, cover ${path.basename(coverPath)}` : ''));
|
|
797
804
|
return output;
|
|
798
805
|
}
|
|
@@ -15,6 +15,11 @@ export interface GenerateSiteConfig {
|
|
|
15
15
|
/** directory to write pages into (the served web root, e.g. ./docs) */
|
|
16
16
|
outputDir: string;
|
|
17
17
|
projectName?: string;
|
|
18
|
+
/**
|
|
19
|
+
* URL of the volume manifest (`/epub-volumes.json`). Print reads the default volume's
|
|
20
|
+
* cover from it; omitted when the site has no ePub or opted out with `printCover: false`.
|
|
21
|
+
*/
|
|
22
|
+
bookVolumes?: string;
|
|
18
23
|
/** site-level description, used as a fallback when a doc has none */
|
|
19
24
|
description?: string;
|
|
20
25
|
/** <html lang>, default 'en' */
|
|
@@ -303,6 +303,7 @@ export async function generateSite(config) {
|
|
|
303
303
|
projectName: config.projectName,
|
|
304
304
|
projectLinks: config.projectLinks,
|
|
305
305
|
logo: config.logo,
|
|
306
|
+
bookVolumes: config.bookVolumes,
|
|
306
307
|
}));
|
|
307
308
|
// The theme stylesheet (config.stylesUrl) is written separately by
|
|
308
309
|
// ./generate-css.ts; pages here just <link> to it.
|
|
@@ -21,7 +21,7 @@ export type { BookManifest } from '../book-manifest.js';
|
|
|
21
21
|
export { placeInBook, bookTextOf } from '../book-target.js';
|
|
22
22
|
export type { BookPlacement } from '../book-target.js';
|
|
23
23
|
export { assembleCorpus, resolveInsets, splitConditions, SingleSourceError, } from './single-source.js';
|
|
24
|
-
export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, type EpubVolume, } from './epub-volumes.js';
|
|
24
|
+
export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, attachCovers, printCoverManifestUrl, VOLUME_MANIFEST, type EpubVolume, } from './epub-volumes.js';
|
|
25
25
|
export { currentHolder, describeHolder, lockPathFor, lockDecision, isProcessAlive, } from './build-lock.js';
|
|
26
26
|
export type { LockHolder, LockDecision } from './build-lock.js';
|
|
27
27
|
export { registerGrammar, registeredGrammars, highlightHtml, highlightBlocks, grammarFor, } from '../highlight.js';
|
|
@@ -22,7 +22,7 @@ export { placeInBook, bookTextOf } from '../book-target.js';
|
|
|
22
22
|
// Insets and conditional text (#217): `extractDocs` applies these; exported for a consumer
|
|
23
23
|
// that builds its corpus some other way.
|
|
24
24
|
export { assembleCorpus, resolveInsets, splitConditions, SingleSourceError, } from './single-source.js';
|
|
25
|
-
export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, } from './epub-volumes.js';
|
|
25
|
+
export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, attachCovers, printCoverManifestUrl, VOLUME_MANIFEST, } from './epub-volumes.js';
|
|
26
26
|
/*
|
|
27
27
|
Reading the build lock is part of the PUBLIC surface, not an internal detail (#117).
|
|
28
28
|
|
|
@@ -28,7 +28,7 @@ function registerSiteDialects(config) {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
import { namedBooks, partitionByBook, DEFAULT_BOOK } from '../book-target.js';
|
|
31
|
-
import { listEpubVolumes, renderEpubDownloads } from './epub-volumes.js';
|
|
31
|
+
import { attachCovers, listEpubVolumes, printCoverManifestUrl, VOLUME_MANIFEST, renderEpubDownloads, } from './epub-volumes.js';
|
|
32
32
|
import { buildSlugMap } from '../routing.js';
|
|
33
33
|
import { computeAssetStamp, missingStampInputWarning } from './asset-stamp.js';
|
|
34
34
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, } from 'fs';
|
|
@@ -656,7 +656,7 @@ export async function buildSite(config, opts = {}) {
|
|
|
656
656
|
await Bun.write(DOCS_JSON, JSON.stringify(corpus, null, 2));
|
|
657
657
|
// A stable, machine-readable list so a consumer can link the books without
|
|
658
658
|
// re-deriving the filename — the "documented helper" half of #46.
|
|
659
|
-
await Bun.write(`${PUBLIC}
|
|
659
|
+
await Bun.write(`${PUBLIC}/${VOLUME_MANIFEST}`, JSON.stringify(volumes, null, 2));
|
|
660
660
|
};
|
|
661
661
|
// Auto-create missing section docs + regenerate their TOC blocks, then
|
|
662
662
|
// re-extract so the corpus reflects the on-disk changes.
|
|
@@ -1479,6 +1479,7 @@ export async function buildSite(config, opts = {}) {
|
|
|
1479
1479
|
docs,
|
|
1480
1480
|
outputDir: PUBLIC,
|
|
1481
1481
|
projectName: config.name,
|
|
1482
|
+
bookVolumes: printCoverManifestUrl(config),
|
|
1482
1483
|
description: config.description,
|
|
1483
1484
|
baseUrl: config.baseUrl,
|
|
1484
1485
|
lang: config.lang,
|
|
@@ -1600,6 +1601,12 @@ export async function buildSite(config, opts = {}) {
|
|
|
1600
1601
|
bookTarget,
|
|
1601
1602
|
});
|
|
1602
1603
|
}
|
|
1604
|
+
// Now the covers exist (or do not), say so in the manifest: a `coverUrl` there
|
|
1605
|
+
// names a file this build wrote. Print reads the default volume's from here.
|
|
1606
|
+
const manifest = `${PUBLIC}/${VOLUME_MANIFEST}`;
|
|
1607
|
+
if (existsSync(manifest)) {
|
|
1608
|
+
await Bun.write(manifest, JSON.stringify(attachCovers(JSON.parse(await Bun.file(manifest).text()), readdirSync(PUBLIC), config.basePath), null, 2));
|
|
1609
|
+
}
|
|
1603
1610
|
}
|
|
1604
1611
|
console.timeEnd('build');
|
|
1605
1612
|
// A failed library typecheck (above) marks the whole build failed so a one-shot
|
|
@@ -321,6 +321,11 @@ export interface SiteConfig {
|
|
|
321
321
|
coverIcon?: string;
|
|
322
322
|
/** background color for the generated cover, default '#1f2933' */
|
|
323
323
|
coverColor?: string;
|
|
324
|
+
/**
|
|
325
|
+
* `false` keeps the cover out of Print as PDF, which otherwise opens with the
|
|
326
|
+
* default volume's cover. The ePub's own cover is unaffected.
|
|
327
|
+
*/
|
|
328
|
+
printCover?: boolean;
|
|
324
329
|
};
|
|
325
330
|
/**
|
|
326
331
|
* Curate the book artifact (ePub, and later print) as a subset / reordering of
|