@panphora/clayjs 1.15.1 → 1.16.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 (506) hide show
  1. package/README.md +4 -5
  2. package/THIRD-PARTY-NOTICES.md +16 -0
  3. package/compat/highlight-11.9.0.min.js +1213 -0
  4. package/compat/highlight-LICENSE +29 -0
  5. package/compat/journal-assets.lock.json +22 -0
  6. package/compat/overtype-2.6.1.min.js +116 -0
  7. package/compat/overtype-LICENSE +21 -0
  8. package/dist/all.js +90 -0
  9. package/dist/clay-dom.js +84 -0
  10. package/dist/clay-events.js +103 -0
  11. package/dist/clay-internals.js +85 -0
  12. package/dist/clay-options.js +93 -0
  13. package/dist/clay-router.js +98 -0
  14. package/dist/clay-ui.js +95 -0
  15. package/dist/clay-utils.js +84 -0
  16. package/dist/clay.js +146 -0
  17. package/dist/clay.standalone.js +83090 -58706
  18. package/dist/m/attrs/add-from-T5LJXFFI.js +1 -0
  19. package/dist/m/attrs/onaftersave-OKSWEJQF.js +1 -0
  20. package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +1 -0
  21. package/dist/m/attrs/save-freeze-FUP4Q4X3.js +1 -0
  22. package/dist/m/c-2AZDZWCK.js +1 -0
  23. package/dist/m/c-2JL6E75N.js +1 -0
  24. package/dist/m/c-2M73252O.js +1 -0
  25. package/dist/m/c-2SPVQSHC.js +1 -0
  26. package/dist/m/c-33HUYEDI.js +1 -0
  27. package/dist/m/c-33JJ6CBX.js +2 -0
  28. package/dist/m/c-35NBGMZB.js +1 -0
  29. package/dist/m/c-3GJ4SDE2.js +1 -0
  30. package/dist/m/c-3IQRECYS.js +1 -0
  31. package/dist/m/c-3OWDMNJR.js +2 -0
  32. package/dist/m/c-46XYM55L.js +1 -0
  33. package/dist/m/c-4B6H4G5V.js +1 -0
  34. package/dist/m/c-4DRMS4QG.js +9 -0
  35. package/dist/m/c-4FFHZM32.js +1 -0
  36. package/dist/m/c-4JZDZFF6.js +1 -0
  37. package/dist/m/c-4L3BTFRK.js +1 -0
  38. package/dist/m/c-4Q2XTQ2N.js +2 -0
  39. package/dist/m/c-4WKFKEKF.js +1 -0
  40. package/dist/m/c-4YSCRICO.js +1 -0
  41. package/dist/m/c-54RCDCBU.js +1 -0
  42. package/dist/m/c-5M4YEKCX.js +4 -0
  43. package/dist/m/c-5R4PTRG2.js +1 -0
  44. package/dist/m/c-5V3MOVJT.js +1 -0
  45. package/dist/m/c-5VYOWDTI.js +1 -0
  46. package/dist/m/c-5Y6TNX5W.js +34 -0
  47. package/dist/m/c-5YIHJ3TJ.js +1 -0
  48. package/dist/m/c-66O32MV3.js +28 -0
  49. package/dist/m/c-6HMB24EV.js +1 -0
  50. package/dist/m/c-7DJDKPGL.js +1 -0
  51. package/dist/m/c-7O6AFR65.js +1 -0
  52. package/dist/m/c-7SWMYKAI.js +1 -0
  53. package/dist/m/c-7VGZ7RU5.js +1 -0
  54. package/dist/m/c-A7LS5UZW.js +1 -0
  55. package/dist/m/c-ANQTJ3B5.js +2 -0
  56. package/dist/m/c-AQLFEATB.js +1 -0
  57. package/dist/m/c-AYVOREBE.js +1 -0
  58. package/dist/m/c-B2ZXPOS3.js +3 -0
  59. package/dist/m/c-BBVGTQG7.js +1 -0
  60. package/dist/m/c-BMIZKMPV.js +1 -0
  61. package/dist/m/c-BTDI4Y23.js +1 -0
  62. package/dist/m/c-BWFVSBDF.js +1 -0
  63. package/dist/m/c-BYTKL6UX.js +1 -0
  64. package/dist/m/c-CFHW5AMY.js +6 -0
  65. package/dist/m/c-CJZSLMVQ.js +1 -0
  66. package/dist/m/c-CYWLPPUS.js +1 -0
  67. package/dist/m/c-CZUSOB7V.js +1 -0
  68. package/dist/m/c-DBE6ORSI.js +1 -0
  69. package/dist/m/c-DCFSEJZG.js +1 -0
  70. package/dist/m/c-DNZ5IFM6.js +1 -0
  71. package/dist/m/c-EDHUR4MX.js +1 -0
  72. package/dist/m/c-EJTGYSLN.js +1 -0
  73. package/dist/m/c-EY6UOM54.js +1 -0
  74. package/dist/m/c-F3CKJTJS.js +1 -0
  75. package/dist/m/c-F7Y6FLPY.js +1 -0
  76. package/dist/m/c-FA4DZVTC.js +1 -0
  77. package/dist/m/c-FILLTJA7.js +1 -0
  78. package/dist/m/c-G2BUZITR.js +1 -0
  79. package/dist/m/c-GB3JTV6E.js +1 -0
  80. package/dist/m/c-GBGWXTX4.js +1 -0
  81. package/dist/m/c-GHBCCVG4.js +1 -0
  82. package/dist/m/c-GJAEGGXY.js +1 -0
  83. package/dist/m/c-GOWBDM6J.js +1 -0
  84. package/dist/m/c-GQ6F6IDK.js +1 -0
  85. package/dist/m/c-H4LG442Q.js +1 -0
  86. package/dist/m/c-HA6INT6J.js +1 -0
  87. package/dist/m/c-HPURYVZS.js +2 -0
  88. package/dist/m/c-HRDCD3WN.js +67 -0
  89. package/dist/m/c-HT33EQRN.js +1 -0
  90. package/dist/m/c-HVYXIS72.js +1 -0
  91. package/dist/m/c-IB52R237.js +1 -0
  92. package/dist/m/c-IJUK2GPW.js +1 -0
  93. package/dist/m/c-IKWNE4R4.js +1 -0
  94. package/dist/m/c-IU3IYEU6.js +1 -0
  95. package/dist/m/c-IZ2EBRAJ.js +1 -0
  96. package/dist/m/c-J7CA54IP.js +1 -0
  97. package/dist/m/c-JAX2667S.js +6 -0
  98. package/dist/m/c-KI7M4AY3.js +1 -0
  99. package/dist/m/c-KIPQJX4B.js +1 -0
  100. package/dist/m/c-KKWLDOPM.js +1 -0
  101. package/dist/m/c-KR4UW4KW.js +1 -0
  102. package/dist/m/c-KY3VJGUA.js +8 -0
  103. package/dist/m/c-L4WQSC27.js +1 -0
  104. package/dist/m/c-LFPHCXY3.js +1 -0
  105. package/dist/m/c-LR4CGEPT.js +91 -0
  106. package/dist/m/c-LRPUO2MX.js +1 -0
  107. package/dist/m/c-LTGMXXKO.js +1 -0
  108. package/dist/m/c-LXENEBV7.js +1 -0
  109. package/dist/m/c-LZJPNR2W.js +1 -0
  110. package/dist/m/c-MMUIUUZG.js +2 -0
  111. package/dist/m/c-MTQHWQC3.js +1 -0
  112. package/dist/m/c-N326R2JD.js +1 -0
  113. package/dist/m/c-N7JPUPGC.js +1 -0
  114. package/dist/m/c-NMJRXKPC.js +8 -0
  115. package/dist/m/c-NRPJEVJS.js +1 -0
  116. package/dist/m/c-NUHP2XDB.js +1 -0
  117. package/dist/m/c-NZMNXVF6.js +1 -0
  118. package/dist/m/c-OCSZ4QGN.js +1 -0
  119. package/dist/m/c-OIBUTDXI.js +1 -0
  120. package/dist/m/c-OIDRZKAF.js +1 -0
  121. package/dist/m/c-OYTFP5QN.js +1 -0
  122. package/dist/m/c-P7KNDG34.js +1 -0
  123. package/dist/m/c-PPOSCAQD.js +1 -0
  124. package/dist/m/c-PQ5LK6QE.js +993 -0
  125. package/dist/m/c-PZ4XEPUP.js +2 -0
  126. package/dist/m/c-QHIWXHRO.js +1 -0
  127. package/dist/m/c-QHWJ5ZIJ.js +1 -0
  128. package/dist/m/c-QIX5BJYN.js +1 -0
  129. package/dist/m/c-QNKREOL6.js +1 -0
  130. package/dist/m/c-QQHEX6HA.js +1 -0
  131. package/dist/m/c-R7RMANJI.js +9 -0
  132. package/dist/m/c-RI4NT75Y.js +1 -0
  133. package/dist/m/c-RM7J7BV2.js +1 -0
  134. package/dist/m/c-RUJKSUQW.js +1 -0
  135. package/dist/m/c-RVMWGXEJ.js +1 -0
  136. package/dist/m/c-S7H2DRCB.js +1 -0
  137. package/dist/m/c-S7LK4KUE.js +1 -0
  138. package/dist/m/c-SGCCC4FW.js +25 -0
  139. package/dist/m/c-SGIA4IEQ.js +188 -0
  140. package/dist/m/c-SWRJFCNN.js +1 -0
  141. package/dist/m/c-T4VO4OCB.js +1 -0
  142. package/dist/m/c-TCCA5BZN.js +2 -0
  143. package/dist/m/c-TQEMVJCS.js +1 -0
  144. package/dist/m/c-TQZ2JYHD.js +1 -0
  145. package/dist/m/c-TR6RMDQ7.js +3 -0
  146. package/dist/m/c-TRS6TM6O.js +1 -0
  147. package/dist/m/c-UGHH4MB3.js +1 -0
  148. package/dist/m/c-UKHTLWUW.js +1 -0
  149. package/dist/m/c-UL2YUY52.js +1 -0
  150. package/dist/m/c-UNN7LWCT.js +1 -0
  151. package/dist/m/c-UXBMRDZB.js +235 -0
  152. package/dist/m/c-V7PZZRG7.js +1 -0
  153. package/dist/m/c-VAK7SG37.js +1 -0
  154. package/dist/m/c-VDSS4K3Z.js +1 -0
  155. package/dist/m/c-VFX45IZQ.js +1 -0
  156. package/dist/m/c-VHNSBD2M.js +1 -0
  157. package/dist/m/c-VKEDWYDF.js +1 -0
  158. package/dist/m/c-VL52ADDR.js +1 -0
  159. package/dist/m/c-WN5AY3OZ.js +1 -0
  160. package/dist/m/c-WNYBLIOZ.js +1 -0
  161. package/dist/m/c-WRBKEJD4.js +1 -0
  162. package/dist/m/c-WWDBYXLK.js +1 -0
  163. package/dist/m/c-WYFDLITL.js +1 -0
  164. package/dist/m/c-XN6MBKI6.js +67 -0
  165. package/dist/m/c-XNNW2CWK.js +1 -0
  166. package/dist/m/c-XPELB6E5.js +1 -0
  167. package/dist/m/c-XQXZAO7N.js +1 -0
  168. package/dist/m/c-Y6USGTS5.js +5 -0
  169. package/dist/m/c-YDH7Q3NY.js +1 -0
  170. package/dist/m/c-YG2NJYS6.js +135 -0
  171. package/dist/m/c-Z36J63PP.js +1 -0
  172. package/dist/m/c-Z4VRC7GU.js +7 -0
  173. package/dist/m/c-Z6K52OLP.js +19 -0
  174. package/dist/m/c-ZDY2MDAA.js +21 -0
  175. package/dist/m/c-ZE46QDIH.js +1 -0
  176. package/dist/m/c-ZUCOKOHT.js +1 -0
  177. package/dist/m/core/admin-attrs-VGNLAUDK.js +1 -0
  178. package/dist/m/core/autosave-E4PEFPZ6.js +1 -0
  179. package/dist/m/core/conflict-notice-VQ6Y64PH.js +1 -0
  180. package/dist/m/core/draft-mode-WSQAT67S.js +1 -0
  181. package/dist/m/core/host-permission-XN3K2IW3.js +1 -0
  182. package/dist/m/core/is-edit-mode-JXHDVGUL.js +1 -0
  183. package/dist/m/core/persist-HKHA66IY.js +3 -0
  184. package/dist/m/core/recovery-TBBSICE2.js +1 -0
  185. package/dist/m/core/save-WUCHTQBE.js +1 -0
  186. package/dist/m/core/save-core-FJVHT5M3.js +1 -0
  187. package/dist/m/core/snapshot-F3SWDZMM.js +1 -0
  188. package/dist/m/core/unsaved-warning-STKBHE6G.js +1 -0
  189. package/dist/m/dom/all-APS5ZSKP.js +1 -0
  190. package/dist/m/dom/dom-helpers-L7OSR6S4.js +1 -0
  191. package/dist/m/events/index-23DPTUT4.js +1 -0
  192. package/dist/m/internals/index-WOVAP3RU.js +1 -0
  193. package/dist/m/lib/cache-bust-CGWT6ZJI.js +1 -0
  194. package/dist/m/lib/region-policy-SB2AWVG5.js +1 -0
  195. package/dist/m/loader-HXX6YNH5.js +1 -0
  196. package/dist/m/manifest-F22DEDAC.js +1 -0
  197. package/dist/m/manifest-edit-C3C7376B.js +1 -0
  198. package/dist/m/manifest.json +3491 -0
  199. package/dist/m/options/options-2LH76JQ3.js +1 -0
  200. package/dist/m/page/editor-XUTB2HB2.js +189 -0
  201. package/dist/m/plugins/ai-edit-ZQ4CGWIP.js +6 -0
  202. package/dist/m/plugins/bind-text-NX46N5UV.js +1 -0
  203. package/dist/m/plugins/cms-HP3LSTPN.js +2293 -0
  204. package/dist/m/plugins/demo-ZIBFAA3R.js +1 -0
  205. package/dist/m/plugins/derive-UAAA4V4S.js +1 -0
  206. package/dist/m/plugins/indicator-PV4YW2ID.js +1 -0
  207. package/dist/m/plugins/live-NUTTXSBS.js +1 -0
  208. package/dist/m/plugins/menu-HCKO3DSP.js +138 -0
  209. package/dist/m/plugins/message-LQ6EDH4V.js +1 -0
  210. package/dist/m/plugins/page-KSUH7G6J.js +2 -0
  211. package/dist/m/plugins/page-view-W73OW7WI.js +1 -0
  212. package/dist/m/plugins/people-AJJ32YCG.js +1 -0
  213. package/dist/m/plugins/quickcrop-QNY3PKLD.js +26 -0
  214. package/dist/m/plugins/quickcrop-QNY3PKLD.js.LEGAL.txt +7 -0
  215. package/dist/m/plugins/richclay-RC3L2F6X.js +517 -0
  216. package/dist/m/plugins/richclay-RC3L2F6X.js.LEGAL.txt +1 -0
  217. package/dist/m/plugins/sortable-YRMQX3ZU.js +1 -0
  218. package/dist/m/plugins/source-KHK5UUSK.js +1 -0
  219. package/dist/m/plugins/undo-OF2L3CXU.js +1 -0
  220. package/dist/m/plugins/unsent-HJFLQJ2O.js +1 -0
  221. package/dist/m/plugins/upgrade-ZNYODP5Y.js +1 -0
  222. package/dist/m/plugins/upload-XSGNILUY.js +1 -0
  223. package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +21 -0
  224. package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +1 -0
  225. package/dist/m/plugins/wire-ZWVWKF76.js +1 -0
  226. package/dist/m/reader-core-ILWLXVMP.js +68 -0
  227. package/dist/m/route/route-KOCZSEGN.js +1 -0
  228. package/dist/m/sync/live-sync-4AUVXVJU.js +1 -0
  229. package/dist/m/ui/index-EVHKMKBL.js +1 -0
  230. package/dist/m/utils/index-4TJCVLT7.js +1 -0
  231. package/dist/m/vendor/Sortable.vendor-A755ITLN.js +3 -0
  232. package/dist/m/vendor/Sortable.vendor-A755ITLN.js.LEGAL.txt +1 -0
  233. package/dist/m/vendor/sap-core.vendor-A7BMUYZT.js +6 -0
  234. package/dist/offline-manifest.json +3800 -0
  235. package/dist/page/themes/light-1.css +406 -0
  236. package/dist/server/core.cjs +23662 -22860
  237. package/dist/server/offline-runtime.cjs +4954 -0
  238. package/dist/server/offline-runtime.mjs +4923 -0
  239. package/dist/server/rooms.mjs +9 -0
  240. package/docs/guide.md +1078 -669
  241. package/entries/all.js +2 -0
  242. package/entries/clay-data.js +1 -1
  243. package/entries/clay-dom.js +2 -0
  244. package/entries/clay-events.js +2 -0
  245. package/entries/clay-internals.js +2 -0
  246. package/entries/clay-options.js +2 -0
  247. package/entries/clay-router.js +2 -0
  248. package/entries/clay-ui.js +2 -0
  249. package/entries/clay-utils.js +2 -0
  250. package/entries/clay.js +2 -0
  251. package/entries/sap.js +6 -6
  252. package/fonts/ibm-plex-mono-LICENSE +93 -0
  253. package/fonts/ibm-plex-mono-latin-300-normal.woff2 +0 -0
  254. package/fonts/ibm-plex-mono-latin-400-italic.woff2 +0 -0
  255. package/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  256. package/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
  257. package/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  258. package/fonts/ibm-plex-sans-LICENSE +93 -0
  259. package/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
  260. package/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
  261. package/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
  262. package/fonts/ibm-plex.css +71 -0
  263. package/fonts/inter-LICENSE +93 -0
  264. package/fonts/inter-cyrillic-ext-wght-normal.woff2 +0 -0
  265. package/fonts/inter-cyrillic-wght-normal.woff2 +0 -0
  266. package/fonts/inter-greek-ext-wght-normal.woff2 +0 -0
  267. package/fonts/inter-greek-wght-normal.woff2 +0 -0
  268. package/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  269. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  270. package/fonts/inter-vietnamese-wght-normal.woff2 +0 -0
  271. package/fonts/inter.css +62 -0
  272. package/fonts/newsreader-LICENSE +93 -0
  273. package/fonts/newsreader-latin-ext-opsz-normal.woff2 +0 -0
  274. package/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  275. package/fonts/newsreader-vietnamese-opsz-normal.woff2 +0 -0
  276. package/fonts/newsreader.css +26 -0
  277. package/fonts/work-sans-LICENSE +93 -0
  278. package/fonts/work-sans-latin-wght-normal.woff2 +0 -0
  279. package/fonts/work-sans.css +8 -0
  280. package/package.json +20 -3
  281. package/packed-contract.json +1 -0
  282. package/src/attrs/add-from.js +27 -4
  283. package/src/attrs/save-freeze.js +7 -52
  284. package/src/core/admin-attrs.js +119 -12
  285. package/src/core/admin-contenteditable.js +12 -10
  286. package/src/core/admin-inputs.js +14 -15
  287. package/src/core/admin-onclick.js +2 -0
  288. package/src/core/autosave.js +74 -21
  289. package/src/core/conflict-download.js +22 -0
  290. package/src/core/conflict-notice-ui.js +1173 -0
  291. package/src/core/conflict-notice.js +140 -697
  292. package/src/core/edit-mode.js +147 -14
  293. package/src/core/etag.js +30 -12
  294. package/src/core/host-meta.js +72 -8
  295. package/src/core/host-permission.js +29 -0
  296. package/src/core/is-edit-mode.js +145 -12
  297. package/src/core/mode-key.js +125 -0
  298. package/src/core/mode-lifecycle.js +544 -0
  299. package/src/core/mode-receipt.js +30 -0
  300. package/src/core/mode-refusal.js +13 -0
  301. package/src/core/mode-resources.js +52 -0
  302. package/src/core/mode-support.js +82 -0
  303. package/src/core/mode-switch.js +259 -0
  304. package/src/core/mode-transition.js +112 -0
  305. package/src/core/page-data-write.js +305 -0
  306. package/src/core/page-data.js +26 -227
  307. package/src/core/page-format-wire.js +4 -0
  308. package/src/core/page-format.js +58 -0
  309. package/src/core/persist.js +37 -9
  310. package/src/core/preserve-authored.js +114 -0
  311. package/src/core/recovery.js +772 -0
  312. package/src/core/runtime-url.js +105 -0
  313. package/src/core/save-core.js +109 -12
  314. package/src/core/save-replace.js +72 -0
  315. package/src/core/save-size-notice.js +134 -0
  316. package/src/core/save-size.js +140 -0
  317. package/src/core/save.js +805 -118
  318. package/src/core/snapshot.js +121 -11
  319. package/src/core/source-map.js +157 -9
  320. package/src/core/unsaved-warning.js +14 -4
  321. package/src/core/upgrade-run.js +408 -0
  322. package/src/core/version.js +18 -0
  323. package/src/lib/assets.js +30 -0
  324. package/src/lib/autosave-debug-gate.js +50 -0
  325. package/src/lib/autosave-scheduler.js +141 -0
  326. package/src/lib/boot-imports.js +8 -0
  327. package/src/lib/derived.js +3 -1
  328. package/src/lib/dirty-gate.js +15 -0
  329. package/src/lib/doc-storage.js +33 -0
  330. package/src/lib/effect.js +9 -4
  331. package/src/lib/mutation.js +4 -2
  332. package/src/lib/offline-runtime.js +226 -0
  333. package/src/lib/preload.js +48 -0
  334. package/src/lib/root-attrs.js +1 -1
  335. package/src/lib/trash.js +18 -0
  336. package/src/lib/undo/capture-move.js +152 -0
  337. package/src/live/local-channel.js +61 -0
  338. package/src/live/local-lane.js +60 -9
  339. package/src/live/server-lane.js +6 -3
  340. package/src/loader-logic.js +76 -15
  341. package/src/loader.js +218 -30
  342. package/src/page/adopt-boot.js +76 -22
  343. package/src/page/adopt.js +25 -4
  344. package/src/page/awareness.js +230 -0
  345. package/src/page/blocks.js +79 -5
  346. package/src/page/blocksel.js +20 -4
  347. package/src/page/board-face.js +13 -0
  348. package/src/page/board-panel.js +5 -17
  349. package/src/page/board-style.js +2 -13
  350. package/src/page/board-view.js +1 -11
  351. package/src/page/board-views.js +17 -0
  352. package/src/page/board.js +2 -2
  353. package/src/page/boot-pack.js +26 -0
  354. package/src/page/boot.js +237 -0
  355. package/src/page/checklist.js +1 -1
  356. package/src/page/chrome.js +411 -61
  357. package/src/page/csv-import.js +17 -2
  358. package/src/page/document-title-dom.js +40 -0
  359. package/src/page/document-title-runtime.js +76 -0
  360. package/src/page/document-title.js +9 -0
  361. package/src/page/drag.js +48 -13
  362. package/src/page/drop.js +2 -1
  363. package/src/page/editor.js +330 -86
  364. package/src/page/feature-loader.js +72 -0
  365. package/src/page/features/board.js +12 -0
  366. package/src/page/features/code.js +13 -0
  367. package/src/page/features/media.js +290 -0
  368. package/src/page/features/registry.js +23 -0
  369. package/src/page/features/table.js +383 -0
  370. package/src/page/{tool-view.js → features/tool.js} +33 -2
  371. package/src/page/format-codec.js +215 -0
  372. package/src/page/format-gate.js +179 -0
  373. package/src/page/format-level.js +157 -0
  374. package/src/page/highlight-slot.js +23 -0
  375. package/src/page/highlight.js +30 -16
  376. package/src/page/history-intent.js +78 -0
  377. package/src/page/inline.js +18 -25
  378. package/src/page/items.js +52 -11
  379. package/src/page/link.js +1 -0
  380. package/src/page/markdown.js +86 -1
  381. package/src/page/media-views.js +3 -0
  382. package/src/page/media.js +33 -245
  383. package/src/page/outline-cache.js +11 -0
  384. package/src/page/placeholder.js +27 -7
  385. package/src/page/projection.js +196 -50
  386. package/src/page/reader-patch.js +306 -0
  387. package/src/page/recover.js +1 -1
  388. package/src/page/rich/page-tool.js +1 -4
  389. package/src/page/runtime-owner.js +27 -0
  390. package/src/page/save-projection.js +7 -4
  391. package/src/page/schema.js +14 -0
  392. package/src/page/table-rect-key.js +3 -0
  393. package/src/page/table-select.js +4 -3
  394. package/src/page/table-style.js +1 -1
  395. package/src/page/table-views.js +3 -0
  396. package/src/page/table.js +8 -293
  397. package/src/page/themes/light-1.css +139 -2
  398. package/src/page/themes/light-1.source.css +269 -0
  399. package/src/page/tool-capability.js +3 -0
  400. package/src/page/tool-views.js +3 -0
  401. package/src/page/view.js +390 -107
  402. package/src/page/ybind/awareness.js +172 -0
  403. package/src/page/ybind/binding.js +9 -1
  404. package/src/page/ybind/bridge.js +50 -47
  405. package/src/page/ybind/decode.js +52 -19
  406. package/src/page/ybind/doc.js +27 -2
  407. package/src/page/ybind/group.js +12 -3
  408. package/src/page/ybind/index.js +2 -0
  409. package/src/page/ybind/lineage.js +71 -0
  410. package/src/page/ybind/marks.js +25 -1
  411. package/src/page/ybind/migrate.js +48 -13
  412. package/src/page/ybind/moves.js +32 -21
  413. package/src/page/ybind/positions.js +50 -0
  414. package/src/page/ybind/provider.js +84 -22
  415. package/src/page/ybind/recovery-proof.js +191 -0
  416. package/src/page/ybind/recovery-replay.js +130 -0
  417. package/src/page/ybind/tokens.js +15 -6
  418. package/src/page/ybind/walk.js +10 -3
  419. package/src/page/ybind/writer.js +48 -4
  420. package/src/plugins/ai-edit.js +167 -32
  421. package/src/plugins/bind-text.js +218 -0
  422. package/src/plugins/cms.js +93 -0
  423. package/src/plugins/indicator.js +57 -5
  424. package/src/plugins/live.js +282 -34
  425. package/src/plugins/menu/claygoo.svg.js +1 -0
  426. package/src/plugins/menu/seo-head.js +323 -0
  427. package/src/plugins/menu/seo.js +849 -0
  428. package/src/plugins/menu.js +425 -0
  429. package/src/plugins/page-view.js +131 -0
  430. package/src/plugins/page.js +1022 -275
  431. package/src/plugins/people.js +158 -31
  432. package/src/plugins/richclay.js +119 -9
  433. package/src/plugins/sortable.js +370 -60
  434. package/src/plugins/source.js +189 -366
  435. package/src/plugins/undo.js +289 -19
  436. package/src/plugins/unsent.js +132 -0
  437. package/src/plugins/upgrade-run.js +1 -0
  438. package/src/plugins/upgrade.js +210 -0
  439. package/src/plugins/upload-task.js +571 -0
  440. package/src/plugins/upload-ui.js +265 -0
  441. package/src/plugins/upload.js +34 -153
  442. package/src/plugins/visitor-drafts/inert.js +32 -0
  443. package/src/plugins/visitor-drafts/owner-chip.js +3 -0
  444. package/src/plugins/visitor-drafts/prompt.js +2 -2
  445. package/src/plugins/visitor-drafts-owner.js +85 -0
  446. package/src/plugins/visitor-drafts.js +31 -42
  447. package/src/plugins/wire.js +82 -1
  448. package/src/reader-core.js +26 -0
  449. package/src/recovery/conflicts.js +287 -0
  450. package/src/recovery/identity.js +154 -0
  451. package/src/recovery/store.js +792 -0
  452. package/src/recovery/undo.js +257 -0
  453. package/src/server/checkpoint/index.js +68 -4
  454. package/src/server/offline-runtime/index.js +214 -0
  455. package/src/server/rooms/index.js +2 -1
  456. package/src/server/rooms/live-data-rooms.js +2 -2
  457. package/src/server/rooms/page-admission.js +33 -0
  458. package/src/server/rooms/page-lineage.js +56 -0
  459. package/src/server/rooms/page-rebase.js +11 -5
  460. package/src/server/rooms/page-state.js +2 -0
  461. package/src/server/rooms/page-write-admission.js +9 -3
  462. package/src/source/session.js +705 -0
  463. package/src/sync/conflict-revert.js +5 -4
  464. package/src/sync/conflicts-edit.js +119 -0
  465. package/src/sync/conflicts-facade.js +49 -0
  466. package/src/sync/conflicts.js +99 -1
  467. package/src/sync/editing-marker.js +37 -6
  468. package/src/sync/editing-sender.js +8 -1
  469. package/src/sync/live-sync-edit.js +63 -0
  470. package/src/sync/live-sync.js +364 -119
  471. package/src/sync/local-presence.js +187 -0
  472. package/src/sync/merge-declarations.js +10 -0
  473. package/src/sync/merge-engine.js +12 -0
  474. package/src/sync/presence-store.js +412 -0
  475. package/src/sync/presence-target.js +243 -0
  476. package/src/sync/presence.js +72 -41
  477. package/src/sync/saved-handoff.js +200 -0
  478. package/src/sync/saved-stream.js +535 -0
  479. package/src/sync/version-guard.js +92 -0
  480. package/src/sync/wire-profile.js +81 -0
  481. package/src/ui/bevel-controls.js +2 -8
  482. package/src/ui/page-scheme.js +7 -0
  483. package/src/ui/skins/cms.js +1 -1
  484. package/src/ui/theme-config.js +150 -0
  485. package/src/ui/theme.js +6 -138
  486. package/src/ui/toast-lazy.js +11 -0
  487. package/src/vendor/LICENSES-optional.md +253 -0
  488. package/src/vendor/highlight.vendor.js +3 -0
  489. package/src/vendor/hljs-bash.vendor.js +2 -0
  490. package/src/vendor/hljs-css.vendor.js +2 -0
  491. package/src/vendor/hljs-javascript.vendor.js +2 -0
  492. package/src/vendor/hljs-json.vendor.js +2 -0
  493. package/src/vendor/hljs-python.vendor.js +2 -0
  494. package/src/vendor/hljs-xml.vendor.js +2 -0
  495. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  496. package/src/vendor/hyper-html-api-upgrade.vendor.js +5 -0
  497. package/src/vendor/hyper-morph-identity.vendor.js +2 -0
  498. package/src/vendor/hyper-morph-json.vendor.js +3 -0
  499. package/src/vendor/hyper-morph.vendor.js +6 -6
  500. package/src/vendor/hyper-undo.vendor.js +1 -1
  501. package/src/vendor/hypercms.vendor.js +169 -84
  502. package/src/vendor/marked.vendor.js +68 -0
  503. package/src/vendor/overtype.vendor.js +994 -0
  504. package/src/vendor/richclay.vendor.js +17 -17
  505. package/src/vendor/sap-core.vendor.js +5 -5
  506. package/src/vendor/sap-ownership.vendor.js +11 -7
