@erdemtuna/doc-review 0.8.0 → 0.9.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 +155 -19
- package/package.json +4 -2
- package/src/SKILL.md +56 -7
- package/src/atomic-write.js +53 -0
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +52 -130
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/edit-limits.js +23 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/poll-transport.js +222 -0
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +240 -53
- package/src/setup-guidance.js +128 -0
- package/src/setup.js +41 -15
- package/src/state.js +257 -35
- package/src/view-identity.js +99 -0
|
@@ -0,0 +1,337 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** UI policy always describes the visible frame, not a newer on-disk source. */
|
|
2
|
+
export function executionPresentation(page, rendered, { loading = false, pendingReload = false, error = null } = {}) {
|
|
3
|
+
const eligible = page?.kind === "file" && !page.markdown;
|
|
4
|
+
const policy = rendered?.savePolicy;
|
|
5
|
+
return {
|
|
6
|
+
eligible,
|
|
7
|
+
preference: page?.executionPreference || "auto",
|
|
8
|
+
editDescription: policy === "writable" ? "Edits save directly to this file"
|
|
9
|
+
: policy === "feedback-only" ? "Edits are feedback for the agent to apply"
|
|
10
|
+
: "Waiting for the page's editing policy",
|
|
11
|
+
status: error || (pendingReload ? "Showing the previous page while reload is pending."
|
|
12
|
+
: loading ? "Loading page…" : ""),
|
|
13
|
+
detail: [
|
|
14
|
+
rendered?.executionNotice,
|
|
15
|
+
rendered?.executionMode === "static" && policy === "feedback-only" && eligible
|
|
16
|
+
? "Scripts are disabled for recovery. Edits still go to the agent; the source is not overwritten." : null,
|
|
17
|
+
].filter(Boolean).join(" "),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createExecutionControls({ api, sessionId, elements, changed, failed }) {
|
|
22
|
+
let busy = false;
|
|
23
|
+
let currentPage = null;
|
|
24
|
+
async function recover(preference) {
|
|
25
|
+
if (busy || !currentPage) return;
|
|
26
|
+
const key = currentPage.key;
|
|
27
|
+
busy = true;
|
|
28
|
+
if (elements.menu) {
|
|
29
|
+
elements.menu.open = false;
|
|
30
|
+
elements.menu.querySelector("summary")?.focus({ preventScroll: true });
|
|
31
|
+
}
|
|
32
|
+
elements.static.disabled = elements.auto.disabled = true;
|
|
33
|
+
try {
|
|
34
|
+
const result = await api(`/api/session/${sessionId}/execution`, {
|
|
35
|
+
method: "POST", body: JSON.stringify({ key, preference }),
|
|
36
|
+
});
|
|
37
|
+
if (currentPage?.key === key) changed(result.page);
|
|
38
|
+
// The server's reload event owns draft-safe frame replacement.
|
|
39
|
+
} catch (error) { failed(error); }
|
|
40
|
+
finally {
|
|
41
|
+
busy = false;
|
|
42
|
+
elements.static.disabled = elements.auto.disabled = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
elements.static.addEventListener("click", () => void recover("static"));
|
|
46
|
+
elements.auto.addEventListener("click", () => void recover("auto"));
|
|
47
|
+
return {
|
|
48
|
+
render(page, rendered, options) {
|
|
49
|
+
currentPage = page;
|
|
50
|
+
const view = executionPresentation(page, rendered, options);
|
|
51
|
+
elements.static.hidden = !view.eligible || view.preference === "static";
|
|
52
|
+
elements.auto.hidden = !view.eligible || view.preference !== "static";
|
|
53
|
+
if (elements.menu) elements.menu.hidden = !view.eligible && !view.detail;
|
|
54
|
+
elements.status.textContent = view.status;
|
|
55
|
+
elements.status.hidden = !view.status;
|
|
56
|
+
if (elements.details) {
|
|
57
|
+
elements.details.textContent = view.detail;
|
|
58
|
+
elements.details.hidden = !view.detail;
|
|
59
|
+
}
|
|
60
|
+
if (elements.editDescription) elements.editDescription.textContent = view.editDescription;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/frame-policy.js
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
const BASE_SANDBOX = "allow-scripts allow-forms allow-modals";
|
|
2
2
|
const LOCALHOST_SANDBOX = `${BASE_SANDBOX} allow-popups allow-downloads allow-same-origin`;
|
|
3
3
|
|
|
4
|
+
// Exact immutable review modules, not the artifact directory or whole origin.
|
|
5
|
+
// Do not add strict-dynamic or a nonce: either would let authored code authorize
|
|
6
|
+
// mutable external scripts. The capability in the SDK tag is correlation only.
|
|
7
|
+
export const TRUSTED_SDK_MODULE_PATHS = Object.freeze([
|
|
8
|
+
"/sdk.js", "/anchor-text.js", "/click-target.js", "/comment-target.js",
|
|
9
|
+
"/editing.js", "/frame-channel.js", "/icons.js", "/review-mode.js",
|
|
10
|
+
"/semantic-snapshot.js", "/serialize.js", "/positioning.js",
|
|
11
|
+
"/revision-schema.js", "/view-identity.js",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export function interactiveFileCsp(sdkOrigin) {
|
|
15
|
+
const origin = new URL(sdkOrigin);
|
|
16
|
+
if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password ||
|
|
17
|
+
origin.pathname !== "/" || origin.search || origin.hash) {
|
|
18
|
+
throw new TypeError("The SDK origin must be an HTTP origin without a path or credentials.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const modules = TRUSTED_SDK_MODULE_PATHS.map((route) => `${origin.origin}${route}`).join(" ");
|
|
22
|
+
return [
|
|
23
|
+
"default-src 'none'",
|
|
24
|
+
`script-src 'unsafe-inline' ${modules}`,
|
|
25
|
+
"script-src-attr 'unsafe-inline'",
|
|
26
|
+
"worker-src 'none'",
|
|
27
|
+
"child-src 'none'",
|
|
28
|
+
"frame-src 'none'",
|
|
29
|
+
"object-src 'none'",
|
|
30
|
+
"connect-src 'none'",
|
|
31
|
+
"base-uri 'none'",
|
|
32
|
+
"form-action 'none'",
|
|
33
|
+
"style-src 'unsafe-inline' http: https: data:",
|
|
34
|
+
"img-src http: https: data: blob:",
|
|
35
|
+
"font-src http: https: data:",
|
|
36
|
+
"media-src http: https: data: blob:",
|
|
37
|
+
].join("; ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const trustedFileCsp = interactiveFileCsp;
|
|
41
|
+
|
|
4
42
|
/**
|
|
5
43
|
* Localhost apps need their real origin so their routing and JavaScript work.
|
|
6
44
|
* Files and rendered Markdown do not: An opaque iframe origin prevents them
|