tosijs-ui 1.9.2 → 1.9.3

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,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.3
4
+
5
+ Two adopter-reported fixes, both with the diagnosis largely done by the reporter.
6
+
7
+ ### The dev server crashed instead of exiting (#47)
8
+
9
+ A long-lived `bun start` **segfaulted at the 8-hour idle timeout** rather than exiting —
10
+ so a workspace left up for a working day went offline silently, and the first sign was a
11
+ tunnel link failing. All six exit paths called `server.stop()` immediately before
12
+ `process.exit()`; that buys nothing (the OS closes the sockets) and was exactly where it
13
+ died. They now just exit.
14
+
15
+ Note for anyone hitting this elsewhere: **`try/catch` is not a mitigation** — a segfault
16
+ is not a catchable exception, so the only workaround is not calling the API. Filed
17
+ upstream as [oven-sh/bun#36788](https://github.com/oven-sh/bun/issues/36788).
18
+
19
+ ### ePub volumes are now linkable (#46)
20
+
21
+ The build wrote an ePub per volume and linked to none of them, so a reader had no route
22
+ to a book that existed. The filename is *derived* (`<project>-<volume>.epub`), so
23
+ hand-written links rot silently when a volume is renamed.
24
+
25
+ Three ways to surface them, and the ePub build now names its output through the same
26
+ helper, so a link cannot point at a name nothing wrote:
27
+
28
+ ```text
29
+ <!-- epub-downloads --> drop in any page → a link per volume
30
+ /epub-volumes.json manifest: book, title, filename, url
31
+ listEpubVolumes() exported from tosijs-ui/site
32
+ ```
33
+
34
+ `epub.volumeTitles` renames what humans read without moving what published links point
35
+ at.
36
+
37
+ ### Also
38
+
39
+ - **A valid session now trumps a stale invite link** (#45). Clicking an older link while
40
+ already signed in — a second window, a link scrolled back to in chat, a bookmark —
41
+ walled you with "that invite link has been used". The stale token is simply irrelevant
42
+ to someone who already holds a session.
43
+ - **`tosijs-tunnel --close` confirms identity before signalling.** It SIGTERMed every
44
+ `pgrep -f` match, and `pgrep -f` matches an argv *substring* — a shell echoing the
45
+ command or an editor holding a log path both matched. It now checks the process really
46
+ is `ssh`, says what it signalled, and names what it skipped. It also interpolated `host`
47
+ into a REGEX unescaped, so `me@a.b` matched `me@axb` and a host containing `+`, `(` or
48
+ `*` changed the pattern's meaning outright.
49
+
50
+ ### Internals, for anyone reading the source
51
+
52
+ - One `shutdown(code)` helper replaces the six copy-pasted exit sites. The 1.9.0 review
53
+ had flagged that duplication as a tidiness follow-up; it turned out not to be untidy but
54
+ to be six copies of the crash above — you cannot fix a bug once if it exists six times.
55
+ - `resolveLinkArrival()` joins `mayReadSite` / `isLockedDown` as a decision extracted from
56
+ the dev server's request closure, where nothing could test it. Each of these was
57
+ extracted only after shipping a bug in it.
58
+
3
59
  ## 1.9.2
4
60
 
5
61
  **Accessibility: every default color pair now meets WCAG AA.** Found by running
package/bin/tunnel.ts CHANGED
@@ -108,8 +108,17 @@ if (!host) {
108
108
  process.exit(1)
109
109
  }
110
110
 
111
- /** pgrep pattern that matches only OUR forward, so --close can't kill someone else's. */
112
- const pattern = `ssh .*-R ${remotePort}:localhost:${tunnelLocalPort} ${host}`
111
+ /*
112
+ pgrep pattern that matches only OUR forward, so --close can't kill someone else's.
113
+
114
+ `host` is interpolated into a REGEX, so it must be escaped: it is user config
115
+ (`user@box.example.com`), and an unescaped `.` matches any character while `+`, `(` or
116
+ `*` change the pattern's meaning outright. Unescaped, `me@a.b` also matches `me@axb`.
117
+ */
118
+ const rx = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
119
+ const pattern = `ssh .*-R ${remotePort}:localhost:${tunnelLocalPort} ${rx(
120
+ String(host)
121
+ )}`
113
122
 
114
123
  async function running(): Promise<number[]> {
115
124
  const out = await $`pgrep -f ${pattern}`.nothrow().quiet().text()
@@ -175,16 +184,43 @@ if (has('status')) {
175
184
  }
176
185
 
177
186
  if (has('close')) {
187
+ /*
188
+ Confirm identity before signalling, and leave a receipt.
189
+
190
+ `pgrep -f` matches an argv SUBSTRING, so anything whose command line happens to contain
191
+ this pattern matches — a shell running an `echo` of the command, an editor holding a log
192
+ path, another agent tailing output. Signalling on a substring match alone is how a tool
193
+ kills something it never started.
194
+
195
+ `killStrayServer` in dev-server.ts already set this norm ("reclaiming a port is
196
+ destructive, so the log says exactly which pid/comm"), but the discipline lived in
197
+ another file and did not travel. Same rule here: check `comm` is really ssh, say what
198
+ was signalled, and name what was skipped and why.
199
+ */
178
200
  const pids = await running()
201
+ const closed: number[] = []
179
202
  for (const pid of pids) {
203
+ const comm = (
204
+ await $`ps -p ${pid} -o comm=`.nothrow().quiet().text()
205
+ ).trim()
206
+ if (!/(^|\/)ssh$/.test(comm)) {
207
+ console.warn(
208
+ `⚠️ pid ${pid} matched the tunnel pattern but is ${
209
+ comm || 'unknown'
210
+ }, not ssh — leaving it alone.`
211
+ )
212
+ continue
213
+ }
180
214
  try {
181
215
  process.kill(pid, 'SIGTERM')
216
+ closed.push(pid)
217
+ console.log(` SIGTERM → pid ${pid} (${comm})`)
182
218
  } catch {
183
- /* already gone */
219
+ /* already gone between pgrep and here */
184
220
  }
185
221
  }
186
222
  console.log(
187
- pids.length ? `closed (pid ${pids.join(', ')})` : 'nothing to close'
223
+ closed.length ? `closed (pid ${closed.join(', ')})` : 'nothing to close'
188
224
  )
189
225
  process.exit(0)
190
226
  }
@@ -83,3 +83,9 @@ export declare function hasTunnel(config: {
83
83
  tunnel?: unknown;
84
84
  };
85
85
  }): boolean;
86
+ export type LinkArrival = 'issue-session' | 'already-authenticated' | 'reject';
87
+ export declare function resolveLinkArrival(opts: {
88
+ /** the session redeemLink() minted, or null if the token was invalid/spent */
89
+ redeemed: string | null;
90
+ hasValidSession: boolean;
91
+ }): LinkArrival;
@@ -266,3 +266,10 @@ export function isLockedDown(config) {
266
266
  export function hasTunnel(config) {
267
267
  return Boolean(config.preview?.tunnel);
268
268
  }
269
+ export function resolveLinkArrival(opts) {
270
+ if (opts.redeemed)
271
+ return 'issue-session';
272
+ if (opts.hasValidSession)
273
+ return 'already-authenticated';
274
+ return 'reject';
275
+ }
@@ -16,7 +16,7 @@ import { preflight } from './preflight.js';
16
16
  import { auditDependencies, reportAudit } from './audit-guard.js';
17
17
  import { openDevBrowser } from './open-browser.js';
18
18
  import { resolveTunnelLocalPort } from './site-config.js';
19
- import { TUNNEL_LINK_CMD, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, readCookie, redeemLink, sessionCookie, urlWithoutToken, validSession, mayWriteSource, isProxiedRequest, SESSION_COOKIE, } from './dev-auth.js';
19
+ import { TUNNEL_LINK_CMD, resolveLinkArrival, isLockedDown, hasTunnel, isLoopbackAddressForAuth as isLoopbackAddress, mayReadSite, shouldInterceptLinkToken, createAuthState, issueLink, readCookie, redeemLink, sessionCookie, urlWithoutToken, validSession, mayWriteSource, isProxiedRequest, SESSION_COOKIE, } from './dev-auth.js';
20
20
  const TEST_RESULTS_FILE = '.browser-tests.json';
21
21
  const DEFAULT_IDLE_HOURS = 8;
22
22
  /**
@@ -941,20 +941,37 @@ export async function devServer(config, opts = {}) {
941
941
  'Referrer-Policy': 'no-referrer',
942
942
  'Cache-Control': 'no-store',
943
943
  };
944
- if (session) {
944
+ const heldCookie = readCookie(request.headers.get('cookie'), SESSION_COOKIE);
945
+ const arrival = resolveLinkArrival({
946
+ redeemed: session,
947
+ hasValidSession: validSession(auth, heldCookie, Date.now()),
948
+ });
949
+ if (arrival === 'issue-session') {
945
950
  headers['Set-Cookie'] = sessionCookie(session);
946
951
  console.log('🔓 edit link redeemed — session issued');
947
952
  return new Response(null, { status: 302, headers });
948
953
  }
949
954
  /*
950
- Invalid or ALREADY SPENT — and the likely culprit is not an attacker but a
955
+ A VALID SESSION TRUMPS A STALE LINK.
956
+
957
+ The comment that used to sit here asserted that a session holder "never lands
958
+ here, their cookie is sent automatically" — and nothing enforced it. The token is
959
+ read before any session check, so anyone already signed in who clicks an older link
960
+ (a second window, a link scrolled back to in chat, a bookmark) got walled with
961
+ "that invite link has been used" while holding a perfectly good session. The stale
962
+ token is simply irrelevant to them.
963
+
964
+ Strip it and carry on. No new session is issued: they already have one, and
965
+ re-issuing on a spent token would make expiry meaningless.
966
+ */
967
+ if (arrival === 'already-authenticated') {
968
+ return new Response(null, { status: 302, headers });
969
+ }
970
+ /*
971
+ Genuinely unauthenticated, and the likely culprit is not an attacker but a
951
972
  chat-app link-preview bot, whose GET *is* the first use. dev-auth.ts names
952
973
  unfurlers as a reason for single-use without handling the consequence: you click
953
974
  a dead link, get silently redirected, and the next signal is a save failing.
954
-
955
- An anonymous reader who already holds a session never lands here (their cookie is
956
- sent automatically), so saying this out loud costs nothing and saves a confusing
957
- hunt.
958
975
  */
959
976
  const spent = `<!doctype html><meta charset=utf-8><title>Link already used</title>` +
960
977
  `<style>body{font:16px/1.6 system-ui;margin:15vh auto;max-width:32rem;padding:0 1.5rem;color:#222}` +
@@ -1072,7 +1089,12 @@ export async function devServer(config, opts = {}) {
1072
1089
  }
1073
1090
  return new Response('File not found', { status: 404 });
1074
1091
  };
1075
- const server = Bun.serve({
1092
+ /*
1093
+ Not bound to a name: nothing stops these listeners. `process.exit()` ends the process
1094
+ and the OS closes the sockets, and calling `.stop()` first is what segfaulted on the
1095
+ idle path (#47). A handle we would never legitimately use is an invitation to use it.
1096
+ */
1097
+ Bun.serve({
1076
1098
  port: PORT,
1077
1099
  tls: {
1078
1100
  key: Bun.file('./tls/key.pem'),
@@ -1108,10 +1130,9 @@ export async function devServer(config, opts = {}) {
1108
1130
  ? resolveTunnelLocalPort(config, process.env)
1109
1131
  : undefined;
1110
1132
  const configuredTunnelPort = config.preview?.tunnel?.localPort;
1111
- let tunnelServer;
1112
1133
  if (tunnelPort && !testMode && !process.env.DEV_NO_TUNNEL) {
1113
1134
  try {
1114
- tunnelServer = Bun.serve({
1135
+ Bun.serve({
1115
1136
  port: tunnelPort,
1116
1137
  hostname: '127.0.0.1',
1117
1138
  fetch: (request, srv) => handleRequest(request, srv, true),
@@ -1132,6 +1153,27 @@ export async function devServer(config, opts = {}) {
1132
1153
  `${configuredTunnelPort ? '' : ' — it defaults to PORT + 1'}.`);
1133
1154
  }
1134
1155
  }
1156
+ /*
1157
+ Exit WITHOUT stopping the listeners first.
1158
+
1159
+ Every one of these six sites called `server.stop()` (and `tunnelServer?.stop()`)
1160
+ immediately before `process.exit()`. That buys nothing — `process.exit` terminates the
1161
+ process and the OS closes the listening sockets — and it is where the process was
1162
+ dying: tosijs-3d reported a reproducible **segfault at ~7.95h** against the 8h idle
1163
+ timer, twice, on the shutdown path, with flat 0.34GB RSS (so not the Bun.build native
1164
+ leak) and a faulting address that decodes to ASCII text rather than a null — the shape
1165
+ of a use-after-free inside Bun's own teardown. tosijs-ui#47.
1166
+
1167
+ A `try/catch` around the stops would NOT help: a segfault is not a JS exception. Not
1168
+ making the call is the only fix available to us. Filed upstream separately, because
1169
+ Bun should not crash here either.
1170
+
1171
+ Child processes we spawned are a different matter — those we must still reap, since the
1172
+ OS will not do it for a detached Electron grandchild. Callers do that before calling in.
1173
+ */
1174
+ const shutdown = (code) => {
1175
+ process.exit(code);
1176
+ };
1135
1177
  console.log(`Listening on https://localhost:${PORT}`);
1136
1178
  // ── open (or bring to front) this project's browser tab ─────────────────────
1137
1179
  //
@@ -1183,9 +1225,7 @@ export async function devServer(config, opts = {}) {
1183
1225
  ` updates, which a long-running process never does. Disable with\n` +
1184
1226
  ` DEV_IDLE_TIMEOUT_HOURS=0 or idleTimeoutHours in your site config.\n`);
1185
1227
  clearInterval(timer);
1186
- server.stop();
1187
- tunnelServer?.stop();
1188
- process.exit(0);
1228
+ shutdown(0);
1189
1229
  }
1190
1230
  const ok = await preflight({
1191
1231
  label: 'Dev server',
@@ -1194,9 +1234,7 @@ export async function devServer(config, opts = {}) {
1194
1234
  });
1195
1235
  if (!ok) {
1196
1236
  clearInterval(timer);
1197
- server.stop();
1198
- tunnelServer?.stop();
1199
- process.exit(1);
1237
+ shutdown(1);
1200
1238
  }
1201
1239
  }, TICK_MS);
1202
1240
  // Never let the health check itself be the thing keeping the process alive.
@@ -1315,8 +1353,7 @@ export async function devServer(config, opts = {}) {
1315
1353
  console.error(`haltija did not report a private port within 30s (${portFile}).\n` +
1316
1354
  ` Is \`bunx ${HALTIJA_PKG}\` able to run? Try it by hand.`);
1317
1355
  await stopHaltija();
1318
- server.stop();
1319
- process.exit(1);
1356
+ shutdown(1);
1320
1357
  }
1321
1358
  console.log(` private haltija on port ${hjPort}`);
1322
1359
  console.log('Opening demo site...');
@@ -1332,21 +1369,18 @@ export async function devServer(config, opts = {}) {
1332
1369
  if (nav.exitCode !== 0) {
1333
1370
  console.error(`hj navigate failed (exit ${nav.exitCode}): ${nav.stderr.toString().trim() || 'no browser reachable'}`);
1334
1371
  await stopHaltija();
1335
- server.stop();
1336
- process.exit(1);
1372
+ shutdown(1);
1337
1373
  }
1338
1374
  try {
1339
1375
  const results = await testResults;
1340
1376
  const exitCode = results.failed > 0 ? 1 : 0;
1341
1377
  await stopHaltija();
1342
- server.stop();
1343
- process.exit(exitCode);
1378
+ shutdown(exitCode);
1344
1379
  }
1345
1380
  catch (e) {
1346
1381
  console.error(e.message);
1347
1382
  await stopHaltija();
1348
- server.stop();
1349
- process.exit(1);
1383
+ shutdown(1);
1350
1384
  }
1351
1385
  }
1352
1386
  }
@@ -0,0 +1,49 @@
1
+ export interface EpubVolume {
2
+ /** the `book` value, or '' for the default volume */
3
+ book: string;
4
+ /** human title — "<project>" or "<project> — <volume>", or an explicit override */
5
+ title: string;
6
+ /** output filename, e.g. `my-project-appendices.epub` */
7
+ filename: string;
8
+ /** served URL, honouring basePath */
9
+ url: string;
10
+ }
11
+ export interface VolumeNamingConfig {
12
+ name?: string;
13
+ basePath?: string;
14
+ epub?: boolean | {
15
+ title?: string;
16
+ volumeTitles?: Record<string, string>;
17
+ };
18
+ }
19
+ /**
20
+ * The identity of one volume. `bookTarget` undefined means the default volume.
21
+ *
22
+ * Kept deliberately pure and dependency-free so the ePub builder, the site build and an
23
+ * adopter's own script all agree by construction rather than by comment.
24
+ */
25
+ export declare function epubVolumeIdentity(config: VolumeNamingConfig, bookTarget?: string): EpubVolume;
26
+ /**
27
+ * Every volume this corpus will produce, default first.
28
+ *
29
+ * Empty when the corpus has no publishable docs at all — which is a real state (everything
30
+ * hidden, or everything `book: "none"`) and must not be reported as one nameless volume.
31
+ */
32
+ export declare function listEpubVolumes(corpus: Array<{
33
+ filename: string;
34
+ title?: string;
35
+ parent?: string;
36
+ book?: string | string[];
37
+ hidden?: boolean;
38
+ }>, config: VolumeNamingConfig): EpubVolume[];
39
+ /** The marker a consumer drops into a page to get the download list. */
40
+ export declare const EPUB_DOWNLOADS_MARKER: RegExp;
41
+ /**
42
+ * Replace `<!-- epub-downloads -->` with a markdown list of the built volumes.
43
+ *
44
+ * Substituted at BUILD time, into the doc text, so the statically-rendered page and the
45
+ * hydrated SPA show the same thing and the client needs no new data source. A marker in a
46
+ * corpus that builds no ePub renders as nothing rather than an empty list or a stray
47
+ * comment.
48
+ */
49
+ export declare function renderEpubDownloads(text: string, volumes: EpubVolume[]): string;
@@ -0,0 +1,88 @@
1
+ /*
2
+ Which ePubs does this build produce, and where do they land?
3
+
4
+ The output filename is DERIVED — from the project name and, for a named volume, the
5
+ `book` value (`foresight-rpg` + `book: "foresight-1986"` →
6
+ `foresight-rpg-foresight-1986.epub`). Nothing published that derivation, so a consumer
7
+ wanting to link their own book had to reverse-engineer it from the source and re-derive it
8
+ by hand every time a volume was renamed.
9
+
10
+ That is not a documentation gap, it is a missing seam: foresight-rpg shipped a valid ePub
11
+ that **nobody could download**, because the site built the file and then linked to nothing.
12
+ It was caught on review rather than by anyone noticing — which is the tell. A build that
13
+ produces an artifact should be able to say where it is.
14
+
15
+ One definition, used by three callers: the ePub build names its output with it, the
16
+ orchestrator writes a manifest from it, and `<!-- epub-downloads -->` in any page renders
17
+ from it. Consumers can import it directly rather than hard-coding a filename.
18
+ */
19
+ import { DEFAULT_BOOK, namedBooks, partitionByBook } from '../book-target.js';
20
+ import { buildSlugMap } from '../routing.js';
21
+ /** Lowercase, hyphenated — the same shape the ePub build uses for filenames. */
22
+ function slugify(text) {
23
+ return text
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-|-$/g, '');
27
+ }
28
+ /**
29
+ * The identity of one volume. `bookTarget` undefined means the default volume.
30
+ *
31
+ * Kept deliberately pure and dependency-free so the ePub builder, the site build and an
32
+ * adopter's own script all agree by construction rather than by comment.
33
+ */
34
+ export function epubVolumeIdentity(config, bookTarget) {
35
+ const epub = typeof config.epub === 'object' ? config.epub : {};
36
+ const baseTitle = epub.title ?? config.name ?? 'book';
37
+ const title = bookTarget
38
+ ? (epub.volumeTitles?.[bookTarget] ?? `${baseTitle} — ${bookTarget}`)
39
+ : baseTitle;
40
+ const filename = bookTarget
41
+ ? `${slugify(baseTitle)}-${slugify(bookTarget)}.epub`
42
+ : `${slugify(baseTitle)}.epub`;
43
+ const base = (config.basePath ?? '/').replace(/\/+$/, '');
44
+ return { book: bookTarget ?? DEFAULT_BOOK, title, filename, url: `${base}/${filename}` };
45
+ }
46
+ /**
47
+ * Every volume this corpus will produce, default first.
48
+ *
49
+ * Empty when the corpus has no publishable docs at all — which is a real state (everything
50
+ * hidden, or everything `book: "none"`) and must not be reported as one nameless volume.
51
+ */
52
+ export function listEpubVolumes(corpus, config) {
53
+ const slugs = buildSlugMap(corpus);
54
+ const partitioned = partitionByBook(corpus, slugs);
55
+ const books = [
56
+ ...(partitioned.get(DEFAULT_BOOK)?.length ? [undefined] : []),
57
+ ...namedBooks(corpus, slugs),
58
+ ];
59
+ return books.map((b) => epubVolumeIdentity(config, b));
60
+ }
61
+ /** The marker a consumer drops into a page to get the download list. */
62
+ export const EPUB_DOWNLOADS_MARKER = /<!--\s*epub-downloads\s*-->/g;
63
+ /**
64
+ * Replace `<!-- epub-downloads -->` with a markdown list of the built volumes.
65
+ *
66
+ * Substituted at BUILD time, into the doc text, so the statically-rendered page and the
67
+ * hydrated SPA show the same thing and the client needs no new data source. A marker in a
68
+ * corpus that builds no ePub renders as nothing rather than an empty list or a stray
69
+ * comment.
70
+ */
71
+ export function renderEpubDownloads(text, volumes) {
72
+ /*
73
+ No `.test()` guard — but as simplification, not a bug fix.
74
+
75
+ A `/g` regex carries `lastIndex`, and `.test()` advances it, which LOOKS like it should
76
+ make a `test`-then-`replace` pair stateful. It does not: `String.replace` with `/g`
77
+ resets `lastIndex` to 0, and a failing `.test()` resets it too, so the pair is
78
+ self-correcting. (Verified rather than reasoned — an earlier version of this comment
79
+ confidently described an alternating no-op that mutation testing showed does not exist.)
80
+
81
+ The guard is dropped because `replace` already returns the input unchanged when nothing
82
+ matches, so it only bought a redundant scan and a subtlety to explain.
83
+ */
84
+ const list = volumes
85
+ .map((v) => `- [${v.title}](${v.url}) *(ePub)*`)
86
+ .join('\n');
87
+ return text.replace(EPUB_DOWNLOADS_MARKER, list);
88
+ }
@@ -20,6 +20,7 @@ import { renderDocMarkdown } from '../render.js';
20
20
  import { buildSlugMap, pathForSlug, slugForPath } from '../routing.js';
21
21
  import { buildNavTree } from '../nav-tree.js';
22
22
  import { partitionByBook, DEFAULT_BOOK } from '../book-target.js';
23
+ import { epubVolumeIdentity } from './epub-volumes.js';
23
24
  import { DEFAULT_BOOK_CSS, stripDocMeta, flatten, slugify } from '../book-html.js';
24
25
  import { selectBookDocs } from '../book-manifest.js';
25
26
  // Re-exported for back-compat (tosijs-ui/site's public surface + tests).
@@ -637,9 +638,9 @@ export async function buildEpub(config, opts = {}) {
637
638
  // An explicit `output` names ONE file, so it can only apply to the default volume —
638
639
  // otherwise every volume writes to it and you ship whichever bound last.
639
640
  (opts.bookTarget ? undefined : opts.output) ??
640
- path.join(outDir, opts.bookTarget
641
- ? `${slugify(baseTitle)}-${slugify(opts.bookTarget)}.epub`
642
- : `${slugify(baseTitle)}.epub`));
641
+ // Named through the SHARED helper, so the manifest, the download links and the file
642
+ // on disk all derive from one function. A link cannot point at a name nothing wrote.
643
+ path.join(outDir, epubVolumeIdentity({ name: baseTitle, epub: { title: baseTitle } }, opts.bookTarget).filename));
643
644
  await zipEpub(buildDir, output);
644
645
  fs.rmSync(buildDir, { recursive: true, force: true });
645
646
  // Release the parser window: an unclosed happy-dom Window holds its whole
@@ -14,3 +14,4 @@ export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
14
14
  export type { BuildEpubOptions, BookMeta } from './epub.js';
15
15
  export { selectBookDocs } from '../book-manifest.js';
16
16
  export type { BookManifest } from '../book-manifest.js';
17
+ export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, type EpubVolume, } from './epub-volumes.js';
@@ -12,3 +12,4 @@ export { extractDocs, saveDocsJSON } from './docs.js';
12
12
  export { generateSite } from './generate-site.js';
13
13
  export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
14
14
  export { selectBookDocs } from '../book-manifest.js';
15
+ export { listEpubVolumes, epubVolumeIdentity, renderEpubDownloads, } from './epub-volumes.js';
@@ -13,6 +13,7 @@ cause an endless rebuild loop.
13
13
  */
14
14
  import * as path from 'path';
15
15
  import { namedBooks, partitionByBook, DEFAULT_BOOK } from '../book-target.js';
16
+ import { listEpubVolumes, renderEpubDownloads, } from './epub-volumes.js';
16
17
  import { buildSlugMap } from '../routing.js';
17
18
  import { existsSync, mkdirSync, unlinkSync } from 'fs';
18
19
  import { tmpdir } from 'os';
@@ -288,6 +289,39 @@ export async function buildSite(config, opts = {}) {
288
289
  output: DOCS_JSON,
289
290
  });
290
291
  extract();
292
+ /*
293
+ Fill `<!-- epub-downloads -->` and publish a manifest of the volumes this build makes.
294
+
295
+ Substituted into the corpus HERE, before pages are generated, so the static page and
296
+ the hydrated SPA render the same list and the client needs no new data source. The
297
+ volume set is computed from the corpus itself, so it is known before the ePubs are
298
+ built — the links and the files derive from one function and cannot disagree.
299
+
300
+ Why this exists: the build produced a valid ePub and linked to nothing, so a reader had
301
+ no route to it. A build that makes an artifact should be able to say where it is
302
+ (tosijs-ui#46).
303
+ */
304
+ const publishVolumeLinks = async () => {
305
+ if (!config.epub)
306
+ return;
307
+ const corpus = JSON.parse(await Bun.file(DOCS_JSON).text());
308
+ const volumes = listEpubVolumes(corpus, config);
309
+ if (!volumes.length)
310
+ return;
311
+ let touched = 0;
312
+ for (const doc of corpus) {
313
+ const filled = renderEpubDownloads(doc.text ?? '', volumes);
314
+ if (filled !== doc.text) {
315
+ doc.text = filled;
316
+ touched++;
317
+ }
318
+ }
319
+ if (touched)
320
+ await Bun.write(DOCS_JSON, JSON.stringify(corpus, null, 2));
321
+ // A stable, machine-readable list so a consumer can link the books without
322
+ // re-deriving the filename — the "documented helper" half of #46.
323
+ await Bun.write(`${PUBLIC}/epub-volumes.json`, JSON.stringify(volumes, null, 2));
324
+ };
291
325
  // Auto-create missing section docs + regenerate their TOC blocks, then
292
326
  // re-extract so the corpus reflects the on-disk changes.
293
327
  ensureSections({
@@ -676,6 +710,9 @@ export async function buildSite(config, opts = {}) {
676
710
  console.warn(`import-resolver: could not set it up (${String(e)}) — skipping`);
677
711
  }
678
712
  }
713
+ // Fill the download marker and write the volume manifest before pages are rendered,
714
+ // so the static HTML and the SPA agree. PUBLIC exists by now (static assets copied).
715
+ await publishVolumeLinks();
679
716
  // Generate the static, pre-rendered doc site (one /slug/index.html per doc).
680
717
  // Runs after the static-asset copy so the generated index.html (README) wins,
681
718
  // and after the bundle copy so every page's <script src> resolves.