@erdemtuna/doc-review 0.10.0 → 0.11.0

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.
@@ -1,380 +0,0 @@
1
- // Historical content is reconstructed with inert, allowlisted DOM nodes only.
2
- // Never assign captured attributes, HTML, URLs, or authored styles to the DOM.
3
- const marks = {
4
- strong: "strong", em: "em", underline: "u", strike: "s", delete: "s",
5
- insert: "u", code: "code", kbd: "kbd", samp: "samp", sub: "sub",
6
- sup: "sup", mark: "mark",
7
- };
8
- const containers = new Set(["table", "thead", "tbody", "tfoot", "tr", "ul", "ol", "dl"]);
9
- const readable = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "p", "pre", "blockquote", "address"]);
10
- const node = (document, tag, className, text) => {
11
- const element = document.createElement(tag);
12
- if (className)
13
- element.className = className;
14
- if (text !== undefined)
15
- element.textContent = text;
16
- return element;
17
- };
18
- const sourceLines = (block) => block?.startLine ?? "";
19
- const rowParent = (block) => {
20
- if (!["td", "th"].includes(block?.tag))
21
- return null;
22
- // A bare path such as table/tbody/tr does not distinguish repeated rows.
23
- // Only the saved positional selector is sufficient for row reconstruction.
24
- return block.selector?.match(/^(.* > tr(?::nth-of-type\(\d+\))?(?:\[id=".*"\])?) > (?:td|th):nth-of-type\(\d+\)/)?.[1] || null;
25
- };
26
- function appendRuns(element, block, segments, side) {
27
- const document = element.ownerDocument;
28
- const parts = segments?.length
29
- ? segments.filter((part) => side === "before" ? !part.added : !part.removed)
30
- : [{ value: block.text }];
31
- const runs = block.runs || [{ text: block.text, marks: [] }];
32
- let runIndex = 0;
33
- let runOffset = 0;
34
- for (const part of parts) {
35
- let offset = 0;
36
- while (offset < part.value.length) {
37
- while (runIndex < runs.length && runOffset >= runs[runIndex].text.length) {
38
- runIndex++;
39
- runOffset = 0;
40
- }
41
- const run = runs[runIndex];
42
- const length = Math.min(part.value.length - offset, run ? run.text.length - runOffset : Infinity);
43
- let leaf = document.createTextNode(part.value.slice(offset, offset + length));
44
- for (const mark of run?.marks || []) {
45
- if (!Object.hasOwn(marks, mark))
46
- continue;
47
- const wrapper = node(document, marks[mark]);
48
- wrapper.append(leaf);
49
- leaf = wrapper;
50
- }
51
- if (run?.href) {
52
- const reference = node(document, "span", "saved-link");
53
- reference.title = `Saved link: ${run.href} (not opened from history)`;
54
- reference.append(leaf);
55
- leaf = reference;
56
- }
57
- if (side === "before" ? part.removed : part.added) {
58
- const emphasis = node(document, side === "before" ? "del" : "ins");
59
- emphasis.append(leaf);
60
- leaf = emphasis;
61
- }
62
- element.append(leaf);
63
- offset += length;
64
- runOffset += length;
65
- }
66
- }
67
- }
68
- function metadata(block, change, side) {
69
- if (!change || !block)
70
- return [];
71
- const other = change[side === "before" ? "afterBlock" : "beforeBlock"];
72
- if (!other)
73
- return [];
74
- const labels = [];
75
- if (block.tag !== other.tag)
76
- labels.push(`Element: ${block.tag}`);
77
- for (const key of new Set([...Object.keys(block.attributes || {}), ...Object.keys(other.attributes || {})])) {
78
- if (block.attributes?.[key] === other.attributes?.[key])
79
- continue;
80
- const name = { href: "Link destination", src: "Image reference", alt: "Alternative text", id: "Element ID" }[key] || key;
81
- labels.push(`${name}: ${block.attributes?.[key] ?? "(not set)"}`);
82
- }
83
- const links = [...new Set((block.runs || []).map((run) => run.href).filter(Boolean))];
84
- const otherLinks = [...new Set((other.runs || []).map((run) => run.href).filter(Boolean))];
85
- if (JSON.stringify(links) !== JSON.stringify(otherLinks))
86
- labels.push(`Link destination: ${links.join(", ") || "(none)"}`);
87
- if (change.fields?.includes("runs") && block.text === other.text && !labels.length)
88
- labels.push("Inline formatting changed");
89
- if (change.fields?.includes("structure") && block.tag === other.tag) {
90
- labels.push(`Structure: ${(block.path || []).join(" › ") || "document root"}`);
91
- }
92
- return labels;
93
- }
94
- function renderBlock(document, row, side, mode, change, listNumbers) {
95
- const block = row[`${side}Block`];
96
- const wrapper = node(document, "div", "comparison-block");
97
- if (!block)
98
- return wrapper;
99
- if (mode === "source") {
100
- const pre = node(document, "pre");
101
- appendRuns(pre, block, row.segments, side);
102
- wrapper.append(pre);
103
- if (!block.text.endsWith("\n"))
104
- wrapper.append(node(document, "small", "newline-note", "↵ No newline at end of file"));
105
- const other = row[side === "before" ? "afterBlock" : "beforeBlock"];
106
- const ending = (text) => text.endsWith("\r\n") ? "CRLF" : text.endsWith("\n") ? "LF" : "none";
107
- if (other && ending(block.text) !== ending(other.text)) {
108
- wrapper.append(node(document, "small", "newline-note", `Line ending: ${ending(block.text)}`));
109
- }
110
- return wrapper;
111
- }
112
- const tag = block.tag || "p";
113
- if (tag === "img") {
114
- const image = node(document, "figure", "saved-image");
115
- image.append(node(document, "figcaption", "", `Image: ${block.attributes?.alt || "No saved alternative text"}`));
116
- image.append(node(document, "small", "", `Saved reference: ${block.attributes?.src || "unavailable"} · Image not loaded`));
117
- wrapper.append(image);
118
- }
119
- else if (tag === "hr") {
120
- wrapper.append(node(document, "hr"));
121
- }
122
- else if (containers.has(tag) && !block.text) {
123
- if (change)
124
- wrapper.append(node(document, "small", "structure-note", `${tag} structure ${row.kind}`));
125
- }
126
- else {
127
- const text = node(document, readable.has(tag) ? tag : tag === "li" ? "li" : "p");
128
- appendRuns(text, block, row.segments, side);
129
- if (tag === "li") {
130
- const listTag = [...(block.path || [])].reverse().find((item) => /^(ul|ol)(#|$)/.test(item))?.split("#")[0];
131
- const list = node(document, listTag || "ul", "saved-list");
132
- if (listTag === "ol")
133
- list.start = listNumbers.get(block) ?? 1;
134
- const depth = (block.path || []).filter((item) => /^(ul|ol)(#|$)/.test(item)).length;
135
- list.style.marginInlineStart = `${Math.min(6, Math.max(0, depth - 1)) * 14}px`;
136
- list.append(text);
137
- wrapper.append(list);
138
- if (!listTag)
139
- wrapper.append(node(document, "small", "structure-note", "List item · parent structure unavailable"));
140
- }
141
- else {
142
- wrapper.append(text);
143
- if (["td", "th"].includes(tag) && !rowParent(block)) {
144
- wrapper.append(node(document, "small", "structure-note", "Table cell · row structure unavailable"));
145
- }
146
- else if (["button", "summary", "dt", "dd", "caption", "figcaption"].includes(tag)) {
147
- wrapper.append(node(document, "small", "structure-note", `${tag === "button" ? "Saved button · inactive" : tag}`));
148
- }
149
- }
150
- }
151
- for (const label of metadata(block, change, side))
152
- wrapper.append(node(document, "small", "metadata-note", label));
153
- return wrapper;
154
- }
155
- function legacyRows(comparison) {
156
- return (comparison.changes || []).map((change, index) => ({
157
- id: `legacy-row-${index}`, kind: change.kind, changeId: change.id || `legacy-change-${index}`,
158
- beforeBlock: change.beforeBlock || (change.kind === "added" ? null : { text: change.before || "", tag: "p" }),
159
- afterBlock: change.afterBlock || (change.kind === "removed" ? null : { text: change.after || "", tag: "p" }),
160
- segments: change.segments || [],
161
- }));
162
- }
163
- function listPositions(rows, side) {
164
- const positions = new Map();
165
- const lists = new Map();
166
- for (const row of rows) {
167
- const block = row[`${side}Block`];
168
- if (!block)
169
- continue;
170
- if (block.tag === "ol") {
171
- const key = block.selector.replace(/\[id=".*"\]$/, "");
172
- const reversed = "reversed" in (block.attributes || {});
173
- const children = reversed ? rows.filter((candidate) => {
174
- const child = candidate[`${side}Block`];
175
- return child?.tag === "li" && child.selector?.replace(/ > li:nth-of-type\(\d+\).*$/, "") === key;
176
- }).length : 1;
177
- const start = block.attributes?.start;
178
- lists.set(key, {
179
- next: start !== undefined && Number.isSafeInteger(Number(start)) ? Number(start) : children,
180
- step: reversed ? -1 : 1,
181
- });
182
- }
183
- if (block.tag !== "li")
184
- continue;
185
- const key = block.selector?.replace(/ > li:nth-of-type\(\d+\).*$/, "") || (block.path || []).join("/");
186
- const list = lists.get(key) || { next: 1, step: 1 };
187
- lists.set(key, list);
188
- const explicit = block.attributes?.value;
189
- if (explicit !== undefined && Number.isFinite(Number(explicit)))
190
- list.next = Number(explicit);
191
- positions.set(block, list.next);
192
- list.next += list.step;
193
- }
194
- return positions;
195
- }
196
- export function createComparisonView(root) {
197
- const document = root.ownerDocument;
198
- const headingSlot = root.parentElement?.querySelector(".comparison-heading-slot");
199
- let signature;
200
- let inputs;
201
- let changes = [];
202
- let targets = new Map();
203
- let selected;
204
- return {
205
- render(comparison, { mode = "content", key = "" } = {}) {
206
- const nextInputs = [key, mode, comparison.version, comparison.status, comparison.available, comparison.rows, comparison.changes];
207
- if (inputs && nextInputs.every((value, index) => value === inputs[index]))
208
- return;
209
- inputs = nextInputs;
210
- // Immutable data identity includes content, not mutable timing/freshness notices.
211
- const nextSignature = `${key}:${mode}:${JSON.stringify([comparison.version, comparison.status, comparison.available, comparison.rows, comparison.changes])}`;
212
- if (signature === nextSignature)
213
- return;
214
- signature = nextSignature;
215
- selected = null;
216
- targets = new Map();
217
- changes = comparison.changes || [];
218
- const rows = comparison.rows || legacyRows(comparison);
219
- root.replaceChildren();
220
- headingSlot?.replaceChildren();
221
- root.className = `change-detail comparison-surface comparison-${mode}`;
222
- if (!rows.length) {
223
- if (comparison.available !== false)
224
- root.textContent = "No changes detected in this comparison format.";
225
- return;
226
- }
227
- // A budget failure is explicit and complete, not a silently truncated view.
228
- const nodes = rows.reduce((sum, row) => sum + ["before", "after"].reduce((n, side) => {
229
- const runs = row[`${side}Block`]?.runs || [];
230
- const depth = runs.reduce((maximum, run) => Math.max(maximum, run.marks.length), 0);
231
- return n + 12 + (runs.length + (row.segments?.length || 1)) * (depth + 4);
232
- }, 0), 0);
233
- if (nodes > 120000) {
234
- root.append(node(document, "p", "comparison-budget", "Comparison rendering limit exceeded. The saved comparison is intact; use Source or a smaller review round."));
235
- return;
236
- }
237
- if (!comparison.rows)
238
- root.append(node(document, "p", "comparison-notice", "Saved excerpts only · unchanged context was not recorded in this comparison."));
239
- const heads = node(document, "div", "comparison-headings");
240
- heads.append(node(document, "div", "", "Before · feedback sent"), node(document, "div", "", "After · captured result"));
241
- (headingSlot || root).append(heads);
242
- const changeMap = new Map(changes.map((change, index) => [change.id || `legacy-change-${index}`, change]));
243
- const numbers = { before: listPositions(rows, "before"), after: listPositions(rows, "after") };
244
- const units = [];
245
- for (const row of rows) {
246
- if (row.kind === "unchanged" && ["beforeBlock", "afterBlock"].every((side) => !row[side] || (containers.has(row[side].tag) && !row[side].text)))
247
- continue;
248
- const tableKey = mode === "content" && (rowParent(row.beforeBlock) || rowParent(row.afterBlock))
249
- ? `${rowParent(row.beforeBlock) || ""}|${rowParent(row.afterBlock) || ""}` : null;
250
- const previous = units.at(-1);
251
- if (tableKey && previous?.tableKey === tableKey)
252
- previous.rows.push(row);
253
- else
254
- units.push({ rows: [row], tableKey });
255
- }
256
- const renderUnit = (unit) => {
257
- const pair = node(document, "div", "comparison-row");
258
- const unchanged = unit.rows.every((row) => row.kind === "unchanged");
259
- const kinds = [...new Set(unit.rows.map((row) => row.kind).filter((kind) => kind !== "unchanged"))];
260
- const changeLabel = kinds.map((kind) => ({ added: "Added", removed: "Removed", modified: "Modified" })[kind] || "Changed").join(" · ");
261
- if (unchanged)
262
- pair.classList.add("comparison-unchanged");
263
- pair.dataset.rowId = unit.rows[0].id;
264
- for (const side of ["before", "after"]) {
265
- const cell = node(document, "section", `comparison-cell comparison-${side}`);
266
- cell.setAttribute("aria-label", `${side === "before" ? "Before" : "After"} · ${unchanged ? "unchanged" : changeLabel.toLowerCase()}`);
267
- if (unit.rows.every((row) => !row[`${side}Block`]))
268
- cell.classList.add("comparison-gap");
269
- const changed = unit.rows.some((row) => row.kind !== "unchanged" && row[`${side}Block`]);
270
- if (changed)
271
- cell.classList.add(side === "before" ? "comparison-removed" : "comparison-added");
272
- const gutter = node(document, "span", "comparison-gutter", mode === "source"
273
- ? `${sourceLines(unit.rows[0][`${side}Block`])} ${changed ? side === "before" ? "−" : "+" : ""}`
274
- : changed ? side === "before" ? "−" : "+" : "");
275
- gutter.setAttribute("aria-hidden", "true");
276
- cell.append(gutter);
277
- const body = node(document, "div", "comparison-cell-body");
278
- const mobileLabel = node(document, "span", "comparison-mobile-label", unchanged
279
- ? mode === "source" ? `Unchanged · Before ${sourceLines(unit.rows[0].beforeBlock)} / After ${sourceLines(unit.rows[0].afterBlock)}` : "Unchanged · both versions"
280
- : `${side === "before" ? "Before" : "After"} · ${changeLabel.toLowerCase()}`);
281
- body.append(mobileLabel);
282
- if (changed)
283
- body.append(node(document, "span", "comparison-change-label", changeLabel));
284
- let tableRow;
285
- if (unit.tableKey) {
286
- const table = node(document, "table", "saved-table");
287
- table.setAttribute("aria-label", "Saved table row");
288
- const tbody = node(document, "tbody");
289
- tableRow = node(document, "tr");
290
- tbody.append(tableRow);
291
- table.append(tbody);
292
- body.append(table);
293
- }
294
- for (const row of unit.rows) {
295
- const block = row[`${side}Block`];
296
- const rendered = renderBlock(document, row, side, mode, changeMap.get(row.changeId), numbers[side]);
297
- if (row.changeId) {
298
- rendered.dataset.changeId = row.changeId;
299
- if (!targets.has(row.changeId))
300
- targets.set(row.changeId, pair);
301
- }
302
- if (row.moveId && block) {
303
- rendered.append(node(document, "small", "metadata-note", `↔ Relocated content · ${side === "before" ? "original" : "result"} position (identity not certain)`));
304
- }
305
- if (row.kind === "removed" && side === "before" && !row.moveId) {
306
- rendered.title = "Removed content has no target on the latest page.";
307
- }
308
- if (tableRow) {
309
- const td = node(document, block?.tag === "th" ? "th" : "td");
310
- const colspan = Number(block?.attributes?.colspan);
311
- if (Number.isInteger(colspan) && colspan > 0 && colspan <= 100)
312
- td.colSpan = colspan;
313
- td.append(rendered);
314
- tableRow.append(td);
315
- if (block?.attributes?.rowspan && block.attributes.rowspan !== "1") {
316
- rendered.append(node(document, "small", "structure-note", `Saved row span: ${block.attributes.rowspan} · shown in row order`));
317
- }
318
- }
319
- else
320
- body.append(rendered);
321
- }
322
- cell.append(body);
323
- pair.append(cell);
324
- }
325
- return pair;
326
- };
327
- const changed = units.map((unit) => unit.rows.some((row) => row.kind !== "unchanged"));
328
- const visible = new Set();
329
- for (const [index, value] of changed.entries())
330
- if (value) {
331
- for (let nearby = Math.max(0, index - 2); nearby <= Math.min(units.length - 1, index + 2); nearby++)
332
- visible.add(nearby);
333
- }
334
- if (!changes.length) {
335
- root.append(node(document, "p", "comparison-notice", "No changes detected in this comparison format."));
336
- for (let index = 0; index < Math.min(3, units.length); index++)
337
- visible.add(index);
338
- }
339
- for (let index = 0; index < units.length;) {
340
- if (visible.has(index)) {
341
- root.append(renderUnit(units[index++]));
342
- continue;
343
- }
344
- const start = index;
345
- while (index < units.length && !visible.has(index))
346
- index++;
347
- const end = index;
348
- let next = start;
349
- const expand = node(document, "button", "comparison-expand");
350
- expand.type = "button";
351
- const update = () => {
352
- expand.textContent = `↕ Show ${Math.min(20, end - next)} of ${end - next} unchanged ${mode === "source" ? "lines" : "blocks"}`;
353
- expand.setAttribute("aria-label", expand.textContent);
354
- };
355
- update();
356
- expand.addEventListener("click", () => {
357
- const limit = Math.min(end, next + 20);
358
- while (next < limit)
359
- expand.before(renderUnit(units[next++]));
360
- if (next === end) {
361
- const previous = expand.previousElementSibling;
362
- previous.tabIndex = -1;
363
- previous.focus({ preventScroll: true });
364
- expand.remove();
365
- }
366
- else
367
- update();
368
- });
369
- root.append(expand);
370
- }
371
- },
372
- select(index, { scroll = false } = {}) {
373
- selected?.classList.remove("comparison-current");
374
- selected = targets.get(changes[index]?.id || `legacy-change-${index}`);
375
- selected?.classList.add("comparison-current");
376
- if (scroll && selected)
377
- selected.scrollIntoView({ block: "center", behavior: "instant" });
378
- },
379
- };
380
- }