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 +123 -0
- package/bin/caddy-install.ts +0 -0
- package/bin/make-icon-data.js +4 -1
- package/bin/release-notes.ts +0 -0
- package/dist/dialog.d.ts +14 -2
- package/dist/dialog.js +87 -5
- package/dist/doc-system/site/build-stamp.d.ts +6 -1
- package/dist/doc-system/site/index.d.ts +1 -0
- package/dist/doc-system/site/library-bundle.d.ts +26 -0
- package/dist/doc-system/site/library-bundle.js +100 -0
- package/dist/doc-system/site/orchestrator.d.ts +1 -0
- package/dist/doc-system/site/orchestrator.js +77 -6
- package/dist/doc-system/site/site-config.d.ts +18 -4
- package/dist/doc-system/site/sourcemap-relocate.d.ts +7 -0
- package/dist/doc-system/site/sourcemap-relocate.js +50 -0
- package/dist/icon-data.d.ts +322 -1
- package/dist/icon-types.d.ts +14 -0
- package/dist/icons.d.ts +1 -0
- package/dist/iife.js +88 -88
- package/dist/iife.js.map +10 -9
- package/dist/markdown-viewer.d.ts +7 -0
- package/dist/markdown-viewer.js +83 -5
- package/dist/tag-list.d.ts +1 -0
- package/dist/tag-list.js +127 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +4 -3
- package/dist/.metadata_never_index +0 -0
- package/dist/tsconfig.tsbuildinfo +0 -1
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)
|
package/bin/caddy-install.ts
CHANGED
|
File without changes
|
package/bin/make-icon-data.js
CHANGED
|
@@ -414,7 +414,10 @@ let source =
|
|
|
414
414
|
(typeDeclaration ? typeDeclaration + '\n\n' : '') +
|
|
415
415
|
'export default ' +
|
|
416
416
|
emitObject(iconData) +
|
|
417
|
-
|
|
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
|
package/bin/release-notes.ts
CHANGED
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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 ||
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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;
|