tosijs-ui 1.12.7 → 1.12.8

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,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.8
4
+
5
+ ### Diffs: pick a side by clicking it, and see what actually changed
6
+
7
+ Two things, and they apply to **both** diff views — the resolvable one and the read-only one
8
+ used by `<tosi-code>`'s review overlay and the doc-browser's before-save screen.
9
+
10
+ **Clicking a coloured line picks that side**, equivalent to its button. The line is what a
11
+ reader is already looking at and pointing at; reaching for a small button to say "this one"
12
+ when the thing is under the cursor was friction with nothing behind it. Dragging to select
13
+ text does **not** change anything — those lines are also what you copy from, and without that
14
+ guard, selecting a line to copy it would silently flip the resolution and rewrite the value.
15
+
16
+ **Within a changed line, the words that actually differ are marked** more strongly than the
17
+ line around them. A whole-line wash says "something here changed", which for a one-word edit
18
+ throws away most of the signal — so the line colour tells you which side you are on, and the
19
+ run tells you what moved. `diffTokens(before, after)` and `tokenRunsForLines(lines)` are
20
+ exported if you want that data without the DOM.
21
+
22
+ Word runs, not characters: a character diff of `sat` → `sprawled` marks the shared `s` and
23
+ produces confetti. Whitespace and punctuation are tokens so the runs **reassemble each input
24
+ exactly** — a diff viewer that silently drops a space misrepresents the text it exists to show
25
+ — and whitespace between two changed words is absorbed into the change, or `sat on` renders as
26
+ two boxes with a hole punched between them. Lines over 400 tokens fall back to whole-line
27
+ marking rather than locking a tab on a minified bundle pasted into a diff.
28
+
29
+ The read-only view keeps its line order exactly. Pairing is computed per run of consecutive
30
+ changed lines, so an interleaved edit is marked correctly without the reordering that the
31
+ resolvable view's block grouping does.
32
+
33
+ `tokenRunsForLines` returns a **dense** array — one entry per input line, `undefined` where a
34
+ line has no runs. Worth stating because the obvious implementation (`new Array(n)`) is sparse,
35
+ and `map`/`forEach` silently skip holes: fine for the indexed read it was written for, a trap
36
+ for anyone iterating it.
37
+
38
+ ### Generated asset URLs are build-stamped
39
+
40
+ `/doc-system.css` and the hydration bundle are now emitted with a `?v=` stamp taken from the
41
+ same build identity that writes `/version.json`, so a page cannot claim one build while
42
+ loading another's cached bundle. Stale filenames going stale is not hypothetical — it happened
43
+ on this project's own doc site and cost a round of "is this a layout regression?" before
44
+ turning out to be a cached bundle, with a hard reload as the fix nobody thinks to try.
45
+
46
+ A query rather than content-hashed filenames, deliberately: `docs/` is committed in this repo
47
+ and its siblings, so hashing would add and delete a file on every build and put churn in every
48
+ diff.
49
+
50
+ **The stamp is the project's own version**, so it moves once per release — the granularity at
51
+ which a published site actually changes — and a rebuild between releases is byte-identical. The
52
+ first attempt derived it from the commit, which was precise and unusable: it re-stamped all 68
53
+ generated pages on every commit, and because the test lanes build too, it did not terminate
54
+ (commit → build → 68 dirty → commit → build). The honest tradeoff of the version, stated rather
55
+ than left to be discovered: two builds of the same version with different code share a stamp, so
56
+ a mid-version redeploy can still serve a cached bundle. That is exactly the previous state — no
57
+ worse — and it closes the case that actually bit, a cached bundle surviving _across_ releases.
58
+
59
+ Cross-origin URLs are left alone — `scriptUrl` may point at a CDN, and busting someone else's
60
+ cache was never the point — as are URLs that already carry a query or fragment.
61
+
62
+ Consumers of `tosijs-ui/site` get this automatically; `generateSite` also takes an explicit
63
+ `assetStamp` if you are driving it directly. Leave it unset and the output is unchanged.
64
+
3
65
  ## 1.12.7
4
66
 
5
67
  ### Live-example and doc-test failures now tell you where
File without changes
package/dist/diff.d.ts CHANGED
@@ -39,6 +39,32 @@ export declare function resolveDiff(blocks: DiffBlock[], choices: DiffResolution
39
39
  * in `after`), in display order.
