tosijs-ui 1.15.3 → 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,52 @@
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
+
3
50
  ## 1.15.3
4
51
 
5
52
  ### `<tosi-md sanitize="on">`, and a heads-up: it becomes the default in 1.16 (#179)
@@ -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,11 +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
23
  import { relocateSourcemaps } from './sourcemap-relocate.js';
24
+ import { bundleBuildArgs, bundleDeclarationArgs, extensionlessImports, } from './library-bundle.js';
24
25
  import { checkExamples, formatExampleProblems, } from './check-examples.js';
25
26
  import { ensureSections } from './sections.js';
26
27
  import { generateLlmsTxt } from './make-llms-txt.js';
@@ -348,7 +349,9 @@ export function shouldCleanDist(config) {
348
349
  // even alongside the other flags — the consumer's function is the authority.
349
350
  if (config.libraryBuild)
350
351
  return false;
351
- return config.emitLibrary === true || Boolean(config.libraryTsconfig);
352
+ return (config.emitLibrary === true ||
353
+ Boolean(config.libraryTsconfig) ||
354
+ Boolean(config.libraryBundle));
352
355
  }
353
356
  /*
354
357
  Announce-once state for the "writes outside outputDir" warning.
@@ -714,8 +717,9 @@ export async function buildSite(config, opts = {}) {
714
717
  directory and may legitimately emit a subset, which is exactly the reported case.
715
718
  - nothing configured — NOT cleaned. Nothing here owns it.
716
719
 
717
- Incidental win: `emitLibrary` runs `tsc --incremental`, whose `.tsbuildinfo` lives in
718
- `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.
719
723
  */
720
724
  if (shouldCleanDist(config)) {
721
725
  await $ `rm -rf ${DIST}`.text();
@@ -748,6 +752,36 @@ export async function buildSite(config, opts = {}) {
748
752
  tsconfig: config.libraryTsconfig,
749
753
  });
750
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
+ }
751
785
  else if (config.libraryTsconfig) {
752
786
  // Consumer-controlled library build (handles root noEmit, removeComments,
753
787
  // outDir, etc.). tsc output is left visible so the errors are readable.
@@ -759,13 +793,38 @@ export async function buildSite(config, opts = {}) {
759
793
  }
760
794
  }
761
795
  else if (config.emitLibrary) {
762
- 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();
763
805
  if (r.exitCode !== 0) {
764
806
  console.error(`❌ tsc --declaration FAILED (exit ${r.exitCode}) — emitted dist/*.d.ts may ` +
765
807
  `be stale or contain type errors. Build marked failed.`);
766
808
  libraryBuildFailed = true;
767
809
  }
768
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
+ }
769
828
  // The hydration bundle. If bundleEntry is set we build it (IIFE), else pages
770
829
  // load config.scriptUrl (default /iife.js) — a prebuilt/CDN bundle the
771
830
  // consumer supplies (e.g. via staticDirs or an absolute URL).
@@ -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
  /**