@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,109 @@
|
|
|
1
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
|
+
import { newestComments } from "./chrome-session.js";
|
|
3
|
+
function age(timestamp) {
|
|
4
|
+
const seconds = Math.max(0, Math.round((Date.now() - (timestamp ?? Date.now())) / 1000));
|
|
5
|
+
if (seconds < 45)
|
|
6
|
+
return "just now";
|
|
7
|
+
const minutes = Math.round(seconds / 60);
|
|
8
|
+
if (minutes < 60)
|
|
9
|
+
return `${minutes}m`;
|
|
10
|
+
const hours = Math.round(minutes / 60);
|
|
11
|
+
return hours < 24 ? `${hours}h` : `${Math.round(hours / 24)}d`;
|
|
12
|
+
}
|
|
13
|
+
export function createCommentsController(options) {
|
|
14
|
+
let disposed = false;
|
|
15
|
+
let sectionOpen = true;
|
|
16
|
+
const store = createControllerStore(() => {
|
|
17
|
+
const state = options.read();
|
|
18
|
+
return {
|
|
19
|
+
open: state.open && !state.comparing,
|
|
20
|
+
sectionOpen,
|
|
21
|
+
sectionLock: state.ui.edit ? "Save or cancel the comment edit to collapse."
|
|
22
|
+
: state.ui.confirmation ? "Finish or cancel the deletion to collapse." : "",
|
|
23
|
+
activeId: state.activeId,
|
|
24
|
+
disabled: state.ended,
|
|
25
|
+
loading: !state.hasPage && !state.error && !state.ended,
|
|
26
|
+
error: !state.hasPage ? state.error : null,
|
|
27
|
+
showEmpty: state.hasPage && state.comments.length === 0 && !state.composeOpen,
|
|
28
|
+
cards: newestComments(state.comments).map((comment) => ({
|
|
29
|
+
id: comment.id, quote: comment.quote, feedback: comment.feedback,
|
|
30
|
+
age: age(comment.updatedAt || comment.createdAt),
|
|
31
|
+
correction: !!comment.correction, edited: !!comment.updatedAt,
|
|
32
|
+
orphaned: state.orphans.has(comment.id), active: state.activeId === comment.id,
|
|
33
|
+
})),
|
|
34
|
+
others: state.others.map(({ key, filename, count }) => ({ key, filename, count })),
|
|
35
|
+
ui: state.ui,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
const available = () => !disposed && !options.read().ended && !options.read().comparing;
|
|
39
|
+
const has = (id) => available() && options.read().comments.some((comment) => comment.id === id);
|
|
40
|
+
const deleting = () => options.read().ui.confirmation?.status === "deleting";
|
|
41
|
+
const editable = (id) => has(id) && !deleting() && (!options.read().ui.edit || options.read().ui.edit?.commentId === id);
|
|
42
|
+
const ownsEdit = (id) => has(id) && options.read().ui.edit?.commentId === id;
|
|
43
|
+
const publish = () => {
|
|
44
|
+
if (disposed)
|
|
45
|
+
return;
|
|
46
|
+
const state = options.read();
|
|
47
|
+
if (state.ui.edit || state.ui.confirmation)
|
|
48
|
+
sectionOpen = true;
|
|
49
|
+
store.publish();
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
getSnapshot: store.getSnapshot, subscribe: store.subscribe, publish,
|
|
53
|
+
commands: {
|
|
54
|
+
setSectionOpen(open) {
|
|
55
|
+
const state = options.read();
|
|
56
|
+
if (!available() || (!open && (state.ui.edit || state.ui.confirmation)))
|
|
57
|
+
return;
|
|
58
|
+
sectionOpen = open;
|
|
59
|
+
publish();
|
|
60
|
+
},
|
|
61
|
+
close() { if (available())
|
|
62
|
+
options.close(); },
|
|
63
|
+
activate(id, scroll) { if (editable(id))
|
|
64
|
+
options.activate(id, scroll); },
|
|
65
|
+
edit(id, surface = "drawer") {
|
|
66
|
+
if (editable(id)) {
|
|
67
|
+
sectionOpen = true;
|
|
68
|
+
options.edit(id, surface);
|
|
69
|
+
publish();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
dismiss() { if (available())
|
|
73
|
+
options.dismiss?.(); },
|
|
74
|
+
updateEdit(id, value) {
|
|
75
|
+
if (!ownsEdit(id))
|
|
76
|
+
return;
|
|
77
|
+
const edit = options.read().ui.edit;
|
|
78
|
+
if (!edit || edit.status === "saving")
|
|
79
|
+
return;
|
|
80
|
+
Object.assign(edit, value, { validation: value.draft === edit.draft ? edit.validation : "" });
|
|
81
|
+
publish();
|
|
82
|
+
},
|
|
83
|
+
save(id) { if (ownsEdit(id) && !options.read().ui.edit?.composing)
|
|
84
|
+
return options.save(); },
|
|
85
|
+
cancelEdit(id) { if (ownsEdit(id))
|
|
86
|
+
options.cancelEdit(); },
|
|
87
|
+
confirm(id, surface = "drawer") {
|
|
88
|
+
if (has(id) && !options.read().ui.edit && !deleting()) {
|
|
89
|
+
sectionOpen = true;
|
|
90
|
+
options.confirm(id, surface);
|
|
91
|
+
publish();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
cancelDelete(id) {
|
|
95
|
+
if (has(id) && !deleting() && options.read().ui.confirmation?.commentId === id)
|
|
96
|
+
options.cancelDelete();
|
|
97
|
+
},
|
|
98
|
+
remove(id) {
|
|
99
|
+
if (has(id) && options.read().ui.confirmation?.commentId === id)
|
|
100
|
+
return options.remove(id);
|
|
101
|
+
},
|
|
102
|
+
navigate(key) {
|
|
103
|
+
if (available() && options.read().others.some((page) => page.key === key))
|
|
104
|
+
return options.navigate(key);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
dispose() { disposed = true; store.dispose(); },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
|
+
export function createComposerDraft() {
|
|
3
|
+
return { text: "", selectionStart: 0, selectionEnd: 0, composing: false, error: "", retry: false };
|
|
4
|
+
}
|
|
5
|
+
export function createContextualController(options) {
|
|
6
|
+
let disposed = false;
|
|
7
|
+
const store = createControllerStore(options.read);
|
|
8
|
+
const available = () => !disposed && options.read().open && !options.read().disabled;
|
|
9
|
+
return {
|
|
10
|
+
getSnapshot: store.getSnapshot, subscribe: store.subscribe, publish: store.publish,
|
|
11
|
+
commands: {
|
|
12
|
+
update(value) {
|
|
13
|
+
if (!available() || options.read().submitting)
|
|
14
|
+
return;
|
|
15
|
+
Object.assign(options.read().draft, value);
|
|
16
|
+
store.publish();
|
|
17
|
+
},
|
|
18
|
+
submit() {
|
|
19
|
+
if (available() && !options.read().draft.composing)
|
|
20
|
+
return options.submit();
|
|
21
|
+
},
|
|
22
|
+
cancel() { if (available() && !options.read().submitting)
|
|
23
|
+
return options.cancel(); },
|
|
24
|
+
reveal() { if (available())
|
|
25
|
+
options.reveal(); },
|
|
26
|
+
focus() { if (available())
|
|
27
|
+
options.focus(); },
|
|
28
|
+
measure() { if (!disposed)
|
|
29
|
+
options.measure(); },
|
|
30
|
+
},
|
|
31
|
+
dispose() { disposed = true; store.dispose(); },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Correlation alone does not validate a message type or its payload. */
|
|
2
|
+
export function isFrameIdentity(value) {
|
|
3
|
+
return typeof value === "object" && value !== null &&
|
|
4
|
+
"capability" in value && typeof value.capability === "string" &&
|
|
5
|
+
"generation" in value && typeof value.generation === "number" &&
|
|
6
|
+
"pageKey" in value && typeof value.pageKey === "string";
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function record(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
export function isExecutionPolicy(value) {
|
|
5
|
+
return record(value) &&
|
|
6
|
+
(value.executionMode === "static" || value.executionMode === "interactive" || value.executionMode === "application") &&
|
|
7
|
+
(value.savePolicy === "writable" || value.savePolicy === "feedback-only") &&
|
|
8
|
+
typeof value.feedbackOnly === "boolean";
|
|
9
|
+
}
|
|
10
|
+
export function isPageResponse(value) {
|
|
11
|
+
return record(value) && isExecutionPolicy(value) &&
|
|
12
|
+
typeof value.key === "string" && typeof value.file === "string" &&
|
|
13
|
+
typeof value.filename === "string" && typeof value.markdown === "boolean" &&
|
|
14
|
+
typeof value.canRevert === "boolean" && typeof value.pollCommand === "string" &&
|
|
15
|
+
typeof value.historySupported === "boolean" &&
|
|
16
|
+
(value.executionPreference === undefined || value.executionPreference === "auto" || value.executionPreference === "static") &&
|
|
17
|
+
((value.kind === "file" && value.url === undefined) || (value.kind === "url" && typeof value.url === "string")) &&
|
|
18
|
+
Array.isArray(value.comments) && Array.isArray(value.edits);
|
|
19
|
+
}
|
|
20
|
+
export function isRenderExecution(value) {
|
|
21
|
+
return record(value) && isExecutionPolicy(value) &&
|
|
22
|
+
(value.executionNotice === null || typeof value.executionNotice === "string");
|
|
23
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture small controller projections, not entire history/comparison payloads.
|
|
3
|
+
* Inputs are plain records/arrays/scalars; project mutable maps into entries first.
|
|
4
|
+
* Only publish reads the owner. Reads and unchanged snapshot branches are cached.
|
|
5
|
+
* Subscription cleanup does not dispose the owner. Disposal retains the final
|
|
6
|
+
* snapshot, stops publication, and rejects new subscriptions.
|
|
7
|
+
*/
|
|
8
|
+
export function createControllerStore(read) {
|
|
9
|
+
const owned = new WeakSet();
|
|
10
|
+
function capture(value, previous, ancestors = new Set()) {
|
|
11
|
+
if (value === null || typeof value !== "object") {
|
|
12
|
+
if (typeof value === "function")
|
|
13
|
+
throw new TypeError("Controller snapshots cannot contain functions");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (owned.has(value))
|
|
17
|
+
return value;
|
|
18
|
+
const array = Array.isArray(value);
|
|
19
|
+
const prototype = Object.getPrototypeOf(value);
|
|
20
|
+
if (!array && prototype !== Object.prototype && prototype !== null) {
|
|
21
|
+
throw new TypeError("Controller snapshots require plain records and arrays; project mutable collections first");
|
|
22
|
+
}
|
|
23
|
+
if (ancestors.has(value))
|
|
24
|
+
throw new TypeError("Controller snapshots cannot contain cycles");
|
|
25
|
+
ancestors.add(value);
|
|
26
|
+
try {
|
|
27
|
+
const keys = Reflect.ownKeys(value);
|
|
28
|
+
const prior = previous !== null && typeof previous === "object" &&
|
|
29
|
+
Array.isArray(previous) === array && Object.getPrototypeOf(previous) === prototype
|
|
30
|
+
? previous : undefined;
|
|
31
|
+
let unchanged = prior !== undefined && Reflect.ownKeys(prior).length === keys.length;
|
|
32
|
+
const next = array
|
|
33
|
+
? [] : Object.create(prototype);
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
36
|
+
if (!("value" in descriptor))
|
|
37
|
+
throw new TypeError("Controller snapshots cannot contain accessors");
|
|
38
|
+
const before = prior && Object.getOwnPropertyDescriptor(prior, key);
|
|
39
|
+
const child = capture(descriptor.value, before?.value, ancestors);
|
|
40
|
+
if (!before || !Object.is(child, before.value))
|
|
41
|
+
unchanged = false;
|
|
42
|
+
Object.defineProperty(next, key, {
|
|
43
|
+
value: child, enumerable: descriptor.enumerable, writable: true,
|
|
44
|
+
configurable: key !== "length" || !array,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (unchanged)
|
|
48
|
+
return prior;
|
|
49
|
+
Object.freeze(next);
|
|
50
|
+
owned.add(next);
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
ancestors.delete(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
let snapshot = capture(read(), undefined);
|
|
58
|
+
let disposed = false;
|
|
59
|
+
const subscriptions = new Set();
|
|
60
|
+
return {
|
|
61
|
+
getSnapshot: () => snapshot,
|
|
62
|
+
subscribe(listener) {
|
|
63
|
+
if (disposed)
|
|
64
|
+
throw new Error("Controller store is disposed");
|
|
65
|
+
const subscription = { listener };
|
|
66
|
+
subscriptions.add(subscription);
|
|
67
|
+
return () => { subscriptions.delete(subscription); };
|
|
68
|
+
},
|
|
69
|
+
publish() {
|
|
70
|
+
if (disposed)
|
|
71
|
+
return false;
|
|
72
|
+
const next = capture(read(), snapshot);
|
|
73
|
+
if (Object.is(next, snapshot))
|
|
74
|
+
return false;
|
|
75
|
+
snapshot = next;
|
|
76
|
+
const errors = [];
|
|
77
|
+
for (const subscription of [...subscriptions]) {
|
|
78
|
+
if (!subscriptions.has(subscription))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
subscription.listener();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
errors.push(error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (errors.length)
|
|
88
|
+
throw new AggregateError(errors, "Controller subscription failed");
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
dispose() {
|
|
92
|
+
disposed = true;
|
|
93
|
+
subscriptions.clear();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { parse, serialize } from "parse5";
|
|
2
|
+
export const INTERACTIVE_FILE_NOTICE = "Inline page interactions run automatically. Editing is feedback-only. External scripts, application imports, workers, and embedded applications are unsupported; use localhost for those applications. Only the currently visible view is captured.";
|
|
3
|
+
const attr = (node, name) => node.attrs?.find((item) => item.name === name)?.value;
|
|
4
|
+
const javascriptTypes = new Set([
|
|
5
|
+
"application/ecmascript", "application/javascript", "application/x-ecmascript", "application/x-javascript",
|
|
6
|
+
"text/ecmascript", "text/javascript", "text/javascript1.0", "text/javascript1.1",
|
|
7
|
+
"text/javascript1.2", "text/javascript1.3", "text/javascript1.4", "text/javascript1.5",
|
|
8
|
+
"text/jscript", "text/livescript", "text/x-ecmascript", "text/x-javascript",
|
|
9
|
+
]);
|
|
10
|
+
const urlAttributes = new Set(["href", "src", "action", "formaction", "data", "background"]);
|
|
11
|
+
function dependency(node) {
|
|
12
|
+
const tag = node.tagName;
|
|
13
|
+
const source = attr(node, "src") ?? attr(node, "href");
|
|
14
|
+
if (tag === "script" && source !== undefined)
|
|
15
|
+
return { kind: "external-script", source };
|
|
16
|
+
if (tag === "script" && ["importmap", "speculationrules"].includes((attr(node, "type") || "").trim().toLowerCase())) {
|
|
17
|
+
return { kind: "application-import-map" };
|
|
18
|
+
}
|
|
19
|
+
if (["iframe", "frame", "object", "embed"].includes(tag)) {
|
|
20
|
+
return { kind: "embedded-application", source: attr(node, "src") || attr(node, "data") };
|
|
21
|
+
}
|
|
22
|
+
if (tag === "link") {
|
|
23
|
+
const rel = (attr(node, "rel") || "").toLowerCase().split(/\s+/);
|
|
24
|
+
if (rel.includes("modulepreload") || (rel.includes("preload") && ["script", "worker"].includes((attr(node, "as") || "").toLowerCase()))) {
|
|
25
|
+
return { kind: "script-preload", source: attr(node, "href") };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Analyze authored source, never an injected SDK or a runtime DOM serialization. */
|
|
31
|
+
export function analyzeDocumentExecution(source) {
|
|
32
|
+
if (source instanceof Uint8Array)
|
|
33
|
+
source = Buffer.from(source).toString("utf8");
|
|
34
|
+
if (typeof source !== "string")
|
|
35
|
+
throw new TypeError("HTML source bytes or text are required.");
|
|
36
|
+
const document = parse(source);
|
|
37
|
+
let active = false;
|
|
38
|
+
let application = false;
|
|
39
|
+
const visit = (node, includeTemplates = false) => {
|
|
40
|
+
if (dependency(node))
|
|
41
|
+
active = application = true;
|
|
42
|
+
if (node.tagName === "script") {
|
|
43
|
+
const type = (attr(node, "type") ?? (attr(node, "language") ? `text/${attr(node, "language")}` : "")).trim().toLowerCase();
|
|
44
|
+
if (!type || type === "module" || javascriptTypes.has(type))
|
|
45
|
+
active = true;
|
|
46
|
+
}
|
|
47
|
+
for (const attribute of node.attrs || []) {
|
|
48
|
+
const name = attribute.name.toLowerCase();
|
|
49
|
+
if (/^on[a-z]/.test(name) || (urlAttributes.has(name) &&
|
|
50
|
+
/^javascript:/i.test(attribute.value.replace(/[\t\n\r]/g, "").replace(/^[\u0000-\u0020]+/, ""))))
|
|
51
|
+
active = true;
|
|
52
|
+
}
|
|
53
|
+
for (const child of node.childNodes || [])
|
|
54
|
+
visit(child, includeTemplates);
|
|
55
|
+
if (includeTemplates && node.content)
|
|
56
|
+
visit(node.content, true);
|
|
57
|
+
};
|
|
58
|
+
visit(document);
|
|
59
|
+
// Dormant template content cannot instantiate itself. Once authored execution
|
|
60
|
+
// exists, include its dependencies in the same bounded application policy.
|
|
61
|
+
if (active)
|
|
62
|
+
visit(document, true);
|
|
63
|
+
return {
|
|
64
|
+
executionMode: application ? "application" : active ? "interactive" : "static",
|
|
65
|
+
savePolicy: active ? "feedback-only" : "writable",
|
|
66
|
+
feedbackOnly: active,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function documentExecutionPolicy(page, source, preference = "auto") {
|
|
70
|
+
if (page.kind === "url")
|
|
71
|
+
return { executionMode: "application", savePolicy: "feedback-only", feedbackOnly: true };
|
|
72
|
+
if (page.markdown)
|
|
73
|
+
return { executionMode: "static", savePolicy: "feedback-only", feedbackOnly: true };
|
|
74
|
+
const policy = analyzeDocumentExecution(source);
|
|
75
|
+
return { ...policy, executionMode: preference === "static" ? "static" : policy.executionMode };
|
|
76
|
+
}
|
|
77
|
+
/** Keep inline interactions; the response CSP bounds dynamically created dependencies. */
|
|
78
|
+
export function transformInteractiveHtml(html) {
|
|
79
|
+
const document = parse(String(html));
|
|
80
|
+
const unsupportedDependencies = [];
|
|
81
|
+
let omitted = 0;
|
|
82
|
+
const visit = (parent) => {
|
|
83
|
+
parent.childNodes = (parent.childNodes || []).filter((node) => {
|
|
84
|
+
const unsupported = dependency(node);
|
|
85
|
+
if (unsupported) {
|
|
86
|
+
if (unsupportedDependencies.length < 100) {
|
|
87
|
+
unsupportedDependencies.push({ ...unsupported, source: String(unsupported.source || "").slice(0, 512) });
|
|
88
|
+
}
|
|
89
|
+
else
|
|
90
|
+
omitted += 1;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (node.tagName === "base")
|
|
94
|
+
return false;
|
|
95
|
+
if (node.tagName === "meta" && ["content-security-policy", "refresh"].includes((attr(node, "http-equiv") || "").trim().toLowerCase()))
|
|
96
|
+
return false;
|
|
97
|
+
if (node.childNodes)
|
|
98
|
+
visit(node);
|
|
99
|
+
if (node.content)
|
|
100
|
+
visit(node.content);
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
visit(document);
|
|
105
|
+
if (omitted)
|
|
106
|
+
unsupportedDependencies.push({ kind: "additional-dependencies", count: omitted });
|
|
107
|
+
const notice = INTERACTIVE_FILE_NOTICE + (unsupportedDependencies.length ? " Unsupported dependency elements were removed." : "");
|
|
108
|
+
const root = document.childNodes.find((node) => node.tagName === "html");
|
|
109
|
+
if (!root.childNodes.some((node) => node.tagName === "body")) {
|
|
110
|
+
const body = { nodeName: "body", tagName: "body", attrs: [], namespaceURI: "http://www.w3.org/1999/xhtml", childNodes: [], parentNode: root };
|
|
111
|
+
root.childNodes = root.childNodes.filter((node) => node.tagName !== "frameset");
|
|
112
|
+
root.childNodes.push(body);
|
|
113
|
+
}
|
|
114
|
+
return { html: serialize(document), unsupportedDependencies, notice };
|
|
115
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const MAX_EDIT_CHARACTERS = 200_000;
|
|
2
|
+
const EDIT_FIELDS = ["before", "after", "before_html", "after_html", "moved_after", "moved_before"];
|
|
3
|
+
export function limitEditFields(input) {
|
|
4
|
+
const fields = {};
|
|
5
|
+
const truncatedFields = [];
|
|
6
|
+
for (const field of EDIT_FIELDS) {
|
|
7
|
+
const value = input[field];
|
|
8
|
+
if (typeof value !== "string")
|
|
9
|
+
continue;
|
|
10
|
+
let end = 0;
|
|
11
|
+
let count = 0;
|
|
12
|
+
// Count Unicode code points, not UTF-16 halves of an astral character.
|
|
13
|
+
for (const character of value) {
|
|
14
|
+
if (count === MAX_EDIT_CHARACTERS)
|
|
15
|
+
break;
|
|
16
|
+
end += character.length;
|
|
17
|
+
count += 1;
|
|
18
|
+
}
|
|
19
|
+
fields[field] = value.slice(0, end);
|
|
20
|
+
if (end < value.length)
|
|
21
|
+
truncatedFields.push(field);
|
|
22
|
+
}
|
|
23
|
+
return { fields, truncated: truncatedFields.length > 0, truncated_fields: truncatedFields };
|
|
24
|
+
}
|
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,19 @@
|
|
|
1
|
+
/** UI policy 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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { record } from "./chrome-api.js";
|
|
2
|
+
import { createControllerStore } from "./controller-store.js";
|
|
3
|
+
import { deliverFeedback } from "./history-coordinator.js";
|
|
4
|
+
export function createFeedbackController(options) {
|
|
5
|
+
let state = { phase: "idle" };
|
|
6
|
+
let disposed = false;
|
|
7
|
+
let inFlight = false;
|
|
8
|
+
let sent = false;
|
|
9
|
+
const store = createControllerStore(() => ({ ...state, sending: inFlight, sent }));
|
|
10
|
+
const busy = () => inFlight;
|
|
11
|
+
const publish = () => { if (!disposed)
|
|
12
|
+
store.publish(); };
|
|
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
|
+
getSnapshot: store.getSnapshot,
|
|
21
|
+
subscribe: store.subscribe,
|
|
22
|
+
clearSent() { sent = false; if (!busy())
|
|
23
|
+
state = { phase: "idle" }; publish(); },
|
|
24
|
+
async send() {
|
|
25
|
+
if (busy() || disposed || sent)
|
|
26
|
+
return;
|
|
27
|
+
const identity = options.current();
|
|
28
|
+
const sentNote = options.note();
|
|
29
|
+
inFlight = true;
|
|
30
|
+
state = { phase: "saving" };
|
|
31
|
+
options.pauseCapture();
|
|
32
|
+
publish();
|
|
33
|
+
let delivering = false;
|
|
34
|
+
let delivered = false;
|
|
35
|
+
try {
|
|
36
|
+
const outcome = await deliverFeedback({
|
|
37
|
+
save: () => options.save.barrier(),
|
|
38
|
+
capture: () => options.capture(),
|
|
39
|
+
deliver: async (snapshot) => {
|
|
40
|
+
await options.save.settled();
|
|
41
|
+
await options.save.settleEdits(identity.key);
|
|
42
|
+
if (!current(identity))
|
|
43
|
+
throw new Error("The page changed before sending. Retry on the current page.");
|
|
44
|
+
if (options.save.state.conflict || options.save.state.status === "failed" ||
|
|
45
|
+
(options.policy() === "writable" && !options.save.state.dynamic && options.save.state.dirty)) {
|
|
46
|
+
throw new Error("Your page edits have not finished saving.");
|
|
47
|
+
}
|
|
48
|
+
delivering = true;
|
|
49
|
+
state = { phase: "delivering" };
|
|
50
|
+
publish();
|
|
51
|
+
const result = record(await options.request(`/api/page/${identity.key}/send`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
sessionId: options.sessionId, note: sentNote.trim(),
|
|
55
|
+
history: {
|
|
56
|
+
renderId: identity.renderId, generation: identity.generation,
|
|
57
|
+
semantic: snapshot?.semantic || null,
|
|
58
|
+
semanticCapturedAt: snapshot?.semanticCapturedAt, view: snapshot?.view,
|
|
59
|
+
expectedSourceHash: snapshot?.sourceHash || options.save.state.baseHash || options.sourceHash(),
|
|
60
|
+
allowUnavailable: true,
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
64
|
+
if (result.ok === false) {
|
|
65
|
+
throw new Error(typeof result.error === "string" ? result.error : "Feedback delivery was not confirmed");
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
committed() {
|
|
69
|
+
delivered = true;
|
|
70
|
+
sent = true;
|
|
71
|
+
state = { phase: "delivered" };
|
|
72
|
+
if (!disposed)
|
|
73
|
+
options.clearNote(sentNote);
|
|
74
|
+
publish();
|
|
75
|
+
},
|
|
76
|
+
refresh: () => disposed ? Promise.resolve(false) : options.refresh(),
|
|
77
|
+
});
|
|
78
|
+
if (disposed)
|
|
79
|
+
return;
|
|
80
|
+
if (outcome.refreshFailure) {
|
|
81
|
+
const notice = "Feedback sent. History could not be refreshed; your feedback does not need to be sent again.";
|
|
82
|
+
if (sent)
|
|
83
|
+
state = { phase: "delivered", notice };
|
|
84
|
+
options.warning(notice);
|
|
85
|
+
}
|
|
86
|
+
else if (outcome.captureFailure) {
|
|
87
|
+
const notice = "Feedback sent. Content comparison may be incomplete.";
|
|
88
|
+
if (sent)
|
|
89
|
+
state = { phase: "delivered", notice };
|
|
90
|
+
options.announce(notice);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (disposed)
|
|
95
|
+
return;
|
|
96
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
97
|
+
const message = delivered ? "Feedback sent. History is temporarily unavailable; do not send it again."
|
|
98
|
+
: !delivering ? `Feedback not sent: ${detail} Your feedback and drafts are still here.`
|
|
99
|
+
: `Feedback delivery was not confirmed: ${detail} No automatic retry was made. Check the agent before sending again.`;
|
|
100
|
+
state = delivered ? { phase: "delivered" }
|
|
101
|
+
: { phase: delivering ? "uncertain" : "failed", message };
|
|
102
|
+
options.failed(message);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
inFlight = false;
|
|
106
|
+
publish();
|
|
107
|
+
if (!disposed)
|
|
108
|
+
options.resumeCapture();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
dispose() { disposed = true; store.dispose(); },
|
|
112
|
+
};
|
|
113
|
+
}
|