@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,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture the reviewed, live DOM as inert descriptors, never historical HTML.
|
|
3
|
+
* Keep this function self-contained: the SDK can inject its toString() source.
|
|
4
|
+
* Limits fail the entire capture rather than publish a truncated baseline.
|
|
5
|
+
*/
|
|
6
|
+
export function captureSemanticSnapshot(document) {
|
|
7
|
+
const limits = {
|
|
8
|
+
blocks: 2000, nodes: 30000, text: 1000000, blockText: 100000,
|
|
9
|
+
runs: 1000, depth: 64, attribute: 4096, milliseconds: 1500,
|
|
10
|
+
};
|
|
11
|
+
const started = Date.now();
|
|
12
|
+
let visited = 0;
|
|
13
|
+
let characters = 0;
|
|
14
|
+
const blocks = [];
|
|
15
|
+
const limitations = new Set([
|
|
16
|
+
"hidden-or-virtualized-content", "closed-shadow-roots",
|
|
17
|
+
"cross-origin-frames", "canvas-content", "same-url-image-bytes",
|
|
18
|
+
]);
|
|
19
|
+
const fail = (reason) => {
|
|
20
|
+
const error = new Error(`Semantic capture unavailable: ${reason}`);
|
|
21
|
+
error.code = "SEMANTIC_CAPTURE_LIMIT";
|
|
22
|
+
error.reason = reason;
|
|
23
|
+
throw error;
|
|
24
|
+
};
|
|
25
|
+
if (!document?.body || !document.defaultView) {
|
|
26
|
+
const error = new Error("Semantic capture requires a live document");
|
|
27
|
+
error.code = "SEMANTIC_CAPTURE_UNAVAILABLE";
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const excluded = new Set([
|
|
31
|
+
"script", "style", "noscript", "template", "head", "input", "textarea",
|
|
32
|
+
"select", "option", "datalist", "output", "embed", "object",
|
|
33
|
+
]);
|
|
34
|
+
const blockTags = new Set([
|
|
35
|
+
"address", "article", "aside", "blockquote", "body", "button", "caption", "dd",
|
|
36
|
+
"details", "dialog", "div", "dl", "dt", "fieldset", "figcaption", "figure",
|
|
37
|
+
"footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "img",
|
|
38
|
+
"li", "main", "nav", "ol", "p", "pre", "section", "summary", "table",
|
|
39
|
+
"tbody", "td", "tfoot", "th", "thead", "tr", "ul",
|
|
40
|
+
]);
|
|
41
|
+
const structural = new Set([
|
|
42
|
+
"blockquote", "caption", "dd", "details", "dl", "dt", "figcaption",
|
|
43
|
+
"figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "li",
|
|
44
|
+
"ol", "pre", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul",
|
|
45
|
+
]);
|
|
46
|
+
const markNames = {
|
|
47
|
+
b: "strong", strong: "strong", i: "em", em: "em", u: "underline",
|
|
48
|
+
s: "strike", del: "delete", ins: "insert", code: "code", kbd: "kbd",
|
|
49
|
+
samp: "samp", sub: "sub", sup: "sup", mark: "mark",
|
|
50
|
+
};
|
|
51
|
+
const attributeNames = [
|
|
52
|
+
"id", "href", "src", "alt", "title", "start", "reversed", "value",
|
|
53
|
+
"rowspan", "colspan", "scope", "headers", "type",
|
|
54
|
+
];
|
|
55
|
+
const tick = (depth) => {
|
|
56
|
+
if (++visited > limits.nodes) fail("nodes");
|
|
57
|
+
if (depth > limits.depth) fail("depth");
|
|
58
|
+
if (visited % 32 === 0 && Date.now() - started > limits.milliseconds) fail("time");
|
|
59
|
+
};
|
|
60
|
+
const bounded = (value) => {
|
|
61
|
+
if (value.length > limits.attribute) fail("attribute");
|
|
62
|
+
characters += value.length;
|
|
63
|
+
if (characters > limits.text) fail("text");
|
|
64
|
+
return value;
|
|
65
|
+
};
|
|
66
|
+
const urlValue = (value) => {
|
|
67
|
+
// References are inert strings, but do not retain inline payloads or credentials.
|
|
68
|
+
if (/^\s*(?:javascript|vbscript|data|blob):/i.test(value)) {
|
|
69
|
+
limitations.add("unsafe-or-inline-url");
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(value, document.baseURI);
|
|
74
|
+
if (["javascript:", "vbscript:", "data:", "blob:"].includes(url.protocol)) {
|
|
75
|
+
limitations.add("unsafe-or-inline-url");
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (url.username || url.password) {
|
|
79
|
+
limitations.add("credentialed-url");
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
limitations.add("invalid-url");
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return bounded(value);
|
|
87
|
+
};
|
|
88
|
+
const preservedRuns = new WeakSet();
|
|
89
|
+
const attributesFor = (element) => {
|
|
90
|
+
const attrs = {};
|
|
91
|
+
for (const key of attributeNames) {
|
|
92
|
+
if (key === "value" && element.localName !== "li") continue;
|
|
93
|
+
if (key === "type" && element.localName !== "button") continue;
|
|
94
|
+
if (!element.hasAttribute(key)) continue;
|
|
95
|
+
const value = element.getAttribute(key);
|
|
96
|
+
const safe = key === "href" || key === "src" ? urlValue(value) : bounded(value);
|
|
97
|
+
if (safe !== null) attrs[key] = safe;
|
|
98
|
+
}
|
|
99
|
+
return attrs;
|
|
100
|
+
};
|
|
101
|
+
const escapeString = (value) => value.replace(/[\\"\n\r\f]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
|
|
102
|
+
const visibleStyle = (element) => {
|
|
103
|
+
if (element.hasAttribute("hidden") || element.hasAttribute("inert") ||
|
|
104
|
+
element.getAttribute("aria-hidden") === "true") return null;
|
|
105
|
+
const style = document.defaultView.getComputedStyle(element);
|
|
106
|
+
return style.display !== "none" && style.visibility !== "hidden" &&
|
|
107
|
+
style.visibility !== "collapse" && style.contentVisibility !== "hidden" &&
|
|
108
|
+
style.opacity !== "0" ? style : null;
|
|
109
|
+
};
|
|
110
|
+
const addRun = (block, text, marks, href, preserve) => {
|
|
111
|
+
if (!text) return;
|
|
112
|
+
if (!preserve) {
|
|
113
|
+
text = text.replace(/[\t\n\r\f ]+/g, " ");
|
|
114
|
+
if (/[ \t\r\n\f]$/.test(block.text)) text = text.replace(/^ /, "");
|
|
115
|
+
}
|
|
116
|
+
if (!text) return;
|
|
117
|
+
characters += text.length;
|
|
118
|
+
if (characters > limits.text) fail("text");
|
|
119
|
+
if (block.text.length + text.length > limits.blockText) fail("block-text");
|
|
120
|
+
const previous = block.runs.at(-1);
|
|
121
|
+
if (previous && previous.href === href &&
|
|
122
|
+
previous.marks.length === marks.length && previous.marks.every((m, i) => m === marks[i])) {
|
|
123
|
+
previous.text += text;
|
|
124
|
+
if (preserve) preservedRuns.add(previous);
|
|
125
|
+
} else {
|
|
126
|
+
if (block.runs.length >= limits.runs) fail("runs");
|
|
127
|
+
const run = { text, marks: [...marks], ...(href === undefined ? {} : { href }) };
|
|
128
|
+
if (preserve) preservedRuns.add(run);
|
|
129
|
+
block.runs.push(run);
|
|
130
|
+
}
|
|
131
|
+
block.text += text;
|
|
132
|
+
};
|
|
133
|
+
const walk = (node, context, depth, selector, path, marks, href, preserve) => {
|
|
134
|
+
tick(depth);
|
|
135
|
+
if (node.nodeType === 3) {
|
|
136
|
+
if (context) addRun(context, node.nodeValue, marks, href, preserve);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (node.nodeType !== 1) return;
|
|
140
|
+
const tag = node.localName.toLowerCase();
|
|
141
|
+
if (excluded.has(tag) || node.hasAttribute("data-eh-ui") ||
|
|
142
|
+
node.hasAttribute("data-eh-sdk") || node.hasAttribute("data-eh-route")) return;
|
|
143
|
+
const style = visibleStyle(node);
|
|
144
|
+
if (!style) return;
|
|
145
|
+
if (tag === "iframe") {
|
|
146
|
+
limitations.add("embedded-documents");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (tag === "canvas") return;
|
|
150
|
+
if (tag === "svg" || tag === "video" || tag === "audio") {
|
|
151
|
+
limitations.add("non-text-media");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (node.shadowRoot) limitations.add("open-shadow-roots");
|
|
155
|
+
const reviewMark = tag === "mark" && node.hasAttribute("data-eh-mark");
|
|
156
|
+
let block = context;
|
|
157
|
+
const isBlock = blockTags.has(tag);
|
|
158
|
+
if (isBlock) {
|
|
159
|
+
if (blocks.length >= limits.blocks) fail("blocks");
|
|
160
|
+
const attributes = attributesFor(node);
|
|
161
|
+
// An authored ID is useful evidence, not a globally stable revision ID.
|
|
162
|
+
const ownSelector = attributes.id
|
|
163
|
+
? `${selector}[id="${escapeString(attributes.id)}"]`
|
|
164
|
+
: selector;
|
|
165
|
+
if (ownSelector.length > limits.attribute) fail("selector");
|
|
166
|
+
block = {
|
|
167
|
+
id: `b${blocks.length + 1}`, tag, selector: ownSelector, text: "",
|
|
168
|
+
attributes, runs: [], path: [...path],
|
|
169
|
+
};
|
|
170
|
+
blocks.push(block);
|
|
171
|
+
const pathEntry = attributes.id ? `${tag}#${attributes.id}` : tag;
|
|
172
|
+
if (pathEntry.length > limits.attribute) fail("path");
|
|
173
|
+
path = [...path, pathEntry];
|
|
174
|
+
}
|
|
175
|
+
const nextMarks = [...marks];
|
|
176
|
+
if (!reviewMark && markNames[tag]) nextMarks.push(markNames[tag]);
|
|
177
|
+
if (style.fontWeight === "bold" || Number(style.fontWeight) >= 600) nextMarks.push("strong");
|
|
178
|
+
if (/^(italic|oblique)/.test(style.fontStyle)) nextMarks.push("em");
|
|
179
|
+
if (style.textDecorationLine?.includes("underline")) nextMarks.push("underline");
|
|
180
|
+
if (style.textDecorationLine?.includes("line-through")) nextMarks.push("strike");
|
|
181
|
+
const normalizedMarks = [...new Set(nextMarks)].sort();
|
|
182
|
+
let nextHref = href;
|
|
183
|
+
if (tag === "a" && node.hasAttribute("href")) {
|
|
184
|
+
const safe = urlValue(node.getAttribute("href"));
|
|
185
|
+
nextHref = safe === null ? undefined : safe;
|
|
186
|
+
}
|
|
187
|
+
const codeWhitespace = preserve || tag === "pre" || tag === "code" ||
|
|
188
|
+
/^(pre|pre-wrap|break-spaces)$/.test(style.whiteSpace);
|
|
189
|
+
if (tag === "br" && block) addRun(block, "\n", normalizedMarks, nextHref, true);
|
|
190
|
+
const siblings = new Map();
|
|
191
|
+
for (const child of node.childNodes) {
|
|
192
|
+
let childSelector = selector;
|
|
193
|
+
if (child.nodeType === 1) {
|
|
194
|
+
const name = child.localName.toLowerCase();
|
|
195
|
+
const index = (siblings.get(name) || 0) + 1;
|
|
196
|
+
siblings.set(name, index);
|
|
197
|
+
childSelector = `${selector} > ${name}:nth-of-type(${index})`;
|
|
198
|
+
}
|
|
199
|
+
// Closed <details> expose only the summary; never capture concealed answers.
|
|
200
|
+
if (tag === "details" && !node.hasAttribute("open") &&
|
|
201
|
+
!(child.nodeType === 1 && child.localName === "summary")) continue;
|
|
202
|
+
walk(child, block, depth + 1, childSelector, path, normalizedMarks, nextHref, codeWhitespace);
|
|
203
|
+
}
|
|
204
|
+
if (isBlock && !codeWhitespace) {
|
|
205
|
+
while (block.runs.length && !block.runs[0].text.trim() &&
|
|
206
|
+
!preservedRuns.has(block.runs[0])) block.runs.shift();
|
|
207
|
+
while (block.runs.length && !block.runs.at(-1).text.trim() &&
|
|
208
|
+
!preservedRuns.has(block.runs.at(-1))) block.runs.pop();
|
|
209
|
+
if (block.runs.length) {
|
|
210
|
+
if (!preservedRuns.has(block.runs[0])) {
|
|
211
|
+
block.runs[0].text = block.runs[0].text.replace(/^[\t\n\r\f ]+/, "");
|
|
212
|
+
}
|
|
213
|
+
if (!preservedRuns.has(block.runs.at(-1))) {
|
|
214
|
+
block.runs.at(-1).text = block.runs.at(-1).text.replace(/[\t\n\r\f ]+$/, "");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
block.text = block.runs.map((run) => run.text).join("");
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
walk(document.body, null, 0, "body", [], [], undefined, false);
|
|
221
|
+
if (Date.now() - started > limits.milliseconds) fail("time");
|
|
222
|
+
return {
|
|
223
|
+
version: 1,
|
|
224
|
+
blocks: blocks.filter((block) => block.text || structural.has(block.tag) ||
|
|
225
|
+
block.attributes.id || block.attributes.title),
|
|
226
|
+
limitations: [...limitations].sort(),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const SEMANTIC_SNAPSHOT_SOURCE = `(${captureSemanticSnapshot.toString()})`;
|