@erdemtuna/doc-review 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +52 -137
  2. package/{src → lib}/SKILL.md +28 -4
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/chrome-api.js +78 -0
  6. package/lib/chrome-client.js +2255 -0
  7. package/lib/chrome-session.js +76 -0
  8. package/{src → lib}/chrome.css +253 -8
  9. package/lib/chrome.html +196 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comparison-view.js +380 -0
  15. package/lib/contracts/feedback.js +1 -0
  16. package/lib/contracts/frame.js +7 -0
  17. package/lib/contracts/history.js +1 -0
  18. package/lib/contracts/index.js +2 -0
  19. package/lib/contracts/page.js +23 -0
  20. package/lib/document-execution.js +115 -0
  21. package/lib/document-trust.js +2 -0
  22. package/lib/edit-limits.js +24 -0
  23. package/lib/editing.js +107 -0
  24. package/lib/execution-client.js +69 -0
  25. package/lib/feedback-controller.js +106 -0
  26. package/lib/frame-channel.js +42 -0
  27. package/lib/frame-controller.js +313 -0
  28. package/lib/frame-host.js +136 -0
  29. package/lib/frame-policy.js +50 -0
  30. package/lib/history-client.js +112 -0
  31. package/lib/history-coordinator.js +210 -0
  32. package/lib/history-policy.js +43 -0
  33. package/lib/history-server.js +464 -0
  34. package/lib/html-transform.js +52 -0
  35. package/lib/icons.js +249 -0
  36. package/{src → lib}/markdown.js +25 -26
  37. package/lib/paths.js +83 -0
  38. package/lib/poll-transport.js +220 -0
  39. package/lib/positioning.js +97 -0
  40. package/lib/review-controller.js +93 -0
  41. package/lib/review-mode.js +66 -0
  42. package/lib/revision-diff.js +482 -0
  43. package/lib/revision-schema.js +235 -0
  44. package/lib/revision-store.js +190 -0
  45. package/lib/save-controller.js +300 -0
  46. package/lib/sdk.js +2629 -0
  47. package/lib/semantic-snapshot.js +268 -0
  48. package/lib/serialize.js +32 -0
  49. package/lib/server-entry.js +26 -0
  50. package/lib/server-lock.js +109 -0
  51. package/lib/server.js +1501 -0
  52. package/lib/setup-guidance.js +119 -0
  53. package/{src → lib}/setup.js +44 -55
  54. package/lib/state.js +733 -0
  55. package/lib/view-identity.js +112 -0
  56. package/package.json +24 -12
  57. package/src/anchor-text.js +0 -160
  58. package/src/atomic-write.js +0 -53
  59. package/src/chrome-client.js +0 -2006
  60. package/src/chrome-session.js +0 -85
  61. package/src/chrome.html +0 -129
  62. package/src/cli.js +0 -267
  63. package/src/click-target.js +0 -26
  64. package/src/comment-anchor.js +0 -22
  65. package/src/comment-target.js +0 -77
  66. package/src/edit-limits.js +0 -23
  67. package/src/editing.js +0 -99
  68. package/src/frame-channel.js +0 -44
  69. package/src/frame-policy.js +0 -16
  70. package/src/html-transform.js +0 -62
  71. package/src/icons.js +0 -251
  72. package/src/paths.js +0 -91
  73. package/src/poll-transport.js +0 -222
  74. package/src/positioning.js +0 -107
  75. package/src/review-mode.js +0 -59
  76. package/src/sdk.js +0 -2243
  77. package/src/serialize.js +0 -32
  78. package/src/server-entry.js +0 -26
  79. package/src/server-lock.js +0 -101
  80. package/src/server.js +0 -1298
  81. package/src/setup-guidance.js +0 -128
  82. package/src/state.js +0 -507
