@erdemtuna/doc-review 0.8.1 → 0.10.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.
Files changed (82) hide show
  1. package/README.md +52 -137
  2. package/{src → lib}/SKILL.md +28 -4
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/chrome-api.js +78 -0
  6. package/lib/chrome-client.js +2255 -0
  7. package/lib/chrome-session.js +76 -0
  8. package/{src → lib}/chrome.css +253 -8
  9. package/lib/chrome.html +196 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comparison-view.js +380 -0
  15. package/lib/contracts/feedback.js +1 -0
  16. package/lib/contracts/frame.js +7 -0
  17. package/lib/contracts/history.js +1 -0
  18. package/lib/contracts/index.js +2 -0
  19. package/lib/contracts/page.js +23 -0
  20. package/lib/document-execution.js +115 -0
  21. package/lib/document-trust.js +2 -0
  22. package/lib/edit-limits.js +24 -0
  23. package/lib/editing.js +107 -0
  24. package/lib/execution-client.js +69 -0
  25. package/lib/feedback-controller.js +106 -0
  26. package/lib/frame-channel.js +42 -0
  27. package/lib/frame-controller.js +313 -0
  28. package/lib/frame-host.js +136 -0
  29. package/lib/frame-policy.js +50 -0
  30. package/lib/history-client.js +112 -0
  31. package/lib/history-coordinator.js +210 -0
  32. package/lib/history-policy.js +43 -0
  33. package/lib/history-server.js +464 -0
  34. package/lib/html-transform.js +52 -0
  35. package/lib/icons.js +249 -0
  36. package/{src → lib}/markdown.js +25 -26
  37. package/lib/paths.js +83 -0
  38. package/lib/poll-transport.js +220 -0
  39. package/lib/positioning.js +97 -0
  40. package/lib/review-controller.js +93 -0
  41. package/lib/review-mode.js +66 -0
  42. package/lib/revision-diff.js +482 -0
  43. package/lib/revision-schema.js +235 -0
  44. package/lib/revision-store.js +190 -0
  45. package/lib/save-controller.js +300 -0
  46. package/lib/sdk.js +2629 -0
  47. package/lib/semantic-snapshot.js +268 -0
  48. package/lib/serialize.js +32 -0
  49. package/lib/server-entry.js +26 -0
  50. package/lib/server-lock.js +109 -0
  51. package/lib/server.js +1501 -0
  52. package/lib/setup-guidance.js +119 -0
  53. package/{src → lib}/setup.js +44 -55
  54. package/lib/state.js +733 -0
  55. package/lib/view-identity.js +112 -0
  56. package/package.json +24 -12
  57. package/src/anchor-text.js +0 -160
  58. package/src/atomic-write.js +0 -53
  59. package/src/chrome-client.js +0 -2006
  60. package/src/chrome-session.js +0 -85
  61. package/src/chrome.html +0 -129
  62. package/src/cli.js +0 -267
  63. package/src/click-target.js +0 -26
  64. package/src/comment-anchor.js +0 -22
  65. package/src/comment-target.js +0 -77
  66. package/src/edit-limits.js +0 -23
  67. package/src/editing.js +0 -99
  68. package/src/frame-channel.js +0 -44
  69. package/src/frame-policy.js +0 -16
  70. package/src/html-transform.js +0 -62
  71. package/src/icons.js +0 -251
  72. package/src/paths.js +0 -91
  73. package/src/poll-transport.js +0 -222
  74. package/src/positioning.js +0 -107
  75. package/src/review-mode.js +0 -59
  76. package/src/sdk.js +0 -2243
  77. package/src/serialize.js +0 -32
  78. package/src/server-entry.js +0 -26
  79. package/src/server-lock.js +0 -101
  80. package/src/server.js +0 -1298
  81. package/src/setup-guidance.js +0 -128
  82. package/src/state.js +0 -507
