@panphora/clayjs 1.13.1 → 1.14.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 (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69667 -41708
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +94 -93
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/server/checkpoint/index.js +218 -0
  114. package/src/server/rooms/index.js +59 -0
  115. package/src/server/rooms/live-data-ops.js +369 -0
  116. package/src/server/rooms/live-data-rooms.js +965 -0
  117. package/src/server/rooms/live-data-tags.js +49 -0
  118. package/src/server/rooms/live-page-history.js +41 -0
  119. package/src/server/rooms/live-provenance.js +142 -0
  120. package/src/server/rooms/page-admission.js +79 -0
  121. package/src/server/rooms/page-conversion.js +62 -0
  122. package/src/server/rooms/page-rebase.js +108 -0
  123. package/src/server/rooms/page-state.js +91 -0
  124. package/src/server/rooms/page-write-admission.js +63 -0
  125. package/src/server/rooms/page-write-guard.js +23 -0
  126. package/src/server/rooms/recent.js +18 -0
  127. package/src/server/rooms/spec-wire.js +17 -0
  128. package/src/standalone.js +22 -0
  129. package/src/sync/live-sync.js +13 -2
  130. package/src/sync/stream.js +62 -16
  131. package/src/ui/index.js +4 -0
  132. package/src/ui/toast.js +21 -5
  133. package/src/vendor/pierre/pierre.vendor.js +3 -0
  134. package/src/vendor/yjs.vendor.js +7 -0
  135. package/src/page/binding.js +0 -647
  136. package/src/page/replay.js +0 -578
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -34,7 +34,16 @@
34
34
  "./entries/*": "./entries/*",
35
35
  "./clay.standalone.js": "./dist/clay.standalone.js",
36
36
  "./tailwind": "./src/tailwind.js",
37
+ "./page/yjs": "./src/page/ybind/external.js",
37
38
  "./dist/*": "./dist/*",
39
+ "./server/rooms": {
40
+ "import": "./dist/server/rooms.mjs",
41
+ "require": "./dist/server/rooms.cjs"
42
+ },
43
+ "./server/checkpoint": {
44
+ "import": "./dist/server/checkpoint.mjs",
45
+ "require": "./dist/server/checkpoint.cjs"
46
+ },
38
47
  "./*": "./entries/*"
39
48
  },
40
49
  "files": [
@@ -63,6 +72,7 @@
63
72
  "deploy": "node build.js && wrangler deploy && git rev-parse HEAD > .deploy",
64
73
  "build:parse5": "node scripts/build-parse5-vendor.mjs",
65
74
  "build:prosemirror": "node scripts/build-prosemirror-vendor.mjs",
75
+ "build:yjs": "node scripts/build-yjs-vendor.mjs",
66
76
  "build:bevel": "node scripts/build-bevel-subset.mjs",
67
77
  "check:bevel": "node scripts/build-bevel-subset.mjs --check",
68
78
  "build:skins": "node scripts/build-vendor-skins.mjs",
@@ -74,10 +84,13 @@
74
84
  "@web/test-runner": "^0.20.2",
75
85
  "@web/test-runner-commands": "^0.9.0",
76
86
  "@web/test-runner-playwright": "^0.11.1",
87
+ "cheerio": "1.2.0",
77
88
  "esbuild": "^0.28.2",
89
+ "hyper-morph": "1.6.3",
78
90
  "jest": "^29.7.0",
79
91
  "jest-environment-jsdom": "^29.7.0",
80
92
  "jsdom": "^20.0.3",
93
+ "lib0": "0.2.119",
81
94
  "marked": "^18.0.14",
82
95
  "parse5": "^7.3.0",
83
96
  "playwright": "1.62.1",
@@ -93,7 +106,8 @@
93
106
  "prosemirror-transform": "1.12.2",
94
107
  "prosemirror-view": "1.42.6",
95
108
  "turndown": "^7.2.4",
96
- "turndown-plugin-gfm": "^1.0.2"
109
+ "turndown-plugin-gfm": "^1.0.2",
110
+ "yjs": "13.6.27"
97
111
  },
