@erdemtuna/doc-review 0.7.0 → 0.8.1

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/src/chrome.html CHANGED
@@ -8,6 +8,27 @@
8
8
  </head>
9
9
  <body data-session="__SESSION_ID__" data-token="__TOKEN__">
10
10
  <div class="app">
11
+ <header class="toolbar" aria-label="Review controls">
12
+ <div class="toolbar-product" aria-hidden="true"></div>
13
+ <div class="mode-control">
14
+ <button type="button" id="modeButton" class="toolbar-btn" aria-haspopup="menu" aria-expanded="false">
15
+ <span id="modeIcon"></span><span id="modeLabel">View</span><span id="modeChevron"></span>
16
+ </button>
17
+ <div id="modeMenu" class="mode-menu" role="menu" hidden>
18
+ <button type="button" role="menuitemradio" aria-checked="true" data-mode="view">
19
+ <span data-icon="eye"></span><span><strong>View</strong><small>Editing off, comments enabled</small></span><span class="menu-check"></span>
20
+ </button>
21
+ <button type="button" role="menuitemradio" aria-checked="false" data-mode="edit">
22
+ <span data-icon="pencil"></span><span><strong>Edit</strong><small>Direct editing on</small></span><span class="menu-check"></span>
23
+ </button>
24
+ </div>
25
+ </div>
26
+ <div class="toolbar-actions">
27
+ <button type="button" id="commentsButton" class="toolbar-btn" aria-controls="drawer" aria-label="Comments" aria-expanded="false">
28
+ <span data-icon="messages"></span><span class="toolbar-label">Comments</span><span id="toolbarCount" class="count-badge">0</span>
29
+ </button>
30
+ </div>
31
+ </header>
11
32
  <main class="stage">
12
33
  <iframe
13
34
  id="frame"
@@ -15,34 +36,50 @@
15
36
  sandbox="allow-scripts allow-forms allow-modals"></iframe>
16
37
  </main>
17
38
 
18
- <button type="button" id="handle" class="handle" title="Hide comments panel" aria-label="Hide comments panel">›</button>
19
-
20
- <aside class="rail" id="rail">
21
- <div class="rail-scroll">
22
- <div class="rail-head">
23
- <span class="section">Feedback</span>
24
- <span class="dim" id="count">0</span>
25
- <button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark">☾</button>
39
+ <section id="compose" class="contextual-compose" role="dialog" aria-modal="false" aria-labelledby="composeTitle" hidden>
40
+ <div class="compose-head">
41
+ <div>
42
+ <span class="chip" id="composeKind">Selection</span>
43
+ <strong id="composeTitle">Add comment</strong>
26
44
  </div>
45
+ <button type="button" id="composeClose" class="icon-btn" title="Cancel comment" aria-label="Cancel comment"></button>
46
+ </div>
47
+ <div id="composeDirection" class="compose-direction" hidden>
48
+ <span id="composeDirectionText"></span>
49
+ <button type="button" id="composeReveal" class="link-action">Back to selection</button>
50
+ </div>
51
+ <p class="quote" id="composeQuote"></p>
52
+ <textarea id="composeText" rows="3" placeholder="What should change?" aria-label="Comment" aria-describedby="composeHelp"></textarea>
53
+ <p id="composeHelp" class="compose-help"><span>Enter to comment</span><span>Shift+Enter for new line</span></p>
54
+ <p id="composeError" class="compose-error" role="alert" hidden></p>
55
+ <div class="compose-actions">
56
+ <button type="button" id="composeAdd" class="btn-primary"><span data-icon="messageSquarePlus"></span><span id="composeAddLabel">Comment</span></button>
57
+ <button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
58
+ </div>
59
+ </section>
27
60
 
