tosijs-ui 1.7.0 → 1.7.2

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/babylon-3d.d.ts +1 -1
  3. package/dist/babylon-3d.js +1 -1
  4. package/dist/code-editor-cm.js +5 -1
  5. package/dist/code-editor.d.ts +1 -1
  6. package/dist/code-editor.js +1 -1
  7. package/dist/data-table.d.ts +3 -1
  8. package/dist/data-table.js +97 -5
  9. package/dist/doc-browser.js +6 -1
  10. package/dist/doc-system/doc-system-styles.js +11 -0
  11. package/dist/doc-system/site/dev-server.d.ts +11 -0
  12. package/dist/doc-system/site/dev-server.js +26 -3
  13. package/dist/doc-system/site/generate-site.d.ts +18 -0
  14. package/dist/doc-system/site/generate-site.js +42 -10
  15. package/dist/doc-system/site/site-config.d.ts +10 -3
  16. package/dist/hydrate/{ajs-language-h1x0ydap.js → ajs-language-qm5w38ae.js} +3 -3
  17. package/dist/hydrate/{ajs-language-h1x0ydap.js.map → ajs-language-qm5w38ae.js.map} +1 -1
  18. package/dist/hydrate/{code-editor-cm-2hgj9002.js → code-editor-cm-95n31m4f.js} +8 -8
  19. package/dist/hydrate/{code-editor-cm-2hgj9002.js.map → code-editor-cm-95n31m4f.js.map} +3 -3
  20. package/dist/hydrate/hydrate.js +78 -78
  21. package/dist/hydrate/hydrate.js.map +20 -19
  22. package/dist/hydrate/{index-nsmyq9mv.js → index-0ps8jzdn.js} +3 -3
  23. package/dist/hydrate/{index-nsmyq9mv.js.map → index-0ps8jzdn.js.map} +1 -1
  24. package/dist/hydrate/{index-iife-64z9yngn.js → index-iife-415dypjp.js} +3 -3
  25. package/dist/hydrate/{index-iife-64z9yngn.js.map → index-iife-415dypjp.js.map} +1 -1
  26. package/dist/hydrate/index-iife-kwhe0yt8.js +27 -0
  27. package/dist/hydrate/{index-iife-5wzyvyzm.js.map → index-iife-kwhe0yt8.js.map} +2 -2
  28. package/dist/hydrate/{index-iife-x2h4f6fc.js → index-iife-ype8sch3.js} +3 -3
  29. package/dist/hydrate/{index-iife-x2h4f6fc.js.map → index-iife-ype8sch3.js.map} +1 -1
  30. package/dist/hydrate/{tjs-import-resolver-akt3k27z.js → tjs-import-resolver-s4z0w296.js} +2 -2
  31. package/dist/iife.js +70 -70
  32. package/dist/iife.js.map +21 -20
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/live-example/component.d.ts +8 -1
  36. package/dist/live-example/component.js +8 -0
  37. package/dist/live-example/styles.d.ts +8 -1
  38. package/dist/live-example/styles.js +17 -1
  39. package/dist/live-theme.js +98 -0
  40. package/dist/side-nav.d.ts +1 -1
  41. package/dist/side-nav.js +4 -4
  42. package/dist/size-break.d.ts +1 -1
  43. package/dist/size-break.js +3 -3
  44. package/dist/tab-selector.d.ts +11 -2
  45. package/dist/tab-selector.js +19 -6
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/value-renderer.d.ts +26 -0
  48. package/dist/value-renderer.js +209 -0
  49. package/dist/version.d.ts +1 -1
  50. package/dist/version.js +1 -1
  51. package/llms.txt +3 -3
  52. package/package.json +1 -1
  53. package/dist/bp-loader.d.ts +0 -1
  54. package/dist/bp-loader.js +0 -26
  55. package/dist/hydrate/index-iife-5wzyvyzm.js +0 -27
  56. /package/dist/hydrate/{tjs-import-resolver-akt3k27z.js.map → tjs-import-resolver-s4z0w296.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.2
4
+
5
+ - **Fix: doc sites are now mount-point-agnostic** (issue #25). The build baked `basePath` into
6
+ every functional URL (nav/content links, `scriptUrl`, `stylesUrl`, favicon, `docsUrl`), so a
7
+ build was locked to one mount — adding a custom domain to a GitHub project page flipped the
8
+ live site to the root while `docs/` still pointed at `/repo`, 404ing every asset into a bare,
9
+ unstyled shell with no warning. Functional URLs are now emitted **relative to each page**, so
10
+ one build works at `/repo`, a custom-domain root, or a moved mount with no rebuild. *Metadata*
11
+ URLs (`canonical`, `og:url`, `og:image`, `sitemap.xml`) stay absolute via `baseUrl`+`basePath`
12
+ — so `basePath` now only affects metadata, never the page's assets. (The hydrated SPA's own
13
+ nav/`pushState` and `__TJS_LOCAL_BASE` remain root-relative — tracked in #16.)
14
+ - **Fix: the haltija dev channel loaded once per background-test iframe.** The doc-browser's
15
+ background test runner loads every page-with-tests in a hidden iframe, each served the injected
16
+ dev-channel loader — so `dev.js` was imported N times into throwaway frames. The loader is now
17
+ gated to the top window (`self===top`).
18
+ - **Live examples get a rounded, padded card.** `.preview` gains `padding` (breathing room around
19
+ rendered content) and its first child's top margin is dropped; the inset border moves to the
20
+ example's `:host` with a `border-radius`, so the whole example (preview + editors) reads as one
21
+ bordered card. CodeMirror tooltips render fixed-positioned so autocomplete isn't clipped by the
22
+ card's `overflow: hidden`.
23
+ - **Removed the "blueprint loading" doc page.** It documented tosijs's own
24
+ `<xin-loader>`/`<xin-blueprint>` elements (no tosijs-ui component), so it belongs in tosijs's
25
+ docs. The generic `blueprint` icon stays.
26
+
27
+ ## 1.7.1
28
+
29
+ - **Fix: components looked wrong in the doc site's dark mode** (e.g. `<tosi-table>` stayed
30
+ white). The doc-system theme drives the legacy `--background`/`--text-color` family and never
31
+ set the `--tosi-*` component palette, so components fell back to their baked-in light defaults
32
+ (`var(--tosi-bg, #fff)`). The doc-system now bridges `--tosi-accent`/`--tosi-bg`/`--tosi-text`/
33
+ `--tosi-bg-inset` onto its own colors **as references**, so every `--tosi-*` component follows
34
+ the site theme automatically — including dark mode (the referenced vars flip via
35
+ `invertLuminance`). The data-table pinned-row example now uses `var(--tosi-table-bg)` instead of
36
+ a hardwired `#eee`.
37
+ - **Data-table `type` example** demonstrating the new column types (currency, number, percent,
38
+ fixed, bytes, boolean) added to the docs.
39
+
40
+ - **Fix: "edit page source" showed HTML instead of source in a dev server without
41
+ `editableSources`.** The `/__docstore/source` endpoint was only routed when
42
+ `editableSources` was on; otherwise the request fell through to the SPA `index.html`
43
+ fallback and returned the rendered page at status 200 — so the client loaded HTML *as* the
44
+ source (and save read HTML). The endpoint is now handled unconditionally and answers a clean
45
+ `501` when editing is disabled, so `loadSource` falls back to the GitHub raw source (read-only
46
+ editing, matching the deployed site). Belt-and-suspenders: the client also rejects any
47
+ `text/html` response from that endpoint (guards SPA-rewrite hosts too). Documented the option
48
+ more fully in `doc-site-system.md`.
49
+ - **Local example save now gives feedback.** "Save changes (local)" writes a per-browser
50
+ scratchpad (not the file) — it used to do so silently, reading as a no-op. It now posts a
51
+ toast (`postNotification`): *"Saved to this browser only — use 'Save to source' to write the
52
+ file."*
53
+
54
+ - **`valueRenderer(type)` + declarative `<tosi-table>` column `type`.** A new exported helper
55
+ turns a compact type string into a reusable, locale-aware renderer with a sensible default
56
+ alignment: `number`, `currency`/`currency(USD)`, `fixed`/`fixed(n)` (default 2),
57
+ `percent`/`percent(n)` (of a fraction; default 0), `sci`, `eng`, `bytes`/`bytes(iec)` (SI ÷1000
58
+ or IEC ÷1024 units, right-aligned), and `boolean`/`boolean(t)`/`boolean(t,f)` (icons via the
59
+ `icons` proxy, centered; default `checkSquare`/`square`). Numeric cells also get a
60
+ `-negative` / `-zero` **state class** by value sign, so you get red negatives (etc.) from one
61
+ CSS rule — no custom cell renderer. Data-table columns take a `type`
62
+ (`ValueRendererType`) and format + align automatically — no hand-rolled `dataCell`; an explicit
63
+ `align`/`dataCell` still wins. Formatting follows `setLocale()`. Verified end-to-end (unit +
64
+ browser).
65
+
66
+ - **Deprecation cleanup: `on<Event>` component callbacks → `handle<Event>`.** tosijs reserves the
67
+ `on<Event>` prefix for elements-factory event-handler sugar and now warns when a component
68
+ *defines* such a property. Renamed the internal ones: `onResize` → `handleResize` (`size-break`,
69
+ `side-nav`, `code-editor`, `babylon-3d`, `tab-selector`) and `onScrollEnd` → `handleScrollEnd`
70
+ (`data-table`). Verified: those pages now load with zero `on<Event>` deprecation warnings.
71
+ (`<tosi-tabs>`'s public `onCloseTab` still uses the old name — a breaking rename deferred to a
72
+ deprecation-alias pass; and the `tosiValue()`/`tosiPath()` accessor deprecation is a separate,
73
+ larger cleanup — both tracked.)
74
+
75
+ - **CSS canary** — an inline doc-test on the `live-theme` page that smoke-tests the whole
76
+ styling chain in a real browser (`StyleSheet()` → the `vars` proxy → scaled-var `calc()` →
77
+ `createTheme`/`applyTheme` → cascade → `getComputedStyle`), plus a theme color-change
78
+ propagating to computed style, dark-mode luminance inversion, and `Color.inverseLuminance`.
79
+ Each link is silent when it breaks (happy-dom can't resolve `var()`/the cascade), so a red
80
+ now means a system-level CSS break, not a component quirk. Runs in the `doc-tests.pw.ts` CI
81
+ gate.
82
+
3
83
  ## 1.7.0
4
84
 
5
85
  > ### ⚠️ BREAKING CHANGE — `<tosi-code>` moved from ACE to CodeMirror 6
@@ -51,7 +51,7 @@ export declare class B3d extends WebComponent {
51
51
  sceneCreated: B3dCallback;
52
52
  update: B3dCallback;
53
53
  private _update;
54
- onResize(): void;
54
+ handleResize(): void;
55
55
  loadScene: (path: string, file: string, processCallback?: MeshProcessCallback) => Promise<void>;
56
56
  loadUI: (options: B3dUIOptions) => Promise<any>;
57
57
  connectedCallback(): void;
@@ -299,7 +299,7 @@ export class B3d extends WebComponent {
299
299
  }
300
300
  }
301
301
  };
302
- onResize() {
302
+ handleResize() {
303
303
  if (this.engine) {
304
304
  this.engine.resize();
305
305
  }
@@ -10,7 +10,7 @@ Languages here are the always-available set (js/ts/css/html/markdown). First-cla
10
10
  tjs (highlighting + runtime-value autocomplete via tjs-lang's editors/codemirror) is
11
11
  layered in separately and only when tjs-lang is installed.
12
12
  */
13
- import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, } from '@codemirror/view';
13
+ import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine, tooltips, } from '@codemirror/view';
14
14
  import { EditorState, Compartment, Annotation, Transaction, } from '@codemirror/state';
15
15
  import { indentWithTab, history, defaultKeymap, historyKeymap, undo, redo, undoDepth, redoDepth, } from '@codemirror/commands';
16
16
  import { indentUnit, foldGutter, foldKeymap, indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, } from '@codemirror/language';
@@ -129,6 +129,10 @@ const editorTheme = EditorView.theme({
129
129
  function baseExtensions() {
130
130
  return [
131
131
  editorTheme,
132
+ // Render autocomplete / hover tooltips in a fixed layer so they escape the
133
+ // live-example's `:host { overflow: hidden }` (the rounded card clip) instead
134
+ // of being cut off at the editor's bottom edge in a short example.
135
+ tooltips({ position: 'fixed' }),
132
136
  lineNumbers(),
133
137
  highlightActiveLineGutter(),
134
138
  highlightSpecialChars(),
@@ -92,7 +92,7 @@ export declare class CodeEditor extends WebComponent<CodeEditorParts> {
92
92
  fontFamily: string;
93
93
  };
94
94
  };
95
- onResize(): void;
95
+ handleResize(): void;
96
96
  connectedCallback(): void;
97
97
  disconnectedCallback(): void;
98
98
  private isTjsMode;
@@ -267,7 +267,7 @@ export class CodeEditor extends WebComponent {
267
267
  fontFamily: "Menlo, Monaco, Consolas, 'Courier New', monospace",
268
268
  },
269
269
  };
270
- onResize() {
270
+ handleResize() {
271
271
  this._handle?.refresh();
272
272
  }
273
273
  connectedCallback() {
@@ -1,11 +1,13 @@
1
1
  import { Component as WebComponent, ElementCreator } from 'tosijs';
2
2
  import { SortCallback } from './make-sorter';
3
+ import { ValueRendererType } from './value-renderer';
3
4
  export interface ColumnOptions {
4
5
  name?: string;
5
6
  prop: string;
6
7
  width: number;
7
8
  visible?: boolean;
8
9
  align?: string;
10
+ type?: ValueRendererType;
9
11
  pinned?: 'left' | 'right';
10
12
  sort?: false | 'ascending' | 'descending';
11
13
  headerCell?: (options: ColumnOptions) => HTMLElement;
@@ -201,7 +203,7 @@ export declare class TosiTable extends WebComponent {
201
203
  private updateSelection;
202
204
  private findCell;
203
205
  private _pendingFocus;
204
- private onScrollEnd;
206
+ private handleScrollEnd;
205
207
  private focusCell;
206
208
  private handleKeyNav;
207
209
  connectedCallback(): void;
@@ -194,6 +194,7 @@ export interface ColumnOptions {
194
194
  width: number
195
195
  visible?: boolean
196
196
  align?: string
197
+ type?: string // valueRenderer type: 'currency(USD)', 'fixed(2)', 'bytes', 'boolean(check,x)', …
197
198
  pinned?: 'left' | 'right'
198
199
  sort?: false | 'ascending' | 'descending'
199
200
  headerCell?: (options: ColumnOptions) => HTMLElement
@@ -201,6 +202,59 @@ export interface ColumnOptions {
201
202
  }
202
203
  ```
203
204
 
205
+ ## Column value types
206
+
207
+ Give a column a `type` and it's formatted (and aligned) automatically — no hand-rolled
208
+ `dataCell`. The `type` is a [`valueRenderer`](value-renderer) string:
209
+
210
+ ```
211
+ [
212
+ { prop: 'name', width: 160 },
213
+ { prop: 'price', width: 100, type: 'currency(USD)' }, // localized $, right-aligned
214
+ { prop: 'weight', width: 100, type: 'fixed(3)' }, // 3 decimals, right-aligned
215
+ { prop: 'size', width: 100, type: 'bytes' }, // 1.5 MB, right-aligned
216
+ { prop: 'active', width: 60, type: 'boolean' }, // checkSquare / square, centered
217
+ ]
218
+ ```
219
+
220
+ Numeric types (`number`, `currency`, `fixed`, `percent`, `sci`, `eng`, `bytes`) right-align
221
+ by default; `boolean` centers and renders icons. An explicit `align` — or a `dataCell` —
222
+ always wins. Formatting follows the app locale (`setLocale()`). Numeric cells also get a
223
+ `-negative` or `-zero` state class by value sign, so the red "Change" values above come from
224
+ one CSS rule (`.-negative { color: #e44 }`), no cell renderer.
225
+
226
+ ```js
227
+ import { tosiTable } from 'tosijs-ui'
228
+
229
+ const rows = [
230
+ { item: 'Alpha Widget', price: 12.5, qty: 1240, change: 3.25, rate: 0.075, mass: 1.23456, size: 1_536_000, active: true, flagged: false },
231
+ { item: 'Beta Gadget', price: 4.99, qty: 42, change: -1.5, rate: 0.2, mass: 0.5, size: 512, active: false, flagged: true },
232
+ { item: 'Gamma Sprocket', price: 199, qty: 8, change: 0, rate: 1.5, mass: 12.005, size: 2_500_000_000, active: true, flagged: true },
233
+ { item: 'Delta Cog', price: 0.75, qty: 99999, change: -0.12, rate: 0.004, mass: 0.001, size: 48_200, active: false, flagged: false },
234
+ ]
235
+
236
+ const table = tosiTable({ style: { display: 'block', height: '240px' } })
237
+ table.value = {
238
+ array: rows,
239
+ columns: [
240
+ { prop: 'item', name: 'Item', width: 150 },
241
+ { prop: 'price', name: 'Price', width: 110, type: 'currency(USD)' },
242
+ { prop: 'change', name: 'Change', width: 100, type: 'currency(USD)' }, // red negatives via CSS
243
+ { prop: 'qty', name: 'Qty', width: 90, type: 'number' },
244
+ { prop: 'rate', name: 'Rate', width: 80, type: 'percent(1)' },
245
+ { prop: 'mass', name: 'Mass', width: 90, type: 'fixed(2)' },
246
+ { prop: 'size', name: 'Size', width: 100, type: 'bytes' },
247
+ { prop: 'active', name: 'Active', width: 80, type: 'boolean' },
248
+ { prop: 'flagged', name: 'Flag', width: 80, type: 'boolean(check, x)' },
249
+ ],
250
+ }
251
+ preview.append(table)
252
+ ```
253
+ ```css
254
+ .preview .-negative { color: #e44; }
255
+ .preview .-zero { opacity: 0.45; }
256
+ ```
257
+
204
258
  ## Pinned Columns and Rows
205
259
 
206
260
  Set `pinned: 'left'` or `pinned: 'right'` on individual columns to pin
@@ -314,11 +368,11 @@ preview.append(table)
314
368
  width: 100%;
315
369
  }
316
370
  .preview tosi-table .pinned-bottom {
317
- background: #eee;
371
+ background: var(--tosi-table-bg, var(--tosi-bg, #fff));
318
372
  font-weight: bold;
319
373
  }
320
374
  .preview .row-pinned .td {
321
- background: #eee;
375
+ background: var(--tosi-table-bg, var(--tosi-bg, #fff));
322
376
  }
323
377
  .preview .num-cell {
324
378
  font-variant-numeric: tabular-nums;
@@ -513,6 +567,7 @@ As well as any column names you want localized.
513
567
  import { Component as WebComponent, elements, vars, varDefault, tosiValue, getListItem, getListBinding, tosi, } from 'tosijs';
514
568
  import { trackDrag } from './track-drag';
515
569
  import { icons } from './icons';
570
+ import { valueRenderer } from './value-renderer';
516
571
  import { popMenu } from './menu';
517
572
  import * as dragAndDrop from './drag-and-drop';
518
573
  import { tosiLocalized, localize } from './localize';
@@ -545,6 +600,20 @@ function defaultWidth(array, prop, charWidth) {
545
600
  }
546
601
  const { div, span, button } = elements;
547
602
  const passThru = (array) => array;
603
+ // One ValueRenderer per typed column, cached: `Intl.*Format` construction is the
604
+ // expensive part and a column's renderer is stable for its lifetime. Returns null
605
+ // when the column has no `type` or supplies its own `dataCell` (which always wins).
606
+ const columnRenderers = new WeakMap();
607
+ function columnRenderer(col) {
608
+ if (!col.type || col.dataCell)
609
+ return null;
610
+ let renderer = columnRenderers.get(col);
611
+ if (!renderer) {
612
+ renderer = valueRenderer(col.type);
613
+ columnRenderers.set(col, renderer);
614
+ }
615
+ return renderer;
616
+ }
548
617
  export class TosiTable extends WebComponent {
549
618
  static preferredTagName = 'tosi-table';
550
619
  // Layout: a single .scroll-area inside :host is the only scroll container
@@ -968,7 +1037,9 @@ export class TosiTable extends WebComponent {
968
1037
  // position: sticky lives in `.col-pinned` (added by cellClasses), so only
969
1038
  // the per-cell offsets need to be set inline here.
970
1039
  const style = {
971
- justifyContent: col.align || 'left',
1040
+ // Explicit align wins; else the column type's default (numerics right,
1041
+ // booleans center); else left.
1042
+ justifyContent: col.align || columnRenderer(col)?.align || 'left',
972
1043
  ...extra,
973
1044
  };
974
1045
  if (si.left != null)
@@ -993,6 +1064,27 @@ export class TosiTable extends WebComponent {
993
1064
  this.applyGridCellAttrs(cell, colIndex, si, style);
994
1065
  return cell;
995
1066
  }
1067
+ // A `type` column formats through its cached ValueRenderer. The binding's toDOM
1068
+ // runs per stamped row, so it stays locale-reactive and works for icon cells
1069
+ // (which replace children) as well as text.
1070
+ const renderer = columnRenderer(col);
1071
+ if (renderer) {
1072
+ return span({
1073
+ class: this.cellClasses('td', si),
1074
+ role: 'gridcell',
1075
+ tabindex: -1,
1076
+ ariaColindex: String(colIndex + 1),
1077
+ style,
1078
+ bind: {
1079
+ value: item[col.prop],
1080
+ binding: {
1081
+ toDOM(el, val) {
1082
+ renderer.toDOM(el, val);
1083
+ },
1084
+ },
1085
+ },
1086
+ });
1087
+ }
996
1088
  return span({
997
1089
  class: this.cellClasses('td', si),
998
1090
  role: 'gridcell',
@@ -1287,7 +1379,7 @@ export class TosiTable extends WebComponent {
1287
1379
  return cells?.[colIndex] ?? null;
1288
1380
  }
1289
1381
  _pendingFocus = null;
1290
- onScrollEnd = () => {
1382
+ handleScrollEnd = () => {
1291
1383
  if (!this._pendingFocus)
1292
1384
  return;
1293
1385
  const { row, col } = this._pendingFocus;
@@ -1659,7 +1751,7 @@ export class TosiTable extends WebComponent {
1659
1751
  ...visibleBinding,
1660
1752
  this._tbodyBottom,
1661
1753
  ].filter(Boolean));
1662
- this._scrollArea.addEventListener('scrollend', this.onScrollEnd);
1754
+ this._scrollArea.addEventListener('scrollend', this.handleScrollEnd);
1663
1755
  this.append(this._scrollArea);
1664
1756
  this.observePinnedRowMutations();
1665
1757
  this.tagPinnedRows();
@@ -726,7 +726,12 @@ export function createDocBrowser(options) {
726
726
  const loadSource = async (p) => {
727
727
  try {
728
728
  const r = await fetch(`/__docstore/source?file=${encodeURIComponent(p)}`);
729
- if (r.ok)
729
+ // Accept only a genuine source response. A misconfigured dev server (no
730
+ // editableSources) or a SPA-rewrite host answers an unknown path with
731
+ // index.html at status 200 — taking that would load the PAGE as the source.
732
+ // Reject HTML and fall through to the GitHub raw source instead.
733
+ const contentType = r.headers.get('content-type') || '';
734
+ if (r.ok && !/text\/html/i.test(contentType))
730
735
  return await r.text();
731
736
  }
732
737
  catch {
@@ -62,6 +62,17 @@ export function docSystemStyleSpec(theme = {}) {
62
62
  _fontSize: '16px',
63
63
  _codeFontSize: '14px',
64
64
  ...colors,
65
+ // Bridge the tosijs component palette (`--tosi-*`) onto the doc-system's color
66
+ // family. Defined as REFERENCES, so a component reading `var(--tosi-bg)` follows
67
+ // the site theme automatically — including dark mode, where `invertLuminance()`
68
+ // flips the referenced `--background`/`--text-color`/… in `.darkmode`. Without
69
+ // this the doc-system never set `--tosi-*`, so components fell back to their
70
+ // baked-in light defaults (e.g. `var(--tosi-bg, #fff)` → white <tosi-table> in
71
+ // dark mode). Colors only — component metrics keep their own defaults.
72
+ _tosiAccent: vars.brandColor,
73
+ _tosiBg: vars.background,
74
+ _tosiText: vars.textColor,
75
+ _tosiBgInset: vars.insetBg,
65
76
  _spacing: '10px',
66
77
  _lineHeight: 'calc(var(--font-size) * 1.6)',
67
78
  _h1Scale: '2',
@@ -28,6 +28,17 @@ export declare function resolveIdleMs(configHours: number | undefined, envHours:
28
28
  * off. Only an explicit non-positive number disables the ceiling.
29
29
  */
30
30
  export declare function resolveLimitMb(configMb: number | undefined, envMb: string | undefined): number;
31
+ /**
32
+ * The localhost-gated haltija dev-channel loader injected into served HTML at
33
+ * serve time (never bundled, never in the built output).
34
+ *
35
+ * `self===top` keeps it in the TOP window only. The doc-browser's background test
36
+ * runner loads every page-with-tests in a hidden iframe (`?_testMode=1`), each
37
+ * served this same HTML — without the guard, haltija's `dev.js` gets imported
38
+ * once per test page (N redundant loads in throwaway frames). An agent only ever
39
+ * drives the top page, so nested frames never need the channel.
40
+ */
41
+ export declare function haltijaLoaderSnippet(httpsPort: number): string;
31
42
  export declare function devServer(config: SiteConfig, opts?: {
32
43
  test?: boolean;
33
44
  build?: () => unknown | Promise<unknown>;
@@ -78,6 +78,20 @@ export function resolveLimitMb(configMb, envMb) {
78
78
  const mb = Number.isFinite(raw) ? raw : DEFAULT_LIMIT_MB;
79
79
  return mb > 0 ? mb : 0;
80
80
  }
81
+ /**
82
+ * The localhost-gated haltija dev-channel loader injected into served HTML at
83
+ * serve time (never bundled, never in the built output).
84
+ *
85
+ * `self===top` keeps it in the TOP window only. The doc-browser's background test
86
+ * runner loads every page-with-tests in a hidden iframe (`?_testMode=1`), each
87
+ * served this same HTML — without the guard, haltija's `dev.js` gets imported
88
+ * once per test page (N redundant loads in throwaway frames). An agent only ever
89
+ * drives the top page, so nested frames never need the channel.
90
+ */
91
+ export function haltijaLoaderSnippet(httpsPort) {
92
+ return (`<script>self===top&&/^localhost$|^127\\./.test(location.hostname)` +
93
+ `&&import('https://localhost:${httpsPort}/dev.js')</script>`);
94
+ }
81
95
  /**
82
96
  * Reclaim the port we are about to bind, from the process LISTENING on it.
83
97
  *
@@ -259,8 +273,7 @@ export async function devServer(config, opts = {}) {
259
273
  (config.haltijaDev === true ||
260
274
  process.env.HALTIJA_DEV === '1' ||
261
275
  process.env.HALTIJA_DEV === 'true');
262
- const HALTIJA_SNIPPET = `<script>/^localhost$|^127\\./.test(location.hostname)` +
263
- `&&import('https://localhost:${HALTIJA_HTTPS_PORT}/dev.js')</script>`;
276
+ const HALTIJA_SNIPPET = haltijaLoaderSnippet(HALTIJA_HTTPS_PORT);
264
277
  let testReportResolve;
265
278
  // Source read/write for in-browser "edit page source" (config.editableSources).
266
279
  // Local dev only — your machine, your files — so the lone guard is correctness:
@@ -581,11 +594,21 @@ export async function devServer(config, opts = {}) {
581
594
  // Source read/write for in-browser "edit page source" (opt-in, dev only).
582
595
  // A write lands in the repo file; the chokidar watcher then rebuilds and
583
596
  // the page refreshes — the build itself is the preview.
584
- if (config.editableSources && reqPath === '/__docstore/source') {
597
+ if (reqPath === '/__docstore/source') {
598
+ // Handle this endpoint UNCONDITIONALLY so it never falls through to the SPA
599
+ // index.html fallback below. A 200-with-HTML there is silently corrupting:
600
+ // the client loads the PAGE as the "source" (edit-page-source shows HTML;
601
+ // save-to-source reads HTML). When editing isn't enabled, answer with a clean
602
+ // status so `loadSource` falls back to the GitHub raw source (read-only), which
603
+ // is what the deployed static site already does.
604
+ if (!config.editableSources) {
605
+ return new Response('editableSources is not enabled in this doc-site config (set editableSources: true to edit/save source in dev)', { status: 501, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
606
+ }
585
607
  if (request.method === 'GET')
586
608
  return handleReadSource(request);
587
609
  if (request.method === 'POST')
588
610
  return handleWriteSource(request);
611
+ return new Response('method not allowed', { status: 405 });
589
612
  }
590
613
  if (reqPath === '/')
591
614
  reqPath = '/index.html';
@@ -56,4 +56,22 @@ export interface GenerateSiteConfig {
56
56
  /** extra lines injected into every <head> (favicon, analytics, etc.) */
57
57
  headExtra?: string;
58
58
  }
59
+ /**
60
+ * Depth of a page below the site root: 0 for the root index (served at the mount
61
+ * root), 1 for every `/slug/` page. The generator only ever emits a flat `/slug/`
62
+ * tree (see `pathForSlug`), so a non-root page is always exactly one directory deep.
63
+ */
64
+ export declare function pageDepth(slug: string): number;
65
+ /**
66
+ * Rewrite a root-relative *functional* URL (asset ref, nav / content link) to be
67
+ * relative to a page at `depth`, so ONE build works at ANY mount point — a GitHub
68
+ * project page under `/repo`, a custom-domain root, or a moved mount — with no
69
+ * `basePath` rebuild. Relative URLs resolve against wherever the page is actually
70
+ * served, so `basePath` is deliberately NOT applied here: a page at `/repo/x/`
71
+ * gets `../styles.css` → `/repo/styles.css`, the same page at `/x/` gets it at
72
+ * `/styles.css`. External (`https://…`, `//…`) and already-relative refs pass
73
+ * through untouched. (Metadata URLs still use `withBase` — they need the absolute
74
+ * origin.) See issue #25; the runtime/SPA-navigation half is issue #16.
75
+ */
76
+ export declare function relativeUrl(depth: number, p: string): string;
59
77
  export declare function generateSite(config: GenerateSiteConfig): Promise<number>;
@@ -27,11 +27,11 @@ const escapeText = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace
27
27
  * <details> on the path to `currentFilename` opened. `currentFilename` gets
28
28
  * aria-current. Crawlable + no-JS: every node is a real <a>.
29
29
  */
30
- function navHtml(docs, slugMap, currentFilename, basePath) {
30
+ function navHtml(docs, slugMap, currentFilename, depth) {
31
31
  const roots = buildNavTree(docs, slugMap);
32
32
  const open = navOpenPath(roots, currentFilename);
33
33
  const renderNode = (node, indent) => {
34
- const href = withBase(basePath, pathForSlug(node.slug));
34
+ const href = relativeUrl(depth, pathForSlug(node.slug));
35
35
  const current = node.doc.filename === currentFilename;
36
36
  // `doc-link` is what the HYDRATED nav emits, and the shared nav CSS is written
37
37
  // against it. Emitting a bare <a> here forced a second, hand-copied rule set under
@@ -78,16 +78,48 @@ function absUrl(baseUrl, pathOrUrl) {
78
78
  /**
79
79
  * Prefix a root-relative path with basePath. No-op for '/', empty, protocol-
80
80
  * relative (`//…`), or absolute (`https://…`) URLs.
81
+ *
82
+ * Used ONLY for *metadata* URLs now (canonical, og:url, og:image, sitemap) — they
83
+ * legitimately need the real absolute origin+path. *Functional* URLs go through
84
+ * `relativeUrl` instead, so the artifact is mount-agnostic. See `relativeUrl`.
81
85
  */
82
86
  function withBase(basePath, p) {
83
87
  if (!p || !basePath || basePath === '/' || /^(https?:)?\/\//.test(p))
84
88
  return p;
85
89
  return basePath.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
86
90
  }
91
+ /**
92
+ * Depth of a page below the site root: 0 for the root index (served at the mount
93
+ * root), 1 for every `/slug/` page. The generator only ever emits a flat `/slug/`
94
+ * tree (see `pathForSlug`), so a non-root page is always exactly one directory deep.
95
+ */
96
+ export function pageDepth(slug) {
97
+ return slug === '' ? 0 : 1;
98
+ }
99
+ /**
100
+ * Rewrite a root-relative *functional* URL (asset ref, nav / content link) to be
101
+ * relative to a page at `depth`, so ONE build works at ANY mount point — a GitHub
102
+ * project page under `/repo`, a custom-domain root, or a moved mount — with no
103
+ * `basePath` rebuild. Relative URLs resolve against wherever the page is actually
104
+ * served, so `basePath` is deliberately NOT applied here: a page at `/repo/x/`
105
+ * gets `../styles.css` → `/repo/styles.css`, the same page at `/x/` gets it at
106
+ * `/styles.css`. External (`https://…`, `//…`) and already-relative refs pass
107
+ * through untouched. (Metadata URLs still use `withBase` — they need the absolute
108
+ * origin.) See issue #25; the runtime/SPA-navigation half is issue #16.
109
+ */
110
+ export function relativeUrl(depth, p) {
111
+ if (!p || /^(https?:)?\/\//.test(p) || !p.startsWith('/'))
112
+ return p;
113
+ const rel = '../'.repeat(depth) + p.slice(1);
114
+ return rel === '' ? './' : rel;
115
+ }
87
116
  function pageHtml(doc, config, slugMap, configAttr) {
88
117
  const { projectName = '', baseUrl = '', lang = 'en', favicon = '/favicon.svg', docsUrl = '/docs.json', scriptUrl = '/iife.js', hydrateUrl, stylesUrl = '/doc-system.css', localizedUrl = '/localized-strings.txt', basePath, headExtra = '', bakes, } = config;
118
+ // Functional URLs are emitted relative to THIS page's depth so the build is
119
+ // mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
120
+ const depth = pageDepth(slugMap[doc.filename] ?? '');
89
121
  const localizedAttr = config.localizedStrings
90
- ? ` localized="${escapeAttr(withBase(basePath, localizedUrl))}"`
122
+ ? ` localized="${escapeAttr(relativeUrl(depth, localizedUrl))}"`
91
123
  : '';
92
124
  // ONE rule, shared with the doc-browser's hydration path — see doc-title.ts. Writing
93
125
  // it twice is what made the title change on hydration (issue #6).
@@ -103,9 +135,9 @@ function pageHtml(doc, config, slugMap, configAttr) {
103
135
  // static HTML is correct for no-JS readers and crawlers (the doc-browser also
104
136
  // does this client-side after hydration).
105
137
  const body = rewriteDocLinks(renderDocMarkdown(doc.text, { bakes: bakes?.get(doc.filename) }), (filename) => slugMap[filename] !== undefined
106
- ? withBase(basePath, pathForSlug(slugMap[filename]))
138
+ ? relativeUrl(depth, pathForSlug(slugMap[filename]))
107
139
  : null);
108
- const nav = navHtml(config.docs, slugMap, doc.filename, basePath);
140
+ const nav = navHtml(config.docs, slugMap, doc.filename, depth);
109
141
  const navbar = linkListHtml('doc-navbar', config.navbarLinks);
110
142
  const jsonLd = baseUrl
111
143
  ? jsonLdScript({
@@ -126,7 +158,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
126
158
  ' <link rel="preconnect" href="https://fonts.googleapis.com" />',
127
159
  ' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
128
160
  // Burned-in theme: styles the page with no JS and with zero flash on hydration.
129
- ` <link rel="stylesheet" href="${escapeAttr(withBase(basePath, stylesUrl))}" data-tosi-doc-system />`,
161
+ ` <link rel="stylesheet" href="${escapeAttr(relativeUrl(depth, stylesUrl))}" data-tosi-doc-system />`,
130
162
  ` <title>${escapeText(title)}</title>`,
131
163
  description
132
164
  ? ` <meta name="description" content="${escapeAttr(description)}" />`
@@ -155,7 +187,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
155
187
  ? ` <meta name="twitter:image" content="${escapeAttr(imageAbs)}" />`
156
188
  : '',
157
189
  jsonLd,
158
- ` <link rel="icon" href="${escapeAttr(withBase(basePath, favicon))}" />`,
190
+ ` <link rel="icon" href="${escapeAttr(relativeUrl(depth, favicon))}" />`,
159
191
  headExtra,
160
192
  ]
161
193
  .filter(Boolean)
@@ -189,7 +221,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
189
221
  ${head}
190
222
  </head>
191
223
  <body>
192
- <tosi-doc-system docs="${escapeAttr(withBase(basePath, docsUrl))}" config="${configAttr}"${localizedAttr}>
224
+ <tosi-doc-system docs="${escapeAttr(relativeUrl(depth, docsUrl))}" config="${configAttr}"${localizedAttr}>
193
225
  <article class="doc-content">
194
226
  ${body}
195
227
  </article>
@@ -197,8 +229,8 @@ ${nav}
197
229
  ${navbar}
198
230
  </tosi-doc-system>
199
231
  ${hydrateUrl
200
- ? `<script type="module" src="${escapeAttr(withBase(basePath, hydrateUrl))}"></script>`
201
- : `<script src="${escapeAttr(withBase(basePath, scriptUrl))}"></script>`}
232
+ ? `<script type="module" src="${escapeAttr(relativeUrl(depth, hydrateUrl))}"></script>`
233
+ : `<script src="${escapeAttr(relativeUrl(depth, scriptUrl))}"></script>`}
202
234
  </body>
203
235
  </html>
204
236
  `;
@@ -88,9 +88,16 @@ export interface SiteConfig {
88
88
  domain?: string;
89
89
  /**
90
90
  * URL prefix the site is served under, default '/'. Set to '/<repo>' for a
91
- * GitHub project page without a custom domain; every absolute URL the
92
- * generator emits (links, canonical, sitemap, scriptUrl, stylesUrl) is
93
- * rewritten under it.
91
+ * GitHub project page without a custom domain.
92
+ *
93
+ * As of the mount-agnostic build (issue #25), `basePath` only affects
94
+ * *metadata* URLs — `canonical`, `og:url`, `og:image`, and `sitemap.xml` —
95
+ * which need the real absolute served path for SEO. *Functional* URLs (nav /
96
+ * content links, `scriptUrl`, `stylesUrl`, favicon, `docsUrl`) are emitted
97
+ * **relative to each page**, so one build works at `/repo`, at a custom-domain
98
+ * root, or a moved mount with no rebuild. You still want `basePath` correct so
99
+ * search engines see canonical URLs at the real path; getting it wrong now only
100
+ * mis-states metadata, it no longer 404s the page's assets.
94
101
  */
95
102
  basePath?: string;
96
103
  /**