tosijs-ui 1.6.21 → 1.6.23

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,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.23
4
+
5
+ **The two biggest sources of ecosystem-wide friction — runaway dev servers and haltija
6
+ conflicts — fixed in the version people actually run.** If you use `tosijs-ui/site`, update.
7
+
8
+ ### Machine-safety guards (a leaking/forgotten dev server took a machine down twice)
9
+
10
+ - **`killStrayServer` no longer `kill -9`s processes merely _connected_ to its port.** It freed
11
+ its port with `lsof -ti:PORT | xargs kill -9`, and `lsof -i:PORT` matches sockets whose local
12
+ **or remote** port is PORT — so it SIGKILLed every _client_ of that port, not just the
13
+ listener: the browser reading your page, an editor's language server, anything with a
14
+ connection. It now signals **only the process listening** on the port, only after confirming
15
+ it is a JS runtime, SIGTERM first and SIGKILL only as a fallback.
16
+
17
+ - **Machine-health preflight** before each build and at dev-server launch (and on a 60s tick):
18
+ refuses to add load to a machine that is already dying — a runaway dev server, a VM stall.
19
+ Names the offending PIDs, sizes, ages and project dirs. **Local LLM runtimes (LM Studio,
20
+ ollama, llama.cpp, mlx_lm, vLLM, …) are exempt** — their memory shrinks the dev budget rather
21
+ than tripping the guard, and a big _non-dev_ process (Docker, a JVM, a database) only warns,
22
+ never fails. It never kills anything; it declines to _start_. `preflight: 'fail' | 'warn' |
23
+ false` in the site config, or `DEV_SKIP_PREFLIGHT=1`; auto-downgraded to a warning in CI.
24
+
25
+ - **`idleTimeoutHours` (default 8)** — ⚠️ **behavior change:** the dev server now exits after 8
26
+ idle hours (no request, no rebuild). A forgotten dev server is not inert — it is a days-old
27
+ process still running the code it loaded at launch (updating the package does nothing for it).
28
+ `idleTimeoutHours: 0` disables it.
29
+
30
+ - **RSS ceiling** (`memoryLimitMb`) sampled every 60s, and a **rebuild-loop detector** that
31
+ stops a build which writes a file it also watches instead of spawning a bundler forever.
32
+
33
+ ### haltija
34
+
35
+ - Spawned as a **pinned range (`haltija@^1.4.0`)**, overridable with `HALTIJA_VERSION`, instead
36
+ of a floating `@latest` fetched fresh on every adopter's machine. The 1.4.0 floor is
37
+ deliberate: it routes `hj` by working directory (so a project's dev server drives its own
38
+ browser, not whichever was focused), never overwrites a newer machine-wide `hj`, and exits
39
+ non-zero on a failed command — which the doc-test lane now checks instead of racing to a
40
+ timeout. Teardown kills only its own process tree, never a haltija you were already running.
41
+
42
+ ### Also fixed
43
+
44
+ - **Builds again against tosijs 1.6.9.** `<tosi-example>` detected hydration with a
45
+ `try { this.parts.js } catch` probe, which before tosijs 1.6.9 permanently poisoned the
46
+ `parts` proxy on the pre-hydration read it performed (it could blank the whole doc page), and
47
+ whose `private get hydrated()` collided with 1.6.9's new public `hydrated` (a `tsc` error, so
48
+ the package could not build against current tosijs). The probe is gone — it uses the base
49
+ class's `this.hydrated` now — and the tosijs floor moves to **`^1.6.9`**
50
+ ([tosijs#13](https://github.com/tonioloewald/tosijs/issues/13)).
51
+
52
+ - **The doc-browser no longer double-suffixes the page `<title>`.** On path routing it re-derived
53
+ the title on hydration, ignoring `headTitle` and re-appending the project name to a title that
54
+ already contained it — so the home page flipped from its real title to `tosijs-ui — tosijs-ui`
55
+ the moment the bundle loaded. The static generator and the runtime now share **one**
56
+ `pageTitle` rule (case-insensitive, both-directions containment check; whitespace-only titles
57
+ and project names handled). Fixes [#6](https://github.com/tonioloewald/tosijs-ui/issues/6).
58
+
59
+ - **Two more per-rebuild build leaks moved to child processes** (the dev server lives for days):
60
+ the live-example **check** (`new AsyncFunction` per block + a `Bun.Transpiler` per `ts` block)
61
+ and the bundle **gzip** (native zlib). Both now run in a child whose memory the OS reclaims on
62
+ exit; the gzip stays zlib-in-a-child (not the `gzip` CLI) so the reported size doesn't drift.
63
+
64
+ ## 1.6.22
65
+
66
+ ### Fixed
67
+
68
+ - **The dev server no longer leaks memory on every rebuild — update as a priority if
69
+ you use `tosijs-ui/site`.** `buildSite()` called `Bun.build()` in-process, and Bun's
70
+ bundler never returns its native arena: RSS grows monotonically per call with no
71
+ plateau (40 sequential builds of one entry = **+367MB**, still climbing ~5MB/build at
72
+ the end), while the JS heap stays flat — so it is invisible to `Bun.gc()` and to any
73
+ heap profiler. `devServer()` calls it once per rebuild in a process that runs for
74
+ days, so it compounds: a ~2-day watch session reached **136GB RSS** and took the
75
+ machine down with it. Filed upstream as
76
+ [oven-sh/bun#34053](https://github.com/oven-sh/bun/issues/34053).
77
+
78
+ The bundle now builds in a **child process** (`bun build`), whose memory the OS
79
+ reclaims on exit — the same 15 bundles leave the parent **+0.5MB** instead of +192MB.
80
+ `buildEpub()` moved to a child too (happy-dom + `@resvg/resvg-js` are both native and
81
+ retaining, and it runs on every rebuild), with a 120s timeout kill so a hung or failed
82
+ ePub warns instead of wedging the rebuild. Measured on this repo's own dev server:
83
+ **baseline RSS 503MB → 150MB, and per-rebuild growth 26–59MB → ~2.7MB.**
84
+
85
+ Bundle output is byte-for-byte identical; this is purely where the work runs.
86
+
87
+ ### Added
88
+
89
+ - **Dev-server memory watchdog.** `devServer()` now samples RSS after each rebuild and,
90
+ past a ceiling (`memoryLimitMb` in the site config, or `DEV_MEMORY_LIMIT_MB`; default
91
+ 4096), prints the growth-per-rebuild and exits rather than let a leak thrash the
92
+ machine. It distinguishes real growth (a leak — report it) from a build whose baseline
93
+ simply exceeds the ceiling (raise the ceiling).
94
+
95
+ ## 1.6.21
96
+
97
+ - Memory-routed nav-toggle fix; `buildSite` `.tjs` hooks (`libraryBuild`,
98
+ `generateCssPreload` — see BUILD-TJS-HOOK.md); tjs-lang 0.9.0.
99
+
3
100
  ## 1.6.20
4
101
 
5
102
  ### Fixed
@@ -1,15 +1,7 @@
1
- import { Component as WebComponent, ElementCreator, PartsMap } from 'tosijs';
2
- import type { CmHandle } from './code-editor-cm';
3
- interface CodeEditorParts extends PartsMap {
4
- host: HTMLDivElement;
5
- }
6
- export declare class CodeEditor extends WebComponent<CodeEditorParts> {
1
+ import { Component as WebComponent, ElementCreator } from 'tosijs';
2
+ export declare class CodeEditor extends WebComponent {
7
3
  static preferredTagName: string;
8
4
  private source;
9
- private _handle;
10
- private _loadPromise;
11
- private _appliedMode;
12
- private _appliedDisabled;
13
5
  get value(): string;
14
6
  set value(text: string);
15
7
  private _original;
@@ -20,16 +12,16 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
20
12
  showDiff(on: boolean): void;
21
13
  static initAttributes: {
22
14
  mode: string;
15
+ theme: string;
23
16
  disabled: boolean;
24
17
  };
25
18
  role: string;
26
- /** The underlying CodeMirror EditorView (undefined until loaded). */
27
- get editor(): CmHandle['view'] | undefined;
28
- undo(): void;
29
- redo(): void;
30
- canUndo(): boolean;
31
- canRedo(): boolean;
32
- content: () => HTMLDivElement[];
19
+ private _ace;
20
+ private _editor;
21
+ private _editorPromise;
22
+ options: any;
23
+ get ace(): any;
24
+ get editor(): any;
33
25
  static shadowStyleSpec: {
34
26
  ':host': {
35
27
  display: string;
@@ -37,27 +29,9 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
37
29
  width: string;
38
30
  height: string;
39
31
  };
40
- '[part="host"]': {
41
- height: string;
42
- };
43
- '.cm-editor': {
44
- height: string;
45
- };
46
- '.cm-scroller': {
47
- outline: string;
48
- fontFamily: string;
49
- };
50
32
  };
51
33
  onResize(): void;
52
34
  connectedCallback(): void;
53
- private isTjsMode;
54
- /**
55
- * When in tjs mode, lazily upgrade the editor to tjs-lang's CodeMirror language +
56
- * autocomplete. No-op (keeps TS highlighting) if not tjs, if tjs-lang isn't
57
- * installed, or if the mode/handle changed before the async load resolved.
58
- */
59
- private applyTjsExtension;
60
35
  render(): void;
61
36
  }
62
37
  export declare const codeEditor: ElementCreator<CodeEditor>;
63
- export {};
@@ -1,12 +1,12 @@
1
1
  /*#
2
2
  # code
3
3
 
4
- A [CodeMirror 6](https://codemirror.net/) wrapper.
4
+ An [ACE Editor](https://ace.c9.io/) wrapper.
5
5
 
6
6
  Sometimes, it's nice to be able to just toss a code-editor in a web-page.
7
7
 
8
- `<tosi-code>`'s `value` is the code it contains. Its `mode` attribute sets the
9
- language (`javascript`, `typescript`, `tjs`, `css`, `html`, `markdown`).
8
+ `<tosi-code>`'s `value` is the code it contains. Its `mode` attribute sets the language, and you can further configure
9
+ the ACE editor instance via its `options` property.
10
10
 
11
11
  ```html
12
12
  <tosi-code style="width: 100%; height: 100%" mode="css">
@@ -16,33 +16,51 @@ body {
16
16
  </tosi-code>
17
17
  ```
18
18
 
19
- The `<tosi-code>` element has an `editor` property that gives you its CodeMirror
20
- [`EditorView`](https://codemirror.net/docs/ref/#view.EditorView), and
21
- `undo()` / `redo()` / `canUndo()` / `canRedo()` methods for history control.
22
-
23
- CodeMirror is loaded lazily on first use — a page with no `<tosi-code>` bundles
24
- none of it.
19
+ The `<tosi-code>` element has an `editor` property that gives you its ACE editor instance,
20
+ and an `ace` property that returns the `ace` module, giving you complete access to the
21
+ [Ace API](https://ace.c9.io/api/index.html).
25
22
  */
26
23
  /*{ "parent": "Components" }*/
27
- import { Component as WebComponent, elements } from 'tosijs';
24
+ import { Component as WebComponent } from 'tosijs';
25
+ import { scriptTag } from './via-tag';
28
26
  import { tosiDiff } from './diff';
29
- const { div } = elements;
27
+ const ACE_BASE_URL = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.2/';
28
+ const DEFAULT_THEME = 'ace/theme/tomorrow';
29
+ const getAce = async () => {
30
+ const { ace } = await scriptTag(`${ACE_BASE_URL}ace.min.js`);
31
+ return ace;
32
+ };
33
+ const makeCodeEditor = async (codeElement, mode = 'html', options = {}, theme = DEFAULT_THEME) => {
34
+ const ace = await getAce();
35
+ ace.config.set('basePath', ACE_BASE_URL);
36
+ const editor = ace.edit(codeElement, {
37
+ mode: `ace/mode/${mode}`,
38
+ tabSize: 2,
39
+ useSoftTabs: true,
40
+ useWorker: false,
41
+ // Wrap wide lines instead of scrolling horizontally; indentedSoftWrap keeps
42
+ // wrapped continuations aligned under the line's indent.
43
+ wrap: true,
44
+ indentedSoftWrap: true,
45
+ ...options,
46
+ });
47
+ editor.setTheme(theme);
48
+ return { ace, editor };
49
+ };
30
50
  export class CodeEditor extends WebComponent {
31
51
  static preferredTagName = 'tosi-code';
32
52
  source = '';
33
- _handle;
34
- _loadPromise;
35
- _appliedMode = '';
36
- _appliedDisabled;
37
53
  get value() {
38
- return this._handle ? this._handle.getValue() : this.source;
54
+ return this.editor === undefined ? this.source : this.editor.getValue();
39
55
  }
40
56
  set value(text) {
41
- if (this._handle) {
42
- this._handle.setValue(text);
57
+ if (this.editor === undefined) {
58
+ this.source = text;
43
59
  }
44
60
  else {
45
- this.source = text;
61
+ this.editor.setValue(text);
62
+ this.editor.clearSelection();
63
+ this.editor.session.getUndoManager().reset();
46
64
  }
47
65
  }
48
66
  // Baseline for `showDiff()` — the version to diff the current `value` against.
@@ -54,9 +72,9 @@ export class CodeEditor extends WebComponent {
54
72
  set original(text) {
55
73
  this._original = text;
56
74
  }
57
- // Diff overlay — built only on the editor's public surface (`value` + `original`)
58
- // and the tosi-diff component, never the underlying editor's API, so it is
59
- // editor-agnostic (this is why it survived the Ace → CodeMirror swap untouched).
75
+ // Diff overlay — deliberately built only on the editor's public surface
76
+ // (`value` + `original`) and the tosi-diff component, never the underlying
77
+ // editor's API, so it survives a future Ace → CodeMirror swap untouched.
60
78
  diffOverlay;
61
79
  get showingDiff() {
62
80
  return this.diffOverlay !== undefined && !this.diffOverlay.hidden;
@@ -85,27 +103,20 @@ export class CodeEditor extends WebComponent {
85
103
  }
86
104
  static initAttributes = {
87
105
  mode: 'javascript',
106
+ theme: DEFAULT_THEME,
88
107
  disabled: false,
89
108
  };
90
109
  role = 'code editor';
91
- /** The underlying CodeMirror EditorView (undefined until loaded). */
92
- get editor() {
93
- return this._handle?.view;
110
+ _ace;
111
+ _editor;
112
+ _editorPromise;
113
+ options = {};
114
+ get ace() {
115
+ return this._ace;
94
116
  }
95
- // History control — so consumers use these instead of reaching into `editor`.
96
- undo() {
97
- this._handle?.undo();
98
- }
99
- redo() {
100
- this._handle?.redo();
101
- }
102
- canUndo() {
103
- return this._handle?.canUndo() ?? false;
104
- }
105
- canRedo() {
106
- return this._handle?.canRedo() ?? false;
117
+ get editor() {
118
+ return this._editor;
107
119
  }
108
- content = () => [div({ part: 'host' })];
109
120
  static shadowStyleSpec = {
110
121
  ':host': {
111
122
  display: 'block',
@@ -113,71 +124,32 @@ export class CodeEditor extends WebComponent {
113
124
  width: '100%',
114
125
  height: '100%',
115
126
  },
116
- '[part="host"]': { height: '100%' },
117
- '.cm-editor': { height: '100%' },
118
- '.cm-scroller': {
119
- outline: 'none',
120
- fontFamily: "Menlo, Monaco, Consolas, 'Courier New', monospace",
121
- },
122
127
  };
123
128
  onResize() {
124
- this._handle?.refresh();
129
+ if (this.editor !== undefined) {
130
+ this.editor.resize(true);
131
+ }
125
132
  }
126
133
  connectedCallback() {
127
134
  super.connectedCallback();
128
135
  if (this.source === '') {
129
136
  this.value = this.textContent !== null ? this.textContent.trim() : '';
130
137
  }
131
- if (this._loadPromise === undefined) {
132
- // Lazy chunk — CodeMirror only enters the bundle here, on first editor use.
133
- this._loadPromise = import('./code-editor-cm').then(({ createCmEditor }) => {
134
- const handle = createCmEditor(this.parts.host, {
135
- value: this.source,
136
- mode: this.mode,
137
- readOnly: this.disabled,
138
- root: this.shadowRoot ?? undefined,
139
- onChange: (value) => this.dispatchEvent(new CustomEvent('change', { detail: { value } })),
140
- });
141
- this._handle = handle;
142
- this._appliedMode = this.mode;
143
- this._appliedDisabled = this.disabled;
144
- this.applyTjsExtension();
145
- return handle;
138
+ if (this._editorPromise === undefined) {
139
+ this._editorPromise = makeCodeEditor(this, this.mode, this.options, this.theme);
140
+ this._editorPromise.then(({ ace, editor }) => {
141
+ this._ace = ace;
142
+ this._editor = editor;
143
+ editor.setValue(this.source, 1);
144
+ editor.clearSelection();
145
+ editor.session.getUndoManager().reset();
146
146
  });
147
147
  }
148
148
  }
149
- isTjsMode() {
150
- return this.mode === 'tjs' || this.mode === 'ajs';
151
- }
152
- /**
153
- * When in tjs mode, lazily upgrade the editor to tjs-lang's CodeMirror language +
154
- * autocomplete. No-op (keeps TS highlighting) if not tjs, if tjs-lang isn't
155
- * installed, or if the mode/handle changed before the async load resolved.
156
- */
157
- applyTjsExtension() {
158
- const handle = this._handle;
159
- if (!handle || !this.isTjsMode())
160
- return;
161
- import('./code-editor-cm')
162
- .then(({ loadTjsExtension }) => loadTjsExtension())
163
- .then((ext) => {
164
- if (ext && this._handle === handle && this.isTjsMode()) {
165
- handle.setLanguageExtension(ext);
166
- }
167
- });
168
- }
169
149
  render() {
170
150
  super.render();
171
- if (this._handle) {
172
- if (this.disabled !== this._appliedDisabled) {
173
- this._handle.setReadOnly(this.disabled);
174
- this._appliedDisabled = this.disabled;
175
- }
176
- if (this.mode !== this._appliedMode) {
177
- this._handle.setMode(this.mode);
178
- this._appliedMode = this.mode;
179
- this.applyTjsExtension();
180
- }
151
+ if (this._editorPromise !== undefined) {
152
+ this._editorPromise.then(({ editor }) => editor.setReadOnly(this.disabled));
181
153
  }
182
154
  }
183
155
  }
@@ -155,6 +155,7 @@ import { elements, vars, varDefault, bindings, touch, getListItem, debounce, tos
155
155
  import { buildSlugMap, pathForSlug, filenameForPath } from './doc-system/routing';
156
156
  import { buildNavTree } from './doc-system/nav-tree';
157
157
  import { renderDocMarkdown } from './doc-system/render';
158
+ import { pageTitle } from './doc-system/doc-title';
158
159
  import { LiveExample, testManager } from './live-example';
159
160
  import { tosiSidenav, TosiSidenav } from './side-nav';
160
161
  import { icons } from './icons';
@@ -613,9 +614,11 @@ export function createDocBrowser(options) {
613
614
  LiveExample.insertExamples(docContent, context, doc.path || undefined);
614
615
  scrollToHashExample();
615
616
  if (routing === 'path') {
616
- document.title = projectName
617
- ? `${doc.title} — ${projectName}`
618
- : doc.title;
617
+ // The SAME rule the static generator used for this page's <head> (doc-title.ts).
618
+ // This used to re-derive it — ignoring `headTitle` and re-suffixing a title that
619
+ // already ended in the project name — so the home page's title flipped to
620
+ // "tosijs-ui — tosijs-ui" the moment the bundle loaded (issue #6).
621
+ document.title = pageTitle(doc, projectName);
619
622
  }
620
623
  };
621
624
  // Always resolve to the RAW doc from the original array — docs reached via the
@@ -0,0 +1,19 @@
1
+ /** The fields of a Doc that bear on its title. Structural, so both callers fit. */
2
+ export interface TitledDoc {
3
+ title: string;
4
+ /** verbatim <title>, no project suffix — a per-doc override */
5
+ headTitle?: string;
6
+ }
7
+ /**
8
+ * `headTitle` wins verbatim. Otherwise suffix the project name — but only when the two
9
+ * are genuinely distinct.
10
+ *
11
+ * "Distinct" means **neither string contains the other, compared case-insensitively.**
12
+ * A one-way, case-sensitive `title.includes(projectName)` check missed real cases: a
13
+ * project titled `Tosijs-UI` against a doc titled `tosijs-ui` (case), and a doc whose
14
+ * title is a *substring* of the project name (the containment runs the other way). Any
15
+ * of those, suffixed, reads as redundant — "tosijs-ui — tosijs-ui" being the one that
16
+ * shipped (issue #6). When one already contains the other, the doc's own title is the
17
+ * more specific label, so use it alone.
18
+ */
19
+ export declare function pageTitle(doc: TitledDoc, projectName?: string): string;
@@ -0,0 +1,51 @@
1
+ /*
2
+ The ONE rule for a doc page's <title>.
3
+
4
+ It has to be one rule, because it is applied twice — once by the static generator into
5
+ the pre-rendered `<head>`, and once by the doc-browser when it hydrates. Two
6
+ independently-written copies drifted, and the title became the one thing on the page
7
+ that visibly MOVED on hydration:
8
+
9
+ JS off → "tosijs-ui — robust, dependency-free web components" (headTitle)
10
+ hydrated → "tosijs-ui — tosijs-ui" (!)
11
+
12
+ ...because the runtime copy ignored `headTitle` and re-suffixed a title that already
13
+ ended in the project name. That is GitHub issue #6, and it was live on our own home
14
+ page while this release's whole thesis was "hydration is purely additive, nothing
15
+ moves".
16
+
17
+ Shared by `site/generate-site.ts` (build) and `doc-browser.ts` (runtime). Pure — no DOM,
18
+ no Bun — so both can import it.
19
+ */
20
+ /**
21
+ * `headTitle` wins verbatim. Otherwise suffix the project name — but only when the two
22
+ * are genuinely distinct.
23
+ *
24
+ * "Distinct" means **neither string contains the other, compared case-insensitively.**
25
+ * A one-way, case-sensitive `title.includes(projectName)` check missed real cases: a
26
+ * project titled `Tosijs-UI` against a doc titled `tosijs-ui` (case), and a doc whose
27
+ * title is a *substring* of the project name (the containment runs the other way). Any
28
+ * of those, suffixed, reads as redundant — "tosijs-ui — tosijs-ui" being the one that
29
+ * shipped (issue #6). When one already contains the other, the doc's own title is the
30
+ * more specific label, so use it alone.
31
+ */
32
+ export function pageTitle(doc, projectName) {
33
+ // A whitespace-only `headTitle` is not an override, it's a blank <title> — trim it
34
+ // and fall through. Trimming also cleans up an accidentally-padded one ("verbatim"
35
+ // means "no project suffix", not "keep your stray whitespace").
36
+ const headTitle = doc.headTitle?.trim();
37
+ if (headTitle)
38
+ return headTitle;
39
+ // Trim first: a whitespace-only name ('' or ' ') is "no project name", not a real
40
+ // one. Untrimmed, ' ' is truthy and slips through — and worse, `title.includes(' ')`
41
+ // is true for any multi-word title, so it would silently suppress a suffix that
42
+ // should appear. Trimming also strips a padded 'tosijs-ui ' down to the real name.
43
+ const project = projectName?.trim() ?? '';
44
+ if (!project)
45
+ return doc.title;
46
+ const t = doc.title.toLowerCase();
47
+ const p = project.toLowerCase();
48
+ if (t.includes(p) || p.includes(t))
49
+ return doc.title;
50
+ return `${doc.title} — ${project}`;
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ /*
2
+ Live-example build check — the subprocess entry point for checkExamples().
3
+
4
+ Why a child process, when this is "just" compiling some strings:
5
+
6
+ checkExamples() compiles EVERY executable block in the corpus (83 of them here) with
7
+ `new AsyncFunction(js)` on EVERY dev rebuild, and — for `ts` blocks — runs them
8
+ through a `Bun.Transpiler`. Both retain memory the JS heap never shows you:
9
+
10
+ - JSC caches compiled code, KEYED BY SOURCE TEXT. That last part is the trap: replay
11
+ the same corpus and RSS plateaus (the cache dedups), so a leak test on an unchanged
12
+ corpus reports "flat" and lies. But a dev server only rebuilds BECAUSE A FILE
13
+ CHANGED — the source is fresh every time, so nothing dedups and it never plateaus.
14
+ Measured with fresh sources: +7.1MB over 40 rebuilds, still climbing.
15
+ - `Bun.Transpiler` strands ~40KB per construction (oven-sh/bun#34053).
16
+
17
+ In a process that lives for days across thousands of rebuilds, that is unbounded. The
18
+ child hands all of it back to the OS on exit. Same reasoning — and the same fix — as
19
+ the bundle step, the ePub step, and generate-css; see the Bun.build note in
20
+ orchestrator.ts.
21
+
22
+ bun check-examples-cli.ts <docs.json> [contextKey ...]
23
+ → writes ExampleProblem[] as JSON to stdout; exit 0 whether or not problems were
24
+ found (a problem is data, not a crash — only a real failure exits non-zero).
25
+
26
+ Build-time only. Never import this from browser code.
27
+ */
28
+ import { checkExamples } from './check-examples';
29
+ const docsJson = process.argv[2];
30
+ if (!docsJson) {
31
+ console.error('check-examples-cli: expected a docs.json path');
32
+ process.exit(1);
33
+ }
34
+ const contextKeys = process.argv.slice(3);
35
+ const corpus = JSON.parse(await Bun.file(docsJson).text());
36
+ const problems = await checkExamples(corpus, contextKeys.length ? { contextKeys } : {});
37
+ // stdout is the channel — the parent parses this. Anything else this process prints
38
+ // (warnings from the transform, say) goes to stderr so it can't corrupt the payload.
39
+ process.stdout.write(JSON.stringify(problems));
@@ -2,6 +2,32 @@ import type { SiteConfig } from './site-config';
2
2
  declare global {
3
3
  var Bun: any;
4
4
  }
5
+ /**
6
+ * Resolve the idle-exit timeout to milliseconds (0 = disabled).
7
+ *
8
+ * Env wins over config, config over the default. An unparseable value falls back
9
+ * to the default rather than to 0: a typo'd `DEV_IDLE_TIMEOUT_HOURS=8h` must not
10
+ * silently turn the guard OFF — that is the exact failure it exists to prevent.
11
+ * Only an explicit non-positive number disables it.
12
+ */
13
+ export declare function resolveIdleMs(configHours: number | undefined, envHours: string | undefined): number;
14
+ /**
15
+ * Resolve the RSS ceiling in MB (0 = disabled).
16
+ *
17
+ * Same rule as `resolveIdleMs`, and it was NOT being applied: the ceiling was read as
18
+ * `Number(env ?? config ?? 4096)`, which fails in both directions at once —
19
+ *
20
+ * DEV_MEMORY_LIMIT_MB='' → `??` passes '' through → Number('') === 0 → the ceiling
21
+ * is ZERO, so `rss >= limit` is true on the first sample
22
+ * and the dev server kills itself on every rebuild.
23
+ * DEV_MEMORY_LIMIT_MB=4gb → NaN → every `>=` comparison is false → the guard is
24
+ * silently OFF, on the machine of someone who was
25
+ * explicitly trying to configure it.
26
+ *
27
+ * An empty env var is *unset*, not zero. Garbage falls back to the default, never to
28
+ * off. Only an explicit non-positive number disables the ceiling.
29
+ */
30
+ export declare function resolveLimitMb(configMb: number | undefined, envMb: string | undefined): number;
5
31
  export declare function devServer(config: SiteConfig, opts?: {
6
32
  test?: boolean;
7
33
  build?: () => unknown | Promise<unknown>;