@panphora/clayjs 1.16.0 → 1.17.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 (283) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +7917 -5621
  11. package/dist/m/attrs/refetch-on-save-ZUE7PSCI.js +1 -0
  12. package/dist/m/attrs/save-freeze-MYNVXREN.js +1 -0
  13. package/dist/m/c-2YYDMLNZ.js +1 -0
  14. package/dist/m/c-336SGU5W.js +1 -0
  15. package/dist/m/c-3H6BZDNJ.js +6 -0
  16. package/dist/m/{c-LR4CGEPT.js → c-3RUU4TPX.js} +1 -1
  17. package/dist/m/c-3XIRD4NU.js +1 -0
  18. package/dist/m/c-4DFEJ2PO.js +3 -0
  19. package/dist/m/c-56QRZTZH.js +1 -0
  20. package/dist/m/c-5J4E7O4Y.js +4 -0
  21. package/dist/m/c-5LKDWQTL.js +19 -0
  22. package/dist/m/c-5QWQ7A5L.js +1 -0
  23. package/dist/m/c-5RJFTLSF.js +1 -0
  24. package/dist/m/c-6AZOGAPE.js +1 -0
  25. package/dist/m/{c-Y6USGTS5.js → c-6CRUUXOX.js} +5 -5
  26. package/dist/m/{c-54RCDCBU.js → c-6KEVEQ7L.js} +1 -1
  27. package/dist/m/{c-LTGMXXKO.js → c-6NC5I2TV.js} +1 -1
  28. package/dist/m/{c-YDH7Q3NY.js → c-74MGYWZF.js} +1 -1
  29. package/dist/m/{c-Z36J63PP.js → c-7OLLJPWP.js} +1 -1
  30. package/dist/m/c-A3E3NIMC.js +1 -0
  31. package/dist/m/{c-5R4PTRG2.js → c-AMSNPOCX.js} +1 -1
  32. package/dist/m/c-B3JM5ZXC.js +1 -0
  33. package/dist/m/c-BPF2NSPV.js +1 -0
  34. package/dist/m/c-BQ6LOBEE.js +1 -0
  35. package/dist/m/c-CJ3A7ZKE.js +1 -0
  36. package/dist/m/c-CQHPGKX2.js +1 -0
  37. package/dist/m/c-DCXOXBF3.js +1 -0
  38. package/dist/m/c-DFS4XZJV.js +2 -0
  39. package/dist/m/{c-F3CKJTJS.js → c-DNENWY4O.js} +1 -1
  40. package/dist/m/c-E4LIR5X5.js +1 -0
  41. package/dist/m/c-EVCQ3BKA.js +1 -0
  42. package/dist/m/{c-5Y6TNX5W.js → c-EZU56NQV.js} +2 -2
  43. package/dist/m/c-FDNHHZY5.js +1 -0
  44. package/dist/m/{c-SWRJFCNN.js → c-FRLHSY7E.js} +1 -1
  45. package/dist/m/c-FROJUZMV.js +1 -0
  46. package/dist/m/c-G2JPKCTP.js +1 -0
  47. package/dist/m/c-GB6TU7WV.js +1 -0
  48. package/dist/m/{c-GB3JTV6E.js → c-GFJJEBTU.js} +1 -1
  49. package/dist/m/{c-J7CA54IP.js → c-GSFIGKOP.js} +1 -1
  50. package/dist/m/{c-OYTFP5QN.js → c-GY422N3Q.js} +1 -1
  51. package/dist/m/c-HAWFNBYG.js +1 -0
  52. package/dist/m/{c-33HUYEDI.js → c-HDDEEL77.js} +1 -1
  53. package/dist/m/c-HFKUVACH.js +1 -0
  54. package/dist/m/c-HHDFCNHV.js +1 -0
  55. package/dist/m/c-I2ZATQCD.js +2 -0
  56. package/dist/m/c-IEM4NTCS.js +1 -0
  57. package/dist/m/c-IW6GGOU2.js +1 -0
  58. package/dist/m/{c-SGCCC4FW.js → c-JDCX26UP.js} +2 -2
  59. package/dist/m/c-JRKY44DK.js +1 -0
  60. package/dist/m/c-JU3NK25H.js +1 -0
  61. package/dist/m/{c-VFX45IZQ.js → c-KAU6E43D.js} +1 -1
  62. package/dist/m/c-KBS3UV6B.js +1 -0
  63. package/dist/m/c-KGVGROZT.js +1 -0
  64. package/dist/m/{c-YG2NJYS6.js → c-KQ6TYJAP.js} +2 -2
  65. package/dist/m/{c-2JL6E75N.js → c-KU3ZFADW.js} +1 -1
  66. package/dist/m/c-LWLBHLVC.js +3 -0
  67. package/dist/m/c-M2L47S2X.js +7 -0
  68. package/dist/m/c-MFP2ZWD4.js +1 -0
  69. package/dist/m/{c-4YSCRICO.js → c-MKSQ4Y57.js} +1 -1
  70. package/dist/m/{c-2M73252O.js → c-NUFFJVVJ.js} +1 -1
  71. package/dist/m/c-NUHSPT5P.js +1 -0
  72. package/dist/m/c-O72HVYSA.js +1 -0
  73. package/dist/m/c-OW5QIGG3.js +1 -0
  74. package/dist/m/c-PGRCK73F.js +1 -0
  75. package/dist/m/{c-4DRMS4QG.js → c-PS5ARVWR.js} +1 -1
  76. package/dist/m/c-Q622NS2Y.js +1 -0
  77. package/dist/m/c-Q7NQD65V.js +1 -0
  78. package/dist/m/c-QH63LQOS.js +1 -0
  79. package/dist/m/{c-TQZ2JYHD.js → c-QICP2YR5.js} +1 -1
  80. package/dist/m/{c-7VGZ7RU5.js → c-QJ4OM3G6.js} +1 -1
  81. package/dist/m/{c-MMUIUUZG.js → c-R3L7ZJGB.js} +1 -1
  82. package/dist/m/c-R7IWVINQ.js +1 -0
  83. package/dist/m/{c-HVYXIS72.js → c-RSMOODDU.js} +1 -1
  84. package/dist/m/{c-KKWLDOPM.js → c-RYUXUUBX.js} +1 -1
  85. package/dist/m/c-S4ZEH46B.js +9 -0
  86. package/dist/m/c-SDAXDM2D.js +1 -0
  87. package/dist/m/c-SMBZG5X4.js +1 -0
  88. package/dist/m/{c-UXBMRDZB.js → c-SMGU7RT3.js} +1 -1
  89. package/dist/m/c-SPIORRYS.js +1 -0
  90. package/dist/m/c-SWLRT66R.js +1 -0
  91. package/dist/m/c-SZGYW5HT.js +1 -0
  92. package/dist/m/c-TCJTVAFN.js +1 -0
  93. package/dist/m/{c-HRDCD3WN.js → c-TJ6WOJL3.js} +1 -1
  94. package/dist/m/c-TYLXX4NF.js +21 -0
  95. package/dist/m/c-U62OB4BT.js +1 -0
  96. package/dist/m/{c-NMJRXKPC.js → c-U6YWUAR3.js} +1 -1
  97. package/dist/m/c-UDCSU5G5.js +2 -0
  98. package/dist/m/{c-NZMNXVF6.js → c-UDUUJPFZ.js} +1 -1
  99. package/dist/m/c-VE6ZMYMC.js +1 -0
  100. package/dist/m/c-VG5XIN4T.js +1 -0
  101. package/dist/m/c-VHM5XQHK.js +1 -0
  102. package/dist/m/c-VI5RMSVY.js +1 -0
  103. package/dist/m/c-VRZN56XY.js +1 -0
  104. package/dist/m/c-W5MCHEGA.js +1 -0
  105. package/dist/m/c-WDC5GVKU.js +1 -0
  106. package/dist/m/c-WWXIDH4O.js +28 -0
  107. package/dist/m/{c-QIX5BJYN.js → c-XCNTK76U.js} +1 -1
  108. package/dist/m/c-XMS6DPBR.js +1 -0
  109. package/dist/m/c-Y5HBBIQ4.js +1 -0
  110. package/dist/m/{c-5M4YEKCX.js → c-ZVPVXTJF.js} +1 -1
  111. package/dist/m/c-ZVRKXKM7.js +1 -0
  112. package/dist/m/{c-HT33EQRN.js → c-ZWIOJGMT.js} +1 -1
  113. package/dist/m/core/admin-attrs-QHOKPJ7W.js +1 -0
  114. package/dist/m/core/autosave-HRYGUWV6.js +1 -0
  115. package/dist/m/core/conflict-notice-6MROJN2L.js +1 -0
  116. package/dist/m/core/host-permission-FNXVEFNE.js +1 -0
  117. package/dist/m/core/is-edit-mode-KTWA3O5N.js +1 -0
  118. package/dist/m/core/{persist-HKHA66IY.js → persist-JFYRHMRJ.js} +1 -1
  119. package/dist/m/core/recovery-FLTCC2OC.js +1 -0
  120. package/dist/m/core/save-HQZ4S667.js +1 -0
  121. package/dist/m/core/save-core-KGQWFUNH.js +1 -0
  122. package/dist/m/core/snapshot-EGQZNT45.js +1 -0
  123. package/dist/m/core/unsaved-warning-IDJLD66H.js +1 -0
  124. package/dist/m/events/index-RN2JNGBV.js +1 -0
  125. package/dist/m/internals/index-PVXGPUDA.js +1 -0
  126. package/dist/m/lib/cache-bust-MUMFXZK6.js +1 -0
  127. package/dist/m/loader-S4I7GKXR.js +1 -0
  128. package/dist/m/manifest-4C419D67.js +1 -0
  129. package/dist/m/manifest-edit-B077F288.js +1 -0
  130. package/dist/m/manifest.json +1555 -1058
  131. package/dist/m/options/options-EJMS3K24.js +1 -0
  132. package/dist/m/page/{editor-XUTB2HB2.js → editor-NNJA4OKD.js} +21 -8
  133. package/dist/m/plugins/{ai-edit-ZQ4CGWIP.js → ai-edit-QN4PMMNL.js} +1 -1
  134. package/dist/m/plugins/demo-RM5IR6AO.js +1 -0
  135. package/dist/m/plugins/indicator-URDBBOQO.js +1 -0
  136. package/dist/m/plugins/live-HFWD4OIH.js +1 -0
  137. package/dist/m/plugins/{menu-HCKO3DSP.js → menu-3HIEQSI6.js} +2 -2
  138. package/dist/m/plugins/page-JOKB6XPR.js +2 -0
  139. package/dist/m/plugins/page-view-NXOJHNW6.js +1 -0
  140. package/dist/m/plugins/people-SGPJKANK.js +1 -0
  141. package/dist/m/plugins/{richclay-RC3L2F6X.js → richclay-NRQDSWX6.js} +2 -2
  142. package/dist/m/plugins/sortable-GWT4PWDV.js +1 -0
  143. package/dist/m/plugins/source-4ZRSAIYM.js +1 -0
  144. package/dist/m/plugins/undo-VDA5P7FH.js +1 -0
  145. package/dist/m/plugins/{upgrade-ZNYODP5Y.js → upgrade-W7FG5VFG.js} +1 -1
  146. package/dist/m/plugins/upload-WLYKK6TL.js +1 -0
  147. package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +21 -0
  148. package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +1 -0
  149. package/dist/m/plugins/wire-4N6GYAF6.js +1 -0
  150. package/dist/m/{reader-core-ILWLXVMP.js → reader-core-DXYCURFF.js} +2 -2
  151. package/dist/m/sync/live-sync-J7VMYTKS.js +1 -0
  152. package/dist/m/ui/{index-EVHKMKBL.js → index-3JLVI27O.js} +1 -1
  153. package/dist/offline-manifest.json +857 -623
  154. package/dist/page/themes/light-1.css +25 -1
  155. package/dist/server/core.cjs +115 -24
  156. package/package.json +1 -1
  157. package/src/core/boot-phase.js +2 -1
  158. package/src/core/conflict-download.js +3 -29
  159. package/src/core/download.js +32 -0
  160. package/src/core/host-meta.js +18 -0
  161. package/src/core/save-core.js +8 -4
  162. package/src/core/save.js +4 -4
  163. package/src/core/snapshot.js +39 -1
  164. package/src/lib/autosave-scheduler.js +3 -4
  165. package/src/page/blocks.js +34 -1
  166. package/src/page/board-views.js +2 -16
  167. package/src/page/chart-tables.js +11 -0
  168. package/src/page/chart.js +6 -10
  169. package/src/page/checkpoint-proof.js +289 -0
  170. package/src/page/chrome.js +24 -6
  171. package/src/page/document-title-dom.js +12 -0
  172. package/src/page/document-title-runtime.js +15 -1
  173. package/src/page/document-title.js +4 -1
  174. package/src/page/editor.js +77 -12
  175. package/src/page/emoji-data.js +162 -0
  176. package/src/page/features/board.js +12 -3
  177. package/src/page/features/chart.js +11 -0
  178. package/src/page/features/dates.js +180 -0
  179. package/src/page/features/emoji.js +187 -0
  180. package/src/page/features/mentions.js +188 -0
  181. package/src/page/features/registry.js +6 -1
  182. package/src/page/features/table.js +2 -2
  183. package/src/page/format-command.js +9 -0
  184. package/src/page/format-host.js +54 -0
  185. package/src/page/format-native-commands.js +142 -0
  186. package/src/page/format-native.js +42 -0
  187. package/src/page/inline.js +32 -8
  188. package/src/page/items.js +15 -3
  189. package/src/page/markdown.js +26 -2
  190. package/src/page/mention-picker.js +99 -0
  191. package/src/page/merge.js +1 -1
  192. package/src/page/projection.js +58 -15
  193. package/src/page/save-projection.js +39 -0
  194. package/src/page/save-states.js +1 -1
  195. package/src/page/schema.js +33 -0
  196. package/src/page/themes/content.css +25 -1
  197. package/src/page/themes/light-1.css +25 -1
  198. package/src/page/view-hooks.js +32 -0
  199. package/src/page/ybind/binding.js +31 -4
  200. package/src/page/ybind/marks.js +2 -1
  201. package/src/page/ybind/provider.js +13 -2
  202. package/src/page/ybind/recovery-replay.js +362 -4
  203. package/src/plugins/live.js +37 -1
  204. package/src/plugins/page.js +117 -3
  205. package/src/plugins/visitor-drafts/intro.js +2 -1
  206. package/src/server/checkpoint/index.js +2 -2
  207. package/src/sync/saved-stream.js +17 -8
  208. package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +0 -1
  209. package/dist/m/attrs/save-freeze-FUP4Q4X3.js +0 -1
  210. package/dist/m/c-35NBGMZB.js +0 -1
  211. package/dist/m/c-3IQRECYS.js +0 -1
  212. package/dist/m/c-4B6H4G5V.js +0 -1
  213. package/dist/m/c-4FFHZM32.js +0 -1
  214. package/dist/m/c-66O32MV3.js +0 -28
  215. package/dist/m/c-7O6AFR65.js +0 -1
  216. package/dist/m/c-ANQTJ3B5.js +0 -2
  217. package/dist/m/c-AYVOREBE.js +0 -1
  218. package/dist/m/c-B2ZXPOS3.js +0 -3
  219. package/dist/m/c-BMIZKMPV.js +0 -1
  220. package/dist/m/c-CJZSLMVQ.js +0 -1
  221. package/dist/m/c-DBE6ORSI.js +0 -1
  222. package/dist/m/c-EDHUR4MX.js +0 -1
  223. package/dist/m/c-EY6UOM54.js +0 -1
  224. package/dist/m/c-F7Y6FLPY.js +0 -1
  225. package/dist/m/c-FA4DZVTC.js +0 -1
  226. package/dist/m/c-G2BUZITR.js +0 -1
  227. package/dist/m/c-GBGWXTX4.js +0 -1
  228. package/dist/m/c-GHBCCVG4.js +0 -1
  229. package/dist/m/c-IB52R237.js +0 -1
  230. package/dist/m/c-IKWNE4R4.js +0 -1
  231. package/dist/m/c-IU3IYEU6.js +0 -1
  232. package/dist/m/c-IZ2EBRAJ.js +0 -1
  233. package/dist/m/c-KR4UW4KW.js +0 -1
  234. package/dist/m/c-KY3VJGUA.js +0 -8
  235. package/dist/m/c-LXENEBV7.js +0 -1
  236. package/dist/m/c-PPOSCAQD.js +0 -1
  237. package/dist/m/c-QQHEX6HA.js +0 -1
  238. package/dist/m/c-R7RMANJI.js +0 -9
  239. package/dist/m/c-RI4NT75Y.js +0 -1
  240. package/dist/m/c-RUJKSUQW.js +0 -1
  241. package/dist/m/c-T4VO4OCB.js +0 -1
  242. package/dist/m/c-TR6RMDQ7.js +0 -3
  243. package/dist/m/c-UKHTLWUW.js +0 -1
  244. package/dist/m/c-UNN7LWCT.js +0 -1
  245. package/dist/m/c-V7PZZRG7.js +0 -1
  246. package/dist/m/c-VL52ADDR.js +0 -1
  247. package/dist/m/c-WWDBYXLK.js +0 -1
  248. package/dist/m/c-XNNW2CWK.js +0 -1
  249. package/dist/m/c-Z4VRC7GU.js +0 -7
  250. package/dist/m/c-Z6K52OLP.js +0 -19
  251. package/dist/m/c-ZDY2MDAA.js +0 -21
  252. package/dist/m/core/admin-attrs-VGNLAUDK.js +0 -1
  253. package/dist/m/core/autosave-E4PEFPZ6.js +0 -1
  254. package/dist/m/core/conflict-notice-VQ6Y64PH.js +0 -1
  255. package/dist/m/core/host-permission-XN3K2IW3.js +0 -1
  256. package/dist/m/core/is-edit-mode-JXHDVGUL.js +0 -1
  257. package/dist/m/core/recovery-TBBSICE2.js +0 -1
  258. package/dist/m/core/save-WUCHTQBE.js +0 -1
  259. package/dist/m/core/save-core-FJVHT5M3.js +0 -1
  260. package/dist/m/core/snapshot-F3SWDZMM.js +0 -1
  261. package/dist/m/core/unsaved-warning-STKBHE6G.js +0 -1
  262. package/dist/m/events/index-23DPTUT4.js +0 -1
  263. package/dist/m/internals/index-WOVAP3RU.js +0 -1
  264. package/dist/m/lib/cache-bust-CGWT6ZJI.js +0 -1
  265. package/dist/m/loader-HXX6YNH5.js +0 -1
  266. package/dist/m/manifest-F22DEDAC.js +0 -1
  267. package/dist/m/manifest-edit-C3C7376B.js +0 -1
  268. package/dist/m/options/options-2LH76JQ3.js +0 -1
  269. package/dist/m/plugins/demo-ZIBFAA3R.js +0 -1
  270. package/dist/m/plugins/indicator-PV4YW2ID.js +0 -1
  271. package/dist/m/plugins/live-NUTTXSBS.js +0 -1
  272. package/dist/m/plugins/page-KSUH7G6J.js +0 -2
  273. package/dist/m/plugins/page-view-W73OW7WI.js +0 -1
  274. package/dist/m/plugins/people-AJJ32YCG.js +0 -1
  275. package/dist/m/plugins/sortable-YRMQX3ZU.js +0 -1
  276. package/dist/m/plugins/source-KHK5UUSK.js +0 -1
  277. package/dist/m/plugins/undo-OF2L3CXU.js +0 -1
  278. package/dist/m/plugins/upload-XSGNILUY.js +0 -1
  279. package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +0 -21
  280. package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +0 -1
  281. package/dist/m/plugins/wire-ZWVWKF76.js +0 -1
  282. package/dist/m/sync/live-sync-4AUVXVJU.js +0 -1
  283. /package/dist/m/plugins/{richclay-RC3L2F6X.js.LEGAL.txt → richclay-NRQDSWX6.js.LEGAL.txt} +0 -0
