@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.
Files changed (103) hide show
  1. package/README.md +58 -222
  2. package/{src → lib}/SKILL.md +5 -2
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/changes-controller.js +184 -0
  6. package/lib/chrome-api.js +78 -0
  7. package/lib/chrome-client.js +1700 -0
  8. package/lib/chrome-session.js +73 -0
  9. package/lib/chrome.html +53 -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/comments-controller.js +109 -0
  15. package/lib/contextual-controller.js +33 -0
  16. package/lib/contracts/feedback.js +1 -0
  17. package/lib/contracts/frame.js +7 -0
  18. package/lib/contracts/history.js +1 -0
  19. package/lib/contracts/index.js +2 -0
  20. package/lib/contracts/page.js +23 -0
  21. package/lib/controller-store.js +96 -0
  22. package/lib/document-execution.js +115 -0
  23. package/lib/edit-limits.js +24 -0
  24. package/lib/editing.js +107 -0
  25. package/lib/execution-client.js +19 -0
  26. package/lib/feedback-controller.js +113 -0
  27. package/lib/feedback-panel-controller.js +161 -0
  28. package/lib/frame-channel.js +42 -0
  29. package/lib/frame-controller.js +313 -0
  30. package/lib/frame-host.js +136 -0
  31. package/lib/frame-policy.js +50 -0
  32. package/lib/history-client.js +112 -0
  33. package/lib/history-coordinator.js +217 -0
  34. package/lib/history-policy.js +43 -0
  35. package/lib/history-server.js +464 -0
  36. package/lib/html-transform.js +52 -0
  37. package/lib/icons.js +325 -0
  38. package/{src → lib}/markdown.js +25 -26
  39. package/lib/paths.js +83 -0
  40. package/lib/poll-transport.js +220 -0
  41. package/lib/positioning.js +97 -0
  42. package/lib/recovery-controller.js +123 -0
  43. package/lib/review-controller.js +93 -0
  44. package/lib/review-mode.js +66 -0
  45. package/lib/revision-diff.js +482 -0
  46. package/lib/revision-schema.js +235 -0
  47. package/lib/revision-store.js +190 -0
  48. package/lib/save-controller.js +303 -0
  49. package/lib/sdk.js +2629 -0
  50. package/lib/semantic-snapshot.js +268 -0
  51. package/lib/serialize.js +32 -0
  52. package/lib/server-entry.js +26 -0
  53. package/lib/server-lock.js +109 -0
  54. package/lib/server.js +1501 -0
  55. package/lib/setup-guidance.js +119 -0
  56. package/{src → lib}/setup.js +44 -55
  57. package/lib/state.js +733 -0
  58. package/lib/toolbar-controller.js +32 -0
  59. package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
  60. package/lib/ui/chrome.css +4881 -0
  61. package/lib/ui/chrome.js +52 -0
  62. package/lib/view-identity.js +112 -0
  63. package/package.json +50 -14
  64. package/src/anchor-text.js +0 -160
  65. package/src/atomic-write.js +0 -53
  66. package/src/chrome-client.js +0 -2747
  67. package/src/chrome-session.js +0 -85
  68. package/src/chrome.css +0 -652
  69. package/src/chrome.html +0 -196
  70. package/src/cli.js +0 -271
  71. package/src/click-target.js +0 -26
  72. package/src/comment-anchor.js +0 -22
  73. package/src/comment-target.js +0 -164
  74. package/src/comparison-view.js +0 -337
  75. package/src/document-execution.js +0 -103
  76. package/src/edit-limits.js +0 -23
  77. package/src/editing.js +0 -99
  78. package/src/execution-client.js +0 -63
  79. package/src/frame-channel.js +0 -44
  80. package/src/frame-policy.js +0 -54
  81. package/src/history-client.js +0 -104
  82. package/src/history-coordinator.js +0 -186
  83. package/src/history-policy.js +0 -43
  84. package/src/history-server.js +0 -463
  85. package/src/html-transform.js +0 -62
  86. package/src/icons.js +0 -251
  87. package/src/paths.js +0 -91
  88. package/src/poll-transport.js +0 -222
  89. package/src/positioning.js +0 -107
  90. package/src/review-mode.js +0 -59
  91. package/src/revision-diff.js +0 -440
  92. package/src/revision-schema.js +0 -219
  93. package/src/revision-store.js +0 -177
  94. package/src/sdk.js +0 -2607
  95. package/src/semantic-snapshot.js +0 -230
  96. package/src/serialize.js +0 -32
  97. package/src/server-entry.js +0 -26
  98. package/src/server-lock.js +0 -101
  99. package/src/server.js +0 -1466
  100. package/src/setup-guidance.js +0 -128
  101. package/src/state.js +0 -732
  102. package/src/view-identity.js +0 -99
  103. /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,2 @@
1
+ export { isFrameIdentity } from "./frame.js";
2
+ export { isExecutionPolicy, isPageResponse, isRenderExecution } from "./page.js";
@@ -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
+ }