98
112
  "hyper": {
99
113
  "kind": "lib",
@@ -11,8 +11,22 @@
11
11
  "@panphora/clayjs/entries/clay-ui.js",
12
12
  "@panphora/clayjs/entries/clay-utils.js",
13
13
  "@panphora/clayjs/entries/sap.js",
14
- "@panphora/clayjs/clay.standalone.js"
14
+ "@panphora/clayjs/clay.standalone.js",
15
+ "@panphora/clayjs/server/rooms",
16
+ "@panphora/clayjs/server/checkpoint",
17
+ "@panphora/clayjs/page/yjs"
15
18
  ],
16
- "members": ["THIRD-PARTY-NOTICES.md", "docs/guide.md"],
17
- "families": ["./entries/*", "./dist/*", "./src/*"]
19
+ "members": [
20
+ "THIRD-PARTY-NOTICES.md",
21
+ "dist/server/rooms.cjs",
22
+ "dist/server/rooms.mjs",
23
+ "dist/server/checkpoint.cjs",
24
+ "dist/server/checkpoint.mjs",
25
+ "docs/guide.md"
26
+ ],
27
+ "families": [
28
+ "./entries/*",
29
+ "./dist/*",
30
+ "./src/*"
31
+ ]
18
32
  }
@@ -67,10 +67,9 @@ export function buildRecovery(records, { refused = false } = {}) {
67
67
  };
68
68
  }
69
69
 
