@erdemtuna/doc-review 0.9.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.
- package/README.md +58 -222
- package/{src → lib}/SKILL.md +5 -2
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +1700 -0
- package/lib/chrome-session.js +73 -0
- package/lib/chrome.html +53 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/controller-store.js +96 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +19 -0
- package/lib/feedback-controller.js +113 -0
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +217 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +325 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +303 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/lib/view-identity.js +112 -0
- package/package.json +50 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/chrome.css +0 -652
- package/src/chrome.html +0 -196
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/document-trust.js +0 -0
package/src/comment-target.js
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
2
|
-
|
|
3
|
-
export function groupCommentTargets(targets) {
|
|
4
|
-
const groups = new Map();
|
|
5
|
-
for (const [id, element] of targets) {
|
|
6
|
-
if (!element?.isConnected) continue;
|
|
7
|
-
if (!groups.has(element)) groups.set(element, []);
|
|
8
|
-
groups.get(element).push(id);
|
|
9
|
-
}
|
|
10
|
-
return groups;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function nextCommentId(ids, activeId) {
|
|
14
|
-
return ids.length ? ids[(ids.indexOf(activeId) + 1) % ids.length] : null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Retarget dwell is independent of initial intent; revisiting a candidate
|
|
18
|
-
* does not restart its clock. The caller owns corridor and selection policy. */
|
|
19
|
-
export function createHoverIntent(commit, {
|
|
20
|
-
initial = 150, retarget = 100, exit = 120,
|
|
21
|
-
schedule = setTimeout, unschedule = clearTimeout,
|
|
22
|
-
} = {}) {
|
|
23
|
-
let current = null;
|
|
24
|
-
let candidate = null;
|
|
25
|
-
let timer = null;
|
|
26
|
-
const cancel = () => {
|
|
27
|
-
unschedule(timer);
|
|
28
|
-
timer = null;
|
|
29
|
-
candidate = null;
|
|
30
|
-
};
|
|
31
|
-
return {
|
|
32
|
-
cancel,
|
|
33
|
-
reset() { cancel(); current = null; },
|
|
34
|
-
request(key, payload, immediate = false) {
|
|
35
|
-
if (key === current && !immediate) { cancel(); return; }
|
|
36
|
-
if (timer !== null && candidate === key && !immediate) return;
|
|
37
|
-
cancel();
|
|
38
|
-
candidate = key;
|
|
39
|
-
const apply = () => {
|
|
40
|
-
timer = null;
|
|
41
|
-
current = key;
|
|
42
|
-
candidate = null;
|
|
43
|
-
commit(payload);
|
|
44
|
-
};
|
|
45
|
-
if (immediate) apply();
|
|
46
|
-
else timer = schedule(apply, key === null ? exit : current === null ? initial : retarget);
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function normalizeSelectionRange(map, range) {
|
|
52
|
-
let first = null;
|
|
53
|
-
let last = null;
|
|
54
|
-
// Element offsets index children, not characters. Intersect the filtered text
|
|
55
|
-
// nodes so an exclusive endpoint before the next block never includes it.
|
|
56
|
-
for (const entry of map) {
|
|
57
|
-
const length = entry.node.nodeValue.length;
|
|
58
|
-
if (range.comparePoint(entry.node, length) < 0 || range.comparePoint(entry.node, 0) > 0) continue;
|
|
59
|
-
const from = entry.node === range.startContainer ? range.startOffset : 0;
|
|
60
|
-
const to = entry.node === range.endContainer ? range.endOffset : length;
|
|
61
|
-
if (to <= from) continue;
|
|
62
|
-
first ||= { entry, offset: from };
|
|
63
|
-
last = { entry, offset: to };
|
|
64
|
-
}
|
|
65
|
-
if (!first || !last) return null;
|
|
66
|
-
const normalized = range.cloneRange();
|
|
67
|
-
normalized.setStart(first.entry.node, first.offset);
|
|
68
|
-
normalized.setEnd(last.entry.node, last.offset);
|
|
69
|
-
return {
|
|
70
|
-
start: first.entry.start + first.offset,
|
|
71
|
-
end: last.entry.start + last.offset,
|
|
72
|
-
range: normalized,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function sameRange(one, two) {
|
|
77
|
-
return !!one && !!two &&
|
|
78
|
-
one.startContainer === two.startContainer && one.startOffset === two.startOffset &&
|
|
79
|
-
one.endContainer === two.endContainer && one.endOffset === two.endOffset;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function pointInCommentApproach(point, target, action) {
|
|
83
|
-
if (!target || !action || action.width <= 0 || action.height <= 0) return false;
|
|
84
|
-
return point.x >= Math.min(target.left, action.left) &&
|
|
85
|
-
point.x <= Math.max(target.right, action.right) &&
|
|
86
|
-
point.y >= Math.min(target.top, action.top) &&
|
|
87
|
-
point.y <= Math.max(target.bottom, action.bottom);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export function sanitizeRect(rect) {
|
|
91
|
-
if (!rect) return null;
|
|
92
|
-
const values = ["left", "top", "right", "bottom", "width", "height"];
|
|
93
|
-
if (!values.every((key) => finite(rect[key]))) return null;
|
|
94
|
-
if (rect.width < 0 || rect.height < 0 || rect.right < rect.left || rect.bottom < rect.top) return null;
|
|
95
|
-
return {
|
|
96
|
-
left: rect.left,
|
|
97
|
-
top: rect.top,
|
|
98
|
-
right: rect.right,
|
|
99
|
-
bottom: rect.bottom,
|
|
100
|
-
width: rect.width,
|
|
101
|
-
height: rect.height,
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function sanitizeClientRect(rect, viewport) {
|
|
106
|
-
const safe = sanitizeRect(rect);
|
|
107
|
-
if (!safe || !viewport || !finite(viewport.width) || !finite(viewport.height)) return null;
|
|
108
|
-
const { left: rawLeft, top: rawTop, right: rawRight, bottom: rawBottom } = safe;
|
|
109
|
-
if (rawRight <= 0 || rawBottom <= 0 || rawLeft >= viewport.width || rawTop >= viewport.height) return null;
|
|
110
|
-
const left = Math.max(0, Math.min(viewport.width, rawLeft));
|
|
111
|
-
const top = Math.max(0, Math.min(viewport.height, rawTop));
|
|
112
|
-
const right = Math.max(left, Math.min(viewport.width, rawRight));
|
|
113
|
-
const bottom = Math.max(top, Math.min(viewport.height, rawBottom));
|
|
114
|
-
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function sanitizeClientRects(rects, viewport, limit = 64) {
|
|
118
|
-
if (!Array.isArray(rects)) return [];
|
|
119
|
-
return rects.slice(0, limit).map((rect) => sanitizeClientRect(rect, viewport)).filter(Boolean);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function sanitizeClipRect(rect, viewport) {
|
|
123
|
-
const safe = sanitizeClientRect(rect, viewport);
|
|
124
|
-
return safe && safe.width > 0 && safe.height > 0 ? safe : null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function sanitizeRelation(value) {
|
|
128
|
-
return ["visible", "above", "below", "unavailable"].includes(value) ? value : "unavailable";
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function targetMessage(
|
|
132
|
-
{ kind, quote, anchor, rects, generation, relation, clip, horizontal },
|
|
133
|
-
viewport
|
|
134
|
-
) {
|
|
135
|
-
const safeRects = sanitizeClientRects(rects, viewport);
|
|
136
|
-
const safeClip = sanitizeClipRect(clip, viewport);
|
|
137
|
-
if (!Number.isSafeInteger(generation) || generation < 1) return null;
|
|
138
|
-
const safeRelation = sanitizeRelation(relation);
|
|
139
|
-
if (!safeClip || (safeRelation === "visible" && !safeRects.length)) return null;
|
|
140
|
-
return {
|
|
141
|
-
kind: kind === "element" ? "element" : "selection",
|
|
142
|
-
quote: String(quote || ""),
|
|
143
|
-
anchor: anchor || null,
|
|
144
|
-
rects: safeRects,
|
|
145
|
-
generation,
|
|
146
|
-
relation: safeRelation,
|
|
147
|
-
clip: safeClip,
|
|
148
|
-
horizontal: finite(horizontal) ? Math.max(safeClip.left, Math.min(safeClip.right, horizontal)) : null,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function acceptsTargetGeometry(currentGeneration, incoming) {
|
|
153
|
-
return !!incoming && Number.isSafeInteger(incoming.generation) && incoming.generation === currentGeneration;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function acceptedOpenGeneration(
|
|
157
|
-
{ pendingGeneration = null, retargetGeneration = null },
|
|
158
|
-
{ accepted, requestedGeneration }
|
|
159
|
-
) {
|
|
160
|
-
if (!accepted) return pendingGeneration;
|
|
161
|
-
if (requestedGeneration === retargetGeneration) return retargetGeneration;
|
|
162
|
-
if (requestedGeneration === pendingGeneration) return pendingGeneration;
|
|
163
|
-
return null;
|
|
164
|
-
}
|
package/src/comparison-view.js
DELETED
|
@@ -1,337 +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) element.className = className;
|
|
13
|
-
if (text !== undefined) element.textContent = text;
|
|
14
|
-
return element;
|
|
15
|
-
};
|
|
16
|
-
const sourceLines = (block) => block?.startLine ?? "";
|
|
17
|
-
const rowParent = (block) => {
|
|
18
|
-
if (!["td", "th"].includes(block?.tag)) return null;
|
|
19
|
-
// A bare path such as table/tbody/tr does not distinguish repeated rows.
|
|
20
|
-
// Only the saved positional selector is sufficient for row reconstruction.
|
|
21
|
-
return block.selector?.match(/^(.* > tr(?::nth-of-type\(\d+\))?(?:\[id=".*"\])?) > (?:td|th):nth-of-type\(\d+\)/)?.[1] || null;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
function appendRuns(element, block, segments, side) {
|
|
25
|
-
const document = element.ownerDocument;
|
|
26
|
-
const parts = segments?.length
|
|
27
|
-
? segments.filter((part) => side === "before" ? !part.added : !part.removed)
|
|
28
|
-
: [{ value: block.text }];
|
|
29
|
-
const runs = block.runs || [{ text: block.text, marks: [] }];
|
|
30
|
-
let runIndex = 0;
|
|
31
|
-
let runOffset = 0;
|
|
32
|
-
for (const part of parts) {
|
|
33
|
-
let offset = 0;
|
|
34
|
-
while (offset < part.value.length) {
|
|
35
|
-
while (runIndex < runs.length && runOffset >= runs[runIndex].text.length) {
|
|
36
|
-
runIndex++;
|
|
37
|
-
runOffset = 0;
|
|
38
|
-
}
|
|
39
|
-
const run = runs[runIndex];
|
|
40
|
-
const length = Math.min(part.value.length - offset, run ? run.text.length - runOffset : Infinity);
|
|
41
|
-
let leaf = document.createTextNode(part.value.slice(offset, offset + length));
|
|
42
|
-
for (const mark of run?.marks || []) {
|
|
43
|
-
if (!Object.hasOwn(marks, mark)) continue;
|
|
44
|
-
const wrapper = node(document, marks[mark]);
|
|
45
|
-
wrapper.append(leaf);
|
|
46
|
-
leaf = wrapper;
|
|
47
|
-
}
|
|
48
|
-
if (run?.href) {
|
|
49
|
-
const reference = node(document, "span", "saved-link");
|
|
50
|
-
reference.title = `Saved link: ${run.href} (not opened from history)`;
|
|
51
|
-
reference.append(leaf);
|
|
52
|
-
leaf = reference;
|
|
53
|
-
}
|
|
54
|
-
if (side === "before" ? part.removed : part.added) {
|
|
55
|
-
const emphasis = node(document, side === "before" ? "del" : "ins");
|
|
56
|
-
emphasis.append(leaf);
|
|
57
|
-
leaf = emphasis;
|
|
58
|
-
}
|
|
59
|
-
element.append(leaf);
|
|
60
|
-
offset += length;
|
|
61
|
-
runOffset += length;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function metadata(block, change, side) {
|
|
67
|
-
if (!change || !block) return [];
|
|
68
|
-
const other = change[side === "before" ? "afterBlock" : "beforeBlock"];
|
|
69
|
-
if (!other) return [];
|
|
70
|
-
const labels = [];
|
|
71
|
-
if (block.tag !== other.tag) labels.push(`Element: ${block.tag}`);
|
|
72
|
-
for (const key of new Set([...Object.keys(block.attributes || {}), ...Object.keys(other.attributes || {})])) {
|
|
73
|
-
if (block.attributes?.[key] === other.attributes?.[key]) continue;
|
|
74
|
-
const name = { href: "Link destination", src: "Image reference", alt: "Alternative text", id: "Element ID" }[key] || key;
|
|
75
|
-
labels.push(`${name}: ${block.attributes?.[key] ?? "(not set)"}`);
|
|
76
|
-
}
|
|
77
|
-
const links = [...new Set((block.runs || []).map((run) => run.href).filter(Boolean))];
|
|
78
|
-
const otherLinks = [...new Set((other.runs || []).map((run) => run.href).filter(Boolean))];
|
|
79
|
-
if (JSON.stringify(links) !== JSON.stringify(otherLinks)) labels.push(`Link destination: ${links.join(", ") || "(none)"}`);
|
|
80
|
-
if (change.fields?.includes("runs") && block.text === other.text && !labels.length) labels.push("Inline formatting changed");
|
|
81
|
-
if (change.fields?.includes("structure") && block.tag === other.tag) {
|
|
82
|
-
labels.push(`Structure: ${(block.path || []).join(" › ") || "document root"}`);
|
|
83
|
-
}
|
|
84
|
-
return labels;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function renderBlock(document, row, side, mode, change, listNumbers) {
|
|
88
|
-
const block = row[`${side}Block`];
|
|
89
|
-
const wrapper = node(document, "div", "comparison-block");
|
|
90
|
-
if (!block) return wrapper;
|
|
91
|
-
if (mode === "source") {
|
|
92
|
-
const pre = node(document, "pre");
|
|
93
|
-
appendRuns(pre, block, row.segments, side);
|
|
94
|
-
wrapper.append(pre);
|
|
95
|
-
if (!block.text.endsWith("\n")) wrapper.append(node(document, "small", "newline-note", "↵ No newline at end of file"));
|
|
96
|
-
const other = row[side === "before" ? "afterBlock" : "beforeBlock"];
|
|
97
|
-
const ending = (text) => text.endsWith("\r\n") ? "CRLF" : text.endsWith("\n") ? "LF" : "none";
|
|
98
|
-
if (other && ending(block.text) !== ending(other.text)) {
|
|
99
|
-
wrapper.append(node(document, "small", "newline-note", `Line ending: ${ending(block.text)}`));
|
|
100
|
-
}
|
|
101
|
-
return wrapper;
|
|
102
|
-
}
|
|
103
|
-
const tag = block.tag || "p";
|
|
104
|
-
if (tag === "img") {
|
|
105
|
-
const image = node(document, "figure", "saved-image");
|
|
106
|
-
image.append(node(document, "figcaption", "", `Image: ${block.attributes?.alt || "No saved alternative text"}`));
|
|
107
|
-
image.append(node(document, "small", "", `Saved reference: ${block.attributes?.src || "unavailable"} · Image not loaded`));
|
|
108
|
-
wrapper.append(image);
|
|
109
|
-
} else if (tag === "hr") {
|
|
110
|
-
wrapper.append(node(document, "hr"));
|
|
111
|
-
} else if (containers.has(tag) && !block.text) {
|
|
112
|
-
if (change) wrapper.append(node(document, "small", "structure-note", `${tag} structure ${row.kind}`));
|
|
113
|
-
} else {
|
|
114
|
-
const text = node(document, readable.has(tag) ? tag : tag === "li" ? "li" : "p");
|
|
115
|
-
appendRuns(text, block, row.segments, side);
|
|
116
|
-
if (tag === "li") {
|
|
117
|
-
const listTag = [...(block.path || [])].reverse().find((item) => /^(ul|ol)(#|$)/.test(item))?.split("#")[0];
|
|
118
|
-
const list = node(document, listTag || "ul", "saved-list");
|
|
119
|
-
if (listTag === "ol") list.start = listNumbers.get(block) ?? 1;
|
|
120
|
-
const depth = (block.path || []).filter((item) => /^(ul|ol)(#|$)/.test(item)).length;
|
|
121
|
-
list.style.marginInlineStart = `${Math.min(6, Math.max(0, depth - 1)) * 14}px`;
|
|
122
|
-
list.append(text);
|
|
123
|
-
wrapper.append(list);
|
|
124
|
-
if (!listTag) wrapper.append(node(document, "small", "structure-note", "List item · parent structure unavailable"));
|
|
125
|
-
} else {
|
|
126
|
-
wrapper.append(text);
|
|
127
|
-
if (["td", "th"].includes(tag) && !rowParent(block)) {
|
|
128
|
-
wrapper.append(node(document, "small", "structure-note", "Table cell · row structure unavailable"));
|
|
129
|
-
} else if (["button", "summary", "dt", "dd", "caption", "figcaption"].includes(tag)) {
|
|
130
|
-
wrapper.append(node(document, "small", "structure-note", `${tag === "button" ? "Saved button · inactive" : tag}`));
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
for (const label of metadata(block, change, side)) wrapper.append(node(document, "small", "metadata-note", label));
|
|
135
|
-
return wrapper;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function legacyRows(comparison) {
|
|
139
|
-
return (comparison.changes || []).map((change, index) => ({
|
|
140
|
-
id: `legacy-row-${index}`, kind: change.kind, changeId: change.id || `legacy-change-${index}`,
|
|
141
|
-
beforeBlock: change.beforeBlock || (change.kind === "added" ? null : { text: change.before || "", tag: "p" }),
|
|
142
|
-
afterBlock: change.afterBlock || (change.kind === "removed" ? null : { text: change.after || "", tag: "p" }),
|
|
143
|
-
segments: change.segments || [],
|
|
144
|
-
}));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function listPositions(rows, side) {
|
|
148
|
-
const positions = new Map();
|
|
149
|
-
const lists = new Map();
|
|
150
|
-
for (const row of rows) {
|
|
151
|
-
const block = row[`${side}Block`];
|
|
152
|
-
if (!block) continue;
|
|
153
|
-
if (block.tag === "ol") {
|
|
154
|
-
const key = block.selector.replace(/\[id=".*"\]$/, "");
|
|
155
|
-
const reversed = "reversed" in (block.attributes || {});
|
|
156
|
-
const children = reversed ? rows.filter((candidate) => {
|
|
157
|
-
const child = candidate[`${side}Block`];
|
|
158
|
-
return child?.tag === "li" && child.selector?.replace(/ > li:nth-of-type\(\d+\).*$/, "") === key;
|
|
159
|
-
}).length : 1;
|
|
160
|
-
const start = block.attributes?.start;
|
|
161
|
-
lists.set(key, {
|
|
162
|
-
next: start !== undefined && Number.isSafeInteger(Number(start)) ? Number(start) : children,
|
|
163
|
-
step: reversed ? -1 : 1,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
if (block.tag !== "li") continue;
|
|
167
|
-
const key = block.selector?.replace(/ > li:nth-of-type\(\d+\).*$/, "") || (block.path || []).join("/");
|
|
168
|
-
const list = lists.get(key) || { next: 1, step: 1 };
|
|
169
|
-
lists.set(key, list);
|
|
170
|
-
const explicit = block.attributes?.value;
|
|
171
|
-
if (explicit !== undefined && Number.isFinite(Number(explicit))) list.next = Number(explicit);
|
|
172
|
-
positions.set(block, list.next);
|
|
173
|
-
list.next += list.step;
|
|
174
|
-
}
|
|
175
|
-
return positions;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function createComparisonView(root) {
|
|
179
|
-
const document = root.ownerDocument;
|
|
180
|
-
const headingSlot = root.parentElement?.querySelector(".comparison-heading-slot");
|
|
181
|
-
let signature;
|
|
182
|
-
let inputs;
|
|
183
|
-
let changes = [];
|
|
184
|
-
let targets = new Map();
|
|
185
|
-
let selected;
|
|
186
|
-
return {
|
|
187
|
-
render(comparison, { mode = "content", key = "" } = {}) {
|
|
188
|
-
const nextInputs = [key, mode, comparison.version, comparison.status, comparison.available, comparison.rows, comparison.changes];
|
|
189
|
-
if (inputs && nextInputs.every((value, index) => value === inputs[index])) return;
|
|
190
|
-
inputs = nextInputs;
|
|
191
|
-
// Immutable data identity includes content, not mutable timing/freshness notices.
|
|
192
|
-
const nextSignature = `${key}:${mode}:${JSON.stringify([comparison.version, comparison.status, comparison.available, comparison.rows, comparison.changes])}`;
|
|
193
|
-
if (signature === nextSignature) return;
|
|
194
|
-
signature = nextSignature;
|
|
195
|
-
selected = null;
|
|
196
|
-
targets = new Map();
|
|
197
|
-
changes = comparison.changes || [];
|
|
198
|
-
const rows = comparison.rows || legacyRows(comparison);
|
|
199
|
-
root.replaceChildren();
|
|
200
|
-
headingSlot?.replaceChildren();
|
|
201
|
-
root.className = `change-detail comparison-surface comparison-${mode}`;
|
|
202
|
-
if (!rows.length) {
|
|
203
|
-
if (comparison.available !== false) root.textContent = "No changes detected in this comparison format.";
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
// A budget failure is explicit and complete, not a silently truncated view.
|
|
207
|
-
const nodes = rows.reduce((sum, row) => sum + ["before", "after"].reduce((n, side) => {
|
|
208
|
-
const runs = row[`${side}Block`]?.runs || [];
|
|
209
|
-
const depth = runs.reduce((maximum, run) => Math.max(maximum, run.marks.length), 0);
|
|
210
|
-
return n + 12 + (runs.length + (row.segments?.length || 1)) * (depth + 4);
|
|
211
|
-
}, 0), 0);
|
|
212
|
-
if (nodes > 120000) {
|
|
213
|
-
root.append(node(document, "p", "comparison-budget", "Comparison rendering limit exceeded. The saved comparison is intact; use Source or a smaller review round."));
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
if (!comparison.rows) root.append(node(document, "p", "comparison-notice", "Saved excerpts only · unchanged context was not recorded in this comparison."));
|
|
217
|
-
const heads = node(document, "div", "comparison-headings");
|
|
218
|
-
heads.append(node(document, "div", "", "Before · feedback sent"), node(document, "div", "", "After · captured result"));
|
|
219
|
-
(headingSlot || root).append(heads);
|
|
220
|
-
const changeMap = new Map(changes.map((change, index) => [change.id || `legacy-change-${index}`, change]));
|
|
221
|
-
const numbers = { before: listPositions(rows, "before"), after: listPositions(rows, "after") };
|
|
222
|
-
const units = [];
|
|
223
|
-
for (const row of rows) {
|
|
224
|
-
if (row.kind === "unchanged" && ["beforeBlock", "afterBlock"].every((side) =>
|
|
225
|
-
!row[side] || (containers.has(row[side].tag) && !row[side].text))) continue;
|
|
226
|
-
const tableKey = mode === "content" && (rowParent(row.beforeBlock) || rowParent(row.afterBlock))
|
|
227
|
-
? `${rowParent(row.beforeBlock) || ""}|${rowParent(row.afterBlock) || ""}` : null;
|
|
228
|
-
const previous = units.at(-1);
|
|
229
|
-
if (tableKey && previous?.tableKey === tableKey) previous.rows.push(row);
|
|
230
|
-
else units.push({ rows: [row], tableKey });
|
|
231
|
-
}
|
|
232
|
-
const renderUnit = (unit) => {
|
|
233
|
-
const pair = node(document, "div", "comparison-row");
|
|
234
|
-
const unchanged = unit.rows.every((row) => row.kind === "unchanged");
|
|
235
|
-
const kinds = [...new Set(unit.rows.map((row) => row.kind).filter((kind) => kind !== "unchanged"))];
|
|
236
|
-
const changeLabel = kinds.map((kind) => ({ added: "Added", removed: "Removed", modified: "Modified" })[kind] || "Changed").join(" · ");
|
|
237
|
-
if (unchanged) pair.classList.add("comparison-unchanged");
|
|
238
|
-
pair.dataset.rowId = unit.rows[0].id;
|
|
239
|
-
for (const side of ["before", "after"]) {
|
|
240
|
-
const cell = node(document, "section", `comparison-cell comparison-${side}`);
|
|
241
|
-
cell.setAttribute("aria-label", `${side === "before" ? "Before" : "After"} · ${unchanged ? "unchanged" : changeLabel.toLowerCase()}`);
|
|
242
|
-
if (unit.rows.every((row) => !row[`${side}Block`])) cell.classList.add("comparison-gap");
|
|
243
|
-
const changed = unit.rows.some((row) => row.kind !== "unchanged" && row[`${side}Block`]);
|
|
244
|
-
if (changed) cell.classList.add(side === "before" ? "comparison-removed" : "comparison-added");
|
|
245
|
-
const gutter = node(document, "span", "comparison-gutter", mode === "source"
|
|
246
|
-
? `${sourceLines(unit.rows[0][`${side}Block`])} ${changed ? side === "before" ? "−" : "+" : ""}`
|
|
247
|
-
: changed ? side === "before" ? "−" : "+" : "");
|
|
248
|
-
gutter.setAttribute("aria-hidden", "true");
|
|
249
|
-
cell.append(gutter);
|
|
250
|
-
const body = node(document, "div", "comparison-cell-body");
|
|
251
|
-
const mobileLabel = node(document, "span", "comparison-mobile-label", unchanged
|
|
252
|
-
? mode === "source" ? `Unchanged · Before ${sourceLines(unit.rows[0].beforeBlock)} / After ${sourceLines(unit.rows[0].afterBlock)}` : "Unchanged · both versions"
|
|
253
|
-
: `${side === "before" ? "Before" : "After"} · ${changeLabel.toLowerCase()}`);
|
|
254
|
-
body.append(mobileLabel);
|
|
255
|
-
if (changed) body.append(node(document, "span", "comparison-change-label", changeLabel));
|
|
256
|
-
let tableRow;
|
|
257
|
-
if (unit.tableKey) {
|
|
258
|
-
const table = node(document, "table", "saved-table");
|
|
259
|
-
table.setAttribute("aria-label", "Saved table row");
|
|
260
|
-
const tbody = node(document, "tbody");
|
|
261
|
-
tableRow = node(document, "tr");
|
|
262
|
-
tbody.append(tableRow);
|
|
263
|
-
table.append(tbody);
|
|
264
|
-
body.append(table);
|
|
265
|
-
}
|
|
266
|
-
for (const row of unit.rows) {
|
|
267
|
-
const block = row[`${side}Block`];
|
|
268
|
-
const rendered = renderBlock(document, row, side, mode, changeMap.get(row.changeId), numbers[side]);
|
|
269
|
-
if (row.changeId) {
|
|
270
|
-
rendered.dataset.changeId = row.changeId;
|
|
271
|
-
if (!targets.has(row.changeId)) targets.set(row.changeId, pair);
|
|
272
|
-
}
|
|
273
|
-
if (row.moveId && block) {
|
|
274
|
-
rendered.append(node(document, "small", "metadata-note", `↔ Relocated content · ${side === "before" ? "original" : "result"} position (identity not certain)`));
|
|
275
|
-
}
|
|
276
|
-
if (row.kind === "removed" && side === "before" && !row.moveId) {
|
|
277
|
-
rendered.title = "Removed content has no target on the latest page.";
|
|
278
|
-
}
|
|
279
|
-
if (tableRow) {
|
|
280
|
-
const td = node(document, block?.tag === "th" ? "th" : "td");
|
|
281
|
-
const colspan = Number(block?.attributes?.colspan);
|
|
282
|
-
if (Number.isInteger(colspan) && colspan > 0 && colspan <= 100) td.colSpan = colspan;
|
|
283
|
-
td.append(rendered);
|
|
284
|
-
tableRow.append(td);
|
|
285
|
-
if (block?.attributes?.rowspan && block.attributes.rowspan !== "1") {
|
|
286
|
-
rendered.append(node(document, "small", "structure-note", `Saved row span: ${block.attributes.rowspan} · shown in row order`));
|
|
287
|
-
}
|
|
288
|
-
} else body.append(rendered);
|
|
289
|
-
}
|
|
290
|
-
cell.append(body);
|
|
291
|
-
pair.append(cell);
|
|
292
|
-
}
|
|
293
|
-
return pair;
|
|
294
|
-
};
|
|
295
|
-
const changed = units.map((unit) => unit.rows.some((row) => row.kind !== "unchanged"));
|
|
296
|
-
const visible = new Set();
|
|
297
|
-
for (const [index, value] of changed.entries()) if (value) {
|
|
298
|
-
for (let nearby = Math.max(0, index - 2); nearby <= Math.min(units.length - 1, index + 2); nearby++) visible.add(nearby);
|
|
299
|
-
}
|
|
300
|
-
if (!changes.length) {
|
|
301
|
-
root.append(node(document, "p", "comparison-notice", "No changes detected in this comparison format."));
|
|
302
|
-
for (let index = 0; index < Math.min(3, units.length); index++) visible.add(index);
|
|
303
|
-
}
|
|
304
|
-
for (let index = 0; index < units.length;) {
|
|
305
|
-
if (visible.has(index)) { root.append(renderUnit(units[index++])); continue; }
|
|
306
|
-
const start = index;
|
|
307
|
-
while (index < units.length && !visible.has(index)) index++;
|
|
308
|
-
const end = index;
|
|
309
|
-
let next = start;
|
|
310
|
-
const expand = node(document, "button", "comparison-expand");
|
|
311
|
-
expand.type = "button";
|
|
312
|
-
const update = () => {
|
|
313
|
-
expand.textContent = `↕ Show ${Math.min(20, end - next)} of ${end - next} unchanged ${mode === "source" ? "lines" : "blocks"}`;
|
|
314
|
-
expand.setAttribute("aria-label", expand.textContent);
|
|
315
|
-
};
|
|
316
|
-
update();
|
|
317
|
-
expand.addEventListener("click", () => {
|
|
318
|
-
const limit = Math.min(end, next + 20);
|
|
319
|
-
while (next < limit) expand.before(renderUnit(units[next++]));
|
|
320
|
-
if (next === end) {
|
|
321
|
-
const previous = expand.previousElementSibling;
|
|
322
|
-
previous.tabIndex = -1;
|
|
323
|
-
previous.focus({ preventScroll: true });
|
|
324
|
-
expand.remove();
|
|
325
|
-
} else update();
|
|
326
|
-
});
|
|
327
|
-
root.append(expand);
|
|
328
|
-
}
|
|
329
|
-
},
|
|
330
|
-
select(index, { scroll = false } = {}) {
|
|
331
|
-
selected?.classList.remove("comparison-current");
|
|
332
|
-
selected = targets.get(changes[index]?.id || `legacy-change-${index}`);
|
|
333
|
-
selected?.classList.add("comparison-current");
|
|
334
|
-
if (scroll && selected) selected.scrollIntoView({ block: "center", behavior: "instant" });
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { parse, serialize } from "parse5";
|
|
2
|
-
|
|
3
|
-
export const INTERACTIVE_FILE_NOTICE = "Inline page interactions run automatically. Editing is feedback-only. External scripts, application imports, workers, and embedded applications are unsupported; use localhost for those applications. Only the currently visible view is captured.";
|
|
4
|
-
|
|
5
|
-
const attr = (node, name) => node.attrs?.find((item) => item.name === name)?.value;
|
|
6
|
-
const javascriptTypes = new Set([
|
|
7
|
-
"application/ecmascript", "application/javascript", "application/x-ecmascript", "application/x-javascript",
|
|
8
|
-
"text/ecmascript", "text/javascript", "text/javascript1.0", "text/javascript1.1",
|
|
9
|
-
"text/javascript1.2", "text/javascript1.3", "text/javascript1.4", "text/javascript1.5",
|
|
10
|
-
"text/jscript", "text/livescript", "text/x-ecmascript", "text/x-javascript",
|
|
11
|
-
]);
|
|
12
|
-
const urlAttributes = new Set(["href", "src", "action", "formaction", "data", "background"]);
|
|
13
|
-
|
|
14
|
-
function dependency(node) {
|
|
15
|
-
const tag = node.tagName;
|
|
16
|
-
const source = attr(node, "src") ?? attr(node, "href");
|
|
17
|
-
if (tag === "script" && source !== undefined) return { kind: "external-script", source };
|
|
18
|
-
if (tag === "script" && ["importmap", "speculationrules"].includes((attr(node, "type") || "").trim().toLowerCase())) {
|
|
19
|
-
return { kind: "application-import-map" };
|
|
20
|
-
}
|
|
21
|
-
if (["iframe", "frame", "object", "embed"].includes(tag)) {
|
|
22
|
-
return { kind: "embedded-application", source: attr(node, "src") || attr(node, "data") };
|
|
23
|
-
}
|
|
24
|
-
if (tag === "link") {
|
|
25
|
-
const rel = (attr(node, "rel") || "").toLowerCase().split(/\s+/);
|
|
26
|
-
if (rel.includes("modulepreload") || (rel.includes("preload") && ["script", "worker"].includes((attr(node, "as") || "").toLowerCase()))) {
|
|
27
|
-
return { kind: "script-preload", source: attr(node, "href") };
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Analyze authored source, never an injected SDK or a runtime DOM serialization. */
|
|
34
|
-
export function analyzeDocumentExecution(source) {
|
|
35
|
-
if (source instanceof Uint8Array) source = Buffer.from(source).toString("utf8");
|
|
36
|
-
if (typeof source !== "string") throw new TypeError("HTML source bytes or text are required.");
|
|
37
|
-
const document = parse(source);
|
|
38
|
-
let active = false;
|
|
39
|
-
let application = false;
|
|
40
|
-
const visit = (node, includeTemplates = false) => {
|
|
41
|
-
if (dependency(node)) active = application = true;
|
|
42
|
-
if (node.tagName === "script") {
|
|
43
|
-
const type = (attr(node, "type") ?? (attr(node, "language") ? `text/${attr(node, "language")}` : "")).trim().toLowerCase();
|
|
44
|
-
if (!type || type === "module" || javascriptTypes.has(type)) active = true;
|
|
45
|
-
}
|
|
46
|
-
for (const attribute of node.attrs || []) {
|
|
47
|
-
const name = attribute.name.toLowerCase();
|
|
48
|
-
if (/^on[a-z]/.test(name) || (urlAttributes.has(name) &&
|
|
49
|
-
/^javascript:/i.test(attribute.value.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+/, "")))) active = true;
|
|
50
|
-
}
|
|
51
|
-
for (const child of node.childNodes || []) visit(child, includeTemplates);
|
|
52
|
-
if (includeTemplates && node.content) visit(node.content, true);
|
|
53
|
-
};
|
|
54
|
-
visit(document);
|
|
55
|
-
// Dormant template content cannot instantiate itself. Once authored execution
|
|
56
|
-
// exists, include its dependencies in the same bounded application policy.
|
|
57
|
-
if (active) visit(document, true);
|
|
58
|
-
return {
|
|
59
|
-
executionMode: application ? "application" : active ? "interactive" : "static",
|
|
60
|
-
savePolicy: active ? "feedback-only" : "writable",
|
|
61
|
-
feedbackOnly: active,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function documentExecutionPolicy(page, source, preference = "auto") {
|
|
66
|
-
if (page.kind === "url") return { executionMode: "application", savePolicy: "feedback-only", feedbackOnly: true };
|
|
67
|
-
if (page.markdown) return { executionMode: "static", savePolicy: "feedback-only", feedbackOnly: true };
|
|
68
|
-
const policy = analyzeDocumentExecution(source);
|
|
69
|
-
return { ...policy, executionMode: preference === "static" ? "static" : policy.executionMode };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Keep inline interactions; the response CSP bounds dynamically created dependencies. */
|
|
73
|
-
export function transformInteractiveHtml(html) {
|
|
74
|
-
const document = parse(String(html));
|
|
75
|
-
const unsupportedDependencies = [];
|
|
76
|
-
let omitted = 0;
|
|
77
|
-
const visit = (parent) => {
|
|
78
|
-
parent.childNodes = (parent.childNodes || []).filter((node) => {
|
|
79
|
-
const unsupported = dependency(node);
|
|
80
|
-
if (unsupported) {
|
|
81
|
-
if (unsupportedDependencies.length < 100) {
|
|
82
|
-
unsupportedDependencies.push({ ...unsupported, source: String(unsupported.source || "").slice(0, 512) });
|
|
83
|
-
} else omitted += 1;
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
if (node.tagName === "base") return false;
|
|
87
|
-
if (node.tagName === "meta" && ["content-security-policy", "refresh"].includes((attr(node, "http-equiv") || "").trim().toLowerCase())) return false;
|
|
88
|
-
if (node.childNodes) visit(node);
|
|
89
|
-
if (node.content) visit(node.content);
|
|
90
|
-
return true;
|
|
91
|
-
});
|
|
92
|
-
};
|
|
93
|
-
visit(document);
|
|
94
|
-
if (omitted) unsupportedDependencies.push({ kind: "additional-dependencies", count: omitted });
|
|
95
|
-
const notice = INTERACTIVE_FILE_NOTICE + (unsupportedDependencies.length ? " Unsupported dependency elements were removed." : "");
|
|
96
|
-
const root = document.childNodes.find((node) => node.tagName === "html");
|
|
97
|
-
if (!root.childNodes.some((node) => node.tagName === "body")) {
|
|
98
|
-
const body = { nodeName: "body", tagName: "body", attrs: [], namespaceURI: "http://www.w3.org/1999/xhtml", childNodes: [], parentNode: root };
|
|
99
|
-
root.childNodes = root.childNodes.filter((node) => node.tagName !== "frameset");
|
|
100
|
-
root.childNodes.push(body);
|
|
101
|
-
}
|
|
102
|
-
return { html: serialize(document), unsupportedDependencies, notice };
|
|
103
|
-
}
|
package/src/edit-limits.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export const MAX_EDIT_CHARACTERS = 200_000;
|
|
2
|
-
|
|
3
|
-
const EDIT_FIELDS = ["before", "after", "before_html", "after_html", "moved_after", "moved_before"];
|
|
4
|
-
|
|
5
|
-
export function limitEditFields(input) {
|
|
6
|
-
const fields = {};
|
|
7
|
-
const truncatedFields = [];
|
|
8
|
-
for (const field of EDIT_FIELDS) {
|
|
9
|
-
const value = input[field];
|
|
10
|
-
if (typeof value !== "string") continue;
|
|
11
|
-
let end = 0;
|
|
12
|
-
let count = 0;
|
|
13
|
-
// Count Unicode code points, not UTF-16 halves of an astral character.
|
|
14
|
-
for (const character of value) {
|
|
15
|
-
if (count === MAX_EDIT_CHARACTERS) break;
|
|
16
|
-
end += character.length;
|
|
17
|
-
count += 1;
|
|
18
|
-
}
|
|
19
|
-
fields[field] = value.slice(0, end);
|
|
20
|
-
if (end < value.length) truncatedFields.push(field);
|
|
21
|
-
}
|
|
22
|
-
return { fields, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
|
|
23
|
-
}
|