@@ -0,0 +1,105 @@
1
+ import { setCloneCanonicalizer } from "./snapshot.js";
2
+ import {
3
+ LOCAL_PREFIX,
4
+ canonicalizeRuntimeURL,
5
+ parseRuntimeURL,
6
+ } from "../lib/offline-runtime.js";
7
+
8
+ // The release's exact aliases, from compat/journal-assets.lock.json: the only foreign URLs a
9
+ // saved page may get back for a local runtime address. A unit test keeps the two equal.
10
+ export const RUNTIME_ALIASES = Object.freeze({
11
+ "https://cdn.jsdelivr.net/npm/overtype@2.6.1/dist/overtype.min.js": "compat/overtype-2.6.1.min.js",
12
+ "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js": "compat/highlight-11.9.0.min.js",
13
+ "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,300;0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Sans:wght@400;500;600&display=swap": "fonts/ibm-plex.css",
14
+ });
15
+
16
+ export const RUNTIME_MAP_ID = "clay-runtime-map";
17
+
18
+ const RUNTIME_REL = new Set(["stylesheet", "preload", "modulepreload"]);
19
+
20
+ let cachedText = null;
21
+ let cachedDescriptor = null;
22
+
23
+ function parseDescriptor(text) {
24
+ let value;
25
+ try {
26
+ value = JSON.parse(text);
27
+ } catch {
28
+ return null;
29
+ }
30
+ if (!value || typeof value !== "object" || value.schema !== 1) return null;
31
+ return value;
32
+ }
33
+
34
+ export function runtimeDescriptor() {
35
+ if (typeof document === "undefined") return null;
36
+ const element = document.getElementById(RUNTIME_MAP_ID);
37
+ if (!element) return null;
38
+ const text = element.textContent ?? "";
39
+ if (text === cachedText) return cachedDescriptor;
40
+ cachedText = text;
41
+ cachedDescriptor = parseDescriptor(text);
42
+ return cachedDescriptor;
43
+ }
44
+
45
+ const RUNTIME_SELECTOR = `script[src*="${LOCAL_PREFIX}"], link[href*="${LOCAL_PREFIX}"], #${RUNTIME_MAP_ID}`;
46
+
47
+ // Native selector first: this runs on every capture, dirty checks included, and almost every page
48
+ // has nothing to canonicalize.
49
+ function holdsRuntime(root) {
50
+ if (root.querySelector(RUNTIME_SELECTOR)) return true;
51
+ for (const template of root.querySelectorAll("template")) {
52
+ if (holdsRuntime(template.content)) return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function elements(root) {
58
+ const found = [];
59
+ const stack = [root];
60
+ while (stack.length) {
61
+ const node = stack.pop();
62
+ for (const child of node.children ?? []) {
63
+ found.push(child);
64
+ stack.push(child);
65
+ if (child.content) stack.push(child.content);
66
+ }
67
+ }
68
+ return found;
69
+ }
70
+
71
+ function candidateAttribute(element) {
72
+ if (element.tagName === "SCRIPT" && element.hasAttribute("src")) return "src";
73
+ if (element.tagName !== "LINK" || !element.hasAttribute("href")) return null;
74
+ const rel = (element.getAttribute("rel") ?? "").toLowerCase();
75
+ return rel.split(/\s+/).some((token) => RUNTIME_REL.has(token)) ? "href" : null;
76
+ }
77
+
78
+ export function canonicalizeRuntimeClone(clone) {
79
+ if (!clone || !holdsRuntime(clone)) return;
80
+
81
+ const candidates = [];
82
+ let runtime = false;
83
+ for (const element of elements(clone)) {
84
+ if (element.getAttribute("id") === RUNTIME_MAP_ID) {
85
+ element.remove();
86
+ continue;
87
+ }
88
+ const name = candidateAttribute(element);
89
+ if (!name) continue;
90
+ candidates.push({ element, name });
91
+ if (!runtime && (element.getAttribute(name) ?? "").includes(LOCAL_PREFIX)) runtime = true;
92
+ }
93
+ if (!runtime) return;
94
+
95
+ const documentURL = location.href;
96
+ const descriptor = runtimeDescriptor();
97
+ for (const { element, name } of candidates) {
98
+ const value = element.getAttribute(name);
99
+ const parsed = parseRuntimeURL(value, { documentURL });
100
+ if (!parsed || parsed.kind !== "local") continue;
101
+ element.setAttribute(name, canonicalizeRuntimeURL(value, { descriptor, documentURL, aliases: RUNTIME_ALIASES }));
102
+ }
103
+ }
104
+
105
+ setCloneCanonicalizer(canonicalizeRuntimeClone);
@@ -13,11 +13,16 @@ import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/u
13
13
  import { saveToken } from "./host-attrs.js";
14
14
  import { lastSeenEtag, conditionalSaves, recordEtag } from "./etag.js";
15
15
  import { hostMeta } from "./host-meta.js";
16
+ import { saveSize, storedFile, knownSaveLimit, saveLimit, announceSaveSize } from "./save-size.js";
17
+ import { PAGE_FORMAT, PAGE_FORMAT_HEADER, knownPageFormatFloor, withPageFormatFloor } from "./page-format.js";
18
+ import "./runtime-url.js";
16
19
  import {
17
20
  getPageContents,
18
21
  onSnapshot,
19
22
  addDocumentTransform,
20
- currentCaptureId
23
+ currentCaptureId,
24
+ captureArtifact,
25
+ releaseCaptureArtifact
21
26
  } from "./snapshot.js";
22
27
 
23
28
  // =============================================================================
@@ -100,10 +105,16 @@ export function onSaveAccepted(callback) {
100
105
  saveAcceptedHooks.push(callback);
101
106
  }
102
107
 
103
- function notifySaveAccepted(html, captureId) {
108
+ function notifySaveAccepted(html, captureId, artifact = null) {
104
109
  for (const hook of saveAcceptedHooks) {
105
110
  try {
106
- hook(html, { captureId: captureId ?? null });
111
+ // The artifact is the capture's own private model of these bytes, and it is
112
+ // offered only when it exists: a raw save and a capture without one keep the
113
+ // shape they had.
114
+ const accepted = artifact
115
+ ? { captureId: captureId ?? null, artifact }
116
+ : { captureId: captureId ?? null };
117
+ hook(html, accepted);
107
118
  } catch (err) {
108
119
  console.error('clayjs: onSaveAccepted hook failed:', err);
109
120
  }
@@ -242,7 +253,7 @@ export function saveFateIsUnknown() {
242
253
  * zeroed array is the failure that would do exactly that, silently, so a missing
243
254
  * crypto implementation falls back to something random rather than to zeros.
244
255
  */
245
- function mintSaveId() {
256
+ export function mintSaveId() {
246
257
  const uuid = window.crypto?.randomUUID?.();
247
258
  if (uuid) return uuid;
248
259
  if (window.crypto?.getRandomValues) {
@@ -339,6 +350,49 @@ function skippedResult(msg) {
339
350
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
340
351
  }
341
352
 
353
+ // A body the host will not store, refused here instead of on the wire. It is an
354
+ // ordinary error result with one more fact on it, because the caller's answer to
355
+ // "too large" is the same answer it gives any failed save: keep the writing, do not
356
+ // mark it saved, and let the person shrink or download it.
357
+ function tooLargeResult(size) {
358
+ const err = new Error(`This document is ${size.bytes} bytes; this host saves at most ${size.maxBytes}`);
359
+ err.code = 'save-too-large';
360
+ return { ...errorResult(err), sent: false, size };
361
+ }
362
+
363
+ // ============================================
364
+ // THE DOCUMENT-OPERATION SLOT
365
+ // ============================================
366
+ //
367
+ // One slot for the operations that speak for the document as a whole: a mode switch's
368
+ // barrier, an upgrade, and a whole-document replacement. Two of them at once is how a
369
+ // generation gets flushed and then replaced by a later one, so the second asks and is
370
+ // told no instead of interleaving. Kinds are open strings; the slot only cares that it
371
+ // is exclusive, and that the token it hands back is the one that releases it.
372
+ let documentOperation = null;
373
+
374
+ /**
375
+ * Take the slot, or learn it is taken.
376
+ *
377
+ * @param {string} kind - 'mode' | 'replace' | 'upgrade'
378
+ * @returns {?Object} an opaque token to release with, or null when an operation holds it
379
+ */
380
+ export function claimDocumentOperation(kind) {
381
+ if (documentOperation) return null;
382
+ documentOperation = { kind };
383
+ return documentOperation;
384
+ }
385
+
386
+ /** Release the slot. Only the token that holds it does anything; idempotent. */
387
+ export function releaseDocumentOperation(token) {
388
+ if (documentOperation === token) documentOperation = null;
389
+ }
390
+
391
+ /** Which operation holds the document, or null. */
392
+ export function operationKind() {
393
+ return documentOperation ? documentOperation.kind : null;
394
+ }
395
+
342
396
  // The metadata a caller attaches to a capture was refused here, before anything
343
397
  // went out. The host's own grammar is the only definition of valid, so an
344
398
  // `invalid-header` here is the same answer a host would have given, without the
@@ -437,7 +491,7 @@ function formatConversionHeader(conversion) {
437
491
  * @param {?string} conversionHeader - the `Page-Conversion` value for this logical save, or null
438
492
  * @returns {{url: string, options: Object}}
439
493
  */
440
- function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false, conversionHeader = null) {
494
+ export function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false, conversionHeader = null) {
441
495
  const token = saveToken();
442
496
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
443
497
  const options = {
@@ -457,7 +511,11 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive =
457
511
  // gating it on discovery would mean holding the save until an async lookup
458
512
  // resolved. Nothing is inferred from the header being accepted: only a host
459
513
  // that ANSWERS with the id has said anything.
460
- 'Save-ID': saveId
514
+ 'Save-ID': saveId,
515
+ // The page format this client writes. R understands floor 1 and says so on
516
+ // every page save; a host reads it as protocol capability, never as
517
+ // authorization, and refuses a lower one before mutation.
518
+ [PAGE_FORMAT_HEADER]: String(PAGE_FORMAT)
461
519
  },
462
520
  body: content
463
521
  };
@@ -595,7 +653,11 @@ function sendOnce(content, saveId, etag, { captureId = null, conversionHeader =
595
653
  }
596
654
  if (!res.ok) {
597
655
  const error = new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
598
- error.code = data.code ?? null;
656
+ // A 413 is the host refusing these bytes for their size, whatever it called
657
+ // the refusal: the one code a caller can act on is the same one the local
658
+ // guard gives. No limit is invented from it — a refusal is not an
659
+ // announcement, and the measurement the caller sees is this attempt's own.
660
+ error.code = res.status === 413 ? 'save-too-large' : (data.code ?? null);
599
661
  error.status = res.status;
600
662
  // Spec §6: a conflict may name what moved the document. Only the host can
601
663
  // know, and it often cannot, so this rides through as-is and the notice
@@ -819,11 +881,21 @@ async function sendSave(content, { captureId = null, conversionHeader = null, at
819
881
  // One private implementation behind every entry point, so the hooks see the same
820
882
  // thing whether the bytes came from a bound capture or a raw caller. `captureId`
821
883
  // is null for a raw save: it belongs to no capture and can confirm none.
822
- function runSave(html, captureId, callback, conversion) {
884
+ function runSave(html, captureId, callback, conversion, capture = null) {
885
+ // A capture taken before the floor rose cannot drop the marker by omitting it:
886
+ // the bytes that go out carry the floor this tab last saw. At floor 0 this is
887
+ // the string itself, unchanged.
888
+ html = withPageFormatFloor(html, knownPageFormatFloor());
823
889
  return new Promise((resolve) => {
824
890
  const attempts = [];
891
+ // Read once, before the first request: the artifact belongs to the capture that
892
+ // produced these bytes, and it is released however this save settles. A timeout
893
+ // recovered through the host's receipt reaches `accepted` like any other success,
894
+ // so it adopts the artifact of the request that landed.
895
+ const artifact = captureArtifact(capture);
825
896
  const finished = (result, accepted = false) => {
826
- if (accepted) notifySaveAccepted(html, captureId ?? null);
897
+ if (accepted) notifySaveAccepted(html, captureId ?? null, artifact);
898
+ releaseCaptureArtifact(capture);
827
899
  notifySaveOutcome({
828
900
  captureId: captureId ?? null,
829
901
  result,
@@ -859,6 +931,20 @@ function runSave(html, captureId, callback, conversion) {
859
931
  return;
860
932
  }
861
933
 
934
+ // The one measurement every entry point shares: the exact string about to be
935
+ // handed to the backend, once per attempt, whichever lane asked. Not a second
936
+ // capture — these are the bytes 05b already took — and not `outerHTML`, which is
937
+ // a different string. The limit is whatever discovery last said; unknown refuses
938
+ // nothing, so a host that advertises no ceiling, or has not answered yet, gets the
939
+ // save and stays the authority on it.
940
+ saveLimit();
941
+ const size = saveSize(storedFile(html), knownSaveLimit());
942
+ announceSaveSize(size);
943
+ if (size.state === 'over') {
944
+ saveInProgress = false;
945
+ return finished(tooLargeResult(size));
946
+ }
947
+
862
948
  sendSave(html, { captureId, conversionHeader: formatted.header, attempts })
863
949
  .then(successResult, errorResult)
864
950
  // Clear the flag BEFORE handing the result back, so a caller that queues a
@@ -866,7 +952,11 @@ function runSave(html, captureId, callback, conversion) {
866
952
  // is busy by the save that just finished.
867
953
  .then(result => {
868
954
  saveInProgress = false;
869
- if (result.ok) notifySaveAccepted(html, captureId ?? null);
955
+ // A host's 413 is the same refusal the guard above makes, so it carries the
956
+ // same measurement. Its own answer about the size wins over nothing: the bytes
957
+ // are this attempt's, and the limit is still only what discovery advertised.
958
+ if (result.code === 'save-too-large' && !result.size) result.size = size;
959
+ if (result.ok) notifySaveAccepted(html, captureId ?? null, artifact);
870
960
  finished(result);
871
961
  });
872
962
  });
@@ -904,8 +994,8 @@ export function saveHtml(html, callback = () => {}) {
904
994
  * @param {Object} [options.conversion] - {source:'file', base} or {source:'room', base, epoch}
905
995
  * @returns {Promise<Object>}
906
996
  */
907
- export function saveCapturedHtml(html, captureId, callback = () => {}, { conversion } = {}) {
908
- return runSave(html, captureId, callback, conversion);
997
+ export function saveCapturedHtml(html, captureId, callback = () => {}, { conversion, capture = null } = {}) {
998
+ return runSave(html, captureId, callback, conversion, capture);
909
999
  }
910
1000
 
911
1001
  /**
@@ -936,15 +1026,22 @@ export function saveConversion({ html, captureId, conversion }) {
936
1026
  */
937
1027
  export function replacePageWith(url, callback = () => {}) {
938
1028
  return new Promise((resolve) => {
1029
+ let operation = null;
939
1030
  const done = (result) => {
1031
+ releaseDocumentOperation(operation);
940
1032
  if (typeof callback === 'function') callback(result);
941
1033
  resolve(result);
942
1034
  };
943
1035
 
944
1036
  if (!isEditMode) return done(skippedResult('Not in edit mode'));
1037
+ if (operationKind()) return done(skippedResult('Another document operation is in progress'));
945
1038
  if (saveInProgress) return done(skippedResult('Save already in progress'));
946
1039
  if (isDraftMode) return done(false);
947
1040
 
1041
+ // A replacement writes the whole document, so it holds the slot from the fetch to
1042
+ // the save: a mode switch cannot start its barrier in the middle of one.
1043
+ operation = claimDocumentOperation('replace');
1044
+
948
1045
  // Resolved against the document's real location, not a <base href> the page
949
1046
  // may have set.
950
1047
  fetch(new URL(url, window.location.href).href)
@@ -0,0 +1,72 @@
1
+ // One conditional save of bytes this page did not build: the upgrade path's write
2
+ // (plan 05, Step 6). It is deliberately not part of the save core every editor
3
+ // loads. There is no capture, no beforeSave, no snapshot and no retry: the bytes
4
+ // handed in are the whole document, already composed by the caller, and the only
5
+ // question this answers is whether the host took them.
6
+ //
7
+ // It speaks through the same request builder as every other save, so the endpoint,
8
+ // the headers and the token route are the save lane's own. `If-Match` comes from the
9
+ // caller (the stamp the page was loaded with or last saved at) and falls back to the
10
+ // tab's stamp. A host that did not announce `conditional` gets no request at all:
11
+ // without the capability the header means nothing, and a write this large is not
12
+ // worth sending to a host that cannot refuse it.
13
+ import { buildSaveRequest, mintSaveId } from "./save-core.js";
14
+ import { conditionalSaves, lastSeenEtag, recordEtag, seedEtag } from "./etag.js";
15
+
16
+ const TIMEOUT_MS = 30000;
17
+
18
+ function failed() {
19
+ return { ok: false, code: "failed" };
20
+ }
21
+
22
+ /**
23
+ * Save exactly `html` over the document, conditional on the version it replaces.
24
+ *
25
+ * @param {string} html - the whole document, as it should be stored
26
+ * @param {Object} [options]
27
+ * @param {?string} [options.etag] - the stamp to replace, defaulting to this tab's
28
+ * @returns {Promise<{ok: true, etag: ?string}|{ok: false, code: "conflict"|"forbidden"|"unsupported"|"failed"}>}
29
+ */
30
+ export async function replace(html, { etag } = {}) {
31
+ if (typeof html !== "string" || html === "") return failed();
32
+
33
+ const given = typeof etag === "string" && etag ? etag : null;
34
+ // Discovery is what says whether the capability exists, and it is also where a
35
+ // tab that has never saved learns the stamp it is about to replace.
36
+ if (!conditionalSaves()) await seedEtag();
37
+ if (!conditionalSaves()) return { ok: false, code: "unsupported" };
38
+
39
+ // With no stamp the write would be unconditional, which is exactly what this lane
40
+ // promises never to send. A reload gives the tab the stamp it is missing.
41
+ const stamp = given || lastSeenEtag();
42
+ if (!stamp) return { ok: false, code: "conflict" };
43
+ const controller = new AbortController();
44
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
45
+ try {
46
+ const { url, options } = buildSaveRequest(html, true, controller.signal, mintSaveId(), stamp);
47
+ const res = await fetch(url, options);
48
+ const text = await res.text();
49
+ let data = {};
50
+ if (text) {
51
+ try {
52
+ data = JSON.parse(text);
53
+ } catch (_) {
54
+ data = {};
55
+ }
56
+ }
57
+ if (!res.ok) {
58
+ if (res.status === 412) return { ok: false, code: "conflict" };
59
+ if (res.status === 401 || res.status === 403) return { ok: false, code: "forbidden" };
60
+ return failed();
61
+ }
62
+ const stored = typeof data.etag === "string" && data.etag ? data.etag : null;
63
+ // The write landed, so the stamp it replaced describes bytes the host no longer
64
+ // stores: the answer's own stamp is the one this tab holds from here on.
65
+ recordEtag(stored);
66
+ return { ok: true, etag: stored };
67
+ } catch (_) {
68
+ return failed();
69
+ } finally {
70
+ clearTimeout(timeoutId);
71
+ }
72
+ }
@@ -0,0 +1,134 @@
1
+ import { isEditMode } from "./is-edit-mode.js";
2
+ import { downloadStored } from "./conflict-download.js";
3
+ import { bevelSurface, bevelText, bevelButton, pageScheme, setShown } from "../ui/bevel-controls.js";
4
+ import { set } from "../lib/hostile-css.js";
5
+
6
+ // The one quiet word about how large this document is.
7
+ //
8
+ // `clay:save-size` is a measurement of a real save attempt, never a live reading, so
9
+ // every sentence here says "At the last save attempt": the number is the bytes the last
10
+ // attempt measured, and nothing claims the page is that size now. Most documents never
11
+ // reach the host's advertised limit, so save.js fetches this module only when one does,
12
+ // and it draws nothing until then.
13
+ //
14
+ // One element, updated in place: a save on a phone fires often, and a notice per save
15
+ // would be a toast storm. A later measurement below the near threshold takes it away,
16
+ // and so does leaving edit: the notice is editor chrome, not part of the page.
17
+ //
18
+ // The download is the safe copy this library already offers for the current document
19
+ // (conflict-download.js's `downloadStored`), a file openable in a browser rather than a
20
+ // save the host would refuse.
21
+
22
+ const NEAR_MESSAGE = "Download a copy, then reduce its size; saving still works.";
23
+ const OVER_MESSAGE = "Download a copy to keep your work.";
24
+
25
+ let root = null;
26
+ let sentence = null;
27
+ let copy = null;
28
+ let listeners = null;
29
+
30
+ // A size a person can read at a glance. Kilobyte precision under a megabyte, one
31
+ // decimal above it: the question is "how close is this to the limit", not the exact byte.
32
+ function readable(bytes) {
33
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return "an unknown size";
34
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
35
+ if (bytes >= 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
36
+ return `${bytes} bytes`;
37
+ }
38
+
39
+ function words({ bytes, maxBytes, state }) {
40
+ const measured = `At the last save attempt: ${readable(bytes)} of ${readable(maxBytes)}.`;
41
+ return state === "over"
42
+ ? `This host can't save a file this size. ${measured} ${OVER_MESSAGE}`
43
+ : `This file is getting large. ${measured} ${NEAR_MESSAGE}`;
44
+ }
45
+
46
+ // A phone keyboard shrinks the visual viewport but leaves fixed elements pinned to the
47
+ // layout viewport, so a bottom-anchored bar parks itself behind the keyboard. Same fix
48
+ // as the conflict notice.
49
+ function place() {
50
+ if (!root) return;
51
+ const vv = window.visualViewport;
52
+ const lift = vv ? Math.max(0, window.innerHeight - vv.height - vv.offsetTop) : 0;
53
+ set(root, "bottom", `calc(${16 + lift}px + env(safe-area-inset-bottom,0px))`);
54
+ }
55
+
56
+ // The safe copy the conflict notice offers, for the same reason: a file the person can
57
+ // open has to exist outside a host that will not store these bytes. It already reports
58
+ // its own failure.
59
+ function download() {
60
+ downloadStored({});
61
+ }
62
+
63
+ function build() {
64
+ root = bevelSurface("div", [
65
+ "position:fixed", "left:50%", "transform:translateX(-50%)",
66
+ "z-index:2147483000", "display:flex", "align-items:center", "gap:10px 12px",
67
+ "width:max-content", "max-width:calc(100vw - 24px)", "flex-wrap:wrap",
68
+ "padding:8px 8px 8px 14px", "text-align:left", `color-scheme:${pageScheme()}`,
69
+ ]);
70
+ // An injected element is in no document on disk, and `editor-ui` is the bundle that
71
+ // says so: not saved, not watched, not in any snapshot, not undoable, not data. The
72
+ // controls inside carry the same markers, as every other notice this library draws.
73
+ root.setAttribute("clay", "editor-ui");
74
+ root.setAttribute("data-clay-save-size", "");
75
+ root.setAttribute("role", "status");
76
+ sentence = bevelText("span", [], "");
77
+ copy = bevelButton("Download my copy", { small: true, onClick: download });
78
+ setShown(copy, false, "inline-flex");
79
+ root.append(sentence, copy);
80
+ document.body.appendChild(root);
81
+ place();
82
+ window.visualViewport?.addEventListener("resize", place);
83
+ window.visualViewport?.addEventListener("scroll", place);
84
+ }
85
+
86
+ function render(size) {
87
+ if (!root) build();
88
+ sentence.textContent = words(size);
89
+ // Only an oversize document offers the copy: a near one still saves, and its text
90
+ // already recommends downloading one.
91
+ setShown(copy, size.state === "over", "inline-flex");
92
+ place();
93
+ }
94
+
95
+ function clear() {
96
+ if (!root) return;
97
+ window.visualViewport?.removeEventListener("resize", place);
98
+ window.visualViewport?.removeEventListener("scroll", place);
99
+ root.remove();
100
+ root = null;
101
+ sentence = null;
102
+ copy = null;
103
+ }
104
+
105
+ function onSaveSize(event) {
106
+ const size = event?.detail;
107
+ if (!size) return;
108
+ if (size.state === "near" || size.state === "over") render(size);
109
+ else clear();
110
+ }
111
+
112
+ function onModeChange() {
113
+ if (!isEditMode) stop();
114
+ }
115
+
116
+ // announceSaveSize (save-size.js) asks for this file on the first measurement that is near or over,
117
+ // and hands it the event that asked: it is replayed here, after the listeners are on,
118
+ // exactly as if it had arrived on a page that already held the notice.
119
+ export function start(trigger) {
120
+ if (!isEditMode) return;
121
+ listeners ||= new AbortController();
122
+ document.addEventListener("clay:save-size", onSaveSize, { signal: listeners.signal });
123
+ document.addEventListener("clay:modechange", onModeChange, { signal: listeners.signal });
124
+ if (trigger?.type === "clay:save-size") onSaveSize(trigger);
125
+ }
126
+
127
+ // A committed exit takes the notice with it: the handlers, the node it drew and the
128
+ // measurement it was drawing. A later entry starts again from whatever the next save
129
+ // measures.
130
+ export function stop() {
131
+ listeners?.abort();
132
+ listeners = null;
133
+ clear();
134
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * save-size.js — how large a save's exact bytes are, and the host's own ceiling (§9).
3
+ *
4
+ * `document.maxBytes` in discovery is the one place a host states what it will store:
5
+ * the maximum uncompressed UTF-8 size of the HTML save body, inclusive. Only a
6
+ * positive safe integer is an answer; missing, zero, negative, fractional and
7
+ * nonnumeric all mean "unknown". Unknown refuses nothing: the save goes out and the
8
+ * server stays authoritative. Nothing here guesses a ceiling from a hostname,
9
+ * compression or a browser storage quota, and `document.upload.maxBytes` is a
10
+ * different promise about different bytes.
11
+ */
12
+
13
+ import { hostMeta } from "./host-meta.js";
14
+
15
+ // The limit the last discovery answer announced. Memoized beside `hostMeta`'s own
16
+ // memoized promise, because a save reads it synchronously: the request a person feels
17
+ // is the save, and holding it for a discovery round trip would make a first save wait
18
+ // out a timeout on a host that has no `/_/meta` at all. Until an answer lands the
19
+ // limit is unknown, which is the honest reading and refuses nothing.
20
+ let limit = null;
21
+ let measures = null;
22
+ let asked = null;
23
+
24
+ function advertised(meta) {
25
+ const value = meta?.document?.maxBytes;
26
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
27
+ }
28
+
29
+ function adopt(meta) {
30
+ limit = advertised(meta);
31
+ measures = meta?.document?.maxBytesMeasures === 'file' ? 'file' : null;
32
+ return limit;
33
+ }
34
+
35
+ // A Document's live state rides in one <script merge="page"> tag that Hyperclay
36
+ // stores apart from the file, and its limit (`maxBytesMeasures: 'file'`) is judged on
37
+ // the body without that tag, the way the host's splitPage() cuts it: the whole line
38
+ // when the tag stands alone on it, otherwise the tag itself. Where the host would not
39
+ // split (no tag, two tags), the cut here can only make the count smaller, so the
40
+ // host's own 413 stays the answer and nothing is refused that the host would store.
41
+ const PAGE_TAG = /<script\b[^>]*\bmerge=(["'])page\1[^>]*>[\s\S]*?<\/script\s*>/i;
42
+
43
+ export function storedFile(html, measure = measures) {
44
+ if (measure !== 'file' || typeof html !== 'string') return html;
45
+ const match = PAGE_TAG.exec(html);
46
+ if (!match) return html;
47
+ let start = match.index;
48
+ let end = start + match[0].length;
49
+ const from = html.lastIndexOf('\n', start - 1) + 1;
50
+ const newline = html.indexOf('\n', end);
51
+ const to = newline === -1 ? html.length : newline + 1;
52
+ if (!/\S/.test(html.slice(from, start)) && !/\S/.test(html.slice(end, to))) { start = from; end = to; }
53
+ return html.slice(0, start) + html.slice(end);
54
+ }
55
+
56
+ /**
57
+ * How big these exact bytes are, against a limit that may not be known.
58
+ *
59
+ * @param {string} html - the bytes a save would send
60
+ * @param {number} [maxBytes] - the host's advertised ceiling, if it advertised one
61
+ * @returns {{bytes: number, maxBytes: ?number, state: 'unknown'|'ok'|'near'|'over'}}
62
+ */
63
+ export function saveSize(html, maxBytes) {
64
+ const bytes = new TextEncoder().encode(html).byteLength;
65
+ const known = Number.isSafeInteger(maxBytes) && maxBytes > 0;
66
+ return {
67
+ bytes, maxBytes: known ? maxBytes : null,
68
+ state: !known ? 'unknown' : bytes > maxBytes ? 'over' :
69
+ bytes >= Math.ceil(maxBytes * 0.8) ? 'near' : 'ok'
70
+ };
71
+ }
72
+
73
+ /**
74
+ * The limit the last discovery answer advertised, without asking the host.
75
+ *
76
+ * @returns {?number}
77
+ */
78
+ export function knownSaveLimit() {
79
+ return limit;
80
+ }
81
+
82
+ /**
83
+ * Ask the host what it accepts, once per page.
84
+ *
85
+ * Joins the memoized discovery answer, so this never costs a second request. `fresh`
86
+ * asks again, for a caller that wants the ceiling as it stands now.
87
+ *
88
+ * @param {Object} [options]
89
+ * @param {boolean} [options.fresh]
90
+ * @returns {Promise<?number>} the advertised limit, or null when it announced none
91
+ */
92
+ export function saveLimit({ fresh = false } = {}) {
93
+ if (fresh) {
94
+ asked = hostMeta({ fresh: true }).then(adopt);
95
+ return asked;
96
+ }
97
+ if (!asked) asked = hostMeta().then(adopt);
98
+ return asked;
99
+ }
100
+
101
+ /**
102
+ * `clay.save.limits`: the host's save ceiling, and whether it said one at all.
103
+ *
104
+ * @param {Object} [options]
105
+ * @param {boolean} [options.fresh]
106
+ * @returns {Promise<{maxBytes: ?number}>}
107
+ */
108
+ export function saveLimits({ fresh = false } = {}) {
109
+ return saveLimit({ fresh }).then((maxBytes) => ({ maxBytes }));
110
+ }
111
+
112
+ /**
113
+ * `clay.save.inspect`: measure bytes that are not this page's document.
114
+ *
115
+ * No capture, no send, no baseline, no save claim, and nothing said to anybody about
116
+ * what this page currently holds: an import preview asks about candidate bytes.
117
+ *
118
+ * @param {string} html
119
+ * @returns {Promise<{bytes: number, maxBytes: ?number, state: string}>}
120
+ */
121
+ export async function inspectSaveSize(html) {
122
+ const maxBytes = await saveLimit();
123
+ return saveSize(storedFile(html), maxBytes);
124
+ }
125
+
126
+ /**
127
+ * Say what the last real save attempt measured, on the document.
128
+ *
129
+ * @param {{bytes: number, maxBytes: ?number, state: string}} size
130
+ */
131
+ export function announceSaveSize(size) {
132
+ const event = new CustomEvent('clay:save-size', { detail: size });
133
+ document.dispatchEvent(event);
134
+ // The notice is fetched only by a measurement near or over the ceiling, so most edit
135
+ // boots never download it; once loaded it listens for itself, and this replay of the
136
+ // event that fetched it only redraws in place.
137
+ if (size.state === 'near' || size.state === 'over') {
138
+ import('./save-size-notice.js').then((notice) => notice.start(event), () => {});
139
+ }
140
+ }