70
- export function downloadRecovery(records, { refused = false } = {}) {
70
+ export function downloadHtml({ html, name }) {
71
71
  let url = null;
72
72
  try {
73
- const { html, name } = buildRecovery(records, { refused });
74
73
  url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
75
74
  const a = document.createElement("a");
76
75
  a.setAttribute("clay", "no-save no-watch no-snapshot");
@@ -89,3 +88,13 @@ export function downloadRecovery(records, { refused = false } = {}) {
89
88
  return false;
90
89
  }
91
90
  }
91
+
92
+ export function downloadRecovery(records, { refused = false } = {}) {
93
+ try {
94
+ const { html, name } = buildRecovery(records, { refused });
95
+ return downloadHtml({ html, name });
96
+ } catch (err) {
97
+ console.error("clayjs: could not build the download", err);
98
+ return false;
99
+ }
100
+ }
@@ -107,6 +107,81 @@ export function resetHostMeta() {
107
107
  refreshing = null;
108
108
  }
109
109
 
110
+ /**
111
+ * Ask the host what became of one identified save.
112
+ *
113
+ * The same discovery document answers it, but this is a separate request with its
114
+ * own abort timer: it must not share the memoized discovery promise, must not
115
+ * update the cached document, and must not record any ETag. Two concurrent
116
+ * questions get two independent answers.
117
+ *
118
+ * The host must have advertised `save-history`, the response must be a spec
119
+ * document that still advertises it, and the echoed id and hash must match this
120
+ * question exactly. Anything else, including a network failure or a timeout at
121
+ * six seconds, is null; a wrong or missing answer is never read as "not applied".
122
+ *
123
+ * @param {Object} query
124
+ * @param {string} query.saveId
125
+ * @param {string} query.requestHash
126
+ * @param {AbortSignal} [query.signal]
127
+ * @returns {Promise<?{saveId: string, requestHash: string, status: "applied"|"unknown"}>}
128
+ */
129
+ export async function querySaveOutcome({ saveId, requestHash, signal } = {}) {
130
+ if (isDraftMode) return null;
131
+ if (typeof saveId !== 'string' || !saveId) return null;
132
+ if (typeof requestHash !== 'string' || !requestHash) return null;
133
+ if (!/^https?:$/.test(window.location.protocol)) return null;
134
+ if (signal?.aborted) return null;
135
+
136
+ const controller = new AbortController();
137
+ const abort = () => controller.abort();
138
+ const timeoutId = setTimeout(abort, META_TIMEOUT_MS);
139
+ signal?.addEventListener('abort', abort);
140
+ try {
141
+ const meta = await hostMeta();
142
+ if (hostMetaOutcome(meta) !== 'ok' || !meta.extensions.includes('save-history')) return null;
143
+ if (controller.signal.aborted) return null;
144
+
145
+ const token = saveToken();
146
+ const path = token ? `${META_PATH}/${token}` : META_PATH;
147
+ const res = await fetch(new URL(path, window.location.origin).href, {
148
+ method: 'GET',
149
+ credentials: token ? 'omit' : 'same-origin',
150
+ headers: {
151
+ 'Document-URL': window.location.href,
152
+ 'Save-ID': saveId,
153
+ 'Save-Request-Hash': requestHash
154
+ },
155
+ cache: 'no-store',
156
+ redirect: 'manual',
157
+ signal: controller.signal
158
+ });
159
+ if (!res.ok) return null;
160
+ const text = await res.text();
161
+ if (!text) return null;
162
+
163
+ let body;
164
+ try {
165
+ body = JSON.parse(text);
166
+ } catch (_) {
167
+ return null;
168
+ }
169
+ if (!body || typeof body !== 'object' || typeof body.spec !== 'number') return null;
170
+ if (!Array.isArray(body.extensions) || !body.extensions.includes('save-history')) return null;
171
+ const outcome = body.document && typeof body.document === 'object' ? body.document.saveOutcome : null;
172
+ if (!outcome || typeof outcome !== 'object') return null;
173
+ if (outcome.saveId !== saveId || outcome.requestHash !== requestHash) return null;
174
+ if (outcome.status !== 'applied' && outcome.status !== 'unknown') return null;
175
+
176
+ return { saveId, requestHash, status: outcome.status };
177
+ } catch (_) {
178
+ return null;
179
+ } finally {
180
+ clearTimeout(timeoutId);
181
+ signal?.removeEventListener('abort', abort);
182
+ }
183
+ }
184
+
110
185
  async function fetchMeta() {
111
186
  // A file opened from disk has no host to ask, and fetch would only throw.
112
187
  if (!/^https?:$/.test(window.location.protocol)) return settle("none");
@@ -76,6 +76,17 @@ export default function enablePersistentFormInputValues(filterBySelector = PERSI
76
76
  if ((el.matches(inputSelector) && (el.type === 'checkbox' || el.type === 'radio')) ||
77
77
  el.matches(selectSelector)) {
78
78
  serializeControlToAttributes(el);
79
+ // Checking a radio unchecks its group without a change event on the others,
80
+ // so their checked attributes are cleared here; otherwise two radios in one
81
+ // group carry checked and an undo or reload picks the wrong one.
82
+ if (el.type === 'radio' && el.name) {
83
+ const group = el.form ? [...el.form.elements] : [...document.getElementsByName(el.name)];
84
+ for (const other of group) {
85
+ if (other !== el && other.type === 'radio' && other.name === el.name && other.form === el.form && other.matches(inputSelector)) {
86
+ serializeControlToAttributes(other);
87
+ }
88
+ }
89
+ }
79
90
  }
80
91
  }, true);
81
92
 
@@ -16,7 +16,8 @@ import { hostMeta } from "./host-meta.js";
16
16
  import {
17
17
  getPageContents,
18
18
  onSnapshot,
19
- addDocumentTransform
19
+ addDocumentTransform,
20
+ currentCaptureId
20
21
  } from "./snapshot.js";
21
22
 
22
23
  // =============================================================================
@@ -99,16 +100,74 @@ export function onSaveAccepted(callback) {
99
100
  saveAcceptedHooks.push(callback);
100
101
  }
101
102
 
102
- function notifySaveAccepted(html) {
103
+ function notifySaveAccepted(html, captureId) {
103
104
  for (const hook of saveAcceptedHooks) {
104
105
  try {
105
- hook(html);
106
+ hook(html, { captureId: captureId ?? null });
106
107
  } catch (err) {
107
108
  console.error('clayjs: onSaveAccepted hook failed:', err);
108
109
  }
109
110
  }
110
111
  }
111
112
 
113
+ // Listeners for "this exact body is about to leave". One call per real fetch, with
114
+ // the capture identity the bytes came from, so a recovery can bind its evidence to
115
+ // the request rather than guess from timing or from the newest global capture.
116
+ const saveAttemptHooks = [];
117
+ // Listeners for "this logical save has settled", once each. The point is the pair
118
+ // (which capture, which actual sends), which is what an uncertain attempt later
119
+ // has to prove.
120
+ const saveOutcomeHooks = [];
121
+
122
+ /**
123
+ * Observe each real send of a save, synchronously before the fetch.
124
+ *
125
+ * @param {Function} listener - Receives {captureId, saveId, html, etag}
126
+ * @returns {Function} unsubscribe
127
+ */
128
+ export function onSaveAttempt(listener) {
129
+ saveAttemptHooks.push(listener);
130
+ return () => {
131
+ const index = saveAttemptHooks.indexOf(listener);
132
+ if (index >= 0) saveAttemptHooks.splice(index, 1);
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Observe the settlement of one logical save, after any accepted hook and before
138
+ * the caller's callback.
139
+ *
140
+ * @param {Function} listener - Receives {captureId, result, attempts, sent}
141
+ * @returns {Function} unsubscribe
142
+ */
143
+ export function onSaveOutcome(listener) {
144
+ saveOutcomeHooks.push(listener);
145
+ return () => {
146
+ const index = saveOutcomeHooks.indexOf(listener);
147
+ if (index >= 0) saveOutcomeHooks.splice(index, 1);
148
+ };
149
+ }
150
+
151
+ function notifySaveAttempt(attempt) {
152
+ for (const hook of saveAttemptHooks) {
153
+ try {
154
+ hook(attempt);
155
+ } catch (err) {
156
+ console.error('clayjs: onSaveAttempt hook failed:', err);
157
+ }
158
+ }
159
+ }
160
+
161
+ function notifySaveOutcome(outcome) {
162
+ for (const hook of saveOutcomeHooks) {
163
+ try {
164
+ hook(outcome);
165
+ } catch (err) {
166
+ console.error('clayjs: onSaveOutcome hook failed:', err);
167
+ }
168
+ }
169
+ }
170
+
112
171
  // =============================================================================
113
172
  // RE-EXPORTS FROM SNAPSHOT (for backwards compat)
114
173
  // =============================================================================
@@ -238,12 +297,17 @@ function errorResult(err) {
238
297
  // truncation refusal, a 409 that lifts itself within a second, which suspended
239
298
  // autosave over a condition that had already cleared.
240
299
  const conflicted = !timedOut && err.status === 412;
300
+ // A save the host has already applied is proof a write happened, but not proof
301
+ // of what the document holds now: the client's edits are neither confirmed on
302
+ // disk nor known to be absent. It is an uncertain outcome, like a timeout, and
303
+ // until recovery lands it is carried as one rather than reported as a failure.
304
+ const alreadyApplied = !timedOut && !conflicted && err.code === 'save-already-applied';
241
305
  return {
242
306
  ok: false,
243
307
  msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
244
308
  // A timeout is not evidence the write failed. The request may well have landed,
245
309
  // so this reports "we do not know" rather than asserting something false.
246
- msgType: timedOut ? 'unknown' : (conflicted ? 'conflict' : 'error'),
310
+ msgType: timedOut || alreadyApplied ? 'unknown' : (conflicted ? 'conflict' : 'error'),
247
311
  code: timedOut ? 'timeout' : (conflicted ? 'conflict' : (err.code ?? null)),
248
312
  changedBy: conflicted ? (err.changedBy ?? null) : null,
249
313
  // A refusal that may be answering this tab's own timed-out write. Only the
@@ -258,6 +322,15 @@ function errorResult(err) {
258
322
  // An attempt of this save timed out, so it may have landed whatever the
259
323
  // final answer says.
260
324
  hadTimeout: timedOut || err.hadTimeout === true,
325
+ // The typed refusal behind `code`, kept for the caller: `page-capacity` carries
326
+ // which limit was hit, the conversion codes carry which condition failed.
327
+ reason: err.reason ?? null,
328
+ // Only meaningful while a save can still retry; a caller sees how long the
329
+ // host asked it to wait. Null when the host named no delay.
330
+ retryAfter: err.retryAfter ?? null,
331
+ // The host's answer to a save that already happened. Only `save-already-applied`
332
+ // produces one, and recovery is what will act on it.
333
+ saveOutcome: err.saveOutcome ?? null,
261
334
  etag: null
262
335
  };
263
336
  }
@@ -266,6 +339,62 @@ function skippedResult(msg) {
266
339
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
267
340
  }
268
341
 
342
+ // The metadata a caller attaches to a capture was refused here, before anything
343
+ // went out. The host's own grammar is the only definition of valid, so an
344
+ // `invalid-header` here is the same answer a host would have given, without the
345
+ // request. Nothing about an ordinary save changes.
346
+ function invalidHeaderResult() {
347
+ return { ok: false, msg: 'Invalid page conversion metadata', msgType: 'error', code: 'invalid-header', etag: null };
348
+ }
349
+
350
+ // How many extra sends one logical save may make while the host is still
351
+ // converting the page. `page-converting` is a pre-commit refusal, so the same
352
+ // body is worth offering again; anything else is surfaced on the first answer.
353
+ const PAGE_CONVERTING_MAX_RETRIES = 3;
354
+ const HTTP_DATE = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: \d|\d{2}) \d{2}:\d{2}:\d{2} \d{4})$/;
355
+
356
+ /**
357
+ * Decode a `Retry-After` header into whole seconds.
358
+ *
359
+ * The header is either an integer number of seconds or an HTTP date, per RFC 7231.
360
+ * A date is measured against now, because that is the only clock both sides could
361
+ * mean. Clamped to 0..3600 so a bogus or hostile value cannot park a save for a
362
+ * day; anything that is neither shape, or absent, is null.
363
+ */
364
+ function parseRetryAfter(value) {
365
+ if (value == null) return null;
366
+ const raw = String(value).trim();
367
+ if (!raw) return null;
368
+ if (/^-?\d+$/.test(raw)) return Math.max(0, Math.min(3600, Number(raw)));
369
+ if (!HTTP_DATE.test(raw)) return null;
370
+ const when = Date.parse(raw);
371
+ if (Number.isNaN(when)) return null;
372
+ return Math.max(0, Math.min(3600, Math.round((when - Date.now()) / 1000)));
373
+ }
374
+
375
+ // The host grammar for `Page-Conversion`, from `server-lib/page-conversion.js`:
376
+ // `file` takes a 64-lowercase-hex baseline and no epoch; `room` takes the same
377
+ // baseline plus an epoch of [A-Za-z0-9-]{1,64}. Validating here means a caller
378
+ // with bad metadata gets a typed answer instead of a wasted round trip.
379
+ const CONVERSION_BASE = /^[0-9a-f]{64}$/;
380
+ const CONVERSION_EPOCH = /^[A-Za-z0-9-]{1,64}$/;
381
+
382
+ function formatConversionHeader(conversion) {
383
+ if (conversion == null) return { header: null };
384
+ if (typeof conversion !== 'object') return { invalid: true };
385
+ const { source, base, epoch } = conversion;
386
+ if (typeof base !== 'string' || !CONVERSION_BASE.test(base)) return { invalid: true };
387
+ if (source === 'file') {
388
+ if (epoch != null) return { invalid: true };
389
+ return { header: `v1; source=file; base=${base}` };
390
+ }
391
+ if (source === 'room') {
392
+ if (typeof epoch !== 'string' || !CONVERSION_EPOCH.test(epoch)) return { invalid: true };
393
+ return { header: `v1; source=room; base=${base}; epoch=${epoch}` };
394
+ }
395
+ return { invalid: true };
396
+ }
397
+
269
398
  // =============================================================================
270
399
  // THE WIRE
271
400
  // =============================================================================
@@ -305,9 +434,10 @@ function skippedResult(msg) {
305
434
  * @param {string} saveId - this attempt's §6 receipt id
306
435
  * @param {?string} etag - the stamp to send as If-Match, or null
307
436
  * @param {boolean} keepalive - whether to ask the transport to see the request through
437
+ * @param {?string} conversionHeader - the `Page-Conversion` value for this logical save, or null
308
438
  * @returns {{url: string, options: Object}}
309
439
  */
310
- function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
440
+ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false, conversionHeader = null) {
311
441
  const token = saveToken();
312
442
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
313
443
  const options = {
@@ -334,6 +464,11 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive =
334
464
 
335
465
  for (const [name, value] of extraSaveHeaders) options.headers[name] = value;
336
466
 
467
+ // The conversion request belongs to ONE logical save, and goes on every attempt
468
+ // of it. Set after the process-global headers so the capture-bound value is the
469
+ // one that describes these bytes.
470
+ if (conversionHeader) options.headers['Page-Conversion'] = conversionHeader;
471
+
337
472
  if (etag) options.headers['If-Match'] = etag;
338
473
 
339
474
  if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
@@ -411,9 +546,15 @@ function sendToDraftStore(content) {
411
546
  * instead of the status.
412
547
  *
413
548
  * @param {string} content - HTML to save
549
+ * @param {string} saveId - this attempt's receipt id
550
+ * @param {?string} etag - the stamp to send as If-Match, or null
551
+ * @param {Object} [context]
552
+ * @param {?number} [context.captureId] - the capture these bytes came from
553
+ * @param {?string} [context.conversionHeader] - per-logical-save conversion header
554
+ * @param {Array} [context.attempts] - collects each unique actual send
414
555
  * @returns {Promise<Object>} The server's response body
415
556
  */
416
- function sendOnce(content, saveId, etag) {
557
+ function sendOnce(content, saveId, etag, { captureId = null, conversionHeader = null, attempts = [] } = {}) {
417
558
  const controller = new AbortController();
418
559
  const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
419
560
 
@@ -430,9 +571,18 @@ function sendOnce(content, saveId, etag) {
430
571
  // returns early never reaches this point, so the hint cannot leak onto a later one.
431
572
  const keepalive = nextSaveKeepalive;
432
573
  nextSaveKeepalive = false;
433
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
574
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive, conversionHeader);
434
575
  rememberSentId(saveId);
435
576
 
577
+ // A draft save is not a request to any host and has no receipt to recover, so it
578
+ // is not an attempt as far as this feature is concerned.
579
+ if (!isDraftMode) {
580
+ if (!attempts.some(attempt => attempt.saveId === saveId && attempt.etag === (etag ?? null))) {
581
+ attempts.push({ saveId, etag: etag ?? null });
582
+ }
583
+ notifySaveAttempt({ captureId, saveId, html: content, etag: etag ?? null });
584
+ }
585
+
436
586
  return (isDraftMode ? sendToDraftStore(content) : fetch(url, options))
437
587
  .then(res => res.text().then(text => {
438
588
  let data = {};
@@ -457,6 +607,14 @@ function sendOnce(content, saveId, etag) {
457
607
  // whether the document was moved by this tab's own earlier save.
458
608
  error.etag = data.etag ?? null;
459
609
  error.saveId = data.saveId ?? null;
610
+ // The refusal's own vocabulary. `reason` is the typed cause behind the
611
+ // stable `code`, and both ride out unchanged so a caller can act on the
612
+ // specific one. `saveOutcome` is the host's receipt answer to a duplicate
613
+ // that already landed, and it is the whole reply to a client whose own
614
+ // response was lost.
615
+ error.reason = typeof data.reason === 'string' ? data.reason : null;
616
+ error.saveOutcome = data.saveOutcome ?? null;
617
+ error.retryAfter = parseRetryAfter(res.headers?.get?.('Retry-After'));
460
618
  throw error;
461
619
  }
462
620
  // The one place a stamp is ever learned from a save (§6). A response with no
@@ -579,9 +737,13 @@ async function recoverUnknownSave() {
579
737
  * Each recovery sends at most one further request, so a save can never loop.
580
738
  *
581
739
  * @param {string} content - HTML to save
740
+ * @param {Object} [context]
741
+ * @param {?number} [context.captureId] - the capture these bytes came from
742
+ * @param {?string} [context.conversionHeader] - per-logical-save conversion header
743
+ * @param {Array} [context.attempts] - collects each unique actual send
582
744
  * @returns {Promise<Object>} The server's response body
583
745
  */
584
- async function sendSave(content) {
746
+ async function sendSave(content, { captureId = null, conversionHeader = null, attempts = [] } = {}) {
585
747
  // The re-send below is a RETRY of this attempt, so it carries this same id:
586
748
  // whichever of the two requests the host ends up storing, its receipt then
587
749
  // answers for this save. A fresh id on the re-send would leave the first
@@ -595,9 +757,18 @@ async function sendSave(content) {
595
757
  // is fast and conclusive, so it gets its own turn regardless.
596
758
  let reSent = false;
597
759
  let duplicateResolved = false;
760
+ // `page-converting` is the one refusal that is worth offering the same bytes
761
+ // again: the host has written nothing and is telling us when it expects to be
762
+ // ready. The capture these bytes came from is pinned here, so a capture made
763
+ // while we wait — newer edits, a disk frame — cancels the retry instead of
764
+ // being overwritten by stale bytes.
765
+ let convertingRetries = 0;
766
+ // A bound save retries only while its own capture is still the newest. A raw
767
+ // save has no capture identity, so it uses the newest capture at send time.
768
+ const retryGuard = captureId ?? currentCaptureId();
598
769
  for (;;) {
599
770
  try {
600
- return await sendOnce(content, saveId, etag);
771
+ return await sendOnce(content, saveId, etag, { captureId, conversionHeader, attempts });
601
772
  } catch (err) {
602
773
  if (reSent) err.hadTimeout = true;
603
774
  if (err.name === 'AbortError') {
@@ -626,6 +797,16 @@ async function sendSave(content) {
626
797
  continue;
627
798
  }
628
799
 
800
+ if (err.code === 'page-converting' && convertingRetries < PAGE_CONVERTING_MAX_RETRIES) {
801
+ if (currentCaptureId() !== retryGuard) throw err;
802
+ convertingRetries += 1;
803
+ // Default a missing or unparseable header to one second, so a host that
804
+ // asks for a retry without naming a delay still gets a bounded one.
805
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, err.retryAfter ?? 1) * 1000));
806
+ if (currentCaptureId() !== retryGuard) throw err;
807
+ continue;
808
+ }
809
+
629
810
  throw err;
630
811
  }
631
812
  }
@@ -635,32 +816,36 @@ async function sendSave(content) {
635
816
  // SAVE FUNCTIONS
636
817
  // =============================================================================
637
818
 
638
- /**
639
- * Save specific HTML content to the server.
640
- *
641
- * Returns a Promise resolving with {msg, msgType, code, etag} — the same object
642
- * passed to the callback. Never rejects.
643
- *
644
- * @param {string} html - HTML string to save
645
- * @param {Function} callback - Called with the result on completion
646
- * @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
647
- *
648
- * @example
649
- * const {msg, msgType} = await saveHtml(myHtml);
650
- * if (msgType === 'error') console.error('Save failed:', msg);
651
- */
652
- export function saveHtml(html, callback = () => {}) {
819
+ // One private implementation behind every entry point, so the hooks see the same
820
+ // thing whether the bytes came from a bound capture or a raw caller. `captureId`
821
+ // is null for a raw save: it belongs to no capture and can confirm none.
822
+ function runSave(html, captureId, callback, conversion) {
653
823
  return new Promise((resolve) => {
654
- const done = (result) => {
824
+ const attempts = [];
825
+ const finished = (result, accepted = false) => {
826
+ if (accepted) notifySaveAccepted(html, captureId ?? null);
827
+ notifySaveOutcome({
828
+ captureId: captureId ?? null,
829
+ result,
830
+ attempts,
831
+ sent: attempts.length > 0
832
+ });
655
833
  if (typeof callback === 'function') callback(result);
656
834
  resolve(result);
657
835
  };
658
836
 
659
837
  if (!isEditMode && !window.clay?.testMode) {
660
- return done(skippedResult('Not in edit mode'));
838
+ return finished(skippedResult('Not in edit mode'));
661
839
  }
662
840
  if (saveInProgress) {
663
- return done(skippedResult('Save already in progress'));
841
+ return finished(skippedResult('Save already in progress'));
842
+ }
843
+
844
+ // Refused locally and before the lane is claimed: no request is made, and the
845
+ // caller gets the same typed `invalid-header` a host would have sent back.
846
+ const formatted = formatConversionHeader(conversion);
847
+ if (formatted.invalid) {
848
+ return finished(invalidHeaderResult());
664
849
  }
665
850
 
666
851
  saveInProgress = true;
@@ -669,24 +854,74 @@ export function saveHtml(html, callback = () => {}) {
669
854
  if (window.clay?.testMode) {
670
855
  setTimeout(() => {
671
856
  saveInProgress = false;
672
- done(successResult({ msg: 'Test mode: save skipped' }));
857
+ finished(successResult({ msg: 'Test mode: save skipped' }));
673
858
  }, 0);
674
859
  return;
675
860
  }
676
861
 
677
- sendSave(html)
862
+ sendSave(html, { captureId, conversionHeader: formatted.header, attempts })
678
863
  .then(successResult, errorResult)
679
864
  // Clear the flag BEFORE handing the result back, so a caller that queues a
680
865
  // follow-up save can start it immediately rather than being told the lane
681
866
  // is busy by the save that just finished.
682
867
  .then(result => {
683
868
  saveInProgress = false;
684
- if (result.ok) notifySaveAccepted(html);
685
- done(result);
869
+ if (result.ok) notifySaveAccepted(html, captureId ?? null);
870
+ finished(result);
686
871
  });
687
872
  });
688
873
  }
689
874
 
875
+ /**
876
+ * Save specific HTML content to the server.
877
+ *
878
+ * Returns a Promise resolving with {msg, msgType, code, etag} — the same object
879
+ * passed to the callback. Never rejects.
880
+ *
881
+ * @param {string} html - HTML string to save
882
+ * @param {Function} callback - Called with the result on completion
883
+ * @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
884
+ *
885
+ * @example
886
+ * const {msg, msgType} = await saveHtml(myHtml);
887
+ * if (msgType === 'error') console.error('Save failed:', msg);
888
+ */
889
+ export function saveHtml(html, callback = () => {}) {
890
+ return runSave(html, null, callback, null);
891
+ }
892
+
893
+ /**
894
+ * Save HTML that came from a specific capture.
895
+ *
896
+ * The capture id travels only in-process: it is what lets an attempt hook and an
897
+ * outcome hook agree that these bytes and that snapshot are the same save. A
898
+ * `conversion` request rides every attempt of this one logical save and no other.
899
+ *
900
+ * @param {string} html
901
+ * @param {?number} captureId
902
+ * @param {Function} [callback]
903
+ * @param {Object} [options]
904
+ * @param {Object} [options.conversion] - {source:'file', base} or {source:'room', base, epoch}
905
+ * @returns {Promise<Object>}
906
+ */
907
+ export function saveCapturedHtml(html, captureId, callback = () => {}, { conversion } = {}) {
908
+ return runSave(html, captureId, callback, conversion);
909
+ }
910
+
911
+ /**
912
+ * Internal entry point for the dormant adoption path: request conversion while
913
+ * saving a capture's body. Not published on `window.clay`.
914
+ *
915
+ * @param {Object} request
916
+ * @param {string} request.html
917
+ * @param {?number} request.captureId
918
+ * @param {Object} request.conversion
919
+ * @returns {Promise<Object>}
920
+ */
921
+ export function saveConversion({ html, captureId, conversion }) {
922
+ return runSave(html, captureId ?? null, undefined, conversion);
923
+ }
924
+
690
925
  /**
691
926
  * Fetch HTML from a URL and save it to replace the current page.
692
927
  *