tosijs-ui 1.6.5 → 1.6.7

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.
@@ -46,8 +46,12 @@ export interface LinkItem {
46
46
  * - 'query' (default, legacy): single-page app, links are `?filename`.
47
47
  * - 'path': clean per-page URLs (`/slug/`), for the static pre-rendered site
48
48
  * driven by <tosi-doc-system>. Requires a real page to exist at each path.
49
+ * - 'memory': self-contained — navigation never touches window.history/location
50
+ * and the instance ignores the page URL. For an embedded/nested browser (a
51
+ * live demo, a floating help panel). Drive it via `initialRoute` +
52
+ * `onRouteChange`, and the returned element's `.navigate(slug)` method.
49
53
  */
50
- export type DocRoutingMode = 'query' | 'path';
54
+ export type DocRoutingMode = 'query' | 'path' | 'memory';
51
55
  export interface DocBrowserOptions {
52
56
  docs: Doc[];
53
57
  context?: Record<string, any>;
@@ -56,6 +60,16 @@ export interface DocBrowserOptions {
56
60
  navSize?: number;
57
61
  minSize?: number;
58
62
  routing?: DocRoutingMode;
63
+ /**
64
+ * Memory routing only: the slug to show first (instead of the page URL / first
65
+ * doc). Lets a host mount the browser already pointed at a specific doc.
66
+ */
67
+ initialRoute?: string;
68
+ /**
69
+ * Memory routing only: called with the current doc's slug whenever in-app
70
+ * navigation happens, so a host can reflect it (e.g. to an attribute).
71
+ */
72
+ onRouteChange?: (slug: string) => void;
59
73
  /**
60
74
  * Header-bar links. When provided, these replace the legacy `projectLinks` icon
61
75
  * set in the header (each renders as an icon if `icon` names a known icon, else
@@ -239,20 +239,37 @@ const testIndicatorStyleSpec = {
239
239
  },
240
240
  };
241
241
  export function createDocBrowser(options) {
242
- const { docs, context = {}, projectName = '', projectLinks = {}, navSize = 200, minSize = 600, routing = 'query', navbarLinks, contentElement, } = options;
242
+ const { docs, context = {}, projectName = '', projectLinks = {}, navSize = 200, minSize = 600, routing = 'query', initialRoute, onRouteChange, navbarLinks, contentElement, } = options;
243
+ // Memory routing is fully self-contained: it never reads or writes
244
+ // window.history/location, so an embedded or nested browser can't hijack the
245
+ // host page's URL (or recurse into it).
246
+ const memoryRouting = routing === 'memory';
243
247
  // Initialize testStatus on all docs so tosi can track it
244
248
  for (const doc of docs) {
245
249
  doc.testStatus = undefined;
246
250
  }
247
251
  // Routing abstraction — keeps the legacy `?filename` SPA behavior the default,
248
252
  // while letting <tosi-doc-system> drive clean `/slug/` URLs off the same docs.
249
- const slugMap = routing === 'path' ? buildSlugMap(docs) : {};
250
- const hrefFor = (filename) => routing === 'path' ? pathForSlug(slugMap[filename] ?? filename) : `?${filename}`;
251
- const filenameFromLocation = () => routing === 'path'
252
- ? filenameForPath(document.location.pathname, slugMap)
253
- : document.location.search !== ''
254
- ? document.location.search.substring(1).split('&')[0]
255
- : '';
253
+ // Both path and memory routing key off slugs; only legacy query routing doesn't.
254
+ const slugMap = routing === 'query' ? {} : buildSlugMap(docs);
255
+ const slugFor = (filename) => slugMap[filename] ?? filename;
256
+ const filenameForSlug = (slug) => {
257
+ for (const d of docs)
258
+ if (slugFor(d.filename) === slug)
259
+ return d.filename;
260
+ return '';
261
+ };
262
+ const hrefFor = (filename) => routing === 'query' ? `?${filename}` : pathForSlug(slugFor(filename));
263
+ const filenameFromLocation = () => {
264
+ // Memory routing ignores the page URL entirely — it starts at initialRoute.
265
+ if (memoryRouting)
266
+ return initialRoute ? filenameForSlug(initialRoute) : '';
267
+ return routing === 'path'
268
+ ? filenameForPath(document.location.pathname, slugMap)
269
+ : document.location.search !== ''
270
+ ? document.location.search.substring(1).split('&')[0]
271
+ : '';
272
+ };
256
273
  const docName = filenameFromLocation() || docs[0]?.filename || 'README.md';
257
274
  const currentDoc = docs.find((doc) => doc.filename === docName) || docs[0];
258
275
  const { app } = tosi({
@@ -272,10 +289,13 @@ export function createDocBrowser(options) {
272
289
  let backgroundTestsStarted = false;
273
290
  let pagesWithTests = 0;
274
291
  let pagesTested = 0;
275
- // Set up global promise for scriptable browser integration
276
- window.__docTestResults = new Promise((resolve) => {
277
- testResultsResolve = resolve;
278
- });
292
+ // Set up global promise for scriptable browser integration. A memory-routed
293
+ // (embedded) browser must not clobber the host page's global.
294
+ if (!memoryRouting) {
295
+ window.__docTestResults = new Promise((resolve) => {
296
+ testResultsResolve = resolve;
297
+ });
298
+ }
279
299
  const updateDocTestStatus = (filename) => {
280
300
  const results = pageTestResults[filename];
281
301
  // Callback receives bare object, return is proxy - cast to work with both
@@ -378,9 +398,13 @@ export function createDocBrowser(options) {
378
398
  },
379
399
  onInput: filterDocs,
380
400
  });
381
- window.addEventListener('popstate', () => {
382
- navigateTo(filenameFromLocation());
383
- });
401
+ // Memory routing is decoupled from the page URL, so it ignores browser
402
+ // back/forward (the host owns history, if any).
403
+ if (!memoryRouting) {
404
+ window.addEventListener('popstate', () => {
405
+ navigateTo(filenameFromLocation());
406
+ });
407
+ }
384
408
  const headerContent = [
385
409
  button({
386
410
  class: 'iconic',
@@ -459,7 +483,12 @@ export function createDocBrowser(options) {
459
483
  padding: '0 1em',
460
484
  overflow: 'hidden',
461
485
  });
462
- let adoptInitialContent = contentElement !== undefined;
486
+ // Adoption is zero-flash hydration of a statically pre-rendered page: it only
487
+ // holds for the page's own path-routed instance, whose static `.doc-content`
488
+ // matches the initial doc. A memory-routed embed's initial doc
489
+ // (initialRoute/docs[0]) has no relation to whatever happens to sit inside the
490
+ // host element (often an empty placeholder), so always render it fresh.
491
+ let adoptInitialContent = contentElement !== undefined && !memoryRouting;
463
492
  const showDoc = (doc) => {
464
493
  if (adoptInitialContent) {
465
494
  adoptInitialContent = false; // leave the pre-rendered HTML untouched
@@ -482,6 +511,19 @@ export function createDocBrowser(options) {
482
511
  showDoc(doc);
483
512
  refreshNav(true);
484
513
  };
514
+ // User-initiated navigation: record the new location (history for path/query,
515
+ // the onRouteChange callback for memory) and then render the doc. Every nav
516
+ // click funnels through here so memory mode never touches window.history.
517
+ const go = (filename) => {
518
+ if (memoryRouting) {
519
+ onRouteChange?.(slugFor(filename));
520
+ }
521
+ else {
522
+ const href = hrefFor(filename);
523
+ window.history.pushState({ href }, '', href);
524
+ }
525
+ navigateTo(filename);
526
+ };
485
527
  // ── Hierarchical nav (path routing) ───────────────────────────────────────
486
528
  // Build nested <details> from the doc tree; current-highlight, test status,
487
529
  // search visibility, and auto-open are applied imperatively by refreshNav.
@@ -512,12 +554,10 @@ export function createDocBrowser(options) {
512
554
  // Use the href from the closure, not the event — the click can land on a
513
555
  // child of the <a> (the localized label), so event.currentTarget/target
514
556
  // isn't reliably the anchor.
515
- const href = hrefFor(doc.filename);
516
557
  const nav = event.target.closest('tosi-sidenav');
517
558
  if (nav)
518
559
  nav.contentVisible = true;
519
- window.history.pushState({ href }, '', href);
520
- navigateTo(String(doc.filename));
560
+ go(String(doc.filename));
521
561
  event.preventDefault();
522
562
  const results = pageTestResults[doc.filename];
523
563
  if (results && !results.passed) {
@@ -598,7 +638,7 @@ export function createDocBrowser(options) {
598
638
  refreshNav(true);
599
639
  return root;
600
640
  };
601
- const navContent = routing === 'path'
641
+ const navContent = routing !== 'query'
602
642
  ? buildHierarchicalNav()
603
643
  : div({
604
644
  ...navStyle,
@@ -613,12 +653,10 @@ export function createDocBrowser(options) {
613
653
  bindDocLink: '^.filename',
614
654
  bindTestStatus: '^.testStatus',
615
655
  onClick(event) {
616
- const anchor = event.target;
617
656
  const doc = getListItem(event.target);
618
657
  const nav = event.target.closest('tosi-sidenav');
619
658
  nav.contentVisible = true;
620
- window.history.pushState({ href: anchor.href }, '', anchor.href);
621
- navigateTo(String(doc.filename));
659
+ go(String(doc.filename));
622
660
  event.preventDefault();
623
661
  },
624
662
  }, tosiLocalized({ bindText: '^.title' }))));
@@ -766,9 +804,7 @@ export function createDocBrowser(options) {
766
804
  // Navigate to the page
767
805
  const docObj = app.docs.find((d) => String(d.filename) === filename);
768
806
  if (docObj) {
769
- const href = hrefFor(filename);
770
- window.history.pushState({ href }, '', href);
771
- navigateTo(filename);
807
+ go(filename);
772
808
  // Scroll to failing test after render
773
809
  setTimeout(() => {
774
810
  const failedExample = document.querySelector('tosi-example.-test-failed');
@@ -825,9 +861,10 @@ export function createDocBrowser(options) {
825
861
  lines.unshift(`**Summary: ${totalPassed} passed, ${totalFailed} failed**`, '');
826
862
  return lines.join('\n');
827
863
  }
828
- // Detect if running as background test iframe
864
+ // Detect if running as background test iframe (never for an embedded browser —
865
+ // it shares the host page's URL but isn't the test target).
829
866
  const searchParams = new URLSearchParams(window.location.search);
830
- const isTestFrame = searchParams.get('_testMode') === '1';
867
+ const isTestFrame = !memoryRouting && searchParams.get('_testMode') === '1';
831
868
  const testFrameFilename = isTestFrame ? filenameFromLocation() : null;
832
869
  // Listen for test completion events
833
870
  container.addEventListener('testcomplete', ((event) => {
@@ -960,8 +997,20 @@ export function createDocBrowser(options) {
960
997
  }
961
998
  }
962
999
  };
963
- // Start now if enabled, and watch for toggle
964
- startBackgroundTests();
965
- testManager.enabled.observe(startBackgroundTests);
1000
+ // Start now if enabled, and watch for toggle. A memory-routed (embedded)
1001
+ // browser never spawns the test-runner iframes (they'd navigate the host site).
1002
+ if (!memoryRouting) {
1003
+ startBackgroundTests();
1004
+ testManager.enabled.observe(startBackgroundTests);
1005
+ }
1006
+ // Memory routing: let the host drive navigation programmatically (by slug) and
1007
+ // read the current slug back, so the browser can live in a floating panel etc.
1008
+ if (memoryRouting) {
1009
+ ;
1010
+ container.navigate = (slug) => navigateTo(filenameForSlug(slug) || slug);
1011
+ Object.defineProperty(container, 'currentSlug', {
1012
+ get: () => slugFor(String(app.currentDoc.filename)),
1013
+ });
1014
+ }
966
1015
  return container;
967
1016
  }
@@ -5,6 +5,8 @@ export declare class TosiDocSystem extends Component {
5
5
  docs: string;
6
6
  config: string;
7
7
  localized: string;
8
+ routing: string;
9
+ route: string;
8
10
  accent: string;
9
11
  background: string;
10
12
  text: string;
@@ -13,6 +15,9 @@ export declare class TosiDocSystem extends Component {
13
15
  content: null;
14
16
  private corpus?;
15
17
  private browser?;
18
+ private appliedRoute;
19
+ private suppressed;
20
+ private nestingDepth;
16
21
  private prefs;
17
22
  private stylesApplied;
18
23
  private applyStyles;
@@ -31,6 +31,15 @@ language).
31
31
  - `config` — inline JSON `{ projectName, projectLinks }` baked in at build time.
32
32
  - `localized` — URL of a tab-separated translation table; when set, it powers the
33
33
  settings menu's language picker.
34
+ - `routing` — `''` (default) drives the page with clean `/slug/` URLs. Set
35
+ `"memory"` for a **self-contained** instance whose navigation never touches the
36
+ page URL or history — for embedding the docs in a dialog, side panel, or
37
+ floating element. (A `<tosi-doc-system>` nested inside another one — e.g. the
38
+ live demo on this page — is forced to memory routing automatically, and a demo
39
+ nested two levels deep renders an inert placeholder instead of recursing.)
40
+ - `route` — memory routing only: the current doc's slug. Set it to navigate the
41
+ embedded browser to a doc; it's reflected back here as the user clicks around,
42
+ so a host can observe/bind it (e.g. to remember where a help panel was left).
34
43
  - `accent` / `background` / `text` — base theme colors (most of the palette is
35
44
  derived from `accent` via color math).
36
45
 
@@ -38,6 +47,13 @@ The element is light-DOM (no shadow root) so the pre-rendered markdown remains t
38
47
  real, indexable page content until hydration replaces it with the live browser.
39
48
  Live-example modules default to the `xinjs` / `xinjsui` IIFE globals; override by
40
49
  setting the element's `context` property before it connects.
50
+
51
+ A self-contained, controllable embed (e.g. docs in a floating panel):
52
+
53
+ ```html
54
+ <tosi-doc-system docs="/docs.json" routing="memory" route="data-table">
55
+ </tosi-doc-system>
56
+ ```
41
57
  */
42
58
  /*{ "parent": "Appendices" }*/
43
59
  import { Component, StyleSheet, elements, tosi, vars } from 'tosijs';
@@ -47,7 +63,7 @@ import { docSystemStyleSpec } from './doc-system-styles';
47
63
  import { icons } from '../icons';
48
64
  import { popMenu } from '../menu';
49
65
  import { i18n, setLocale, initLocalization } from '../localize';
50
- const { button } = elements;
66
+ const { button, div } = elements;
51
67
  const PREFS_KEY = 'tosi-doc-system-prefs';
52
68
  export class TosiDocSystem extends Component {
53
69
  static preferredTagName = 'tosi-doc-system';
@@ -55,6 +71,15 @@ export class TosiDocSystem extends Component {
55
71
  docs: '/docs.json',
56
72
  config: '',
57
73
  localized: '',
74
+ // Routing mode. '' (default) → clean `/slug/` URLs that drive the page. Set
75
+ // 'memory' (or 'internal') for a self-contained instance that never touches
76
+ // the page URL — for embedding the docs in a panel, dialog, etc. A nested
77
+ // <tosi-doc-system> (inside a live example) is forced to 'memory' too.
78
+ routing: '',
79
+ // Memory routing only: the current doc slug. Set it to navigate the embedded
80
+ // browser; it's reflected back here as the user clicks around, so a host can
81
+ // bind/observe it (e.g. docs in a floating element).
82
+ route: '',
58
83
  // Base theme colors — most of the palette is derived from `accent`.
59
84
  accent: '',
60
85
  background: '',
@@ -68,6 +93,27 @@ export class TosiDocSystem extends Component {
68
93
  // The loaded corpus and the mounted browser; both also act as one-time guards.
69
94
  corpus;
70
95
  browser;
96
+ // Route-reflection bookkeeping (memory routing): the last slug applied to the
97
+ // browser, so external `route` changes navigate but internal navigation (which
98
+ // writes `route` back) doesn't re-navigate or loop.
99
+ appliedRoute = '';
100
+ suppressed = false;
101
+ // How many <tosi-doc-system> ancestors this element has, crossing shadow
102
+ // boundaries. 0 = the page's own; ≥1 = embedded in another (e.g. the live
103
+ // demo on the doc-system page). Used to force memory routing on nested
104
+ // instances and to stop runaway recursion (a doc page that demos itself).
105
+ nestingDepth() {
106
+ let depth = 0;
107
+ let node = this.parentNode;
108
+ while (node) {
109
+ if (node.nodeType === Node.ELEMENT_NODE &&
110
+ node.tagName === 'TOSI-DOC-SYSTEM') {
111
+ depth++;
112
+ }
113
+ node = node.parentNode || node.host || null;
114
+ }
115
+ return depth;
116
+ }
71
117
  // User theme/locale preferences (persisted to localStorage).
72
118
  prefs;
73
119
  stylesApplied = false;
@@ -224,6 +270,15 @@ export class TosiDocSystem extends Component {
224
270
  }
225
271
  connectedCallback() {
226
272
  super.connectedCallback();
273
+ // A doc page can demo the doc system itself. The page's browser (depth 0)
274
+ // and one embedded demo (depth 1) are fine, but a demo INSIDE that demo
275
+ // (depth ≥ 2) would recurse forever — so render an inert placeholder and
276
+ // stop. (Don't even fetch the corpus.)
277
+ if (this.nestingDepth() >= 2) {
278
+ this.suppressed = true;
279
+ this.replaceChildren(div({ class: 'doc-system-nested' }, '📖 nested doc-system preview (suppressed)'));
280
+ return;
281
+ }
227
282
  this.applyStyles();
228
283
  this.initPrefs();
229
284
  void this.initLocale();
@@ -253,8 +308,19 @@ export class TosiDocSystem extends Component {
253
308
  }
254
309
  render() {
255
310
  super.render();
256
- if (this.corpus === undefined || this.browser !== undefined)
311
+ if (this.suppressed || this.corpus === undefined)
312
+ return;
313
+ // Already mounted: the only live input is an external `route` change (memory
314
+ // routing), which navigates the embedded browser. The appliedRoute guard
315
+ // stops the browser's own write-back to `route` from re-navigating.
316
+ if (this.browser !== undefined) {
317
+ const navigate = this.browser.navigate;
318
+ if (navigate && this.route && this.route !== this.appliedRoute) {
319
+ this.appliedRoute = this.route;
320
+ navigate(this.route);
321
+ }
257
322
  return;
323
+ }
258
324
  let config = {};
259
325
  if (this.config) {
260
326
  try {
@@ -264,15 +330,28 @@ export class TosiDocSystem extends Component {
264
330
  console.warn('<tosi-doc-system> ignoring invalid config attribute');
265
331
  }
266
332
  }
333
+ // Nested instances (a live demo of the doc system) must never drive the page
334
+ // URL — force memory routing. A top-level instance opts in via the attribute.
335
+ const nested = this.nestingDepth() >= 1;
336
+ const memory = nested || this.routing === 'memory' || this.routing === 'internal';
267
337
  // Adopt the pre-rendered markdown for the landing page so it is hydrated in
268
338
  // place, never re-rendered. createDocBrowser grafts this node into its content
269
339
  // area; the static nav list (already crawled) is replaced by the reactive one.
270
340
  const contentElement = this.querySelector('.doc-content') || undefined;
271
341
  // Header-bar links are declared as a real, crawlable list in the static page.
272
342
  const navbarLinks = this.parseLinks('.doc-navbar');
343
+ this.appliedRoute = this.route;
273
344
  this.browser = createDocBrowser({
274
345
  docs: this.corpus,
275
- routing: 'path',
346
+ routing: memory ? 'memory' : 'path',
347
+ initialRoute: memory ? this.route || undefined : undefined,
348
+ onRouteChange: memory
349
+ ? (slug) => {
350
+ // Record before reflecting so the resulting render() no-ops.
351
+ this.appliedRoute = slug;
352
+ this.route = slug;
353
+ }
354
+ : undefined,
276
355
  context: this.context || {
277
356
  tosijs: globalThis.xinjs,
278
357
  'tosijs-ui': globalThis.xinjsui,
@@ -282,10 +361,13 @@ export class TosiDocSystem extends Component {
282
361
  navbarLinks: navbarLinks.length ? navbarLinks : undefined,
283
362
  contentElement,
284
363
  });
285
- // Add the settings (theme + language) menu to the header.
286
- const header = this.browser.querySelector('header');
287
- if (header)
288
- header.append(this.settingsButton());
364
+ // Add the settings (theme + language) menu — except in a nested demo, where
365
+ // a gear that retints the whole host page would be surprising.
366
+ if (!nested) {
367
+ const header = this.browser.querySelector('header');
368
+ if (header)
369
+ header.append(this.settingsButton());
370
+ }
289
371
  this.replaceChildren(this.browser);
290
372
  }
291
373
  }
@@ -4,4 +4,5 @@ declare global {
4
4
  }
5
5
  export declare function devServer(config: SiteConfig, opts?: {
6
6
  test?: boolean;
7
+ build?: () => unknown | Promise<unknown>;
7
8
  }): Promise<void>;
@@ -98,9 +98,15 @@ export async function devServer(config, opts = {}) {
98
98
  }
99
99
  }
100
100
  if (!testMode) {
101
- // Rebuild on any source change. buildSite() already re-extracts docs and
102
- // regenerates icon-data, so a single rebuild covers everything. Serialize
103
- // builds (no overlap) and coalesce bursts.
101
+ // Rebuild on any source change. By default that's just buildSite(), but a
102
+ // consumer whose full build has steps BEYOND buildSite — e.g. a custom IIFE
103
+ // bundle built separately (because it needs a Bun plugin buildSite can't
104
+ // take) — MUST pass opts.build with their whole pipeline. buildSite() starts
105
+ // with `rm -rf <outputDir>`, so any artifact those extra steps produced
106
+ // (iife.js, etc.) is deleted on the first rebuild and, without opts.build,
107
+ // never regenerated — leaving the page's /iife.js to 404 into the SPA
108
+ // fallback (it "loads as html"). Serialize builds and coalesce bursts.
109
+ const runBuild = opts.build ?? (() => buildSite(config));
104
110
  let building = false;
105
111
  let pending = false;
106
112
  const rebuild = async () => {
@@ -110,7 +116,7 @@ export async function devServer(config, opts = {}) {
110
116
  }
111
117
  building = true;
112
118
  try {
113
- await buildSite(config);
119
+ await runBuild();
114
120
  }
115
121
  catch (error) {
116
122
  console.error('rebuild failed:', error);
@@ -19,6 +19,9 @@ import { extractDocs } from './docs';
19
19
  import { ensureSections } from './sections';
20
20
  import { generateLlmsTxt } from './make-llms-txt';
21
21
  import { generateSite } from './generate-site';
22
+ // Module specifiers contain regex metacharacters (`/`, `.`, `@`, …), so escape
23
+ // before interpolating into the require-shim detector below.
24
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
25
  export async function buildSite(config) {
23
26
  const PROJECT_ROOT = './';
24
27
  const PUBLIC = path.resolve(PROJECT_ROOT, config.outputDir ?? 'docs');
@@ -89,13 +92,6 @@ export async function buildSite(config) {
89
92
  // consumer supplies (e.g. via staticDirs or an absolute URL).
90
93
  const scriptName = (config.scriptUrl ?? '/iife.js').replace(/^\//, '');
91
94
  if (config.bundleEntry) {
92
- // IIFE externals become a synchronous require() shim that throws at
93
- // module-eval ("Dynamic require of … is not supported") — the build still
94
- // succeeds, so warn rather than fail silently.
95
- if (config.bundleExternals && config.bundleExternals.length > 0) {
96
- console.warn(`⚠️ bundleExternals in an IIFE bundle (${config.bundleExternals.join(', ')}): Bun emits a dynamic require() shim that throws at runtime. Load these\n` +
97
- ` via import() (dynamically, so the bundler keeps them async) or an importmap, not a static import.`);
98
- }
99
95
  const result = await Bun.build({
100
96
  entrypoints: [config.bundleEntry],
101
97
  outdir: DIST,
@@ -111,6 +107,23 @@ export async function buildSite(config) {
111
107
  console.error(message);
112
108
  return false;
113
109
  }
110
+ // Warn only when an external actually compiled to a synchronous require()
111
+ // shim, which throws at module-eval ("Dynamic require of … is not
112
+ // supported"). That only happens for a *static* `import x from 'ext'`. A
113
+ // *dynamic* `import('ext')` is preserved as native `import("ext")` and
114
+ // resolves via the page's importmap — the recommended pattern, so it must
115
+ // stay silent. The config alone can't tell the two apart; the emitted
116
+ // bundle can, so we inspect the actual output.
117
+ if (config.bundleExternals && config.bundleExternals.length > 0) {
118
+ const js = (await Promise.all(result.outputs
119
+ .filter((o) => o.kind === 'entry-point' || o.path.endsWith('.js'))
120
+ .map((o) => o.text()))).join('\n');
121
+ const broken = config.bundleExternals.filter((ext) => new RegExp(`(?:__require|\\brequire)\\(\\s*["'\`]${escapeRegExp(ext)}["'\`]`).test(js));
122
+ if (broken.length > 0) {
123
+ console.warn(`⚠️ bundleExternals compiled to a synchronous require() shim that throws at runtime (${broken.join(', ')}). Reference these via a dynamic import() (kept async by the bundler)\n` +
124
+ ` or an importmap, not a static import.`);
125
+ }
126
+ }
114
127
  await $ `cp ${DIST}/${scriptName} ${PUBLIC}`.text();
115
128
  const bundleFile = await Bun.file(`${DIST}/${scriptName}`).arrayBuffer();
116
129
  // import.meta is illegal in a classic <script> — if it survived bundling