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.
@@ -12,31 +12,80 @@ the icon system here would put src/icon-data.ts into `bun --watch`'s graph and
12
12
  cause an endless rebuild loop.
13
13
  */
14
14
  import * as path from 'path';
15
- import { existsSync, mkdirSync } from 'fs';
15
+ import { existsSync, mkdirSync, unlinkSync } from 'fs';
16
+ import { tmpdir } from 'os';
16
17
  import { gzipSync } from 'zlib';
17
- import { $ } from 'bun';
18
+ import { $, spawn } from 'bun';
18
19
  import { extractDocs } from './docs';
19
20
  import { checkExamples, formatExampleProblems } from './check-examples';
20
21
  import { ensureSections } from './sections';
21
22
  import { generateLlmsTxt } from './make-llms-txt';
22
23
  import { generateSite } from './generate-site';
23
24
  import { findOutputDirOverlap } from './output-guard';
24
- import { buildEpub } from './epub';
25
+ import { tjsEditorExternal, tjsEditorLeakedAsExternal, classicScriptSyntaxErrorInChild, } from './bundle-guard';
25
26
  // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
26
27
  // before interpolating into the require-shim detector below.
27
- const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
- // Externalize the tjs CodeMirror editor extension ONLY when tjs-lang isn't
29
- // installed — otherwise it's bundled in so it shares the editor's CodeMirror
30
- // instance (see the `external` comment in buildSite). Returns [] when present.
31
- function tjsEditorExternal(root) {
28
+ /** Give up on a hung ePub child rather than wedge the dev server's rebuild. */
29
+ const EPUB_TIMEOUT_MS = 120_000;
30
+ /**
31
+ * Run buildEpub() in a child process.
32
+ *
33
+ * buildEpub drives happy-dom (HTML→XHTML for every chapter) and @resvg/resvg-js
34
+ * (cover raster) — both native, both retaining — and it runs on EVERY dev rebuild,
35
+ * so in-process it strands memory in a watch process that lives for days. The child
36
+ * hands it all back on exit. If it hangs we kill it, and if it fails we warn: the
37
+ * ePub is a side artifact, so neither should block the page you're trying to look at.
38
+ *
39
+ * Only the data buildEpub reads is forwarded — SiteConfig also carries functions
40
+ * (llmsTxt, libraryBuild, prebuild) that don't survive JSON.
41
+ */
42
+ async function buildEpubInChild(config, opts) {
43
+ // Resolve the sibling relative to THIS module so it works both in-repo (.ts)
44
+ // and when shipped (compiled .js) — same trick as generate-css below.
45
+ const cliTs = `${import.meta.dir}/epub-cli.ts`;
46
+ const cli = existsSync(cliTs) ? cliTs : `${import.meta.dir}/epub-cli.js`;
47
+ const payload = {
48
+ config: {
49
+ basePath: config.basePath,
50
+ baseUrl: config.baseUrl,
51
+ book: config.book,
52
+ docsJson: config.docsJson,
53
+ favicon: config.favicon,
54
+ lang: config.lang,
55
+ name: config.name,
56
+ outputDir: config.outputDir,
57
+ },
58
+ opts,
59
+ };
60
+ const payloadPath = path.join(tmpdir(), `tosijs-epub-${process.pid}.json`);
61
+ await Bun.write(payloadPath, JSON.stringify(payload));
62
+ const child = spawn(['bun', cli, payloadPath], {
63
+ stdout: 'inherit',
64
+ stderr: 'inherit',
65
+ });
66
+ const killer = setTimeout(() => {
67
+ console.warn(`⚠️ epub build exceeded ${EPUB_TIMEOUT_MS / 1000}s — killing it. The site is\n` + ` fine; the .epub may be stale.`);
68
+ child.kill();
69
+ }, EPUB_TIMEOUT_MS);
32
70
  try {
33
- Bun.resolveSync('tjs-lang/editors/codemirror', root);
34
- return [];
71
+ const code = await child.exited;
72
+ if (code !== 0) {
73
+ console.warn(`⚠️ epub build failed (exit ${code}). The site is fine; the .epub may be stale.`);
74
+ }
35
75
  }
36
- catch {
37
- return ['tjs-lang/editors/codemirror'];
76
+ finally {
77
+ clearTimeout(killer);
78
+ try {
79
+ unlinkSync(payloadPath);
80
+ }
81
+ catch {
82
+ // already gone — fine
83
+ }
38
84
  }
39
85
  }
86
+ // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
87
+ // before interpolating into the require-shim detector below.
88
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
89
  export async function buildSite(config) {
41
90
  const PROJECT_ROOT = './';
42
91
  const PUBLIC = path.resolve(PROJECT_ROOT, config.outputDir ?? 'docs');
@@ -97,7 +146,8 @@ export async function buildSite(config) {
97
146
  }
98
147
  }
99
148
  // Copy static-asset dirs into the web root.
100
- const staticDirs = config.staticDirs ?? (existsSync('demo/static') ? ['demo/static'] : ['static']);
149
+ const staticDirs = config.staticDirs ??
150
+ (existsSync('demo/static') ? ['demo/static'] : ['static']);
101
151
  for (const dir of staticDirs) {
102
152
  if (existsSync(dir))
103
153
  await $ `cp -R ${dir}/. ${PUBLIC}`.text();
@@ -142,36 +192,58 @@ export async function buildSite(config) {
142
192
  // consumer supplies (e.g. via staticDirs or an absolute URL).
143
193
  const scriptName = (config.scriptUrl ?? '/iife.js').replace(/^\//, '');
144
194
  if (config.bundleEntry) {
145
- const result = await Bun.build({
146
- entrypoints: [config.bundleEntry],
147
- outdir: DIST,
148
- sourcemap: 'linked',
149
- format: 'iife',
150
- minify: true,
151
- naming: scriptName,
152
- // tjs-lang's TRANSPILER (browser bundles) is dynamically import()'d at
153
- // runtime (same-origin `/tjs/` copy, else CDN), so keep it out of the bundle.
154
- //
155
- // `tjs-lang/editors/codemirror` (the CodeMirror language + autocomplete) is
156
- // different: it MUST share the editor's CodeMirror instance, so it has to be
157
- // bundled IN — a separately loaded copy carries its own `@codemirror/state`
158
- // and silently no-ops. So we do NOT externalize it *when tjs-lang is
159
- // installed*. But tjs-lang is an OPTIONAL peer; if it's absent, bundling would
160
- // fail to resolve, so we externalize it in that case (the runtime import then
161
- // no-ops to plain TS highlighting — graceful degradation).
162
- external: [
163
- 'tjs-lang/browser',
164
- 'tjs-lang/browser/from-ts',
165
- ...tjsEditorExternal(PROJECT_ROOT),
166
- ...(config.bundleExternals ?? []),
167
- ],
168
- });
169
- if (!result.success) {
195
+ // tjs-lang's TRANSPILER (browser bundles) is dynamically import()'d at
196
+ // runtime (same-origin `/tjs/` copy, else CDN), so keep it out of the bundle.
197
+ //
198
+ // `tjs-lang/editors/codemirror` (the CodeMirror language + autocomplete) is
199
+ // different: it MUST share the editor's CodeMirror instance, so it has to be
200
+ // bundled IN — a separately loaded copy carries its own `@codemirror/state`
201
+ // and silently no-ops. So we do NOT externalize it *when tjs-lang is
202
+ // installed*. But tjs-lang is an OPTIONAL peer; if it's absent, bundling would
203
+ // fail to resolve, so we externalize it in that case (the runtime import then
204
+ // no-ops to plain TS highlighting — graceful degradation).
205
+ //
206
+ // NB: bare `'tjs-lang'` must NOT appear here. Externals are PREFIX matches, so
207
+ // it would silently externalize tjs-lang/editors/codemirror along with it.
208
+ const externals = [
209
+ 'tjs-lang/browser',
210
+ 'tjs-lang/browser/from-ts',
211
+ ...tjsEditorExternal(PROJECT_ROOT),
212
+ ...(config.bundleExternals ?? []),
213
+ ];
214
+ // Bundle in a CHILD PROCESS, not via Bun.build().
215
+ //
216
+ // Bun.build() never gives back the bundler's native arena: measured at ~9MB
217
+ // of RSS per call and rising, monotonic, with no plateau (40 sequential
218
+ // builds of one real entry = +367MB, still climbing ~5MB/build at the end),
219
+ // while the JS heap stays flat — so it is invisible to Bun.gc() and to any
220
+ // heap profiler. devServer() calls this once per rebuild in a process that
221
+ // lives for DAYS, so it compounds: a ~2-day watch session reached 136GB RSS
222
+ // and took the machine down with it. Filed as oven-sh/bun#34053.
223
+ //
224
+ // The CLI does identical work in a child whose memory the OS reclaims on
225
+ // exit: the same 15 bundles leave the parent +0.5MB instead of +192MB.
226
+ // Keep these flags in sync with the Bun.build() options they replace.
227
+ const bundle = spawn([
228
+ 'bun',
229
+ 'build',
230
+ config.bundleEntry,
231
+ '--outdir',
232
+ DIST,
233
+ '--sourcemap=linked',
234
+ '--format=iife',
235
+ '--minify',
236
+ '--entry-naming',
237
+ scriptName,
238
+ ...externals.flatMap((ext) => ['--external', ext]),
239
+ ], { stdout: 'inherit', stderr: 'inherit' });
240
+ if ((await bundle.exited) !== 0) {
170
241
  console.error('bundle build failed');
171
- for (const message of result.logs)
172
- console.error(message);
173
242
  return false;
174
243
  }
244
+ await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
245
+ const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
246
+ const bundleJs = Buffer.from(bundleFile).toString('utf8');
175
247
  // Warn only when an external actually compiled to a synchronous require()
176
248
  // shim, which throws at module-eval ("Dynamic require of … is not
177
249
  // supported"). That only happens for a *static* `import x from 'ext'`. A
@@ -180,23 +252,37 @@ export async function buildSite(config) {
180
252
  // stay silent. The config alone can't tell the two apart; the emitted
181
253
  // bundle can, so we inspect the actual output.
182
254
  if (config.bundleExternals && config.bundleExternals.length > 0) {
183
- const js = (await Promise.all(result.outputs
184
- .filter((o) => o.kind === 'entry-point' || o.path.endsWith('.js'))
185
- .map((o) => o.text()))).join('\n');
186
- const broken = config.bundleExternals.filter((ext) => new RegExp(`(?:__require|\\brequire)\\(\\s*["'\`]${escapeRegExp(ext)}["'\`]`).test(js));
255
+ const broken = config.bundleExternals.filter((ext) => new RegExp(`(?:__require|\\brequire)\\(\\s*["'\`]${escapeRegExp(ext)}["'\`]`).test(bundleJs));
187
256
  if (broken.length > 0) {
188
257
  console.warn(`⚠️ bundleExternals compiled to a synchronous require() shim that throws at runtime (${broken.join(', ')}). Reference these via a dynamic import() (kept async by the bundler)\n` +
189
258
  ` or an importmap, not a static import.`);
190
259
  }
191
260
  }
192
- await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
193
- const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
194
- // import.meta is illegal in a classic <script> — if it survived bundling
195
- // (a branch the bundler couldn't eliminate) the IIFE will SyntaxError.
196
- if (Buffer.from(bundleFile).toString('utf8').includes('import.meta')) {
197
- console.warn(`⚠️ ${scriptName} contains \`import.meta\`, which is a SyntaxError in a classic <script>.\n` +
198
- ` A dependency referenced it in a branch the bundler couldn't drop — mark that dep external\n` +
199
- ` (+ importmap) or choose a browser-only entry point.`);
261
+ // The IIFE must parse as a classic <script>. `import.meta` is the usual way it
262
+ // doesn't (a branch the bundler couldn't eliminate) — and it's a SyntaxError, so
263
+ // the whole bundle fails to evaluate and the page never hydrates. Compile it
264
+ // (without running it) rather than grepping: the substring also occurs inside
265
+ // string literals — acorn's error messages contain it — which made the old grep
266
+ // fire on every build while the bundle was fine.
267
+ const syntaxError = await classicScriptSyntaxErrorInChild(`${DIST}/${scriptName}`);
268
+ if (syntaxError) {
269
+ console.error(`⚠️ ${scriptName} does not parse as a classic <script>: ${syntaxError}\n` +
270
+ ` The page will not hydrate. If a dependency pulled in \`import.meta\`, mark it\n` +
271
+ ` external (+ importmap) or choose a browser-only entry point.`);
272
+ return false;
273
+ }
274
+ // tjs-lang's CodeMirror extension MUST be bundled, not externalized — a separate
275
+ // copy carries its own @codemirror/state and silently no-ops (tjs highlighting and
276
+ // autocomplete just stop working, with no error anywhere). If it was externalized,
277
+ // the bundler leaves its specifier behind. Failing the build is the only way this
278
+ // gets noticed; every test lane stays green when it regresses.
279
+ if (tjsEditorExternal(PROJECT_ROOT).length === 0 &&
280
+ tjsEditorLeakedAsExternal(bundleJs)) {
281
+ console.error(`⚠️ ${scriptName} externalized tjs-lang's CodeMirror extension instead of bundling it.\n` +
282
+ ` It must share the editor's single CodeMirror instance; a separately loaded copy\n` +
283
+ ` silently no-ops. Check the bundle's \`external\` list — entries are PREFIX matches,\n` +
284
+ ` so a bare 'tjs-lang' externalizes tjs-lang/editors/codemirror along with it.`);
285
+ return false;
200
286
  }
201
287
  const bundleGzip = gzipSync(Buffer.from(bundleFile));
202
288
  console.log(`${scriptName}: ${(bundleFile.byteLength / 1024).toFixed(1)}kb (${(bundleGzip.length / 1024).toFixed(1)}kb gzip)`);
@@ -319,7 +405,7 @@ export async function buildSite(config) {
319
405
  // the corpus and survives the `rm -rf <outputDir>` at the top of the NEXT build
320
406
  // (otherwise a dev rebuild would silently drop it). Cheap (~0.4s).
321
407
  if (config.epub) {
322
- await buildEpub(config, typeof config.epub === 'object' ? config.epub : {});
408
+ await buildEpubInChild(config, typeof config.epub === 'object' ? config.epub : {});
323
409
  }
324
410
  console.timeEnd('build');
325
411
  return true;
@@ -186,6 +186,16 @@ export interface SiteConfig {
186
186
  port?: number;
187
187
  /** extra dev-server watch paths (added to docPaths + bundleEntry dir). */
188
188
  watchPaths?: string[];
189
+ /**
190
+ * RSS ceiling (MB) for the dev server, default 4096. A watch process lives for
191
+ * days across thousands of rebuilds, so anything the build strands per rebuild
192
+ * compounds until the machine swaps itself to death. Past this, the server
193
+ * prints the growth-per-rebuild and exits rather than take the machine with it.
194
+ * Overridden by the DEV_MEMORY_LIMIT_MB env var. Raise it if a genuinely large
195
+ * build needs the headroom — but sustained growth per rebuild is a leak, not a
196
+ * ceiling that's too low.
197
+ */
198
+ memoryLimitMb?: number;
189
199
  /**
190
200
  * Enable the dev-server source read/write endpoints (`/__docstore/source`) that
191
201
  * back in-browser "edit page source". Local dev only — the dev server runs on