@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
package/lib/cli.js ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, serverProtocolMatches, statePath, targetKey, } from "./paths.js";
7
+ import { readServerLock } from "./server-lock.js";
8
+ import { installSkills, shellQuote } from "./setup.js";
9
+ import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, parseServerResponse, pollUntilDeadline, requestRaw } from "./poll-transport.js";
10
+ const here = path.dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
12
+ const HELP = `doc-review ${pkg.version}
13
+
14
+ doc-review <file-or-localhost-url> Open a file or localhost page for review
15
+ doc-review poll <target> Wait for feedback, print it as JSON (for agents)
16
+ --ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
17
+ --timeout <secs> End-to-end cutoff; default 12 hours (43200 seconds)
18
+ doc-review status <target> Report whether feedback is waiting, without blocking
19
+ doc-review setup Teach Claude Code / Codex how to use doc-review
20
+ doc-review setup --global ...for every project, not just this one
21
+
22
+ Everything runs locally. No account, no cloud, no database.
23
+ Use Review / Changes in the browser for retained content history.
24
+ Plain HTML edits autosave. Self-contained file scripts run automatically with feedback-only edits.
25
+ Use More for optional script-disabled recovery. Comparison capture does not block sending feedback.
26
+ Acknowledgement handles feedback; browser result capture may complete later.
27
+ `;
28
+ // --------------------------------------------------------------- server glue
29
+ function readServerRecord() {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(serverPath(), "utf8"));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ const request = requestRaw;
38
+ async function alive(server, deadline) {
39
+ if (!server?.port || !server.instance_id)
40
+ return false;
41
+ const lock = readServerLock();
42
+ if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id)
43
+ return false;
44
+ try {
45
+ deadline?.check();
46
+ const res = await request(server, {
47
+ method: "GET", path: "/health", timeout: Math.min(1200, deadline?.remaining() ?? 1200),
48
+ }, undefined, { time: deadline?.time });
49
+ deadline?.check();
50
+ const health = parseServerResponse(res);
51
+ if (health.pid !== server.pid || health.instance_id !== server.instance_id) {
52
+ throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
53
+ }
54
+ if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
55
+ throw new Error(`Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
56
+ "End active reviews and stop/restart the old doc-review server before retrying. " +
57
+ "Its live writer lock and queued feedback have not been changed.");
58
+ }
59
+ return true;
60
+ }
61
+ catch (err) {
62
+ if (isRecoverableTransportError(err))
63
+ return false;
64
+ throw err;
65
+ }
66
+ }
67
+ async function ensureServer(deadline = createDeadline(20)) {
68
+ deadline.check();
69
+ ensureStateDir();
70
+ for (let launch = 0; launch < 3; launch += 1) {
71
+ const saved = readServerRecord();
72
+ if (await alive(saved, deadline))
73
+ return saved;
74
+ deadline.check();
75
+ const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
76
+ detached: true,
77
+ stdio: "ignore",
78
+ });
79
+ let launchError;
80
+ child.on("error", (err) => { launchError = err; });
81
+ child.unref();
82
+ for (let attempt = 0; attempt < 60; attempt += 1) {
83
+ await deadline.sleep(100);
84
+ if (launchError)
85
+ throw launchError;
86
+ const record = readServerRecord();
87
+ if (await alive(record, deadline))
88
+ return record;
89
+ if (child.exitCode !== null && child.exitCode !== 0) {
90
+ throw new Error("The local doc-review server failed to start. Check the state directory permissions and server startup diagnostics before retrying.");
91
+ }
92
+ if (child.exitCode !== null && !readServerLock())
93
+ break;
94
+ }
95
+ }
96
+ throw Object.assign(new Error("Could not start the local doc-review server yet."), { code: "SERVER_START_PENDING" });
97
+ }
98
+ function openBrowser(url) {
99
+ const command = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
100
+ const child = spawn(command[0], command[1], { detached: true, stdio: "ignore" });
101
+ // A missing opener (headless Linux without xdg-open) surfaces as an async
102
+ // 'error' event, not a throw. Printing the URL below is the fallback.
103
+ child.on("error", () => { });
104
+ child.unref();
105
+ }
106
+ // ------------------------------------------------------------------ commands
107
+ async function openCommand(input) {
108
+ const target = canonicalTarget(input);
109
+ if (target.kind === "file" && !fs.existsSync(target.value)) {
110
+ console.error(`File not found: ${target.value}`);
111
+ process.exit(1);
112
+ }
113
+ const server = await ensureServer();
114
+ const res = await request(server, { method: "POST", path: "/api/session", headers: { "content-type": "application/json" } }, { target: target.value });
115
+ const body = JSON.parse(res.raw);
116
+ if (res.status !== 200) {
117
+ console.error(body.error || "Could not open that file.");
118
+ process.exit(1);
119
+ }
120
+ const url = `http://127.0.0.1:${server.port}${body.path}`;
121
+ openBrowser(url);
122
+ console.log(`Reviewing ${target.kind === "url" ? target.value : path.basename(target.value)}`);
123
+ console.log(url);
124
+ console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
125
+ }
126
+ /**
127
+ * The consumer is an agent reading a pipe. process.exit() does not wait for
128
+ * pending stdout writes, so a large payload could arrive truncated — always
129
+ * wait for the write to hand off before returning.
130
+ */
131
+ function writeStdout(text) {
132
+ return new Promise((resolve) => process.stdout.write(text, resolve));
133
+ }
134
+ async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
135
+ const deadline = createDeadline(timeoutSecs);
136
+ const target = canonicalTarget(input).value;
137
+ const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
138
+ process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
139
+ const batch = await pollUntilDeadline({
140
+ target, ackId, deadline, discover: ensureServer,
141
+ diagnostic: (text) => process.stderr.write(text),
142
+ });
143
+ await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
144
+ }
145
+ /**
146
+ * Instant answer, no blocking. Asks the running server when there is one;
147
+ * otherwise reads the persisted state directly, so a dead server still
148
+ * reports feedback that is waiting for a fresh poll.
149
+ */
150
+ async function statusCommand(input) {
151
+ const target = canonicalTarget(input).value;
152
+ const saved = readServerRecord();
153
+ if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) {
154
+ const res = await request(saved, { method: "GET", path: `/api/status?target=${encodeURIComponent(target)}` });
155
+ if (res.status === 200) {
156
+ process.stdout.write(`${JSON.stringify(JSON.parse(res.raw), null, 2)}\n`);
157
+ return;
158
+ }
159
+ }
160
+ let data = { pages: {}, batches: {} };
161
+ try {
162
+ data = JSON.parse(fs.readFileSync(statePath(), "utf8"));
163
+ }
164
+ catch {
165
+ // No state yet: everything below reads as empty.
166
+ }
167
+ const key = targetKey(target);
168
+ const pending = (data.batches || {})[key];
169
+ const page = (data.pages || {})[key];
170
+ const payload = {
171
+ status: pending ? "feedback-waiting" : "idle",
172
+ feedback_waiting: !!pending,
173
+ agent_listening: false,
174
+ server_running: false,
175
+ unsent: {
176
+ comments: page ? page.comments.length : 0,
177
+ edits: page ? page.edits.length : 0,
178
+ },
179
+ };
180
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
181
+ }
182
+ // ---------------------------------------------------------------------- main
183
+ const argv = process.argv.slice(2);
184
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
185
+ console.log(HELP);
186
+ process.exit(0);
187
+ }
188
+ if (argv[0] === "--version" || argv[0] === "-v") {
189
+ console.log(pkg.version);
190
+ process.exit(0);
191
+ }
192
+ process.on("SIGINT", () => {
193
+ process.stderr.write("\nStopped waiting. Your feedback is safe — run the same command again to pick it up.\n");
194
+ process.exit(130);
195
+ });
196
+ function parsePollArgs(rest) {
197
+ const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
198
+ let sawTimeout = false;
199
+ for (let i = 0; i < rest.length; i += 1) {
200
+ const arg = rest[i];
201
+ if (arg === "--ack") {
202
+ const value = rest[(i += 1)];
203
+ if (!value || value.startsWith("-")) {
204
+ throw new Error("--ack requires the batch_id from the feedback response, e.g. --ack b_123");
205
+ }
206
+ parsed.ackId = value;
207
+ }
208
+ else if (arg.startsWith("--ack=")) {
209
+ throw new Error("Use --ack <batch_id> with the batch ID as a separate argument.");
210
+ }
211
+ else if (arg === "--timeout") {
212
+ sawTimeout = true;
213
+ parsed.timeoutSecs = Number(rest[(i += 1)]);
214
+ }
215
+ else if (arg.startsWith("--timeout=")) {
216
+ sawTimeout = true;
217
+ parsed.timeoutSecs = Number(arg.slice("--timeout=".length));
218
+ }
219
+ else if (!arg.startsWith("-") && !parsed.file)
220
+ parsed.file = arg;
221
+ else
222
+ throw new Error(`Unknown poll argument: ${arg}`);
223
+ }
224
+ // A malformed value must fail loudly — NaN or 0 silently waiting forever is
225
+ // the exact hang the flag exists to prevent.
226
+ if (sawTimeout && (!Number.isFinite(parsed.timeoutSecs) || parsed.timeoutSecs <= 0)) {
227
+ throw new Error("--timeout wants a number of seconds, e.g. --timeout 300");
228
+ }
229
+ return parsed;
230
+ }
231
+ try {
232
+ if (argv[0] === "poll") {
233
+ const { file, ackId, timeoutSecs } = parsePollArgs(argv.slice(1));
234
+ if (!file)
235
+ throw new Error("Usage: doc-review poll <file-or-localhost-url> [--ack <batch_id>] [--timeout <secs>]");
236
+ await pollCommand(file, { ackId, timeoutSecs });
237
+ }
238
+ else if (argv[0] === "status") {
239
+ const file = argv.find((a, i) => i > 0 && !a.startsWith("-"));
240
+ if (!file)
241
+ throw new Error("Usage: doc-review status <file-or-localhost-url>");
242
+ await statusCommand(file);
243
+ }
244
+ else if (argv[0] === "setup") {
245
+ const isGlobal = argv.includes("--global") || argv.includes("-g");
246
+ installSkills(process.cwd(), { global: isGlobal }).forEach((line) => console.log(line));
247
+ }
248
+ else {
249
+ await openCommand(argv[0]);
250
+ }
251
+ }
252
+ catch (err) {
253
+ console.error(err.message || String(err));
254
+ process.exit(1);
255
+ }
@@ -0,0 +1,28 @@
1
+ export function navigationHref(target) {
2
+ const link = target?.closest?.("a[href]");
3
+ if (link)
4
+ return link.getAttribute("href") || "";
5
+ const control = target?.closest?.("[data-href]");
6
+ return control ? control.getAttribute("data-href") || "" : "";
7
+ }
8
+ /**
9
+ * Decide what a modified click on a "#…" link should do. Setting the real
10
+ * hash is what makes CSS :target routing (single-file "pages") show the
11
+ * section — a bare scrollIntoView can't reach a display:none target and
12
+ * never fires :target. Only when the hash is already current does scrolling
13
+ * become the right move, since re-setting an identical hash is a no-op.
14
+ */
15
+ export function hashClickAction(href, currentHash) {
16
+ const decode = (value) => {
17
+ try {
18
+ return decodeURIComponent(value);
19
+ }
20
+ catch {
21
+ return value;
22
+ }
23
+ };
24
+ const id = decode(href.slice(1));
25
+ if (decode((currentHash || "").slice(1)) === id)
26
+ return { kind: "scroll", id };
27
+ return { kind: "navigate", hash: href };
28
+ }
@@ -0,0 +1,23 @@
1
+ const text = (value, limit) => typeof value === "string" ? value.slice(0, limit) : "";
2
+ export function normalizeCommentAnchor(kind, anchor) {
3
+ if (!anchor || typeof anchor !== "object" || Array.isArray(anchor))
4
+ return null;
5
+ if (kind === "element") {
6
+ const selector = text(anchor.selector, 2000);
7
+ if (!selector)
8
+ return null;
9
+ return {
10
+ selector,
11
+ ...(text(anchor.label, 500) ? { label: text(anchor.label, 500) } : {}),
12
+ };
13
+ }
14
+ const quote = text(anchor.quote, 4000);
15
+ if (!quote)
16
+ return null;
17
+ return {
18
+ quote,
19
+ ...(text(anchor.prefix, 1000) ? { prefix: text(anchor.prefix, 1000) } : {}),
20
+ ...(text(anchor.suffix, 1000) ? { suffix: text(anchor.suffix, 1000) } : {}),
21
+ ...(text(anchor.selector, 2000) ? { selector: text(anchor.selector, 2000) } : {}),
22
+ };
23
+ }
@@ -0,0 +1,164 @@
1
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
2
+ export function groupCommentTargets(targets) {
3
+ const groups = new Map();
4
+ for (const [id, element] of targets) {
5
+ if (!element?.isConnected)
6
+ continue;
7
+ if (!groups.has(element))
8
+ groups.set(element, []);
9
+ groups.get(element).push(id);
10
+ }
11
+ return groups;
12
+ }
13
+ export function nextCommentId(ids, activeId) {
14
+ return ids.length ? ids[(ids.indexOf(activeId) + 1) % ids.length] : null;
15
+ }
16
+ /** Retarget dwell is independent of initial intent; revisiting a candidate
17
+ * does not restart its clock. The caller owns corridor and selection policy. */
18
+ export function createHoverIntent(commit, { initial = 150, retarget = 100, exit = 120, schedule = setTimeout, unschedule = clearTimeout, } = {}) {
19
+ let current = null;
20
+ let candidate = null;
21
+ let timer = null;
22
+ const cancel = () => {
23
+ unschedule(timer);
24
+ timer = null;
25
+ candidate = null;
26
+ };
27
+ return {
28
+ cancel,
29
+ reset() { cancel(); current = null; },
30
+ request(key, payload, immediate = false) {
31
+ if (key === current && !immediate) {
32
+ cancel();
33
+ return;
34
+ }
35
+ if (timer !== null && candidate === key && !immediate)
36
+ 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)
46
+ apply();
47
+ else
48
+ timer = schedule(apply, key === null ? exit : current === null ? initial : retarget);
49
+ },
50
+ };
51
+ }
52
+ export function normalizeSelectionRange(map, range) {
53
+ let first = null;
54
+ let last = null;
55
+ // Element offsets index children, not characters. Intersect the filtered text
56
+ // nodes so an exclusive endpoint before the next block never includes it.
57
+ for (const entry of map) {
58
+ const length = entry.node.nodeValue.length;
59
+ if (range.comparePoint(entry.node, length) < 0 || range.comparePoint(entry.node, 0) > 0)
60
+ continue;
61
+ const from = entry.node === range.startContainer ? range.startOffset : 0;
62
+ const to = entry.node === range.endContainer ? range.endOffset : length;
63
+ if (to <= from)
64
+ continue;
65
+ first ||= { entry, offset: from };
66
+ last = { entry, offset: to };
67
+ }
68
+ if (!first || !last)
69
+ return null;
70
+ const normalized = range.cloneRange();
71
+ normalized.setStart(first.entry.node, first.offset);
72
+ normalized.setEnd(last.entry.node, last.offset);
73
+ return {
74
+ start: first.entry.start + first.offset,
75
+ end: last.entry.start + last.offset,
76
+ range: normalized,
77
+ };
78
+ }
79
+ export function sameRange(one, two) {
80
+ return !!one && !!two &&
81
+ one.startContainer === two.startContainer && one.startOffset === two.startOffset &&
82
+ one.endContainer === two.endContainer && one.endOffset === two.endOffset;
83
+ }
84
+ export function pointInCommentApproach(point, target, action) {
85
+ if (!target || !action || action.width <= 0 || action.height <= 0)
86
+ return false;
87
+ return point.x >= Math.min(target.left, action.left) &&
88
+ point.x <= Math.max(target.right, action.right) &&
89
+ point.y >= Math.min(target.top, action.top) &&
90
+ point.y <= Math.max(target.bottom, action.bottom);
91
+ }
92
+ export function sanitizeRect(rect) {
93
+ if (!rect)
94
+ return null;
95
+ const values = ["left", "top", "right", "bottom", "width", "height"];
96
+ if (!values.every((key) => finite(rect[key])))
97
+ return null;
98
+ if (rect.width < 0 || rect.height < 0 || rect.right < rect.left || rect.bottom < rect.top)
99
+ return null;
100
+ return {
101
+ left: rect.left,
102
+ top: rect.top,
103
+ right: rect.right,
104
+ bottom: rect.bottom,
105
+ width: rect.width,
106
+ height: rect.height,
107
+ };
108
+ }
109
+ export function sanitizeClientRect(rect, viewport) {
110
+ const safe = sanitizeRect(rect);
111
+ if (!safe || !viewport || !finite(viewport.width) || !finite(viewport.height))
112
+ return null;
113
+ const { left: rawLeft, top: rawTop, right: rawRight, bottom: rawBottom } = safe;
114
+ if (rawRight <= 0 || rawBottom <= 0 || rawLeft >= viewport.width || rawTop >= viewport.height)
115
+ return null;
116
+ const left = Math.max(0, Math.min(viewport.width, rawLeft));
117
+ const top = Math.max(0, Math.min(viewport.height, rawTop));
118
+ const right = Math.max(left, Math.min(viewport.width, rawRight));
119
+ const bottom = Math.max(top, Math.min(viewport.height, rawBottom));
120
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
121
+ }
122
+ export function sanitizeClientRects(rects, viewport, limit = 64) {
123
+ if (!Array.isArray(rects))
124
+ return [];
125
+ return rects.slice(0, limit).map((rect) => sanitizeClientRect(rect, viewport)).filter(Boolean);
126
+ }
127
+ export function sanitizeClipRect(rect, viewport) {
128
+ const safe = sanitizeClientRect(rect, viewport);
129
+ return safe && safe.width > 0 && safe.height > 0 ? safe : null;
130
+ }
131
+ export function sanitizeRelation(value) {
132
+ return ["visible", "above", "below", "unavailable"].includes(value) ? value : "unavailable";
133
+ }
134
+ export function targetMessage({ kind, quote, anchor, rects, generation, relation, clip, horizontal }, viewport) {
135
+ const safeRects = sanitizeClientRects(rects, viewport);
136
+ const safeClip = sanitizeClipRect(clip, viewport);
137
+ if (!Number.isSafeInteger(generation) || generation < 1)
138
+ return null;
139
+ const safeRelation = sanitizeRelation(relation);
140
+ if (!safeClip || (safeRelation === "visible" && !safeRects.length))
141
+ return null;
142
+ return {
143
+ kind: kind === "element" ? "element" : "selection",
144
+ quote: String(quote || ""),
145
+ anchor: anchor || null,
146
+ rects: safeRects,
147
+ generation,
148
+ relation: safeRelation,
149
+ clip: safeClip,
150
+ horizontal: finite(horizontal) ? Math.max(safeClip.left, Math.min(safeClip.right, horizontal)) : null,
151
+ };
152
+ }
153
+ export function acceptsTargetGeometry(currentGeneration, incoming) {
154
+ return !!incoming && Number.isSafeInteger(incoming.generation) && incoming.generation === currentGeneration;
155
+ }
156
+ export function acceptedOpenGeneration({ pendingGeneration = null, retargetGeneration = null }, { accepted, requestedGeneration }) {
157
+ if (!accepted)
158
+ return pendingGeneration;
159
+ if (requestedGeneration === retargetGeneration)
160
+ return retargetGeneration;
161
+ if (requestedGeneration === pendingGeneration)
162
+ return pendingGeneration;
163
+ return null;
164
+ }