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 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.
@@ -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; // Show briefly before fade
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 — but park it off-screen rather than rely on opacity:0 over the
1942
- visible page: Chromium (and haltija on top of it) still composites a 0-opacity layer at 0,0,
1943
- which flashes as the frame navigates page to page.
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: -10000px; top: 0; width: ${vpWidth}px; height: ${vpHeight}px; opacity: 0; pointer-events: none;`;
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 (isLocalhost) {
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 ((extras || asRoot) && filePath.endsWith('.html')) {
1391
+ if (filePath.endsWith('.html')) {
1377
1392
  const read = await Bun.file(filePath).text();
1378
1393
  const html = asRoot ? rootRelativeUrls(read) : read;
1379
- const injected = html.includes('</body>')
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
- await buildEpub(config, opts ?? {});
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
- await $ `zip -Xr9D ${outputAbs} META-INF OEBPS -x mimetype`
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 html = rewriteInBookLinks(
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
  }