28
- <div id="compose" hidden>
29
- <div class="compose">
30
- <div class="compose-head">
31
- <span class="chip" id="composeKind">Selection</span>
32
- <span class="dim">Click to write · Esc to dismiss</span>
33
- </div>
34
- <p class="quote" id="composeQuote"></p>
35
- <textarea id="composeText" rows="3" placeholder="What should change?"></textarea>
36
- <div class="compose-actions">
37
- <button type="button" id="composeAdd" class="btn-primary">Add comment <span class="key">⏎</span></button>
38
- <button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
39
- </div>
40
- </div>
61
+ <article id="alignedCard" class="aligned-card" hidden></article>
62
+
63
+ <aside class="drawer" id="drawer" aria-label="Review feedback" aria-hidden="true">
64
+ <div class="drawer-head">
65
+ <div>
66
+ <span class="section">Review</span>
67
+ <h2 id="drawerTitle">Comments</h2>
41
68
  </div>
69
+ <button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark"></button>
70
+ <button type="button" id="drawerClose" class="icon-btn" title="Close review drawer" aria-label="Close review drawer"></button>
71
+ </div>
72
+ <div id="commentsSection" class="drawer-inventory" tabindex="-1" aria-labelledby="inventoryHeading">
73
+ <section>
74
+ <div class="rail-head">
75
+ <span class="section" id="inventoryHeading">Comments</span>
76
+ <span class="dim" id="count">0</span>
77
+ </div>
42
78
 
43
- <div id="cards" class="cards"></div>
79
+ <div id="cards" class="cards"></div>
44
80
 
45
- <div id="empty" class="empty">Select text or click an element to comment on it</div>
81
+ <div id="empty" class="empty">Select text or focus an element, then use the comment button</div>
82
+ </section>
46
83
 
47
84
  <section id="othersBox" hidden>
48
85
  <div class="rail-head tight">
@@ -61,21 +98,30 @@
61
98
  <div class="save-line" id="saveLine"><span class="dot"></span><span id="saveText"></span></div>
62
99
  <div class="edit-list" id="editList"></div>
63
100
  </section>
64
- </div>
65
101
 
66
- <div class="rail-foot">
67
- <textarea id="note" rows="2" placeholder="Overall note…"></textarea>
68
- <button type="button" id="send" class="send" disabled>Nothing to send yet</button>
69
- <div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
70
- <div class="handoff" id="handoff" hidden>
71
- <p class="handoff-title">Sent — but no agent is listening yet.</p>
72
- <p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
73
- <div class="handoff-cmd"><code id="handoffCmd"></code></div>
74
- <button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
75
- </div>
76
- <button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
77
102
  </div>
103
+ <footer id="sendSection" class="drawer-footer send-section" tabindex="-1" aria-label="Send feedback">
104
+ <div class="send-primary">
105
+ <div class="rail-head tight">
106
+ <span class="section">Send feedback</span>
107
+ </div>
108
+ <textarea id="note" rows="2" placeholder="Overall note…"></textarea>
109
+ <button type="button" id="send" class="send" disabled>Nothing to send yet</button>
110
+ <div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
111
+ </div>
112
+ <div class="send-secondary">
113
+ <div class="handoff" id="handoff" hidden>
114
+ <p class="handoff-title">Sent — but no agent is listening yet.</p>
115
+ <p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
116
+ <div class="handoff-cmd"><code id="handoffCmd"></code></div>
117
+ <button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
118
+ </div>
119
+ <button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
120
+ </div>
121
+ </footer>
78
122
  </aside>
123
+ <div id="drawerBackdrop" class="drawer-backdrop" hidden></div>
124
+ <div id="liveRegion" class="sr-only" role="status" aria-live="polite"></div>
79
125
  </div>
80
126
 
81
127
  <script type="module" src="/chrome.js"></script>
package/src/cli.js CHANGED
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
- import http from "node:http";
4
3
  import path from "node:path";
5
4
  import { spawn } from "node:child_process";
6
5
  import { fileURLToPath } from "node:url";
7
- import { canonicalTarget, ensureStateDir, SERVER_PROTOCOL, serverPath, statePath, targetKey } from "./paths.js";
6
+ import {
7
+ canonicalTarget,
8
+ ensureStateDir,
9
+ SERVER_PROTOCOL,
10
+ serverPath,
11
+ serverProtocolMatches,
12
+ statePath,
13
+ targetKey,
14
+ } from "./paths.js";
8
15
  import { readServerLock } from "./server-lock.js";
9
16
  import { installSkills, shellQuote } from "./setup.js";
