tosijs-ui 1.6.21 → 1.7.0-beta.1

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.
@@ -0,0 +1,50 @@
1
+ declare global {
2
+ var Bun: any;
3
+ }
4
+ /** The specifier that must NOT survive in the bundle when tjs-lang is installed. */
5
+ export declare const TJS_EDITOR_SPECIFIER = "tjs-lang/editors/codemirror";
6
+ /**
7
+ * Externalize the tjs CodeMirror extension ONLY when tjs-lang isn't installed.
8
+ *
9
+ * When it IS installed we bundle it in, so it shares the editor's single CodeMirror
10
+ * instance. When it's absent, bundling would fail to resolve, so we externalize it and
11
+ * the runtime import degrades to plain TS highlighting.
12
+ *
13
+ * Returns [] (i.e. "bundle it") when tjs-lang resolves, else [TJS_EDITOR_SPECIFIER].
14
+ */
15
+ export declare function tjsEditorExternal(root: string): string[];
16
+ /**
17
+ * Did the tjs CodeMirror extension get externalized out of the bundle?
18
+ *
19
+ * If it was bundled, its specifier is gone (the module's code is inlined). If it was
20
+ * externalized, the bundler leaves the specifier behind in an `import("…")`. So the
21
+ * presence of the specifier in the emitted JS means the invariant is broken.
22
+ *
23
+ * Checking the identifier (`tjsCompletionSource`) instead would be useless: the import
24
+ * SITE names it either way, so that grep passes in exactly the broken case.
25
+ */
26
+ export declare function tjsEditorLeakedAsExternal(bundleJs: string): boolean;
27
+ /**
28
+ * Compile (without running) the bundle as a classic script body, and return the syntax
29
+ * error if it isn't valid. `import.meta` is a SyntaxError outside a module, so this
30
+ * catches the real failure.
31
+ *
32
+ * The naive `bundleJs.includes('import.meta')` this replaces false-positived on EVERY
33
+ * build that bundled a JS parser: acorn's own error-message string literals contain
34
+ * "import.meta", so the guard fired while the bundle was perfectly fine — and its advice
35
+ * ("mark that dep external") would have undone the deliberate decision to bundle the tjs
36
+ * CodeMirror extension. A guard that cries wolf on every build trains everyone to ignore
37
+ * the real hit.
38
+ */
39
+ export declare function classicScriptSyntaxError(bundleJs: string): string | null;
40
+ /**
41
+ * The same check, run in a CHILD process — this is what the build calls.
42
+ *
43
+ * Compiling the bundle strands memory in the parent: JSC caches the compiled code, so
44
+ * `new Function()` over a 1.2MB bundle retains ~378KB *per call* even after a forced
45
+ * GC. The build runs on every dev rebuild in a process that lives for days, so that is
46
+ * exactly the shape of leak this release just fixed elsewhere — parsing to guard
47
+ * against a leak while leaking would be a poor joke. The child hands it all back on
48
+ * exit. (Cost: one ~30ms process. See oven-sh/bun#34053 for the family.)
49
+ */
50
+ export declare function classicScriptSyntaxErrorInChild(bundlePath: string): Promise<string | null>;
@@ -0,0 +1,111 @@
1
+ /*
2
+ Guards on the emitted hydration bundle.
3
+
4
+ Two invariants that both fail SILENTLY at runtime, which is why they're checked at
5
+ build time against the actual emitted bytes rather than against the config:
6
+
7
+ 1. `import.meta` must not survive into an IIFE — it's a SyntaxError in a classic
8
+ <script>, so the whole bundle fails to evaluate and the page never hydrates.
9
+ 2. tjs-lang's CodeMirror extension must be BUNDLED, not externalized — a separately
10
+ loaded copy carries its own `@codemirror/state` and silently no-ops (no error, no
11
+ warning; tjs highlighting and autocomplete just quietly stop working).
12
+
13
+ Build-time only. Never import from browser code.
14
+ */
15
+ /** The specifier that must NOT survive in the bundle when tjs-lang is installed. */
16
+ export const TJS_EDITOR_SPECIFIER = 'tjs-lang/editors/codemirror';
17
+ /**
18
+ * Externalize the tjs CodeMirror extension ONLY when tjs-lang isn't installed.
19
+ *
20
+ * When it IS installed we bundle it in, so it shares the editor's single CodeMirror
21
+ * instance. When it's absent, bundling would fail to resolve, so we externalize it and
22
+ * the runtime import degrades to plain TS highlighting.
23
+ *
24
+ * Returns [] (i.e. "bundle it") when tjs-lang resolves, else [TJS_EDITOR_SPECIFIER].
25
+ */
26
+ export function tjsEditorExternal(root) {
27
+ try {
28
+ Bun.resolveSync(TJS_EDITOR_SPECIFIER, root);
29
+ return [];
30
+ }
31
+ catch {
32
+ return [TJS_EDITOR_SPECIFIER];
33
+ }
34
+ }
35
+ /**
36
+ * Did the tjs CodeMirror extension get externalized out of the bundle?
37
+ *
38
+ * If it was bundled, its specifier is gone (the module's code is inlined). If it was
39
+ * externalized, the bundler leaves the specifier behind in an `import("…")`. So the
40
+ * presence of the specifier in the emitted JS means the invariant is broken.
41
+ *
42
+ * Checking the identifier (`tjsCompletionSource`) instead would be useless: the import
43
+ * SITE names it either way, so that grep passes in exactly the broken case.
44
+ */
45
+ export function tjsEditorLeakedAsExternal(bundleJs) {
46
+ return bundleJs.includes(TJS_EDITOR_SPECIFIER);
47
+ }
48
+ /**
49
+ * Compile (without running) the bundle as a classic script body, and return the syntax
50
+ * error if it isn't valid. `import.meta` is a SyntaxError outside a module, so this
51
+ * catches the real failure.
52
+ *
53
+ * The naive `bundleJs.includes('import.meta')` this replaces false-positived on EVERY
54
+ * build that bundled a JS parser: acorn's own error-message string literals contain
55
+ * "import.meta", so the guard fired while the bundle was perfectly fine — and its advice
56
+ * ("mark that dep external") would have undone the deliberate decision to bundle the tjs
57
+ * CodeMirror extension. A guard that cries wolf on every build trains everyone to ignore
58
+ * the real hit.
59
+ */
60
+ export function classicScriptSyntaxError(bundleJs) {
61
+ try {
62
+ new Function(bundleJs);
63
+ return null;
64
+ }
65
+ catch (e) {
66
+ return e instanceof SyntaxError ? e.message : null;
67
+ }
68
+ }
69
+ /**
70
+ * The same check, run in a CHILD process — this is what the build calls.
71
+ *
72
+ * Compiling the bundle strands memory in the parent: JSC caches the compiled code, so
73
+ * `new Function()` over a 1.2MB bundle retains ~378KB *per call* even after a forced
74
+ * GC. The build runs on every dev rebuild in a process that lives for days, so that is
75
+ * exactly the shape of leak this release just fixed elsewhere — parsing to guard
76
+ * against a leak while leaking would be a poor joke. The child hands it all back on
77
+ * exit. (Cost: one ~30ms process. See oven-sh/bun#34053 for the family.)
78
+ */
79
+ export async function classicScriptSyntaxErrorInChild(bundlePath) {
80
+ const self = `${import.meta.dir}/bundle-guard.ts`;
81
+ const cli = (await Bun.file(self).exists())
82
+ ? self
83
+ : `${import.meta.dir}/bundle-guard.js`;
84
+ const child = Bun.spawn(['bun', cli, bundlePath], {
85
+ stdout: 'ignore',
86
+ stderr: 'pipe',
87
+ });
88
+ // ALWAYS drain stderr, not just on failure: an unconsumed pipe stays open and its
89
+ // buffer is retained, which turns this guard into a per-rebuild leak of its own.
90
+ const [code, message] = await Promise.all([
91
+ child.exited,
92
+ new Response(child.stderr).text(),
93
+ ]);
94
+ if (code === 0)
95
+ return null;
96
+ return message.trim() || 'the bundle does not parse as a classic <script>';
97
+ }
98
+ // CLI entry (the child above): `bun bundle-guard.ts <bundle.js>` — exits 1 with the
99
+ // syntax error on stderr, 0 if the bundle is a valid classic script.
100
+ if (import.meta.main) {
101
+ const file = process.argv[2];
102
+ if (!file) {
103
+ console.error('bundle-guard: expected a bundle path');
104
+ process.exit(1);
105
+ }
106
+ const error = classicScriptSyntaxError(await Bun.file(file).text());
107
+ if (error) {
108
+ console.error(error);
109
+ process.exit(1);
110
+ }
111
+ }
@@ -14,7 +14,7 @@ are not executed, so they're skipped.
14
14
  Build-time only (bun). Never import from browser code.
