@panphora/clayjs 1.18.1 → 1.20.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 (180) hide show
  1. package/README.md +1 -1
  2. package/dist/all.js +1 -1
  3. package/dist/clay-dom.js +1 -1
  4. package/dist/clay-events.js +1 -1
  5. package/dist/clay-internals.js +1 -1
  6. package/dist/clay-options.js +1 -1
  7. package/dist/clay-router.js +1 -1
  8. package/dist/clay-ui.js +1 -1
  9. package/dist/clay-utils.js +1 -1
  10. package/dist/clay.js +1 -1
  11. package/dist/clay.standalone.js +8773 -8444
  12. package/dist/m/attrs/{refetch-on-save-ZUE7PSCI.js → refetch-on-save-DPGJJMGN.js} +1 -1
  13. package/dist/m/attrs/save-freeze-MTFVVYV5.js +1 -0
  14. package/dist/m/{c-KP64LT2A.js → c-2VB555FG.js} +1 -1
  15. package/dist/m/{c-KGHVFRR3.js → c-3AXJDL2P.js} +1 -1
  16. package/dist/m/{c-372N6H3B.js → c-3GJ4XARY.js} +1 -1
  17. package/dist/m/c-3TWCKFOP.js +1 -0
  18. package/dist/m/{c-U7SHNWDH.js → c-3YH6JSIL.js} +1 -1
  19. package/dist/m/{c-AMU3BPPR.js → c-44B5ODN7.js} +2 -2
  20. package/dist/m/{c-S2U6NJU4.js → c-62HZW5DH.js} +1 -1
  21. package/dist/m/{c-ZK4GIZH2.js → c-67ILSNPR.js} +1 -1
  22. package/dist/m/c-67LGZ52V.js +20 -0
  23. package/dist/m/{c-W6DQSHXI.js → c-7SXYQCN4.js} +1 -1
  24. package/dist/m/{c-5OOVFCJM.js → c-AL4RBJF2.js} +1 -1
  25. package/dist/m/{c-L42OLXWM.js → c-ANNFZFLN.js} +1 -1
  26. package/dist/m/{c-QO4HNGCQ.js → c-B4VYNXTQ.js} +1 -1
  27. package/dist/m/{c-7PRJCA3E.js → c-BJPZCWAV.js} +1 -1
  28. package/dist/m/{c-SW4YEG2D.js → c-BTLFLLML.js} +1 -1
  29. package/dist/m/c-C4IIQZGJ.js +1 -0
  30. package/dist/m/{c-5T6NZHNM.js → c-CAWNFER6.js} +1 -1
  31. package/dist/m/c-CSNE5EIF.js +1 -0
  32. package/dist/m/{c-DVYGFTSK.js → c-DDDOKPI6.js} +2 -2
  33. package/dist/m/{c-6DB2OLRJ.js → c-DHXPDLW6.js} +1 -1
  34. package/dist/m/{c-WCXLG3MV.js → c-E4IBSAQM.js} +1 -1
  35. package/dist/m/{c-IVTRVENZ.js → c-EDSPKYMA.js} +1 -1
  36. package/dist/m/{c-UORXLSD3.js → c-EFD6QNTE.js} +1 -1
  37. package/dist/m/{c-7MG5RU63.js → c-EGTIAL6P.js} +1 -1
  38. package/dist/m/{c-SPZPGD3R.js → c-EOCKLMKX.js} +1 -1
  39. package/dist/m/{c-HMV6P4RX.js → c-EOCZ2YVL.js} +1 -1
  40. package/dist/m/{c-B5UJQT7W.js → c-EZ7B5AZT.js} +1 -1
  41. package/dist/m/{c-UPD33ZPE.js → c-FDAXZ2PM.js} +1 -1
  42. package/dist/m/{c-RND3G2S2.js → c-FNVQAACK.js} +1 -1
  43. package/dist/m/c-FSWRGBVQ.js +1 -0
  44. package/dist/m/c-FTWGYI5E.js +1 -0
  45. package/dist/m/{c-CCQ25GFY.js → c-GKEATME6.js} +1 -1
  46. package/dist/m/{c-YBKHUAK7.js → c-GPGYKER7.js} +1 -1
  47. package/dist/m/{c-FWKHM4E7.js → c-GSRLXYWL.js} +1 -1
  48. package/dist/m/{c-B7J4K2AE.js → c-GVPNDIKJ.js} +2 -2
  49. package/dist/m/{c-AMYEFA2E.js → c-I2IFKJX2.js} +1 -1
  50. package/dist/m/c-ICMAOOGK.js +1 -0
  51. package/dist/m/{c-BUAYFEV5.js → c-J3BXFTZS.js} +1 -1
  52. package/dist/m/{c-BIJALSRD.js → c-JDZDF2QM.js} +1 -1
  53. package/dist/m/{c-UDHIVLYH.js → c-JH5FF4NL.js} +1 -1
  54. package/dist/m/{c-OYRZ7XG2.js → c-JHJR5BQX.js} +1 -1
  55. package/dist/m/{c-V44KWI2L.js → c-JOH753EJ.js} +2 -2
  56. package/dist/m/{c-P3OFYUMA.js → c-JUE7B4OX.js} +1 -1
  57. package/dist/m/c-K76AUL4I.js +7 -0
  58. package/dist/m/{c-ZWIOJGMT.js → c-LA2BW7UY.js} +1 -1
  59. package/dist/m/{c-ICJYHABF.js → c-LDUNOR57.js} +1 -1
  60. package/dist/m/{c-C6IPRG34.js → c-LIBE4HPD.js} +1 -1
  61. package/dist/m/c-LPUJXRXW.js +1 -0
  62. package/dist/m/{c-TTB5EK4S.js → c-LQU7TXIH.js} +1 -1
  63. package/dist/m/{c-5ZQV7KC4.js → c-LV5MABYD.js} +1 -1
  64. package/dist/m/{c-URSMSFCB.js → c-M4FSPNQ2.js} +1 -1
  65. package/dist/m/{c-2KIEGNC4.js → c-MEWUGR4D.js} +1 -1
  66. package/dist/m/c-MIL4B2JQ.js +1 -0
  67. package/dist/m/{c-IVMD3FEM.js → c-N7Y52HJH.js} +1 -1
  68. package/dist/m/{c-PLN76GF3.js → c-NFR673TL.js} +2 -2
  69. package/dist/m/{c-T637T7SN.js → c-NLZ5AN4L.js} +1 -1
  70. package/dist/m/{c-OPLUZR3A.js → c-OABVRYOT.js} +1 -1
  71. package/dist/m/c-PIVIYLTY.js +1 -0
  72. package/dist/m/{c-L7JL5TL3.js → c-QY7F6EN6.js} +1 -1
  73. package/dist/m/{c-UY4QX42B.js → c-RG6XUZFA.js} +1 -1
  74. package/dist/m/c-RHMPKZZ7.js +1 -0
  75. package/dist/m/{c-TJAXGIRV.js → c-RQRMPZXD.js} +1 -1
  76. package/dist/m/{c-GY422N3Q.js → c-RS77ODLY.js} +1 -1
  77. package/dist/m/{c-IMZCWYHR.js → c-RT3C74GY.js} +1 -1
  78. package/dist/m/{c-CAS3NND4.js → c-S5IEIZCN.js} +1 -1
  79. package/dist/m/{c-K3ADYNLU.js → c-SBSMJIPC.js} +1 -1
  80. package/dist/m/c-SFKI6OLM.js +2 -0
  81. package/dist/m/{c-4TXNLX4K.js → c-SJPD46ZO.js} +4 -4
  82. package/dist/m/{c-HHGJBU4D.js → c-SO63UGPQ.js} +1 -1
  83. package/dist/m/{c-PLP4JKFC.js → c-TUGVINJR.js} +1 -1
  84. package/dist/m/{c-Q26MME6N.js → c-TWCXBWS3.js} +1 -1
  85. package/dist/m/{c-GNDL3TKB.js → c-U5X57T4L.js} +1 -1
  86. package/dist/m/{c-4YU3BR4D.js → c-UXEVJ63W.js} +1 -1
  87. package/dist/m/{c-KJJPV77R.js → c-V2J5C5MT.js} +1 -1
  88. package/dist/m/{c-6UGWFMQC.js → c-VBLFAQD2.js} +1 -1
  89. package/dist/m/{c-ERQSNR6K.js → c-VQCEOGKH.js} +2 -2
  90. package/dist/m/{c-YVZ2L7CE.js → c-VSPAFOKU.js} +1 -1
  91. package/dist/m/{c-5ONTYIUI.js → c-VY6XRBOW.js} +1 -1
  92. package/dist/m/{c-2KL3BD52.js → c-W23KVGUO.js} +1 -1
  93. package/dist/m/{c-COPTL5WB.js → c-W3K23S72.js} +1 -1
  94. package/dist/m/c-WFMRX5RZ.js +1 -0
  95. package/dist/m/{c-BWFVSBDF.js → c-WN3MXOLT.js} +1 -1
  96. package/dist/m/{c-X5TSXZYJ.js → c-XD5MOROJ.js} +1 -1
  97. package/dist/m/{c-MWXFLW4J.js → c-XQSXKIKM.js} +1 -1
  98. package/dist/m/{c-OTMKN2OO.js → c-Y33LUUIQ.js} +1 -1
  99. package/dist/m/{c-JNLX3RJB.js → c-YQUJ74R3.js} +1 -1
  100. package/dist/m/{c-2GRUM77Q.js → c-YSB6JKV2.js} +2 -2
  101. package/dist/m/{c-WQWIRWO3.js → c-Z32V4MHK.js} +1 -1
  102. package/dist/m/{c-BHZYSHW4.js → c-ZJLU56DV.js} +1 -1
  103. package/dist/m/{c-DKLS74XB.js → c-ZQXNHZZT.js} +2 -2
  104. package/dist/m/{c-TNEM3RNM.js → c-ZSCBEB7I.js} +1 -1
  105. package/dist/m/core/{admin-attrs-YY7T5FGU.js → admin-attrs-ZG7R5V3E.js} +1 -1
  106. package/dist/m/core/{autosave-S6OMNDCK.js → autosave-VPORYJSE.js} +1 -1
  107. package/dist/m/core/{conflict-notice-IWHMHH43.js → conflict-notice-JMAZLVRX.js} +1 -1
  108. package/dist/m/core/{persist-UKXV5KLA.js → persist-3OPWJKZS.js} +1 -1
  109. package/dist/m/core/{save-LYY7K6U7.js → save-AOJBDSOK.js} +1 -1
  110. package/dist/m/core/{save-core-MKRPSEEO.js → save-core-IOCMXO5L.js} +1 -1
  111. package/dist/m/core/{snapshot-NU3YM6GR.js → snapshot-O7II2AGG.js} +1 -1
  112. package/dist/m/core/unsaved-warning-U5VUJFOE.js +1 -0
  113. package/dist/m/events/{index-RN2JNGBV.js → index-B6TGDYMG.js} +1 -1
  114. package/dist/m/internals/{index-TGLQINN3.js → index-HDRANDWE.js} +1 -1
  115. package/dist/m/lib/{cache-bust-MUMFXZK6.js → cache-bust-NH7VBWHO.js} +1 -1
  116. package/dist/m/lib/{region-policy-SB2AWVG5.js → region-policy-KWBPROWF.js} +1 -1
  117. package/dist/m/loader-DZZLUBVB.js +1 -0
  118. package/dist/m/manifest-3AEF5191.js +1 -0
  119. package/dist/m/manifest-edit-29A26F22.js +1 -0
  120. package/dist/m/manifest.json +1336 -1281
  121. package/dist/m/options/{options-EJMS3K24.js → options-55LQHSJ4.js} +1 -1
  122. package/dist/m/page/{editor-6LRB3GE4.js → editor-EY2WAAGB.js} +2 -2
  123. package/dist/m/plugins/{ai-edit-UTV7YO2I.js → ai-edit-J5VLSW5I.js} +1 -1
  124. package/dist/m/plugins/{demo-OYO5GCAO.js → demo-42BSYQ7T.js} +1 -1
  125. package/dist/m/plugins/like-EV7MTZ56.js +1 -0
  126. package/dist/m/plugins/{live-AD4H6XSL.js → live-5EZN2EYP.js} +1 -1
  127. package/dist/m/plugins/menu-KGWKT6CS.js +1 -0
  128. package/dist/m/plugins/{page-MD3T2ZX3.js → page-LVW5DWA4.js} +2 -2
  129. package/dist/m/plugins/{page-view-PAWVZNFY.js → page-view-5KQ76IJB.js} +1 -1
  130. package/dist/m/plugins/{sortable-4S6SQC3D.js → sortable-WJZZBM7R.js} +1 -1
  131. package/dist/m/plugins/{source-TJXOIOYY.js → source-CCEUM3OR.js} +1 -1
  132. package/dist/m/plugins/{undo-NLPGCJQY.js → undo-3JEYK5QY.js} +1 -1
  133. package/dist/m/plugins/{upgrade-HSUNDNJD.js → upgrade-NW556WJD.js} +1 -1
  134. package/dist/m/plugins/{upload-NHGJXZK2.js → upload-TTCQRIM5.js} +1 -1
  135. package/dist/m/plugins/{visitor-drafts-BW43DGY3.js → visitor-drafts-2AWNT23K.js} +2 -2
  136. package/dist/m/plugins/{visitor-drafts-owner-7ODQG7JX.js → visitor-drafts-owner-MZP45XSG.js} +1 -1
  137. package/dist/m/plugins/{wire-VBWXEXU2.js → wire-VRYPY6EJ.js} +1 -1
  138. package/dist/m/{reader-core-4W2KYEWQ.js → reader-core-2GXY7SHY.js} +2 -2
  139. package/dist/m/sync/{live-sync-MO5DH33W.js → live-sync-SKPMFYOO.js} +1 -1
  140. package/dist/m/ui/{index-IVWN2QC4.js → index-QNIPDHXQ.js} +1 -1
  141. package/dist/offline-manifest.json +652 -616
  142. package/docs/guide.md +25 -8
  143. package/package.json +4 -2
  144. package/src/core/conflict-download.js +8 -7
  145. package/src/core/conflict-notice-ui.js +4 -4
  146. package/src/core/copy-content.js +103 -0
  147. package/src/core/download.js +14 -2
  148. package/src/core/save-size-notice.js +2 -2
  149. package/src/core/upgrade-run.js +5 -1
  150. package/src/lib/region-policy.js +1 -1
  151. package/src/loader-logic.js +5 -1
  152. package/src/loader.js +2 -0
  153. package/src/page/features/export-html.js +4 -4
  154. package/src/page/features/export-markdown.js +2 -2
  155. package/src/page/recovery-ui.js +2 -2
  156. package/src/page/save-states.js +4 -4
  157. package/src/plugins/like.js +254 -0
  158. package/src/plugins/menu/actions.js +1 -1
  159. package/src/plugins/page.js +4 -4
  160. package/src/plugins/visitor-drafts/prompt.js +12 -12
  161. package/src/plugins/wire.js +3 -1
  162. package/dist/m/attrs/save-freeze-LWGF6XUN.js +0 -1
  163. package/dist/m/c-D5IKEKXW.js +0 -7
  164. package/dist/m/c-DCFSEJZG.js +0 -1
  165. package/dist/m/c-EVYAISP3.js +0 -1
  166. package/dist/m/c-GRXHHFPO.js +0 -1
  167. package/dist/m/c-HT2DKBV2.js +0 -1
  168. package/dist/m/c-MCOOHCSG.js +0 -1
  169. package/dist/m/c-VILENZTD.js +0 -1
  170. package/dist/m/c-VR2BJ4AN.js +0 -1
  171. package/dist/m/c-WGW764WX.js +0 -1
  172. package/dist/m/c-WNTYKCDG.js +0 -1
  173. package/dist/m/c-YBSS55KU.js +0 -20
  174. package/dist/m/core/unsaved-warning-7QTKEN7Z.js +0 -1
  175. package/dist/m/loader-3BBNZZGM.js +0 -1
  176. package/dist/m/manifest-26C55578.js +0 -1
  177. package/dist/m/manifest-edit-4396A009.js +0 -1
  178. package/dist/m/plugins/menu-M5WW7DBF.js +0 -1
  179. /package/dist/m/{c-QTTJYJMA.js → c-FILLTJA7.js} +0 -0
  180. /package/dist/m/plugins/{people-JCHVPF7P.js → people-C7TVBBMR.js} +0 -0