17
+ import { createDeadline, DEFAULT_POLL_SECONDS, isRecoverableTransportError, parseServerResponse, pollUntilDeadline, requestRaw } from "./poll-transport.js";
10
18
 
11
19
  const here = path.dirname(fileURLToPath(import.meta.url));
12
20
  const pkg = JSON.parse(fs.readFileSync(path.join(here, "..", "package.json"), "utf8"));
@@ -16,7 +24,7 @@ const HELP = `doc-review ${pkg.version}
16
24
  doc-review <file-or-localhost-url> Open a file or localhost page for review
17
25
  doc-review poll <target> Wait for feedback, print it as JSON (for agents)
18
26
  --ack <batch_id> Acknowledge that exact delivered batch, then keep waiting
19
- --timeout <secs> Exit with {"status":"timeout"} if nothing arrives
27
+ --timeout <secs> End-to-end cutoff; default 12 hours (43200 seconds)
20
28
  doc-review status <target> Report whether feedback is waiting, without blocking
21
29
  doc-review setup Teach Claude Code / Codex how to use doc-review
22
30
  doc-review setup --global ...for every project, not just this one
@@ -34,72 +42,64 @@ function readServerRecord() {
34
42
  }
35
43
  }
36
44
 
37
- function request(server, options, body) {
38
- const port = typeof server === "number" ? server : server.port;
39
- const token = typeof server === "number" ? "" : server.token || "";
40
- return new Promise((resolve, reject) => {
41
- const req = http.request(
42
- {
43
- host: "127.0.0.1",
44
- port,
45
- ...options,
46
- headers: { ...(token ? { "x-doc-review-token": token } : {}), ...(options.headers || {}) },
47
- },
48
- (res) => {
49
- let raw = "";
50
- res.setEncoding("utf8");
51
- res.on("data", (chunk) => {
52
- raw += chunk;
53
- });
54
- res.on("end", () => resolve({ status: res.statusCode, raw }));
55
- }
56
- );
57
- req.on("error", reject);
58
- if (options.timeout) req.setTimeout(options.timeout, () => req.destroy(new Error("timeout")));
59
- if (body) req.write(JSON.stringify(body));
60
- req.end();
61
- });
62
- }
45
+ const request = requestRaw;
63
46
 
64
- async function alive(server) {
47
+ async function alive(server, deadline) {
48
+ if (!server?.port || !server.instance_id) return false;
49
+ const lock = readServerLock();
50
+ if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
65
51
  try {
66
- const lock = readServerLock();
67
- if (lock?.pid !== server.pid || lock?.instance_id !== server.instance_id) return false;
68
- const res = await request(server, { method: "GET", path: "/health", timeout: 1200 });
69
- if (res.status !== 200) return false;
70
- const health = JSON.parse(res.raw);
71
- return (
72
- health.protocol === SERVER_PROTOCOL &&
73
- health.pid === server.pid &&
74
- health.instance_id === server.instance_id
75
- );
76
- } catch {
77
- return false;
52
+ deadline?.check();
53
+ const res = await request(server, {
54
+ method: "GET", path: "/health", timeout: Math.min(1200, deadline?.remaining() ?? 1200),
55
+ }, undefined, { time: deadline?.time });
56
+ deadline?.check();
57
+ const health = parseServerResponse(res);
58
+ if (health.pid !== server.pid || health.instance_id !== server.instance_id) {
59
+ throw new Error("The doc-review server identity does not match its writer lock. End the review and restart the server.");
60
+ }
61
+ if (!serverProtocolMatches(health.protocol) || !serverProtocolMatches(server.protocol)) {
62
+ throw new Error(
63
+ `Incompatible live doc-review server (protocol ${health.protocol}; this CLI requires ${SERVER_PROTOCOL}). ` +
64
+ "End active reviews and stop/restart the old doc-review server before retrying. " +
65
+ "Its live writer lock and queued feedback have not been changed.",
66
+ );
67
+ }
68
+ return true;
69
+ } catch (err) {
70
+ if (isRecoverableTransportError(err)) return false;
71
+ throw err;
78
72
  }
79
73
  }
80
74
 
81
- async function ensureServer() {
75
+ async function ensureServer(deadline = createDeadline(20)) {
76
+ deadline.check();
82
77
  ensureStateDir();
83
78
  for (let launch = 0; launch < 3; launch += 1) {
84
79
  const saved = readServerRecord();
85
- if (saved?.protocol === SERVER_PROTOCOL && saved.port && saved.instance_id && (await alive(saved))) return saved;
80
+ if (await alive(saved, deadline)) return saved;
81
+ deadline.check();
86
82
 
87
83
  const child = spawn(process.execPath, [path.join(here, "server-entry.js")], {
88
84
  detached: true,
89
85
  stdio: "ignore",
90
86
  });
87
+ let launchError;
88
+ child.on("error", (err) => { launchError = err; });
91
89
  child.unref();
92
90
 
93
91
  for (let attempt = 0; attempt < 60; attempt += 1) {
94
- await new Promise((r) => setTimeout(r, 100));
92
+ await deadline.sleep(100);
93
+ if (launchError) throw launchError;
95
94
  const record = readServerRecord();
96
- // Same protocol gate as above: a still-running server from an older
97
- // version answers /health too, and must not be adopted here.
98
- if (record?.protocol === SERVER_PROTOCOL && record.port && record.instance_id && (await alive(record))) return record;
95
+ if (await alive(record, deadline)) return record;
96
+ if (child.exitCode !== null && child.exitCode !== 0) {
97
+ throw new Error("The local doc-review server failed to start. Check the state directory permissions and server startup diagnostics before retrying.");
98
+ }
99
99
  if (child.exitCode !== null && !readServerLock()) break;
100
100
  }
101
101
  }
102
- throw new Error("Could not start the local doc-review server.");
102
+ throw Object.assign(new Error("Could not start the local doc-review server yet."), { code: "SERVER_START_PENDING" });
103
103
  }
104
104
 
105
105
  function openBrowser(url) {
@@ -134,48 +134,6 @@ async function openCommand(input) {
134
134
  console.log(`\nWaiting for feedback? Run:\n doc-review poll ${shellQuote(target.value)}`);
135
135
  }
136
136
 
137
- /**
138
- * One long-poll attempt. Resolves { kind: "data", raw } when the server
139
- * answers, or { kind: "timeout" } when the caller's deadline passes first.
140
- */
141
- function pollOnce(server, target, ackId, timeoutMs) {
142
- const query = `target=${encodeURIComponent(target)}${ackId ? `&ack=${encodeURIComponent(ackId)}` : ""}`;
143
- return new Promise((resolve, reject) => {
144
- let done = false;
145
- const settle = (fn, value) => {
146
- if (done) return;
147
- done = true;
148
- clearTimeout(timer);
149
- fn(value);
150
- };
151
- const req = http.request(
152
- {
153
- host: "127.0.0.1",
154
- port: server.port,
155
- method: "GET",
156
- path: `/api/poll?${query}`,
157
- headers: { "x-doc-review-token": server.token || "" },
158
- },
159
- (res) => {
160
- let raw = "";
161
- res.setEncoding("utf8");
162
- res.on("data", (chunk) => {
163
- raw += chunk;
164
- });
165
- res.on("end", () => settle(resolve, { kind: "data", raw: raw.trim() }));
166
- }
167
- );
168
- const timer = timeoutMs
169
- ? setTimeout(() => {
170
- settle(resolve, { kind: "timeout" });
171
- req.destroy();
172
- }, timeoutMs)
173
- : null;
174
- req.on("error", (err) => settle(reject, err));
175
- req.end();
176
- });
177
- }
178
-
179
137
  /**
180
138
  * The consumer is an agent reading a pipe. process.exit() does not wait for
181
139
  * pending stdout writes, so a large payload could arrive truncated — always
@@ -185,50 +143,18 @@ function writeStdout(text) {
185
143
  return new Promise((resolve) => process.stdout.write(text, resolve));
186
144
  }
187
145
 
188
- function printTimeout(waitedSecs) {
189
- const payload = {
190
- status: "timeout",
191
- waited_seconds: waitedSecs,
192
- next_step:
193
- "No feedback yet. Run the same poll command again to keep waiting, or `doc-review status <target>` to check without blocking.",
194
- };
195
- return writeStdout(`${JSON.stringify(payload, null, 2)}\n`);
196
- }
197
-
198
- async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
146
+ async function pollCommand(input, { ackId = "", timeoutSecs = DEFAULT_POLL_SECONDS } = {}) {
147
+ const deadline = createDeadline(timeoutSecs);
199
148
  const target = canonicalTarget(input).value;
200
- let server = await ensureServer();
201
149
 
202
150
  const label = /^https?:\/\//i.test(target) ? target : path.basename(target);
203
151
  process.stderr.write(`Waiting for feedback on ${label} — comment in the browser, then hit Send.\n`);
204
152
 
205
- const deadline = timeoutSecs ? Date.now() + timeoutSecs * 1000 : null;
206
- for (let attempt = 0; attempt < 3; attempt += 1) {
207
- const remaining = deadline ? deadline - Date.now() : 0;
208
- if (deadline && remaining <= 0) return printTimeout(timeoutSecs);
209
- let result;
210
- try {
211
- result = await pollOnce(server, target, ackId && attempt === 0 ? ackId : "", remaining);
212
- } catch (err) {
213
- process.stderr.write(`Lost the connection (${err.message}); retrying.\n`);
214
- server = await ensureServer();
215
- continue;
216
- }
217
- if (result.kind === "timeout") return printTimeout(timeoutSecs);
218
- if (!result.raw) {
219
- server = await ensureServer();
220
- continue;
221
- }
222
- try {
223
- const batch = JSON.parse(result.raw);
224
- await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
225
- return;
226
- } catch {
227
- process.stderr.write("Unexpected response from the doc-review server; retrying.\n");
228
- }
229
- }
230
- process.stderr.write("Gave up waiting for feedback.\n");
231
- process.exit(1);
153
+ const batch = await pollUntilDeadline({
154
+ target, ackId, deadline, discover: ensureServer,
155
+ diagnostic: (text) => process.stderr.write(text),
156
+ });
157
+ await writeStdout(`${JSON.stringify(batch, null, 2)}\n`);
232
158
  }
233
159
 
234
160
  /**
@@ -239,7 +165,7 @@ async function pollCommand(input, { ackId = "", timeoutSecs = 0 } = {}) {
239
165
  async function statusCommand(input) {
240
166
  const target = canonicalTarget(input).value;
241
167
  const saved = readServerRecord();
242
- if (saved?.protocol === SERVER_PROTOCOL && saved.port && saved.instance_id && (await alive(saved))) {
168
+ if (serverProtocolMatches(saved?.protocol) && saved.port && saved.instance_id && (await alive(saved))) {
243
169
  const res = await request(saved, { method: "GET", path: `/api/status?target=${encodeURIComponent(target)}` });
244
170
  if (res.status === 200) {
245
171
  process.stdout.write(`${JSON.stringify(JSON.parse(res.raw), null, 2)}\n`);
@@ -289,7 +215,7 @@ process.on("SIGINT", () => {
289
215
  });
290
216
 
291
217
  function parsePollArgs(rest) {
292
- const parsed = { file: "", ackId: "", timeoutSecs: 0 };
218
+ const parsed = { file: "", ackId: "", timeoutSecs: DEFAULT_POLL_SECONDS };
293
219
  let sawTimeout = false;
294
220
  for (let i = 0; i < rest.length; i += 1) {
295
221
  const arg = rest[i];
@@ -0,0 +1,22 @@
1
+ const text = (value, limit) => typeof value === "string" ? value.slice(0, limit) : "";
2
+
3
+ export function normalizeCommentAnchor(kind, anchor) {
4
+ if (!anchor || typeof anchor !== "object" || Array.isArray(anchor)) return null;
5
+ if (kind === "element") {
6
+ const selector = text(anchor.selector, 2000);
7
+ if (!selector) return null;
8
+ return {
9
+ selector,
10
+ ...(text(anchor.label, 500) ? { label: text(anchor.label, 500) } : {}),
11
+ };
12
+ }
13
+
14
+ const quote = text(anchor.quote, 4000);
15
+ if (!quote) return null;
16
+ return {
17
+ quote,
18
+ ...(text(anchor.prefix, 1000) ? { prefix: text(anchor.prefix, 1000) } : {}),
19
+ ...(text(anchor.suffix, 1000) ? { suffix: text(anchor.suffix, 1000) } : {}),
20
+ ...(text(anchor.selector, 2000) ? { selector: text(anchor.selector, 2000) } : {}),
21
+ };
22
+ }
@@ -0,0 +1,77 @@
1
+ const finite = (value) => typeof value === "number" && Number.isFinite(value);
2
+
3
+ export function sanitizeRect(rect) {
4
+ if (!rect) return null;
5
+ const values = ["left", "top", "right", "bottom", "width", "height"];
6
+ if (!values.every((key) => finite(rect[key]))) return null;
7
+ if (rect.width < 0 || rect.height < 0 || rect.right < rect.left || rect.bottom < rect.top) return null;
8
+ return {
9
+ left: rect.left,
10
+ top: rect.top,
11
+ right: rect.right,
12
+ bottom: rect.bottom,
13
+ width: rect.width,
14
+ height: rect.height,
15
+ };
16
+ }
17
+
18
+ export function sanitizeClientRect(rect, viewport) {
19
+ const safe = sanitizeRect(rect);
20
+ if (!safe || !viewport || !finite(viewport.width) || !finite(viewport.height)) return null;
21
+ const { left: rawLeft, top: rawTop, right: rawRight, bottom: rawBottom } = safe;
22
+ if (rawRight <= 0 || rawBottom <= 0 || rawLeft >= viewport.width || rawTop >= viewport.height) return null;
23
+ const left = Math.max(0, Math.min(viewport.width, rawLeft));
24
+ const top = Math.max(0, Math.min(viewport.height, rawTop));
25
+ const right = Math.max(left, Math.min(viewport.width, rawRight));
26
+ const bottom = Math.max(top, Math.min(viewport.height, rawBottom));
27
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
28
+ }
29
+
30
+ export function sanitizeClientRects(rects, viewport, limit = 64) {
31
+ if (!Array.isArray(rects)) return [];
32
+ return rects.slice(0, limit).map((rect) => sanitizeClientRect(rect, viewport)).filter(Boolean);
33
+ }
34
+
35
+ export function sanitizeClipRect(rect, viewport) {
36
+ const safe = sanitizeClientRect(rect, viewport);
37
+ return safe && safe.width > 0 && safe.height > 0 ? safe : null;
38
+ }
39
+
40
+ export function sanitizeRelation(value) {
41
+ return ["visible", "above", "below", "unavailable"].includes(value) ? value : "unavailable";
42
+ }
43
+
44
+ export function targetMessage(
45
+ { kind, quote, anchor, rects, generation, relation, clip, horizontal },
46
+ viewport
47
+ ) {
48
+ const safeRects = sanitizeClientRects(rects, viewport);
49
+ const safeClip = sanitizeClipRect(clip, viewport);
50
+ if (!Number.isSafeInteger(generation) || generation < 1) return null;
51
+ const safeRelation = sanitizeRelation(relation);
52
+ if (!safeClip || (safeRelation === "visible" && !safeRects.length)) return null;
53
+ return {
54
+ kind: kind === "element" ? "element" : "selection",
55
+ quote: String(quote || ""),
56
+ anchor: anchor || null,
57
+ rects: safeRects,
58
+ generation,
59
+ relation: safeRelation,
60
+ clip: safeClip,
61
+ horizontal: finite(horizontal) ? Math.max(safeClip.left, Math.min(safeClip.right, horizontal)) : null,
62
+ };
63
+ }
64
+
65
+ export function acceptsTargetGeometry(currentGeneration, incoming) {
66
+ return !!incoming && Number.isSafeInteger(incoming.generation) && incoming.generation === currentGeneration;
67
+ }
68
+
69
+ export function acceptedOpenGeneration(
70
+ { pendingGeneration = null, retargetGeneration = null },
71
+ { accepted, requestedGeneration }
72
+ ) {
73
+ if (!accepted) return pendingGeneration;
74
+ if (requestedGeneration === retargetGeneration) return retargetGeneration;
75
+ if (requestedGeneration === pendingGeneration) return pendingGeneration;
76
+ return null;
77
+ }
@@ -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
+ }