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.
- package/CHANGELOG.md +132 -0
- package/bin/make-icon-data.js +0 -0
- package/dist/code-editor-cm.js +41 -17
- package/dist/code-editor.d.ts +32 -2
- package/dist/code-editor.js +192 -25
- package/dist/doc-system/site/bundle-guard.d.ts +50 -0
- package/dist/doc-system/site/bundle-guard.js +111 -0
- package/dist/doc-system/site/check-examples.js +11 -3
- package/dist/doc-system/site/dev-server.js +97 -7
- package/dist/doc-system/site/epub-cli.d.ts +1 -0
- package/dist/doc-system/site/epub-cli.js +22 -0
- package/dist/doc-system/site/epub.js +34 -9
- package/dist/doc-system/site/make-llms-txt.js +1 -1
- package/dist/doc-system/site/orchestrator.js +139 -53
- package/dist/doc-system/site/site-config.d.ts +10 -0
- package/dist/iife.js +78 -71
- package/dist/iife.js.map +8 -8
- package/dist/live-example/code-transform.d.ts +39 -0
- package/dist/live-example/code-transform.js +305 -4
- package/dist/live-example/component.d.ts +10 -0
- package/dist/live-example/component.js +105 -21
- package/dist/live-example/execution.d.ts +16 -0
- package/dist/live-example/execution.js +29 -8
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +5 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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: '
|
|
318
|
-
stderr: '
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
29
|
-
'
|
|
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
|
|
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
|
|
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 {
|
|
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 +
|
|
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
|
}
|