@@ -0,0 +1,464 @@
1
+ import fs from "node:fs";
2
+ import crypto from "node:crypto";
3
+ import { performance } from "node:perf_hooks";
4
+ import { stripSdk } from "./html-transform.js";
5
+ import { isMarkdown } from "./markdown.js";
6
+ import { compareSemanticSnapshots, compareSources, DIFF_VERSION } from "./revision-diff.js";
7
+ import { REVISION_LIMITS } from "./revision-schema.js";
8
+ import { normalizeView, compareCapturedViews } from "./view-identity.js";
9
+ const SOURCE_LIMIT = REVISION_LIMITS.sourceBytes;
10
+ const hash = (value) => crypto.createHash("sha1").update(value).digest("hex");
11
+ export class HistoryRequestError extends Error {
12
+ constructor(message, { status = 400, code = "history_invalid_request", targets = [] } = {}) {
13
+ super(message);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.targets = targets;
17
+ }
18
+ }
19
+ export function historyErrorStatus(error) {
20
+ if (Number.isInteger(error.status) && error.status >= 400 && error.status < 600)
21
+ return error.status;
22
+ return {
23
+ INVALID_REVISION: 400, SNAPSHOT_TOO_LARGE: 413, CAPTURE_CONFLICT: 409, CAPTURE_FINALIZED: 409,
24
+ }[error.code] ?? 500;
25
+ }
26
+ function sourceFor(page) {
27
+ if (!page) {
28
+ throw new HistoryRequestError("The reviewed target is no longer available.", {
29
+ status: 409, code: "history_target_unavailable",
30
+ });
31
+ }
32
+ if (page.kind === "url")
33
+ return null;
34
+ let stat;
35
+ let content;
36
+ try {
37
+ stat = fs.statSync(page.file);
38
+ if (stat.size <= SOURCE_LIMIT)
39
+ content = stripSdk(fs.readFileSync(page.file, "utf8"));
40
+ }
41
+ catch (error) {
42
+ if (!["ENOENT", "EACCES", "EPERM"].includes(error.code))
43
+ throw error;
44
+ throw new HistoryRequestError("The reviewed source is missing or cannot be read.", {
45
+ status: 409, code: "history_source_unavailable",
46
+ });
47
+ }
48
+ if (stat.size > SOURCE_LIMIT) {
49
+ throw new HistoryRequestError(`The source exceeds the ${SOURCE_LIMIT / (1024 * 1024)} MiB history capture limit.`, {
50
+ status: 413, code: "history_source_too_large",
51
+ });
52
+ }
53
+ if (Buffer.byteLength(content) > SOURCE_LIMIT) {
54
+ throw new HistoryRequestError(`The source exceeds the ${SOURCE_LIMIT / (1024 * 1024)} MiB history capture limit.`, {
55
+ status: 413, code: "history_source_too_large",
56
+ });
57
+ }
58
+ return {
59
+ content,
60
+ hash: hash(content),
61
+ mediaType: isMarkdown(page.file) ? "text/markdown" : "text/html",
62
+ };
63
+ }
64
+ export function createHistoryController({ store, sessions, currentRender, readBody, json, emit, pageState, }) {
65
+ const cache = new Map();
66
+ let cachedBytes = 0;
67
+ const revision = (id) => id ? store.revisions.get(id) : null;
68
+ const saveRevision = (value) => {
69
+ const provenance = {
70
+ pageKey: value.documentId,
71
+ ...(value.provenance.sessionId ? { sessionId: value.provenance.sessionId } : {}),
72
+ ...(value.provenance.renderGeneration !== undefined ? { generation: value.provenance.renderGeneration } : {}),
73
+ ...(value.source?.hash ? { sourceHash: value.source.hash } : {}),
74
+ ...(value.provenance.captureId ? { captureId: value.provenance.captureId } : {}),
75
+ feedbackOnlyEdits: value.provenance.feedbackOnly === true,
76
+ trustedInteractive: value.provenance.trustedInteractive === true,
77
+ };
78
+ return store.revisions.put({
79
+ documentId: value.documentId,
80
+ reason: value.provenance.captureReason,
81
+ limitations: value.limitations || [],
82
+ ...(value.source ? {
83
+ source: {
84
+ text: value.source.content, mediaType: value.source.mediaType,
85
+ capturedAt: value.capturedAt, provenance,
86
+ },
87
+ } : {}),
88
+ ...(value.semantic ? {
89
+ semantic: { snapshot: value.semantic, capturedAt: value.semanticCapturedAt ?? value.capturedAt, provenance, view: value.view },
90
+ } : {}),
91
+ });
92
+ };
93
+ const roundFor = (session, roundId) => store.getRound(session.entryKey, roundId);
94
+ function changed(entryKey, roundId) {
95
+ for (const session of sessions.values()) {
96
+ if (session.entryKey === entryKey)
97
+ emit(session, "history", { roundId });
98
+ }
99
+ }
100
+ function currentFrame(session, input, key) {
101
+ const render = typeof input.renderId === "string" ? currentRender(input.renderId) : null;
102
+ if (!render || render.sessionId !== session.id || render.pageKey !== key ||
103
+ render.generation !== input.generation || render.documentState !== "served" ||
104
+ session.activeKey !== key) {
105
+ throw new HistoryRequestError("The page changed or its current frame is unavailable. Recapture it.", {
106
+ status: 409, code: "history_frame_changed",
107
+ });
108
+ }
109
+ return render;
110
+ }
111
+ function checkedSource(page, render, expectedHash) {
112
+ const source = sourceFor(page);
113
+ if (source && (typeof expectedHash !== "string" ||
114
+ source.hash !== expectedHash ||
115
+ render.sourceHash !== expectedHash)) {
116
+ throw new HistoryRequestError("The source changed since this version was reviewed. Reload and recapture it.", {
117
+ status: 409, code: "history_source_changed",
118
+ });
119
+ }
120
+ return source;
121
+ }
122
+ function snapshot(session, key, input, reason) {
123
+ const page = store.page(key);
124
+ const render = currentFrame(session, input, key);
125
+ if (!input.semantic || typeof input.semantic !== "object" || Array.isArray(input.semantic)) {
126
+ throw new HistoryRequestError("The current page did not provide a content snapshot.", {
127
+ status: 409, code: "history_semantic_unavailable",
128
+ });
129
+ }
130
+ if (input.semanticCapturedAt !== undefined && (!Number.isSafeInteger(input.semanticCapturedAt) ||
131
+ input.semanticCapturedAt < render.createdAt || input.semanticCapturedAt > Date.now() + 5000)) {
132
+ throw new HistoryRequestError("The content capture timestamp does not belong to this rendered version.");
133
+ }
134
+ const source = checkedSource(page, render, input.expectedSourceHash);
135
+ return {
136
+ documentId: key,
137
+ capturedAt: Date.now(),
138
+ semanticCapturedAt: input.semanticCapturedAt,
139
+ source,
140
+ semantic: input.semantic,
141
+ view: normalizeView(input.view),
142
+ provenance: {
143
+ sessionId: session.id,
144
+ renderGeneration: render.generation,
145
+ renderId: render.renderId,
146
+ pageKey: key,
147
+ sourceHashAtRender: render.sourceHash ?? null,
148
+ captureReason: reason,
149
+ feedbackOnly: page.kind === "url" || isMarkdown(page.file) || render.feedbackOnly === true,
150
+ trustedInteractive: render.trustedInteractive === true,
151
+ },
152
+ };
153
+ }
154
+ function prepareBaseline(session, pages, input) {
155
+ if (input === undefined)
156
+ return null;
157
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
158
+ throw new HistoryRequestError("Invalid history submission.");
159
+ }
160
+ if (input.allowUnavailable !== undefined && typeof input.allowUnavailable !== "boolean") {
161
+ throw new HistoryRequestError("Invalid missing-comparison choice.");
162
+ }
163
+ const keys = pages.length ? pages.map((page) => page.key) : [session.entryKey];
164
+ const pending = [];
165
+ const unavailable = [];
166
+ for (const key of keys) {
167
+ const page = store.page(key);
168
+ let value = null;
169
+ let reason = null;
170
+ try {
171
+ value = snapshot(session, key, input, "send");
172
+ }
173
+ catch (error) {
174
+ if (!(error instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(error.code))
175
+ throw error;
176
+ reason = error.message;
177
+ unavailable.push({ key, filename: pageState(key)?.filename || key, reason });
178
+ if (input.allowUnavailable) {
179
+ try {
180
+ const source = sourceFor(page);
181
+ if (source) {
182
+ value = {
183
+ documentId: key, capturedAt: Date.now(), source, semantic: null,
184
+ provenance: { captureReason: "send-source-only", pageKey: key },
185
+ limitations: ["semantic_before_unavailable"],
186
+ };
187
+ }
188
+ }
189
+ catch (sourceError) {
190
+ if (!(sourceError instanceof HistoryRequestError) && !["ENOENT", "EACCES", "EPERM"].includes(sourceError.code)) {
191
+ throw sourceError;
192
+ }
193
+ reason = sourceError.message;
194
+ }
195
+ }
196
+ }
197
+ pending.push({ key, value, reason });
198
+ }
199
+ if (unavailable.length && !input.allowUnavailable) {
200
+ throw new HistoryRequestError("Some pages need to be recaptured before their comparison can be saved.", {
201
+ status: 409, code: "history_baseline_unavailable", targets: unavailable,
202
+ });
203
+ }
204
+ const targets = pending.map(({ key, value, reason }) => ({
205
+ key,
206
+ ...(value ? { baselineRevisionId: saveRevision(value).revisionId } : {
207
+ baselineUnavailable: reason || "The before snapshot is unavailable.",
208
+ }),
209
+ ownerSessionId: session.id,
210
+ }));
211
+ return { targets, unavailable };
212
+ }
213
+ function presentRound(round) {
214
+ return {
215
+ ...round,
216
+ sentAt: round.sentAt ?? round.createdAt,
217
+ targets: round.targets.map((target) => ({
218
+ ...target,
219
+ captureStatus: target.capture?.status || (target.resultRevisionId ? "ready" : "pending"),
220
+ filename: pageState(target.key)?.filename || target.key,
221
+ kind: store.page(target.key)?.kind === "url" ? "url" : "file",
222
+ })),
223
+ };
224
+ }
225
+ function list(session) {
226
+ return {
227
+ activeKey: session.activeKey,
228
+ rounds: store.listHistory(session.entryKey).map(presentRound),
229
+ };
230
+ }
231
+ function captureSources(entryKey, roundId) {
232
+ const rounds = roundId ? [store.getRound(entryKey, roundId)] : store.listHistory(entryKey);
233
+ for (const round of rounds) {
234
+ if (round?.feedbackStatus !== "acknowledged" || round.completedAt)
235
+ continue;
236
+ for (const target of round.targets) {
237
+ if (target.sourceResultRevisionId || target.resultRevisionId ||
238
+ target.capture?.status === "unavailable" || store.page(target.key)?.kind === "url")
239
+ continue;
240
+ try {
241
+ const saved = saveRevision({
242
+ documentId: target.key, capturedAt: Date.now(),
243
+ source: sourceFor(store.page(target.key)),
244
+ provenance: { captureReason: "acknowledged-source-result" },
245
+ });
246
+ store.recordSourceResult(entryKey, round.roundId, target.key, { revisionId: saved.revisionId });
247
+ console.info("[doc-review]", { event: "history-source-captured", roundId: round.roundId, key: target.key });
248
+ }
249
+ catch (error) {
250
+ try {
251
+ store.recordSourceResult(entryKey, round.roundId, target.key, { unavailable: String(error.message).slice(0, 500) });
252
+ }
253
+ catch (persistenceError) {
254
+ console.error("[doc-review]", { event: "history-source-failure-not-persisted", code: persistenceError.code || "history_storage_failed" });
255
+ }
256
+ console.error("[doc-review]", { event: "history-source-unavailable", roundId: round.roundId, key: target.key, code: error.code || "history_storage_failed" });
257
+ }
258
+ }
259
+ }
260
+ }
261
+ function comparison(round, key, mode) {
262
+ if (!["content", "source"].includes(mode))
263
+ throw new HistoryRequestError("Unknown comparison mode.");
264
+ const target = round.targets.find((item) => item.key === key);
265
+ if (!target)
266
+ throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
267
+ const before = revision(target.baselineRevisionId);
268
+ const after = revision(mode === "source" ? target.sourceResultRevisionId || target.resultRevisionId : target.resultRevisionId);
269
+ const representation = mode === "content" ? "semantic" : "source";
270
+ if (!before?.[representation] || !after?.[representation]) {
271
+ return {
272
+ available: false,
273
+ mode,
274
+ reason: !before?.[representation]
275
+ ? target.baselineUnavailable || `The before ${mode} snapshot is unavailable.`
276
+ : (mode === "source" ? target.sourceResultUnavailable : target.resultUnavailable) || `The result ${mode} snapshot has not been captured.`,
277
+ changes: [],
278
+ limitations: [mode === "source" ? "source_unavailable" : "semantic_unavailable"],
279
+ };
280
+ }
281
+ const cacheKey = `${DIFF_VERSION}:${before.revisionId}:${after.revisionId}:${mode}`;
282
+ if (cache.has(cacheKey))
283
+ return cache.get(cacheKey).value;
284
+ const started = performance.now();
285
+ const differences = mode === "source"
286
+ ? compareSources(store.revisions.readSource(before.revisionId), store.revisions.readSource(after.revisionId))
287
+ : compareSemanticSnapshots(store.revisions.readSemantic(before.revisionId), store.revisions.readSemantic(after.revisionId));
288
+ const result = {
289
+ ...differences,
290
+ available: differences.status === "complete",
291
+ ...(differences.status !== "complete" ? {
292
+ reason: differences.status === "limited"
293
+ ? "This comparison exceeds its processing limits. Its contents have not been partially displayed."
294
+ : "The captured content cannot be compared.",
295
+ } : {}),
296
+ mode,
297
+ baselineRevisionId: before.revisionId,
298
+ resultRevisionId: after.revisionId,
299
+ beforeCapturedAt: before[representation].capturedAt,
300
+ afterCapturedAt: after[representation].capturedAt,
301
+ sourceHash: after.source?.provenance.sourceHash ?? null,
302
+ capturedSessionId: after.semantic?.provenance.sessionId ?? null,
303
+ capturedGeneration: after.semantic?.provenance.generation ?? null,
304
+ ...(mode === "content" ? { viewComparison: compareCapturedViews(before.semantic?.view, after.semantic?.view) } : {}),
305
+ };
306
+ const bytes = Buffer.byteLength(JSON.stringify(result));
307
+ while (cache.size && (cache.size >= 16 || cachedBytes + bytes > 8 * 1024 * 1024)) {
308
+ const oldest = cache.keys().next().value;
309
+ cachedBytes -= cache.get(oldest).bytes;
310
+ cache.delete(oldest);
311
+ }
312
+ if (bytes <= 8 * 1024 * 1024) {
313
+ cache.set(cacheKey, { value: result, bytes });
314
+ cachedBytes += bytes;
315
+ }
316
+ console.info("[doc-review]", {
317
+ event: "history-compared", roundId: round.roundId, key, mode,
318
+ durationMs: Math.round(performance.now() - started), changes: result.changes.length,
319
+ });
320
+ return result;
321
+ }
322
+ async function handle(req, res, url) {
323
+ const match = url.pathname.match(/^\/api\/session\/(\w+)\/history(?:\/([\w-]+))?(?:\/(compare|capture))?$/);
324
+ if (!match)
325
+ return false;
326
+ const session = sessions.get(match[1]);
327
+ if (!session) {
328
+ json(res, 404, { error: "unknown session" });
329
+ return true;
330
+ }
331
+ try {
332
+ if (!match[2] && req.method === "GET") {
333
+ json(res, 200, list(session));
334
+ return true;
335
+ }
336
+ let round = roundFor(session, match[2]);
337
+ if (!round)
338
+ throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
339
+ if (!match[3] && req.method === "GET") {
340
+ json(res, 200, { round: presentRound(round) });
341
+ return true;
342
+ }
343
+ if (match[3] === "compare" && req.method === "GET") {
344
+ json(res, 200, comparison(round, url.searchParams.get("key") || session.activeKey, url.searchParams.get("mode") || "content"));
345
+ return true;
346
+ }
347
+ if (match[3] === "capture" && req.method === "POST") {
348
+ const body = await readBody(req);
349
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
350
+ throw new HistoryRequestError("Invalid capture request.");
351
+ }
352
+ round = roundFor(session, match[2]);
353
+ if (!round)
354
+ throw new HistoryRequestError("Unknown round in this review history.", { status: 404 });
355
+ const key = typeof body.key === "string" ? body.key : session.activeKey;
356
+ const target = round.targets.find((item) => item.key === key);
357
+ if (!target)
358
+ throw new HistoryRequestError("This target does not belong to the selected round.", { status: 404 });
359
+ if (round.feedbackStatus !== "acknowledged") {
360
+ throw new HistoryRequestError("The round has not been acknowledged.", { status: 409 });
361
+ }
362
+ if (target.resultRevisionId) {
363
+ json(res, 200, { ok: true, alreadyCaptured: true, round: presentRound(round) });
364
+ return true;
365
+ }
366
+ if (body.manual !== undefined && typeof body.manual !== "boolean") {
367
+ throw new HistoryRequestError("Invalid manual capture choice.");
368
+ }
369
+ if (body.finalUnavailable !== undefined && body.finalUnavailable !== true) {
370
+ throw new HistoryRequestError("Invalid final capture choice.");
371
+ }
372
+ if (body.finalUnavailable === true) {
373
+ if (body.manual !== true)
374
+ throw new HistoryRequestError("Finishing without content capture must be an explicit choice.");
375
+ if (target.capture?.status === "unavailable") {
376
+ json(res, 200, { ok: true, alreadyFinalized: true, round: presentRound(round) });
377
+ return true;
378
+ }
379
+ captureSources(session.entryKey, round.roundId);
380
+ const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
381
+ ownerSessionId: session.id, generation: 0,
382
+ });
383
+ const updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
384
+ captureId: claimed.captureId, ownerSessionId: session.id, generation: 0,
385
+ reason: "Content capture was explicitly finished with the available snapshots.", final: true,
386
+ });
387
+ changed(session.entryKey, round.roundId);
388
+ json(res, 200, { ok: true, round: presentRound(updated.round) });
389
+ return true;
390
+ }
391
+ const baseline = revision(target.baselineRevisionId);
392
+ const owner = target.ownerSessionId || baseline?.semantic?.provenance.sessionId;
393
+ if (owner && owner !== session.id && body.manual !== true) {
394
+ throw new HistoryRequestError("The review window that sent this round owns automatic capture. Use Capture result to capture here.", {
395
+ status: 409, code: "history_capture_owner",
396
+ });
397
+ }
398
+ const render = currentFrame(session, body, key);
399
+ if (!target.sourceResultRevisionId)
400
+ captureSources(session.entryKey, round.roundId);
401
+ const claimed = store.claimCapture(session.entryKey, round.roundId, key, {
402
+ ownerSessionId: session.id, generation: render.generation,
403
+ });
404
+ const ownership = {
405
+ captureId: claimed.captureId, ownerSessionId: session.id, generation: render.generation,
406
+ };
407
+ let updated;
408
+ let saved;
409
+ try {
410
+ if (typeof body.error === "string" && body.error.trim()) {
411
+ if (body.error.length > 500)
412
+ throw new HistoryRequestError("Capture failure reason is too long.");
413
+ updated = store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
414
+ ...ownership, reason: body.error, final: false,
415
+ });
416
+ }
417
+ else {
418
+ const value = snapshot(session, key, body, body.manual === true ? "manual-result" : "automatic-result");
419
+ const viewComparison = compareCapturedViews(baseline?.semantic?.view, value.view);
420
+ if (viewComparison.status === "mismatch") {
421
+ throw new HistoryRequestError(viewComparison.message, { status: 409, code: "history_view_mismatch" });
422
+ }
423
+ value.provenance.captureId = claimed.captureId;
424
+ saved = saveRevision(value);
425
+ updated = store.recordCaptureResult(session.entryKey, round.roundId, key, {
426
+ ...ownership, revisionId: saved.revisionId,
427
+ });
428
+ }
429
+ }
430
+ catch (error) {
431
+ try {
432
+ store.markCaptureUnavailable(session.entryKey, round.roundId, key, {
433
+ ...ownership, reason: String(error.message).slice(0, 500), final: false,
434
+ });
435
+ changed(session.entryKey, round.roundId);
436
+ }
437
+ catch (persistenceError) {
438
+ console.error("[doc-review]", { event: "history-failure-not-persisted", code: persistenceError.code || "history_failed" });
439
+ }
440
+ throw error;
441
+ }
442
+ changed(session.entryKey, round.roundId);
443
+ console.info("[doc-review]", {
444
+ event: saved ? "history-captured" : "history-capture-unavailable",
445
+ roundId: round.roundId, key, ...(saved ? { revisionId: saved.revisionId } : {}),
446
+ });
447
+ json(res, 200, { ok: !!saved, round: presentRound(updated.round) });
448
+ return true;
449
+ }
450
+ json(res, 405, { error: "method not allowed" });
451
+ }
452
+ catch (error) {
453
+ const status = historyErrorStatus(error);
454
+ console.error("[doc-review]", { event: "history-failed", status, code: error.code || "history_failed" });
455
+ json(res, status, {
456
+ error: error.message,
457
+ code: error.code || "history_failed",
458
+ ...(error.targets?.length ? { targets: error.targets } : {}),
459
+ });
460
+ }
461
+ return true;
462
+ }
463
+ return { prepareBaseline, captureSources, list, handle, changed };
464
+ }
@@ -0,0 +1,52 @@
1
+ // Match only the tag itself: injection adds no whitespace, so stripping must
2
+ // not eat any either, or open→save would not round-trip byte-identically.
3
+ const SDK_TAG_RE = /<script[^>]*\bdata-eh-sdk\b[^>]*\bdata-eh-bootstrap\b[^>]*><\/script>/gi;
4
+ const SDK_BASE_RE = /<base[^>]*\bdata-eh-sdk\b[^>]*>/gi;
5
+ const SDK_ROUTE_RE = /<script[^>]*\bdata-eh-route\b[^>]*>[\s\S]*?<\/script>/gi;
6
+ const ASSET_TAG_RE = /<(script|link|img|source|video|audio|iframe|embed|object)\b[^>]*>/gi;
7
+ const ASSET_ATTR_RE = /(\s)(src|href|poster|data)=(['"])(.*?)\3/gi;
8
+ function absolutizeAssets(html, baseHref) {
9
+ return String(html).replace(ASSET_TAG_RE, (tag) => tag.replace(ASSET_ATTR_RE, (attribute, space, name, quote, value) => {
10
+ if (!value || /^(?:data|blob|javascript):/i.test(value) || value.startsWith("#"))
11
+ return attribute;
12
+ const absolute = new URL(value, baseHref).href;
13
+ return `${space}${name}=${quote}${absolute}${quote}`;
14
+ }));
15
+ }
16
+ /**
17
+ * Add the review bootstrap tags. Everything else about the artifact is left
18
+ * byte-identical, so the saved file renders the same standalone.
19
+ */
20
+ export function injectSdk(html, key, { src = "/sdk.js", baseHref = "", nonce = "", generation = 0, pageKey = key } = {}) {
21
+ const clean = stripSdk(html);
22
+ const escapedSrc = String(src).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
23
+ const escapedNonce = String(nonce).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
24
+ const escapedPageKey = String(pageKey).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
25
+ const nonceAttr = escapedNonce ? ` nonce="${escapedNonce}"` : "";
26
+ const tag = `<script data-eh-sdk data-eh-bootstrap type="module"${nonceAttr}` +
27
+ ` data-generation="${Number(generation)}" data-page-key="${escapedPageKey}" src="${escapedSrc}"></script>`;
28
+ let prepared = clean;
29
+ if (baseHref) {
30
+ const url = new URL(baseHref);
31
+ const route = JSON.stringify(`${url.pathname}${url.search}${url.hash}`).replace(/</g, "\\u003c");
32
+ const restoreRoute = `<script data-eh-route>history.replaceState(null,"",location.origin+${route})</script>`;
33
+ prepared = absolutizeAssets(prepared, baseHref);
34
+ prepared = /<head(?:\s[^>]*)?>/i.test(prepared)
35
+ ? prepared.replace(/<head(\s[^>]*)?>/i, (head) => `${head}${restoreRoute}`)
36
+ : `${restoreRoute}${prepared}`;
37
+ }
38
+ // Put the trusted module before authored markup can enter an unclosed
39
+ // script/style/textarea raw-text context. Removing this exact tag restores
40
+ // every authored byte.
41
+ const doctype = /^(\uFEFF?\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>)/i.exec(prepared);
42
+ const at = doctype ? doctype[0].length : 0;
43
+ return `${prepared.slice(0, at)}${tag}${prepared.slice(at)}`;
44
+ }
45
+ /** Remove any injected tag, so a file saved with one never keeps it. */
46
+ export function stripSdk(html) {
47
+ return String(html).replace(SDK_TAG_RE, "").replace(SDK_BASE_RE, "").replace(SDK_ROUTE_RE, "");
48
+ }
49
+ export function hasSdk(html) {
50
+ SDK_TAG_RE.lastIndex = 0;
51
+ return SDK_TAG_RE.test(String(html));
52
+ }