@@ -0,0 +1,32 @@
1
+ import { state } from "../vendor/prosemirror.vendor.js";
2
+
3
+ // The update hooks a loaded view part registers for its editor view. The plugin
4
+ // below calls them on every document change, so a part keeps its blocks fresh
5
+ // without the editor importing it: the part adds its hook when it installs and
6
+ // removes it on dispose.
7
+ export const viewHooks = new WeakMap();
8
+
9
+ // The feature loader of each mounted editor, so an insert path can ask for a
10
+ // block's part before it draws the block.
11
+ export const viewFeatures = new WeakMap();
12
+
13
+ export function addViewHook(view, hook) {
14
+ const hooks = viewHooks.get(view) ?? new Set();
15
+ viewHooks.set(view, hooks);
16
+ hooks.add(hook);
17
+ return () => hooks.delete(hook);
18
+ }
19
+
20
+ export function viewHookPlugin() {
21
+ return new state.Plugin({
22
+ view: (view) => ({
23
+ update: (next) => { for (const hook of viewHooks.get(next) ?? []) hook.update?.(next); },
24
+ destroy: () => {
25
+ const hooks = viewHooks.get(view);
26
+ if (!hooks) return;
27
+ for (const hook of hooks) hook.destroy?.(view);
28
+ hooks.clear();
29
+ },
30
+ }),
31
+ });
32
+ }
@@ -3,16 +3,32 @@ import { NET, createPageDoc, walk, docOf } from "./core.js";
3
3
  import { createBridge, keepViewPos } from "./bridge.js";