40
40
  */
41
41
  export declare function diffLines(before: string, after: string): DiffLine[];
42
+ /** A run of text within a changed line, flagged if it is part of what actually differs. */
43
+ export interface TokenRun {
44
+ text: string;
45
+ changed: boolean;
46
+ }
47
+ /**
48
+ * Word-level diff of two lines, as runs that reassemble each input exactly.
49
+ *
50
+ * Returns everything flagged `changed` when the lines are too long to diff cheaply, or when
51
+ * they share nothing — in both cases the containing line highlight already says it.
52
+ */
53
+ export declare function diffTokens(before: string, after: string): {
54
+ removed: TokenRun[];
55
+ added: TokenRun[];
56
+ };
57
+ /**
58
+ * Token runs for each line of a FLAT line diff, pairing within each run of changed lines
59
+ * without reordering anything.
60
+ *
61
+ * The resolvable view groups changes into blocks, which reorders an interleaved edit
62
+ * (`-a +A -b +B` becomes `-a -b +A +B`) — better to resolve, but a changed rendering. The
63
+ * read-only view must keep its order exactly, and it turns out it does not have to give
64
+ * anything up for that: pairing is computed per run of consecutive non-context lines, and the
65
+ * lines are still emitted where they already were. Same marking, same order.
66
+ */
67
+ export declare function tokenRunsForLines(lines: DiffLine[]): Array<TokenRun[] | undefined>;
42
68
  interface DiffParts extends PartsMap {
43
69
  body: HTMLElement;
44
70
  }
@@ -68,6 +94,11 @@ export declare class TosiDiff extends Component<DiffParts> {
68
94
  whiteSpace: string;
69
95
  wordBreak: string;
70
96
  };
