tosijs-ui 1.15.2 → 1.15.4

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,128 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.4
4
+
5
+ ### `libraryBundle`: a library build Node can actually load (#169)
6
+
7
+ `emitLibrary` and `libraryTsconfig` run a bare `tsc`, which copies each import specifier through
8
+ unchanged. Written in the usual bundler style (`export * from './model'`), that produces a
9
+ `dist/` **Node refuses** (`ERR_MODULE_NOT_FOUND`), though Bun and bundlers resolve it, so it
10
+ tends to ship unnoticed. Three projects had each written their own guard for it.
11
+
12
+ The new `libraryBundle` option is the paved road:
13
+
14
+ ```ts
15
+ libraryBundle: { entries: ['src/index.ts', 'src/core.ts'] }
16
+ ```
17
+
18
+ `bun build` bundles the entries with code-splitting (every declared dependency and peer stays an
19
+ import), `tsc` emits declarations only, and the build **fails** if `dist/` still has a relative
20
+ import without a file extension. Options: `externals` (default: all declared dependencies and
21
+ peers), `tsconfig` for the declaration pass, `target` (default `browser`). It needs `typescript`
22
+ installed, as any TypeScript library has.
23
+
24
+ `emitLibrary` and `libraryTsconfig` keep working, and now **warn** when their output has that
25
+ problem.
26
+
27
+ ### Tag colours on chips and in the pick menu (#173)
28
+
29
+ A Tag in `<tosi-tag-list>`'s `availableTags` can carry `background` and `color`. Chips now use
30
+ them (they were ignored), and the pick menu shows each coloured tag's caption as a lozenge in
31
+ those colours, with the checkmark beside it. Without a `color`, the text is black or white,
32
+ whichever contrasts more.
33
+
34
+ ### Tag list fixes (#189)
35
+
36
+ - A Tag object without a `caption` (the obvious way to colour chips: `{ value, color, background }`)
37
+ showed as a **blank row** in the pick menu; on a phone, a tall empty column. It now shows its
38
+ value.
39
+ - A selected tag that was also in `availableTags` as an object appeared in the menu **twice**:
40
+ string tags were compared against Tag objects with `includes`. It now compares by value.
41
+ - `<tosi-tag-list>`'s `value` is documented as `string | string[]`, but an array threw
42
+ `split is not a function` on render.
43
+
44
+ ### `emitLibrary` no longer ships `tsconfig.tsbuildinfo`
45
+
46
+ Libraries built with `emitLibrary` shipped TypeScript's incremental-build cache in `dist/`: no
47
+ use to consumers, and different on every rebuild. It is gone; rebuild and republish to drop it
48
+ from your package.
49
+
50
+ ## 1.15.3
51
+
52
+ ### `<tosi-md sanitize="on">`, and a heads-up: it becomes the default in 1.16 (#179)
53
+
54
+ `<tosi-md>` assigns rendered markdown to the page, and markdown can carry raw HTML. For text you
55
+ did not write (issue bodies, comments, anything from a user or an API), that is a **stored
56
+ XSS**: tosijs-virta's pre-release review demonstrated reading a token out of `localStorage`
57
+ through an issue body with `<img onerror>`.
58
+
59
+ `sanitize="on"` strips executable content (scripts, event-handler attributes, unsafe URL
60
+ schemes) with [kilpi](https://www.npmjs.com/package/tosijs-kilpi), and drops any link that is
61
+ not safe to navigate to. It **removes some elements together with everything inside them**:
62
+ `script`, `style`, `iframe`, `object`, `embed`, `form`, `link`, `meta`, `base`, `noscript`,
63
+ `template` and SVG animation elements. So markdown that embeds a `<form>` needs `sanitize="off"`.
64
+ Markdown, ordinary links (relative, `#anchor`, `mailto:`), inputs and custom elements survive. A
65
+ bare `<tosi-md sanitize>` also means on.
66
+
67
+ **If you leave `sanitize` unset, nothing changes in this release** except one console warning
68
+ per page. **In 1.16 the default becomes `on`.** Set `sanitize="on"` or `sanitize="off"` now and
69
+ the upgrade will not affect you; `off` keeps raw HTML and is silent.
70
+
71
+ `tosijs-kilpi` is a new runtime dependency: +865 bytes gzip on `dist/iife.js`.
72
+
73
+ ### Escape closes a `<tosi-dialog>` properly — `confirm`/`alert`/`prompt` no longer hang (#183)
74
+
75
+ Escape let the browser close the native `<dialog>` directly, bypassing `close()`. So
76
+ `await TosiDialog.confirm(...)` (and `alert`, `prompt`) **never settled**, the `showModal()`
77
+ promise never resolved, and a `removeOnClose` dialog stayed in the DOM, one leaked element per
78
+ Escape. Escape now closes with `'cancel'` through the same path as the Cancel button. So does
79
+ the browser closing the element on its own (a `<form method="dialog">`, or Chrome refusing a
80
+ repeated Escape), so nothing waits forever.
81
+
82
+ **New: `dialogWillClose` can refuse a close.** Return `false`, or a Promise of `false`, and the
83
+ dialog stays open, e.g. while an async save is in flight. `close()` now returns whether it
84
+ closed. Reported by snowfox-app, which carried a workaround at nine sites.
85
+
86
+ The default `dialogWillClose` no longer `console.log`s on every close, and the docs now say that
87
+ the built-in **OK** button sits beside footer buttons rather than being replaced by them.
88
+
89
+ ### Icon types: `IconElement` is importable, and `icons.x()` needs no `!` (#176, #181)
90
+
91
+ - `IconElement`, `IconName` and `SVGIconMap` are exported from `tosijs-ui/icons`:
92
+ `import { icons, type IconElement } from 'tosijs-ui/icons'`. 1.15 widened the icon return
93
+ type from `SVGElement` to `SVGElement | HTMLSpanElement` (a composite icon is a span), and
94
+ there was no importable name for the new union.
95
+ - Under `noUncheckedIndexedAccess`, every `icons.user()` read as possibly undefined, forcing
96
+ `icons.user!()` everywhere. Built-in icon names are now explicit keys of the type, so the `!`
97
+ can go, and a misspelled `IconName` is a type error. Composites and `defineIcons()` names
98
+ still go through the index signature.
99
+ - `tosijs-make-icons` now generates `satisfies IconData` rather than `as IconData`, which keeps
100
+ icon names in the type system. **It needs TypeScript 4.9 or later** in the project that runs
101
+ it.
102
+
103
+ ### `tagList.popSelectMenu` can be replaced after the element is on the page (#172)
104
+
105
+ The documented "replace this method" hook only worked if you replaced it before the element
106
+ connected, which `tosiTagList({...})` never allows: the button captured the original function.
107
+ It now looks the method up on every click.
108
+
109
+ ### Doc-site sourcemaps no longer expose the build machine's paths
110
+
111
+ A doc site built with `bundleEntry` published sourcemaps whose sources climbed to the
112
+ filesystem root and into the builder's home directory (`../../../../../../Users/<name>/…`),
113
+ because the hydration bundle was built in the OS temp directory. The deployed site revealed
114
+ the build machine's directory layout, and two machines could not produce the same site. The
115
+ bundle is now built under your project's `node_modules/.cache`, and its maps are rewritten to
116
+ point at the sources from where they are served, so devtools find them and every machine
117
+ builds identical output. Rebuild your site to replace the old maps.
118
+
119
+ ### `/version.json`: what `commit` means (#180)
120
+
121
+ Documentation only. Since #122, a content-identical rebuild keeps the previous stamp, so `commit`
122
+ is the last commit whose build *changed the site*, and can be older than `HEAD`. To check that
123
+ committed output matches `HEAD`, rebuild and confirm the tree is clean. `doc-site-system.md` now
124
+ says so and shows `contentHash`.
125
+
3
126
  ## 1.15.2
4
127
 
5
128
  ### The `tjs-lang` peer admits 0.14.x (#182)
File without changes
@@ -414,7 +414,10 @@ let source =
414
414
  (typeDeclaration ? typeDeclaration + '\n\n' : '') +
415
415
  'export default ' +
416
416
  emitObject(iconData) +
417
- (isTypescript ? ' as IconData\n' : '\n')
417
+ // `satisfies`, not `as`: `as IconData` widened the keys to `string`, so the icon NAMES were
418
+ // lost to the type system and `icons.user` read as possibly-undefined under
419
+ // noUncheckedIndexedAccess (#181). `satisfies` checks the shape and keeps the names.
420
+ (isTypescript ? ' satisfies IconData\n' : '\n')
418
421
 
419
422
  /*
420
423
  If prettier is resolvable, let it have the last word — it picks up the CONSUMER's own
File without changes
package/dist/dialog.d.ts CHANGED
@@ -51,10 +51,22 @@ export declare class TosiDialog extends Component<DialogParts> {
51
51
  removeOnClose: boolean;
52
52
  closeOnBackgroundClick: boolean;
53
53
  constructor();
54
- dialogWillClose: (reason?: string) => void;
54
+ /**
55
+ Called before the dialog closes, with the reason (`'confirm'`, `'cancel'`, or whatever was
56
+ passed to `close()`). Return `false` — or a Promise resolving to `false` — to refuse the
57
+ close, e.g. while an async save is in flight: a dialog that cannot be cancelled must not
58
+ look as though it was.
59
+ */
60
+ dialogWillClose: (_reason?: string) => void | boolean | Promise<void | boolean>;
55
61
  initialFocus(): void;
56
62
  showModal: () => Promise<string | null>;
57
- close: (reason?: string) => void;
63
+ /**
64
+ Close the dialog unless `dialogWillClose` vetoes it. Returns whether it closed — a Promise
65
+ of that when `dialogWillClose` is async.
66
+ */
67
+ close: (reason?: string) => boolean | Promise<boolean>;
68
+ onCancel: (event: Event) => void;
69
+ onNativeClose: () => void;
58
70
  ok: () => void;
59
71
  content: () => HTMLDialogElement;
60
72
  }
