@ascribed/review 0.1.2-next.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/LICENSE +373 -0
- package/README.md +183 -0
- package/dist/github/index.d.ts +7 -0
- package/dist/github/index.js +8 -0
- package/dist/github/index.js.map +1 -0
- package/dist/github/marker.d.ts +63 -0
- package/dist/github/marker.js +156 -0
- package/dist/github/marker.js.map +1 -0
- package/dist/github/queries.d.ts +16 -0
- package/dist/github/queries.js +174 -0
- package/dist/github/queries.js.map +1 -0
- package/dist/github/read.d.ts +84 -0
- package/dist/github/read.js +141 -0
- package/dist/github/read.js.map +1 -0
- package/dist/github/repository.d.ts +30 -0
- package/dist/github/repository.js +77 -0
- package/dist/github/repository.js.map +1 -0
- package/dist/github/requests.d.ts +65 -0
- package/dist/github/requests.js +274 -0
- package/dist/github/requests.js.map +1 -0
- package/dist/github/session.d.ts +143 -0
- package/dist/github/session.js +581 -0
- package/dist/github/session.js.map +1 -0
- package/dist/github/transport.d.ts +33 -0
- package/dist/github/transport.js +206 -0
- package/dist/github/transport.js.map +1 -0
- package/dist/marks/index.d.ts +92 -0
- package/dist/marks/index.js +766 -0
- package/dist/marks/index.js.map +1 -0
- package/dist/overlay/index.d.ts +4 -0
- package/dist/overlay/index.js +7 -0
- package/dist/overlay/index.js.map +1 -0
- package/dist/overlay/markdown.d.ts +4 -0
- package/dist/overlay/markdown.js +368 -0
- package/dist/overlay/markdown.js.map +1 -0
- package/dist/overlay/overlay.d.ts +47 -0
- package/dist/overlay/overlay.js +1710 -0
- package/dist/overlay/overlay.js.map +1 -0
- package/dist/overlay/style.d.ts +1 -0
- package/dist/overlay/style.js +570 -0
- package/dist/overlay/style.js.map +1 -0
- package/dist/overlay/text.d.ts +33 -0
- package/dist/overlay/text.js +200 -0
- package/dist/overlay/text.js.map +1 -0
- package/dist/overlay/types.d.ts +60 -0
- package/dist/overlay/types.js +2 -0
- package/dist/overlay/types.js.map +1 -0
- package/dist/place/anchor.d.ts +23 -0
- package/dist/place/anchor.js +49 -0
- package/dist/place/anchor.js.map +1 -0
- package/dist/place/index.d.ts +4 -0
- package/dist/place/index.js +6 -0
- package/dist/place/index.js.map +1 -0
- package/dist/place/lines.d.ts +56 -0
- package/dist/place/lines.js +280 -0
- package/dist/place/lines.js.map +1 -0
- package/dist/place/place.d.ts +77 -0
- package/dist/place/place.js +182 -0
- package/dist/place/place.js.map +1 -0
- package/dist/shared/errors.d.ts +40 -0
- package/dist/shared/errors.js +22 -0
- package/dist/shared/errors.js.map +1 -0
- package/dist/shared/git.d.ts +4 -0
- package/dist/shared/git.js +29 -0
- package/dist/shared/git.js.map +1 -0
- package/dist/shared/types.d.ts +56 -0
- package/dist/shared/types.js +3 -0
- package/dist/shared/types.js.map +1 -0
- package/package.json +61 -0
- package/src/marks/marks.css +361 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
// @ascribed/review/marks: marks what changed on a rendered page, in the
|
|
2
|
+
// browser.
|
|
3
|
+
//
|
|
4
|
+
// The page is Ascribe's site output rendered with source anchors (the
|
|
5
|
+
// site-render contract, §7): every block's element carries
|
|
6
|
+
// `data-ascribe-source`, and `data-ascribe-via` when it came through
|
|
7
|
+
// includes. The changes are `ascribe diff --format json`'s, for that page.
|
|
8
|
+
// `markChanges` applies them to the page's elements; `marks.css` (exported
|
|
9
|
+
// as `@ascribed/review/marks.css`) draws them, and `setShow` switches between
|
|
10
|
+
// the changes, the page as it will be, and the page as it was.
|
|
11
|
+
//
|
|
12
|
+
// Everything the marks add is an element with `data-ascribe-ui`, and every
|
|
13
|
+
// element they change gets `data-ascribe-change`, so `clearMarks` can put the
|
|
14
|
+
// page back as it was rendered.
|
|
15
|
+
const LABELS = {
|
|
16
|
+
added: "Added",
|
|
17
|
+
changed: "Changed",
|
|
18
|
+
removed: "Removed",
|
|
19
|
+
moved: "Moved",
|
|
20
|
+
};
|
|
21
|
+
/** Blocks a label can start, inline, as their first child. */
|
|
22
|
+
const LABEL_INSIDE = new Set([
|
|
23
|
+
"p",
|
|
24
|
+
"h1",
|
|
25
|
+
"h2",
|
|
26
|
+
"h3",
|
|
27
|
+
"h4",
|
|
28
|
+
"h5",
|
|
29
|
+
"h6",
|
|
30
|
+
"li",
|
|
31
|
+
"dt",
|
|
32
|
+
"dd",
|
|
33
|
+
"summary",
|
|
34
|
+
"td",
|
|
35
|
+
"th",
|
|
36
|
+
// A tab's panel: before it would put the label among the tab list's panels.
|
|
37
|
+
"ascribe-tab",
|
|
38
|
+
]);
|
|
39
|
+
/** Parses `<path>:<first>-<last>`, splitting at the last `:`. */
|
|
40
|
+
export function parseSource(source) {
|
|
41
|
+
const colon = source.lastIndexOf(":");
|
|
42
|
+
if (colon < 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
const match = /^(\d+)-(\d+)$/.exec(source.slice(colon + 1));
|
|
45
|
+
if (!match)
|
|
46
|
+
return undefined;
|
|
47
|
+
let path = source.slice(0, colon);
|
|
48
|
+
try {
|
|
49
|
+
path = decodeURIComponent(path);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Not percent-encoded as the grammar says; show it as it is.
|
|
53
|
+
}
|
|
54
|
+
return { path, first: Number(match[1]), last: Number(match[2]) };
|
|
55
|
+
}
|
|
56
|
+
/** `guides/install.md:12`, or `guides/install.md:12-14`, and its includes. */
|
|
57
|
+
export function describeSource(source, via) {
|
|
58
|
+
const parsed = parseSource(source);
|
|
59
|
+
let text = source;
|
|
60
|
+
if (parsed) {
|
|
61
|
+
const lines = parsed.first === parsed.last ? `${parsed.first}` : `${parsed.first}-${parsed.last}`;
|
|
62
|
+
text = `${parsed.path}:${lines}`;
|
|
63
|
+
}
|
|
64
|
+
if (via) {
|
|
65
|
+
const outer = via.split(" ").map((v) => {
|
|
66
|
+
const colon = v.lastIndexOf(":");
|
|
67
|
+
if (colon < 0)
|
|
68
|
+
return v;
|
|
69
|
+
try {
|
|
70
|
+
return `${decodeURIComponent(v.slice(0, colon))}${v.slice(colon)}`;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return v;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
text += ` via ${outer.join(", ")}`;
|
|
77
|
+
}
|
|
78
|
+
return text;
|
|
79
|
+
}
|
|
80
|
+
function viaOf(element) {
|
|
81
|
+
return element.getAttribute("data-ascribe-via") ?? "";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The element of a block: the one with exactly its anchor, or, when there's
|
|
85
|
+
* none (a paragraph in a tight list item has no element of its own), the
|
|
86
|
+
* smallest anchored element with the same file and includes whose lines
|
|
87
|
+
* contain it.
|
|
88
|
+
*/
|
|
89
|
+
export function findBlock(root, anchor) {
|
|
90
|
+
const via = anchor.via.join(" ");
|
|
91
|
+
const anchored = Array.from(root.querySelectorAll("[data-ascribe-source]"));
|
|
92
|
+
const exact = anchored.find((el) => el.getAttribute("data-ascribe-source") === anchor.source && viaOf(el) === via);
|
|
93
|
+
if (exact)
|
|
94
|
+
return exact;
|
|
95
|
+
const want = parseSource(anchor.source);
|
|
96
|
+
if (!want)
|
|
97
|
+
return null;
|
|
98
|
+
let best = null;
|
|
99
|
+
let bestSpan = Infinity;
|
|
100
|
+
for (const el of anchored) {
|
|
101
|
+
if (viaOf(el) !== via)
|
|
102
|
+
continue;
|
|
103
|
+
const has = parseSource(el.getAttribute("data-ascribe-source") ?? "");
|
|
104
|
+
if (!has || has.path !== want.path)
|
|
105
|
+
continue;
|
|
106
|
+
if (has.first > want.first || has.last < want.last)
|
|
107
|
+
continue;
|
|
108
|
+
const span = has.last - has.first;
|
|
109
|
+
if (span < bestSpan) {
|
|
110
|
+
best = el;
|
|
111
|
+
bestSpan = span;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
function ui(doc, tag, className, text) {
|
|
117
|
+
const el = doc.createElement(tag);
|
|
118
|
+
el.className = className;
|
|
119
|
+
el.setAttribute("data-ascribe-ui", "");
|
|
120
|
+
if (text !== undefined)
|
|
121
|
+
el.textContent = text;
|
|
122
|
+
return el;
|
|
123
|
+
}
|
|
124
|
+
/** A label, inside the block at its start, or just before it. */
|
|
125
|
+
function addLabel(element, kind) {
|
|
126
|
+
const doc = element.ownerDocument;
|
|
127
|
+
const label = ui(doc, "span", "ascribe-label", LABELS[kind]);
|
|
128
|
+
label.setAttribute("data-ascribe-label", kind);
|
|
129
|
+
if (LABEL_INSIDE.has(element.localName)) {
|
|
130
|
+
element.prepend(label);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
label.classList.add("ascribe-label-before");
|
|
134
|
+
element.before(label);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* What goes in `holder` to show `before`, a block of the page as it was: a
|
|
139
|
+
* copy, or for an item going into an item, the copy's contents.
|
|
140
|
+
*/
|
|
141
|
+
function contentOf(holder, before) {
|
|
142
|
+
const copy = copyOf(before);
|
|
143
|
+
if (holder.localName === "li" && copy.localName === "li")
|
|
144
|
+
return Array.from(copy.childNodes);
|
|
145
|
+
return [copy];
|
|
146
|
+
}
|
|
147
|
+
/** A copy of a block of the page as it was, without its anchors or ids. */
|
|
148
|
+
function copyOf(element) {
|
|
149
|
+
const copy = element.cloneNode(true);
|
|
150
|
+
for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
|
|
151
|
+
el.removeAttribute("data-ascribe-source");
|
|
152
|
+
el.removeAttribute("data-ascribe-via");
|
|
153
|
+
el.removeAttribute("id");
|
|
154
|
+
}
|
|
155
|
+
return copy;
|
|
156
|
+
}
|
|
157
|
+
/** The element to add beside `sibling`: an item in a list, else a `div`. */
|
|
158
|
+
function holderFor(container) {
|
|
159
|
+
return container.localName === "ul" || container.localName === "ol" ? "li" : "div";
|
|
160
|
+
}
|
|
161
|
+
/** The nearest heading before `node` in `root`, as text. */
|
|
162
|
+
function headingBefore(root, node) {
|
|
163
|
+
const headings = Array.from(root.querySelectorAll("h1, h2, h3, h4, h5, h6"));
|
|
164
|
+
let found;
|
|
165
|
+
for (const heading of headings) {
|
|
166
|
+
if (heading === node || heading.contains(node)) {
|
|
167
|
+
found = heading;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
if (heading.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING)
|
|
171
|
+
found = heading;
|
|
172
|
+
else
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
return found ? ownText(found).trim() : undefined;
|
|
176
|
+
}
|
|
177
|
+
/** An element's text without what the marks added. */
|
|
178
|
+
function ownText(element) {
|
|
179
|
+
let text = "";
|
|
180
|
+
const walk = (node) => {
|
|
181
|
+
for (const child of Array.from(node.childNodes)) {
|
|
182
|
+
if (child.nodeType === Node.TEXT_NODE)
|
|
183
|
+
text += child.nodeValue ?? "";
|
|
184
|
+
else if (child instanceof Element && !child.hasAttribute("data-ascribe-ui"))
|
|
185
|
+
walk(child);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
walk(element);
|
|
189
|
+
return text.replace(/\s+/g, " ");
|
|
190
|
+
}
|
|
191
|
+
/** Where a removed block, or a moved block's old place, goes in the page now. */
|
|
192
|
+
class Placer {
|
|
193
|
+
root;
|
|
194
|
+
/** The last element placed after each block, so several stay in order. */
|
|
195
|
+
#last = new Map();
|
|
196
|
+
#first = new Map();
|
|
197
|
+
constructor(root) {
|
|
198
|
+
this.root = root;
|
|
199
|
+
}
|
|
200
|
+
/** Puts `make(container)`'s element where `change` was. */
|
|
201
|
+
place(change, make) {
|
|
202
|
+
const after = change.after ? findBlock(this.root, change.after) : null;
|
|
203
|
+
const parent = change.parent ? findBlock(this.root, change.parent) : null;
|
|
204
|
+
if (after && after !== parent && after.parentElement) {
|
|
205
|
+
let previous = this.#last.get(after) ?? after;
|
|
206
|
+
// Past what the marks put after the block itself: its copy as it
|
|
207
|
+
// was, or its "moved from" link.
|
|
208
|
+
for (let next = previous.nextElementSibling; next &&
|
|
209
|
+
(next.hasAttribute("data-ascribe-was-only") ||
|
|
210
|
+
next.classList.contains("ascribe-move-after")); next = next.nextElementSibling) {
|
|
211
|
+
previous = next;
|
|
212
|
+
}
|
|
213
|
+
const el = make(after.parentElement);
|
|
214
|
+
// A label before the next block belongs to that block, so this goes
|
|
215
|
+
// before it.
|
|
216
|
+
previous.after(el);
|
|
217
|
+
this.#last.set(after, el);
|
|
218
|
+
return el;
|
|
219
|
+
}
|
|
220
|
+
const container = parent ?? this.root;
|
|
221
|
+
const el = make(container);
|
|
222
|
+
const previous = this.#first.get(container);
|
|
223
|
+
if (previous) {
|
|
224
|
+
previous.after(el);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// First among the container's blocks: before its first anchored
|
|
228
|
+
// child, or its first label, or at its end.
|
|
229
|
+
const first = Array.from(container.children).find((c) => c.hasAttribute("data-ascribe-source") || c.hasAttribute("data-ascribe-ui"));
|
|
230
|
+
if (first)
|
|
231
|
+
first.before(el);
|
|
232
|
+
else
|
|
233
|
+
container.append(el);
|
|
234
|
+
}
|
|
235
|
+
this.#first.set(container, el);
|
|
236
|
+
return el;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The block's own text, whitespace collapsed as `ascribe diff` collapses it,
|
|
241
|
+
* with where each character is: text inside the block's anchored children,
|
|
242
|
+
* which are blocks of their own, and inside what the marks added, isn't its.
|
|
243
|
+
*/
|
|
244
|
+
function textMap(element) {
|
|
245
|
+
const chars = [];
|
|
246
|
+
const at = [];
|
|
247
|
+
let space = false;
|
|
248
|
+
let spaceAt;
|
|
249
|
+
const walk = (node) => {
|
|
250
|
+
for (const child of Array.from(node.childNodes)) {
|
|
251
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
252
|
+
const value = child.nodeValue ?? "";
|
|
253
|
+
let offset = 0;
|
|
254
|
+
for (const ch of value) {
|
|
255
|
+
if (/\s/.test(ch)) {
|
|
256
|
+
if (!space)
|
|
257
|
+
spaceAt = { node: child, offset };
|
|
258
|
+
space = true;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
if (space && chars.length > 0 && spaceAt) {
|
|
262
|
+
chars.push(" ");
|
|
263
|
+
at.push(spaceAt);
|
|
264
|
+
}
|
|
265
|
+
space = false;
|
|
266
|
+
chars.push(ch);
|
|
267
|
+
at.push({ node: child, offset });
|
|
268
|
+
}
|
|
269
|
+
offset += ch.length;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (child instanceof Element &&
|
|
273
|
+
!child.hasAttribute("data-ascribe-ui") &&
|
|
274
|
+
!child.hasAttribute("data-ascribe-source")) {
|
|
275
|
+
walk(child);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
walk(element);
|
|
280
|
+
return { text: chars, at };
|
|
281
|
+
}
|
|
282
|
+
/** Wraps characters `[start, end)` of the map in elements made by `make`. */
|
|
283
|
+
function wrapRange(map, start, end, make) {
|
|
284
|
+
// Each text node's part of the range, wrapped by itself, last first so
|
|
285
|
+
// earlier offsets stay right.
|
|
286
|
+
const parts = [];
|
|
287
|
+
for (let i = start; i < end; i++) {
|
|
288
|
+
const at = map.at[i];
|
|
289
|
+
if (!at)
|
|
290
|
+
continue;
|
|
291
|
+
const width = charWidth(at);
|
|
292
|
+
const part = parts[parts.length - 1];
|
|
293
|
+
if (part && part.node === at.node)
|
|
294
|
+
part.to = at.offset + width;
|
|
295
|
+
else
|
|
296
|
+
parts.push({ node: at.node, from: at.offset, to: at.offset + width });
|
|
297
|
+
}
|
|
298
|
+
for (const part of parts.reverse()) {
|
|
299
|
+
const range = part.node.ownerDocument.createRange();
|
|
300
|
+
range.setStart(part.node, part.from);
|
|
301
|
+
range.setEnd(part.node, part.to);
|
|
302
|
+
const wrapper = make();
|
|
303
|
+
range.surroundContents(wrapper);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function charWidth(at) {
|
|
307
|
+
const code = at.node.nodeValue?.codePointAt(at.offset) ?? 0;
|
|
308
|
+
return code > 0xffff ? 2 : 1;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Marks the changed words of a block: the words added or replacing others
|
|
312
|
+
* in `<ins>`, and the words removed in `<del>` where they were. Skipped when
|
|
313
|
+
* the block's text isn't the text the change was computed from.
|
|
314
|
+
*/
|
|
315
|
+
function markWords(element, words) {
|
|
316
|
+
const map = textMap(element);
|
|
317
|
+
const nowChars = Array.from(words.now_text);
|
|
318
|
+
const text = map.text.join("");
|
|
319
|
+
const offset = text === words.now_text ? 0 : findChars(map.text, nowChars);
|
|
320
|
+
if (offset < 0)
|
|
321
|
+
return false;
|
|
322
|
+
const doc = element.ownerDocument;
|
|
323
|
+
const wasChars = Array.from(words.was_text);
|
|
324
|
+
// Each removed run goes where it was: after as many unchanged characters
|
|
325
|
+
// of the new text as there were before it in the old.
|
|
326
|
+
const insertions = [];
|
|
327
|
+
let removedBefore = 0;
|
|
328
|
+
for (const [start, end] of words.was) {
|
|
329
|
+
const unchangedBefore = start - removedBefore;
|
|
330
|
+
insertions.push({
|
|
331
|
+
at: nowPosition(words.now, unchangedBefore),
|
|
332
|
+
text: wasChars.slice(start, end).join(""),
|
|
333
|
+
});
|
|
334
|
+
removedBefore += end - start;
|
|
335
|
+
}
|
|
336
|
+
const edits = [
|
|
337
|
+
...words.now.map(([start, end]) => ({ at: start, kind: "ins", end })),
|
|
338
|
+
...insertions.map((i) => ({ at: i.at, kind: "del", text: i.text })),
|
|
339
|
+
];
|
|
340
|
+
// At one position, the removed words go first, before the words that
|
|
341
|
+
// replace them.
|
|
342
|
+
edits.sort((a, b) => b.at - a.at || (a.kind === "del" ? -1 : 1));
|
|
343
|
+
for (const edit of edits) {
|
|
344
|
+
if (edit.kind === "ins") {
|
|
345
|
+
wrapRange(map, offset + edit.at, offset + edit.end, () => ui(doc, "ins", "ascribe-ins"));
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
const del = ui(doc, "del", "ascribe-del", edit.text);
|
|
349
|
+
insertAt(map, offset + edit.at, del, element);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
/** Where in the new text `unchanged` unchanged characters end. */
|
|
355
|
+
function nowPosition(ranges, unchanged) {
|
|
356
|
+
let position = unchanged;
|
|
357
|
+
for (const [start, end] of ranges) {
|
|
358
|
+
if (start < position)
|
|
359
|
+
position += end - start;
|
|
360
|
+
else
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
return position;
|
|
364
|
+
}
|
|
365
|
+
function findChars(haystack, needle) {
|
|
366
|
+
if (needle.length === 0)
|
|
367
|
+
return -1;
|
|
368
|
+
outer: for (let i = 0; i + needle.length <= haystack.length; i++) {
|
|
369
|
+
for (let j = 0; j < needle.length; j++)
|
|
370
|
+
if (haystack[i + j] !== needle[j])
|
|
371
|
+
continue outer;
|
|
372
|
+
return i;
|
|
373
|
+
}
|
|
374
|
+
return -1;
|
|
375
|
+
}
|
|
376
|
+
/** Inserts `node` before character `index` of the map, or at its end. */
|
|
377
|
+
function insertAt(map, index, node, element) {
|
|
378
|
+
const at = map.at[index];
|
|
379
|
+
if (at) {
|
|
380
|
+
const rest = at.node.splitText(at.offset);
|
|
381
|
+
rest.before(node);
|
|
382
|
+
// The map's later positions in this node now point into `rest`.
|
|
383
|
+
for (let i = index; i < map.at.length; i++) {
|
|
384
|
+
const later = map.at[i];
|
|
385
|
+
if (later && later.node === at.node && later.offset >= at.offset) {
|
|
386
|
+
map.at[i] = { node: rest, offset: later.offset - at.offset };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const last = map.at[map.at.length - 1];
|
|
392
|
+
if (last) {
|
|
393
|
+
const rest = last.node.splitText(last.offset + charWidth(last));
|
|
394
|
+
rest.before(node);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
element.append(node);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Marks `changes` on the page in `root`, and returns the marks in page
|
|
402
|
+
* order. Marks already there are cleared first.
|
|
403
|
+
*/
|
|
404
|
+
export function markChanges(root, changes, options = {}) {
|
|
405
|
+
clearMarks(root);
|
|
406
|
+
const doc = root.ownerDocument;
|
|
407
|
+
const was = options.was ?? null;
|
|
408
|
+
const placer = new Placer(root);
|
|
409
|
+
const marks = [];
|
|
410
|
+
root.classList.add("ascribe-marks");
|
|
411
|
+
if (!root.hasAttribute("data-ascribe-show"))
|
|
412
|
+
root.setAttribute("data-ascribe-show", "changes");
|
|
413
|
+
let moveCount = 0;
|
|
414
|
+
for (const change of changes) {
|
|
415
|
+
switch (change.kind) {
|
|
416
|
+
case "added":
|
|
417
|
+
case "changed": {
|
|
418
|
+
const element = change.now ? findBlock(root, change.now) : null;
|
|
419
|
+
if (!element || element.hasAttribute("data-ascribe-change"))
|
|
420
|
+
break;
|
|
421
|
+
element.setAttribute("data-ascribe-change", change.kind);
|
|
422
|
+
addLabel(element, change.kind);
|
|
423
|
+
if (change.kind === "changed") {
|
|
424
|
+
// Where it was, for threads on its old text.
|
|
425
|
+
if (change.was)
|
|
426
|
+
element.setAttribute("data-ascribe-was-source", change.was.source);
|
|
427
|
+
const before = was && change.was ? findBlock(was, change.was) : null;
|
|
428
|
+
if (change.words)
|
|
429
|
+
markWords(element, change.words);
|
|
430
|
+
if (before) {
|
|
431
|
+
// As it was, the block as the old page rendered it.
|
|
432
|
+
const copy = copyOf(before);
|
|
433
|
+
copy.setAttribute("data-ascribe-ui", "");
|
|
434
|
+
copy.setAttribute("data-ascribe-was-only", "");
|
|
435
|
+
element.after(copy);
|
|
436
|
+
element.setAttribute("data-ascribe-has-was", "");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
marks.push({ change, element });
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case "removed": {
|
|
443
|
+
const before = was && change.was ? findBlock(was, change.was) : null;
|
|
444
|
+
const element = placer.place(change, (container) => {
|
|
445
|
+
const holder = ui(doc, holderFor(container), "ascribe-removed");
|
|
446
|
+
holder.setAttribute("data-ascribe-change", "removed");
|
|
447
|
+
if (change.was)
|
|
448
|
+
holder.setAttribute("data-ascribe-was-source", change.was.source);
|
|
449
|
+
const label = ui(doc, "span", "ascribe-label", LABELS.removed);
|
|
450
|
+
label.setAttribute("data-ascribe-label", "removed");
|
|
451
|
+
const body = ui(doc, "div", "ascribe-removed-body");
|
|
452
|
+
if (before)
|
|
453
|
+
body.append(...contentOf(holder, before));
|
|
454
|
+
else
|
|
455
|
+
body.textContent = change.text ?? "";
|
|
456
|
+
const toggle = ui(doc, "button", "ascribe-toggle", "Show");
|
|
457
|
+
toggle.type = "button";
|
|
458
|
+
toggle.setAttribute("aria-expanded", "false");
|
|
459
|
+
toggle.addEventListener("click", (event) => {
|
|
460
|
+
event.stopPropagation();
|
|
461
|
+
const open = holder.classList.toggle("ascribe-open");
|
|
462
|
+
toggle.textContent = open ? "Collapse" : "Show";
|
|
463
|
+
toggle.setAttribute("aria-expanded", String(open));
|
|
464
|
+
});
|
|
465
|
+
holder.append(label, body, toggle);
|
|
466
|
+
return holder;
|
|
467
|
+
});
|
|
468
|
+
marks.push({ change, element });
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
case "moved": {
|
|
472
|
+
const element = change.now ? findBlock(root, change.now) : null;
|
|
473
|
+
if (!element || element.hasAttribute("data-ascribe-change"))
|
|
474
|
+
break;
|
|
475
|
+
const id = `ascribe-move-${++moveCount}`;
|
|
476
|
+
const before = was && change.was ? findBlock(was, change.was) : null;
|
|
477
|
+
const stub = placer.place(change, (container) => {
|
|
478
|
+
const holder = ui(doc, holderFor(container), "ascribe-moved-from");
|
|
479
|
+
holder.id = `${id}-from`;
|
|
480
|
+
holder.setAttribute("data-ascribe-change", "moved-from");
|
|
481
|
+
if (change.was)
|
|
482
|
+
holder.setAttribute("data-ascribe-was-source", change.was.source);
|
|
483
|
+
const note = ui(doc, "span", "ascribe-move-note");
|
|
484
|
+
note.append(doc.createTextNode(`A ${blockWord(element)} moved from here `));
|
|
485
|
+
const link = ui(doc, "a", "ascribe-move-link");
|
|
486
|
+
link.href = `#${id}`;
|
|
487
|
+
note.append(link);
|
|
488
|
+
holder.append(note);
|
|
489
|
+
// As it was, the block itself.
|
|
490
|
+
const copy = ui(doc, "div", "ascribe-was-copy");
|
|
491
|
+
copy.setAttribute("data-ascribe-was-only", "");
|
|
492
|
+
if (before)
|
|
493
|
+
copy.append(...contentOf(holder, before));
|
|
494
|
+
else
|
|
495
|
+
copy.textContent = change.text ?? ownText(element);
|
|
496
|
+
holder.append(copy);
|
|
497
|
+
return holder;
|
|
498
|
+
});
|
|
499
|
+
element.setAttribute("data-ascribe-change", "moved");
|
|
500
|
+
element.setAttribute("data-ascribe-move", id);
|
|
501
|
+
if (!element.id)
|
|
502
|
+
element.id = id;
|
|
503
|
+
addLabel(element, "moved");
|
|
504
|
+
const back = ui(doc, "a", "ascribe-move-link");
|
|
505
|
+
back.href = `#${stub.id}`;
|
|
506
|
+
back.textContent = `from “${headingBefore(root, stub) ?? "the top of the page"}” ↑`;
|
|
507
|
+
const forward = stub.querySelector(".ascribe-move-link");
|
|
508
|
+
if (forward) {
|
|
509
|
+
forward.href = `#${element.id}`;
|
|
510
|
+
forward.textContent = `to “${headingBefore(root, element) ?? "the top of the page"}” ↓`;
|
|
511
|
+
}
|
|
512
|
+
if (LABEL_INSIDE.has(element.localName)) {
|
|
513
|
+
const note = ui(doc, "span", "ascribe-move-note");
|
|
514
|
+
note.append(" ", back);
|
|
515
|
+
element.append(note);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
const holder = ui(doc, "div", "ascribe-move-note ascribe-move-after");
|
|
519
|
+
holder.append(back);
|
|
520
|
+
element.after(holder);
|
|
521
|
+
}
|
|
522
|
+
for (const link of [back, forward]) {
|
|
523
|
+
link?.addEventListener("click", (event) => {
|
|
524
|
+
const target = doc.getElementById(link.hash.slice(1));
|
|
525
|
+
if (!target || !root.contains(target))
|
|
526
|
+
return;
|
|
527
|
+
event.preventDefault();
|
|
528
|
+
goTo(target);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
marks.push({ change, element });
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
marks.sort((a, b) => a.element === b.element
|
|
537
|
+
? 0
|
|
538
|
+
: a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
539
|
+
? -1
|
|
540
|
+
: 1);
|
|
541
|
+
hintHidden(root, marks);
|
|
542
|
+
return marks;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Says on each tab's label, and on each `<details>`' summary, what's changed
|
|
546
|
+
* in what they can hide: "new" for a tab that's added, else how many
|
|
547
|
+
* changes. A tab that isn't selected, or a closed `<details>`, would
|
|
548
|
+
* otherwise hide its changes until stepped to.
|
|
549
|
+
*/
|
|
550
|
+
function hintHidden(root, marks) {
|
|
551
|
+
const tabs = new Map();
|
|
552
|
+
const summaries = new Map();
|
|
553
|
+
for (const { change, element } of marks) {
|
|
554
|
+
for (let el = element; el && el !== root; el = el.parentElement) {
|
|
555
|
+
if (el.localName === "ascribe-tab") {
|
|
556
|
+
const tab = tabs.get(el) ?? { added: false, count: 0 };
|
|
557
|
+
if (el === element && change.kind === "added")
|
|
558
|
+
tab.added = true;
|
|
559
|
+
else
|
|
560
|
+
tab.count++;
|
|
561
|
+
tabs.set(el, tab);
|
|
562
|
+
}
|
|
563
|
+
const summary = el !== element ? summaryOf(el) : null;
|
|
564
|
+
if (summary && !summary.contains(element)) {
|
|
565
|
+
summaries.set(summary, (summaries.get(summary) ?? 0) + 1);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const doc = root.ownerDocument;
|
|
570
|
+
const hint = (kind, text) => {
|
|
571
|
+
// A space before it, not only a margin, so the label reads "Linux 1
|
|
572
|
+
// change" to a screen reader, not "Linux1 change".
|
|
573
|
+
const el = ui(doc, "span", "ascribe-hint", ` ${text}`);
|
|
574
|
+
el.setAttribute("data-ascribe-hint", kind);
|
|
575
|
+
return el;
|
|
576
|
+
};
|
|
577
|
+
const watching = [];
|
|
578
|
+
for (const [tab, { added, count }] of tabs) {
|
|
579
|
+
const el = added ? hint("added", "new") : hint("changed", plural(count, "change"));
|
|
580
|
+
const place = () => {
|
|
581
|
+
const button = tabButton(tab);
|
|
582
|
+
if (button && !button.contains(el))
|
|
583
|
+
button.append(el);
|
|
584
|
+
};
|
|
585
|
+
place();
|
|
586
|
+
// `@ascribed/elements` makes the tab list once the page has loaded, and
|
|
587
|
+
// again when the tabs are put back in the page.
|
|
588
|
+
const group = tab.parentElement;
|
|
589
|
+
const Observer = doc.defaultView?.MutationObserver;
|
|
590
|
+
if (group && Observer) {
|
|
591
|
+
const observer = new Observer(place);
|
|
592
|
+
observer.observe(group, { childList: true });
|
|
593
|
+
watching.push(observer);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (watching.length > 0)
|
|
597
|
+
tabWatchers.set(root, watching);
|
|
598
|
+
for (const [summary, count] of summaries)
|
|
599
|
+
summary.append(hint("changed", plural(count, "change")));
|
|
600
|
+
}
|
|
601
|
+
/** What waits for each marked page's tab lists, to stop when it's cleared. */
|
|
602
|
+
const tabWatchers = new WeakMap();
|
|
603
|
+
function plural(count, word) {
|
|
604
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
605
|
+
}
|
|
606
|
+
/** A `<details>`' own `<summary>`, or null when `element` isn't one. */
|
|
607
|
+
function summaryOf(element) {
|
|
608
|
+
if (element.localName !== "details")
|
|
609
|
+
return null;
|
|
610
|
+
return element.querySelector(":scope > summary");
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* The button that selects an `<ascribe-tab>`, in its group's tab list, or
|
|
614
|
+
* null before `@ascribed/elements` has made the list.
|
|
615
|
+
*/
|
|
616
|
+
export function tabButton(tab) {
|
|
617
|
+
const group = tab.parentElement;
|
|
618
|
+
if (tab.localName !== "ascribe-tab" || !group)
|
|
619
|
+
return null;
|
|
620
|
+
const tabs = Array.from(group.children).filter((c) => c.localName === "ascribe-tab");
|
|
621
|
+
const buttons = group.querySelectorAll(':scope > [role="tablist"] > [role="tab"]');
|
|
622
|
+
return buttons[tabs.indexOf(tab)] ?? null;
|
|
623
|
+
}
|
|
624
|
+
/** "paragraph", "heading", "list", or "block". */
|
|
625
|
+
function blockWord(element) {
|
|
626
|
+
const name = element.localName;
|
|
627
|
+
if (name === "p")
|
|
628
|
+
return "paragraph";
|
|
629
|
+
if (/^h[1-6]$/.test(name))
|
|
630
|
+
return "heading";
|
|
631
|
+
if (name === "ul" || name === "ol")
|
|
632
|
+
return "list";
|
|
633
|
+
if (name === "li")
|
|
634
|
+
return "list item";
|
|
635
|
+
if (name === "pre")
|
|
636
|
+
return "code block";
|
|
637
|
+
if (name === "table")
|
|
638
|
+
return "table";
|
|
639
|
+
return "block";
|
|
640
|
+
}
|
|
641
|
+
/** Removes every mark from the page in `root`. */
|
|
642
|
+
export function clearMarks(root) {
|
|
643
|
+
for (const observer of tabWatchers.get(root) ?? [])
|
|
644
|
+
observer.disconnect();
|
|
645
|
+
tabWatchers.delete(root);
|
|
646
|
+
for (const el of Array.from(root.querySelectorAll("[data-ascribe-ui]"))) {
|
|
647
|
+
if (el.localName === "ins")
|
|
648
|
+
el.replaceWith(...Array.from(el.childNodes));
|
|
649
|
+
else
|
|
650
|
+
el.remove();
|
|
651
|
+
}
|
|
652
|
+
for (const el of Array.from(root.querySelectorAll("[data-ascribe-change]"))) {
|
|
653
|
+
el.removeAttribute("data-ascribe-change");
|
|
654
|
+
el.removeAttribute("data-ascribe-has-was");
|
|
655
|
+
el.removeAttribute("data-ascribe-was-source");
|
|
656
|
+
if (el.hasAttribute("data-ascribe-move")) {
|
|
657
|
+
if (el.id === el.getAttribute("data-ascribe-move"))
|
|
658
|
+
el.removeAttribute("id");
|
|
659
|
+
el.removeAttribute("data-ascribe-move");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
root.normalize();
|
|
663
|
+
}
|
|
664
|
+
/** Shows the changes, the page as it will be, or the page as it was. */
|
|
665
|
+
export function setShow(root, show) {
|
|
666
|
+
root.setAttribute("data-ascribe-show", show);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Makes `element` visible: opens the `<details>` and selects the tabs it's
|
|
670
|
+
* in.
|
|
671
|
+
*/
|
|
672
|
+
export function reveal(element) {
|
|
673
|
+
for (let el = element; el; el = el.parentElement) {
|
|
674
|
+
if (el instanceof HTMLDetailsElement && !el.open)
|
|
675
|
+
el.open = true;
|
|
676
|
+
if (el.localName === "ascribe-tab" && el.hidden)
|
|
677
|
+
tabButton(el)?.click();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/** Reveals `element`, scrolls to it, and flashes it. */
|
|
681
|
+
export function goTo(element) {
|
|
682
|
+
reveal(element);
|
|
683
|
+
// Not every DOM implements scrolling (jsdom doesn't).
|
|
684
|
+
if (typeof element.scrollIntoView === "function") {
|
|
685
|
+
element.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
686
|
+
}
|
|
687
|
+
element.classList.remove("ascribe-flash");
|
|
688
|
+
// Restart the animation.
|
|
689
|
+
void element.offsetWidth;
|
|
690
|
+
element.classList.add("ascribe-flash");
|
|
691
|
+
if (!element.hasAttribute("tabindex"))
|
|
692
|
+
element.setAttribute("tabindex", "-1");
|
|
693
|
+
element.focus({ preventScroll: true });
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Shows each block's source on hover and focus: `guides/install.md:12`,
|
|
697
|
+
* from its anchor. Returns a function that stops.
|
|
698
|
+
*/
|
|
699
|
+
export function showSources(root) {
|
|
700
|
+
const doc = root.ownerDocument;
|
|
701
|
+
const tip = ui(doc, "span", "ascribe-where");
|
|
702
|
+
tip.setAttribute("role", "status");
|
|
703
|
+
let current = null;
|
|
704
|
+
const show = (target) => {
|
|
705
|
+
const el = target instanceof Element
|
|
706
|
+
? target.closest("[data-ascribe-source], [data-ascribe-was-source]")
|
|
707
|
+
: null;
|
|
708
|
+
if (el === current)
|
|
709
|
+
return;
|
|
710
|
+
current = el;
|
|
711
|
+
if (!el || !root.contains(el)) {
|
|
712
|
+
tip.remove();
|
|
713
|
+
current = null;
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const source = el.getAttribute("data-ascribe-source");
|
|
717
|
+
tip.textContent = source
|
|
718
|
+
? describeSource(source, el.getAttribute("data-ascribe-via"))
|
|
719
|
+
: `was ${describeSource(el.getAttribute("data-ascribe-was-source") ?? "")}`;
|
|
720
|
+
const box = el.getBoundingClientRect();
|
|
721
|
+
const host = root.getBoundingClientRect();
|
|
722
|
+
tip.style.top = `${box.top - host.top}px`;
|
|
723
|
+
root.append(tip);
|
|
724
|
+
};
|
|
725
|
+
const over = (event) => show(event.target);
|
|
726
|
+
const out = (event) => {
|
|
727
|
+
const to = event.relatedTarget;
|
|
728
|
+
if (!(to instanceof Node) || !root.contains(to))
|
|
729
|
+
show(null);
|
|
730
|
+
};
|
|
731
|
+
root.addEventListener("mouseover", over);
|
|
732
|
+
root.addEventListener("focusin", over);
|
|
733
|
+
root.addEventListener("mouseout", out);
|
|
734
|
+
return () => {
|
|
735
|
+
root.removeEventListener("mouseover", over);
|
|
736
|
+
root.removeEventListener("focusin", over);
|
|
737
|
+
root.removeEventListener("mouseout", out);
|
|
738
|
+
tip.remove();
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Takes out of a page's HTML what could run script or take the reader
|
|
743
|
+
* elsewhere: scripts, `<meta>` and `<base>`, event handler attributes, and
|
|
744
|
+
* `javascript:` URLs. A page under review comes from the change being
|
|
745
|
+
* reviewed, so a host disarms it before showing it, whatever its content
|
|
746
|
+
* security policy also blocks.
|
|
747
|
+
*/
|
|
748
|
+
export function disarm(fragment) {
|
|
749
|
+
for (const el of Array.from(fragment.querySelectorAll("script, meta, base")))
|
|
750
|
+
el.remove();
|
|
751
|
+
for (const el of Array.from(fragment.querySelectorAll("*"))) {
|
|
752
|
+
for (const attr of Array.from(el.attributes)) {
|
|
753
|
+
if (/^on/i.test(attr.name) || runsScript(attr.value))
|
|
754
|
+
el.removeAttribute(attr.name);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
/** Whether a URL runs script: `javascript:`, however it's spaced or cased. */
|
|
759
|
+
function runsScript(value) {
|
|
760
|
+
// URL parsing drops ASCII whitespace and control characters first.
|
|
761
|
+
const kept = Array.from(value)
|
|
762
|
+
.filter((ch) => ch.charCodeAt(0) > 0x20)
|
|
763
|
+
.join("");
|
|
764
|
+
return /^javascript:/i.test(kept);
|
|
765
|
+
}
|
|
766
|
+
//# sourceMappingURL=index.js.map
|