97
+ '.diff-line .line-text': {
98
+ minWidth: string;
99
+ whiteSpace: string;
100
+ wordBreak: string;
101
+ };
71
102
  '.diff-line .marker': {
72
103
  textAlign: string;
73
104
  userSelect: string;
@@ -111,6 +142,22 @@ export declare class TosiDiff extends Component<DiffParts> {
111
142
  background: string;
112
143
  color: string;
113
144
  };
145
+ '.diff-line .text.changed': {
146
+ borderRadius: string;
147
+ padding: string;
148
+ };
149
+ '.diff-add .text.changed': {
150
+ background: string;
151
+ };
152
+ '.diff-remove .text.changed': {
153
+ background: string;
154
+ };
155
+ '.diff-line[data-choice]': {
156
+ cursor: string;
157
+ };
158
+ '.diff-line[data-choice]:hover': {
159
+ filter: string;
160
+ };
114
161
  '.diff-line.not-chosen': {
115
162
  opacity: string;
116
163
  textDecoration: string;
package/dist/diff.js CHANGED
@@ -78,6 +78,18 @@ test('rejecting everything yields the original text exactly', () => {
78
78
  })
79
79
  ```
80
80
 
81
+ **Click the coloured text itself** to pick a side — the line is the affordance, so reaching for
82
+ a small button to say "this one" when the thing is right under the cursor is friction with
83
+ nothing behind it. Dragging to select text does *not* change anything: those lines are also
84
+ what you copy from.
85
+
86
+ Within a changed line, **the words that actually differ** are marked more strongly than the line
87
+ around them. A whole-line wash says "something here changed", which for a one-word edit throws
88
+ away most of the signal — so the line colour tells you which side you are on, and the run tells
89
+ you what moved. `diffTokens(before, after)` is exported if you want that data directly; its runs
90
+ reassemble each input exactly, and it falls back to marking the whole line for lines too long to
91
+ diff cheaply.
92
+
81
93
  The unit of decision is a **change block**, not a line: a multi-line edit is one choice,
82
94
  because accepting half of one produces text neither side wrote. Two more exports give you
83
95
  the same model without the DOM — `diffBlocks(diffLines(a, b))` for the blocks, and
@@ -184,6 +196,156 @@ export function diffLines(before, after) {
184
196
  }
185
197
  return out;
186
198
  }
199
+ /*
200
+ Tokens, not characters.
201
+
202
+ A character-level diff of "sat" → "sprawled" marks the shared `s` and produces confetti; word
203
+ runs give a highlight a reader can actually use. Whitespace and punctuation are their own
204
+ tokens so the runs REASSEMBLE the original line exactly — a diff viewer that silently drops a
205
+ space is worse than one that highlights nothing, and the tests assert that reconstruction.
206
+ */
207
+ const TOKENS = /(\s+|[A-Za-z0-9_$]+|[^\sA-Za-z0-9_$]+)/g;
208
+ /*
209
+ Above this many tokens on either side, skip the intra-line pass and mark the whole line.
210
+
211
+ The LCS below is O(n·m), and a minified bundle pasted into a diff is one line with tens of
212
+ thousands of tokens — which would lock the tab to draw a highlight nobody can read at that
213
+ size anyway. Bailing keeps the WHOLE-line rendering, which is the previous behaviour, so the
214
+ cap degrades rather than breaks.
215
+ */
216
+ const MAX_TOKENS = 400;
217
+ /**
218
+ * Word-level diff of two lines, as runs that reassemble each input exactly.
219
+ *
220
+ * Returns everything flagged `changed` when the lines are too long to diff cheaply, or when
221
+ * they share nothing — in both cases the containing line highlight already says it.
222
+ */
223
+ export function diffTokens(before, after) {
224
+ const a = before.match(TOKENS) ?? [];
225
+ const b = after.match(TOKENS) ?? [];
226
+ const whole = () => ({
227
+ removed: before ? [{ text: before, changed: true }] : [],
228
+ added: after ? [{ text: after, changed: true }] : [],
229
+ });
230
+ if (a.length > MAX_TOKENS || b.length > MAX_TOKENS)
231
+ return whole();
232
+ const m = a.length;
233
+ const n = b.length;
234
+ const lcs = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
235
+ for (let i = m - 1; i >= 0; i -= 1) {
236
+ for (let j = n - 1; j >= 0; j -= 1) {
237
+ lcs[i][j] =
238
+ a[i] === b[j]
239
+ ? lcs[i + 1][j + 1] + 1
240
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
241
+ }
242
+ }
243
+ const removed = [];
244
+ const added = [];
245
+ // Coalesce adjacent tokens of the same kind so the DOM is runs, not one span per word.
246
+ const push = (out, text, changed) => {
247
+ const last = out[out.length - 1];
248
+ if (last && last.changed === changed)
249
+ last.text += text;
250
+ else
251
+ out.push({ text, changed });
252
+ };
253
+ let i = 0;
254
+ let j = 0;
255
+ while (i < m && j < n) {
256
+ if (a[i] === b[j]) {
257
+ push(removed, a[i], false);
258
+ push(added, b[j], false);
259
+ i += 1;
260
+ j += 1;
261
+ }
262
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
263
+ push(removed, a[i], true);
264
+ i += 1;
265
+ }
266
+ else {
267
+ push(added, b[j], true);
268
+ j += 1;
269
+ }
270
+ }
271
+ while (i < m)
272
+ push(removed, a[i++], true);
273
+ while (j < n)
274
+ push(added, b[j++], true);
275
+ return { removed: bridgeGaps(removed), added: bridgeGaps(added) };
276
+ }
277
+ /*
278
+ A space BETWEEN two changed words belongs to the change.
279
+
280
+ `sat on` → `sprawled across` shares its inner space, so the LCS calls it common and the
281
+ highlight comes out as two boxes with a hole punched between them — which reads as two
282
+ unrelated edits rather than one phrase. Whitespace flanked by changed runs on both sides is
283
+ absorbed, and the runs re-coalesce so it renders as a single span.
284
+ */
285
+ function bridgeGaps(runs) {
286
+ for (let k = 1; k < runs.length - 1; k += 1) {
287
+ if (!runs[k].changed &&
288
+ /^\s+$/.test(runs[k].text) &&
289
+ runs[k - 1].changed &&
290
+ runs[k + 1].changed) {
291
+ runs[k].changed = true;
292
+ }
293
+ }
294
+ const out = [];
295
+ for (const run of runs) {
296
+ const last = out[out.length - 1];
297
+ if (last && last.changed === run.changed)
298
+ last.text += run.text;
299
+ else
300
+ out.push({ ...run });
301
+ }
302
+ return out;
303
+ }
304
+ /**
305
+ * Token runs for each line of a FLAT line diff, pairing within each run of changed lines
306
+ * without reordering anything.
307
+ *
308
+ * The resolvable view groups changes into blocks, which reorders an interleaved edit
309
+ * (`-a +A -b +B` becomes `-a -b +A +B`) — better to resolve, but a changed rendering. The
310
+ * read-only view must keep its order exactly, and it turns out it does not have to give
311
+ * anything up for that: pairing is computed per run of consecutive non-context lines, and the
312
+ * lines are still emitted where they already were. Same marking, same order.
313
+ */
314
+ export function tokenRunsForLines(lines) {
315
+ /*
316
+ `.fill(undefined)` makes this DENSE. A bare `new Array(n)` is sparse, and `map`/`forEach`
317
+ silently skip holes — fine for the indexed read this function was written for, a trap for
318
+ anyone who iterates the result, which an exported function invites.
319
+ */
320
+ const out = new Array(lines.length).fill(undefined);
321
+ let i = 0;
322
+ while (i < lines.length) {
323
+ if (lines[i].op === 'context') {
324
+ i += 1;
325
+ continue;
326
+ }
327
+ let j = i;
328
+ while (j < lines.length && lines[j].op !== 'context')
329
+ j += 1;
330
+ const removes = [];
331
+ const adds = [];
332
+ for (let k = i; k < j; k += 1) {
333
+ ;
334
+ (lines[k].op === 'remove' ? removes : adds).push(k);
335
+ }
336
+ // k-th removed line against the k-th added line; surplus on either side is wholly
337
+ // added or removed, which the line highlight already says.
338
+ for (let k = 0; k < Math.min(removes.length, adds.length); k += 1) {
339
+ const before = lines[removes[k]].text;
340
+ const after = lines[adds[k]].text;
341
+ const { removed, added } = diffTokens(before, after);
342
+ out[removes[k]] = removed;
343
+ out[adds[k]] = added;
344
+ }
345
+ i = j;
346
+ }
347
+ return out;
348
+ }
187
349
  const MARKER = { context: ' ', add: '+', remove: '-' };
188
350
  export class TosiDiff extends Component {
189
351
  static preferredTagName = 'tosi-diff';
@@ -234,6 +396,11 @@ export class TosiDiff extends Component {
234
396
  whiteSpace: 'pre-wrap',
235
397
  wordBreak: 'break-word',
236
398
  },
399
+ '.diff-line .line-text': {
400
+ minWidth: '0',
401
+ whiteSpace: 'pre-wrap',
402
+ wordBreak: 'break-word',
403
+ },
237
404
  '.diff-line .marker': {
238
405
  textAlign: 'center',
239
406
  userSelect: 'none',
@@ -293,6 +460,31 @@ export class TosiDiff extends Component {
293
460
  */
294
461
  color: varDefault.tosiDiffChoiceSelectedColor(varDefault.tosiAccentText('#fff')),
295
462
  },
463
+ /*
464
+ The runs that ACTUALLY differ, distinct from the line that contains them.
465
+
466
+ A whole-line wash says "something here changed" — which for a one-word edit is most of the
467
+ signal thrown away. These are a second, stronger tint of the SAME hue, so the two read as
468
+ one system rather than two, and they sit on top of the line colour rather than replacing
469
+ it: the line still says which side you are on, the run says what moved.
470
+ */
471
+ '.diff-line .text.changed': {
472
+ borderRadius: varDefault.tosiDiffRunRadius('2px'),
473
+ padding: '0 1px',
474
+ },
475
+ '.diff-add .text.changed': {
476
+ background: varDefault.tosiDiffAddRunBg('color-mix(in srgb, #22c55e 38%, transparent)'),
477
+ },
478
+ '.diff-remove .text.changed': {
479
+ background: varDefault.tosiDiffRemoveRunBg('color-mix(in srgb, #ef4444 38%, transparent)'),
480
+ },
481
+ // The coloured lines pick a side, so say so on hover.
482
+ '.diff-line[data-choice]': {
483
+ cursor: 'pointer',
484
+ },
485
+ '.diff-line[data-choice]:hover': {
486
+ filter: 'brightness(1.06)',
487
+ },
296
488
  '.diff-line.not-chosen': {
297
489
  opacity: '0.4',
298
490
  textDecoration: 'line-through',
@@ -371,13 +563,29 @@ export class TosiDiff extends Component {
371
563
  only one of the two that survives the boundary, which makes it the right tool here even
372
564
  though `target.closest()` is the correct idiom for a light-DOM component.
373
565
  */
374
- const button = event
566
+ const picked = event
375
567
  .composedPath()
376
- .find((node) => node instanceof HTMLElement && node.matches('button[data-hunk]'));
377
- if (button === undefined)
568
+ .find((node) => node instanceof HTMLElement &&
569
+ node.matches('[data-hunk][data-choice]'));
570
+ if (picked === undefined)
571
+ return;
572
+ /*
573
+ A click that ENDS A SELECTION is not a choice.
574
+
575
+ The coloured lines are now clickable, and they are also the text someone drags across to
576
+ copy — so without this, selecting a line to copy it silently flips the resolution and
577
+ rewrites the value. Checked against the shadow root's own selection where available:
578
+ `document.getSelection()` reports collapsed for a selection made inside a shadow tree.
579
+ */
580
+ const root = this.shadowRoot;
581
+ const selection = root?.getSelection?.() ?? document.getSelection();
582
+ if (selection &&
583
+ !selection.isCollapsed &&
584
+ selection.toString().length > 0) {
378
585
  return;
379
- const index = Number(button.dataset.hunk);
380
- const choice = button.dataset.choice;
586
+ }
587
+ const index = Number(picked.dataset.hunk);
588
+ const choice = picked.dataset.choice;
381
589
  const choices = this.resolutions;
382
590
  if (choices[index] === choice)
383
591
  return;
@@ -385,10 +593,36 @@ export class TosiDiff extends Component {
385
593
  this.queueRender();
386
594
  this.dispatchEvent(new Event('change'));
387
595
  };
388
- lineElement(op, text, chosen = true) {
389
- return div({ class: `diff-line diff-${op}${chosen ? '' : ' not-chosen'}` }, span({ class: 'marker' }, MARKER[op]),
596
+ lineElement(op, text, chosen = true, runs, hunk) {
597
+ /*
598
+ `data-hunk`/`data-choice` on the LINE as well as the buttons, so clicking the coloured
599
+ text picks that side. The line IS the affordance — it is what the reader is already
600
+ looking at, and reaching for a small button to say "this one" when the thing is right
601
+ there under the cursor is friction with nothing behind it.
602
+ */
603
+ const pickable = hunk !== undefined && (op === 'add' || op === 'remove')
604
+ ? {
605
+ 'data-hunk': String(hunk),
606
+ 'data-choice': op === 'remove' ? 'original' : 'modified',
607
+ }
608
+ : {};
609
+ /*
610
+ ONE grid child for the text, always — the runs go INSIDE it.
611
+
612
+ `.diff-line` is `grid-template-columns: 1.5em 1fr`, so emitting a span per run put the
613
+ surplus into implicit ROWS: every word stacked vertically down the 1.5em marker column,
614
+ one character wide. Same failure as #102, where a mismatch between track count and cell
615
+ count auto-placed the extras into content-sized implicit tracks. A wrapper pins the text
616
+ to column two no matter how many runs it contains.
617
+ */
390
618
  // Non-breaking fallback so empty lines keep their row height.
391
- span({ class: 'text' }, text === '' ? ' ' : text));
619
+ const body = runs !== undefined && runs.length > 0
620
+ ? runs.map((run) => span({ class: run.changed ? 'text changed' : 'text' }, run.text))
621
+ : [span({ class: 'text' }, text === '' ? ' ' : text)];
622
+ return div({
623
+ class: `diff-line diff-${op}${chosen ? '' : ' not-chosen'}`,
624
+ ...pickable,
625
+ }, span({ class: 'marker' }, MARKER[op]), span({ class: 'line-text' }, ...body));
392
626
  }
393
627
  render() {
394
628
  super.render();
@@ -400,7 +634,14 @@ export class TosiDiff extends Component {
400
634
  */
401
635
  if (!this.resolvable) {
402
636
  const lines = diffLines(this.original, this.modified);
403
- this.parts.body.replaceChildren(...lines.map((line) => this.lineElement(line.op, line.text)));
637
+ /*
638
+ Intra-line marking applies here too. It was withheld at first on the "additive means
639
+ additive" rule, but that rule was about the BLOCK GROUPING, which reorders an interleaved
640
+ edit — marking reorders nothing. The lines, their order and their count are untouched;
641
+ the only difference is that the words which actually differ are now marked inside them.
642
+ */
643
+ const runs = tokenRunsForLines(lines);
644
+ this.parts.body.replaceChildren(...lines.map((line, k) => this.lineElement(line.op, line.text, true, runs[k])));
404
645
  return;
405
646
  }
406
647
  let changeIndex = -1;
@@ -420,7 +661,18 @@ export class TosiDiff extends Component {
420
661
  'data-choice': value,
421
662
  ariaPressed: String(choice === value),
422
663
  }, label);
423
- return div({ class: 'diff-hunk', onClick: this.choose }, div({ class: 'diff-choices' }, pick(this.originalLabel, 'original'), pick(this.modifiedLabel, 'modified')), ...block.removed.map((text) => this.lineElement('remove', text, choice === 'original')), ...block.added.map((text) => this.lineElement('add', text, choice === 'modified')));
664
+ return div({ class: 'diff-hunk', onClick: this.choose }, div({ class: 'diff-choices' }, pick(this.originalLabel, 'original'), pick(this.modifiedLabel, 'modified')),
665
+ /*
666
+ Pair the two sides INDEX-WISE for the intra-line pass. An edit to one line is the
667
+ overwhelmingly common shape, and a pair is the only case where "what actually
668
+ changed" is a meaningful question — where the counts differ, the surplus lines are
669
+ wholly added or removed, which the line highlight already says.
670
+ */
671
+ ...block.removed.map((text, k) => this.lineElement('remove', text, choice === 'original', block.added[k] === undefined
672
+ ? undefined
673
+ : diffTokens(text, block.added[k]).removed, index)), ...block.added.map((text, k) => this.lineElement('add', text, choice === 'modified', block.removed[k] === undefined
674
+ ? undefined
675
+ : diffTokens(block.removed[k], text).added, index)));
424
676
  }));
425
677
  }
426
678
  }
@@ -38,6 +38,20 @@ export interface GenerateSiteConfig {
38
38
  docsUrl?: string;
39
39
  /** path to the IIFE bundle script (default /iife.js) — the CDN/classic-script path */
40
40
  scriptUrl?: string;
41
+ /**
42
+ * Cache-busting stamp appended to generated asset URLs as `?v=`.
43
+ *
44
+ * Stable filenames go stale: a CDN or browser cache can serve yesterday's `hydrate.js`
45
+ * against today's HTML, and the site then looks broken in a way that reproduces nowhere
46
+ * else — the fix being a hard reload nobody thinks to try. Content-hashed FILENAMES would
47
+ * also solve it, and are the wrong trade here: `docs/` is committed in this repo and its
48
+ * siblings, so hashing would add and delete a file on every build and put churn in every
49
+ * diff. A query keeps the filenames stable.
50
+ *
51
+ * Must be DETERMINISTIC per commit, not per build, for the same reason — see build-stamp.ts.
52
+ * Left unset, nothing is appended and the output is exactly as before.
53
+ */
54
+ assetStamp?: string;
41
55
  /**
42
56
  * path to an ESM hydration bundle. When set, pages load THIS as a
43
57
  * `<script type="module">` instead of the classic IIFE `scriptUrl`, so
@@ -76,4 +90,5 @@ export declare function pageDepth(slug: string): number;
76
90
  * origin.) See issue #25; the runtime/SPA-navigation half is issue #16.
77
91
  */
78
92
  export declare function relativeUrl(depth: number, p: string): string;
93
+ export declare function withStamp(url: string, stamp?: string): string;
79
94
  export declare function generateSite(config: GenerateSiteConfig): Promise<number>;
@@ -114,7 +114,7 @@ export function relativeUrl(depth, p) {
114
114
  return rel === '' ? './' : rel;
115
115
  }
116
116
  function pageHtml(doc, config, slugMap, configAttr) {
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;
117
+ const { projectName = '', baseUrl = '', lang = 'en', favicon = '/favicon.svg', docsUrl = '/docs.json', scriptUrl = '/iife.js', hydrateUrl, stylesUrl = '/doc-system.css', assetStamp, localizedUrl = '/localized-strings.txt', basePath, headExtra = '', bakes, } = config;
118
118
  // Functional URLs are emitted relative to THIS page's depth so the build is
119
119
  // mount-agnostic (issue #25); metadata URLs below stay absolute via withBase.
120
120
  const depth = pageDepth(slugMap[doc.filename] ?? '');
@@ -169,7 +169,7 @@ function pageHtml(doc, config, slugMap, configAttr) {
169
169
  ' <link rel="preconnect" href="https://fonts.googleapis.com" />',
170
170
  ' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />',
171
171
  // Burned-in theme: styles the page with no JS and with zero flash on hydration.
172
- ` <link rel="stylesheet" href="${escapeAttr(relativeUrl(depth, stylesUrl))}" data-tosi-doc-system />`,
172
+ ` <link rel="stylesheet" href="${escapeAttr(withStamp(relativeUrl(depth, stylesUrl), assetStamp))}" data-tosi-doc-system />`,
173
173
  ` <title>${escapeText(title)}</title>`,
174
174
  description
175
175
  ? ` <meta name="description" content="${escapeAttr(description)}" />`
@@ -240,12 +240,28 @@ ${nav}
240
240
  ${navbar}
241
241
  </tosi-doc-system>
242
242
  ${hydrateUrl
243
- ? `<script type="module" src="${escapeAttr(relativeUrl(depth, hydrateUrl))}"></script>`
244
- : `<script src="${escapeAttr(relativeUrl(depth, scriptUrl))}"></script>`}
243
+ ? `<script type="module" src="${escapeAttr(withStamp(relativeUrl(depth, hydrateUrl), assetStamp))}"></script>`
244
+ : `<script src="${escapeAttr(withStamp(relativeUrl(depth, scriptUrl), assetStamp))}"></script>`}
245
245
  </body>
246
246
  </html>
247
247
  `;
248
248
  }
249
+ /*
250
+ Append the build stamp to a SAME-ORIGIN asset URL.
251
+
252
+ Only same-origin: `scriptUrl` may legitimately point at a CDN, and appending a query to
253
+ someone else's URL can miss their cache key or, worse, be rejected. A URL that already carries
254
+ a query is left alone — the caller has said something deliberate about it.
255
+ */
256
+ export function withStamp(url, stamp) {
257
+ if (!stamp)
258
+ return url;
259
+ if (url.includes('?') || url.includes('#'))
260
+ return url;
261
+ if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(url))
262
+ return url;
263
+ return `${url}?v=${encodeURIComponent(stamp)}`;
264
+ }
249
265
  export async function generateSite(config) {
250
266
  const { docs, outputDir } = config;
251
267
  const slugMap = buildSlugMap(docs);
@@ -789,34 +789,10 @@ export async function buildSite(config, opts = {}) {
789
789
  // Generate the static, pre-rendered doc site (one /slug/index.html per doc).
790
790
  // Runs after the static-asset copy so the generated index.html (README) wins,
791
791
  // and after the bundle copy so every page's <script src> resolves.
792
- const docs = JSON.parse(await Bun.file(DOCS_JSON).text());
793
- const pageCount = await generateSite({
794
- docs,
795
- outputDir: PUBLIC,
796
- projectName: config.name,
797
- description: config.description,
798
- baseUrl: config.baseUrl,
799
- lang: config.lang,
800
- projectLinks: config.projectLinks,
801
- logo: config.logo,
802
- navbarLinks: config.navbarLinks,
803
- localizedStrings: config.localizedStrings,
804
- favicon: config.favicon,
805
- ogImage: config.ogImage,
806
- // When set, pages load this as a `<script type="module">` (editor lazy-split)
807
- // instead of the classic IIFE. See the ESM hydration bundle above.
808
- hydrateUrl: hydrateName ? `/${hydrateName}` : undefined,
809
- bakes: exampleBakes,
810
- headExtra: [config.headExtra, tjsHead, importResolverHead]
811
- .filter(Boolean)
812
- .join('') || undefined,
813
- scriptUrl: config.scriptUrl,
814
- basePath: config.basePath,
815
- });
816
792
  // Build identity at /version.json — "what am I looking at?" for any deployed copy.
817
793
  // Deterministic (commit-derived, no wall clock) so a committed outputDir doesn't
818
794
  // churn on every build. See build-stamp.ts.
819
- await Bun.write(`${PUBLIC}/version.json`, serializeBuildStamp(await gatherBuildStamp({
795
+ const buildStamp = await gatherBuildStamp({
820
796
  // Read package.json rather than importing the generated src/version.ts —
821
797
  // that import put a generated file in `bun --watch`'s module graph while
822
798
  // prebuild rewrote it every build, i.e. a rebuild loop (899 restarts in
@@ -838,7 +814,70 @@ export async function buildSite(config, opts = {}) {
838
814
  .then((p) => p.version ?? 'unknown')
839
815
  .catch(() => 'unknown'),
840
816
  site: config.name,
841
- })));
817
+ });
818
+ /*
819
+ One stamp, two uses: `/version.json` answers "what am I looking at?", and the same value
820
+ busts asset caches as `?v=`. Derived once so they cannot disagree — a page claiming one
821
+ build while loading another's bundle is the exact confusion this is meant to end.
822
+
823
+ Commit-derived, so a rebuild at the same commit produces byte-identical output and a
824
+ committed `docs/` does not churn. Falls back to the generator version when git is
825
+ unavailable (a consumer may not be in a repo at all), which still busts on upgrade.
826
+ */
827
+ /*
828
+ Stamp from the PROJECT'S VERSION, not its commit.
829
+
830
+ A commit-derived stamp re-stamps every generated page on every commit, and `docs/` is
831
+ committed here and in the sibling projects — so a 68-file diff appeared on each build,
832
+ and it did not terminate: the test lanes build too, so commit → build → dirty → commit.
833
+ That is the same non-terminating loop this repo already documents for release
834
+ annotations, and it is worse than the staleness it was added to fix.
835
+
836
+ A version changes once per release, which is the granularity a published site actually
837
+ changes at, and it leaves a rebuild byte-identical between releases.
838
+
839
+ The tradeoff, stated rather than discovered later: two builds of the SAME version with
840
+ different code carry the same stamp, so a mid-version redeploy can still serve a cached
841
+ bundle. That is no worse than having no stamp at all, which was the previous state, and
842
+ it closes the case that actually bit — a cached bundle surviving across releases.
843
+
844
+ Read from the CONSUMER's cwd, deliberately: this must be the version of the site being
845
+ built, not tosijs-ui's. (`generator` is read from this module's own package.json for the
846
+ opposite reason — see the note below.) Falls back to the generator version, then the
847
+ commit, so a project without a version still gets something that moves.
848
+ */
849
+ const assetStamp = (await Bun.file(`${process.cwd()}/package.json`)
850
+ .json()
851
+ .then((p) => p.version)
852
+ .catch(() => undefined)) ??
853
+ buildStamp.generator ??
854
+ buildStamp.commit;
855
+ const docs = JSON.parse(await Bun.file(DOCS_JSON).text());
856
+ const pageCount = await generateSite({
857
+ docs,
858
+ outputDir: PUBLIC,
859
+ projectName: config.name,
860
+ description: config.description,
861
+ baseUrl: config.baseUrl,
862
+ lang: config.lang,
863
+ projectLinks: config.projectLinks,
864
+ logo: config.logo,
865
+ navbarLinks: config.navbarLinks,
866
+ localizedStrings: config.localizedStrings,
867
+ favicon: config.favicon,
868
+ ogImage: config.ogImage,
869
+ // When set, pages load this as a `<script type="module">` (editor lazy-split)
870
+ // instead of the classic IIFE. See the ESM hydration bundle above.
871
+ hydrateUrl: hydrateName ? `/${hydrateName}` : undefined,
872
+ bakes: exampleBakes,
873
+ headExtra: [config.headExtra, tjsHead, importResolverHead]
874
+ .filter(Boolean)
875
+ .join('') || undefined,
876
+ scriptUrl: config.scriptUrl,
877
+ basePath: config.basePath,
878
+ assetStamp,
879
+ });
880
+ await Bun.write(`${PUBLIC}/version.json`, serializeBuildStamp(buildStamp));
842
881
  // Burn the theme into a static stylesheet (separate subprocess — see
843
882
  // generate-css.ts). Resolve the sibling relative to THIS module so it works
844
883
  // both in-repo (.ts) and when shipped (compiled .js).