@erdemtuna/doc-review 0.9.0 → 0.11.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 +58 -222
- package/{src → lib}/SKILL.md +5 -2
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +1700 -0
- package/lib/chrome-session.js +73 -0
- package/lib/chrome.html +53 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/controller-store.js +96 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +19 -0
- package/lib/feedback-controller.js +113 -0
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +217 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +325 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +303 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/lib/view-identity.js +112 -0
- package/package.json +50 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/chrome.css +0 -652
- package/src/chrome.html +0 -196
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/document-trust.js +0 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const MAX_TABS = 32;
|
|
2
|
+
const MAX_ID = 256;
|
|
3
|
+
const unknown = (reason) => ({ version: 1, status: "unverified", tabs: [], reason });
|
|
4
|
+
export function normalizeView(value) {
|
|
5
|
+
if (value === undefined || value === null)
|
|
6
|
+
return unknown("View identity was not recorded.");
|
|
7
|
+
const invalid = () => { throw Object.assign(new Error("Invalid captured view identity."), { code: "INVALID_REVISION", status: 400 }); };
|
|
8
|
+
if (value.version !== 1 || !["identified", "unverified"].includes(value.status) ||
|
|
9
|
+
!Array.isArray(value.tabs) || value.tabs.length > MAX_TABS)
|
|
10
|
+
invalid();
|
|
11
|
+
if (value.status === "unverified") {
|
|
12
|
+
if (value.tabs.length || (value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500)))
|
|
13
|
+
invalid();
|
|
14
|
+
return unknown(value.reason || "View identity could not be verified.");
|
|
15
|
+
}
|
|
16
|
+
if (!value.tabs.length)
|
|
17
|
+
invalid();
|
|
18
|
+
const groups = new Set();
|
|
19
|
+
const tabs = value.tabs.map((tab) => {
|
|
20
|
+
if (!tab || typeof tab !== "object")
|
|
21
|
+
invalid();
|
|
22
|
+
for (const key of ["groupId", "tabId", "panelId", "label"]) {
|
|
23
|
+
if (typeof tab[key] !== "string" || !tab[key] || tab[key].length > MAX_ID)
|
|
24
|
+
invalid();
|
|
25
|
+
}
|
|
26
|
+
if (groups.has(tab.groupId))
|
|
27
|
+
invalid();
|
|
28
|
+
groups.add(tab.groupId);
|
|
29
|
+
return { groupId: tab.groupId, tabId: tab.tabId, panelId: tab.panelId, label: tab.label };
|
|
30
|
+
}).sort((a, b) => a.groupId.localeCompare(b.groupId));
|
|
31
|
+
return { version: 1, status: "identified", tabs };
|
|
32
|
+
}
|
|
33
|
+
function viewKey(view) {
|
|
34
|
+
const normalized = normalizeView(view);
|
|
35
|
+
return JSON.stringify([normalized.status, normalized.tabs.map(({ groupId, tabId, panelId }) => [groupId, tabId, panelId])]);
|
|
36
|
+
}
|
|
37
|
+
export function sameObservedView(before, after) {
|
|
38
|
+
return viewKey(before) === viewKey(after);
|
|
39
|
+
}
|
|
40
|
+
export function compareCapturedViews(before, after) {
|
|
41
|
+
const left = normalizeView(before);
|
|
42
|
+
const right = normalizeView(after);
|
|
43
|
+
if (left.status === "identified") {
|
|
44
|
+
if (right.status === "identified" && sameObservedView(left, right)) {
|
|
45
|
+
return { status: "matched", message: `Matching visible view: ${left.tabs.map((tab) => tab.label).join(" / ")}.` };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
status: "mismatch",
|
|
49
|
+
message: `Return to ${left.tabs.map((tab) => tab.label).join(" / ")} to capture the result.${right.status === "unverified" ? " The current tab identity could not be verified." : ""}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { status: "unverified", message: "Visible content only; matching view not verified." };
|
|
53
|
+
}
|
|
54
|
+
export function observeView(document) {
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
let visited = 0;
|
|
57
|
+
let limited = false;
|
|
58
|
+
const visible = (element) => {
|
|
59
|
+
for (let node = element; node; node = node.parentElement) {
|
|
60
|
+
if (++visited > 5000 || Date.now() - started > 500) {
|
|
61
|
+
limited = true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (node.hidden || node.getAttribute("aria-hidden") === "true" || node.hasAttribute("data-eh-ui"))
|
|
65
|
+
return false;
|
|
66
|
+
const style = document.defaultView.getComputedStyle(node);
|
|
67
|
+
if (style.display === "none" || style.visibility === "hidden")
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const uniqueId = (element) => {
|
|
73
|
+
if (!element?.id || element.id.length > MAX_ID)
|
|
74
|
+
return false;
|
|
75
|
+
const escaped = element.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\n\r\f]/g, "");
|
|
76
|
+
return document.querySelectorAll(`[id="${escaped}"]`).length === 1;
|
|
77
|
+
};
|
|
78
|
+
const candidates = document.querySelectorAll('[role="tablist"]');
|
|
79
|
+
if (candidates.length > MAX_TABS)
|
|
80
|
+
return unknown("Too many tab groups to identify reliably.");
|
|
81
|
+
const groups = [...candidates].filter(visible);
|
|
82
|
+
if (limited)
|
|
83
|
+
return unknown("View identity observation exceeded its limits.");
|
|
84
|
+
if (!groups.length)
|
|
85
|
+
return unknown("No reliably identifiable tab groups were found.");
|
|
86
|
+
if (groups.length > MAX_TABS)
|
|
87
|
+
return unknown("Too many tab groups to identify reliably.");
|
|
88
|
+
const tabs = [];
|
|
89
|
+
for (const group of groups) {
|
|
90
|
+
const members = [...group.querySelectorAll('[role="tab"]')].filter((tab) => tab.closest('[role="tablist"]') === group);
|
|
91
|
+
if (members.length > 128 || Date.now() - started > 500)
|
|
92
|
+
return unknown("View identity observation exceeded its limits.");
|
|
93
|
+
const selected = members.filter((tab) => tab.getAttribute("aria-selected") === "true");
|
|
94
|
+
if (selected.length !== 1)
|
|
95
|
+
return unknown("A tab group does not have exactly one selected tab.");
|
|
96
|
+
const tab = selected[0];
|
|
97
|
+
const panelId = tab.getAttribute("aria-controls");
|
|
98
|
+
const panel = panelId && document.getElementById(panelId);
|
|
99
|
+
if (!visible(tab) || !uniqueId(tab) || !uniqueId(panel) || panel.getAttribute("role") !== "tabpanel" || !visible(panel)) {
|
|
100
|
+
return unknown("Selected tabs and visible panels do not have unique stable identifiers.");
|
|
101
|
+
}
|
|
102
|
+
// A complete set of authored tab IDs identifies an otherwise unnamed tablist.
|
|
103
|
+
if (!members.length || members.some((member) => !uniqueId(member)))
|
|
104
|
+
return unknown("Tab identifiers are incomplete.");
|
|
105
|
+
const groupId = uniqueId(group) ? `id:${group.id}` : `tabs:${JSON.stringify(members.map((member) => member.id).sort())}`;
|
|
106
|
+
if (groupId.length > MAX_ID)
|
|
107
|
+
return unknown("Tab group identity exceeds the capture limit.");
|
|
108
|
+
const label = (tab.getAttribute("aria-label") || tab.textContent || tab.id).replace(/\s+/g, " ").trim().slice(0, MAX_ID);
|
|
109
|
+
tabs.push({ groupId, tabId: tab.id, panelId, label: label || tab.id });
|
|
110
|
+
}
|
|
111
|
+
return normalizeView({ version: 1, status: "identified", tabs });
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erdemtuna/doc-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Review and edit agent-generated files and localhost pages in the browser, then send the whole batch back to your agent.",
|
|
5
5
|
"author": "Peter Yang",
|
|
6
6
|
"homepage": "https://github.com/erdemtuna/doc-review#readme",
|
|
@@ -13,22 +13,37 @@
|
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
15
|
"bin": {
|
|
16
|
-
"doc-review": "
|
|
16
|
+
"doc-review": "lib/cli.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"
|
|
19
|
+
"lib",
|
|
20
20
|
"README.md",
|
|
21
21
|
"LICENSE"
|
|
22
22
|
],
|
|
23
23
|
"engines": {
|
|
24
|
-
"node": ">=
|
|
24
|
+
"node": ">=24.21.0"
|
|
25
25
|
},
|
|
26
|
+
"packageManager": "npm@12.0.2",
|
|
26
27
|
"scripts": {
|
|
28
|
+
"build": "node scripts/build.js",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.shared.json && tsc -p tsconfig.browser.json && tsc -p tsconfig.node.json && tsc -p tsconfig.ui.json && tsc -p tsconfig.ui-test.json && tsc -p tsconfig.tooling.json",
|
|
30
|
+
"prepack": "node scripts/build.js",
|
|
27
31
|
"test": "npm run test:unit",
|
|
28
|
-
"test:unit": "node --test",
|
|
29
|
-
"test:
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
+
"test:unit": "npm run build && node --test test/*.test.js && npm run test:ui",
|
|
33
|
+
"test:ui": "vitest run",
|
|
34
|
+
"preview:ui": "node scripts/ui-preview.js",
|
|
35
|
+
"preview:recovery": "node scripts/ui-preview.js --recovery",
|
|
36
|
+
"preview:shell": "npm run build && node scripts/shell-preview.js",
|
|
37
|
+
"preview:review": "npm run build && node scripts/migration-review.js",
|
|
38
|
+
"media:readme": "npm run build && node scripts/capture-readme.js",
|
|
39
|
+
"media:preview": "node scripts/preview-readme.js",
|
|
40
|
+
"test:browser": "npm run build && playwright test",
|
|
41
|
+
"test:package": "node scripts/package-smoke.js",
|
|
42
|
+
"test:package:browser": "node scripts/package-smoke.js --browser",
|
|
43
|
+
"test:all": "npm run build && node --test test/*.test.js && npm run test:ui && playwright test && npm run test:package",
|
|
44
|
+
"browser:install": "playwright install chromium",
|
|
45
|
+
"browser:install:ci": "playwright install --with-deps chromium",
|
|
46
|
+
"start": "npm run build && node lib/cli.js"
|
|
32
47
|
},
|
|
33
48
|
"keywords": [
|
|
34
49
|
"html",
|
|
@@ -48,13 +63,34 @@
|
|
|
48
63
|
"registry": "https://registry.npmjs.org"
|
|
49
64
|
},
|
|
50
65
|
"devDependencies": {
|
|
51
|
-
"@playwright/test": "
|
|
52
|
-
"
|
|
53
|
-
"
|
|
66
|
+
"@playwright/test": "1.63.0",
|
|
67
|
+
"@tailwindcss/vite": "4.3.3",
|
|
68
|
+
"@testing-library/dom": "10.4.1",
|
|
69
|
+
"@testing-library/jest-dom": "7.0.1",
|
|
70
|
+
"@testing-library/react": "16.3.3",
|
|
71
|
+
"@testing-library/user-event": "14.6.7",
|
|
72
|
+
"@types/node": "24.10.4",
|
|
73
|
+
"@types/react": "19.3.0",
|
|
74
|
+
"@types/react-dom": "19.3.0",
|
|
75
|
+
"@vitejs/plugin-react": "6.1.1",
|
|
76
|
+
"class-variance-authority": "0.7.1",
|
|
77
|
+
"clsx": "2.1.1",
|
|
78
|
+
"jsdom": "30.0.1",
|
|
79
|
+
"lucide-static": "1.44.0",
|
|
80
|
+
"radix-ui": "1.6.7",
|
|
81
|
+
"react": "19.3.0",
|
|
82
|
+
"react-dom": "19.3.0",
|
|
83
|
+
"shadcn": "4.21.0",
|
|
84
|
+
"tailwind-merge": "3.6.0",
|
|
85
|
+
"tailwindcss": "4.3.3",
|
|
86
|
+
"tw-animate-css": "1.4.0",
|
|
87
|
+
"typescript": "6.0.3",
|
|
88
|
+
"vite": "8.3.0",
|
|
89
|
+
"vitest": "5.0.0"
|
|
54
90
|
},
|
|
55
91
|
"dependencies": {
|
|
56
|
-
"diff": "
|
|
57
|
-
"marked": "
|
|
58
|
-
"parse5": "
|
|
92
|
+
"diff": "9.0.0",
|
|
93
|
+
"marked": "18.0.12",
|
|
94
|
+
"parse5": "8.0.1"
|
|
59
95
|
}
|
|
60
96
|
}
|
package/src/anchor-text.js
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
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
|
-
|
|
8
|
-
export const CONTEXT_PAD = 32;
|
|
9
|
-
|
|
10
|
-
/** Capture `quote` plus surrounding context so it can be re-found after edits. */
|
|
11
|
-
export function buildContext(text, start, end, pad = CONTEXT_PAD) {
|
|
12
|
-
return {
|
|
13
|
-
prefix: text.slice(Math.max(0, start - pad), start),
|
|
14
|
-
quote: text.slice(start, end),
|
|
15
|
-
suffix: text.slice(end, Math.min(text.length, end + pad)),
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function commonSuffixLength(a, b) {
|
|
20
|
-
let n = 0;
|
|
21
|
-
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n += 1;
|
|
22
|
-
return n;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function commonPrefixLength(a, b) {
|
|
26
|
-
let n = 0;
|
|
27
|
-
while (n < a.length && n < b.length && a[n] === b[n]) n += 1;
|
|
28
|
-
return n;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function occurrences(text, quote) {
|
|
32
|
-
const found = [];
|
|
33
|
-
let at = text.indexOf(quote);
|
|
34
|
-
while (at !== -1) {
|
|
35
|
-
found.push(at);
|
|
36
|
-
at = text.indexOf(quote, at + 1);
|
|
37
|
-
}
|
|
38
|
-
return found;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function bestHit(text, hits, quote, prefix, suffix) {
|
|
42
|
-
let best = hits[0];
|
|
43
|
-
let bestScore = -1;
|
|
44
|
-
for (const at of hits) {
|
|
45
|
-
const before = text.slice(Math.max(0, at - prefix.length), at);
|
|
46
|
-
const after = text.slice(at + quote.length, at + quote.length + suffix.length);
|
|
47
|
-
const score = commonSuffixLength(prefix, before) + commonPrefixLength(suffix, after);
|
|
48
|
-
if (score > bestScore) {
|
|
49
|
-
bestScore = score;
|
|
50
|
-
best = at;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return { at: best, score: bestScore };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const collapse = (s) => String(s || "").replace(/\s+/g, " ");
|
|
57
|
-
|
|
58
|
-
/** Collapse whitespace runs, keeping a map from each kept char to its original index. */
|
|
59
|
-
function collapseWithMap(text) {
|
|
60
|
-
let flat = "";
|
|
61
|
-
const map = [];
|
|
62
|
-
let pendingWs = -1;
|
|
63
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
64
|
-
if (/\s/.test(text[i])) {
|
|
65
|
-
if (pendingWs === -1) pendingWs = i;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (pendingWs !== -1 && flat) {
|
|
69
|
-
flat += " ";
|
|
70
|
-
map.push(pendingWs);
|
|
71
|
-
}
|
|
72
|
-
pendingWs = -1;
|
|
73
|
-
flat += text[i];
|
|
74
|
-
map.push(i);
|
|
75
|
-
}
|
|
76
|
-
return { flat, map };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Locate `ctx.quote` in `text`, using prefix/suffix to disambiguate repeats.
|
|
81
|
-
* Returns `{ start, end, exact }` or null when the quote is gone entirely.
|
|
82
|
-
*/
|
|
83
|
-
export function findQuote(text, ctx) {
|
|
84
|
-
const quote = ctx && ctx.quote;
|
|
85
|
-
if (!quote) return null;
|
|
86
|
-
|
|
87
|
-
const hits = occurrences(text, quote);
|
|
88
|
-
if (hits.length === 1) {
|
|
89
|
-
return { start: hits[0], end: hits[0] + quote.length, exact: true };
|
|
90
|
-
}
|
|
91
|
-
if (hits.length > 1) {
|
|
92
|
-
const { at, score } = bestHit(text, hits, quote, ctx.prefix || "", ctx.suffix || "");
|
|
93
|
-
return { start: at, end: at + quote.length, exact: score > 0 };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Reformatting (a prettier run, an agent rewrite) reflows whitespace without
|
|
97
|
-
// changing any words. Match again on whitespace-collapsed text and map the
|
|
98
|
-
// hit back to real offsets, so those comments survive instead of orphaning.
|
|
99
|
-
const { flat, map } = collapseWithMap(text);
|
|
100
|
-
const flatQuote = collapse(quote).trim();
|
|
101
|
-
if (!flatQuote || !map.length) return null;
|
|
102
|
-
const flatHits = occurrences(flat, flatQuote);
|
|
103
|
-
if (!flatHits.length) return null;
|
|
104
|
-
const { at } = bestHit(flat, flatHits, flatQuote, collapse(ctx.prefix || ""), collapse(ctx.suffix || ""));
|
|
105
|
-
return { start: map[at], end: map[at + flatQuote.length - 1] + 1, exact: false };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Collapse runs of whitespace for display in a comment card. */
|
|
109
|
-
export function tidy(text, limit = 0) {
|
|
110
|
-
const flat = String(text == null ? "" : text)
|
|
111
|
-
.replace(/\s+/g, " ")
|
|
112
|
-
.trim();
|
|
113
|
-
if (!limit || flat.length <= limit) return flat;
|
|
114
|
-
return `${flat.slice(0, limit - 1).trimEnd()}…`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function graphemes(text) {
|
|
118
|
-
if (typeof Intl?.Segmenter === "function") {
|
|
119
|
-
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
120
|
-
return [...segmenter.segment(text)].map(({ segment }) => segment);
|
|
121
|
-
}
|
|
122
|
-
// Array.from is deterministic and, unlike string slicing, never splits a
|
|
123
|
-
// surrogate pair. Older engines may split a multi-code-point grapheme.
|
|
124
|
-
return Array.from(text);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function nearbyHeadBoundary(parts, ideal) {
|
|
128
|
-
for (let index = ideal; index >= Math.max(1, ideal - 8); index -= 1) {
|
|
129
|
-
if (/^\s$/u.test(parts[index - 1])) return index - 1;
|
|
130
|
-
}
|
|
131
|
-
return ideal;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function nearbyTailBoundary(parts, ideal) {
|
|
135
|
-
for (let index = ideal; index < Math.min(parts.length - 1, ideal + 8); index += 1) {
|
|
136
|
-
if (/^\s$/u.test(parts[index])) return index + 1;
|
|
137
|
-
}
|
|
138
|
-
return ideal;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Collapse whitespace and preserve both ends of a long quote. `limit` counts
|
|
143
|
-
* grapheme clusters, including the ellipsis.
|
|
144
|
-
*/
|
|
145
|
-
export function tidyMiddle(text, limit = 0) {
|
|
146
|
-
const flat = String(text == null ? "" : text)
|
|
147
|
-
.replace(/\s+/g, " ")
|
|
148
|
-
.trim();
|
|
149
|
-
if (!limit) return flat;
|
|
150
|
-
const parts = graphemes(flat);
|
|
151
|
-
if (parts.length <= limit) return flat;
|
|
152
|
-
if (limit <= 1) return "…";
|
|
153
|
-
|
|
154
|
-
const available = limit - 1;
|
|
155
|
-
const idealHead = Math.ceil(available * 0.6);
|
|
156
|
-
const idealTailStart = parts.length - (available - idealHead);
|
|
157
|
-
const headEnd = nearbyHeadBoundary(parts, idealHead);
|
|
158
|
-
const tailStart = nearbyTailBoundary(parts, idealTailStart);
|
|
159
|
-
return `${parts.slice(0, headEnd).join("").trimEnd()}…${parts.slice(tailStart).join("").trimStart()}`;
|
|
160
|
-
}
|
package/src/atomic-write.js
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
|
|
4
|
-
const RETRY_DELAYS_MS = [10, 20, 40, 80, 160];
|
|
5
|
-
const TRANSIENT_RENAME_ERRORS = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
6
|
-
const waitState = new Int32Array(new SharedArrayBuffer(4));
|
|
7
|
-
|
|
8
|
-
// Keep Store transactions synchronous; rename backoff blocks for at most 310ms.
|
|
9
|
-
const sleepSync = (ms) => Atomics.wait(waitState, 0, 0, ms);
|
|
10
|
-
|
|
11
|
-
export function createAtomicWriter({ fileSystem = fs, sleep = sleepSync, report = console.error } = {}) {
|
|
12
|
-
return function atomicWrite(file, data) {
|
|
13
|
-
const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.doc-review.tmp`;
|
|
14
|
-
let fd;
|
|
15
|
-
let created = false;
|
|
16
|
-
try {
|
|
17
|
-
fd = fileSystem.openSync(tmp, "wx");
|
|
18
|
-
created = true;
|
|
19
|
-
fileSystem.writeFileSync(fd, data);
|
|
20
|
-
fileSystem.closeSync(fd);
|
|
21
|
-
fd = undefined;
|
|
22
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
23
|
-
try {
|
|
24
|
-
fileSystem.renameSync(tmp, file);
|
|
25
|
-
return;
|
|
26
|
-
} catch (err) {
|
|
27
|
-
if (!TRANSIENT_RENAME_ERRORS.has(err.code) || attempt === RETRY_DELAYS_MS.length) throw err;
|
|
28
|
-
sleep(RETRY_DELAYS_MS[attempt]);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
} catch (err) {
|
|
32
|
-
if (fd !== undefined) {
|
|
33
|
-
try {
|
|
34
|
-
fileSystem.closeSync(fd);
|
|
35
|
-
} catch (cleanupError) {
|
|
36
|
-
report(`Could not close atomic-write temporary file ${tmp}: ${cleanupError.message}`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
if (created) {
|
|
40
|
-
try {
|
|
41
|
-
fileSystem.unlinkSync(tmp);
|
|
42
|
-
} catch (cleanupError) {
|
|
43
|
-
if (cleanupError.code !== "ENOENT") {
|
|
44
|
-
report(`Could not remove atomic-write temporary file ${tmp}: ${cleanupError.message}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
throw err;
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export const atomicWrite = createAtomicWriter();
|