package/dist/dialog.js CHANGED
@@ -43,6 +43,32 @@ test('dialog has header slot content', () => {
43
43
  })
44
44
  ```
45
45
 
46
+ The dialog always supplies its own **OK** button (`part="ok"`, which closes with
47
+ `'confirm'`). Buttons you put in the `footer` slot appear **beside** it, not instead of it, so
48
+ add a Cancel button there, not a second OK.
49
+
50
+ ## Closing
51
+
52
+ Every way a dialog closes — the OK button, `close(reason)`, a background click when
53
+ `closeOnBackgroundClick` is set, **Escape**, or a `<form method="dialog">` — goes through the
54
+ same path: `dialogWillClose(reason)` is called, the `showModal()` promise resolves with the
55
+ reason, and a `removeOnClose` dialog removes itself. Escape closes with `'cancel'`.
56
+
57
+ `dialogWillClose` can **refuse** a close by returning `false`, or a Promise resolving to
58
+ `false`. Use it for a dialog that must not be dismissed while it is busy, such as an async save
59
+ in flight. `close()` returns whether the dialog closed, or a Promise of that when
60
+ `dialogWillClose` is async.
61
+
62
+ ```typescript
63
+ const editor = tosiDialog({
64
+ dialogWillClose: () => !saving, // Escape and Cancel do nothing mid-save
65
+ })
66
+ ```
67
+
68
+ The browser can close a `<dialog>` without asking (a `<form method="dialog">` submission, or a
69
+ repeated Escape without fresh user interaction in Chrome). Those cannot be refused; the dialog
70
+ still calls `dialogWillClose` and resolves, so nothing waits forever.
71
+
46
72
  ## Static Functions
47
73
 
48
74
  `TosiDialog` provides static async functions to replace the built-in dialogs provided by
@@ -232,8 +258,14 @@ export class TosiDialog extends Component {
232
258
  }
233
259
  });
234
260
  }
235
- dialogWillClose = (reason = 'cancel') => {
236
- console.log('dialog will close with', reason);
261
+ /**
262
+ Called before the dialog closes, with the reason (`'confirm'`, `'cancel'`, or whatever was
263
+ passed to `close()`). Return `false` — or a Promise resolving to `false` — to refuse the
264
+ close, e.g. while an async save is in flight: a dialog that cannot be cancelled must not
265
+ look as though it was.
266
+ */
267
+ dialogWillClose = (_reason = 'cancel') => {
268
+ /* override to observe or veto */
237
269
  };
238
270
  initialFocus() {
239
271
  this.parts.ok.focus();
@@ -241,8 +273,16 @@ export class TosiDialog extends Component {
241
273
  #modalResolution = (_outcome) => {
242
274
  /* noop */
243
275
  };
276
+ /*
277
+ True once this showing's close side effects have run — resolution, removal — so that no path
278
+ runs them twice. Every route to "closed" funnels into #finish, and there are three: close()
279
+ (buttons, background click, callers), Escape (the native `cancel`), and the browser closing
280
+ the element on its own (the native `close` event, below).
281
+ */
282
+ #settled = false;
244
283
  showModal = () => {
245
284
  this.style.zIndex = String(findHighestZ());
285
+ this.#settled = false;
246
286
  return new Promise((resolve) => {
247
287
  this.#modalResolution = resolve;
248
288
  this.parts.dialog.showModal();
@@ -251,17 +291,59 @@ export class TosiDialog extends Component {
251
291
  });
252
292
  });
253
293
  };
294
+ /**
295
+ Close the dialog unless `dialogWillClose` vetoes it. Returns whether it closed — a Promise
296
+ of that when `dialogWillClose` is async.
297
+ */
254
298
  close = (reason = 'cancel') => {
255
- this.dialogWillClose(reason);
299
+ if (this.#settled) {
300
+ return true;
301
+ }
302
+ const verdict = this.dialogWillClose(reason);
303
+ if (verdict instanceof Promise) {
304
+ return verdict.then((v) => (v === false ? false : this.#finish(reason)));
305
+ }
306
+ return verdict === false ? false : this.#finish(reason);
307
+ };
308
+ #finish(reason) {
309
+ if (this.#settled) {
310
+ return true;
311
+ }
312
+ this.#settled = true;
256
313
  this.#modalResolution(reason);
257
- this.parts.dialog.close();
314
+ if (this.parts.dialog.open) {
315
+ this.parts.dialog.close();
316
+ }
258
317
  if (this.removeOnClose) {
259
318
  this.remove();
260
319
  }
320
+ return true;
321
+ }
322
+ /*
323
+ Escape (#183). The browser would close the <dialog> itself and bypass close(), so the static
324
+ helpers never resolved and removeOnClose leaked the element. Take the close over instead,
325
+ which is also what lets dialogWillClose veto it.
326
+ */
327
+ onCancel = (event) => {
328
+ event.preventDefault();
329
+ this.close('cancel');
330
+ };
331
+ /*
332
+ The browser can still close the element without asking: Chrome's close-watcher makes a repeat
333
+ `cancel` non-cancelable without fresh user activation, and `<form method="dialog">` closes it
334
+ directly. Too late to veto, so tell dialogWillClose and settle, rather than hang the caller.
335
+ */
336
+ onNativeClose = () => {
337
+ if (this.#settled) {
338
+ return;
339
+ }
340
+ const reason = this.parts.dialog.returnValue || 'cancel';
341
+ this.dialogWillClose(reason);
342
+ this.#finish(reason);
261
343
  };
262
344
  ok = () => {
263
345
  this.close('confirm');
264
346
  };
265
- content = () => dialog({ part: 'dialog' }, header(tosiSlot({ name: 'header' })), tosiSlot(), footer(tosiSlot({ name: 'footer' }), button({ part: 'ok', onClick: this.ok }, 'OK')));
347
+ content = () => dialog({ part: 'dialog', onCancel: this.onCancel, onClose: this.onNativeClose }, header(tosiSlot({ name: 'header' })), tosiSlot(), footer(tosiSlot({ name: 'footer' }), button({ part: 'ok', onClick: this.ok }, 'OK')));
266
348
  }
267
349
  export const tosiDialog = TosiDialog.elementCreator();
@@ -3,7 +3,12 @@ export interface BuildStamp {
3
3
  generator: string;
4
4
  /** the project's name, from site config */
5
5
  site?: string;
6
- /** short commit hash the site was built from, when available */
6
+ /**
7
+ * Short hash of the last commit whose build CHANGED the site's content, when available.
8
+ * A content-identical rebuild keeps the previous value (see `stampToWrite`, #122), so this
9
+ * can be older than HEAD — to check committed output matches HEAD, rebuild and check the
10
+ * tree is clean instead (#180).
11
+ */
7
12
  commit?: string;
8
13
  /** ISO-8601 commit timestamp, when available */
9
14
  commitTime?: string;
@@ -8,6 +8,7 @@ export { openDevBrowser, buildOpenPlan } from './open-browser.js';
8
8
  export type { OpenPlan, OpenPlanInput } from './open-browser.js';
9
9
  export { extractDocs, saveDocsJSON } from './docs.js';
10
10
  export type { Doc } from './docs.js';
11
+ export type { LibraryBundle } from './library-bundle.js';
11
12
  export { generateSite } from './generate-site.js';
12
13
  export type { GenerateSiteConfig } from './generate-site.js';
13
14
  export { buildEpub, DEFAULT_BOOK_CSS } from './epub.js';
@@ -0,0 +1,26 @@
1
+ export interface LibraryBundle {
2
+ /** Entry points relative to the project root, e.g. `['src/index.ts', 'src/core.ts']`. */
3
+ entries: string[];
4
+ /**
5
+ * Packages left as imports instead of bundled (each also matches its subpaths). Default:
6
+ * every `dependencies`, `peerDependencies` and `optionalDependencies` name in package.json.
7
+ */
8
+ externals?: string[];
9
+ /** tsconfig for the declaration pass (run with `--emitDeclarationOnly`). Default: the root one. */
10
+ tsconfig?: string;
11
+ /** `bun build --target`. Default `browser`. */
12
+ target?: 'browser' | 'node' | 'bun';
13
+ }
14
+ interface PackageJsonDeps {
15
+ dependencies?: Record<string, string>;
16
+ peerDependencies?: Record<string, string>;
17
+ optionalDependencies?: Record<string, string>;
18
+ }
19
+ /** The externals to use: the configured list, or every declared runtime dependency. */
20
+ export declare function bundleExternals(bundle: LibraryBundle, pkg: PackageJsonDeps): string[];
21
+ /** The `bun build` argv for the JavaScript half. */
22
+ export declare function bundleBuildArgs(bundle: LibraryBundle, pkg: PackageJsonDeps, outdir: string): string[];
23
+ /** The `tsc` argv for the declaration half. */
24
+ export declare function bundleDeclarationArgs(bundle: LibraryBundle, outdir: string): string[];
25
+ export declare function extensionlessImports(dir: string): string[];
26
+ export {};
@@ -0,0 +1,100 @@
1
+ /*
2
+ The bundled library build (`SiteConfig.libraryBundle`, #169).
3
+
4
+ `emitLibrary` / `libraryTsconfig` run a bare `tsc`, which copies each source file's import
5
+ specifiers into the output unchanged. Written in bundler style (`export * from './model'`), that
6
+ output is a `dist/` Node refuses: `ERR_MODULE_NOT_FOUND .../dist/model`. Bun resolves it, so
7
+ nothing in a Bun build or test loop notices, and it ships. tosijs-3d, tosijs-3d-ensemble and
8
+ tosijs-virta each grew their own guard for this, in three different shapes.
9
+
10
+ This is the paved road instead: `bun build` bundles the entries with splitting (its output
11
+ imports its own chunks WITH extensions, by construction), every declared dependency and peer
12
+ stays an import rather than being bundled, and `tsc` emits declarations only. The result is
13
+ then scanned for relative imports without an extension, and any hit fails the build — the
14
+ guard the three repos wrote, done once.
15
+
16
+ Every heavy step runs as a CHILD process (`bun build`, `tsc`): this code also runs inside the
17
+ long-lived dev server, where Bun.build() leaks its native arena on every call.
18
+ */
19
+ import { readdirSync, readFileSync, statSync } from 'fs';
20
+ import * as path from 'path';
21
+ /** The externals to use: the configured list, or every declared runtime dependency. */
22
+ export function bundleExternals(bundle, pkg) {
23
+ const names = bundle.externals ?? [
24
+ ...Object.keys(pkg.dependencies ?? {}),
25
+ ...Object.keys(pkg.peerDependencies ?? {}),
26
+ ...Object.keys(pkg.optionalDependencies ?? {}),
27
+ ];
28
+ return [...new Set(names)].sort();
29
+ }
30
+ /** The `bun build` argv for the JavaScript half. */
31
+ export function bundleBuildArgs(bundle, pkg, outdir) {
32
+ return [
33
+ 'bun',
34
+ 'build',
35
+ ...bundle.entries,
36
+ '--outdir',
37
+ outdir,
38
+ '--format',
39
+ 'esm',
40
+ '--splitting',
41
+ '--target',
42
+ bundle.target ?? 'browser',
43
+ '--sourcemap=linked',
44
+ ...bundleExternals(bundle, pkg).flatMap((name) => [
45
+ '--external',
46
+ name,
47
+ '--external',
48
+ `${name}/*`,
49
+ ]),
50
+ ];
51
+ }
52
+ /** The `tsc` argv for the declaration half. */
53
+ export function bundleDeclarationArgs(bundle, outdir) {
54
+ return [
55
+ 'bun',
56
+ 'tsc',
57
+ ...(bundle.tsconfig ? ['-p', bundle.tsconfig] : []),
58
+ '--declaration',
59
+ '--emitDeclarationOnly',
60
+ // a root tsconfig with `noEmit: true` would otherwise emit nothing at all
61
+ '--noEmit',
62
+ 'false',
63
+ '--outDir',
64
+ outdir,
65
+ ];
66
+ }
67
+ const HAS_EXTENSION = /\.(?:m?js|cjs|json)$/;
68
+ /*
69
+ Real imports, from Bun's parser rather than a regex: a regex also matched specifiers quoted in
70
+ COMMENTS — four false hits in tosijs-ui's own dist, one of them this file's own doc comment.
71
+ One Transpiler for the process: each construction costs ~40KB that is never returned, and this
72
+ runs inside the long-lived dev server (see CLAUDE.md, "Dev servers are the most dangerous thing").
73
+ */
74
+ let transpiler;
75
+ function importsOf(source) {
76
+ transpiler ??= new Bun.Transpiler({ loader: 'js' });
77
+ return transpiler
78
+ .scanImports(source)
79
+ .map((entry) => entry.path);
80
+ }
81
+ export function extensionlessImports(dir) {
82
+ const found = [];
83
+ const walk = (current) => {
84
+ for (const name of readdirSync(current)) {
85
+ const full = path.join(current, name);
86
+ if (statSync(full).isDirectory()) {
87
+ walk(full);
88
+ }
89
+ else if (/\.m?js$/.test(name)) {
90
+ for (const specifier of importsOf(readFileSync(full, 'utf8'))) {
91
+ if (/^\.{1,2}\//.test(specifier) && !HAS_EXTENSION.test(specifier)) {
92
+ found.push(`${path.relative(dir, full)}: ${specifier}`);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ };
98
+ walk(dir);
99
+ return found;
100
+ }
@@ -28,6 +28,7 @@ export declare function shouldCleanDist(config: {
28
28
  emitLibrary?: boolean;
29
29
  libraryTsconfig?: string;
30
30
  libraryBuild?: unknown;
31
+ libraryBundle?: unknown;
31
32
  }): boolean;
32
33
  export declare function buildSite(config: SiteConfig, opts?: {
33
34
  skipAudit?: boolean;
@@ -16,10 +16,12 @@ import { namedBooks, partitionByBook, DEFAULT_BOOK } from '../book-target.js';
16
16
  import { listEpubVolumes, renderEpubDownloads } from './epub-volumes.js';
17
17
  import { buildSlugMap } from '../routing.js';
18
18
  import { computeAssetStamp, missingStampInputWarning } from './asset-stamp.js';
19
- import { existsSync, mkdirSync, unlinkSync } from 'fs';
19
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
20
20
  import { tmpdir } from 'os';
21
21
  import { $, spawn } from 'bun';
22
22
  import { extractDocs } from './docs.js';
23
+ import { relocateSourcemaps } from './sourcemap-relocate.js';
24
+ import { bundleBuildArgs, bundleDeclarationArgs, extensionlessImports, } from './library-bundle.js';
23
25
  import { checkExamples, formatExampleProblems, } from './check-examples.js';
24
26
  import { ensureSections } from './sections.js';
25
27
  import { generateLlmsTxt } from './make-llms-txt.js';
@@ -347,7 +349,9 @@ export function shouldCleanDist(config) {
347
349
  // even alongside the other flags — the consumer's function is the authority.
348
350
  if (config.libraryBuild)
349
351
  return false;
350
- return config.emitLibrary === true || Boolean(config.libraryTsconfig);
352
+ return (config.emitLibrary === true ||
353
+ Boolean(config.libraryTsconfig) ||
354
+ Boolean(config.libraryBundle));
351
355
  }
352
356
  /*
353
357
  Announce-once state for the "writes outside outputDir" warning.
@@ -713,8 +717,9 @@ export async function buildSite(config, opts = {}) {
713
717
  directory and may legitimately emit a subset, which is exactly the reported case.
714
718
  - nothing configured — NOT cleaned. Nothing here owns it.
715
719
 
716
- Incidental win: `emitLibrary` runs `tsc --incremental`, whose `.tsbuildinfo` lives in
717
- `dist/`. Wiping it every run meant incremental compilation never once did anything.
720
+ `emitLibrary` used to run `tsc --incremental`, whose `.tsbuildinfo` lived in `dist/`; wiping
721
+ it every run meant incremental compilation never once did anything. It no longer runs
722
+ incrementally at all — see the tsc call below for why moving the cache is not the fix.
718
723
  */
719
724
  if (shouldCleanDist(config)) {
720
725
  await $ `rm -rf ${DIST}`.text();
@@ -747,6 +752,36 @@ export async function buildSite(config, opts = {}) {
747
752
  tsconfig: config.libraryTsconfig,
748
753
  });
749
754
  }
755
+ else if (config.libraryBundle) {
756
+ // The bundled library build (#169): child processes only, never Bun.build() here.
757
+ const pkg = JSON.parse(readFileSync(path.resolve(PROJECT_ROOT, 'package.json'), 'utf8'));
758
+ for (const [what, argv] of [
759
+ ['bun build', bundleBuildArgs(config.libraryBundle, pkg, DIST)],
760
+ [
761
+ 'tsc --emitDeclarationOnly',
762
+ bundleDeclarationArgs(config.libraryBundle, DIST),
763
+ ],
764
+ ]) {
765
+ const child = spawn([...argv], { stdout: 'pipe', stderr: 'pipe' });
766
+ const output = await drainChild(child);
767
+ if ((await child.exited) !== 0) {
768
+ console.error(output);
769
+ console.error(`❌ libraryBundle: ${what} FAILED — dist/ is incomplete. Build marked failed.` +
770
+ (/Script not found "tsc"/.test(output)
771
+ ? ' TypeScript is not installed in this project: `bun add -d typescript`.'
772
+ : ''));
773
+ libraryBuildFailed = true;
774
+ break;
775
+ }
776
+ }
777
+ if (!libraryBuildFailed) {
778
+ const bad = extensionlessImports(DIST);
779
+ if (bad.length) {
780
+ console.error(`❌ libraryBundle: dist/ has relative imports without a file extension, which Node cannot load:\n ${bad.join('\n ')}`);
781
+ libraryBuildFailed = true;
782
+ }
783
+ }
784
+ }
750
785
  else if (config.libraryTsconfig) {
751
786
  // Consumer-controlled library build (handles root noEmit, removeComments,
752
787
  // outDir, etc.). tsc output is left visible so the errors are readable.
@@ -758,13 +793,38 @@ export async function buildSite(config, opts = {}) {
758
793
  }
759
794
  }
760
795
  else if (config.emitLibrary) {
761
- const r = await $ `bun tsc --declaration --incremental --outDir dist`.nothrow();
796
+ /*
797
+ NOT --incremental. Its .tsbuildinfo lived in dist/, so it shipped in every adopter's
798
+ tarball (and ours), and it differs between a first and a second build — which the publish
799
+ workflow's "shipped files reproduce" check rightly rejects. Moving the cache elsewhere is
800
+ WORSE: dist/ is wiped every build (above), and tsc trusts the cache, sees nothing changed,
801
+ and emits nothing — measured, the second build deleted 883 files from dist/. Since dist/
802
+ is always clean, incremental compilation could never help here anyway.
803
+ */
804
+ const r = await $ `bun tsc --declaration --outDir dist`.nothrow();
762
805
  if (r.exitCode !== 0) {
763
806
  console.error(`❌ tsc --declaration FAILED (exit ${r.exitCode}) — emitted dist/*.d.ts may ` +
764
807
  `be stale or contain type errors. Build marked failed.`);
765
808
  libraryBuildFailed = true;
766
809
  }
767
810
  }
811
+ /*
812
+ A bare tsc copies bundler-style specifiers into dist/ unchanged, and Node cannot load the
813
+ result (#169). Say so loudly on those two paths. Not a failure: it would break working
814
+ builds for consumers who only ever bundle, on a patch upgrade. libraryBundle fails on it.
815
+ */
816
+ if (!libraryBuildFailed &&
817
+ !config.libraryBuild &&
818
+ !config.libraryBundle &&
819
+ (config.libraryTsconfig || config.emitLibrary) &&
820
+ existsSync(DIST)) {
821
+ const bad = extensionlessImports(DIST);
822
+ if (bad.length) {
823
+ console.warn(`⚠️ dist/ has ${bad.length} relative import(s) without a file extension — Node rejects them ` +
824
+ `(ERR_MODULE_NOT_FOUND), even though Bun and bundlers resolve them. Use \`libraryBundle\` ` +
825
+ `instead of ${config.libraryTsconfig ? 'libraryTsconfig' : 'emitLibrary'} (tosijs-ui#169). First: ${bad[0]}`);
826
+ }
827
+ }
768
828
  // The hydration bundle. If bundleEntry is set we build it (IIFE), else pages
769
829
  // load config.scriptUrl (default /iife.js) — a prebuilt/CDN bundle the
770
830
  // consumer supplies (e.g. via staticDirs or an absolute URL).
@@ -951,8 +1011,17 @@ export async function buildSite(config, opts = {}) {
951
1011
  Build it in a temp dir instead. The only consumer is the copy into PUBLIC on the
952
1012
  next line, so nothing needs it to persist — and a temp dir cannot be swept into a
953
1013
  published tarball by a broad `files` entry.
1014
+
1015
+ Not the OS temp dir, though. `bun build` writes each sourcemap `sources` entry relative
1016
+ to the OUTPUT dir, so from /tmp every path climbed to `/` and back down into the builder's
1017
+ home — `../../../../../../Users/<name>/<project>/node_modules/…`. That published the
1018
+ build machine's layout in every chunk map, and made docs/ differ between any two
1019
+ machines, which failed the publish workflow's rebuild check (#178). Under the project's
1020
+ own node_modules/.cache the paths are project-relative, so identical everywhere, and it
1021
+ is equally out of reach of a `files` entry.
954
1022
  */
955
- const HYDRATE_DIR = `${tmpdir()}/tosijs-hydrate-${process.pid}`;
1023
+ const HYDRATE_DIR = path.resolve(PROJECT_ROOT, 'node_modules/.cache', `tosijs-hydrate-${process.pid}`);
1024
+ mkdirSync(path.dirname(HYDRATE_DIR), { recursive: true });
956
1025
  await $ `rm -rf ${HYDRATE_DIR}`.text().catch(() => { });
957
1026
  const esm = spawn([
958
1027
  'bun',
@@ -998,6 +1067,8 @@ export async function buildSite(config, opts = {}) {
998
1067
  // Copy the whole ESM output (entry + hashed chunks) to the served root — the entry
999
1068
  // imports its chunks by RELATIVE path, so they must sit right beside it.
1000
1069
  await $ `cp -R ${HYDRATE_DIR}/. ${PUBLIC}/`.text();
1070
+ // The copy moved every map; re-point their sources from where they now sit (#178).
1071
+ relocateSourcemaps(HYDRATE_DIR, PUBLIC);
1001
1072
  hydrateName = 'hydrate.js';
1002
1073
  /*
1003
1074
  `bundleEntry` REPLACES our bundle rather than extending it, and an entry that forgets
@@ -1,3 +1,4 @@
1
+ import type { LibraryBundle } from './library-bundle.js';
1
2
  import type { ProjectLinks, LinkItem } from '../../doc-browser.js';
2
3
  import type { DocSystemTheme } from '../doc-system-styles.js';
3
4
  import type { Doc } from './docs.js';
@@ -192,10 +193,23 @@ export interface SiteConfig {
192
193
  */
193
194
  prebuild?: () => void | Promise<void>;
194
195
  /**
195
- * Also build the library: `tsc --declaration --incremental --outDir dist`
196
- * (ESM + types). Default false. Repos whose single build publishes BOTH an
197
- * npm package and its doc site (the tosijs-* libs) set this true; a pure docs
198
- * site omits it. Ignored when `libraryTsconfig` is set.
196
+ * Build the library as a BUNDLE — the recommended library build (#169). `bun build` bundles
197
+ * `entries` with splitting into `dist/` (declared dependencies and peers stay imports), then
198
+ * `tsc` emits declarations only. The output is checked for relative imports without a file
199
+ * extension, which Node cannot load, and the build fails on any. Owns `dist/` (cleaned each
200
+ * build). Takes precedence over `libraryTsconfig` / `emitLibrary`; a `libraryBuild`
201
+ * function takes precedence over it.
202
+ *
203
+ * ```ts
204
+ * libraryBundle: { entries: ['src/index.ts', 'src/core.ts'] }
205
+ * ```
206
+ */
207
+ libraryBundle?: LibraryBundle;
208
+ /**
209
+ * Also build the library: `tsc --declaration --outDir dist` (ESM + types). Default false.
210
+ * **Prefer `libraryBundle`:** a bare tsc copies bundler-style specifiers (`from './model'`)
211
+ * into `dist/` unchanged, and Node cannot load that; the build warns when it happens.
212
+ * Ignored when `libraryTsconfig` is set.
199
213
  */
200
214
  emitLibrary?: boolean;
201
215
  /**
@@ -0,0 +1,7 @@
1
+ /** Re-express one map's sources, as if the map moved from `fromDir` to `toDir`. */
2
+ export declare function relocateSources(sources: string[], fromDir: string, toDir: string): string[];
3
+ /**
4
+ * For every `.map` under `builtDir`, write a copy at the same relative path under
5
+ * `servedDir` with its `sources` rewritten for that location.
6
+ */
7
+ export declare function relocateSourcemaps(builtDir: string, servedDir: string): void;