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 +62 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/diff.d.ts +47 -0
- package/dist/diff.js +262 -10
- package/dist/doc-system/site/generate-site.d.ts +15 -0
- package/dist/doc-system/site/generate-site.js +20 -4
- package/dist/doc-system/site/orchestrator.js +65 -26
- package/dist/iife.js +76 -76
- package/dist/iife.js.map +4 -4
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
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
|
|
566
|
+
const picked = event
|
|
375
567
|
.composedPath()
|
|
376
|
-
.find((node) => node instanceof HTMLElement &&
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')),
|
|
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
|
-
|
|
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).
|