@erdemtuna/doc-review 0.9.0 → 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.
- package/README.md +52 -227
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -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/comparison-view.js +380 -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/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -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 +210 -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 +249 -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/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 +300 -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/view-identity.js +112 -0
- package/package.json +24 -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/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}/SKILL.md +0 -0
- /package/{src → lib}/chrome.css +0 -0
- /package/{src → lib}/chrome.html +0 -0
- /package/{src → lib}/document-trust.js +0 -0
package/lib/editing.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure editing helpers, shared by the in-page SDK and Node tests.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is deliberately DOM-free: the SDK feeds in strings and
|
|
5
|
+
* computed-style snapshots, and gets back decisions it can apply.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The list command for a marker typed at the start of a line, or null.
|
|
9
|
+
* `lead` is everything between the start of the block and the caret, so a
|
|
10
|
+
* marker typed mid-sentence never converts.
|
|
11
|
+
*/
|
|
12
|
+
export function listCommandFor(lead) {
|
|
13
|
+
const marker = String(lead || "").replace(/\u00a0/g, " ").trim();
|
|
14
|
+
if (/^[-*]$/.test(marker))
|
|
15
|
+
return "insertUnorderedList";
|
|
16
|
+
if (/^\d{1,3}[.)]$/.test(marker))
|
|
17
|
+
return "insertOrderedList";
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Style properties a fresh list needs when the page's CSS reset hides it.
|
|
22
|
+
* Tailwind preflight and similar resets set `list-style: none` and zero the
|
|
23
|
+
* indent, so a just-created list looks like nothing happened. Returns only
|
|
24
|
+
* the properties that are actually broken, so styled pages stay untouched.
|
|
25
|
+
*/
|
|
26
|
+
export function listStyleFixup(tagName, computed) {
|
|
27
|
+
const patch = {};
|
|
28
|
+
if (computed.listStyleType === "none") {
|
|
29
|
+
patch.listStyleType = /^ol$/i.test(tagName) ? "decimal" : "disc";
|
|
30
|
+
}
|
|
31
|
+
const inset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.marginLeft) || 0);
|
|
32
|
+
if (inset < 16)
|
|
33
|
+
patch.paddingLeft = "1.5em";
|
|
34
|
+
return patch;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Style a fresh link needs when the page can't distinguish it from prose.
|
|
38
|
+
* Reset stylesheets (`a { color: inherit; text-decoration: inherit }`) make a
|
|
39
|
+
* just-created link invisible; underline it only when neither color nor
|
|
40
|
+
* decoration sets it apart.
|
|
41
|
+
*/
|
|
42
|
+
export function linkStyleFixup(anchorComputed, parentComputed) {
|
|
43
|
+
const decorated = String(anchorComputed.textDecorationLine || "").includes("underline");
|
|
44
|
+
const recolored = !!parentComputed && anchorComputed.color !== parentComputed.color;
|
|
45
|
+
return decorated || recolored ? {} : { textDecoration: "underline" };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A typed link, made openable and safe. Bare domains get https://, in-page
|
|
49
|
+
* and relative references pass through, and anything with an executable or
|
|
50
|
+
* unknown scheme (javascript:, data:, …) is rejected outright.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeHref(raw) {
|
|
53
|
+
const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
54
|
+
if (!href)
|
|
55
|
+
return "";
|
|
56
|
+
let candidate = href;
|
|
57
|
+
if (!/^(https?:|mailto:|tel:|#|\/|\.)/i.test(href)) {
|
|
58
|
+
const head = href.split(/[/?#]/)[0];
|
|
59
|
+
if (/^([\w-]+\.)+[\w-]+(:\d+)?$/.test(head) || /^localhost(:\d+)?$/i.test(head)) {
|
|
60
|
+
// A bare domain — "example.com", "localhost:3000/wiki" — gets a scheme.
|
|
61
|
+
candidate = `https://${href}`;
|
|
62
|
+
}
|
|
63
|
+
else if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const parsed = new URL(candidate, "https://relative.invalid");
|
|
69
|
+
if (!/^(https?:|mailto:|tel:)$/i.test(parsed.protocol))
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Classify an authored navigation target. Relative references stay under
|
|
79
|
+
* controlled source-relative navigation; only explicitly safe external
|
|
80
|
+
* schemes leave the review.
|
|
81
|
+
*/
|
|
82
|
+
export function classifyHref(raw) {
|
|
83
|
+
const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
84
|
+
if (!href)
|
|
85
|
+
return "invalid";
|
|
86
|
+
if (href.startsWith("#"))
|
|
87
|
+
return "hash";
|
|
88
|
+
if (/^\/\//.test(href) || /^https?:/i.test(href) || /^(?:mailto|tel):/i.test(href))
|
|
89
|
+
return "external";
|
|
90
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href))
|
|
91
|
+
return "invalid";
|
|
92
|
+
return "navigate";
|
|
93
|
+
}
|
|
94
|
+
export function externalHref(raw, baseHref = "https://relative.invalid/") {
|
|
95
|
+
const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
96
|
+
if (classifyHref(href) !== "external")
|
|
97
|
+
return "";
|
|
98
|
+
if (/^(?:mailto|tel):/i.test(href))
|
|
99
|
+
return href;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = new URL(href, baseHref);
|
|
102
|
+
return /^https?:$/i.test(parsed.protocol) ? parsed.href : "";
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** UI policy always describes the visible frame, not a newer on-disk source. */
|
|
2
|
+
export function executionPresentation(page, rendered, { loading = false, pendingReload = false, error = null } = {}) {
|
|
3
|
+
const eligible = page?.kind === "file" && !page.markdown;
|
|
4
|
+
const policy = rendered?.savePolicy;
|
|
5
|
+
return {
|
|
6
|
+
eligible,
|
|
7
|
+
preference: page?.executionPreference || "auto",
|
|
8
|
+
editDescription: policy === "writable" ? "Edits save directly to this file"
|
|
9
|
+
: policy === "feedback-only" ? "Edits are feedback for the agent to apply"
|
|
10
|
+
: "Waiting for the page's editing policy",
|
|
11
|
+
status: error || (pendingReload ? "Showing the previous page while reload is pending."
|
|
12
|
+
: loading ? "Loading page…" : ""),
|
|
13
|
+
detail: [
|
|
14
|
+
rendered?.executionNotice,
|
|
15
|
+
rendered?.executionMode === "static" && policy === "feedback-only" && eligible
|
|
16
|
+
? "Scripts are disabled for recovery. Edits still go to the agent; the source is not overwritten." : null,
|
|
17
|
+
].filter(Boolean).join(" "),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function createExecutionControls({ api, sessionId, elements, changed, failed }) {
|
|
21
|
+
let busy = false;
|
|
22
|
+
let currentPage = null;
|
|
23
|
+
async function recover(preference) {
|
|
24
|
+
if (busy || !currentPage)
|
|
25
|
+
return;
|
|
26
|
+
const key = currentPage.key;
|
|
27
|
+
busy = true;
|
|
28
|
+
if (elements.menu) {
|
|
29
|
+
elements.menu.open = false;
|
|
30
|
+
elements.menu.querySelector("summary")?.focus({ preventScroll: true });
|
|
31
|
+
}
|
|
32
|
+
elements.static.disabled = elements.auto.disabled = true;
|
|
33
|
+
try {
|
|
34
|
+
const result = await api(`/api/session/${sessionId}/execution`, {
|
|
35
|
+
method: "POST", body: JSON.stringify({ key, preference }),
|
|
36
|
+
});
|
|
37
|
+
if (currentPage?.key === key)
|
|
38
|
+
changed(result.page);
|
|
39
|
+
// The server's reload event owns draft-safe frame replacement.
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
failed(error);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
busy = false;
|
|
46
|
+
elements.static.disabled = elements.auto.disabled = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
elements.static.addEventListener("click", () => void recover("static"));
|
|
50
|
+
elements.auto.addEventListener("click", () => void recover("auto"));
|
|
51
|
+
return {
|
|
52
|
+
render(page, rendered, options) {
|
|
53
|
+
currentPage = page;
|
|
54
|
+
const view = executionPresentation(page, rendered, options);
|
|
55
|
+
elements.static.hidden = !view.eligible || view.preference === "static";
|
|
56
|
+
elements.auto.hidden = !view.eligible || view.preference !== "static";
|
|
57
|
+
if (elements.menu)
|
|
58
|
+
elements.menu.hidden = !view.eligible && !view.detail;
|
|
59
|
+
elements.status.textContent = view.status;
|
|
60
|
+
elements.status.hidden = !view.status;
|
|
61
|
+
if (elements.details) {
|
|
62
|
+
elements.details.textContent = view.detail;
|
|
63
|
+
elements.details.hidden = !view.detail;
|
|
64
|
+
}
|
|
65
|
+
if (elements.editDescription)
|
|
66
|
+
elements.editDescription.textContent = view.editDescription;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { record } from "./chrome-api.js";
|
|
2
|
+
import { deliverFeedback } from "./history-coordinator.js";
|
|
3
|
+
export function createFeedbackController(options) {
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
let state = { phase: "idle" };
|
|
6
|
+
let disposed = false;
|
|
7
|
+
let inFlight = false;
|
|
8
|
+
let sent = false;
|
|
9
|
+
const busy = () => inFlight;
|
|
10
|
+
const publish = () => { if (!disposed)
|
|
11
|
+
for (const listener of listeners)
|
|
12
|
+
listener(); };
|
|
13
|
+
const current = (identity) => !disposed && !!identity.renderId &&
|
|
14
|
+
identity.key === options.current().key && identity.generation === options.current().generation &&
|
|
15
|
+
identity.renderId === options.current().renderId;
|
|
16
|
+
return {
|
|
17
|
+
get state() { return state; },
|
|
18
|
+
get sending() { return busy(); },
|
|
19
|
+
get sent() { return sent; },
|
|
20
|
+
subscribe(listener) {
|
|
21
|
+
listeners.add(listener);
|
|
22
|
+
return () => { listeners.delete(listener); };
|
|
23
|
+
},
|
|
24
|
+
clearSent() { sent = false; if (!busy())
|
|
25
|
+
state = { phase: "idle" }; },
|
|
26
|
+
async send() {
|
|
27
|
+
if (busy() || disposed)
|
|
28
|
+
return;
|
|
29
|
+
const identity = options.current();
|
|
30
|
+
const sentNote = options.note();
|
|
31
|
+
inFlight = true;
|
|
32
|
+
state = { phase: "saving" };
|
|
33
|
+
options.pauseCapture();
|
|
34
|
+
publish();
|
|
35
|
+
let delivering = false;
|
|
36
|
+
let delivered = false;
|
|
37
|
+
try {
|
|
38
|
+
const outcome = await deliverFeedback({
|
|
39
|
+
save: () => options.save.barrier(),
|
|
40
|
+
capture: () => options.capture(),
|
|
41
|
+
deliver: async (snapshot) => {
|
|
42
|
+
await options.save.settled();
|
|
43
|
+
await options.save.settleEdits(identity.key);
|
|
44
|
+
if (!current(identity))
|
|
45
|
+
throw new Error("The page changed before sending. Retry on the current page.");
|
|
46
|
+
if (options.save.state.conflict || options.save.state.status === "failed" ||
|
|
47
|
+
(options.policy() === "writable" && !options.save.state.dynamic && options.save.state.dirty)) {
|
|
48
|
+
throw new Error("Your page edits have not finished saving.");
|
|
49
|
+
}
|
|
50
|
+
delivering = true;
|
|
51
|
+
state = { phase: "delivering" };
|
|
52
|
+
publish();
|
|
53
|
+
const result = record(await options.request(`/api/page/${identity.key}/send`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
sessionId: options.sessionId, note: sentNote.trim(),
|
|
57
|
+
history: {
|
|
58
|
+
renderId: identity.renderId, generation: identity.generation,
|
|
59
|
+
semantic: snapshot?.semantic || null,
|
|
60
|
+
semanticCapturedAt: snapshot?.semanticCapturedAt, view: snapshot?.view,
|
|
61
|
+
expectedSourceHash: snapshot?.sourceHash || options.save.state.baseHash || options.sourceHash(),
|
|
62
|
+
allowUnavailable: true,
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
}));
|
|
66
|
+
if (result.ok === false) {
|
|
67
|
+
throw new Error(typeof result.error === "string" ? result.error : "Feedback delivery was not confirmed");
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
committed() {
|
|
71
|
+
delivered = true;
|
|
72
|
+
sent = true;
|
|
73
|
+
state = { phase: "delivered" };
|
|
74
|
+
if (!disposed)
|
|
75
|
+
options.clearNote(sentNote);
|
|
76
|
+
},
|
|
77
|
+
refresh: () => disposed ? Promise.resolve(false) : options.refresh(),
|
|
78
|
+
});
|
|
79
|
+
if (disposed)
|
|
80
|
+
return;
|
|
81
|
+
if (outcome.refreshFailure)
|
|
82
|
+
options.warning("Feedback sent. History could not be refreshed; your feedback does not need to be sent again.");
|
|
83
|
+
else if (outcome.captureFailure)
|
|
84
|
+
options.announce("Feedback sent. Content comparison may be incomplete.");
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (disposed)
|
|
88
|
+
return;
|
|
89
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
90
|
+
const message = delivered ? "Feedback sent. History is temporarily unavailable; do not send it again."
|
|
91
|
+
: !delivering ? `Feedback not sent: ${detail} Your feedback and drafts are still here.`
|
|
92
|
+
: `Feedback delivery was not confirmed: ${detail} No automatic retry was made. Check the agent before sending again.`;
|
|
93
|
+
state = delivered ? { phase: "delivered" }
|
|
94
|
+
: { phase: delivering ? "uncertain" : "failed", message };
|
|
95
|
+
options.failed(message);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
inFlight = false;
|
|
99
|
+
publish();
|
|
100
|
+
if (!disposed)
|
|
101
|
+
options.resumeCapture();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
dispose() { disposed = true; listeners.clear(); },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
let channel = null;
|
|
2
|
+
export function initializeChannel(capability, generation, pageKey) {
|
|
3
|
+
if (channel)
|
|
4
|
+
throw new Error("Doc Review frame channel is already initialized.");
|
|
5
|
+
channel = {
|
|
6
|
+
capability: String(capability),
|
|
7
|
+
generation: Number(generation),
|
|
8
|
+
pageKey: String(pageKey),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function initializeChannelFromDocument() {
|
|
12
|
+
const script = document.querySelector("script[data-eh-sdk][data-eh-bootstrap]");
|
|
13
|
+
if (!script)
|
|
14
|
+
throw new Error("Doc Review frame bootstrap is missing.");
|
|
15
|
+
const capability = script.nonce;
|
|
16
|
+
const generation = Number(script.dataset.generation);
|
|
17
|
+
const pageKey = String(script.dataset.pageKey || "");
|
|
18
|
+
script.remove();
|
|
19
|
+
if (!capability || !Number.isSafeInteger(generation) || !pageKey) {
|
|
20
|
+
throw new Error("Doc Review frame bootstrap is invalid.");
|
|
21
|
+
}
|
|
22
|
+
initializeChannel(capability, generation, pageKey);
|
|
23
|
+
}
|
|
24
|
+
export function frameMessage(type, payload = {}) {
|
|
25
|
+
if (!channel)
|
|
26
|
+
throw new Error("Doc Review frame channel is not initialized.");
|
|
27
|
+
return {
|
|
28
|
+
...payload,
|
|
29
|
+
type,
|
|
30
|
+
capability: channel.capability,
|
|
31
|
+
generation: channel.generation,
|
|
32
|
+
pageKey: channel.pageKey,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function matchesFrameMessage(message) {
|
|
36
|
+
return !!(channel &&
|
|
37
|
+
message && (typeof message === "object" || typeof message === "function") &&
|
|
38
|
+
"capability" in message && "generation" in message && "pageKey" in message &&
|
|
39
|
+
message.capability === channel.capability &&
|
|
40
|
+
message.generation === channel.generation &&
|
|
41
|
+
message.pageKey === channel.pageKey);
|
|
42
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { record } from "./chrome-api.js";
|
|
2
|
+
export function createFrameController({ sessionId, host, request, suspended, transitioning = () => { }, failed, now = Date.now, setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
|
|
3
|
+
const state = {
|
|
4
|
+
key: null, generation: 0, renderId: null, capability: null, sourceHash: null,
|
|
5
|
+
execution: null, policy: null, phase: { kind: "loading" }, readyAt: 0,
|
|
6
|
+
configurationGeneration: null, pendingReload: false, reloading: false,
|
|
7
|
+
};
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
const timers = new Set();
|
|
10
|
+
const flushes = new Map();
|
|
11
|
+
let readinessTimer = null;
|
|
12
|
+
let replacementTimer = null;
|
|
13
|
+
let readinessRetries = 0;
|
|
14
|
+
let initialLoad = false;
|
|
15
|
+
let readyGeneration = null;
|
|
16
|
+
let sourceRevision = 0;
|
|
17
|
+
let flushSequence = 0;
|
|
18
|
+
let configuration = null;
|
|
19
|
+
let expectedConfiguration = null;
|
|
20
|
+
const alive = () => state.phase.kind !== "disposed";
|
|
21
|
+
const loading = () => state.phase.kind !== "ready";
|
|
22
|
+
const publish = () => { if (alive())
|
|
23
|
+
for (const listener of listeners)
|
|
24
|
+
listener(); };
|
|
25
|
+
const identity = () => ({
|
|
26
|
+
key: state.key, renderId: state.renderId, generation: state.generation, loading: loading(),
|
|
27
|
+
});
|
|
28
|
+
const matches = (value) => alive() && value.key === state.key &&
|
|
29
|
+
value.generation === state.generation && value.renderId === state.renderId;
|
|
30
|
+
const cancelTimer = (timer) => {
|
|
31
|
+
if (timer !== null) {
|
|
32
|
+
clearTimer(timer);
|
|
33
|
+
timers.delete(timer);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function later(callback, delay) {
|
|
37
|
+
const timer = setTimer(() => {
|
|
38
|
+
timers.delete(timer);
|
|
39
|
+
if (alive())
|
|
40
|
+
callback();
|
|
41
|
+
}, delay);
|
|
42
|
+
timers.add(timer);
|
|
43
|
+
return timer;
|
|
44
|
+
}
|
|
45
|
+
function send(message) {
|
|
46
|
+
if (!alive() || loading() || !state.capability)
|
|
47
|
+
return;
|
|
48
|
+
host.send({
|
|
49
|
+
...message, capability: state.capability, generation: state.generation, pageKey: state.key,
|
|
50
|
+
}, state.policy?.targetOrigin || "*");
|
|
51
|
+
}
|
|
52
|
+
function suspend() {
|
|
53
|
+
if (!alive())
|
|
54
|
+
return;
|
|
55
|
+
suspended();
|
|
56
|
+
for (const flush of flushes.values())
|
|
57
|
+
flush.settle(false);
|
|
58
|
+
configuration?.settle(false);
|
|
59
|
+
expectedConfiguration = null;
|
|
60
|
+
for (const timer of timers)
|
|
61
|
+
clearTimer(timer);
|
|
62
|
+
timers.clear();
|
|
63
|
+
readinessTimer = replacementTimer = null;
|
|
64
|
+
state.capability = null;
|
|
65
|
+
state.renderId = null;
|
|
66
|
+
state.execution = null;
|
|
67
|
+
state.phase = { kind: "suspended" };
|
|
68
|
+
readyGeneration = null;
|
|
69
|
+
state.configurationGeneration = null;
|
|
70
|
+
host.suspend();
|
|
71
|
+
}
|
|
72
|
+
function begin(key = state.key) {
|
|
73
|
+
if (!alive())
|
|
74
|
+
throw new Error("Review ended");
|
|
75
|
+
transitioning();
|
|
76
|
+
suspend();
|
|
77
|
+
state.key = key;
|
|
78
|
+
state.generation++;
|
|
79
|
+
state.sourceHash = null;
|
|
80
|
+
sourceRevision++;
|
|
81
|
+
state.readyAt = 0;
|
|
82
|
+
state.phase = { kind: "loading" };
|
|
83
|
+
return state.generation;
|
|
84
|
+
}
|
|
85
|
+
function finishReplacement() {
|
|
86
|
+
cancelTimer(replacementTimer);
|
|
87
|
+
replacementTimer = null;
|
|
88
|
+
host.finishReplacement();
|
|
89
|
+
publish();
|
|
90
|
+
}
|
|
91
|
+
function fail(message) {
|
|
92
|
+
if (!alive())
|
|
93
|
+
return;
|
|
94
|
+
suspend();
|
|
95
|
+
state.phase = { kind: "failed", message };
|
|
96
|
+
state.pendingReload = true;
|
|
97
|
+
finishReplacement();
|
|
98
|
+
failed(message);
|
|
99
|
+
}
|
|
100
|
+
async function register(key, generation) {
|
|
101
|
+
if (!alive() || !key)
|
|
102
|
+
throw new Error("No active review page is selected.");
|
|
103
|
+
const value = record(await request(`/api/session/${sessionId}/render`, {
|
|
104
|
+
method: "POST", body: JSON.stringify({ key, generation }),
|
|
105
|
+
}));
|
|
106
|
+
if (!alive() || state.key !== key || state.generation !== generation)
|
|
107
|
+
return false;
|
|
108
|
+
if (typeof value.renderId !== "string" || typeof value.capability !== "string" ||
|
|
109
|
+
typeof value.path !== "string" || !value.path.startsWith("/artifact/") ||
|
|
110
|
+
(value.sourceHash != null && typeof value.sourceHash !== "string")) {
|
|
111
|
+
throw new Error("The review server returned an invalid frame registration.");
|
|
112
|
+
}
|
|
113
|
+
state.renderId = value.renderId;
|
|
114
|
+
state.capability = value.capability;
|
|
115
|
+
state.sourceHash = typeof value.sourceHash === "string" ? value.sourceHash : null;
|
|
116
|
+
sourceRevision++;
|
|
117
|
+
state.phase = { kind: "loading" };
|
|
118
|
+
initialLoad = true;
|
|
119
|
+
host.navigate(value.path, state.reloading);
|
|
120
|
+
publish();
|
|
121
|
+
readinessTimer = later(() => {
|
|
122
|
+
if (state.key !== key || state.generation !== generation || !loading())
|
|
123
|
+
return;
|
|
124
|
+
if (readinessRetries < 1) {
|
|
125
|
+
readinessRetries++;
|
|
126
|
+
rotate();
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
fail("The reviewed page did not finish loading. Fix the source or local server, then reload.");
|
|
130
|
+
}, 5000);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
function rotate() {
|
|
134
|
+
if (!state.key || !alive())
|
|
135
|
+
return;
|
|
136
|
+
const key = state.key;
|
|
137
|
+
const generation = begin();
|
|
138
|
+
void register(key, generation).catch((error) => {
|
|
139
|
+
if (state.key === key && state.generation === generation) {
|
|
140
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const stopLoad = host.onLoad(() => {
|
|
145
|
+
if (!state.renderId || !state.capability)
|
|
146
|
+
return;
|
|
147
|
+
if (initialLoad) {
|
|
148
|
+
initialLoad = false;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
rotate();
|
|
152
|
+
});
|
|
153
|
+
async function ready() {
|
|
154
|
+
if (!alive() || !state.renderId || !state.capability || readyGeneration === state.generation)
|
|
155
|
+
return null;
|
|
156
|
+
cancelTimer(readinessTimer);
|
|
157
|
+
readinessTimer = null;
|
|
158
|
+
readinessRetries = 0;
|
|
159
|
+
readyGeneration = state.generation;
|
|
160
|
+
state.readyAt = now();
|
|
161
|
+
state.phase = { kind: "confirming" };
|
|
162
|
+
const started = identity();
|
|
163
|
+
const revision = sourceRevision;
|
|
164
|
+
try {
|
|
165
|
+
const value = record(await request(`/api/session/${sessionId}/render/${state.renderId}/ready`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify({ capability: state.capability, generation: state.generation, pageKey: state.key }),
|
|
168
|
+
}));
|
|
169
|
+
if (!matches(started))
|
|
170
|
+
return null;
|
|
171
|
+
if ((value.executionMode !== "static" && value.executionMode !== "interactive" && value.executionMode !== "application") ||
|
|
172
|
+
(value.savePolicy !== "writable" && value.savePolicy !== "feedback-only") ||
|
|
173
|
+
(value.sourceHash != null && typeof value.sourceHash !== "string")) {
|
|
174
|
+
throw new Error("The review could not confirm this document's execution policy. Reload before editing.");
|
|
175
|
+
}
|
|
176
|
+
if (revision === sourceRevision) {
|
|
177
|
+
state.sourceHash = typeof value.sourceHash === "string" ? value.sourceHash : null;
|
|
178
|
+
}
|
|
179
|
+
state.execution = {
|
|
180
|
+
executionMode: value.executionMode, savePolicy: value.savePolicy,
|
|
181
|
+
feedbackOnly: value.savePolicy === "feedback-only",
|
|
182
|
+
executionNotice: typeof value.executionNotice === "string" ? value.executionNotice : null,
|
|
183
|
+
};
|
|
184
|
+
state.phase = { kind: "ready" };
|
|
185
|
+
host.ready(state.execution);
|
|
186
|
+
publish();
|
|
187
|
+
return state.execution;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (matches(started)) {
|
|
191
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
192
|
+
const message = "The review could not confirm this document's execution policy. Reload before editing.";
|
|
193
|
+
fail(detail.startsWith(message) ? detail : `${message} ${detail}`);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function configurationTimeout() {
|
|
199
|
+
if (!host.previous)
|
|
200
|
+
return;
|
|
201
|
+
cancelTimer(replacementTimer);
|
|
202
|
+
const started = identity();
|
|
203
|
+
replacementTimer = later(() => {
|
|
204
|
+
if (matches(started) && host.previous) {
|
|
205
|
+
fail("The page did not confirm its review settings. Reload before continuing.");
|
|
206
|
+
}
|
|
207
|
+
}, 5000);
|
|
208
|
+
}
|
|
209
|
+
function configure(mode, savePolicy, wait = true) {
|
|
210
|
+
if (loading() || !state.capability || !state.execution)
|
|
211
|
+
return Promise.resolve(false);
|
|
212
|
+
configuration?.settle(false);
|
|
213
|
+
expectedConfiguration = { mode, savePolicy };
|
|
214
|
+
configurationTimeout();
|
|
215
|
+
if (!wait) {
|
|
216
|
+
send({ type: "eh:configureReview", mode, savePolicy });
|
|
217
|
+
return Promise.resolve(true);
|
|
218
|
+
}
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
const timer = later(() => settle(false), 3000);
|
|
221
|
+
const settle = (applied) => {
|
|
222
|
+
if (configuration?.settle !== settle)
|
|
223
|
+
return;
|
|
224
|
+
configuration = null;
|
|
225
|
+
cancelTimer(timer);
|
|
226
|
+
resolve(applied);
|
|
227
|
+
};
|
|
228
|
+
configuration = { mode, savePolicy, settle };
|
|
229
|
+
send({ type: "eh:configureReview", mode, savePolicy });
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function flush(strict = false) {
|
|
233
|
+
if (loading() || !state.capability) {
|
|
234
|
+
return strict ? Promise.reject(new Error("The page is not ready. Wait for it to load, then retry.")) : Promise.resolve();
|
|
235
|
+
}
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
const requestId = `flush-${++flushSequence}`;
|
|
238
|
+
const timer = later(() => settle(false), strict ? 6000 : 400);
|
|
239
|
+
const settle = (confirmed) => {
|
|
240
|
+
if (!flushes.delete(requestId))
|
|
241
|
+
return;
|
|
242
|
+
cancelTimer(timer);
|
|
243
|
+
if (!confirmed && strict)
|
|
244
|
+
reject(new Error("The page did not finish saving. Wait for it to load, then retry."));
|
|
245
|
+
else
|
|
246
|
+
resolve();
|
|
247
|
+
};
|
|
248
|
+
flushes.set(requestId, { strict, settle });
|
|
249
|
+
send({ type: "eh:flush", requestId });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
get state() { return state; },
|
|
254
|
+
get loading() { return loading(); },
|
|
255
|
+
identity, matches, begin, register, suspend, ready, configure, flush, send, fail,
|
|
256
|
+
subscribe(listener) {
|
|
257
|
+
listeners.add(listener);
|
|
258
|
+
return () => { listeners.delete(listener); };
|
|
259
|
+
},
|
|
260
|
+
seedGeneration(generation) {
|
|
261
|
+
if (Number.isSafeInteger(generation) && state.renderId === null)
|
|
262
|
+
state.generation = generation;
|
|
263
|
+
},
|
|
264
|
+
resetRetries() { readinessRetries = 0; },
|
|
265
|
+
setPolicy(policy) { state.policy = policy; host.setPolicy(policy); },
|
|
266
|
+
setSourceHash(hash) { state.sourceHash = hash; sourceRevision++; },
|
|
267
|
+
startReload() { state.reloading = true; state.pendingReload = false; },
|
|
268
|
+
restoredScroll() { state.reloading = false; },
|
|
269
|
+
holdReload() { state.pendingReload = true; publish(); },
|
|
270
|
+
accepts(event) {
|
|
271
|
+
if (!alive() || !host.acceptsSource(event.source) || !state.policy ||
|
|
272
|
+
event.origin !== state.policy.incomingOrigin || !state.capability)
|
|
273
|
+
return false;
|
|
274
|
+
const value = event.data;
|
|
275
|
+
if (!value || typeof value !== "object" || !("capability" in value) ||
|
|
276
|
+
!("generation" in value) || !("pageKey" in value) || !("type" in value))
|
|
277
|
+
return false;
|
|
278
|
+
return value.capability === state.capability && value.generation === state.generation &&
|
|
279
|
+
value.pageKey === state.key && typeof value.type === "string" &&
|
|
280
|
+
(!loading() || value.type === "eh:ready");
|
|
281
|
+
},
|
|
282
|
+
configured(mode, savePolicy) {
|
|
283
|
+
if (!expectedConfiguration || expectedConfiguration.mode !== mode ||
|
|
284
|
+
expectedConfiguration.savePolicy !== savePolicy || loading())
|
|
285
|
+
return false;
|
|
286
|
+
state.configurationGeneration = state.generation;
|
|
287
|
+
if (host.previous)
|
|
288
|
+
host.afterPaint(() => { if (!loading())
|
|
289
|
+
finishReplacement(); });
|
|
290
|
+
if (configuration && configuration.mode === mode && configuration.savePolicy === savePolicy) {
|
|
291
|
+
configuration.settle(true);
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
},
|
|
295
|
+
flushed(requestId) {
|
|
296
|
+
if (typeof requestId === "string")
|
|
297
|
+
flushes.get(requestId)?.settle(true);
|
|
298
|
+
else
|
|
299
|
+
for (const request of flushes.values())
|
|
300
|
+
if (!request.strict)
|
|
301
|
+
request.settle(true);
|
|
302
|
+
},
|
|
303
|
+
dispose() {
|
|
304
|
+
if (!alive())
|
|
305
|
+
return;
|
|
306
|
+
suspend();
|
|
307
|
+
state.phase = { kind: "disposed" };
|
|
308
|
+
stopLoad();
|
|
309
|
+
host.dispose();
|
|
310
|
+
listeners.clear();
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|