@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/README.md CHANGED
@@ -1,170 +1,85 @@
1
1
  # Doc Review
2
2
 
3
- Review HTML, Markdown, and localhost pages, edit when you choose, leave contextual comments, and send all feedback to your AI agent at once.
3
+ **Review your coding agent's work in the browser, not in a wall of chat.**
4
4
 
5
- [Read the original Human Review launch post](https://creatoreconomy.so/p/use-my-human-review-skill-to-edit-html-markdown-visually)
5
+ Open an HTML file, a Markdown document, or a localhost page. Point to what needs
6
+ changing, edit the small things yourself, and send your feedback to the agent
7
+ in one batch.
6
8
 
7
- https://github.com/user-attachments/assets/7cab09c9-eaa0-4e8b-984d-2925e810b5c2
9
+ ![A landing page in Doc Review with highlighted text and a comment asking for more specific wording](https://raw.githubusercontent.com/erdemtuna/doc-review/main/assets/doc-review.png)
8
10
 
9
- ## Problem
11
+ *Feedback stays beside the work. Your agent gets the comments, edits, and overall note together.*
10
12
 
11
- Giving AI feedback on files in chat is painful.
13
+ ## Get started
12
14
 
13
- Sometimes you want to change one sentence yourself. Instead, you end up typing:
14
-
15
- > In the third paragraph, change X to Y. Cut the third card because it repeats the first one. Also rewrite the CTA.
16
-
17
- Then the agent changes the file and you have to check whether it understood every instruction. This gets even harder when you’re reviewing a long plan, Markdown document, landing page, or multi-page website.
18
-
19
- ## How to install /doc-review
20
-
21
- The easiest way to install the skill is to paste this into ChatGPT, Claude Code, Codex, or your favorite coding agent:
22
-
23
- ```text
24
- Install the /doc-review skill globally from https://github.com/erdemtuna/doc-review
25
- ```
26
-
27
- You can also install it with `npx`:
15
+ Requires **Node.js 24.21.0 or newer** and a coding agent that can run shell commands.
16
+ Install the skill once:
28
17
 
29
18
  ```sh
30
19
  npx -y @erdemtuna/doc-review setup --global
31
20
  ```
32
21
 
33
- This fork uses the `doc-review` command, `/doc-review` skill, and
34
- `~/.doc-review` state directory. It does not install a `human-review` alias or
35
- migrate upstream state and skill files; remove those old files manually if you
36
- no longer need them.
37
-
38
- ## How to use /doc-review
39
-
40
- Review is opt-in: explicitly invoke `/doc-review` or ask to open an interactive
41
- browser review. Writing, updating, or asking for a general review of content does
42
- not automatically open the browser or start polling. An explicitly started review
43
- continues until you end it or switch tasks.
44
-
45
- After updating installed skill instructions, start a fresh agent session or reload
46
- skills (in Copilot CLI, `/skills reload`). Setup copies the executing package's
47
- skill template; rerunning setup from an older package can restore older behavior.
48
- Setup updates its own `AGENTS.md` guidance inside `<!-- BEGIN doc-review -->` and
49
- `<!-- END doc-review -->` markers. Exact, known older generated blocks are migrated.
50
- Custom guidance is preserved with a migration message rather than overwritten;
51
- update it yourself if it requests automatic review. Invalid or duplicate markers
52
- must be corrected before setup can proceed.
53
-
54
- ![Doc Review visual editor](assets/doc-review.png)
55
-
56
- Open an HTML or Markdown file:
22
+ Start a fresh agent session, then ask it to open a review:
57
23
 
58
24
  ```text
59
- /doc-review (your file)
25
+ /doc-review path/to/landing-page.html
60
26
  ```
61
27
 
62
- Review a page running on localhost:
28
+ The same command works with Markdown or a running local app:
63
29
 
64
30
  ```text
65
- /doc-review (localhost URL)
66
- ```
67
-
68
- Doc Review opens in **View** (`Editing off, comments enabled`) so links, buttons, summaries, tabs, and application controls work normally. The centered mode selector switches to **Edit** (`Direct editing on`) when you want to change content directly. Commenting stays available in both modes.
69
-
70
- Select text, or hover or focus an element, then use the nearby comment icon. `Ctrl+Alt+M` (`Cmd+Option+M` on macOS) opens a comment for the current selection. Press Enter to submit or Shift+Enter for a new line. On desktop the composer stays beside its target, or pins to the effective top or bottom scrolling edge when that target leaves view. **Back to selection** reveals the target without changing the draft. The full-width sheet is used only at the narrow responsive breakpoint.
71
-
72
- **Comments** is the single toolbar entry point for the closed-by-default review drawer. Its feedback inventory scrolls independently while the overall note and **Send to agent** controls remain fixed. Submitting a comment creates its normal highlight and increments the count, but keeps the card closed until you activate its mark or choose **Jump to**. Focus returns to the exact element or selection you reviewed.
73
-
74
- Aligned cards show **Edit**, **Close**, and **More**; drawer cards show **Jump to**, **Edit**, and **More**. Delete lives only in **More** and requires an inline confirmation. Quote hints preserve both the beginning and ending of long selections. Editing has explicit **Save** and **Cancel** controls: Enter saves, Shift+Enter adds a line, Escape cancels, and moving focus never autosaves. A draft follows its comment between aligned and drawer cards and keeps its caret through target movement.
75
-
76
- For writable HTML files, Edit saves direct changes automatically. Markdown and localhost remain editable feedback-only surfaces: their rendered HTML is never written over the source, so click Send and let the agent apply those edits.
77
-
78
- File and rendered Markdown reviews run with authored scripts and inline handlers
79
- blocked, an opaque iframe origin, and no popup or download permission. Their
80
- per-render message capability is rotated for every load and navigation. It is
81
- kept out of document URLs, HTML attributes, authored DOM, and global JavaScript
82
- state; the single-use artifact URL loads a same-origin bootstrap module under a
83
- nonce-based CSP. Relative assets, including a same-artifact `<base>`, resolve
84
- beside the reviewed file, while review navigation always stays relative to the
85
- source file. External bases are ignored.
86
-
87
- Localhost reviews keep `allow-same-origin`, popup, and download compatibility so
88
- application behavior still works. Because localhost application scripts are
89
- trusted in that mode, the render capability provides correlation and stale
90
- message rejection rather than an authorization boundary. The authenticated
91
- parent still validates links and never exposes the raw-file save route to a
92
- localhost review.
93
-
94
- Agent polling returns an immutable `batch_id`. After applying the batch, the
95
- agent acknowledges that exact receipt and keeps waiting:
96
-
97
- ```sh
98
- npx -y @erdemtuna/doc-review poll path/to/file.html --ack b_0123456789abcdef --timeout 600
31
+ /doc-review path/to/plan.md
32
+ /doc-review http://localhost:3000
99
33
  ```
100
34
 
101
- A stale or repeated batch ID is harmless: it never clears newer feedback. The
102
- complete acknowledgement command is included in each response's `next_step`.
103
-
104
- ### Feedback reliability and limits
105
-
106
- `poll` without `--timeout` waits for at most 12 hours. Explicit timeouts include
107
- server discovery, reconnection, and retry backoff, not just time spent connected.
108
- Recoverable connection drops are retried; invalid responses, authorization errors,
109
- and incompatible servers fail visibly. A timeout does not discard feedback: use
110
- `status` to check it, then start another poll if the review is still wanted.
111
-
112
- Each edit text or HTML field is limited to 200,000 Unicode code points. Oversized
113
- edits carry `truncated: true` and `truncated_fields` naming incomplete fields.
114
- The agent must not use partial text or HTML as a complete replacement or invent
115
- the rest. It must obtain the full edit from an authoritative source or ask you
116
- for it before acknowledging the batch.
117
-
118
- External writes to a Markdown source refresh its rendered baseline without
119
- clearing unsent feedback. That preserves your edits; it does not automatically
120
- resolve conflicts with the changed source. HTML autosaves keep their existing
121
- behavior.
35
+ Reviews start only when you request them. Your agent uses the installed skill
36
+ to open the page, wait for feedback, and apply the changes.
122
37
 
123
- ### Upgrading from v0.8.0
38
+ ## From feedback to the next version
124
39
 
125
- The updated CLI requires server protocol 13. An older server that is still
126
- running is not silently reused or forcibly replaced. End active reviews and
127
- shut down that specific old server (or let it exit when idle) before restarting
128
- Doc Review. Keep the `.doc-review` state directory: pending batches and their
129
- exact receipt IDs survive a controlled restart.
40
+ 1. **Open and explore.** Reviews start in **View**, so you can read and use page
41
+ controls without accidentally editing.
42
+ 2. **Point out what matters.** Select text or choose an element to leave a
43
+ comment. Switch to **Edit** for direct changes to wording, formatting, images,
44
+ or layout. Commenting works in either mode.
45
+ 3. **Send one batch.** Open **Comments**, add an overall note if needed, and
46
+ choose **Send to agent**. No need to describe where every sentence lives.
47
+ 4. **Check the result.** Use **Changes** to compare a review round's captured
48
+ before and after content, then continue reviewing. Comparisons show observed
49
+ changes, not a guarantee that every request was resolved.
130
50
 
131
- After installing the updated package, rerun `doc-review setup --global` for
132
- personal skills and `doc-review setup` in projects with generated guidance.
133
- Reload skills or start a fresh agent session afterward. A newer CLI paired with
134
- older instructions is not a complete upgrade.
51
+ You can review a plan, refine a landing page, or walk through a local app without
52
+ moving your feedback into a separate document.
135
53
 
136
- ## What this skill lets you do
54
+ ## What happens to your edits?
137
55
 
138
- - **Edit text directly and tweak basic formatting** (e.g., bold, italic).
139
- - **Make bulleted and numbered lists** — type `- ` or `1. ` at the start of a line, or press ⌘⇧8 / ⌘⇧7. Tab and Shift+Tab indent and outdent.
140
- - **Add links** — select text and press ⌘K. ⌘K inside an existing link edits or removes it.
141
- - **Resize images** by dragging their corner, and **move images** by dragging them to a new spot.
142
- - **Rearrange the page** — hover any block and drag the handle on its left edge to move the whole block somewhere else.
143
- - **Paste images** from your clipboard — file reviews save them beside the document; localhost reviews stage them for the agent to place in the app source.
144
- - **Select a phrase and leave a comment** from the contextual icon, or press Ctrl+Alt+M / Cmd+Option+M.
145
- - **Comment on an image, chart, control, or section** from its hover or keyboard-focus affordance without taking over its normal click.
146
- - **Remove elements** without explaining the deletion in chat.
147
- - **Command-click links** to review multiple pages without losing your feedback.
148
- - **Send every edit and comment at once** instead of writing a long chat message.
56
+ | What you open | Where edits go |
57
+ | --- | --- |
58
+ | Plain HTML | Saved directly to the file, with Revert available |
59
+ | Scripted HTML or Markdown | Sent to the agent to apply to the original source |
60
+ | Localhost page | Sent to the agent to update the app's source |
149
61
 
150
- Doc Review works well for editing AI-generated plans, updating landing pages, reviewing localhost apps, and removing extra copy from a UX.
62
+ Self contained HTML can run inline scripts. For an app with separate script
63
+ dependencies, use its localhost URL instead.
151
64
 
152
- ## What’s inside
65
+ Doc Review runs locally and needs no Doc Review account, hosted backend, or API
66
+ key. The page you review and the coding agent you use may still contact external
67
+ services.
153
68
 
154
- - [`cli.js`](src/cli.js) contains the `doc-review`, `poll`, `status`, and `setup` commands.
155
- - [`server.js`](src/server.js) runs the local review session.
156
- - [`sdk.js`](src/sdk.js) handles editing, comments, highlights, and feedback.
157
- - [`chrome-client.js`](src/chrome-client.js) contains the visual review interface.
158
- - [`markdown.js`](src/markdown.js) renders Markdown files for review.
159
- - [`SKILL.md`](src/SKILL.md) teaches Claude Code, Codex, and other agents how to use Doc Review.
69
+ ## Learn more
160
70
 
161
- Everything runs on your computer. Doc Review doesn’t require an account, cloud service, database, or API key.
162
- Comment geometry remains local, transient presentation data and is never written to review state or sent to the agent. After the agent acknowledges the exact delivered `batch_id`, the comments carried by that batch disappear; newer comments and corrections remain.
71
+ [Usage guide](https://github.com/erdemtuna/doc-review/blob/main/docs/usage.md):
72
+ setup options, comments, comparisons, limitations, and upgrades.
163
73
 
164
- ## Upstream project
74
+ [Development](https://github.com/erdemtuna/doc-review/blob/main/docs/development.md):
75
+ build, test, architecture, and package checks.
165
76
 
166
- Doc Review is an independent fork of [Human Review](https://github.com/petergyang/human-review), originally created by Peter Yang. The fork preserves the upstream MIT license and continues from the upstream `v0.6.1` release.
77
+ [Releasing](https://github.com/erdemtuna/doc-review/blob/main/RELEASING.md):
78
+ the maintainers' release process.
167
79
 
168
- ## License
80
+ ## Credits and license
169
81
 
170
- MIT
82
+ Doc Review is an independent fork of Peter Yang's
83
+ [Human Review](https://github.com/petergyang/human-review), continuing from
84
+ upstream v0.6.1. Licensed under
85
+ [MIT](https://github.com/erdemtuna/doc-review/blob/main/LICENSE).
@@ -18,10 +18,19 @@ end it; stop if they cancel or switch to a different task.
18
18
 
19
19
  ## Review behavior
20
20
 
21
- The user reviews your HTML, Markdown, or localhost page in a real browser. It starts in View,
22
- where normal page controls work. They can switch to Edit, comment explicitly in either mode,
21
+ The user reviews your HTML, Markdown, or localhost page in a real browser. It starts in View.
22
+ Native page controls work; supported self-contained HTML scripts run automatically.
23
+ They can switch to Edit, comment explicitly in either mode,
23
24
  and send the whole batch at once.
24
25
 
26
+ Plain HTML retains direct autosave. Scripted HTML edits are feedback-only:
27
+ apply them to the original source, never serialize the script-modified runtime
28
+ page over the file. Source changes do not require renewed approval.
29
+ Reload without scripts is a temporary recovery option, not permission to write
30
+ a scripted preview over its source. Reloads can wait for unresolved editing work.
31
+ Applications requiring external script dependencies should use localhost review;
32
+ automatic inline execution does not add support for application imports or workers.
33
+
25
34
  Markdown files open rendered. Their quotes and edits reference the rendered text,
26
35
  and the file itself is never touched — apply every change to the Markdown source,
27
36
  keeping its formatting syntax.
@@ -96,6 +105,17 @@ carried; newer comments and corrections survive.
96
105
  The response's `next_step` contains the complete acknowledgement command.
97
106
  Never acknowledge a different or guessed ID.
98
107
 
108
+ Acknowledgement means that you handled the feedback; it is separate from
109
+ browser result-capture readiness. Do not wait for history capture before
110
+ acknowledging completed work, and do not acknowledge merely because a page
111
+ looks stable. The browser captures round results automatically when possible.
112
+ Missing comparison data does not prevent the user from sending feedback.
113
+ Captured differences are observations, not proof that you alone authored them.
114
+ Content results may wait for the same identifiable tab as the baseline.
115
+ The user sees a return-to-tab prompt; do not automate tab clicking to force
116
+ capture. Source comparison remains independent. Unknown view identity is
117
+ labelled unverified, not asserted as a whole-application comparison.
118
+
99
119
  Repeat 3–4 until the user says they are done.
100
120
 
101
121
  Not sure whether feedback is already waiting — say, at the start of a new turn?
@@ -173,8 +193,12 @@ One batch covers every page the user visited, grouped by file or localhost URL.
173
193
  - Find each comment by its `quote`; that exact string is in the file.
174
194
  - `kind: "element"` points at a whole block, so `quote` is its label, not body text.
175
195
  - Fix every page in `pages`, not just the first.
176
- - **Do not write a reply.** There is no chat. The user sees your work when the page
177
- reloads, which happens on its own the moment you save the file.
196
+ - **Do not write a reply.** There is no chat. The user sees the updated page and
197
+ can choose Changes for a fixed Send-to-result comparison. Reload may wait
198
+ for the user to handle unsaved review work. Live result capture may require
199
+ a contextual retry; never describe unavailable history as no changes.
200
+ - Review history uses local content/source snapshots, not screenshots or Git
201
+ commits. Do not create commits or alter repository state to populate history.
178
202
 
179
203
  ## Better edit labels (optional)
180
204
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Quote-with-context anchoring, in the spirit of the W3C TextQuoteSelector.
3
+ *
4
+ * These functions are pure string maths so they can be unit-tested in Node and
5
+ * shipped verbatim to the browser, where the DOM half of anchoring lives.
6
+ */
7
+ export const CONTEXT_PAD = 32;
8
+ /** Capture `quote` plus surrounding context so it can be re-found after edits. */
9
+ export function buildContext(text, start, end, pad = CONTEXT_PAD) {
10
+ return {
11
+ prefix: text.slice(Math.max(0, start - pad), start),
12
+ quote: text.slice(start, end),
13
+ suffix: text.slice(end, Math.min(text.length, end + pad)),
14
+ };
15
+ }
16
+ function commonSuffixLength(a, b) {
17
+ let n = 0;
18
+ while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n])
19
+ n += 1;
20
+ return n;
21
+ }
22
+ function commonPrefixLength(a, b) {
23
+ let n = 0;
24
+ while (n < a.length && n < b.length && a[n] === b[n])
25
+ n += 1;
26
+ return n;
27
+ }
28
+ function occurrences(text, quote) {
29
+ const found = [];
30
+ let at = text.indexOf(quote);
31
+ while (at !== -1) {
32
+ found.push(at);
33
+ at = text.indexOf(quote, at + 1);
34
+ }
35
+ return found;
36
+ }
37
+ function bestHit(text, hits, quote, prefix, suffix) {
38
+ let best = hits[0];
39
+ let bestScore = -1;
40
+ for (const at of hits) {
41
+ const before = text.slice(Math.max(0, at - prefix.length), at);
42
+ const after = text.slice(at + quote.length, at + quote.length + suffix.length);
43
+ const score = commonSuffixLength(prefix, before) + commonPrefixLength(suffix, after);
44
+ if (score > bestScore) {
45
+ bestScore = score;
46
+ best = at;
47
+ }
48
+ }
49
+ return { at: best, score: bestScore };
50
+ }
51
+ const collapse = (s) => String(s || "").replace(/\s+/g, " ");
52
+ /** Collapse whitespace runs, keeping a map from each kept char to its original index. */
53
+ function collapseWithMap(text) {
54
+ let flat = "";
55
+ const map = [];
56
+ let pendingWs = -1;
57
+ for (let i = 0; i < text.length; i += 1) {
58
+ if (/\s/.test(text[i])) {
59
+ if (pendingWs === -1)
60
+ pendingWs = i;
61
+ continue;
62
+ }
63
+ if (pendingWs !== -1 && flat) {
64
+ flat += " ";
65
+ map.push(pendingWs);
66
+ }
67
+ pendingWs = -1;
68
+ flat += text[i];
69
+ map.push(i);
70
+ }
71
+ return { flat, map };
72
+ }
73
+ /**
74
+ * Locate `ctx.quote` in `text`, using prefix/suffix to disambiguate repeats.
75
+ * Returns `{ start, end, exact }` or null when the quote is gone entirely.
76
+ */
77
+ export function findQuote(text, ctx) {
78
+ const quote = ctx && ctx.quote;
79
+ if (!quote)
80
+ return null;
81
+ const hits = occurrences(text, quote);
82
+ if (hits.length === 1) {
83
+ return { start: hits[0], end: hits[0] + quote.length, exact: true };
84
+ }
85
+ if (hits.length > 1) {
86
+ const { at, score } = bestHit(text, hits, quote, ctx.prefix || "", ctx.suffix || "");
87
+ return { start: at, end: at + quote.length, exact: score > 0 };
88
+ }
89
+ // Reformatting (a prettier run, an agent rewrite) reflows whitespace without
90
+ // changing any words. Match again on whitespace-collapsed text and map the
91
+ // hit back to real offsets, so those comments survive instead of orphaning.
92
+ const { flat, map } = collapseWithMap(text);
93
+ const flatQuote = collapse(quote).trim();
94
+ if (!flatQuote || !map.length)
95
+ return null;
96
+ const flatHits = occurrences(flat, flatQuote);
97
+ if (!flatHits.length)
98
+ return null;
99
+ const { at } = bestHit(flat, flatHits, flatQuote, collapse(ctx.prefix || ""), collapse(ctx.suffix || ""));
100
+ return { start: map[at], end: map[at + flatQuote.length - 1] + 1, exact: false };
101
+ }
102
+ /** Collapse runs of whitespace for display in a comment card. */
103
+ export function tidy(text, limit = 0) {
104
+ const flat = String(text == null ? "" : text)
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ if (!limit || flat.length <= limit)
108
+ return flat;
109
+ return `${flat.slice(0, limit - 1).trimEnd()}…`;
110
+ }
111
+ function graphemes(text) {
112
+ if (typeof Intl?.Segmenter === "function") {
113
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
114
+ return [...segmenter.segment(text)].map(({ segment }) => segment);
115
+ }
116
+ // Array.from is deterministic and, unlike string slicing, never splits a
117
+ // surrogate pair. Older engines may split a multi-code-point grapheme.
118
+ return Array.from(text);
119
+ }
120
+ function nearbyHeadBoundary(parts, ideal) {
121
+ for (let index = ideal; index >= Math.max(1, ideal - 8); index -= 1) {
122
+ if (/^\s$/u.test(parts[index - 1]))
123
+ return index - 1;
124
+ }
125
+ return ideal;
126
+ }
127
+ function nearbyTailBoundary(parts, ideal) {
128
+ for (let index = ideal; index < Math.min(parts.length - 1, ideal + 8); index += 1) {
129
+ if (/^\s$/u.test(parts[index]))
130
+ return index + 1;
131
+ }
132
+ return ideal;
133
+ }
134
+ /**
135
+ * Collapse whitespace and preserve both ends of a long quote. `limit` counts
136
+ * grapheme clusters, including the ellipsis.
137
+ */
138
+ export function tidyMiddle(text, limit = 0) {
139
+ const flat = String(text == null ? "" : text)
140
+ .replace(/\s+/g, " ")
141
+ .trim();
142
+ if (!limit)
143
+ return flat;
144
+ const parts = graphemes(flat);
145
+ if (parts.length <= limit)
146
+ return flat;
147
+ if (limit <= 1)
148
+ return "…";
149
+ const available = limit - 1;
150
+ const idealHead = Math.ceil(available * 0.6);
151
+ const idealTailStart = parts.length - (available - idealHead);
152
+ const headEnd = nearbyHeadBoundary(parts, idealHead);
153
+ const tailStart = nearbyTailBoundary(parts, idealTailStart);
154
+ return `${parts.slice(0, headEnd).join("").trimEnd()}…${parts.slice(tailStart).join("").trimStart()}`;
155
+ }
@@ -0,0 +1,54 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ const RETRY_DELAYS_MS = [10, 20, 40, 80, 160];
4
+ const TRANSIENT_RENAME_ERRORS = new Set(["EPERM", "EACCES", "EBUSY"]);
5
+ const waitState = new Int32Array(new SharedArrayBuffer(4));
6
+ // Keep Store transactions synchronous; rename backoff blocks for at most 310ms.
7
+ const sleepSync = (ms) => Atomics.wait(waitState, 0, 0, ms);
8
+ export function createAtomicWriter({ fileSystem = fs, sleep = sleepSync, report = console.error } = {}) {
9
+ return function atomicWrite(file, data) {
10
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
11
+ let fd;
12
+ let created = false;
13
+ try {
14
+ fd = fileSystem.openSync(tmp, "wx");
15
+ created = true;
16
+ fileSystem.writeFileSync(fd, data);
17
+ fileSystem.closeSync(fd);
18
+ fd = undefined;
19
+ for (let attempt = 0;; attempt += 1) {
20
+ try {
21
+ fileSystem.renameSync(tmp, file);
22
+ return;
23
+ }
24
+ catch (err) {
25
+ if (!TRANSIENT_RENAME_ERRORS.has(err.code) || attempt === RETRY_DELAYS_MS.length)
26
+ throw err;
27
+ sleep(RETRY_DELAYS_MS[attempt]);
28
+ }
29
+ }
30
+ }
31
+ catch (err) {
32
+ if (fd !== undefined) {
33
+ try {
34
+ fileSystem.closeSync(fd);
35
+ }
36
+ catch (cleanupError) {
37
+ report(`Could not close atomic-write temporary file ${tmp}: ${cleanupError.message}`);
38
+ }
39
+ }
40
+ if (created) {
41
+ try {
42
+ fileSystem.unlinkSync(tmp);
43
+ }
44
+ catch (cleanupError) {
45
+ if (cleanupError.code !== "ENOENT") {
46
+ report(`Could not remove atomic-write temporary file ${tmp}: ${cleanupError.message}`);
47
+ }
48
+ }
49
+ }
50
+ throw err;
51
+ }
52
+ };
53
+ }
54
+ export const atomicWrite = createAtomicWriter();
@@ -0,0 +1,78 @@
1
+ export class ApiError extends Error {
2
+ status;
3
+ code;
4
+ targets;
5
+ constructor(message, status, code, targets) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.targets = targets;
10
+ this.name = "ApiError";
11
+ }
12
+ }
13
+ export function record(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
15
+ throw new Error("The review server returned an invalid response.");
16
+ }
17
+ return value;
18
+ }
19
+ export function createReviewApi({ token, fetch: request = globalThis.fetch, }) {
20
+ const pending = new Set();
21
+ let disposed = false;
22
+ async function send(path, options = {}) {
23
+ if (disposed)
24
+ throw new Error("The review connection is closed.");
25
+ const controller = new AbortController();
26
+ const abort = () => controller.abort(options.signal?.reason);
27
+ if (options.signal?.aborted)
28
+ abort();
29
+ else
30
+ options.signal?.addEventListener("abort", abort, { once: true });
31
+ pending.add(controller);
32
+ try {
33
+ const headers = new Headers(options.headers);
34
+ if (!headers.has("content-type"))
35
+ headers.set("content-type", "application/json");
36
+ headers.set("x-doc-review-token", token);
37
+ const response = await request(path, { ...options, headers, signal: controller.signal });
38
+ if (!response.ok) {
39
+ let detail = {};
40
+ try {
41
+ detail = record(await response.json());
42
+ }
43
+ catch (error) {
44
+ if (controller.signal.aborted)
45
+ throw error;
46
+ // Preserve the HTTP failure even when the error body is not JSON.
47
+ }
48
+ throw new ApiError(typeof detail.error === "string" ? detail.error : `Request failed (${response.status})`, response.status, typeof detail.code === "string" ? detail.code : undefined, Array.isArray(detail.targets) ? detail.targets : []);
49
+ }
50
+ return await response.json();
51
+ }
52
+ finally {
53
+ pending.delete(controller);
54
+ options.signal?.removeEventListener("abort", abort);
55
+ }
56
+ }
57
+ async function requestJson(path, options, decode) {
58
+ const value = await send(path, options);
59
+ return decode ? decode(value) : value;
60
+ }
61
+ return {
62
+ request: requestJson,
63
+ dispose() {
64
+ if (disposed)
65
+ return;
66
+ disposed = true;
67
+ for (const controller of pending)
68
+ controller.abort(new Error("Review ended"));
69
+ pending.clear();
70
+ },
71
+ };
72
+ }
73
+ import { isPageResponse } from "./contracts/page.js";
74
+ export function decodePage(value) {
75
+ if (!isPageResponse(value))
76
+ throw new Error("The review server returned an invalid page response.");
77
+ return value;
78
+ }