4
4
  import { createYProvider, entriesOf, dec } from "./provider.js";
5
5
 
6
- export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () => {}, ownedTokens = [], authorToken = null, setTimer = setTimeout, clearTimer = clearTimeout, compact, captureTimeout = 500, checkpointWithin = () => {}, requestCheckpoint = null, registerUnsavedState = null, onStopped = () => {} }) {
6
+ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () => {}, ownedTokens = [], authorToken = null, setTimer = setTimeout, clearTimer = clearTimeout, compact, captureTimeout = 500, checkpointWithin = () => {}, requestCheckpoint = null, registerUnsavedState = null, onStopped = () => {}, journal = null }) {
7
7
  for (const [, v] of entriesOf(store.value.y)) Y.applyUpdateV2(ydoc, dec(v), NET);
8
8
  const pd = createPageDoc(ydoc);
9
- const provider = createYProvider({ store, ydoc, setTimer, clearTimer, compact, onStopped });
9
+ // The journal is installed before the provider, because the provider is what sees an
10
+ // update: nothing can reach the document without passing it first. Its own code lives
11
+ // with the recovery channel and is only loaded when a page has one to journal into.
12
+ const pageJournal = journal || null;
13
+ const provider = createYProvider({
14
+ store,
15
+ ydoc,
16
+ setTimer,
17
+ clearTimer,
18
+ compact,
19
+ onStopped,
20
+ journal: { local: (u) => pageJournal?.local(u), dependency: (u) => pageJournal?.dependency(u) },
21
+ });
10
22
  const stats = { groups: 0, mismatch: 0, renders: 0, first: null };
11
23
  const bridge = createBridge({ pd, onNotice, ownedTokens, authorToken, stats, captureTimeout });
12
24
  const waiters = new Set();
13
25
  let disposed = false;
14
26
  let checksQueued = false;
15
- const unsaved = registerUnsavedState?.({ id: "page:live", isPending: () => store.pending.length > 0 || provider.reconcilePending, flush });
27
+ const unsaved = registerUnsavedState?.({
28
+ id: "page:live",
29
+ isPending: () => store.pending.length > 0 || provider.reconcilePending || Boolean(pageJournal && !pageJournal.failure && pageJournal.pending()),
30
+ flush,
31
+ });
16
32
  const changed = () => {
17
33
  unsaved?.changed();
18
34
  if (checksQueued) return;
@@ -24,7 +40,13 @@ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () =>
24
40
  };
25
41
  const offLocal = store.onLocal(() => { checkpointWithin(1500); changed(); });
26
42
  const offRebase = store.onRebase(changed);
27
- const offConfirm = store.onConfirm(changed);
43
+ const offConfirm = store.onConfirm(() => {
44
+ pageJournal?.acknowledged();
45
+ changed();
46
+ });
47
+ // Everything the journal reports while this session is the active one is unsaved state:
48
+ // a write in flight, a write the store refused, or a room that has not confirmed.
49
+ if (pageJournal) pageJournal.onChange = changed;
28
50
 
29
51
  function flush({ timeoutMs = 10_000 } = {}) {
30
52
  if (disposed) return Promise.resolve({ ok: false, code: "disposed" });
@@ -73,6 +95,7 @@ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () =>
73
95
 
74
96
  return {
75
97
  ydoc, pd, provider, bridge, stats, flush, awareness,
98
+ get journal() { return pageJournal; },
76
99
  doc: () => keepViewPos(docOf(pd, walk(pd))),
77
100
  attach: (view) => bridge.attach(view),
78
101
  dispatch: (tr) => bridge.dispatch(tr),
@@ -92,6 +115,10 @@ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () =>
92
115
  offRebase();
93
116
  offConfirm();
94
117
  unsaved?.dispose();
118
+ // Best effort, never the primary path: every append already resolved on its own
119
+ // commit, and what is left is only a write the store refused.
120
+ pageJournal?.close();
121
+ Promise.resolve(pageJournal?.channel?.flush?.()).catch(() => {});
95
122
  for (const waiter of [...waiters]) waiter.finish({ ok: false, code: "disposed" });
96
123
  bridge.dispose();
97
124
  provider.dispose();
@@ -1,5 +1,6 @@
1
1
  import { schema } from "../schema.js";
2
2
  import { entriesOfYjs, isOpaque, opaqueMark, yjsFields } from "../format-codec.js";
3
+ import { NATIVE_NAMES, nativeAttrs } from "../format-native.js";
3
4
 
4
5
  export const markName = (k) => k.split("!")[0].split("~")[0];
5
6
  export const isCancel = (k) => k.includes("!");
@@ -29,7 +30,7 @@ export function marksFromAttributes(a, blockType) {
29
30
  const marks = [];
30
31
  let unknown = null;
31
32
  for (const [n, v] of best) {
32
- if (schema.marks[n]) marks.push(schema.marks[n].create(v === true ? null : v));
33
+ if (schema.marks[n] && (!NATIVE_NAMES.has(n) || nativeAttrs(n, v))) marks.push(schema.marks[n].create(v === true ? null : v));
33
34
  else (unknown ??= {})[n] = v;
34
35
  }
35
36
  // The walk asks this of every text run on every keystroke, and almost none carry a
@@ -11,6 +11,10 @@ export const enc = (u) => toBase64(u);
11
11
  export const dec = (s) => fromBase64(s);
12
12
 
13
13
  export const PART = 12 * 1024;
14
+
15
+ // The origin a replayed journal is applied through. It is offered to the room like any
16
+ // other local work and is never journaled again: a recovery is not this tab's typing.
17
+ export const RECOVERY_ORIGIN = "page-recovery";
14
18
  const baseOf = (k) => PART_KEY.exec(k)?.[1] ?? k;
15
19
 
16
20
  export function yValueOf(ydoc) {
@@ -80,7 +84,7 @@ const utf8Bytes = (text) => (encoder ? encoder.encode(text).length : text.length
80
84
  const sameDeletes = (a, b) => Y.equalDeleteSets(Y.createDeleteSetFromStructStore(a.store), Y.createDeleteSetFromStructStore(b.store));
81
85
  const svEqual = (a, b) => { if (a.size !== b.size) return false; for (const [c, n] of a) if (b.get(c) !== n) return false; return true; };
82
86
 
83
- export function createYProvider({ store, ydoc, compact: limits = {}, setTimer = setTimeout, clearTimer = clearTimeout, onStopped = () => {}, stats = { applied: 0, offered: 0, compactions: 0 } }) {
87
+ export function createYProvider({ store, ydoc, compact: limits = {}, setTimer = setTimeout, clearTimer = clearTimeout, onStopped = () => {}, stats = { applied: 0, offered: 0, compactions: 0 }, journal = null }) {
84
88
  const initial = entriesOf(store.value.y);
85
89
  const known = new Set();
86
90
  // Set when this tab's page can no longer reach the room: "lineage" when the room holds
@@ -116,7 +120,10 @@ export function createYProvider({ store, ydoc, compact: limits = {}, setTimer =
116
120
 
117
121
  for (const [k] of initial) known.add(k);
118
122
 
123
+ // Ahead of the stop guards, and of the room's own delivery: work this tab can no longer
124
+ // offer, and work a refused offer took back, is still work a crash must not lose.
119
125
  const onUpdate = (u, origin) => {
126
+ if (origin !== NET && origin !== ORIGINS_ORIGIN && origin !== RECOVERY_ORIGIN) journal?.local(u);
120
127
  if (stopped || origin === NET || origin === ORIGINS_ORIGIN) return;
121
128
  put(nextKey("u"), u);
122
129
  };
@@ -127,7 +134,11 @@ export function createYProvider({ store, ydoc, compact: limits = {}, setTimer =
127
134
  canCompact = true;
128
135
  known.add(k);
129
136
  stats.applied++;
130
- Y.applyUpdateV2(ydoc, dec(v), NET);
137
+ const update = dec(v);
138
+ Y.applyUpdateV2(ydoc, update, NET);
139
+ // A received update can be the prerequisite of a later local insert, so the journal
140
+ // keeps it beside the work it owns, and never counts it as this tab's own.
141
+ journal?.dependency(update);
131
142
  };
132
143
 
133
144
  let wantReconcile = false;
@@ -16,9 +16,22 @@ import * as Y from "../../vendor/yjs.vendor.js";
16
16
  import { toBase64 } from "../../vendor/yjs.vendor.js";
17
17
  import { NET } from "./layout.js";
18
18
  import { blocksFromYValue } from "./decode.js";
19
- import { sameAuthority } from "../../recovery/identity.js";
20
- import { sameLineage, sharesAncestry } from "./lineage.js";
21
- import { canonicalPageBytes, containsUpdate } from "./recovery-proof.js";
19
+ import { sameAuthority, hostIdentityOf, pathIdentityOf } from "../../recovery/identity.js";
20
+ import { sameLineage, sharesAncestry, lineageOf } from "./lineage.js";
21
+ import { canonicalPageBytes, containsUpdate, digestHex, validatePageCheckpoint } from "./recovery-proof.js";
22
+ import { RECOVERY_ORIGIN } from "./provider.js";
23
+
24
+ /** The channel this adapter journals and replays: one name, one version, everywhere. */
25
+ export const PAGE_CHANNEL = "page.yjs";
26
+ export const PAGE_CHANNEL_VERSION = 1;
27
+
28
+ // When one writer's batches are merged into its capsule: past either threshold, and never
29
+ // over a record the store has not confirmed.
30
+ export const COMPACT_BATCHES = 64;
31
+ export const COMPACT_BYTES = 1024 * 1024;
32
+
33
+ /** The channel's own merge, for the binary store's compaction. */
34
+ export const mergePageUpdates = (parts) => Y.mergeUpdatesV2(parts);
22
35
 
23
36
  export class PageReplayError extends Error {
24
37
  constructor(code, message) {
@@ -125,6 +138,351 @@ export function admitCapsule({ capsule, batches = [], current } = {}) {
125
138
  const stateBytes = canonicalPageBytes(current.state);
126
139
  if (parts.base.length && !containsUpdate(stateBytes, parts.base)) fail("missing-base", "The page no longer contains the journal's base");
127
140
  if (!parts.owned.length) fail("nothing-owned", "This capsule owns no local work");
128
- if (!sharesAncestry(current.state, { v: 1, g: 0, y: { seed: toBase64(parts.owned) } })) fail("foreign-lineage", "This journal shares no ancestry with this page");
141
+ // The reconstructed page is what has to share this page's ancestry, base included:
142
+ // this tab's own inserts carry its client alone, and never the page's.
143
+ const rebuilt = merge([parts.base, parts.dependencies, parts.owned]);
144
+ if (!sharesAncestry(current.state, { v: 1, g: 0, y: { seed: toBase64(rebuilt) } })) fail("foreign-lineage", "This journal shares no ancestry with this page");
129
145
  return parts;
130
146
  }
147
+
148
+ /**
149
+ * One writer's capsules, each with the batches that belong to it.
150
+ *
151
+ * A session that died before its batches were compacted leaves them behind, so a
152
+ * capsule is only ever read together with every batch naming it, whichever session
153
+ * wrote it.
154
+ *
155
+ * @param {Array<Object>} [records] - what the channel listed
156
+ * @returns {Array<{capsule: Object, batches: Array<Object>}>}
157
+ */
158
+ export function capsulesOf(records = []) {
159
+ const capsules = new Map();
160
+ const batches = new Map();
161
+ for (const record of records || []) {
162
+ if (!record || record.supported === false) continue;
163
+ if (record.kind === "binary") {
164
+ capsules.set(identityKey(record.id), record);
165
+ continue;
166
+ }
167
+ if (record.kind !== "binary-batch" || record.capsuleId === undefined) continue;
168
+ const key = identityKey(record.capsuleId);
169
+ const list = batches.get(key) ?? [];
170
+ list.push(record);
171
+ batches.set(key, list);
172
+ }
173
+ return [...capsules.entries()].map(([key, capsule]) => ({ capsule, batches: batches.get(key) ?? [] }));
174
+ }
175
+
176
+ /**
177
+ * Apply an admitted capsule's three parts into a live document.
178
+ *
179
+ * The recovery origin is its own: the provider offers the result to the room, and the
180
+ * journal never takes it as this tab's own work, so a recovery is never journaled twice.
181
+ *
182
+ * @param {Object} doc - the Y.Doc the page is being edited in
183
+ * @param {Object} parts - an `accumulateCapsule` result
184
+ */
185
+ export function applyCapsule(doc, parts) {
186
+ for (const bytes of [parts.base, parts.dependencies, parts.owned]) {
187
+ if (bytes && bytes.length) Y.applyUpdateV2(doc, bytes, RECOVERY_ORIGIN);
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Replay every capsule this page may replay, and name the ones it may not.
193
+ *
194
+ * Nothing is applied for a capsule that fails admission: a changed authority, a changed
195
+ * generation, a base the page no longer contains or no shared ancestry is review, not a
196
+ * merge. A capsule with no owned work has nothing to restore and is skipped silently.
197
+ *
198
+ * @param {{records?: Array<Object>, current: {context: Object, state: Object}, into: Object}} options
199
+ * @returns {{replayed: Array<Object>, review: Array<Object>}}
200
+ */
201
+ export function replayJournals({ records = [], current, into } = {}) {
202
+ const replayed = [];
203
+ const review = [];
204
+ for (const entry of capsulesOf(records)) {
205
+ let parts;
206
+ try {
207
+ parts = admitCapsule({ capsule: entry.capsule, batches: entry.batches, current });
208
+ } catch (error) {
209
+ if (error?.code === "nothing-owned") continue;
210
+ review.push({ capsule: entry.capsule, code: error?.code ?? "invalid-capsule", message: error?.message ?? "This journal cannot be replayed" });
211
+ continue;
212
+ }
213
+ applyCapsule(into, parts);
214
+ replayed.push({ id: entry.capsule.id, revision: parts.revision, owned: parts.owned.length });
215
+ }
216
+ return { replayed, review };
217
+ }
218
+
219
+ /**
220
+ * The context one episode's capsules are journaled under.
221
+ *
222
+ * A host that persists a page generation names it; without one, the generation is the
223
+ * digest of the accepted base, so a page rebuilt from other bytes is another generation
224
+ * and a page that has not changed keeps its own.
225
+ *
226
+ * @param {Object} options
227
+ * @param {{host: string, documentId: string, principalId: string}} options.authority
228
+ * @param {?string} options.generation
229
+ * @param {?Uint8Array} options.base - the accepted state captured at the episode's start
230
+ * @param {number} [options.formatFloor]
231
+ * @returns {Promise<Object>}
232
+ */
233
+ export async function pageJournalContext({ authority, generation = null, base = null, formatFloor = 0 } = {}) {
234
+ const bytes = bytesOf(base) ?? new Uint8Array(0);
235
+ const token = typeof generation === "string" && generation ? generation : await digestHex(bytes);
236
+ return { authority, lineage: await lineageOf(token, bytes), formatFloor };
237
+ }
238
+
239
+ /**
240
+ * The authority this page journals under, or null when it cannot be pinned.
241
+ *
242
+ * A host that names people names the account too, and an answer without one is not a
243
+ * principal: replay waits for the next boot rather than filing work under a guess. A
244
+ * host with no people support gives its document to whoever holds it, so the document's
245
+ * own host identity is the principal there.
246
+ *
247
+ * @param {Object} [options]
248
+ * @param {?Object} [options.meta] - the host's discovery answer
249
+ * @param {?Location} [options.location]
250
+ * @param {?Element} [options.root]
251
+ * @returns {?{host: string, documentId: string, principalId: string}}
252
+ */
253
+ export function pageAuthority({ meta = null, location: where = globalThis.location, root = globalThis.document?.documentElement } = {}) {
254
+ const host = where?.origin ?? "";
255
+ if (!host) return null;
256
+ const documentId = hostIdentityOf(root)?.identity ?? pathIdentityOf(where?.pathname ?? "/").identity;
257
+ if (!documentId) return null;
258
+ const people = meta?.document?.people;
259
+ const principalId = people && typeof people === "object" ? people.me?.id ?? null : documentId;
260
+ if (!principalId) return null;
261
+ return { host, documentId, principalId };
262
+ }
263
+
264
+ const journalKey = (id) => (typeof id === "string" ? id : JSON.stringify(id));
265
+
266
+ /**
267
+ * The accepted page state an episode starts from, as the bytes its capsule keeps.
268
+ *
269
+ * @param {Object} state - a version 1 page value
270
+ * @returns {Uint8Array}
271
+ */
272
+ export const journalBase = (state) => canonicalPageBytes(state);
273
+
274
+ /**
275
+ * The page channel of one recovery service, registered once per page load.
276
+ *
277
+ * A channel is registered at its own version and nothing calls it until a page can
278
+ * actually journal: a reader and a page without a pinned identity register nothing.
279
+ *
280
+ * @param {Object} service - `clay.recovery`
281
+ * @returns {Object} the channel api
282
+ */
283
+ const channels = new WeakMap();
284
+ export function pageChannel(service) {
285
+ if (!service) return null;
286
+ const existing = channels.get(service);
287
+ if (existing) return existing;
288
+ const channel = service.register({
289
+ channel: PAGE_CHANNEL,
290
+ version: PAGE_CHANNEL_VERSION,
291
+ validateCheckpoint: validatePageCheckpoint,
292
+ merge: mergePageUpdates,
293
+ });
294
+ channels.set(service, channel);
295
+ return channel;
296
+ }
297
+
298
+ /**
299
+ * One session's journal in the shared recovery store.
300
+ *
301
+ * Every local update is handed over as it happens: the bytes and the id are fixed
302
+ * synchronously, so a buffer reused after the call cannot change what was written, and
303
+ * the append resolves only once its transaction committed. Nothing here waits for the
304
+ * room, the network or the editor. Compaction names only the records the store has
305
+ * confirmed, because an append still in flight or refused has no revision to swap and
306
+ * must keep exactly the bytes it was given.
307
+ *
308
+ * @param {Object} options
309
+ * @param {Object} options.channel - the `page.yjs` channel api
310
+ * @param {string} options.key - the document this episode belongs to
311
+ * @param {Function|Object} options.context - (base) => Promise<context>, or the context
312
+ * @param {?Uint8Array} [options.base] - the accepted state the episode started from
313
+ * @returns {Object}
314
+ */
315
+ export function createPageJournal({ channel, key, context, base = null, onChange = () => {} }) {
316
+ const records = new Map();
317
+ const waiting = [];
318
+ const inflight = new Set();
319
+ const bytesOfBase = bytesOf(base) ?? new Uint8Array(0);
320
+ let resolved = null;
321
+ let failure = null;
322
+ let batches = 0;
323
+ let batchBytes = 0;
324
+ let episode = false;
325
+ let compacting = null;
326
+ let closed = false;
327
+
328
+ const journal = {
329
+ channel,
330
+ key,
331
+ onChange,
332
+ context: () => ready,
333
+ local: (bytes) => send(bytes, "local"),
334
+ // Only while an episode is open: a peer's updates on their own are not this tab's work
335
+ // to keep, and a page that has journaled nothing owns nothing.
336
+ dependency: (bytes) => { if (episode) send(bytes, "dependency"); },
337
+ records: () => [...records.values()],
338
+ names,
339
+ pending: () => inflight.size + (failure ? 1 : 0),
340
+ get failure() { return failure; },
341
+ retire,
342
+ compact,
343
+ // An acknowledgement is metadata: it says a room holds the bytes and retires nothing.
344
+ acknowledged: () => {
345
+ if (closed || !resolved || !records.size || typeof channel.markAcknowledged !== "function") return Promise.resolve(null);
346
+ return Promise.resolve(channel.markAcknowledged({ records: names(), authority: resolved.authority })).catch(() => null);
347
+ },
348
+ close: () => {
349
+ closed = true;
350
+ waiting.length = 0;
351
+ },
352
+ };
353
+
354
+ const ready = Promise.resolve()
355
+ .then(() => (typeof context === "function" ? context(bytesOfBase) : context))
356
+ .catch(() => null)
357
+ .then((value) => {
358
+ resolved = value && value.authority ? value : null;
359
+ if (!resolved) {
360
+ closed = true;
361
+ failure = "unavailable";
362
+ waiting.length = 0;
363
+ journal.onChange();
364
+ return null;
365
+ }
366
+ for (const run of waiting.splice(0)) run(resolved);
367
+ return resolved;
368
+ });
369
+
370
+ function retire(list) {
371
+ for (const item of list || []) records.delete(journalKey(item?.id));
372
+ }
373
+
374
+ function names() {
375
+ return [...records.values()].map(({ id, revision }) => ({ id, revision }));
376
+ }
377
+
378
+ function compact() {
379
+ if (compacting || closed || !records.size || typeof channel.compact !== "function") return compacting;
380
+ const named = names();
381
+ compacting = Promise.resolve()
382
+ .then(() => channel.compact({ records: named }))
383
+ .then((receipt) => {
384
+ if (!receipt?.ok) return receipt;
385
+ retire(receipt.retired);
386
+ batches = Math.max(0, batches - (receipt.retired?.length ?? 0));
387
+ if (!batches) batchBytes = 0;
388
+ return receipt;
389
+ })
390
+ .catch(() => null)
391
+ .then((receipt) => {
392
+ compacting = null;
393
+ journal.onChange();
394
+ return receipt;
395
+ });
396
+ return compacting;
397
+ }
398
+
399
+ function write(context, bytes, origin, first) {
400
+ let answer;
401
+ try {
402
+ answer = channel.append({ key, context, update: bytes, origin, base: first ? bytesOfBase : null });
403
+ } catch (_) {
404
+ failure = "error";
405
+ journal.onChange();
406
+ return;
407
+ }
408
+ const token = {};
409
+ inflight.add(token);
410
+ Promise.resolve(answer).then(
411
+ (receipt) => {
412
+ inflight.delete(token);
413
+ if (!receipt || receipt.ok !== true) {
414
+ failure = receipt?.code ?? "error";
415
+ journal.onChange();
416
+ return;
417
+ }
418
+ failure = null;
419
+ if (!receipt.skipped) {
420
+ records.set(journalKey(receipt.id), { id: receipt.id, revision: receipt.revision, update: bytes, context });
421
+ batches += 1;
422
+ batchBytes += bytes.byteLength;
423
+ }
424
+ journal.onChange();
425
+ if (batches >= COMPACT_BATCHES || batchBytes >= COMPACT_BYTES) compact();
426
+ },
427
+ () => {
428
+ inflight.delete(token);
429
+ failure = "error";
430
+ journal.onChange();
431
+ },
432
+ );
433
+ }
434
+
435
+ function send(bytes, origin) {
436
+ if (closed || !bytes || !bytes.byteLength || typeof channel.append !== "function") return;
437
+ const first = !episode;
438
+ episode = true;
439
+ if (resolved) write(resolved, bytes, origin, first);
440
+ else waiting.push((context) => write(context, bytes, origin, first));
441
+ }
442
+
443
+ return journal;
444
+ }
445
+
446
+ /**
447
+ * This session's journal, opened from the host's own answer, or null.
448
+ *
449
+ * A host that cannot name who is writing, or a tab with no recovery service, journals
450
+ * nothing: a page that cannot pin its identity never replays across sessions.
451
+ *
452
+ * @param {Object} options
453
+ * @param {?Object} options.meta - the host's discovery answer
454
+ * @param {Object} options.service - `clay.recovery`
455
+ * @param {?Uint8Array} options.base - the accepted state captured before input
456
+ * @param {Function} [options.onChange]
457
+ * @returns {?Object}
458
+ */
459
+ export function openPageJournal({ meta = null, service = null, base = null, onChange = () => {}, location, root } = {}) {
460
+ const authority = pageAuthority({ meta, location, root });
461
+ if (!authority) return null;
462
+ const channel = pageChannel(service);
463
+ if (!channel) return null;
464
+ const generation = meta?.document?.pageState?.generation ?? null;
465
+ const formatFloor = Array.isArray(meta?.extensions) && meta.extensions.includes("page-format-v1") ? 1 : 0;
466
+ return createPageJournal({
467
+ channel,
468
+ key: [authority.host, authority.documentId].join(":"),
469
+ context: (accepted) => pageJournalContext({ authority, generation, base: accepted, formatFloor }),
470
+ base,
471
+ onChange,
472
+ });
473
+ }
474
+
475
+ /**
476
+ * Replay this page's journal once, after the authority snapshot is adopted.
477
+ *
478
+ * @param {{journal: Object, state: Object, into: Object}} options
479
+ * @returns {Promise<{replayed: Array<Object>, review: Array<Object>}>}
480
+ */
481
+ export async function replayInto({ journal, state, into } = {}) {
482
+ if (!journal) return { replayed: [], review: [] };
483
+ const context = await journal.context();
484
+ if (!context) return { replayed: [], review: [] };
485
+ const listed = await journal.channel.list();
486
+ if (!listed?.ok) return { replayed: [], review: [] };
487
+ return replayJournals({ records: listed.records, current: { context, state }, into });
488
+ }
@@ -8,7 +8,7 @@
8
8
  import { contains, parseBy, frameSet, descendants, unionRanges, formatBy } from "../live/provenance.js";
9
9
  import { createStore } from "../live/store.js";
10
10
  import { serializeLive, stampOf, parseStamp, textHash } from "../live/ops.js";
11
- import { onSnapshot } from "../core/snapshot.js";
11
+ import { capturePageState, onSnapshot } from "../core/snapshot.js";
12
12
  import { onSaveAccepted, onSaveAttempt, onSaveOutcome, isSaveInProgress } from "../core/save-core.js";
13
13
  import { savePageForce, baselineSettled, isSaveConflicted, autosaveIsSuspended } from "../core/save.js";
14
14
  import { withKeepalive } from "../core/save-core.js";
@@ -1235,11 +1235,47 @@ onSaveOutcome(({ captureId, result, attempts, sent }) => {
1235
1235
  if (plan) startHistoryRecovery(plan, { result, attempts, startedAt: Date.now() });
1236
1236
  });
1237
1237
 
1238
+ // The recovery integration's turn at an accepted save, when one journals this page.
1239
+ // Registered by page code once it holds a `page.yjs` channel, so a page that journals
1240
+ // nothing loads neither the proof builder nor the conditional client, and pays one null
1241
+ // check per save.
1242
+ let pageCheckpoints = null;
1243
+
1244
+ /**
1245
+ * Hand every accepted save to a page checkpoint integration, or null to stop.
1246
+ *
1247
+ * The integration builds the proof from the capture's pinned page state and the bytes
1248
+ * the host took; nothing here decides what is covered. Its answer is not awaited: a save
1249
+ * that succeeded is saved whether or not a journal can be retired, and a failure there
1250
+ * must never reach the save's own outcome.
1251
+ *
1252
+ * @param {?Function} install - ({capture, state, article, etag}) => unknown
1253
+ */
1254
+ export function registerPageCheckpoints(install) {
1255
+ pageCheckpoints = install;
1256
+ }
1257
+
1258
+ function offerCheckpoint(captureId, html) {
1259
+ if (!pageCheckpoints) return;
1260
+ // Read synchronously, before this capture is released: the value is the one its page
1261
+ // tag carried, and the edits that followed it are not in these bytes.
1262
+ const state = capturePageState(captureId);
1263
+ if (!state) return;
1264
+ let article = null;
1265
+ try { article = parseHtml(html).querySelector("article[data-page]")?.innerHTML ?? null; }
1266
+ catch (_) { article = null; }
1267
+ const etag = lastSeenEtag();
1268
+ Promise.resolve()
1269
+ .then(() => pageCheckpoints({ capture: String(captureId), state, article, etag }))
1270
+ .catch(() => {});
1271
+ }
1272
+
1238
1273
  // An accepted body confirms the ops of the capture that sent it, and only those.
1239
1274
  // A null context (a raw save) cannot consume a captured pin.
1240
1275
  function handleAccepted(html, context) {
1241
1276
  const captureId = context?.captureId;
1242
1277
  if (captureId === null || captureId === undefined) return;
1278
+ offerCheckpoint(captureId, html);
1243
1279
  const pin = capturePins.get(captureId);
1244
1280
  if (!pin) return;
1245
1281
  dropCapture(captureId);