package/docs/guide.md CHANGED
@@ -4,7 +4,7 @@ This is the complete reference for building malleable documents with ClayJS: one
4
4
 
5
5
  It is written for a developer or an LLM building full apps with inline editing and inline controls: kanban boards with card detail, block writers, finance trackers, CRMs, flashcards, trackers. It covers the CMS plugin only in passing ([cms: content-managed pages](https://clayjs.com/docs/guide/components/#cms-content-managed-pages)), because an app whose product is editing should not route editing through a form sidebar.
6
6
 
7
- Every behavior stated here was read from ClayJS 1.17.0 source or executed. The four example files below are complete, tested apps that this guide quotes. A [test suite](https://github.com/panphora/clayjs/tree/main/tests/unit/guide-examples) boots each one in jsdom with the real loader and checks what it saves, how it merges a peer's edit, and what a viewer receives. Open any of them and view source, or download one and open it in HTML Clay to edit it:
7
+ Every behavior stated here was read from ClayJS 1.17.0 source or executed. The four example files below are complete, tested apps that this guide quotes. A [test suite](https://github.com/hyperspacesystems/clayjs/tree/main/tests/unit/guide-examples) boots each one in jsdom with the real loader and checks what it saves, how it merges a peer's edit, and what a viewer receives. Open any of them and view source, or download one and open it in HTML Clay to edit it:
8
8
 
9
9
  | File | What it shows |
10
10
  |---|---|
@@ -314,6 +314,7 @@ Plugins add editing, sync, and UI features to `clay.js`. Most are off by default
314
314
  | `cms` | off | yes | `clay.cms`; implies `quickcrop` and `upload` |
315
315
  | `wire` | off | yes | `clay.wire`, the channel to a local agent process |
316
316
  | `message` | off | yes | `message-form`: a visitor's contact form to the owner, `clay.message` ([message: contact forms](https://clayjs.com/docs/guide/components/#message-contact-forms)) |
317
+ | `like` | off | yes | `like`: a like button on any element, counted by the host, `clay.like` ([like](https://clayjs.com/docs/plugins/like/)) |
317
318
  | `ai-edit` | off | no | select text, ask an agent to rewrite it; implies `wire` |
318
319
  | `demo` | off | yes | fakes saving into `localStorage` for public demos |
319
320
  | `visitor-drafts` | off | yes | `clay.visitorDrafts`; in draft mode, the draft store, download prompt and intro area; outside it, only the owner chip, fetched when the owner edits ([Free documents for visitors](https://clayjs.com/docs/guide/free-documents/)) |
@@ -891,7 +892,23 @@ Traps in this table:
891
892
  - **Fill a scratchpad after `clay.ready`, and insert new ones empty.** The contents are captured when the element first appears. Content written before boot, or present in a cloned element when inserted, becomes its saved contents. Empty the scratchpads in a copied record before inserting it.
892
893
  - **Create `editor-ui` roots at runtime from an unmarked `<template>` on every load.** An `editor-ui` root disappears from the saved file, so it cannot be authored in the file. Do not mark the blueprint inside the `<template>` as `editor-ui`, or the first save empties the template and leaves no chrome to clone on the next load. Leave the blueprint unmarked and set `clay="editor-ui"` on each clone as you insert it.
893
894
 
894
- ### 4.3 `persist`: form controls
895
+ ### 4.3 Copy policy: `no-copy` and `no-copy-unless:paid-forms`
896
+
897
+ Save and sync tokens govern the working document; copy-policy tokens on `clay` govern what travels into an explicit copy:
898
+
899
+ ```html
900
+ <script src="/analytics.js" defer clay="no-copy"></script>
901
+ <header class="intro" clay="no-copy">…</header>
902
+ <form action="/_/message" method="post" clay="no-copy-unless:paid-forms">…</form>
903
+ ```
904
+
905
+ - **Normal saves keep the original.** Ordinary saves, live sync, versions, and automatic recovery retain `no-copy` and conditional regions.
906
+ - **Explicit copies strip `no-copy` content, even for the owner.** Copies, duplicates, forks, HTML downloads, and ZIP exports remove `clay="no-copy"` elements (including inside `<template>` contents). User-requested recovery downloads filter visible HTML and embedded earlier HTML pages while preserving recovery records and edits.
907
+ - **Authored loaders vs runtime scripts.** An authored loader marked `clay="no-copy"` stays in original saves and disappears from copies. Any SDK `<script>` added at runtime uses `clay="no-save no-snapshot no-watch no-copy"` so it is not persisted. ClayJS core knows only generic copy policy, not any analytics provider.
908
+ - **Conditional paid forms.** `clay="no-copy-unless:paid-forms"` aliases legacy `data-paid-only="forms"` (still supported). Hosted copy/create checks the destination's existing `paid-forms` (`forms`) entitlement; offline downloads have no destination entitlement and strip optional conditional content. Reusable templates retain conditional regions for creation-time filtering, while unconditional `no-copy` regions are removed before owned template outputs. Exact case-sensitive matching fails closed, so an unknown conditional spelling cannot disguise a required paid feature.
909
+ - **Supporting hosts and raw file limits.** Enforced in ClayJS client downloads and recovery exports, Hyperclay hosted copy/create/download flows, and Hyperclay Local / HTML Clay copies and exports. Finder, `cp`, and browser Save Page As bypass app hooks and copy raw file bytes unchanged.
910
+
911
+ ### 4.4 `persist`: form controls
895
912
 
896
913
  Put `persist` on every `<input>`, `<textarea>`, and `<select>` whose value belongs to the record. A control's typed value is a property, not an attribute, and serialization only reads attributes. Without `persist`, a typed value is lost on save and the file keeps the authored `value`.
897
914
 
@@ -912,7 +929,7 @@ Put `persist` on every `<input>`, `<textarea>`, and `<select>` whose value belon
912
929
 
913
930
  **After undo, redo, or a merge, ClayJS puts each control's display back in step with its attributes.** A control the user has touched shows its live property, not its attribute. A merge and the undo plugin copy `checked`, `value`, and `selected` back onto the property (undo does this for `persist` controls outside `no-undo` regions). A merge leaves the focused text field alone, while undo restores it too.
914
931
 
915
- ### 4.4 Where to keep data that is not visible text
932
+ ### 4.5 Where to keep data that is not visible text
916
933
 
917
934
  In order of preference:
918
935
 
@@ -934,7 +951,7 @@ In order of preference:
934
951
  4. **A live JSON script (`clay="live"`)** for state that changes faster than a save: game boards, scores, cursors ([Live data: `clay="live"`](https://clayjs.com/docs/guide/live-sync/#live-data-clay-live)).
935
952
  5. **`<template>` content: never for mutable data.** It is saved, but edits there never trigger autosave.
936
953
 
937
- ### 4.5 Element identity: `data-id` and `id`
954
+ ### 4.6 Element identity: `data-id` and `id`
938
955
 
939
956
  Live sync and undo pair elements across versions by **`data-id`, then `id`**, used only when the value is unique on its side and the tag names match. Without an id, elements pair by content, which works for distinct text and fails for identical new items.
940
957
 
@@ -955,11 +972,11 @@ Live sync and undo pair elements across versions by **`data-id`, then `id`**, us
955
972
  - **A `data-id` is also what presence names.** A record target `{ kind: "record", id }` carries it, so a stable id is what lets a peer's badge find the row again after a reload or a merge ([Presence](https://clayjs.com/docs/guide/live-sync/#presence)).
956
973
  - **Use ids to find records in commands:** `` board.querySelector(`.card[data-id="${CSS.escape(id)}"]`) ``. Never find a record by position.
957
974
 
958
- ### 4.6 Assets
975
+ ### 4.7 Assets
959
976
 
960
977
  ClayJS saves an image through its `src`. Upload the file and write the returned URL ([upload and quickcrop](https://clayjs.com/docs/guide/components/#upload-and-quickcrop)). Every host returns a host path such as `/_/uploads/assets-<name>/photo-ab12.png`, which keeps working on that host when the document is renamed or moved. A document opened straight from disk shows those images broken; Export as zip takes a document with its files. Where the host cannot store files, embed a `data:` URL. Data URLs work anywhere, but every save carries the bytes, so keep embedded images small.
961
978
 
962
- ### 4.7 Ephemeral state: per-tab state on saved elements
979
+ ### 4.8 Ephemeral state: per-tab state on saved elements
963
980
 
964
981
  Use `ephemeral` for per-tab state on a saved element, such as a selected card, a row being dragged, or an open section. Toggling a normal class or attribute is an edit like any other: it marks the page unsaved, starts an autosave, adds an undo step, reaches every peer, and lands in the file. Mark it `ephemeral` instead:
965
982
 
@@ -981,7 +998,7 @@ clay.ephemeral.toggle(card, "selected"); // add, remove, toggle(el, word, forc
981
998
 
982
999
  Use `ephemeral` for state on one element. State that comes from the URL hash and spans many records (the open card, a filter) is still painted with a runtime stylesheet ([Per-tab state](https://clayjs.com/docs/guide/architecture/#per-tab-state)).
983
1000
 
984
- ### 4.8 A template is an ordinary record with a tag
1001
+ ### 4.9 A template is an ordinary record with a tag
985
1002
 
986
1003
  A reusable template is a record like any other, marked with a tag. It stays editable, searchable, exported, synced and in a visitor's draft, and it needs no second store and no new format. The app reads the tag off the body it already has:
987
1004
 
@@ -991,7 +1008,7 @@ const templates = open => Entries.live().filter(a => a !== open && summary({ bod
991
1008
 
992
1009
  The tag is matched outside code spans and fences, so a template entry that shows a `#template` example does not become a template itself. Using one copies its text into the page, and the new record is written by the same save as everything else.
993
1010
 
994
- ### 4.9 Keep a helper that reads the text beside the editor, in an `editor-ui` row
1011
+ ### 4.10 Keep a helper that reads the text beside the editor, in an `editor-ui` row
995
1012
 
996
1013
  A helper that reacts to the text being written belongs in a row a script builds beside the editor, marked `clay="editor-ui"`, and refreshed on a debounce. Nothing it draws is saved, printed or synced, and no keystroke pays for it beyond the debounce:
997
1014
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.18.1",
3
+ "version": "1.20.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -66,11 +66,13 @@
66
66
  "scripts": {
67
67
  "test": "node scripts/test-guard.mjs",
68
68
  "measure:load": "node scripts/measure-load.mjs",
69
+ "test:perf": "node scripts/test-guard.mjs --runInBand --testMatch '**/tests/perf/**/*.test.js' tests/perf",
69
70
  "test:conformance": "UPDATE_GOLDENS=0 web-test-runner --config conformance/wtr.config.mjs",
70
71
  "conformance:update": "UPDATE_GOLDENS=1 web-test-runner --config conformance/wtr.config.mjs",
71
72
  "build:standalone": "node build.js --standalone",
72
73
  "prepack": "node build.js --standalone",
73
74
  "test:standalone": "node build.js --standalone && web-test-runner --config tests/browser/wtr.config.mjs",
75
+ "browser:install": "node scripts/chrome-for-testing/browser-runtime.cjs install",
74
76
  "prepublishOnly": "testbox run",
75
77
  "dev": "node tests/stub-server.mjs",
76
78
  "build": "npm run build:bevel && npm run build:skins && node build.js",
@@ -110,7 +112,7 @@
110
112
  "marked": "18.0.14",
111
113
  "overtype": "2.6.1",
112
114
  "parse5": "^7.3.0",
113
- "playwright": "1.62.1",
115
+ "playwright": "1.64.1",
114
116
  "postcss": "^8.5.28",
115
117
  "prosemirror-commands": "1.7.2",
116
118
  "prosemirror-dropcursor": "1.8.4",
@@ -1,6 +1,7 @@
1
1
  import { serializeRecoveryClone, captureRecoveryPage } from "./snapshot.js";
2
2
  import { rowOf } from "./conflict-presentation.js";
3
3
  import { documentName, downloadHtml } from "./download.js";
4
+ import { stripCopyContent } from "./copy-content.js";
4
5
 
5
6
  export { documentName, downloadHtml };
6
7
 
@@ -31,12 +32,12 @@ export function buildRecovery(records, { refused = false } = {}) {
31
32
  if (apply && !seen.has(apply.id)) { seen.add(apply.id); applies.push(apply); }
32
33
  }
33
34
  applies.sort((a, b) => (a.ticket || 0) - (b.ticket || 0));
34
- const pageOf = (a) => serializeRecoveryClone(a.recoveryRoot ?? a.root, { prepared: a.domain === "save" });
35
+ const pageOf = (a) => stripCopyContent(serializeRecoveryClone(a.recoveryRoot ?? a.root, { prepared: a.domain === "save" }));
35
36
 
36
37
  let html;
37
38
  let older;
38
39
  if (refused || !applies.length) {
39
- html = captureRecoveryPage();
40
+ html = stripCopyContent(captureRecoveryPage());
40
41
  older = applies;
41
42
  } else {
42
43
  html = pageOf(applies[applies.length - 1]);
@@ -66,9 +67,9 @@ export function buildRecovery(records, { refused = false } = {}) {
66
67
  // The review panel's own copy: the page a reload kept, when there is one, with every
67
68
  // stored row it drew listed beside it as JSON. The rows are already plain, so nothing
68
69
  // here reads the ledger the tab no longer has.
69
- export function downloadStored({ html = null, edits = [] } = {}) {
70
+ export async function downloadStored({ html = null, edits = [] } = {}) {
70
71
  try {
71
- const page = typeof html === "string" ? html : captureRecoveryPage();
72
+ const page = stripCopyContent(typeof html === "string" ? html : captureRecoveryPage());
72
73
  const data = {
73
74
  format: "clay-lost-edits",
74
75
  version: 1,
@@ -78,17 +79,17 @@ export function downloadStored({ html = null, edits = [] } = {}) {
78
79
  edits: Array.isArray(edits) ? edits : [],
79
80
  earlierPages: [],
80
81
  };
81
- return downloadHtml({ html: embed(page, data), name: `${documentName()}-my-copy-${stamp()}.html` });
82
+ return await downloadHtml({ html: embed(page, data), name: `${documentName()}-my-copy-${stamp()}.html` });
82
83
  } catch (err) {
83
84
  console.error("clayjs: could not build the download", err);
84
85
  return false;
85
86
  }
86
87
  }
87
88
 
88
- export function downloadRecovery(records, { refused = false } = {}) {
89
+ export async function downloadRecovery(records, { refused = false } = {}) {
89
90
  try {
90
91
  const { html, name } = buildRecovery(records, { refused });
91
- return downloadHtml({ html, name });
92
+ return await downloadHtml({ html, name });
92
93
  } catch (err) {
93
94
  console.error("clayjs: could not build the download", err);
94
95
  return false;
@@ -707,12 +707,12 @@ async function onRevert() {
707
707
  let downloadedTimer = null;
708
708
 
709
709
  // Download my copy. Nothing here acknowledges or releases a record.
710
- function onDownload() {
710
+ async function onDownload() {
711
711
  const l = ledger();
712
712
  // A refusal ends in a reload that takes every record with it, so its copy carries
713
713
  // all of them; a replaced notice covers the rows it drew.
714
714
  const records = refused ? (l?.list() ?? []) : renderedIds.map((id) => l?.get(id)).filter(Boolean);
715
- const ok = downloadRecovery(records, { refused: !!refused });
715
+ const ok = await downloadRecovery(records, { refused: !!refused });
716
716
  downloadFailed = !ok;
717
717
  downloaded = ok;
718
718
  clearTimeout(downloadedTimer);
@@ -801,14 +801,14 @@ async function onCopyStored(entry) {
801
801
 
802
802
  // Download my copy, from the stored root: the page as this tab had it, with the
803
803
  // losing text in it. Nothing here acknowledges or releases a record.
804
- function onDownloadRecovered() {
804
+ async function onDownloadRecovered() {
805
805
  const record = recovered.find((item) => item.root);
806
806
  const edits = [];
807
807
  for (const item of recovered) {
808
808
  for (const entry of item.conflicts) edits.push({ id: entry.id, kind: entry.kind, name: storedName(entry), yours: entry.yours, now: entry.now, sentence: entry.sentence });
809
809
  }
810
810
  for (const entry of recoveredUndo) edits.push({ id: entry.id, kind: entry.kind, name: entry.name, yours: entry.yours, now: entry.now, sentence: entry.sentence });
811
- const ok = downloadStored({ html: record?.root ?? null, edits });
811
+ const ok = await downloadStored({ html: record?.root ?? null, edits });
812
812
  downloadFailed = !ok;
813
813
  downloaded = ok;
814
814
  clearTimeout(downloadedTimer);
@@ -0,0 +1,103 @@
1
+ // Copy-only content: marked regions that stay in the file, in normal saves, in snapshots
2
+ // and in recovery storage, and are removed only from the bytes of a copy someone asks for.
3
+ // The removal is a splice on the original string using parse5's source offsets, so every
4
+ // byte outside a removed range survives, templates and implicit end tags included.
5
+
6
+ import { parse } from "../vendor/parse5.vendor.js";
7
+
8
+ const CLAY = "clay";
9
+ const PAID_ONLY = "data-paid-only";
10
+ const NO_COPY = "no-copy";
11
+ const CONDITIONAL_FORMS = "no-copy-unless:paid-forms";
12
+ const CLAY_TOKENS = /[\t\n\f\r ]+/;
13
+
14
+ function attributeOf(node, name) {
15
+ return node.attrs?.find((attr) => attr.name === name)?.value ?? null;
16
+ }
17
+
18
+ function childrenOf(node) {
19
+ return node.content ? [...(node.childNodes || []), node.content] : (node.childNodes || []);
20
+ }
21
+
22
+ function eachElement(node, visit) {
23
+ visit(node);
24
+ for (const child of childrenOf(node)) eachElement(child, visit);
25
+ }
26
+
27
+ function clayTokensOf(node) {
28
+ return (attributeOf(node, CLAY) ?? "").split(CLAY_TOKENS);
29
+ }
30
+
31
+ // The marker is compared to a feature key exactly: no trim, no lowercase, so a typo, an
32
+ // empty value or `FORMS` strips nothing and the content stays, which is the direction
33
+ // that fails closed. `clay="no-copy-unless:paid-forms"` is the approved spelling of the
34
+ // same condition.
35
+ function paidMarkersOf(node) {
36
+ const markers = [];
37
+ const value = attributeOf(node, PAID_ONLY);
38
+ if (value !== null) markers.push(value);
39
+ if (clayTokensOf(node).includes(CONDITIONAL_FORMS)) markers.push("forms");
40
+ return [...new Set(markers)];
41
+ }
42
+
43
+ function lastDescendantEnd(node) {
44
+ let end = null;
45
+ eachElement(node, (entry) => {
46
+ const entryEnd = entry.sourceCodeLocation?.endOffset;
47
+ if (typeof entryEnd === "number" && entryEnd >= 0 && (end === null || entryEnd > end)) end = entryEnd;
48
+ });
49
+ return end;
50
+ }
51
+
52
+ function endOffsetFor(node) {
53
+ const location = node.sourceCodeLocation;
54
+ if (typeof location?.endOffset === "number" && location.endOffset >= 0) return location.endOffset;
55
+ const fallback = lastDescendantEnd(node) ?? location?.startTag?.endOffset ?? null;
56
+ if (fallback === null) return null;
57
+ console.warn(`clayjs: <${node.tagName}> has no end offset; removing through ${fallback}`);
58
+ return fallback;
59
+ }
60
+
61
+ function markedRanges(html, features) {
62
+ const ranges = [];
63
+ eachElement(parse(html, { sourceCodeLocationInfo: true }), (node) => {
64
+ const markers = paidMarkersOf(node);
65
+ const unconditional = clayTokensOf(node).includes(NO_COPY);
66
+ const conditional = features === null ? markers.length > 0 : markers.some((marker) => features.includes(marker));
67
+ if (!unconditional && !conditional) return;
68
+ const start = node.sourceCodeLocation?.startOffset;
69
+ const end = typeof start === "number" ? endOffsetFor(node) : null;
70
+ if (typeof start === "number" && start >= 0 && end !== null && end > start) ranges.push({ start, end });
71
+ });
72
+ return ranges.filter((range, index) => !ranges.some((other, otherIndex) =>
73
+ otherIndex !== index && other.start <= range.start && range.end <= other.end));
74
+ }
75
+
76
+ function leadingRunStart(html, start) {
77
+ const newline = html.lastIndexOf("\n", start - 1);
78
+ if (newline < 0 || /\S/.test(html.slice(newline + 1, start))) return start;
79
+ return html[newline - 1] === "\r" ? newline - 1 : newline;
80
+ }
81
+
82
+ function spliceRanges(html, ranges) {
83
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
84
+ let stripped = html;
85
+ let limit = html.length;
86
+ for (let index = sorted.length - 1; index >= 0; index -= 1) {
87
+ const { start, end } = sorted[index];
88
+ if (end > limit) continue;
89
+ const from = leadingRunStart(html, start);
90
+ if (from >= end) continue;
91
+ stripped = stripped.slice(0, from) + stripped.slice(end);
92
+ limit = from;
93
+ }
94
+ return stripped;
95
+ }
96
+
97
+ // `features` decides the conditional regions: null (the default, and the offline
98
+ // destination) strips every one of them, while a list strips only the missing features
99
+ // it names. Unconditional `no-copy` content always goes.
100
+ export function stripCopyContent(html, features = null) {
101
+ if (typeof html !== "string" || !/clay|data-paid-only/i.test(html)) return html;
102
+ return spliceRanges(html, markedRanges(html, features));
103
+ }
@@ -1,6 +1,13 @@
1
1
  // Saving a page as a file the browser downloads. Kept apart from the conflict copy
2
2
  // builders so an editor that only offers "download this page" loads none of them.
3
3
 
4
+ const HTML_TYPES = ["text/html", "application/xhtml+xml"];
5
+
6
+ function isHtml(type) {
7
+ if (typeof type !== "string") return false;
8
+ return HTML_TYPES.includes(type.split(";")[0].trim().toLowerCase());
9
+ }
10
+
4
11
  export function documentName(pathname = location.pathname) {
5
12
  const last = pathname.split("/").filter(Boolean).pop();
6
13
  if (!last) return pathname.endsWith("/") || pathname === "" ? "index" : "page";
@@ -9,10 +16,15 @@ export function documentName(pathname = location.pathname) {
9
16
  return name.replace(/\.(html?|htmlclay)$/i, "") || "page";
10
17
  }
11
18
 
12
- export function downloadHtml({ html, name, type = "text/html" }) {
19
+ export async function downloadHtml({ html, name, type = "text/html" }) {
13
20
  let url = null;
14
21
  try {
15
- url = URL.createObjectURL(new Blob([html], { type }));
22
+ let contents = html;
23
+ if (isHtml(type) && typeof html === "string" && /clay|data-paid-only/i.test(html)) {
24
+ const { stripCopyContent } = await import("./copy-content.js");
25
+ contents = stripCopyContent(html);
26
+ }
27
+ url = URL.createObjectURL(new Blob([contents], { type }));
16
28
  const a = document.createElement("a");
17
29
  a.setAttribute("clay", "no-save no-watch no-snapshot");
18
30
  a.href = url;
@@ -57,8 +57,8 @@ function place() {
57
57
  // The safe copy the conflict notice offers, for the same reason: a file the person can
58
58
  // open has to exist outside a host that will not store these bytes. It already reports
59
59
  // its own failure.
60
- function download() {
61
- downloadStored({});
60
+ async function download() {
61
+ await downloadStored({});
62
62
  }
63
63
 
64
64
  function build() {
@@ -348,7 +348,11 @@ export async function pageBytes(clay, { loc = location, fetchFn = (url, init) =>
348
348
  export async function downloadCopy({ clay, doc = document, loc = location, fetchFn } = {}) {
349
349
  if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") return false;
350
350
  try {
351
- const bytes = await pageBytes(clay, { loc, fetchFn });
351
+ let bytes = await pageBytes(clay, { loc, fetchFn });
352
+ if (typeof bytes === "string" && /clay|data-paid-only/i.test(bytes)) {
353
+ const { stripCopyContent } = await import("./copy-content.js");
354
+ bytes = stripCopyContent(bytes);
355
+ }
352
356
  const url = URL.createObjectURL(new Blob([bytes], { type: "text/html" }));
353
357
  const a = doc.createElement("a");
354
358
  a.setAttribute("clay", "editor-ui");
@@ -54,7 +54,7 @@ export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-u
54
54
  // Every canonical token spellable inside the space-separated `clay` attribute.
55
55
  // Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
56
56
  // while callers hardcoded their own copies.
57
- export const TOKENS = [...POLICY_TOKENS];
57
+ export const TOKENS = [...POLICY_TOKENS, 'no-copy', 'no-copy-unless:paid-forms'];
58
58
 
59
59
  // True when a region marker is present, whether spelled as a `clay` token
60
60
  // (whitespace-token semantics, matching [clay~=token]) or a legacy bare attribute.
@@ -62,6 +62,9 @@ export const PLUGIN_PATHS = {
62
62
  // A visitor's contact form to the document's owner. Not editOnly: visitors are
63
63
  // who send messages.
64
64
  message: { path: "plugins/message.js", editOnly: false, default: false },
65
+ // A like button on any element, counted by the host. Not editOnly: visitors are
66
+ // who like.
67
+ like: { path: "plugins/like.js", editOnly: false, default: false },
65
68
  // Edit mode only: the AI comment box is an editing gesture. Opt in with
66
69
  // `plugins=ai-edit`; it stays dormant on any host that does not list a ready
67
70
  // `ai-edit` helper.
@@ -139,6 +142,7 @@ export const MODULES = {
139
142
  "plugins/menu.js": () => import("./plugins/menu.js"),
140
143
  "plugins/wire.js": () => import("./plugins/wire.js"),
141
144
  "plugins/message.js": () => import("./plugins/message.js"),
145
+ "plugins/like.js": () => import("./plugins/like.js"),
142
146
  "plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
143
147
  "plugins/demo.js": () => import("./plugins/demo.js"),
144
148
  "plugins/visitor-drafts.js": () => import("./plugins/visitor-drafts.js"),
@@ -151,7 +155,7 @@ export const MODULES = {
151
155
 
152
156
  // `source` is last on purpose: its install captures a save clone, so it wants every
153
157
  // plugin that registers a document transform to have registered it first.
154
- const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "unsent", "quickcrop", "upload", "people", "menu", "upgrade", "cms", "page-view", "sync", "live", "page", "message", "wire", "ai-edit", "demo", "derive", "bind-text", "source"];
158
+ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "unsent", "quickcrop", "upload", "people", "menu", "upgrade", "cms", "page-view", "sync", "live", "page", "message", "like", "wire", "ai-edit", "demo", "derive", "bind-text", "source"];
155
159
 
156
160
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
157
161
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
package/src/loader.js CHANGED
@@ -371,6 +371,8 @@ function attachPluginMember(path, mod, namespaces = loaded) {
371
371
  Object.defineProperty(clay, "me", { get: mod.currentMe, configurable: true, enumerable: true });
372
372
  } else if (path === "plugins/message.js") {
373
373
  clay.message = mod.message;
374
+ } else if (path === "plugins/like.js") {
375
+ clay.like = mod.like;
374
376
  } else if (path === "plugins/wire.js") {
375
377
  clay.wire = mod.wire || mod.default;
376
378
  } else if (path === "plugins/ai-edit.js") {
@@ -112,12 +112,12 @@ export function createFeature({ view = null, download = downloadHtml, fetch: fet
112
112
  }
113
113
 
114
114
  function decide(s) {
115
- listen(s, s.panel, "click", (event) => {
115
+ listen(s, s.panel, "click", async (event) => {
116
116
  const b = event.target.closest?.("[data-act]");
117
117
  if (!b) return;
118
118
  event.preventDefault();
119
119
  if (b.dataset.act === "close") close();
120
- else if (b.dataset.act === "download") save(s);
120
+ else if (b.dataset.act === "download") await save(s);
121
121
  else if (b.dataset.act === "retry") run().catch((error) => console.error("clayjs: the reading copy could not be made", error));
122
122
  });
123
123
  listen(s, doc, "keydown", (event) => {
@@ -130,9 +130,9 @@ export function createFeature({ view = null, download = downloadHtml, fetch: fet
130
130
 
131
131
  // The one place a reading copy becomes a file. The download is handed the bytes that
132
132
  // were shown, never a second render, so what a person saw is what they get.
133
- function save(s) {
133
+ async function save(s) {
134
134
  if (!s.ready || disposed) return { ok: false, code: "not-editing" };
135
- const saved = download({ html: s.ready.html, name: s.ready.name });
135
+ const saved = await download({ html: s.ready.html, name: s.ready.name });
136
136
  close();
137
137
  return { ok: saved === true, downloaded: saved === true };
138
138
  }
@@ -83,10 +83,10 @@ export function createFeature({ view = null, download = downloadHtml } = {}) {
83
83
  s.panel.replaceChildren(...parts);
84
84
  }
85
85
 
86
- function save() {
86
+ async function save() {
87
87
  const s = session;
88
88
  if (!s) return { ok: false, code: "no-export" };
89
- if (download({ html: s.report.text, name: s.name, type: "text/markdown" })) {
89
+ if (await download({ html: s.report.text, name: s.name, type: "text/markdown" })) {
90
90
  close();
91
91
  view.focus?.();
92
92
  return { ok: true, downloaded: true };
@@ -228,12 +228,12 @@ export function createRecoveryReview({
228
228
  button.setAttribute("aria-expanded", "false");
229
229
  };
230
230
 
231
- const download = () => {
231
+ const download = async () => {
232
232
  const edits = [];
233
233
  for (const row of rows) edits.push({ id: row.id, kind: "recovered-page", name: row.message, yours: row.text, now: "", sentence: row.message });
234
234
  const composing = "Text typed while composing never reached the page.";
235
235
  for (const row of journaled) edits.push({ id: row.id, kind: "composing", name: "Typed text", yours: row.text, now: "", sentence: composing });
236
- if (!downloadStored({ html: null, edits })) failure = "That download could not be made.";
236
+ if (!(await downloadStored({ html: null, edits }))) failure = "That download could not be made.";
237
237
  draw();
238
238
  };
239
239
 
@@ -67,14 +67,14 @@ function withoutPageTag(html) {
67
67
  // One complete, openable file: the capture the save would have sent, with the
68
68
  // page projection already applied by the live store's snapshot listener, and
69
69
  // without the page editor's merge state, which no saved file or download carries.
70
- function downloadPageCopy() {
70
+ async function downloadPageCopy() {
71
71
  const { forSave } = captureForSaveAndComparison({ emitForSync: false });
72
- return downloadHtml({ html: withoutPageTag(forSave), name: `${documentName()}.html` });
72
+ return await downloadHtml({ html: withoutPageTag(forSave), name: `${documentName()}.html` });
73
73
  }
74
74
 
75
75
  // A copy of the page captured earlier (captureForSaveAndComparison().forSave), as a download.
76
- function downloadSnapshot(forSave) {
77
- return downloadHtml({ html: withoutPageTag(forSave), name: `${documentName()}.html` });
76
+ async function downloadSnapshot(forSave) {
77
+ return await downloadHtml({ html: withoutPageTag(forSave), name: `${documentName()}.html` });
78
78
  }
79
79
 
80
80
  // The article's own plain text, never the editor's chrome: the capture carries