@@ -0,0 +1,380 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ /** Correlation alone does not validate a message type or its payload. */
2
+ export function isFrameIdentity(value) {
3
+ return typeof value === "object" && value !== null &&
4
+ "capability" in value && typeof value.capability === "string" &&
5
+ "generation" in value && typeof value.generation === "number" &&
6
+ "pageKey" in value && typeof value.pageKey === "string";
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export { isFrameIdentity } from "./frame.js";
2
+ export { isExecutionPolicy, isPageResponse, isRenderExecution } from "./page.js";
@@ -0,0 +1,23 @@
1
+ function record(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+ export function isExecutionPolicy(value) {
5
+ return record(value) &&
6
+ (value.executionMode === "static" || value.executionMode === "interactive" || value.executionMode === "application") &&
7
+ (value.savePolicy === "writable" || value.savePolicy === "feedback-only") &&
8
+ typeof value.feedbackOnly === "boolean";
9
+ }
10
+ export function isPageResponse(value) {
11
+ return record(value) && isExecutionPolicy(value) &&
12
+ typeof value.key === "string" && typeof value.file === "string" &&
13
+ typeof value.filename === "string" && typeof value.markdown === "boolean" &&
14
+ typeof value.canRevert === "boolean" && typeof value.pollCommand === "string" &&
15
+ typeof value.historySupported === "boolean" &&
16
+ (value.executionPreference === undefined || value.executionPreference === "auto" || value.executionPreference === "static") &&
17
+ ((value.kind === "file" && value.url === undefined) || (value.kind === "url" && typeof value.url === "string")) &&
18
+ Array.isArray(value.comments) && Array.isArray(value.edits);
19
+ }
20
+ export function isRenderExecution(value) {
21
+ return record(value) && isExecutionPolicy(value) &&
22
+ (value.executionNotice === null || typeof value.executionNotice === "string");
23
+ }
@@ -0,0 +1,115 @@
1
+ import { parse, serialize } from "parse5";
2
+ 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.";
3
+ const attr = (node, name) => node.attrs?.find((item) => item.name === name)?.value;
4
+ const javascriptTypes = new Set([
5
+ "application/ecmascript", "application/javascript", "application/x-ecmascript", "application/x-javascript",
6
+ "text/ecmascript", "text/javascript", "text/javascript1.0", "text/javascript1.1",
7
+ "text/javascript1.2", "text/javascript1.3", "text/javascript1.4", "text/javascript1.5",
8
+ "text/jscript", "text/livescript", "text/x-ecmascript", "text/x-javascript",
9
+ ]);
10
+ const urlAttributes = new Set(["href", "src", "action", "formaction", "data", "background"]);
11
+ function dependency(node) {
12
+ const tag = node.tagName;
13
+ const source = attr(node, "src") ?? attr(node, "href");
14
+ if (tag === "script" && source !== undefined)
15
+ return { kind: "external-script", source };
16
+ if (tag === "script" && ["importmap", "speculationrules"].includes((attr(node, "type") || "").trim().toLowerCase())) {
17
+ return { kind: "application-import-map" };
18
+ }
19
+ if (["iframe", "frame", "object", "embed"].includes(tag)) {
20
+ return { kind: "embedded-application", source: attr(node, "src") || attr(node, "data") };
21
+ }
22
+ if (tag === "link") {
23
+ const rel = (attr(node, "rel") || "").toLowerCase().split(/\s+/);
24
+ if (rel.includes("modulepreload") || (rel.includes("preload") && ["script", "worker"].includes((attr(node, "as") || "").toLowerCase()))) {
25
+ return { kind: "script-preload", source: attr(node, "href") };
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+ /** Analyze authored source, never an injected SDK or a runtime DOM serialization. */
31
+ export function analyzeDocumentExecution(source) {
32
+ if (source instanceof Uint8Array)
33
+ source = Buffer.from(source).toString("utf8");
34
+ if (typeof source !== "string")
35
+ throw new TypeError("HTML source bytes or text are required.");
36
+ const document = parse(source);
37
+ let active = false;
38
+ let application = false;
39
+ const visit = (node, includeTemplates = false) => {
40
+ if (dependency(node))
41
+ 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))
45
+ active = true;
46
+ }
47
+ for (const attribute of node.attrs || []) {
48
+ const name = attribute.name.toLowerCase();
49
+ if (/^on[a-z]/.test(name) || (urlAttributes.has(name) &&
50
+ /^javascript:/i.test(attribute.value.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+/, ""))))
51
+ active = true;
52
+ }
53
+ for (const child of node.childNodes || [])
54
+ visit(child, includeTemplates);
55
+ if (includeTemplates && node.content)
56
+ visit(node.content, true);
57
+ };
58
+ visit(document);
59
+ // Dormant template content cannot instantiate itself. Once authored execution
60
+ // exists, include its dependencies in the same bounded application policy.
61
+ if (active)
62
+ visit(document, true);
63
+ return {
64
+ executionMode: application ? "application" : active ? "interactive" : "static",
65
+ savePolicy: active ? "feedback-only" : "writable",
66
+ feedbackOnly: active,
67
+ };
68
+ }
69
+ export function documentExecutionPolicy(page, source, preference = "auto") {
70
+ if (page.kind === "url")
71
+ return { executionMode: "application", savePolicy: "feedback-only", feedbackOnly: true };
72
+ if (page.markdown)
73
+ return { executionMode: "static", savePolicy: "feedback-only", feedbackOnly: true };
74
+ const policy = analyzeDocumentExecution(source);
75
+ return { ...policy, executionMode: preference === "static" ? "static" : policy.executionMode };
76
+ }
77
+ /** Keep inline interactions; the response CSP bounds dynamically created dependencies. */
78
+ export function transformInteractiveHtml(html) {
79
+ const document = parse(String(html));
80
+ const unsupportedDependencies = [];
81
+ let omitted = 0;
82
+ const visit = (parent) => {
83
+ parent.childNodes = (parent.childNodes || []).filter((node) => {
84
+ const unsupported = dependency(node);
85
+ if (unsupported) {
86
+ if (unsupportedDependencies.length < 100) {
87
+ unsupportedDependencies.push({ ...unsupported, source: String(unsupported.source || "").slice(0, 512) });
88
+ }
89
+ else
90
+ omitted += 1;
91
+ return false;
92
+ }
93
+ if (node.tagName === "base")
94
+ return false;
95
+ if (node.tagName === "meta" && ["content-security-policy", "refresh"].includes((attr(node, "http-equiv") || "").trim().toLowerCase()))
96
+ return false;
97
+ if (node.childNodes)
98
+ visit(node);
99
+ if (node.content)
100
+ visit(node.content);
101
+ return true;
102
+ });
103
+ };
104
+ visit(document);
105
+ if (omitted)
106
+ unsupportedDependencies.push({ kind: "additional-dependencies", count: omitted });
107
+ const notice = INTERACTIVE_FILE_NOTICE + (unsupportedDependencies.length ? " Unsupported dependency elements were removed." : "");
108
+ const root = document.childNodes.find((node) => node.tagName === "html");
109
+ if (!root.childNodes.some((node) => node.tagName === "body")) {
110
+ const body = { nodeName: "body", tagName: "body", attrs: [], namespaceURI: "http://www.w3.org/1999/xhtml", childNodes: [], parentNode: root };
111
+ root.childNodes = root.childNodes.filter((node) => node.tagName !== "frameset");
112
+ root.childNodes.push(body);
113
+ }
114
+ return { html: serialize(document), unsupportedDependencies, notice };
115
+ }
@@ -0,0 +1,2 @@
1
+ // Deprecated import compatibility; execution no longer reads approval decisions.
2
+ export { transformInteractiveHtml as transformTrustedHtml } from "./document-execution.js";
@@ -0,0 +1,24 @@
1
+ export const MAX_EDIT_CHARACTERS = 200_000;
2
+ const EDIT_FIELDS = ["before", "after", "before_html", "after_html", "moved_after", "moved_before"];
3
+ export function limitEditFields(input) {
4
+ const fields = {};
5
+ const truncatedFields = [];
6
+ for (const field of EDIT_FIELDS) {
7
+ const value = input[field];
8
+ if (typeof value !== "string")
9
+ continue;
10
+ let end = 0;
11
+ let count = 0;
12
+ // Count Unicode code points, not UTF-16 halves of an astral character.
13
+ for (const character of value) {
14
+ if (count === MAX_EDIT_CHARACTERS)
15
+ break;
16
+ end += character.length;
17
+ count += 1;
18
+ }
19
+ fields[field] = value.slice(0, end);
20
+ if (end < value.length)
21
+ truncatedFields.push(field);
22
+ }
23
+ return { fields, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
24
+ }