15
15
  */
16
16
  import { marked } from 'marked';
17
- import { rewriteImports, AsyncFunction, loadTransform } from '../../live-example/code-transform';
17
+ import { rewriteImports, AsyncFunction, loadTransform, } from '../../live-example/code-transform';
18
18
  // The default live-example context (matches the IIFE globals the pages provide).
19
19
  // A project that sets a custom `context` on its <tosi-doc-system> can pass its
20
20
  // own keys; these are the tosijs-ui defaults.
@@ -44,6 +44,14 @@ function collectCodeTokens(text) {
44
44
  export async function checkExamples(docs, opts = {}) {
45
45
  const contextKeys = opts.contextKeys ?? DEFAULT_CONTEXT_KEYS;
46
46
  const problems = [];
47
+ // ONE transpiler for the whole corpus, not one per example. It's stateless, and
48
+ // it's a native object that strands ~40KB of RSS per construction — invisible to
49
+ // the JS heap, so nothing GCs it. checkExamples runs on every dev rebuild, over
50
+ // every `ts` example in the corpus, in a process that lives for days: constructing
51
+ // it in the loop was leaking ~40KB × (ts examples) per rebuild. Reusing one drops
52
+ // that to ~1.6KB per call. (Same family as the Bun.build arena leak — oven-sh/bun#34053.)
53
+ // Lazily created so a corpus with no `ts` examples never makes one.
54
+ let tsTranspiler;
47
55
  for (const doc of docs) {
48
56
  for (const block of collectCodeTokens(doc.text)) {
49
57
  if (!EXECUTABLE.has(block.lang))
@@ -58,7 +66,8 @@ export async function checkExamples(docs, opts = {}) {
58
66
  // fetches the TypeScript compiler from a CDN, which can't run here).
59
67
  // We only need to validate that the source builds, not reproduce tjs
60
68
  // lowering exactly.
61
- js = new Bun.Transpiler({ loader: 'ts' }).transformSync(rewritten);
69
+ tsTranspiler ??= new Bun.Transpiler({ loader: 'ts' });
70
+ js = tsTranspiler.transformSync(rewritten);
62
71
  }
63
72
  else if (dialect === 'tjs') {
64
73
  const transform = await loadTransform('tjs');
@@ -68,7 +77,6 @@ export async function checkExamples(docs, opts = {}) {
68
77
  js = rewritten; // `js` / `test` are already JS
69
78
  }
70
79
  // Syntax-validate the way the component does before running it.
71
- // eslint-disable-next-line no-new
72
80
  new AsyncFunction(js);
73
81
  }
74
82
  catch (err) {
@@ -80,7 +80,10 @@ async function ensureDevCerts() {
80
80
  process.exit(1);
81
81
  }
82
82
  export async function devServer(config, opts = {}) {
83
- const PORT = config.port ?? 8787;
83
+ // PORT env wins over the config, so a test harness can bring up its own instance on
84
+ // its own port instead of adopting (or killing — killStrayServer takes the port)
85
+ // the dev server you already have running.
86
+ const PORT = Number(process.env.PORT ?? config.port ?? 8787);
84
87
  const PUBLIC = path.resolve('./', config.outputDir ?? 'docs');
85
88
  const isSPA = true;
86
89
  const testMode = !!opts.test;
@@ -89,8 +92,17 @@ export async function devServer(config, opts = {}) {
89
92
  // localhost-gated runtime import() of the local channel's dev.js, so haltija
90
93
  // is never bundled and self-disables off-localhost; it's injected only at
91
94
  // serve time (below), so it never lands in the built output.
95
+ //
96
+ // Explicitly OFF under `HALTIJA_DEV=0` and in CI, which both override a config
97
+ // `haltijaDev: true`: the E2E lane starts this server, and there is no agent on
98
+ // the other end of the channel there — spawning it would only download an Electron
99
+ // app into the runner for nobody to look at.
92
100
  const HALTIJA_HTTPS_PORT = 8701;
101
+ const haltijaOff = process.env.HALTIJA_DEV === '0' ||
102
+ process.env.HALTIJA_DEV === 'false' ||
103
+ process.env.CI === 'true';
93
104
  const haltijaDev = !testMode &&
105
+ !haltijaOff &&
94
106
  (config.haltijaDev === true ||
95
107
  process.env.HALTIJA_DEV === '1' ||
96
108
  process.env.HALTIJA_DEV === 'true');
@@ -223,6 +235,57 @@ export async function devServer(config, opts = {}) {
223
235
  // never regenerated — leaving the page's /iife.js to 404 into the SPA
224
236
  // fallback (it "loads as html"). Serialize builds and coalesce bursts.
225
237
  const runBuild = opts.build ?? (() => buildSite(config));
238
+ // Memory watchdog.
239
+ //
240
+ // The build hands work to native code (the bundler, the HTML parser, the SVG
241
+ // rasterizer) which can strand memory the JS heap never sees — so nothing
242
+ // here can GC it away, and `heapUsed` stays flat while RSS climbs. A watch
243
+ // process lives for DAYS across thousands of rebuilds, so a per-rebuild leak
244
+ // compounds until the machine swaps itself to death. That is not
245
+ // hypothetical: a 2-day session reached 136GB RSS and took the machine down
246
+ // (Bun.build's native arena — see orchestrator.ts and oven-sh/bun#34053).
247
+ // Dying loudly beats being the reason a laptop overheats: the dev server is
248
+ // one keystroke to restart, the machine is not.
249
+ const rssMb = () => Math.round(process.memoryUsage().rss / 1e6);
250
+ const limitMb = Number(process.env.DEV_MEMORY_LIMIT_MB ?? config.memoryLimitMb ?? 4096);
251
+ let baselineMb = 0;
252
+ let rebuilds = 0;
253
+ let warned = false;
254
+ const checkMemory = () => {
255
+ const mb = rssMb();
256
+ rebuilds += 1;
257
+ if (!baselineMb)
258
+ baselineMb = mb;
259
+ const growth = mb - baselineMb;
260
+ const each = rebuilds > 1 ? growth / (rebuilds - 1) : 0;
261
+ if (mb >= limitMb) {
262
+ // Distinguish the two ways to get here, because the advice is opposite:
263
+ // memory that GREW across rebuilds is a leak (report it); a build that was
264
+ // simply born bigger than the ceiling just needs a bigger ceiling.
265
+ const leaking = rebuilds > 1 && each >= 1;
266
+ const diagnosis = leaking
267
+ ? ` ${rebuilds} rebuilds, +${growth}MB since the first ` +
268
+ `(~${each.toFixed(1)}MB per rebuild).\n\n` +
269
+ ` Growth per rebuild should be ~0, so this is a leak, not your project\n` +
270
+ ` getting bigger. Restarting reclaims it — please report the numbers\n` +
271
+ ` above. If this build genuinely needs the headroom, raise the ceiling\n` +
272
+ ` with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in your site config.\n`
273
+ : ` ${rebuilds} rebuild${rebuilds === 1 ? '' : 's'}, ` +
274
+ `+${growth}MB since the first — i.e. it is not growing.\n\n` +
275
+ ` This build's baseline footprint is simply above the ceiling, which is\n` +
276
+ ` not a leak. Raise it with DEV_MEMORY_LIMIT_MB=<mb> or memoryLimitMb in\n` +
277
+ ` your site config.\n`;
278
+ console.error(`\n🛑 dev server stopping: ${mb}MB RSS, over the ${limitMb}MB limit.\n\n` +
279
+ diagnosis);
280
+ process.exit(1);
281
+ }
282
+ if (!warned && mb > limitMb * 0.6) {
283
+ warned = true;
284
+ console.warn(`⚠️ dev server at ${mb}MB RSS after ${rebuilds} rebuilds (+${growth}MB, ` +
285
+ `~${each.toFixed(1)}MB each; limit ${limitMb}MB). Growth per rebuild ` +
286
+ `should be ~0 — if this keeps climbing, restart and report it.`);
287
+ }
288
+ };
226
289
  let building = false;
227
290
  let pending = false;
228
291
  const rebuild = async () => {
@@ -239,6 +302,7 @@ export async function devServer(config, opts = {}) {
239
302
  }
240
303
  finally {
241
304
  building = false;
305
+ checkMemory();
242
306
  if (pending) {
243
307
  pending = false;
244
308
  void rebuild();
@@ -313,9 +377,13 @@ export async function devServer(config, opts = {}) {
313
377
  }
314
378
  if (!hjAvailable) {
315
379
  console.log('Starting haltija...');
380
+ // stdout/stderr are NOT inherited: `bunx haltija` launches an Electron app as a
381
+ // grandchild, and killing the bunx wrapper does not kill it. An orphaned Electron
382
+ // holding our inherited stdout keeps the pipe open forever, so a CI job or an
383
+ // agent capturing this command's output sees it hang long after it exited (0).
316
384
  haltija = spawn(['bunx', 'haltija@latest', '-f'], {
317
- stdout: 'inherit',
318
- stderr: 'inherit',
385
+ stdout: 'ignore',
386
+ stderr: 'ignore',
319
387
  });
320
388
  console.log('Waiting for browser...');
321
389
  for (let i = 0; i < 20; i++) {
@@ -343,18 +411,40 @@ export async function devServer(config, opts = {}) {
343
411
  }
344
412
  console.log('Opening demo site...');
345
413
  await $ `hj navigate https://localhost:${PORT}`;
414
+ /**
415
+ * Tear down the haltija WE started — including the Electron grandchild.
416
+ *
417
+ * `haltija.kill()` only signals the `bunx` wrapper; the Electron app it launched
418
+ * survives. A surviving instance leaves stale windows behind, and the next
419
+ * `--test` run reuses any haltija reporting `windows.length > 0` — so the run
420
+ * after this one navigates a dead window, never POSTs /report, and fails with
421
+ * "Browser tests timed out" on a perfectly good codebase. Only ever kills the
422
+ * instance this process spawned; a haltija you were already running is left alone.
423
+ */
424
+ const stopHaltija = async () => {
425
+ if (!haltija)
426
+ return;
427
+ haltija.kill();
428
+ // NB: interpolate the pattern — a quoted literal in a `$` template is passed to
429
+ // pkill WITH the quotes and matches nothing.
430
+ const electron = 'haltija/apps/desktop';
431
+ try {
432
+ await $ `pkill -f ${electron}`.quiet();
433
+ }
434
+ catch {
435
+ // nothing left to kill — fine
436
+ }
437
+ };
346
438
  try {
347
439
  const results = await testResults;
348
440
  const exitCode = results.failed > 0 ? 1 : 0;
349
- if (haltija)
350
- haltija.kill();
441
+ await stopHaltija();
351
442
  server.stop();
352
443
  process.exit(exitCode);
353
444
  }
354
445
  catch (e) {
355
446
  console.error(e.message);
356
- if (haltija)
357
- haltija.kill();
447
+ await stopHaltija();
358
448
  server.stop();
359
449
  process.exit(1);
360
450
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ /*
2
+ Standalone ePub build — the subprocess entry point for buildEpub().
3
+
4
+ buildEpub() drives happy-dom (HTML→XHTML for 50+ chapters) and @resvg/resvg-js
5
+ (cover rasterization). Both hold native/retained memory that the JS heap can't
6
+ see, and the ePub is rebuilt on EVERY dev-server rebuild — so in-process it
7
+ strands memory in a watch process that lives for days. Running it in a child
8
+ hands all of it back to the OS on exit. Same reasoning as the bundle step; see
9
+ the Bun.build note in orchestrator.ts.
10
+
11
+ bun epub-cli.ts <payload.json> # payload = { config, opts }
12
+
13
+ Build-time only. Never import this from browser code.
14
+ */
15
+ import { buildEpub } from './epub';
16
+ const payloadPath = process.argv[2];
17
+ if (!payloadPath) {
18
+ console.error('epub-cli: expected a payload file path');
19
+ process.exit(1);
20
+ }
21
+ const { config, opts } = JSON.parse(await Bun.file(payloadPath).text());
22
+ await buildEpub(config, opts ?? {});
@@ -25,8 +25,20 @@ import { selectBookDocs } from '../book-manifest';
25
25
  export { DEFAULT_BOOK_CSS, stripDocMeta };
26
26
  // ── XML / XHTML helpers ─────────────────────────────────────────────────────
27
27
  const VOID_ELEMENTS = [
28
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
29
- 'param', 'source', 'track', 'wbr',
28
+ 'area',
29
+ 'base',
30
+ 'br',
31
+ 'col',
32
+ 'embed',
33
+ 'hr',
34
+ 'img',
35
+ 'input',
36
+ 'link',
37
+ 'meta',
38
+ 'param',
39
+ 'source',
40
+ 'track',
41
+ 'wbr',
30
42
  ];
31
43
  export function escapeXml(s) {
32
44
  return s
@@ -222,7 +234,9 @@ function packageOpf(meta, chapters, cover) {
222
234
  ...chapters.map((c) => `<itemref idref="${c.id}"/>`),
223
235
  ];
224
236
  // EPUB2 cover fallback (older readers find the thumbnail via this meta).
225
- const coverMeta = cover ? `\n <meta name="cover" content="cover-image"/>` : '';
237
+ const coverMeta = cover
238
+ ? `\n <meta name="cover" content="cover-image"/>`
239
+ : '';
226
240
  return `<?xml version="1.0" encoding="utf-8"?>
227
241
  <package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">
228
242
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
@@ -321,7 +335,9 @@ async function zipEpub(buildDir, outputAbs) {
321
335
  // 1) mimetype first, stored (-0), no extra fields (-X)
322
336
  await $ `zip -X0 ${outputAbs} mimetype`.cwd(buildDir).quiet();
323
337
  // 2) everything else, deflated (-9), recursive (-r), no extra fields, no dir entries (-D)
324
- await $ `zip -Xr9D ${outputAbs} META-INF OEBPS -x mimetype`.cwd(buildDir).quiet();
338
+ await $ `zip -Xr9D ${outputAbs} META-INF OEBPS -x mimetype`
339
+ .cwd(buildDir)
340
+ .quiet();
325
341
  }
326
342
  /** Rasterize an SVG to a PNG buffer via @resvg/resvg-js (optional dep). */
327
343
  async function rasterizeSvg(svg, width) {
@@ -423,7 +439,11 @@ async function makeCover(config, opts, meta) {
423
439
  : ext === '.gif'
424
440
  ? 'image/gif'
425
441
  : 'image/png';
426
- return { file: `cover${ext || '.png'}`, mediaType, data: fs.readFileSync(opts.cover) };
442
+ return {
443
+ file: `cover${ext || '.png'}`,
444
+ mediaType,
445
+ data: fs.readFileSync(opts.cover),
446
+ };
427
447
  }
428
448
  // Embed a glyph (svg) into the generated cover: prefer an explicit `coverIcon`,
429
449
  // else the site favicon. Preserve the source viewBox so any square-ish icon
@@ -500,15 +520,14 @@ export async function buildEpub(config, opts = {}) {
500
520
  const doc = node.doc;
501
521
  // The doc's live-site URL, matching generate-site's canonical link: README
502
522
  // maps to '/' (not '/index/'), others to '/slug/', with basePath applied.
503
- const pageUrl = baseUrl + withBase(config.basePath, pathForSlug(slugMap[doc.filename] ?? ''));
523
+ const pageUrl = baseUrl +
524
+ withBase(config.basePath, pathForSlug(slugMap[doc.filename] ?? ''));
504
525
  const html = renderDocMarkdown(stripDocMeta(doc.text));
505
526
  // happy-dom occasionally throws on exotic content (e.g. an internal selector
506
527
  // bug); fall back to the regex pass for that doc rather than aborting.
507
528
  let bodyHtml;
508
529
  try {
509
- const transform = win && baseUrl
510
- ? (d) => injectExampleLinks(d, pageUrl)
511
- : undefined;
530
+ const transform = win && baseUrl ? (d) => injectExampleLinks(d, pageUrl) : undefined;
512
531
  bodyHtml = win ? htmlToXhtml(html, win, transform) : toXhtml(html);
513
532
  }
514
533
  catch {
@@ -535,6 +554,12 @@ export async function buildEpub(config, opts = {}) {
535
554
  const output = path.resolve(opts.output ?? path.join(outDir, `${slugify(meta.title)}.epub`));
536
555
  await zipEpub(buildDir, output);
537
556
  fs.rmSync(buildDir, { recursive: true, force: true });
557
+ // Release the parser window: an unclosed happy-dom Window holds its whole
558
+ // document (every chapter parsed) plus its timers. Hygiene, not a proven win —
559
+ // buildEpub still grows ~1.5MB per call with this in place, so something here
560
+ // is still retained. buildEpub now runs in a child process (see orchestrator),
561
+ // which is what actually bounds it.
562
+ win?.close?.();
538
563
  console.log(`epub: ${output} (${chapters.length} chapters)`);
539
564
  return output;
540
565
  }
@@ -58,7 +58,7 @@ export function entriesFromCorpus(corpus, meta) {
58
58
  */
59
59
  function entriesFromSrcScan() {
60
60
  const entries = [];
61
- let files = [];
61
+ let files;
62
62
  try {
63
63
  files = fs.readdirSync(SRC).filter((f) => f.endsWith('.ts'));
64
64
  }