tosijs-ui 1.14.0 → 1.14.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,191 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.14.2
4
+
5
+ A single-fix patch, cut from `v1.14.1` rather than from `main`, so it carries none of the
6
+ in-progress 1.15.0 work.
7
+
8
+ ### `import 'tosijs-ui/doc-browser'` now actually defines `<tosi-doc-system>` (#158, #159)
9
+
10
+ **If you are building a doc site with `tosijs-ui/site`, upgrade.** The one import every
11
+ adoption doc tells you to write did not register the doc system.
12
+
13
+ `doc-browser.ts` is a library module — types, helpers, `createDocBrowser()` — while the element
14
+ lives in `doc-system/doc-system.ts`, which imports *from* doc-browser. The dependency arrow ran
15
+ the wrong way, so that import could never have reached the registrar however it was written.
16
+
17
+ What let it survive several releases is that it was a **partial** success. The leaf components
18
+ (`tosi-sidenav`, `tosi-example`, …) registered fine, so your bundle looked healthy, your own
19
+ elements worked, pages served 200 — and `<tosi-doc-system>` sat in the markup inert. The
20
+ expensive half was quieter: no doc system means no `window.__docTestResults`, so an entire
21
+ inline doc-test corpus **silently stopped existing**. Not a red suite; a suite reporting
22
+ nothing. Two projects hit this independently, one of them reaching a release candidate first.
23
+
24
+ `tosijs-ui/doc-browser` now resolves to an entry that imports the registrar and re-exports the
25
+ whole documented API. **No code changes** — the line you already wrote starts working.
26
+
27
+ A more precise spelling, a named `tosijs-ui/doc-system` export, is coming in a later release.
28
+ **This one keeps working**; it is an addition, not a deprecation.
29
+
30
+ Two sentences were separately false and are corrected: `import 'tosijs-ui'` does **not** also
31
+ register the doc system. The root barrel deliberately excludes that cluster (#133 — it was 77%
32
+ of the barrel's weight, landing on every app that imported a button), so the root import gives
33
+ you buttons and an undefined doc system. That text shipped in the emitted `.d.ts`, the adoption
34
+ page and the adopter reference.
35
+
36
+ A test now imports the built entry in a subprocess and fails if the element is undefined.
37
+ Verified by mutation: strip the side-effect import and it goes red.
38
+
39
+ ## 1.14.1
40
+
41
+ ### The doc-test gate can tell a dropped page from a passing one
42
+
43
+ `__docTestResults` reported `{ passed, failed, pages }`, so a consumer gate could assert that
44
+ tests ran and that none failed — but not that **all** of them ran. A runner that silently lost
45
+ half the corpus satisfied every check.
46
+
47
+ That is not hypothetical. The false green fixed in 1.14.0 was a page-**selection** defect: a
48
+ substring match counted 17 pages where 16 had tests, and ran a prose page's examples in a
49
+ background iframe. Fixing the selection did not add a guard against the next one.
50
+
51
+ The resolved object now carries `pagesWithTests` and `pagesTested`, and
52
+ `tests/doc-tests.pw.ts` asserts three things where it previously asserted one: the corpus
53
+ contains test pages at all, every such page reported, and some assertions executed. A corpus
54
+ with genuinely no tests resolves with honest zeros, so "no tests exist" stays distinguishable
55
+ from "tests exist and ran".
56
+
57
+ Framed by tosijs-platform in #142, from a suite that reported **140 pass, 0 fail while twelve
58
+ cases had never executed** — skip-guarded behind emulators that were not running, asserting
59
+ `expect(true).toBe(true)`. When the emulators were finally started it found three production
60
+ bugs in ten minutes. The tests were fine; the reporting was a lie, and a comfortable one, which
61
+ is why it survived. **"We didn't look" and "we looked and it's fine" must not produce the same
62
+ output** — a reported total is that same lie one level up.
63
+
64
+ ### New page: "Adopting the doc system"
65
+
66
+ Three of the four onboarding defects this release fixes (#144, #145, #146) would have been
67
+ prevented by a page that did not exist — a narrow path to read *before* the first build, as
68
+ opposed to a configuration reference nobody reads until something is already wrong.
69
+
70
+ It is organised around the thing that makes this system hard to adopt: **its failure mode is a
71
+ site that serves perfectly and shows nothing.** Not a crash, not a 404 — pages return 200, the
72
+ markup is in the HTML, the adopter's own elements register, and the page is inert. So the page
73
+ leads with a "how to tell it actually worked" checklist (is `tosi-doc-system` defined, is the
74
+ chrome there, read one emitted `<head>`, do the examples run) and then explains the four silent
75
+ failures behind those checks.
76
+
77
+ Includes the complete `bundleEntry` file rather than naming it, since "your entry should import
78
+ what your pages need" was the sentence that cost #145's reporter the most — it reads as *your*
79
+ content, not as the doc system that renders the entire site.
80
+
81
+ ### `<tosi-table>` filters and sorts the whole table, not the first `maxVisibleRows` (#147)
82
+
83
+ The window was applied **first**, so both the filter and the sort saw only the first N rows.
84
+ Two consequences, both reported from production at 300k rows:
85
+
86
+ - **A matching row past the cap could never be found.** A newly-created company appended to a
87
+ 12,000-row list, with the cap at 10,000, did not exist as far as search was concerned.
88
+ - **Sorting did not sort the table.** You got the first N rows in *data* order, arranged
89
+ nicely — not the top N by the sort.
90
+
91
+ This was known and the answer had been a warning: the comment in the render path read *"every
92
+ count, filter and sort then ran on the truncated set, so the numbers were self-consistent and
93
+ wrong"* (#82). A warning does not help someone whose search returns nothing, and it named row
94
+ counts rather than the consequence — so "showing 10,000 of 12,000 rows" gave no clue why a
95
+ search failed.
96
+
97
+ The order is now **filter → force → sort → cluster → window**. `maxVisibleRows` is a *layout*
98
+ ceiling — the browser's maximum element height — so it belongs at the end, against what will be
99
+ drawn, rather than at the start against the data.
100
+
101
+ **Cost, measured on 300k rows**, since this moves the sort from "at most `maxVisibleRows`" to
102
+ "every match": the common case (no sort set) is **0.9ms, down from 2.9ms**; a filter narrowing
103
+ to ~1% is 2.0ms; a broad sort over all 300k is **108ms**. That last one is the price of sorting
104
+ the real table, and it is paid when you click a column header — but `pinColumns()`, a schema
105
+ change and a column resize all trigger a render without changing row order, so the pipeline is
106
+ memoized against the identities that determine it (array, filter, sort, groupId, forced ids).
107
+ A render that changes none of them reuses the result.
108
+
109
+ The truncation warning now counts against the **filtered** set — "10,000 of 300,000" is
110
+ alarming and wrong once a filter has narrowed things to 40 — and says that the rows shown are
111
+ the right ones, there are simply more than the browser can lay out.
112
+
113
+ ### `menuClass` — theme one component's menus without restyling the page (#148)
114
+
115
+ Menu metrics are all theme variables, and setting them on your component **did not work**.
116
+ The popup is built per-invocation and mounted in a body-level `<tosi-float>`, so it is not a
117
+ descendant of whatever opened it, and custom properties have nothing to inherit from. The only
118
+ selector that reached it was `:root` — which restyles every menu on the page, including the doc
119
+ system's own when your component is documented on a `tosijs-ui/site` site. The reporter had not
120
+ shipped their denser menubar for exactly that reason: a component reaching out to re-theme the
121
+ page around it is the wrong direction.
122
+
123
+ ```
124
+ popMenu({ target, menuItems, menuClass: 'my-editor-menu' })
125
+ <tosi-menu menu-class="my-editor-menu">
126
+ <tosi-select menu-class="my-listbox"> // its listbox mounts the same way
127
+ ```
128
+
129
+ The class lands beside `xin-menu tosi-menu`, takes a space-separated list, and **propagates to
130
+ submenus** — which are separate popups, so without that the theming would apply at the top
131
+ level and silently revert one level down. `menuWidth` was already a per-menu attribute, so
132
+ per-menu styling is not new here; this is the same idea for the rest of the knobs.
133
+
134
+ Chosen over the two alternatives in the report: copying the trigger's computed variables onto
135
+ the popup needs no change from adopters but is considerably more magic, and stamping a
136
+ `data-menu-owner` back-reference solves selection without solving theming.
137
+
138
+ ### Adoption blockers found by tosijs-editor onboarding (#144, #145, #146)
139
+
140
+ Three defects and a credential, all found by one project adopting `tosijs-ui/site` for the
141
+ first time. Every one of them failed silently.
142
+
143
+ **A Mapbox token in `dist/` blocked adopters' pushes (#145).** Two `<tosi-map>` doc examples
144
+ carried a literal `pk.` token, which compiled into `dist/mapbox.js`, inlined into `iife.js`,
145
+ and landed in every adopter's sourcemap. It matches Mapbox's published secret pattern, so
146
+ **GitHub push protection blocked adopters the first time they committed their built site** — a
147
+ wall with someone else's name on it.
148
+
149
+ To be clear about what this is and is not: Mapbox `pk.` tokens are **public by design** —
150
+ Mapbox's own docs tell you to put them in client-side JavaScript — so the scanner match is a
151
+ false positive and there was never a secret here. The demos still use the token; it is now
152
+ assigned programmatically and base64'd, which is **not** a security measure and would be a
153
+ poor one. It exists solely so the literal string stops matching a regex. `atob()` protects
154
+ nothing from anyone, which is precisely why it suits a value that needs no protection.
155
+
156
+ `src/no-secrets.test.ts` fails the build on any credential-shaped string in `src/`, and says
157
+ plainly what it cannot see: an encoded value, deliberately. It catches accident, not intent.
158
+
159
+ **`bundleEntry` REPLACES the default bundle rather than extending it (#145).** An entry that
160
+ imports only your own library produces a site where every page renders its prerendered markup
161
+ and nothing else — no header, no nav, no menu, no live examples — with no console error, no
162
+ build warning and no 404. `<tosi-doc-system>` sits in the HTML, inert, because nothing defined
163
+ it, while your own elements register fine and make the bundle look healthy. The jsdoc now says
164
+ this in the imperative with the two-line fix, and the build **scans the emitted bundle and
165
+ warns** when `tosi-doc-system` is absent (and `tosi-example`, when the corpus has executable
166
+ fences). Grepping is sound here for once: `customElements.define` needs the literal tag, so
167
+ minification cannot erase it.
168
+
169
+ **`baseUrl` meant two different things (#144).** `generate-site` treated it as the origin and
170
+ added `basePath` on top; `make-llms-txt` treated it as origin-plus-path and ignored `basePath`
171
+ entirely. On a GitHub project page no configuration satisfied both — matching the canonical
172
+ URLs doubled the prefix in `llms.txt`, matching `llms.txt` dropped it from every canonical and
173
+ sitemap entry. Underneath were **two copies of `withBase`** (`generate-site`, and `epub`, whose
174
+ comment read "mirrors generate-site's withBase") plus this third consumer with none: one rule,
175
+ three implementations, one of them empty. `withBase` now lives once in `routing.ts`, `llms.txt`
176
+ applies it, `baseUrl` is documented as origin-only, and the build warns on the combination that
177
+ is always wrong.
178
+
179
+ **Which fence languages execute was undocumented (#146).** Six run — `js`, `ts`, `tjs`,
180
+ `html`, `css`, `test` — and the only place the distinction appeared named a subset, so the
181
+ mental model everyone formed was "js/ts/tjs/test run, everything else is display". A ` ```css `
182
+ fence showing how to style the component in your app is injected as a page-wide `<style>`; a
183
+ lone ` ```html ` fence showing the markup something compiles to renders as a broken-looking
184
+ example. Neither fails the build. Now documented as a table with display-only alternatives
185
+ (`xml` for markup), along with the grouping rule — adjacent fences form one example, prose
186
+ between them starts a new one — which is why moving a `test` block under its own heading
187
+ silently detaches it from the demo it was written against.
188
+
3
189
  ## 1.14.0
4
190
 
5
191
  ### Quote style no longer decides whether a live example runs (#141)
@@ -238,6 +238,8 @@ export declare class TosiTable extends WebComponent {
238
238
  private _sort?;
239
239
  private _rowGroupId;
240
240
  private _visibleGroupedRowIds;
241
+ private _visibleMemo;
242
+ private computeVisibleRows;
241
243
  private _nonRepeatingGroupedRowCells;
242
244
  private _pinnedTopRows?;
243
245
  private _pinnedBottomRows?;
@@ -1453,6 +1453,76 @@ export class TosiTable extends WebComponent {
1453
1453
  _sort;
1454
1454
  _rowGroupId = null;
1455
1455
  _visibleGroupedRowIds = null;
1456
+ /*
1457
+ Memo for the filter → force → sort → cluster pipeline (#147).
1458
+
1459
+ Correcting the order moved the sort from "at most `maxVisibleRows` rows" to "every matching
1460
+ row", which is the price of sorting the actual table. Measured on 300k rows: a broad sort is
1461
+ **108ms**, against 2.9ms for the old (wrong) window-first order. Paying that once when the
1462
+ user clicks a column header is fine. Paying it on `pinColumns()`, a schema change or a
1463
+ column resize — all of which call `queueRender` and none of which change the row order — is
1464
+ not, and would read as the O(1)-in-rows claim quietly breaking.
1465
+
1466
+ So the result is cached against the identities that actually determine it. Everything else
1467
+ that triggers a render reuses it. With no sort set (the common case) the whole pipeline is
1468
+ 0.9ms and this never earns its keep — it exists for the case where the fix would otherwise
1469
+ regress a real table.
1470
+ */
1471
+ _visibleMemo = null;
1472
+ computeVisibleRows(baseData, groupId, sort) {
1473
+ const memo = this._visibleMemo;
1474
+ if (memo &&
1475
+ memo.array === this._array &&
1476
+ memo.filter === this._filter &&
1477
+ memo.sort === sort &&
1478
+ memo.groupId === groupId &&
1479
+ memo.forcedIds === this._visibleGroupedRowIds) {
1480
+ return memo.result;
1481
+ }
1482
+ let rows = this.filter(baseData);
1483
+ /*
1484
+ Forcing runs BEFORE the sort, so a row re-admitted by `visibleGroupedRowIds` is ordered
1485
+ like any other row rather than being tacked on the end. It re-admits from the full
1486
+ pre-filter set, which is what `baseData` now is.
1487
+ */
1488
+ if (groupId) {
1489
+ rows = withForcedGroups(rows, baseData, groupId, this._visibleGroupedRowIds);
1490
+ }
1491
+ /*
1492
+ `filter` defaults to `passThru`, which returns the SAME array — sorting it in place would
1493
+ reorder the caller's data. Copy only when we are about to mutate.
1494
+ */
1495
+ if (sort)
1496
+ rows = rows.slice().sort(sort);
1497
+ // Clustering LAST — the spec is "grouped, then sorted within the grouping", so any other
1498
+ // sort is applied first and survives inside each group.
1499
+ if (groupId)
1500
+ rows = clusterByGroup(rows, groupId);
1501
+ // The window, last: it is a layout ceiling on what gets DRAWN.
1502
+ const limit = this.maxVisibleRows;
1503
+ if (rows.length > limit) {
1504
+ if (!this._warnedTruncation) {
1505
+ this._warnedTruncation = true;
1506
+ console.warn(`<tosi-table> is showing ${limit.toLocaleString()} of ${rows.length.toLocaleString()} matching rows.\n` +
1507
+ ` maxVisibleRows is ${limit.toLocaleString()}${this._maxVisibleRows === undefined
1508
+ ? ` — derived from this browser's maximum element height (${probeMaxElementHeight().toLocaleString() || 'unknown'}px) at rowHeight ${this.rowHeight}.`
1509
+ : ' — set explicitly on this element.'}\n` +
1510
+ ` Filtering and sorting run over the FULL set, so the rows shown are the right\n` +
1511
+ ` ones — there are simply more of them than the browser can lay out. Raise it\n` +
1512
+ ` with \`table.maxVisibleRows = n\`, or narrow the filter.`);
1513
+ }
1514
+ rows = rows.slice(0, limit);
1515
+ }
1516
+ this._visibleMemo = {
1517
+ array: this._array,
1518
+ filter: this._filter,
1519
+ sort,
1520
+ groupId,
1521
+ forcedIds: this._visibleGroupedRowIds,
1522
+ result: rows,
1523
+ };
1524
+ return rows;
1525
+ }
1456
1526
  _nonRepeatingGroupedRowCells = null;
1457
1527
  // Optional explicit arrays of pinned items. When set, they are managed
1458
1528
  // separately from `array` and override the `pinnedTop` / `pinnedBottom`
@@ -3073,25 +3143,13 @@ export class TosiTable extends WebComponent {
3073
3143
  const pinnedTopData = this.effectivePinnedTopData;
3074
3144
  const pinnedBottomData = this.effectivePinnedBottomData;
3075
3145
  const baseData = this.effectiveBaseData;
3076
- const limit = this.maxVisibleRows;
3077
- const cap = Math.min(baseData.length, limit);
3078
3146
  /*
3079
- Say so when rows are dropped. Truncating in silence meant the table disagreed with the
3080
- data and nothing anywhere said why — every count, filter and sort then ran on the
3081
- truncated set, so the numbers were self-consistent and wrong (#82).
3082
-
3083
- Once per table, and only when it actually bites: a notice on every render of every table
3084
- is how a warning becomes something people filter.
3147
+ The truncation warning moved into `computeVisibleRows`, where the window is now applied
3148
+ (#147). It has to be counted against the FILTERED set: "showing 10,000 of 300,000 rows"
3149
+ is alarming and wrong once the filter has already narrowed things to 40. Kept as a
3150
+ once-per-table notice — one on every render of every table is how a warning becomes
3151
+ something people filter (#82).
3085
3152
  */
3086
- if (cap < baseData.length && !this._warnedTruncation) {
3087
- this._warnedTruncation = true;
3088
- console.warn(`<tosi-table> is showing ${cap.toLocaleString()} of ${baseData.length.toLocaleString()} rows.\n` +
3089
- ` maxVisibleRows is ${limit.toLocaleString()}${this._maxVisibleRows === undefined
3090
- ? ` — derived from this browser's maximum element height (${probeMaxElementHeight().toLocaleString() || 'unknown'}px) at rowHeight ${this.rowHeight}.`
3091
- : ' — set explicitly on this element.'}\n` +
3092
- ` Raise it with \`table.maxVisibleRows = n\`. Beyond the layout ceiling the\n` +
3093
- ` spacer stops growing, so the far end simply cannot be scrolled to.`);
3094
- }
3095
3153
  /*
3096
3154
  A big table with no `rowHeight` is a misconfiguration, not a use case (#84).
3097
3155
 
@@ -3120,29 +3178,35 @@ export class TosiTable extends WebComponent {
3120
3178
  ` depending on row count at all — virtual tables are O(1) in rows.\n` +
3121
3179
  ` rowHeight 0 is for SMALL tables and for variable row heights.`);
3122
3180
  }
3123
- const scope = baseData.slice(0, cap);
3124
3181
  // Fresh per render — see `_groupIdMemo`. Must happen before `groupIdFn` is read.
3125
3182
  this._groupIdMemo = new WeakMap();
3126
3183
  const groupId = this.groupIdFn;
3127
- let visibleData = this.filter(scope);
3184
+ const { sort } = this;
3128
3185
  /*
3129
- Forcing runs BEFORE the sort, so a row re-admitted by `visibleGroupedRowIds` is ordered
3130
- like any other row rather than being tacked on the end.
3186
+ ORDER: filter → force → sort → cluster → WINDOW (#147).
3187
+
3188
+ `maxVisibleRows` used to be applied FIRST, so both the filter and the sort saw only the
3189
+ first N rows. Two consequences a user would call bugs, and both were reported from
3190
+ production at 300k rows:
3191
+
3192
+ - a matching row past the cap could never be found. Search for a company appended to
3193
+ the end of a 12,000-row list, with the cap at 10,000, and it does not exist.
3194
+ - sorting did not sort the table. You got the first N rows in DATA order, arranged
3195
+ nicely — not the top N by the sort.
3196
+
3197
+ The comment on the truncation warning above already said this ("every count, filter and
3198
+ sort then ran on the truncated set, so the numbers were self-consistent and wrong"), and
3199
+ #82's answer was to warn. A warning does not help someone whose search returns nothing.
3200
+
3201
+ The window is a LAYOUT ceiling — the browser's maximum element height — so it belongs at
3202
+ the end, against what will actually be shown, not at the start against the data.
3131
3203
  */
3204
+ const visibleData = this.computeVisibleRows(baseData, groupId, sort);
3132
3205
  if (groupId) {
3133
- visibleData = withForcedGroups(visibleData, scope, groupId, this._visibleGroupedRowIds);
3134
- }
3135
- const { sort } = this;
3136
- if (sort)
3137
- visibleData.sort(sort);
3138
- // Clustering LAST — the spec is "grouped, then sorted within the grouping", so any
3139
- // other sort is applied first and survives inside each group.
3140
- if (groupId) {
3141
- visibleData = clusterByGroup(visibleData, groupId);
3142
3206
  this._grouping = groupRenderMeta(visibleData, groupId);
3143
- // Counted from `scope` (pre-filter) against the final rendered set, and assigned
3144
- // BEFORE the rows below are stamped, so a cell renderer can read it as it renders.
3145
- this._rowGroupCounts = groupCounts(scope, visibleData, groupId);
3207
+ // Counted from the full PRE-FILTER set against the rendered set, and assigned BEFORE
3208
+ // the rows below are stamped, so a cell renderer can read it as it renders.
3209
+ this._rowGroupCounts = groupCounts(baseData, visibleData, groupId);
3146
3210
  }
3147
3211
  else {
3148
3212
  this._grouping = null;
@@ -0,0 +1,2 @@
1
+ import './doc-system/doc-system.js';
2
+ export * from './doc-browser.js';
@@ -0,0 +1,28 @@
1
+ /*
2
+ The entry behind `tosijs-ui/doc-browser`.
3
+
4
+ This exists because that import — the one EVERY adoption doc tells you to write, and the one
5
+ the build's own remediation warning prints when it detects a missing doc system — did not
6
+ define `<tosi-doc-system>`.
7
+
8
+ `doc-browser.ts` is a library module: types, helpers, and the `createDocBrowser()` factory. The
9
+ custom element lives in `doc-system/doc-system.ts`, which imports FROM `doc-browser.ts`. The
10
+ dependency arrow runs the wrong way, so the documented import could never reach the registrar
11
+ however it was written.
12
+
13
+ The result was precisely the failure mode the adoption page warns about, and it is disguised by
14
+ being a PARTIAL success: `tosi-sidenav`, `tosi-example` and the other leaf components do
15
+ register, so the bundle looks healthy, pages serve 200, and `<tosi-doc-system>` sits in the
16
+ markup inert. The expensive half is quieter — no doc system means no `window.__docTestResults`,
17
+ so an adopter's entire inline doc-test corpus silently stops existing. The suite does not fail;
18
+ it reports nothing. Two projects hit this independently (#158, #159), one of them reaching a
19
+ release candidate before anyone noticed.
20
+
21
+ A SEPARATE FILE, not a side-effect import inside `doc-browser.ts`: that would be a genuine
22
+ import cycle (doc-system → doc-browser → doc-system). It would probably work, because
23
+ `doc-system.ts` uses `createDocBrowser` only inside methods — but "probably works, depending on
24
+ evaluation order after your bundler reorders it" is not a thing to put under the one import
25
+ every adopter is told to write. Nothing here depends on evaluation order.
26
+ */
27
+ import './doc-system/doc-system.js';
28
+ export * from './doc-browser.js';
@@ -10,6 +10,20 @@ export interface DocTestResults {
10
10
  passed: number;
11
11
  failed: number;
12
12
  pages: Record<string, PageTestResults>;
13
+ /**
14
+ * How many pages the runner INTENDED to run, and how many reported back.
15
+ *
16
+ * `passed + failed > 0` only proves the runner started. It cannot tell a full corpus from
17
+ * a corpus that silently lost half its pages — and a page-selection defect that ran 16 of
18
+ * 17 pages is exactly the false green fixed in 1.14.0, so this is a live failure mode
19
+ * rather than a hypothetical one. A consumer gate should assert
20
+ * `pagesTested === pagesWithTests && pagesWithTests > 0`.
21
+ *
22
+ * Framed by tosijs-platform in #142: "we didn't look" and "we looked and it's fine" must
23
+ * not produce the same output. Reporting a total is how a skipped run stays comfortable.
24
+ */
25
+ pagesWithTests: number;
26
+ pagesTested: number;
13
27
  }
14
28
  declare global {
15
29
  interface Window {
@@ -488,6 +488,8 @@ export function createDocBrowser(options) {
488
488
  passed: 0,
489
489
  failed: 0,
490
490
  pages: pageTestResults,
491
+ pagesWithTests,
492
+ pagesTested,
491
493
  };
492
494
  for (const pageResults of Object.values(pageTestResults)) {
493
495
  allResults.passed += pageResults.totalPassed;
@@ -1723,7 +1725,13 @@ export function createDocBrowser(options) {
1723
1725
  }
1724
1726
  if (pagesWithTests === 0) {
1725
1727
  if (testResultsResolve) {
1726
- testResultsResolve({ passed: 0, failed: 0, pages: {} });
1728
+ testResultsResolve({
1729
+ passed: 0,
1730
+ failed: 0,
1731
+ pages: {},
1732
+ pagesWithTests: 0,
1733
+ pagesTested: 0,
1734
+ });
1727
1735
  testResultsResolve = undefined;
1728
1736
  }
1729
1737
  return;
@@ -1826,7 +1834,13 @@ export function createDocBrowser(options) {
1826
1834
  setTimeout(() => markPageTested(currentDoc.filename), 2000);
1827
1835
  }
1828
1836
  else if (testResultsResolve) {
1829
- testResultsResolve({ passed: 0, failed: 0, pages: {} });
1837
+ testResultsResolve({
1838
+ passed: 0,
1839
+ failed: 0,
1840
+ pages: {},
1841
+ pagesWithTests: 0,
1842
+ pagesTested: 0,
1843
+ });
1830
1844
  testResultsResolve = undefined;
1831
1845
  }
1832
1846
  }
@@ -48,3 +48,19 @@ export declare function resolveParent(parentValue: string, docs: Array<{
48
48
  filename: string;
49
49
  title?: string;
50
50
  }>, slugMap: Record<string, string>): string;
51
+ /**
52
+ * Prefix a root-relative path with the site's `basePath`.
53
+ *
54
+ * THE ONE COPY. There were two identical implementations (`generate-site.ts`, `epub.ts` —
55
+ * the latter's comment said "mirrors generate-site's withBase") and a third consumer,
56
+ * `make-llms-txt.ts`, that had none at all. So `baseUrl` meant the ORIGIN to the page
57
+ * generator and ORIGIN-PLUS-PATH to llms.txt, and on a GitHub project page no configuration
58
+ * satisfied both: matching the canonical URLs doubled the prefix in llms.txt, and matching
59
+ * llms.txt dropped it from every canonical and sitemap entry (tosijs-ui#144).
60
+ *
61
+ * Nothing failed — `basePath` affects metadata only, so the site works and you find it by
62
+ * reading the emitted `<head>`. The reporter's shipped to Pages before they noticed.
63
+ *
64
+ * `baseUrl` is the ORIGIN ONLY. Absolute URLs and an empty/`/` basePath pass through.
65
+ */
66
+ export declare function withBase(basePath: string | undefined, p: string): string;
@@ -128,3 +128,23 @@ export function resolveParent(parentValue, docs, slugMap) {
128
128
  return d.filename;
129
129
  return '';
130
130
  }
131
+ /**
132
+ * Prefix a root-relative path with the site's `basePath`.
133
+ *
134
+ * THE ONE COPY. There were two identical implementations (`generate-site.ts`, `epub.ts` —
135
+ * the latter's comment said "mirrors generate-site's withBase") and a third consumer,
136
+ * `make-llms-txt.ts`, that had none at all. So `baseUrl` meant the ORIGIN to the page
137
+ * generator and ORIGIN-PLUS-PATH to llms.txt, and on a GitHub project page no configuration
138
+ * satisfied both: matching the canonical URLs doubled the prefix in llms.txt, and matching
139
+ * llms.txt dropped it from every canonical and sitemap entry (tosijs-ui#144).
140
+ *
141
+ * Nothing failed — `basePath` affects metadata only, so the site works and you find it by
142
+ * reading the emitted `<head>`. The reporter's shipped to Pages before they noticed.
143
+ *
144
+ * `baseUrl` is the ORIGIN ONLY. Absolute URLs and an empty/`/` basePath pass through.
145
+ */
146
+ export function withBase(basePath, p) {
147
+ if (!p || !basePath || basePath === '/' || /^(https?:)?\/\//.test(p))
148
+ return p;
149
+ return basePath.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
150
+ }
@@ -17,7 +17,7 @@ Build-time only (Bun APIs + the `zip` CLI); never import from browser code.
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import { renderDocMarkdown } from '../render.js';
20
- import { buildSlugMap, pathForSlug, slugForPath } from '../routing.js';
20
+ import { buildSlugMap, pathForSlug, slugForPath, withBase } from '../routing.js';
21
21
  import { buildNavTree } from '../nav-tree.js';
22
22
  import { partitionByBook, DEFAULT_BOOK } from '../book-target.js';
23
23
  import { epubVolumeIdentity } from './epub-volumes.js';
@@ -159,12 +159,6 @@ function collectExamplePres(el, out) {
159
159
  }
160
160
  }
161
161
  }
162
- /** Prefix a root-relative path with basePath (mirrors generate-site's withBase). */
163
- function withBase(basePath, p) {
164
- if (!p || !basePath || basePath === '/' || /^(https?:)?\/\//.test(p))
165
- return p;
166
- return basePath.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
167
- }
168
162
  /**
169
163
  * Rewrite in-book cross-links so they resolve INSIDE the EPUB (#15). renderDocMarkdown
170
164
  * emits site paths — `/slug/` (wikilinks + the auto-generated section TOCs) and legacy
@@ -74,6 +74,14 @@ export interface GenerateSiteConfig {
74
74
  /** extra lines injected into every <head> (favicon, analytics, etc.) */
75
75
  headExtra?: string;
76
76
  }
77
+ /**
78
+ * Prefix a root-relative path with basePath. No-op for '/', empty, protocol-
79
+ * relative (`//…`), or absolute (`https://…`) URLs.
80
+ *
81
+ * Used ONLY for *metadata* URLs now (canonical, og:url, og:image, sitemap) — they
82
+ * legitimately need the real absolute origin+path. *Functional* URLs go through
83
+ * `relativeUrl` instead, so the artifact is mount-agnostic. See `relativeUrl`.
84
+ */
77
85
  /**
78
86
  * Depth of a page below the site root: 0 for the root index (served at the mount
79
87
  * root), 1 for every `/slug/` page. The generator only ever emits a flat `/slug/`
@@ -11,7 +11,7 @@ Build-time only (uses Bun.write). Shares slug + markdown rendering with the runt
11
11
  component (src/doc-system/*) so static and hydrated output agree.
12
12
  */
13
13
  import { pageTitle } from '../doc-title.js';
14
- import { buildSlugMap, pathForSlug, rewriteDocLinks } from '../routing.js';
14
+ import { buildSlugMap, pathForSlug, rewriteDocLinks, withBase, } from '../routing.js';
15
15
  import { buildNavTree, navOpenPath } from '../nav-tree.js';
16
16
  import { renderDocMarkdown, docDescription, } from '../render.js';
17
17
  const escapeAttr = (s) => s
@@ -83,11 +83,7 @@ function absUrl(baseUrl, pathOrUrl) {
83
83
  * legitimately need the real absolute origin+path. *Functional* URLs go through
84
84
  * `relativeUrl` instead, so the artifact is mount-agnostic. See `relativeUrl`.
85
85
  */
86
- function withBase(basePath, p) {
87
- if (!p || !basePath || basePath === '/' || /^(https?:)?\/\//.test(p))
88
- return p;
89
- return basePath.replace(/\/$/, '') + (p.startsWith('/') ? p : '/' + p);
90
- }
86
+ // `withBase` now lives in ../routing.ts — see the note there (#144).
91
87
  /**
92
88
  * Depth of a page below the site root: 0 for the root index (served at the mount
93
89
  * root), 1 for every `/slug/` page. The generator only ever emits a flat `/slug/`
@@ -11,3 +11,43 @@ export interface HostConfigMismatch {
11
11
  * multi-site config that serves the built directory from any of its targets is fine.
12
12
  */
13
13
  export declare function firebasePublicMismatch(firebaseJson: string, outputDir: string): HostConfigMismatch | null;
14
+ /**
15
+ * `baseUrl` is the ORIGIN; `basePath` is the mount path. Supplying the path in BOTH is
16
+ * always wrong and always silent: `generate-site` emits `baseUrl + withBase(basePath, …)`,
17
+ * so every canonical URL, `og:url` and sitemap entry carries the prefix twice.
18
+ *
19
+ * Nothing fails — `basePath` affects metadata only (#25), so the site builds, serves and
20
+ * navigates correctly with assets resolving relative. You find it by reading the emitted
21
+ * `<head>`, or from Search Console. The reporter's shipped to GitHub Pages first (#144).
22
+ *
23
+ * Warns rather than throws: the emitted site is usable, and refusing to build over a
24
+ * metadata defect would be disproportionate. But it names the exact fix, because "these two
25
+ * fields overlap" is not something a reader can act on.
26
+ */
27
+ export declare function basePathDoubling(config: {
28
+ baseUrl?: string;
29
+ basePath?: string;
30
+ }): string | undefined;
31
+ /**
32
+ * Does a custom `bundleEntry` bundle actually contain the doc system?
33
+ *
34
+ * `bundleEntry` REPLACES tosijs-ui's bundle rather than extending it, so an entry that
35
+ * imports only the adopter's own library produces a site where every page renders its
36
+ * prerendered markup and nothing else: no header, no nav, no menu, no live examples. Nothing
37
+ * fails — `docs.json`, `hydrate.js` and the HTML all serve 200, `<tosi-doc-system>` is
38
+ * present in the markup, and the adopter's own `customElements.get('their-element')` returns
39
+ * true, so the bundle looks healthy. It is inert because nothing defined `tosi-doc-system`
40
+ * (tosijs-ui#145; cost the reporter more than any other onboarding problem).
41
+ *
42
+ * Checked by looking for the tag names in the built output, which survives minification
43
+ * because `customElements.define` needs the literal string. This is the one case where
44
+ * grepping a bundle is sound — we are looking for a STRING the runtime must contain, not for
45
+ * a package path that minification erases.
46
+ *
47
+ * `liveExample` is reported separately: a corpus with no executable fences legitimately does
48
+ * not need it, so the caller decides whether its absence is worth mentioning.
49
+ */
50
+ export declare function bundleRegistrations(bundleSource: string): {
51
+ docSystem: boolean;
52
+ liveExample: boolean;
53
+ };