tosijs-ui 1.16.36 → 1.16.38
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 +40 -0
- package/dist/doc-browser.js +38 -7
- package/dist/doc-system/site/dev-server.d.ts +5 -0
- package/dist/doc-system/site/dev-server.js +24 -2
- package/dist/doc-system/site/epub-cli.js +13 -2
- package/dist/doc-system/site/epub-volumes.d.ts +6 -0
- package/dist/doc-system/site/epub-volumes.js +6 -0
- package/dist/doc-system/site/epub.d.ts +26 -0
- package/dist/doc-system/site/epub.js +179 -7
- 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 +6 -1
- package/dist/iife.js +88 -88
- package/dist/iife.js.map +7 -6
- package/dist/live-example/component.js +4 -3
- package/dist/live-example/dev-served.d.ts +4 -0
- package/dist/live-example/dev-served.js +17 -0
- 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,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.16.38
|
|
4
|
+
|
|
5
|
+
- **The ePub carries its images (#3519).** Every local image went into the book as a site path
|
|
6
|
+
that does not exist inside the zip, and no image file was copied, so every figure was a broken
|
|
7
|
+
image in every reader. It never showed in a library's book, whose images are remote README
|
|
8
|
+
badges; it made an illustrated book impossible. A local image is now copied into the ePub and
|
|
9
|
+
listed in its manifest once, however many chapters use it. A `/images/fig.jpg` path (with or
|
|
10
|
+
without `basePath`) is found in the built site, where your `staticDirs` are; a relative path is
|
|
11
|
+
resolved against the page's URL, as on the site. That covers `<img src>` and `srcset` in
|
|
12
|
+
markdown and raw HTML; `<picture><source>` and SVG `<image>` are not bound yet. Remote and
|
|
13
|
+
`data:` images are unchanged. One that is missing, outside the site, or not
|
|
14
|
+
JPEG/PNG/GIF/SVG/WebP is listed in a warning, and fails a `strict` build, including an image
|
|
15
|
+
only your host serves (a CDN rewrite, a proxied path): the page shows it, the book cannot.
|
|
16
|
+
- **A book with images is bigger, and so is its rebuild.** The ePub now carries every figure,
|
|
17
|
+
and it is rebuilt on every dev-server rebuild; already-compressed images are stored, not
|
|
18
|
+
deflated again, so the cost is copying them. If you commit your output folder, the ePub in it
|
|
19
|
+
grows by the size of your images.
|
|
20
|
+
|
|
21
|
+
## 1.16.37
|
|
22
|
+
|
|
23
|
+
Doc-site test running, as a developer meets it. On a deployed site, tests still start off; the
|
|
24
|
+
only difference there is how a passing run looks to a reader who turns them on.
|
|
25
|
+
|
|
26
|
+
- **Safari no longer stalls the background test run.** The runner loads each page with tests in
|
|
27
|
+
a hidden frame, and parked that frame off-screen; WebKit gives an off-screen frame no
|
|
28
|
+
animation frames, tosijs renders on them, so every page waited out its 45-second deadline and
|
|
29
|
+
the header read "Running 0" for the whole run. The frame now sits inside the viewport, clipped
|
|
30
|
+
so nothing paints. The doc-test gate runs in WebKit for the first time, and passes.
|
|
31
|
+
- **Over the tunnel, tests run as they do on localhost.** They started off there, and turned on
|
|
32
|
+
ran only the page on screen; the dev server now marks the pages it serves, so any page it
|
|
33
|
+
served starts with tests on and runs the whole site in the background. That is every visitor
|
|
34
|
+
to a dev server, on the LAN or through a tunnel link, with or without an edit session: a
|
|
35
|
+
preview link opened on a phone runs every page's tests in hidden frames. Turning tests off
|
|
36
|
+
from the header menu is remembered per browser. A deployed site has no mark and still
|
|
37
|
+
defaults to off.
|
|
38
|
+
- **A pass no longer blanks the header's test widget.** It faded to nothing and left an empty
|
|
39
|
+
rectangle in the bar. It now turns grey and then translucent, all as a CSS filter over the
|
|
40
|
+
pass colour, and stays readable and clickable. Floating over a page with no header it still
|
|
41
|
+
fades out.
|
|
42
|
+
|
|
3
43
|
## 1.16.36
|
|
4
44
|
|
|
5
45
|
One additive site option. Nothing changes for a site that does not set it.
|
package/dist/doc-browser.js
CHANGED
|
@@ -119,6 +119,7 @@ import { popMenu } from './menu.js';
|
|
|
119
119
|
import { codeEditor } from './code-editor.js';
|
|
120
120
|
import { tosiDiff } from './diff.js';
|
|
121
121
|
import { LAN_REFUSAL_ADVICE } from './live-example/source-access.js';
|
|
122
|
+
import { servedForDevelopment } from './live-example/dev-served.js';
|
|
122
123
|
/*
|
|
123
124
|
Does this doc contain a REAL ` ```test ` block, as opposed to mentioning one?
|
|
124
125
|
|
|
@@ -186,6 +187,18 @@ const testIndicatorStyleSpec = {
|
|
|
186
187
|
'70%': { opacity: '1', transform: 'scale(1.1)' },
|
|
187
188
|
'100%': { opacity: '0', transform: 'scale(0.9)', pointerEvents: 'none' },
|
|
188
189
|
},
|
|
190
|
+
/*
|
|
191
|
+
A pass in the header settles into a muted, monochrome badge rather than fading out. Faded,
|
|
192
|
+
it left an empty rectangle in the bar (the widget keeps its box), and the result was gone
|
|
193
|
+
for anyone who looked a moment late. Muted, it reads as "nothing to see" and stays a button.
|
|
194
|
+
All of it is a filter over the pass colour, never a new colour. Grey first, then faded: fading
|
|
195
|
+
while still green put a washed-out green on the header's colour.
|
|
196
|
+
*/
|
|
197
|
+
'@keyframes test-settle': {
|
|
198
|
+
'0%, 40%': { filter: 'grayscale(0) opacity(1)' },
|
|
199
|
+
'55%': { filter: 'grayscale(1) opacity(1)' },
|
|
200
|
+
'100%': { filter: 'grayscale(1) opacity(0.55)' },
|
|
201
|
+
},
|
|
189
202
|
// Hide when tests disabled
|
|
190
203
|
'body:not(.tests-enabled) .doc-link::after, body:not(.tests-enabled) .test-widget': {
|
|
191
204
|
display: 'none !important',
|
|
@@ -240,8 +253,19 @@ const testIndicatorStyleSpec = {
|
|
|
240
253
|
'.test-widget.-passed': {
|
|
241
254
|
_testBg: testColor.pass,
|
|
242
255
|
_testFg: testTextColor.pass,
|
|
256
|
+
animation: 'test-settle 3s ease-out forwards',
|
|
257
|
+
},
|
|
258
|
+
// Floating over the page (no header), it still gets out of the way: there is no gap to leave.
|
|
259
|
+
'.test-widget.-floating.-passed': {
|
|
243
260
|
animation: 'test-fade 3s ease-out forwards',
|
|
244
261
|
},
|
|
262
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
263
|
+
'.test-widget.-passed': {
|
|
264
|
+
animation: 'none',
|
|
265
|
+
filter: 'grayscale(1) opacity(0.55)',
|
|
266
|
+
},
|
|
267
|
+
'.test-widget.-floating.-passed': { opacity: '0', pointerEvents: 'none' },
|
|
268
|
+
},
|
|
245
269
|
'.test-widget.-failed': {
|
|
246
270
|
_testBg: testColor.fail,
|
|
247
271
|
_testFg: testTextColor.fail,
|
|
@@ -1762,7 +1786,7 @@ export function createDocBrowser(options) {
|
|
|
1762
1786
|
else {
|
|
1763
1787
|
testWidget.classList.add('-passed');
|
|
1764
1788
|
testWidget.classList.remove('-failed');
|
|
1765
|
-
testWidget.hidden = false; //
|
|
1789
|
+
testWidget.hidden = false; // settles into a muted badge (see `.test-widget.-passed`)
|
|
1766
1790
|
}
|
|
1767
1791
|
}
|
|
1768
1792
|
updateTestWidgetDisplay();
|
|
@@ -1938,9 +1962,15 @@ export function createDocBrowser(options) {
|
|
|
1938
1962
|
const currentFilename = String(app.currentDoc.filename);
|
|
1939
1963
|
/*
|
|
1940
1964
|
Create a hidden iframe that loads the full page. Keep a real layout size — layout-dependent
|
|
1941
|
-
example tests need one —
|
|
1942
|
-
|
|
1943
|
-
|
|
1965
|
+
example tests need one — and hide it with a clip that leaves nothing to paint, not opacity:
|
|
1966
|
+
Chromium (and haltija on top of it) still composites a 0-opacity layer at 0,0, which flashes
|
|
1967
|
+
as the frame navigates page to page.
|
|
1968
|
+
|
|
1969
|
+
INSIDE the viewport, though. It used to be parked at left:-10000px, and WebKit throttles a
|
|
1970
|
+
frame outside the viewport: its animation frames never came, tosijs renders on animation
|
|
1971
|
+
frames, so every page sat until its 45s deadline and Safari showed "Running 0" for the whole
|
|
1972
|
+
run. That was the "WebKit's iframe runner never signals" of the e2e lane, and it reached
|
|
1973
|
+
readers once the tunnel ran tests in the background.
|
|
1944
1974
|
|
|
1945
1975
|
THE SIZE IS OVERRIDABLE, because 800x600 was the only width the inline tier had ever run at.
|
|
1946
1976
|
A doc-system layout regression at phone width shipped in 1.12.3 and was found by a reader,
|
|
@@ -1959,7 +1989,7 @@ export function createDocBrowser(options) {
|
|
|
1959
1989
|
const vpWidth = Number(vp?.width) > 0 ? Number(vp?.width) : 800;
|
|
1960
1990
|
const vpHeight = Number(vp?.height) > 0 ? Number(vp?.height) : 600;
|
|
1961
1991
|
const testFrame = document.createElement('iframe');
|
|
1962
|
-
testFrame.style.cssText = `position: fixed; left:
|
|
1992
|
+
testFrame.style.cssText = `position: fixed; left: 0; top: 0; z-index: -1; clip-path: inset(100%); width: ${vpWidth}px; height: ${vpHeight}px; opacity: 0; pointer-events: none;`;
|
|
1963
1993
|
document.body.appendChild(testFrame);
|
|
1964
1994
|
// Listen for test results posted from the iframe
|
|
1965
1995
|
const messageHandler = (event) => {
|
|
@@ -2032,11 +2062,12 @@ export function createDocBrowser(options) {
|
|
|
2032
2062
|
void settleCurrentPage(currentFilename);
|
|
2033
2063
|
}
|
|
2034
2064
|
};
|
|
2035
|
-
// Run background tests when enabled (initially or when toggled on)
|
|
2065
|
+
// Run background tests when enabled (initially or when toggled on). Every page where a
|
|
2066
|
+
// developer is looking, the tunnel included; elsewhere only the page on screen.
|
|
2036
2067
|
const startBackgroundTests = () => {
|
|
2037
2068
|
if (!testManager.enabled.value)
|
|
2038
2069
|
return;
|
|
2039
|
-
if (
|
|
2070
|
+
if (servedForDevelopment()) {
|
|
2040
2071
|
setTimeout(runBackgroundTests, 1000);
|
|
2041
2072
|
}
|
|
2042
2073
|
else {
|
|
@@ -31,6 +31,11 @@ import { isLoopbackAddressForAuth as isLoopbackAddress } from './dev-auth.js';
|
|
|
31
31
|
*
|
|
32
32
|
* `root + sep` rather than a bare `startsWith`, or `/srv/docs-evil` counts as inside `/srv/docs`.
|
|
33
33
|
*/
|
|
34
|
+
/**
|
|
35
|
+
* Put `snippet` before every script in `html`: just inside `<head>`, or else just after the
|
|
36
|
+
* doctype. Never before the doctype, which would put the page in quirks mode.
|
|
37
|
+
*/
|
|
38
|
+
export declare function injectFirst(html: string, snippet: string): string;
|
|
34
39
|
export declare function isUnderRoot(root: string, candidate: string): boolean;
|
|
35
40
|
export declare function resolveWatchPaths(config: {
|
|
36
41
|
docPaths?: string[];
|
|
@@ -28,6 +28,7 @@ import { assertSecretNotServed, ensureSessionSecret, readSessionSecret, sessionS
|
|
|
28
28
|
import { revocationList, startRevocationSweep } from './device-revocation.js';
|
|
29
29
|
import { resolveDataStore, resolveDataFile, dataContentProblem, listDataFiles, saveDataFile, dataHistory, readDataAt, isGitWorkTree, } from './data-store.js';
|
|
30
30
|
import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-example/source-access.js';
|
|
31
|
+
import { DEV_SERVER_SNIPPET } from '../../live-example/dev-served.js';
|
|
31
32
|
/**
|
|
32
33
|
* Every path the dev server watches for changes.
|
|
33
34
|
*
|
|
@@ -59,6 +60,20 @@ import { SOURCE_ACCESS_HEADER, SOURCE_ENDPOINT_SNIPPET, } from '../../live-examp
|
|
|
59
60
|
*
|
|
60
61
|
* `root + sep` rather than a bare `startsWith`, or `/srv/docs-evil` counts as inside `/srv/docs`.
|
|
61
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Put `snippet` before every script in `html`: just inside `<head>`, or else just after the
|
|
65
|
+
* doctype. Never before the doctype, which would put the page in quirks mode.
|
|
66
|
+
*/
|
|
67
|
+
export function injectFirst(html, snippet) {
|
|
68
|
+
const head = /<head(\s[^>]*)?>/i.exec(html);
|
|
69
|
+
if (head) {
|
|
70
|
+
const at = head.index + head[0].length;
|
|
71
|
+
return html.slice(0, at) + snippet + html.slice(at);
|
|
72
|
+
}
|
|
73
|
+
const doctype = /^\s*<!doctype[^>]*>/i.exec(html);
|
|
74
|
+
const at = doctype ? doctype[0].length : 0;
|
|
75
|
+
return html.slice(0, at) + snippet + html.slice(at);
|
|
76
|
+
}
|
|
62
77
|
export function isUnderRoot(root, candidate) {
|
|
63
78
|
const base = path.resolve(root);
|
|
64
79
|
const target = path.resolve(candidate);
|
|
@@ -1373,12 +1388,19 @@ export async function devServer(config, opts = {}) {
|
|
|
1373
1388
|
// deployed static site, which has none, is never asked.
|
|
1374
1389
|
(config.editableSources ? SOURCE_ENDPOINT_SNIPPET : '');
|
|
1375
1390
|
const encoding = negotiateEncoding(request?.headers.get('accept-encoding') ?? null);
|
|
1376
|
-
if (
|
|
1391
|
+
if (filePath.endsWith('.html')) {
|
|
1377
1392
|
const read = await Bun.file(filePath).text();
|
|
1378
1393
|
const html = asRoot ? rootRelativeUrls(read) : read;
|
|
1379
|
-
const
|
|
1394
|
+
const withExtras = html.includes('</body>')
|
|
1380
1395
|
? html.replace('</body>', `${extras}</body>`)
|
|
1381
1396
|
: html + extras;
|
|
1397
|
+
/*
|
|
1398
|
+
Tests on by default, every page, for whoever is looking through the dev server (the tunnel
|
|
1399
|
+
included); see dev-served.ts. FIRST in <head>, unlike the extras: it is read when the
|
|
1400
|
+
bundle evaluates, and on the iife path that is a classic script, which runs as it is
|
|
1401
|
+
parsed, before anything at the end of <body> exists.
|
|
1402
|
+
*/
|
|
1403
|
+
const injected = injectFirst(withExtras, DEV_SERVER_SNIPPET);
|
|
1382
1404
|
// Injected HTML is built per request, so it is compressed but never cached — the
|
|
1383
1405
|
// cache key would have to include the injected extras, and HTML is small anyway.
|
|
1384
1406
|
const body = encoding
|
|
@@ -12,7 +12,7 @@ the Bun.build note in orchestrator.ts.
|
|
|
12
12
|
|
|
13
13
|
Build-time only. Never import this from browser code.
|
|
14
14
|
*/
|
|
15
|
-
import { buildEpub } from './epub.js';
|
|
15
|
+
import { buildEpub, EpubImageError, EPUB_IMAGE_EXIT } from './epub.js';
|
|
16
16
|
import { BUILT_IN_DIALECTS, normalizeDialectNames, registerLiveLanguage, } from '../example-policy.js';
|
|
17
17
|
const payloadPath = process.argv[2];
|
|
18
18
|
if (!payloadPath) {
|
|
@@ -26,4 +26,15 @@ for (const name of normalizeDialectNames(config.dialects)) {
|
|
|
26
26
|
if (!BUILT_IN_DIALECTS.has(name))
|
|
27
27
|
registerLiveLanguage(name);
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
try {
|
|
30
|
+
await buildEpub(config, opts ?? {});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// A distinct exit code, so the parent fails a `strict` build on THIS and still only warns
|
|
34
|
+
// about the ePub's other failures (it is otherwise a side artifact).
|
|
35
|
+
if (error instanceof EpubImageError) {
|
|
36
|
+
console.error(error.message);
|
|
37
|
+
process.exit(EPUB_IMAGE_EXIT);
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
@@ -79,3 +79,9 @@ export declare function renderEpubDownloads(text: string, volumes: EpubVolume[])
|
|
|
79
79
|
* `undefined` when it should not: no ePub is built, or `epub.printCover` is `false`.
|
|
80
80
|
*/
|
|
81
81
|
export declare function printCoverManifestUrl(config: VolumeNamingConfig): string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* epub-cli's exit code when a local image cannot be bound into the book. The build fails on it
|
|
84
|
+
* under `strict` and only warns otherwise. Here, not in epub.ts, so the orchestrator can read it
|
|
85
|
+
* without importing the ePub builder it runs in a child.
|
|
86
|
+
*/
|
|
87
|
+
export declare const EPUB_IMAGE_EXIT = 3;
|
|
@@ -139,3 +139,9 @@ export function printCoverManifestUrl(config) {
|
|
|
139
139
|
return undefined;
|
|
140
140
|
return `${(config.basePath ?? '/').replace(/\/+$/, '')}/${VOLUME_MANIFEST}`;
|
|
141
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* epub-cli's exit code when a local image cannot be bound into the book. The build fails on it
|
|
144
|
+
* under `strict` and only warns otherwise. Here, not in epub.ts, so the orchestrator can read it
|
|
145
|
+
* without importing the ePub builder it runs in a child.
|
|
146
|
+
*/
|
|
147
|
+
export const EPUB_IMAGE_EXIT = 3;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EPUB_IMAGE_EXIT } from './epub-volumes.js';
|
|
1
2
|
import type { SiteConfig } from './site-config.js';
|
|
2
3
|
import { DEFAULT_BOOK_CSS, stripDocMeta } from '../book-html.js';
|
|
3
4
|
export { DEFAULT_BOOK_CSS, stripDocMeta };
|
|
@@ -75,8 +76,33 @@ export declare function toXhtml(html: string): string;
|
|
|
75
76
|
* untouched. `bookFiles` maps in-book slug → chapter filename.
|
|
76
77
|
*/
|
|
77
78
|
export declare function rewriteInBookLinks(html: string, bookFiles: Map<string, string>, slugMap: Record<string, string>, basePath?: string): string;
|
|
79
|
+
export { EPUB_IMAGE_EXIT };
|
|
80
|
+
/** Thrown by buildEpub under `strict` when a local image cannot be bound into the book. */
|
|
81
|
+
export declare class EpubImageError extends Error {
|
|
82
|
+
name: string;
|
|
83
|
+
}
|
|
84
|
+
/** An image bound into the book: where it lives in OEBPS, and where it came from. */
|
|
85
|
+
export interface BookImage {
|
|
86
|
+
/** path inside OEBPS, e.g. `images/figs/a.jpg` */
|
|
87
|
+
file: string;
|
|
88
|
+
mediaType: string;
|
|
89
|
+
/** absolute path of the source file */
|
|
90
|
+
source: string;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Rewrite the local images in `html` (`<img src>` and each `srcset` candidate) to book-local
|
|
94
|
+
* paths, registering each file in `images` (keyed by absolute source path, so a figure used
|
|
95
|
+
* twice is bound once). Root-absolute srcs have `basePath` stripped; relative ones resolve
|
|
96
|
+
* against `pagePath`, the page's own site path. Local srcs that cannot be bound are added to
|
|
97
|
+
* `unbound` and left untouched. Not handled: `<picture><source>` and SVG `<image>`.
|
|
98
|
+
*/
|
|
99
|
+
export declare function embedLocalImages(html: string, webRoot: string, basePath: string | undefined, images: Map<string, BookImage>, unbound?: Set<string>, pagePath?: string): string;
|
|
78
100
|
/**
|
|
79
101
|
* Build an EPUB 3 book from the extracted corpus. Returns the output path.
|
|
80
102
|
*/
|
|
81
103
|
export declare function versionAnchoredDate(version: string, override?: string): string;
|
|
104
|
+
/**
|
|
105
|
+
* Build the ePub for `config`'s corpus and return its path. Under `config.strict`, throws
|
|
106
|
+
* {@link EpubImageError} when a local image cannot be bound into the book.
|
|
107
|
+
*/
|
|
82
108
|
export declare function buildEpub(config: SiteConfig, opts?: BuildEpubOptions): Promise<string>;
|
|
@@ -21,7 +21,7 @@ import { highlightHtml } from '../highlight.js';
|
|
|
21
21
|
import { buildSlugMap, pathForSlug, resolveParent, slugForPath, withBase, wikilinkTargets, } 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 { COVER_MEDIA_TYPES, coverStem, epubVolumeIdentity, } from './epub-volumes.js';
|
|
24
|
+
import { COVER_MEDIA_TYPES, EPUB_IMAGE_EXIT, 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';
|
|
@@ -216,6 +216,145 @@ export function rewriteInBookLinks(html, bookFiles, slugMap, basePath) {
|
|
|
216
216
|
return file ? `href="${file}${hash}"` : match;
|
|
217
217
|
});
|
|
218
218
|
}
|
|
219
|
+
/*
|
|
220
|
+
Images bound INTO the book.
|
|
221
|
+
|
|
222
|
+
A chapter's `<img src="/images/fig.jpg">` used to ship as-is, pointing at a site path that
|
|
223
|
+
does not exist inside the zip: the book carried no image files at all, so every local
|
|
224
|
+
figure was a broken image in every reader. Fine for a library whose only images are remote
|
|
225
|
+
README badges; fatal for a manual whose content IS its figures.
|
|
226
|
+
|
|
227
|
+
A root-absolute src (`/images/fig.jpg`) is resolved against the built web root — the output
|
|
228
|
+
dir, which already holds the copied `staticDirs` — so the book embeds exactly the file the page
|
|
229
|
+
shows. basePath is stripped first, as for links. A relative src (`img/fig.jpg`) is first
|
|
230
|
+
resolved against the page's own path (`/slug/`), as a browser on the site resolves it, so it
|
|
231
|
+
binds the same file the page shows. Remote and `data:` srcs are left alone. A local image that
|
|
232
|
+
is missing, escapes the web root, or is not an EPUB core image type is left as-is and reported,
|
|
233
|
+
because a figure that silently vanishes from a book is worse than one that visibly fails; under
|
|
234
|
+
`strict` it fails the build.
|
|
235
|
+
*/
|
|
236
|
+
export { EPUB_IMAGE_EXIT };
|
|
237
|
+
/** Thrown by buildEpub under `strict` when a local image cannot be bound into the book. */
|
|
238
|
+
export class EpubImageError extends Error {
|
|
239
|
+
name = 'EpubImageError';
|
|
240
|
+
}
|
|
241
|
+
/** EPUB 3 core media types for images, by extension. */
|
|
242
|
+
const IMAGE_MEDIA_TYPES = {
|
|
243
|
+
'.jpg': 'image/jpeg',
|
|
244
|
+
'.jpeg': 'image/jpeg',
|
|
245
|
+
'.png': 'image/png',
|
|
246
|
+
'.gif': 'image/gif',
|
|
247
|
+
'.svg': 'image/svg+xml',
|
|
248
|
+
'.webp': 'image/webp',
|
|
249
|
+
};
|
|
250
|
+
/** The five entities an attribute value can carry, and numeric references. */
|
|
251
|
+
function decodeAttr(value) {
|
|
252
|
+
return value.replace(/&(?:#(\d+)|#x([\da-f]+)|(amp|lt|gt|quot|apos));/gi, (match, dec, hex, name) => dec
|
|
253
|
+
? String.fromCodePoint(Number(dec))
|
|
254
|
+
: hex
|
|
255
|
+
? String.fromCodePoint(parseInt(hex, 16))
|
|
256
|
+
: { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[name.toLowerCase()] ?? match);
|
|
257
|
+
}
|
|
258
|
+
// An `<img>` tag, with `>` allowed inside quoted attribute values (`alt="a > b"`).
|
|
259
|
+
const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
260
|
+
// One attribute of a tag: name, then a double-, single- or un-quoted value.
|
|
261
|
+
const ATTRIBUTE = /(\s)([^\s=>/"']+)(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
262
|
+
/**
|
|
263
|
+
* Rewrite the local images in `html` (`<img src>` and each `srcset` candidate) to book-local
|
|
264
|
+
* paths, registering each file in `images` (keyed by absolute source path, so a figure used
|
|
265
|
+
* twice is bound once). Root-absolute srcs have `basePath` stripped; relative ones resolve
|
|
266
|
+
* against `pagePath`, the page's own site path. Local srcs that cannot be bound are added to
|
|
267
|
+
* `unbound` and left untouched. Not handled: `<picture><source>` and SVG `<image>`.
|
|
268
|
+
*/
|
|
269
|
+
export function embedLocalImages(html, webRoot, basePath, images, unbound = new Set(),
|
|
270
|
+
// the page's own site path, for relative srcs; '/' for the root page
|
|
271
|
+
pagePath = '/') {
|
|
272
|
+
const root = path.resolve(webRoot);
|
|
273
|
+
const bp = basePath && basePath !== '/' ? basePath.replace(/\/$/, '') : '';
|
|
274
|
+
// the book-local path for one src, or null to leave it as it is
|
|
275
|
+
const bind = (src) => {
|
|
276
|
+
// remote (any scheme, or protocol-relative) and data: stay as they are
|
|
277
|
+
if (!src || /^[a-z][a-z\d+.-]*:/i.test(src) || src.startsWith('//'))
|
|
278
|
+
return null;
|
|
279
|
+
const fragment = /#.*$/.exec(src)?.[0] ?? '';
|
|
280
|
+
let p = src.replace(/[?#].*$/, '');
|
|
281
|
+
if (!p)
|
|
282
|
+
return null;
|
|
283
|
+
if (p.startsWith('/')) {
|
|
284
|
+
if (bp && (p === bp || p.startsWith(bp + '/')))
|
|
285
|
+
p = p.slice(bp.length);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
// keep the raw percent-encoding: decoded once below, like a root-absolute src
|
|
289
|
+
p = new URL(p, `https://book.invalid${pagePath}`).pathname;
|
|
290
|
+
}
|
|
291
|
+
let decoded;
|
|
292
|
+
try {
|
|
293
|
+
decoded = decodeURIComponent(p);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
decoded = p;
|
|
297
|
+
}
|
|
298
|
+
const source = path.join(root, decoded);
|
|
299
|
+
const mediaType = IMAGE_MEDIA_TYPES[path.extname(source).toLowerCase()];
|
|
300
|
+
if (!source.startsWith(root + path.sep) ||
|
|
301
|
+
!mediaType ||
|
|
302
|
+
!fs.existsSync(source) ||
|
|
303
|
+
!fs.statSync(source).isFile()) {
|
|
304
|
+
unbound.add(src);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
let image = images.get(source);
|
|
308
|
+
if (!image) {
|
|
309
|
+
const rel = path
|
|
310
|
+
.relative(root, source)
|
|
311
|
+
.split(path.sep)
|
|
312
|
+
.map((seg) => seg.replace(/[^\w.-]/g, '_'))
|
|
313
|
+
.join('/');
|
|
314
|
+
// Sanitizing can make two sources collide (`a b.jpg`, `a_b.jpg`); keep both.
|
|
315
|
+
const taken = new Set([...images.values()].map((i) => i.file));
|
|
316
|
+
let file = `images/${rel}`;
|
|
317
|
+
const ext = path.extname(file);
|
|
318
|
+
for (let n = 2; taken.has(file); n += 1) {
|
|
319
|
+
file = `images/${rel.slice(0, rel.length - ext.length)}-${n}${ext}`;
|
|
320
|
+
}
|
|
321
|
+
image = { file, mediaType, source };
|
|
322
|
+
images.set(source, image);
|
|
323
|
+
}
|
|
324
|
+
// A fragment names a part of the file (an SVG sprite), so it travels with it.
|
|
325
|
+
return image.file + fragment;
|
|
326
|
+
};
|
|
327
|
+
return html.replace(IMG_TAG, (tag) => tag.replace(ATTRIBUTE, (match, space, name, eq, dq, sq, bare) => {
|
|
328
|
+
const attr = name.toLowerCase();
|
|
329
|
+
if (attr !== 'src' && attr !== 'srcset')
|
|
330
|
+
return match;
|
|
331
|
+
const raw = dq ?? sq ?? bare ?? '';
|
|
332
|
+
const quote = dq !== undefined ? '"' : sq !== undefined ? "'" : '"';
|
|
333
|
+
const value = decodeAttr(raw);
|
|
334
|
+
let rewritten;
|
|
335
|
+
if (attr === 'src') {
|
|
336
|
+
rewritten = bind(value);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
// `url 2x, url 640w`: bind each candidate, keep its descriptor
|
|
340
|
+
let changed = false;
|
|
341
|
+
const candidates = value.split(',').map((candidate) => {
|
|
342
|
+
const [url, ...descriptor] = candidate.trim().split(/\s+/);
|
|
343
|
+
const bound = bind(url);
|
|
344
|
+
if (bound === null)
|
|
345
|
+
return candidate.trim();
|
|
346
|
+
changed = true;
|
|
347
|
+
return [bound, ...descriptor].join(' ');
|
|
348
|
+
});
|
|
349
|
+
rewritten = changed ? candidates.join(', ') : null;
|
|
350
|
+
}
|
|
351
|
+
// decoded above, so escaped again: a fragment or an untouched srcset candidate may
|
|
352
|
+
// carry `&` or a quote
|
|
353
|
+
return rewritten === null
|
|
354
|
+
? match
|
|
355
|
+
: `${space}${name}${eq}${quote}${escapeXml(rewritten)}${quote}`;
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
219
358
|
// `pageUrl` is the live-site URL of THIS doc (already including baseUrl, basePath
|
|
220
359
|
// and the correct root for README → '/'); each example link just appends `#id`.
|
|
221
360
|
function injectExampleLinks(doc, pageUrl, policy = 'auto') {
|
|
@@ -267,7 +406,7 @@ function containerXml() {
|
|
|
267
406
|
</container>
|
|
268
407
|
`;
|
|
269
408
|
}
|
|
270
|
-
function packageOpf(meta, chapters, cover) {
|
|
409
|
+
function packageOpf(meta, chapters, cover, images = []) {
|
|
271
410
|
const manifestItems = [
|
|
272
411
|
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
|
273
412
|
`<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`,
|
|
@@ -280,6 +419,7 @@ function packageOpf(meta, chapters, cover) {
|
|
|
280
419
|
]
|
|
281
420
|
: []),
|
|
282
421
|
...chapters.map((c) => `<item id="${c.id}" href="${c.href}" media-type="application/xhtml+xml"/>`),
|
|
422
|
+
...images.map((img, i) => `<item id="img-${i + 1}" href="${escapeXml(img.file)}" media-type="${img.mediaType}"/>`),
|
|
283
423
|
];
|
|
284
424
|
const spine = [
|
|
285
425
|
...(cover ? [`<itemref idref="cover-page"/>`] : []),
|
|
@@ -415,8 +555,10 @@ async function zipEpub(buildDir, outputAbs) {
|
|
|
415
555
|
await $ `find . -exec touch -t 202001010000 {} +`.cwd(buildDir).quiet();
|
|
416
556
|
// 1) mimetype first, stored (-0), no extra fields (-X)
|
|
417
557
|
await $ `zip -X0 ${outputAbs} mimetype`.cwd(buildDir).quiet();
|
|
418
|
-
// 2) everything else, deflated (-9), recursive (-r), no extra fields, no dir entries (-D)
|
|
419
|
-
|
|
558
|
+
// 2) everything else, deflated (-9), recursive (-r), no extra fields, no dir entries (-D).
|
|
559
|
+
// Images that are already compressed are stored (-n): deflating a book's worth of JPEGs on
|
|
560
|
+
// every rebuild costs time and saves nothing.
|
|
561
|
+
await $ `zip -Xr9D -n .jpg:.jpeg:.png:.gif:.webp ${outputAbs} META-INF OEBPS -x mimetype`
|
|
420
562
|
.cwd(buildDir)
|
|
421
563
|
.quiet();
|
|
422
564
|
}
|
|
@@ -601,6 +743,10 @@ export function versionAnchoredDate(version, override) {
|
|
|
601
743
|
.toISOString()
|
|
602
744
|
.replace(/\.\d+Z$/, 'Z');
|
|
603
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* Build the ePub for `config`'s corpus and return its path. Under `config.strict`, throws
|
|
748
|
+
* {@link EpubImageError} when a local image cannot be bound into the book.
|
|
749
|
+
*/
|
|
604
750
|
export async function buildEpub(config, opts = {}) {
|
|
605
751
|
const projectVersion = await Bun.file(`${process.cwd()}/package.json`)
|
|
606
752
|
.json()
|
|
@@ -731,6 +877,8 @@ export async function buildEpub(config, opts = {}) {
|
|
|
731
877
|
// example links back to its anchor on the live site.
|
|
732
878
|
const baseUrl = config.baseUrl?.replace(/\/+$/, '');
|
|
733
879
|
const chapters = [];
|
|
880
|
+
const images = new Map();
|
|
881
|
+
const unboundImages = new Set();
|
|
734
882
|
for (const node of flatten(roots)) {
|
|
735
883
|
const doc = node.doc;
|
|
736
884
|
// The doc's live-site URL, matching generate-site's canonical link: README
|
|
@@ -743,7 +891,7 @@ export async function buildEpub(config, opts = {}) {
|
|
|
743
891
|
is the same `highlightHtml` the static pages use, so a code block looks the same on the
|
|
744
892
|
site, in the book and in print.
|
|
745
893
|
*/
|
|
746
|
-
const
|
|
894
|
+
const linked = rewriteInBookLinks(
|
|
747
895
|
/*
|
|
748
896
|
`'none'` — an ePub has no live examples, so the live-fence skip that protects the web
|
|
749
897
|
page buys nothing here and cost the reader 237 of 284 code blocks (js, html, css and
|
|
@@ -752,6 +900,7 @@ export async function buildEpub(config, opts = {}) {
|
|
|
752
900
|
highlighting cannot break the "Run this example live" links.
|
|
753
901
|
*/
|
|
754
902
|
await highlightHtml(renderDocMarkdown(stripDocMeta(bookTextOf(doc)), { wikilinks }), 'none'), bookFiles, slugMap, config.basePath);
|
|
903
|
+
const html = embedLocalImages(linked, outDir, config.basePath, images, unboundImages, pathForSlug(slugMap[doc.filename] ?? ''));
|
|
755
904
|
// happy-dom occasionally throws on exotic content (e.g. an internal selector
|
|
756
905
|
// bug); fall back to the regex pass for that doc rather than aborting.
|
|
757
906
|
let bodyHtml;
|
|
@@ -772,13 +921,34 @@ export async function buildEpub(config, opts = {}) {
|
|
|
772
921
|
title: doc.title,
|
|
773
922
|
});
|
|
774
923
|
}
|
|
924
|
+
for (const img of images.values()) {
|
|
925
|
+
const dest = path.join(buildDir, 'OEBPS', img.file);
|
|
926
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
927
|
+
fs.copyFileSync(img.source, dest);
|
|
928
|
+
}
|
|
929
|
+
if (unboundImages.size) {
|
|
930
|
+
const list = [...unboundImages];
|
|
931
|
+
const report = `epub: ${list.length} local image(s) not bound into the book (missing, outside ` +
|
|
932
|
+
`the site, or not jpg/png/gif/svg/webp):\n` +
|
|
933
|
+
list
|
|
934
|
+
.slice(0, 10)
|
|
935
|
+
.map((s) => ` ${s}`)
|
|
936
|
+
.join('\n') +
|
|
937
|
+
(list.length > 10 ? `\n … and ${list.length - 10} more` : '');
|
|
938
|
+
if (config.strict) {
|
|
939
|
+
// nothing of this book is kept: a stray build folder would land in the site output
|
|
940
|
+
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
941
|
+
throw new EpubImageError(report);
|
|
942
|
+
}
|
|
943
|
+
console.warn(report);
|
|
944
|
+
}
|
|
775
945
|
// Cover (explicit image, or generated from the title + favicon).
|
|
776
946
|
const cover = await makeCover(config, opts, meta);
|
|
777
947
|
if (cover) {
|
|
778
948
|
fs.writeFileSync(path.join(buildDir, 'OEBPS', cover.file), cover.data);
|
|
779
949
|
fs.writeFileSync(path.join(buildDir, 'OEBPS', 'cover.xhtml'), coverPageXhtml(meta.title, cover.file));
|
|
780
950
|
}
|
|
781
|
-
fs.writeFileSync(path.join(buildDir, 'OEBPS', 'package.opf'), packageOpf(meta, chapters, cover));
|
|
951
|
+
fs.writeFileSync(path.join(buildDir, 'OEBPS', 'package.opf'), packageOpf(meta, chapters, cover, [...images.values()]));
|
|
782
952
|
fs.writeFileSync(path.join(buildDir, 'OEBPS', 'contents.xhtml'), tocPageXhtml(roots, fileFor));
|
|
783
953
|
fs.writeFileSync(path.join(buildDir, 'OEBPS', 'nav.xhtml'), navXhtml(meta, roots, fileFor));
|
|
784
954
|
fs.writeFileSync(path.join(buildDir, 'OEBPS', 'toc.ncx'), tocNcx(meta, roots, fileFor));
|
|
@@ -804,7 +974,9 @@ export async function buildEpub(config, opts = {}) {
|
|
|
804
974
|
// is still retained. buildEpub now runs in a child process (see orchestrator),
|
|
805
975
|
// which is what actually bounds it.
|
|
806
976
|
win?.close?.();
|
|
807
|
-
console.log(`epub: ${output} (${chapters.length} chapters
|
|
977
|
+
console.log(`epub: ${output} (${chapters.length} chapters` +
|
|
978
|
+
(images.size ? `, ${images.size} images` : '') +
|
|
979
|
+
')' +
|
|
808
980
|
(coverPath ? `, cover ${path.basename(coverPath)}` : ''));
|
|
809
981
|
return output;
|
|
810
982
|
}
|
|
@@ -18,7 +18,7 @@ export type { Doc } from './docs.js';
|
|
|
18
18
|
export type { LibraryBundle } from './library-bundle.js';
|
|
19
19
|
export { generateSite } from './generate-site.js';
|
|
20
20
|
export type { GenerateSiteConfig } from './generate-site.js';
|
|
21
|
-
export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
|
|
21
|
+
export { buildEpub, DEFAULT_BOOK_CSS, EpubImageError } from './epub.js';
|
|
22
22
|
export type { BuildEpubOptions, BookMeta } from './epub.js';
|
|
23
23
|
export { selectBookDocs } from '../book-manifest.js';
|
|
24
24
|
export type { BookManifest } from '../book-manifest.js';
|
|
@@ -20,7 +20,7 @@ export { auditDependencies, reportAudit, resolveAuditMode, } from './audit-guard
|
|
|
20
20
|
export { openDevBrowser, buildOpenPlan } from './open-browser.js';
|
|
21
21
|
export { extractDocs, saveDocsJSON } from './docs.js';
|
|
22
22
|
export { generateSite } from './generate-site.js';
|
|
23
|
-
export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
|
|
23
|
+
export { buildEpub, DEFAULT_BOOK_CSS, EpubImageError } from './epub.js';
|
|
24
24
|
export { selectBookDocs } from '../book-manifest.js';
|
|
25
25
|
export { placeInBook, bookTextOf } from '../book-target.js';
|
|
26
26
|
// Insets and conditional text (#217): `extractDocs` applies these; exported for a consumer
|
|
@@ -29,7 +29,7 @@ export function registerSiteDialects(config) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
import { namedBooks, partitionByBook, DEFAULT_BOOK } from '../book-target.js';
|
|
32
|
-
import { attachCovers, listEpubVolumes, printCoverManifestUrl, VOLUME_MANIFEST, renderEpubDownloads, } from './epub-volumes.js';
|
|
32
|
+
import { EPUB_IMAGE_EXIT, attachCovers, listEpubVolumes, printCoverManifestUrl, VOLUME_MANIFEST, renderEpubDownloads, } from './epub-volumes.js';
|
|
33
33
|
import { buildSlugMap } from '../routing.js';
|
|
34
34
|
import { assetStampInputs, computeAssetStamp, missingStampInputWarning, } from './asset-stamp.js';
|
|
35
35
|
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, } from 'fs';
|
|
@@ -88,6 +88,8 @@ async function buildEpubInChild(config, opts) {
|
|
|
88
88
|
// the ePub's "run this example live" links count examples the way the page does, so it
|
|
89
89
|
// must know the site's dialects too (1.16.3 review F3)
|
|
90
90
|
dialects: config.dialects,
|
|
91
|
+
// an image the book cannot bind fails a strict build (EPUB_IMAGE_EXIT)
|
|
92
|
+
strict: config.strict,
|
|
91
93
|
},
|
|
92
94
|
opts,
|
|
93
95
|
};
|
|
@@ -103,6 +105,9 @@ async function buildEpubInChild(config, opts) {
|
|
|
103
105
|
}, EPUB_TIMEOUT_MS);
|
|
104
106
|
try {
|
|
105
107
|
const code = await child.exited;
|
|
108
|
+
if (code === EPUB_IMAGE_EXIT && config.strict) {
|
|
109
|
+
misconfigured(true, '⚠️ epub: a local image could not be bound into the book (listed above).');
|
|
110
|
+
}
|
|
106
111
|
if (code !== 0) {
|
|
107
112
|
console.warn(`⚠️ epub build failed (exit ${code}). The site is fine; the .epub may be stale.`);
|
|
108
113
|
}
|