@jxsuite/studio 10.4.0 → 10.5.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 (274) hide show
  1. package/dist/chunks/2020-7sx2vexa.js +4 -0
  2. package/dist/chunks/adapter-12dxr3z7.js +4 -0
  3. package/dist/chunks/{add-new-flow-v0pm96mr.js → add-new-flow-any4pw2q.js} +3 -3
  4. package/dist/chunks/{cf-account-picker-xb8vvt2h.js → cf-account-picker-ajh60k54.js} +3 -3
  5. package/dist/chunks/cf-settings-hbj16kh3.js +4 -0
  6. package/dist/chunks/{color-area-h8dbasp1.js → color-area-gbcftzh3.js} +2 -2
  7. package/dist/chunks/{color-field-qv5r2b16.js → color-field-85136598.js} +3 -3
  8. package/dist/chunks/{color-slider-nz2mrzky.js → color-slider-w1s3ndd8.js} +2 -2
  9. package/dist/chunks/color-thzpppyc.js +4 -0
  10. package/dist/chunks/{combobox-6hs00996.js → combobox-dk98wc5x.js} +3 -3
  11. package/dist/chunks/components-a98hzqw9.js +4 -0
  12. package/dist/chunks/content-source-jq3fjs3w.js +4 -0
  13. package/dist/chunks/{convert-to-repeater-r8sfxjjm.js → convert-to-repeater-g66r6qp1.js} +3 -3
  14. package/dist/chunks/editor-rth1rzj8.js +4 -0
  15. package/dist/chunks/entry-commands-02j0s19w.js +4 -0
  16. package/dist/chunks/{extension-sections-vjzw46xb.js → extension-sections-rqrs7dr0.js} +3 -3
  17. package/dist/chunks/file-ops-6a49er4c.js +4 -0
  18. package/dist/chunks/files-b2p9y0yz.js +4 -0
  19. package/dist/chunks/format-host-7xe57k1s.js +4 -0
  20. package/dist/chunks/fs-events-0xsy168g.js +4 -0
  21. package/dist/chunks/grid-open-h3q4fjy5.js +4 -0
  22. package/dist/chunks/{grid-view-2f03wcgw.js → grid-view-0v4hjspb.js} +17 -17
  23. package/dist/chunks/{icons-gb967qc8.js → icons-j9eksgew.js} +2 -2
  24. package/dist/chunks/{iframe-entry-btck0e9s.js → iframe-entry-4zqtn3ry.js} +4 -4
  25. package/dist/chunks/{iframe-entry-b5qgggyp.js → iframe-entry-54kwwhmg.js} +4 -4
  26. package/dist/chunks/{iframe-entry-42hpmwz5.js → iframe-entry-m24fza30.js} +4 -4
  27. package/dist/chunks/iframe-host-8jcmaktg.js +4 -0
  28. package/dist/chunks/{image-bake-4afr4y6a.js → image-bake-m8gzaan9.js} +3 -3
  29. package/dist/chunks/{image-editor-x7t8jbjn.js → image-editor-0afyws7c.js} +3 -3
  30. package/dist/chunks/{index-yjrfeks8.js → index-2q88g57p.js} +3 -3
  31. package/dist/chunks/index-7nj2375a.js +4 -0
  32. package/dist/chunks/{javascript-hy2mws8v.js → javascript-5sxtr1g7.js} +3 -3
  33. package/dist/chunks/jsonMode-m6sf47hq.js +13 -0
  34. package/dist/chunks/jx-validate-808zgbgy.js +4 -0
  35. package/dist/chunks/layers-0ffdtqjn.js +4 -0
  36. package/dist/chunks/layers-panel-rw62wtvf.js +4 -0
  37. package/dist/chunks/{listbox-7gka1h86.js → listbox-k5p41049.js} +2 -2
  38. package/dist/chunks/lit-html-9bz9rcfv.js +4 -0
  39. package/dist/chunks/media-library-2a5hdbek.js +4 -0
  40. package/dist/chunks/media-open-069kzhj4.js +4 -0
  41. package/dist/chunks/media-picker-4p4pd28y.js +4 -0
  42. package/dist/chunks/menu-26nez152.js +4 -0
  43. package/dist/chunks/{monaco-binding-a51py33c.js → monaco-binding-1s2b8snq.js} +3 -3
  44. package/dist/chunks/monaco-buffer-m9fd2dka.js +4 -0
  45. package/dist/chunks/monaco-setup-e0crh3v6.js +4 -0
  46. package/dist/chunks/notify-re3js157.js +4 -0
  47. package/dist/chunks/platform-rywy3tkg.js +4 -0
  48. package/dist/chunks/popover-fhdnt4be.js +4 -0
  49. package/dist/chunks/publish-panel-czkpg8m4.js +4 -0
  50. package/dist/chunks/{publish-quick-4fwngwrw.js → publish-quick-b9wbhqj6.js} +3 -3
  51. package/dist/chunks/publish-workspace-yy6d23b7.js +4 -0
  52. package/dist/chunks/quick-search-tp7pjnmv.js +4 -0
  53. package/dist/chunks/references-wd2yj8s1.js +4 -0
  54. package/dist/chunks/right-panel-x971k0zc.js +4 -0
  55. package/dist/chunks/source-control-k4p00hpe.js +4 -0
  56. package/dist/chunks/{split-m5rwn31z.js → split-qh21j2b0.js} +2 -2
  57. package/dist/chunks/{studio-v5wktj6f.js → studio-151kgn69.js} +4 -4
  58. package/dist/chunks/studio-15mfah02.js +10 -0
  59. package/dist/chunks/studio-1a7zshnv.js +5 -0
  60. package/dist/chunks/studio-1d8m5s38.js +16 -0
  61. package/dist/chunks/studio-35pmfjtx.js +5 -0
  62. package/dist/chunks/{studio-3na1r48j.js → studio-3yr1zdp9.js} +8 -8
  63. package/dist/chunks/studio-43pynnhz.js +5 -0
  64. package/dist/chunks/studio-44xqe0b7.js +4 -0
  65. package/dist/chunks/{studio-hh394phe.js → studio-48yzqzqr.js} +4 -4
  66. package/dist/chunks/{studio-9gr7xs9r.js → studio-4jy967rj.js} +5 -5
  67. package/dist/chunks/{studio-4bgvvzhv.js → studio-4ncxzw5m.js} +4 -4
  68. package/dist/chunks/{studio-pm17wdc4.js → studio-4s5bka8t.js} +4 -4
  69. package/dist/chunks/{studio-5tet77de.js → studio-4z0cdh16.js} +3 -3
  70. package/dist/chunks/studio-5rq3e4q5.js +5 -0
  71. package/dist/chunks/{studio-wmjmsw0p.js → studio-69g6a0jn.js} +4 -4
  72. package/dist/chunks/studio-6hcfm9q3.js +5 -0
  73. package/dist/chunks/studio-6mf31e16.js +5 -0
  74. package/dist/chunks/studio-6sxnqqw8.js +5 -0
  75. package/dist/chunks/{studio-0939t1q1.js → studio-7qqb7hb8.js} +4 -4
  76. package/dist/chunks/{studio-bg8pzprg.js → studio-7qr8k0v2.js} +4 -4
  77. package/dist/chunks/{studio-0x6f3nk9.js → studio-8ncm9zwx.js} +4 -4
  78. package/dist/chunks/studio-8w5pa88p.js +7 -0
  79. package/dist/chunks/{studio-3qr08sxq.js → studio-9f5vcb4w.js} +3 -3
  80. package/dist/chunks/studio-aaw7wxv8.js +5 -0
  81. package/dist/chunks/studio-afx9brsc.js +5 -0
  82. package/dist/chunks/studio-ajfq4fmr.js +5 -0
  83. package/dist/chunks/{studio-evq47j2z.js → studio-ber1dgte.js} +4 -4
  84. package/dist/chunks/{studio-sce30r7b.js → studio-bk0anq3b.js} +3 -3
  85. package/dist/chunks/{studio-0gvvqj3h.js → studio-bmwgj8ez.js} +4 -4
  86. package/dist/chunks/{studio-s24mhq0z.js → studio-bpzwhn88.js} +4 -4
  87. package/dist/chunks/{studio-g9brbewz.js → studio-c72v1kwc.js} +4 -4
  88. package/dist/chunks/{studio-tpxcryf2.js → studio-cv6gnjqd.js} +4 -4
  89. package/dist/chunks/{studio-90zkgne5.js → studio-czpmzs9r.js} +7 -7
  90. package/dist/chunks/{studio-2pk7kc7z.js → studio-d5f7sskn.js} +4 -4
  91. package/dist/chunks/studio-dmgh2rrj.js +5 -0
  92. package/dist/chunks/{studio-nwf0c32p.js → studio-dst8y8vh.js} +4 -4
  93. package/dist/chunks/studio-ehj3csbm.js +6 -0
  94. package/dist/chunks/{studio-hfqpfmpk.js → studio-ek4fb4eb.js} +4 -4
  95. package/dist/chunks/studio-ep1384t9.js +5 -0
  96. package/dist/chunks/{studio-abh9kfdh.js → studio-eq5vqn1q.js} +4 -4
  97. package/dist/chunks/studio-erjccagy.js +5 -0
  98. package/dist/chunks/studio-ex1644g2.js +5 -0
  99. package/dist/chunks/studio-f3awr5nc.js +12 -0
  100. package/dist/chunks/studio-f69wcajk.js +9 -0
  101. package/dist/chunks/{studio-jma2q8r9.js → studio-g65t6svn.js} +4 -4
  102. package/dist/chunks/{studio-r6mx7vm5.js → studio-gw83z798.js} +5 -5
  103. package/dist/chunks/{studio-2x972v58.js → studio-hy936516.js} +4 -4
  104. package/dist/chunks/studio-j3bq30yz.js +5 -0
  105. package/dist/chunks/studio-j52ks8q7.js +5 -0
  106. package/dist/chunks/{studio-g0hxtcga.js → studio-jqvke4ac.js} +4 -4
  107. package/dist/chunks/{studio-bnc50f9h.js → studio-k05g7efh.js} +4 -4
  108. package/dist/chunks/studio-kefs4sz4.js +5 -0
  109. package/dist/chunks/{studio-2e4hry72.js → studio-ketrgtph.js} +5 -5
  110. package/dist/chunks/studio-m3jrjfhy.js +14 -0
  111. package/dist/chunks/{studio-45mj4jqe.js → studio-m8hesdrw.js} +4 -4
  112. package/dist/chunks/{studio-k0xzrse2.js → studio-n271vdsg.js} +36 -36
  113. package/dist/chunks/studio-n4pnf0sw.js +5 -0
  114. package/dist/chunks/{studio-gnazwtv3.js → studio-ncwx602y.js} +4 -4
  115. package/dist/chunks/{studio-amaz545p.js → studio-nngc45ct.js} +4 -4
  116. package/dist/chunks/{studio-eqcddvc2.js → studio-nv5eq0f7.js} +173 -173
  117. package/dist/chunks/{studio-az3wvyh2.js → studio-nzp0sqjg.js} +4 -4
  118. package/dist/chunks/{studio-k2evngrj.js → studio-pgze9p34.js} +5 -5
  119. package/dist/chunks/studio-ph17an19.js +5 -0
  120. package/dist/chunks/studio-pte5w7rf.js +9 -0
  121. package/dist/chunks/studio-pv1ts08p.js +6 -0
  122. package/dist/chunks/studio-q509sqt8.js +5 -0
  123. package/dist/chunks/{studio-rr00ex23.js → studio-qarg1xf9.js} +6 -6
  124. package/dist/chunks/studio-r5g7fx7q.js +9 -0
  125. package/dist/chunks/studio-s49y6m5b.js +5 -0
  126. package/dist/chunks/studio-s4k1v7dt.js +7 -0
  127. package/dist/chunks/studio-t46rt6gx.js +5 -0
  128. package/dist/chunks/{studio-61rg2cz5.js → studio-td3h1644.js} +4 -4
  129. package/dist/chunks/{studio-v7a7yb5w.js → studio-thgexj3w.js} +7 -7
  130. package/dist/chunks/{studio-vpevkfxw.js → studio-veh91hjv.js} +4 -4
  131. package/dist/chunks/studio-vswmz40d.js +6 -0
  132. package/dist/chunks/{studio-daksw8kh.js → studio-w3aafbpk.js} +4 -4
  133. package/dist/chunks/studio-w4mfaaam.js +5 -0
  134. package/dist/chunks/{studio-4zd6an2b.js → studio-wn2cmf9q.js} +4 -4
  135. package/dist/chunks/studio-x5ffyssw.js +5 -0
  136. package/dist/chunks/{studio-swedpd82.js → studio-x5py815g.js} +4 -4
  137. package/dist/chunks/{studio-4fnb5648.js → studio-xh6rf05g.js} +4 -4
  138. package/dist/chunks/studio-z6s56dzz.js +5 -0
  139. package/dist/chunks/{studio-nvcn2ykc.js → studio-zav9hgb3.js} +4 -4
  140. package/dist/chunks/{studio-a88ej93p.js → studio-ze1sm147.js} +6 -6
  141. package/dist/chunks/{studio-pj216fmn.js → studio-zkkt13qd.js} +4 -4
  142. package/dist/chunks/studio-zn7ysyzb.js +5 -0
  143. package/dist/chunks/{studio-xx3r3sb0.js → studio-ztzf8mce.js} +4 -4
  144. package/dist/chunks/{studio-1410q5dw.js → studio-zzgp5yka.js} +4 -4
  145. package/dist/chunks/{tooltip-dj1pvgz7.js → tooltip-gtx40bjg.js} +3 -3
  146. package/dist/chunks/{tree-j0ebwr5s.js → tree-a19s1c05.js} +2 -2
  147. package/dist/chunks/tsMode-3f116hth.js +14 -0
  148. package/dist/chunks/workspace-home-61n1jz3q.js +4 -0
  149. package/dist/chunks/workspace-sheek6zw.js +4 -0
  150. package/dist/chunks/{ws-client-arz2qsy9.js → ws-client-cb49m87s.js} +3 -3
  151. package/dist/iframe-entry.js +14 -14
  152. package/dist/manifest.json +82 -82
  153. package/dist/studio.js +97 -96
  154. package/dist/workers/ts.worker.js +3 -3
  155. package/package.json +11 -11
  156. package/src/browse/library-layouts.ts +9 -12
  157. package/src/browse/library-pane.ts +22 -16
  158. package/src/browse/library-source.ts +3 -4
  159. package/src/canvas/canvas-live-render.ts +2 -5
  160. package/src/canvas/canvas-page-context.ts +5 -0
  161. package/src/content/entry-commands.ts +32 -27
  162. package/src/content/entry-editor.ts +46 -44
  163. package/src/content/entry-inspector.ts +15 -12
  164. package/src/content/entry-model.ts +3 -3
  165. package/src/content/entry-summaries.ts +5 -5
  166. package/src/content/hold-state.ts +172 -0
  167. package/src/content/page-summaries.ts +10 -14
  168. package/src/editor/inline-edit.ts +6 -1
  169. package/src/editor/repeater-scope.ts +333 -74
  170. package/src/grid/sources/content-source.ts +42 -66
  171. package/src/panels/block-action-bar.ts +3 -4
  172. package/src/panels/events-panel.ts +6 -2
  173. package/src/panels/formula-workspace.ts +9 -0
  174. package/src/panels/properties-panel.ts +127 -35
  175. package/src/panels/statement-editor.ts +4 -0
  176. package/src/panels/tab-strip.ts +29 -26
  177. package/src/services/ai-project-tools.ts +3 -3
  178. package/src/services/ai-tools.ts +5 -5
  179. package/src/services/ai-write-report.ts +22 -4
  180. package/src/services/context-manager.ts +110 -2
  181. package/src/services/document-assistant.ts +2 -2
  182. package/src/services/harness/chat-reducer.ts +9 -4
  183. package/src/services/render-critic.ts +234 -12
  184. package/src/services/tool-executor.ts +16 -8
  185. package/src/surfaces/collections-panel.json +1 -1
  186. package/src/surfaces/collections-panel.ts +1 -1
  187. package/src/surfaces/entry-editor.json +10 -10
  188. package/src/surfaces/entry-editor.ts +22 -19
  189. package/src/surfaces/entry-inspector.json +11 -11
  190. package/src/surfaces/entry-inspector.ts +17 -10
  191. package/src/surfaces/tab-strip.json +5 -5
  192. package/src/surfaces/tab-strip.ts +2 -2
  193. package/src/types.ts +2 -0
  194. package/src/ui/expression-editor.ts +10 -5
  195. package/src/ui/schema-form.ts +18 -1
  196. package/src/utils/edit-display.ts +27 -1
  197. package/dist/chunks/2020-906vx2dq.js +0 -4
  198. package/dist/chunks/adapter-hnvad61x.js +0 -4
  199. package/dist/chunks/cf-settings-mkbcz4z2.js +0 -4
  200. package/dist/chunks/color-04361ttk.js +0 -4
  201. package/dist/chunks/components-w5zqwva1.js +0 -4
  202. package/dist/chunks/content-source-g38w0d3f.js +0 -4
  203. package/dist/chunks/editor-wvf5e6j9.js +0 -4
  204. package/dist/chunks/entry-commands-t13dyw3c.js +0 -4
  205. package/dist/chunks/file-ops-1sghzgcr.js +0 -4
  206. package/dist/chunks/files-zrqtgt41.js +0 -4
  207. package/dist/chunks/format-host-9gy76erh.js +0 -4
  208. package/dist/chunks/fs-events-45rhg3me.js +0 -4
  209. package/dist/chunks/grid-open-t4fq6bbm.js +0 -4
  210. package/dist/chunks/iframe-host-9aq8ze3q.js +0 -4
  211. package/dist/chunks/index-f9qr7kzh.js +0 -4
  212. package/dist/chunks/jsonMode-vj30te2h.js +0 -13
  213. package/dist/chunks/jx-validate-r4ax11e3.js +0 -4
  214. package/dist/chunks/layers-m49nmwvx.js +0 -4
  215. package/dist/chunks/layers-panel-zr92pwdp.js +0 -4
  216. package/dist/chunks/lit-html-k2mb9k0r.js +0 -4
  217. package/dist/chunks/media-library-j74rt0w4.js +0 -4
  218. package/dist/chunks/media-open-mypr0k6b.js +0 -4
  219. package/dist/chunks/media-picker-pemtbsjv.js +0 -4
  220. package/dist/chunks/menu-d2jxpgma.js +0 -4
  221. package/dist/chunks/monaco-buffer-pa0g6ax7.js +0 -4
  222. package/dist/chunks/monaco-setup-0hw9f835.js +0 -4
  223. package/dist/chunks/notify-bwehrp7q.js +0 -4
  224. package/dist/chunks/platform-kv0k65cy.js +0 -4
  225. package/dist/chunks/popover-9tf4cjrk.js +0 -4
  226. package/dist/chunks/publish-panel-vg1n880b.js +0 -4
  227. package/dist/chunks/publish-workspace-jv4w2bx2.js +0 -4
  228. package/dist/chunks/quick-search-nh29rt8a.js +0 -4
  229. package/dist/chunks/references-vqyr5s9x.js +0 -4
  230. package/dist/chunks/right-panel-3ws9f2w0.js +0 -4
  231. package/dist/chunks/source-control-h3hcs5gy.js +0 -4
  232. package/dist/chunks/studio-0khd8xtx.js +0 -4
  233. package/dist/chunks/studio-1ctnw3xv.js +0 -5
  234. package/dist/chunks/studio-1ks5d9y4.js +0 -5
  235. package/dist/chunks/studio-1y9mab9m.js +0 -5
  236. package/dist/chunks/studio-2kpxm0eh.js +0 -7
  237. package/dist/chunks/studio-2zz4qbv9.js +0 -5
  238. package/dist/chunks/studio-3pz4t5a2.js +0 -5
  239. package/dist/chunks/studio-4f7thq0p.js +0 -5
  240. package/dist/chunks/studio-7rrdbvex.js +0 -5
  241. package/dist/chunks/studio-9dq7rwc7.js +0 -5
  242. package/dist/chunks/studio-acer0g8w.js +0 -5
  243. package/dist/chunks/studio-brh8d2pr.js +0 -5
  244. package/dist/chunks/studio-c2fa783p.js +0 -9
  245. package/dist/chunks/studio-cfnn70h1.js +0 -5
  246. package/dist/chunks/studio-cqxj4fcy.js +0 -5
  247. package/dist/chunks/studio-cw11h078.js +0 -5
  248. package/dist/chunks/studio-dn56z7sn.js +0 -5
  249. package/dist/chunks/studio-egnjpyde.js +0 -9
  250. package/dist/chunks/studio-ejrypvmq.js +0 -5
  251. package/dist/chunks/studio-frc16j8v.js +0 -10
  252. package/dist/chunks/studio-j0zf5x2x.js +0 -5
  253. package/dist/chunks/studio-j5fcp6gj.js +0 -5
  254. package/dist/chunks/studio-jd1qb1st.js +0 -7
  255. package/dist/chunks/studio-k1xqrgm5.js +0 -5
  256. package/dist/chunks/studio-k3we3kqb.js +0 -5
  257. package/dist/chunks/studio-knsjyx7n.js +0 -5
  258. package/dist/chunks/studio-mrjtnm62.js +0 -5
  259. package/dist/chunks/studio-n9qba0gx.js +0 -5
  260. package/dist/chunks/studio-nzemfbkk.js +0 -14
  261. package/dist/chunks/studio-pwctzka3.js +0 -16
  262. package/dist/chunks/studio-q80fg1d6.js +0 -12
  263. package/dist/chunks/studio-q9hx2qp0.js +0 -5
  264. package/dist/chunks/studio-qrzmfaf6.js +0 -5
  265. package/dist/chunks/studio-tmfcn347.js +0 -6
  266. package/dist/chunks/studio-wqfaqw4m.js +0 -5
  267. package/dist/chunks/studio-xpx91fby.js +0 -6
  268. package/dist/chunks/studio-xva3hzkv.js +0 -5
  269. package/dist/chunks/studio-z3fztxrp.js +0 -9
  270. package/dist/chunks/studio-z9453ypn.js +0 -6
  271. package/dist/chunks/tsMode-jg1dtphj.js +0 -14
  272. package/dist/chunks/workspace-6ztqcrsh.js +0 -4
  273. package/dist/chunks/workspace-home-rse9h8a0.js +0 -4
  274. package/src/content/draft-state.ts +0 -108
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Repeater scope resolution — enumerate the local `item` / `index` / field tokens available when
3
- * the author inline-edits text INSIDE a repeater (`$prototype: "Array"`) on the design canvas.
2
+ * Repeater scope resolution: what the item of a repeater (`$prototype: "Array"`) holds, deduced
3
+ * from the document and the project without running either.
4
+ *
5
+ * Two surfaces ask. Inline text edited INSIDE a repeater inserts a `${item.data.title}` token, and
6
+ * the `From data…` rung binds a `$map/item/data/title` pointer. They are the same field spelled for
7
+ * two grammars, so there is one walk and each field carries both spellings. A third surface asks
8
+ * for the one field that is neither text nor a value: `$children`, which an element draws by
9
+ * holding it as computed children (spec.md §8.4).
4
10
  *
5
11
  * There is no live `$map` in edit mode: `prepareForEditMode` renders a mapped array as a
6
12
  * `repeater-perimeter` wrapping one glyph-ified template instance, so `renderMappedArrayInto`
@@ -9,23 +15,30 @@
9
15
  * document path (which carries a real `"map"` segment — see canvas/path-mapping.ts
10
16
  * `classifyRenderNode`).
11
17
  *
12
- * Pure and DOM-free: deps limited to `@jxsuite/schema` guards/types + {@link getNodeAtPath}.
18
+ * Pure and DOM-free: deps limited to `@jxsuite/schema` guards/types, the runtime's pointer grammar
19
+ *
20
+ * - {@link getNodeAtPath}.
21
+ *
22
+ * @docs studio/design/repeaters
13
23
  */
14
24
 
25
+ import { escapeToken, refSegments } from "@jxsuite/runtime/pointer";
15
26
  import { getNodeAtPath } from "@jxsuite/schema/doc-ops";
16
27
  import {
17
28
  isJsonObject,
18
29
  isMappedArray,
19
- isNonNullObject,
20
30
  isRef,
31
+ isSchemaOnlyDef,
21
32
  isString,
22
33
  } from "@jxsuite/schema/guards";
23
34
 
24
35
  import type { JxPath } from "../state";
25
36
  import type {
37
+ JsonObject,
38
+ JsonValue,
39
+ JxDocumentValue,
26
40
  JxMappedArray,
27
41
  JxMutableNode,
28
- JxStateDefinition,
29
42
  ProjectConfig,
30
43
  } from "@jxsuite/schema/types";
31
44
 
@@ -58,93 +71,339 @@ export function findEnclosingRepeater(
58
71
  return isMappedArray(node) ? node : null;
59
72
  }
60
73
 
61
- /** Strip a `#/state/<name>` JSON-Pointer ref to its bare state name (null when it is not one). */
62
- function refToStateName(binding: JxMappedArray["items"]): string | null {
74
+ /** One field of a repeater's item, in each grammar that can name it. */
75
+ export interface RepeaterField {
76
+ /** The members walked from the item, unescaped. */
77
+ path: string[];
78
+ /** What a template reads and a person is shown: `item.data.title`. */
79
+ token: string;
80
+ /** What a `$ref` holds: `$map/item/data/title`, with each member escaped (RFC 6901 §3). */
81
+ pointer: string;
82
+ /** Whether the field holds a list, which is all a nested repeater's Items can read. */
83
+ isArray: boolean;
84
+ /** Whether the field holds child nodes, which only an element's computed children can draw. */
85
+ holdsNodes: boolean;
86
+ }
87
+
88
+ /** The one `$`-prefixed member an item offers: the Jx tree a parsed body came out as. */
89
+ const NODES_MEMBER = "$children";
90
+
91
+ /** The content format whose loader parses an entry's body into {@link NODES_MEMBER}. */
92
+ const NODES_FORMAT = "Markdown";
93
+
94
+ /** What every step of the walk reads from. */
95
+ interface ItemSources {
96
+ doc: JxMutableNode;
97
+ config: ProjectConfig | null | undefined;
98
+ }
99
+
100
+ /** Marks a schema as an entry of this content type, so a relationship cycle can be stopped. */
101
+ const ENTRY_TYPE = "$entryType";
102
+
103
+ /** Marks an entry's `data` member, which is a level of the path rather than a field to offer. */
104
+ const ENTRY_DATA = "$entryData";
105
+
106
+ /** How many members a sample list contributes to the shape it implies. */
107
+ const SAMPLE_CAP = 20;
108
+
109
+ /** How many members deep a field path may run, so a walk of a deep schema stays a short list. */
110
+ const DEPTH_CAP = 6;
111
+
112
+ /** How many pointers a `$ref` chain may follow, so `a → b → a` ends. */
113
+ const HOP_CAP = 8;
114
+
115
+ /** The shape of what a content type's loader hands back (`ContentLoaderEntry`). */
116
+ function entrySchema(contentType: string, ctx: ItemSources): JsonObject {
117
+ /* The `content` section is contributed by an extension and absorbed by the project config's index
118
+ signature, so each level is checked before it is read. */
119
+ const content: unknown = ctx.config?.content;
120
+ const type = isJsonObject(content) ? content[contentType] : undefined;
121
+ const data = isJsonObject(type) && isJsonObject(type.schema) ? type.schema : {};
122
+ // A CSV row has no body to parse. A type naming no format is not known to lack one.
123
+ const format = isJsonObject(type) ? type.format : undefined;
124
+ const hasNodes = !isString(format) || format === NODES_FORMAT;
125
+ // Built from entries because the order is what the picker lists: id and body, then the fields.
126
+ const members: [string, JsonValue][] = [
127
+ ["id", {}],
128
+ ["body", {}],
129
+ ...(hasNodes ? ([[NODES_MEMBER, {}]] satisfies [string, JsonValue][]) : []),
130
+ ["data", { ...data, [ENTRY_DATA]: true }],
131
+ ];
132
+ return { [ENTRY_TYPE]: contentType, properties: Object.fromEntries(members) };
133
+ }
134
+
135
+ /** Whether a schema's own keys say what is inside it, so a sample has nothing to add. */
136
+ function describesFields(schema: JsonObject | null, ctx: ItemSources): boolean {
137
+ return (
138
+ isJsonObject(schema?.properties) || isJsonObject(followPointers(schema?.items, ctx)?.properties)
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Follow a schema's pointers to the schema they name, or `null` for one this walk cannot read.
144
+ *
145
+ * Three spellings reach another schema: `{ "$ref": "#/content/authors" }` is a relationship, whose
146
+ * value the loader resolves to the full entry; `{ "$ref": "#/$defs/Member" }` is a shared type; and
147
+ * `{ "type": { "$ref": … } }` is a typed value naming one.
148
+ */
149
+ function followPointers(
150
+ schema: JxDocumentValue | undefined,
151
+ ctx: ItemSources,
152
+ hops = 0,
153
+ ): JsonObject | null {
154
+ if (!isJsonObject(schema) || hops > HOP_CAP) {
155
+ return null;
156
+ }
157
+ const pointer = isRef(schema) ? schema.$ref : isRef(schema.type) ? schema.type.$ref : null;
158
+ if (pointer === null) {
159
+ return schema;
160
+ }
161
+ if (pointer.startsWith("#/content/")) {
162
+ return entrySchema(pointer.slice("#/content/".length), ctx);
163
+ }
164
+ if (pointer.startsWith("#/$defs/")) {
165
+ const [name, ...rest] = refSegments(pointer.slice("#/$defs/".length));
166
+ const projectDefs: unknown = ctx.config?.$defs;
167
+ const declared =
168
+ name === undefined
169
+ ? undefined
170
+ : (ctx.doc.$defs?.[name] ?? (isJsonObject(projectDefs) ? projectDefs[name] : undefined));
171
+ return walkSchema(followPointers(declared, ctx, hops + 1), rest, ctx);
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** The union of two implied shapes, so an optional field the first row lacks is still found. */
177
+ function mergeSamples(a: JsonObject, b: JsonObject): JsonObject {
178
+ const merged: JsonObject = { ...a, ...b };
179
+ if (isJsonObject(a.items) && isJsonObject(b.items)) {
180
+ merged.items = mergeSamples(a.items, b.items);
181
+ }
182
+ if (isJsonObject(a.properties) && isJsonObject(b.properties)) {
183
+ const properties: JsonObject = { ...a.properties, ...b.properties };
184
+ for (const [key, left] of Object.entries(a.properties)) {
185
+ const right = b.properties[key];
186
+ if (isJsonObject(left) && isJsonObject(right)) {
187
+ properties[key] = mergeSamples(left, right);
188
+ }
189
+ }
190
+ merged.properties = properties;
191
+ }
192
+ return merged;
193
+ }
194
+
195
+ /** The shape a sample value implies. */
196
+ function sampleSchema(value: JsonValue | undefined): JsonObject {
197
+ if (Array.isArray(value)) {
198
+ let items: JsonObject = {};
199
+ for (const member of value.slice(0, SAMPLE_CAP)) {
200
+ items = mergeSamples(items, sampleSchema(member));
201
+ }
202
+ return { items };
203
+ }
204
+ if (isJsonObject(value)) {
205
+ return {
206
+ properties: Object.fromEntries(
207
+ Object.entries(value).map(([key, member]) => [key, sampleSchema(member)]),
208
+ ),
209
+ };
210
+ }
211
+ return {};
212
+ }
213
+
214
+ /** The shape of a state entry's value, or `null` when only running it would say. */
215
+ function stateSchema(def: JxDocumentValue | undefined, ctx: ItemSources): JsonObject | null {
216
+ if (Array.isArray(def)) {
217
+ // SAFETY: a naked array state entry is the JSON value it reads as (schema/types `JxStateDefinition`).
218
+ return sampleSchema(def as JsonValue[]);
219
+ }
220
+ if (!isJsonObject(def)) {
221
+ return null;
222
+ }
223
+ /* Detected on the `contentType` string, not the prototype's name, so an import-aliased
224
+ `$prototype` still matches. An `id` is what asks for one entry rather than the list. */
225
+ if (isString(def.contentType)) {
226
+ const entry = entrySchema(def.contentType, ctx);
227
+ return def.$prototype === "ContentEntry" || def.id !== undefined ? entry : { items: entry };
228
+ }
229
+ const declared = followPointers(def, ctx);
230
+ if (describesFields(declared, ctx)) {
231
+ return declared;
232
+ }
233
+ if ("default" in def) {
234
+ return sampleSchema(def.default);
235
+ }
236
+ const isNakedObject = !isSchemaOnlyDef(def) && !Object.keys(def).some((k) => k.startsWith("$"));
237
+ return isNakedObject ? sampleSchema(def) : declared;
238
+ }
239
+
240
+ /** Follow members down a schema, or `null` where the schema stops saying. */
241
+ function walkSchema(
242
+ schema: JsonObject | null,
243
+ segments: readonly string[],
244
+ ctx: ItemSources,
245
+ ): JsonObject | null {
246
+ let current = followPointers(schema, ctx);
247
+ for (const segment of segments) {
248
+ const member = isJsonObject(current?.properties) ? current.properties[segment] : undefined;
249
+ current = followPointers(member ?? (/^\d+$/.test(segment) ? current?.items : undefined), ctx);
250
+ }
251
+ return current;
252
+ }
253
+
254
+ /** The shape of what a repeater's `items` binding reads, in the scope of the repeater itself. */
255
+ function bindingSchema(
256
+ binding: JxMappedArray["items"],
257
+ repeaterPath: JxPath,
258
+ ctx: ItemSources,
259
+ ): JsonObject | null {
260
+ if (Array.isArray(binding)) {
261
+ // SAFETY: an inline `items` list is read straight out of the JSON document.
262
+ return sampleSchema(binding as JsonValue[]);
263
+ }
63
264
  if (!isRef(binding)) {
64
265
  return null;
65
266
  }
66
- const { $ref: target } = binding;
67
- return target.startsWith("#/state/") ? target.slice("#/state/".length) : null;
267
+ const ref = binding.$ref.replace(/^#\/\$map\//, "$map/");
268
+ if (ref.startsWith("#/state/")) {
269
+ const [name, ...rest] = refSegments(ref.slice("#/state/".length));
270
+ const def = name === undefined ? undefined : ctx.doc.state?.[name];
271
+ return walkSchema(stateSchema(def, ctx), rest, ctx);
272
+ }
273
+ // A nested repeater reads a member of the repeater around it, so the walk starts from that item.
274
+ if (ref === "$map/item" || ref.startsWith("$map/item/")) {
275
+ const rest = refSegments(ref.slice("$map/item/".length));
276
+ return walkSchema(itemSchemaAt(repeaterPath, ctx), rest, ctx);
277
+ }
278
+ return null;
279
+ }
280
+
281
+ /** The shape of the item in scope at `path`, or `null` outside a repeater or past what is known. */
282
+ function itemSchemaAt(path: JxPath, ctx: ItemSources): JsonObject | null {
283
+ const repeater = findEnclosingRepeater(ctx.doc, path);
284
+ if (!repeater) {
285
+ return null;
286
+ }
287
+ const list = bindingSchema(repeater.items, path.slice(0, path.lastIndexOf("map")), ctx);
288
+ return followPointers(list?.items, ctx);
68
289
  }
69
290
 
70
- /** Cap + $-filter a field-name list to a stable, author-facing subset. */
71
- function safeFields(names: string[]): string[] {
72
- return names.filter((k) => !k.startsWith("$")).slice(0, FIELD_CAP);
291
+ /** A field name as a template reads it: a member access where a name allows one, else a bracket. */
292
+ function fieldToken(path: readonly string[]): string {
293
+ let token = "item";
294
+ for (const member of path) {
295
+ token += /^[A-Za-z_$][\w$]*$/.test(member) ? `.${member}` : `[${JSON.stringify(member)}]`;
296
+ }
297
+ return token;
73
298
  }
74
299
 
75
300
  /**
76
- * Resolve the local scope tokens for text edited inside `arrayNode`. Always leads with `item` and
77
- * `index`; appends field tokens resolved from the repeater's binding:
301
+ * Every field the item in scope at `path` is known to hold, nearest the item first.
78
302
  *
79
- * - **Content collection** — `items` refs a `{ contentType }` state def. Item shape is `{ id, data: {
80
- * …fields }, body }`, so fields come from `projectConfig.content[contentType].schema.properties`
81
- * and emit as `item.data.<f>` (the `.data.` nesting is LOAD-BEARING — runtime templates use
82
- * `${item.data.title}`), plus `item.id` / `item.body`.
83
- * - **State array** — `items` refs a state def that is an array of objects. Fields come from a
84
- * declared `items.properties`, else are inferred from a sample `default[0]`. Emitted FLAT
85
- * (`item.<f>`, no `.data.`).
86
- * - **Inline array** — `items` is a literal array; fields inferred from `items[0]` object keys.
87
- * - **Fallback** — anything unresolvable yields exactly `["item", "index"]`.
303
+ * Walked a level at a time so {@link FIELD_CAP} cuts the deepest fields and never a top-level one.
304
+ * An object is offered as well as its members, because a component can take the whole value. A list
305
+ * is offered and not entered: one of its members has no single value to bind. A relationship is
306
+ * entered unless the branch already passed through that content type, which ends a post → author →
307
+ * post cycle.
88
308
  *
89
- * @param arrayNode - The repeater node from {@link findEnclosingRepeater}.
90
- * @param state - The document's `state` definitions (`tab.doc.document.state`).
309
+ * `$children` is left out unless asked for: it is a tree of nodes, so a text position, an attribute
310
+ * or a formula operand that read it would draw `[object Object]`.
311
+ *
312
+ * @param doc - The tab's document root (`tab.doc.document`).
313
+ * @param path - The path of the node asking.
91
314
  * @param projectConfig - Parsed `project.json` (`projectState?.projectConfig`).
92
- * @returns Token strings, e.g. `["item", "index", "item.id", "item.body", "item.data.title"]`.
315
+ * @param options - `withNodes` also lists the fields that hold child nodes.
316
+ * @returns The fields, empty outside a repeater and wherever the item's shape cannot be deduced.
93
317
  */
94
- export function resolveRepeaterItemFields(
95
- arrayNode: JxMappedArray,
96
- state: Readonly<Record<string, JxStateDefinition>> | null | undefined,
318
+ export function repeaterItemFields(
319
+ doc: JxMutableNode,
320
+ path: JxPath | null | undefined,
97
321
  projectConfig: ProjectConfig | null | undefined,
98
- ): string[] {
99
- const base = ["item", "index"];
100
- const { items } = arrayNode;
101
- const defs = state ?? {};
102
-
103
- const stateName = refToStateName(items);
104
- if (stateName) {
105
- const def = defs[stateName];
106
- if (isJsonObject(def)) {
107
- // (a) Content collection — `{ contentType: "<name>" }`. Import-aliased `$prototype` still
108
- // Matches because we detect on the `contentType` string, not the prototype name.
109
- if (isString(def.contentType)) {
110
- /* The `content` section is contributed by an extension and absorbed by the project
111
- config's index signature, so each level is checked before it is read. */
112
- const content = projectConfig?.content;
113
- const ct = isJsonObject(content) ? content[def.contentType] : undefined;
114
- const ctSchema = isJsonObject(ct) ? ct.schema : undefined;
115
- const props = isJsonObject(ctSchema) ? ctSchema.properties : undefined;
116
- const out = [...base, "item.id", "item.body"];
117
- if (isNonNullObject(props)) {
118
- for (const f of safeFields(Object.keys(props))) {
119
- out.push(`item.data.${f}`);
120
- }
121
- }
122
- return out;
322
+ { withNodes = false }: { withNodes?: boolean } = {},
323
+ ): RepeaterField[] {
324
+ if (!Array.isArray(path)) {
325
+ return [];
326
+ }
327
+ const ctx: ItemSources = { config: projectConfig, doc };
328
+ const fields: RepeaterField[] = [];
329
+ const pending: { at: string[]; schema: JsonObject | null; via: string[] }[] = [
330
+ { at: [], schema: itemSchemaAt(path, ctx), via: [] },
331
+ ];
332
+ for (const { at, schema, via } of pending) {
333
+ const entryType = isString(schema?.[ENTRY_TYPE]) ? schema[ENTRY_TYPE] : null;
334
+ const passed = entryType === null ? via : [...via, entryType];
335
+ const members = isJsonObject(schema?.properties) ? Object.entries(schema.properties) : [];
336
+ for (const [key, member] of members) {
337
+ const holdsNodes = key === NODES_MEMBER;
338
+ if ((key.startsWith("$") && !(holdsNodes && withNodes)) || fields.length >= FIELD_CAP) {
339
+ continue;
123
340
  }
124
-
125
- // (b) State array — declared object item-schema, or inferred from a sample default[0].
126
- const itemsSchema = def.items;
127
- const declared = isJsonObject(itemsSchema) ? itemsSchema.properties : undefined;
128
- if (isNonNullObject(declared)) {
129
- return [...base, ...safeFields(Object.keys(declared)).map((f) => `item.${f}`)];
341
+ const memberPath = [...at, key];
342
+ const resolved = followPointers(member, ctx);
343
+ if (resolved?.[ENTRY_DATA] !== true) {
344
+ fields.push({
345
+ holdsNodes,
346
+ isArray: resolved?.type === "array" || isJsonObject(resolved?.items),
347
+ path: memberPath,
348
+ pointer: `$map/item/${memberPath.map((m) => escapeToken(m)).join("/")}`,
349
+ token: fieldToken(memberPath),
350
+ });
130
351
  }
131
- const defaults = def.default;
132
- const sample = Array.isArray(defaults) ? defaults[0] : undefined;
133
- if (isJsonObject(sample)) {
134
- return [...base, ...safeFields(Object.keys(sample)).map((f) => `item.${f}`)];
352
+ const type = isString(resolved?.[ENTRY_TYPE]) ? resolved[ENTRY_TYPE] : null;
353
+ if (memberPath.length < DEPTH_CAP && !(type !== null && passed.includes(type))) {
354
+ pending.push({ at: memberPath, schema: resolved, via: passed });
135
355
  }
136
356
  }
137
- return base;
138
357
  }
358
+ return fields;
359
+ }
139
360
 
140
- // (c) Inline literal array — infer fields from the first object element.
141
- if (Array.isArray(items)) {
142
- const [first] = items;
143
- if (isJsonObject(first)) {
144
- return [...base, ...safeFields(Object.keys(first)).map((f) => `item.${f}`)];
145
- }
146
- }
361
+ /**
362
+ * The local scope tokens for text edited inside a repeater: `item` and `index`, then the fields of
363
+ * the item in scope (see {@link repeaterItemFields}).
364
+ *
365
+ * @returns Token strings, e.g. `["item", "index", "item.id", "item.body", "item.data.title"]`.
366
+ */
367
+ export function resolveRepeaterItemFields(
368
+ doc: JxMutableNode,
369
+ path: JxPath | null | undefined,
370
+ projectConfig: ProjectConfig | null | undefined,
371
+ ): string[] {
372
+ return ["item", "index", ...repeaterItemFields(doc, path, projectConfig).map((f) => f.token)];
373
+ }
147
374
 
148
- // (d) Fallback — unresolvable binding, primitive/empty items, or missing schema.
149
- return base;
375
+ /**
376
+ * The `children` template that draws a field's nodes. The fallback is part of it because the field
377
+ * is only known to be possible: an entry loaded without a body has no `$children` to read.
378
+ */
379
+ export function childrenTemplate(field: RepeaterField): string {
380
+ return `\${${field.token} ?? []}`;
381
+ }
382
+
383
+ /**
384
+ * The field a `children` template reads, with or without the fallback {@link childrenTemplate}
385
+ * writes. Anything more than the bare field is the author's own expression and is left as theirs.
386
+ */
387
+ export function fieldOfChildrenTemplate(
388
+ template: string,
389
+ fields: readonly RepeaterField[],
390
+ ): RepeaterField | undefined {
391
+ const token = /^\$\{\s*(.+?)\s*(?:\?\?\s*\[\s*\]\s*)?\}$/u.exec(template)?.[1];
392
+ return fields.find((field) => field.token === token);
393
+ }
394
+
395
+ /**
396
+ * A `$map/…` pointer in the words {@link repeaterItemFields} offers it under, or `null` for any
397
+ * other pointer. A bound chip says the same name the picker did: `item.data.title`.
398
+ */
399
+ export function mapRefLabel(ref: string): string | null {
400
+ if (ref === "$map/index") {
401
+ return "index";
402
+ }
403
+ if (ref === "$map/item") {
404
+ return "item";
405
+ }
406
+ return ref.startsWith("$map/item/")
407
+ ? fieldToken(refSegments(ref.slice("$map/item/".length)))
408
+ : null;
150
409
  }
@@ -12,22 +12,18 @@
12
12
  * Collections type their columns from the content-type schema (plus inferred extras found in the
13
13
  * data); the pages grid is fully inference-driven.
14
14
  *
15
- * **A collection listing obeys the draft perspective (site-architecture.md §7.6).**
16
- * `content/draft-state.ts` owns both the one flag and the one definition of what a draft is, and
17
- * this source calls it rather than testing `draft === true` itself — which is what makes "including
18
- * drafts" mean the same thing here as it does on the tab chip. It can, because that module imports
19
- * nothing but `reactivity`: the richer `content/entry-model.ts` asks THIS file where a collection's
20
- * entries live, so an edge back to it would be the cycle `import/no-cycle` rejects.
15
+ * **A collection listing obeys the hold perspective (site-architecture.md §7.6).**
16
+ * `content/hold-state.ts` owns both the one flag and the reading of a project's rules, and this
17
+ * source calls it rather than reading fields itself, which is what makes "including held entries"
18
+ * mean the same thing here as it does on the tab chip. It can, because that module does not import
19
+ * the richer `content/entry-model.ts`, which asks THIS file where a collection's entries live, so
20
+ * an edge back to it would be the cycle `import/no-cycle` rejects.
21
21
  */
22
22
  import { getPlatform } from "../../platform";
23
23
  import { projectState } from "../../store";
24
24
  import { workspace } from "../../workspace/workspace";
25
- import {
26
- DRAFT_FIELD,
27
- applyDraftFilter,
28
- hasDraftAxis,
29
- includingDrafts,
30
- } from "../../content/draft-state";
25
+ import { applyHoldFilter, holdSwitch, includingHeld } from "../../content/hold-state";
26
+ import type { HoldBearer } from "../../content/hold-state";
31
27
  import { getGridController } from "../grid-controller";
32
28
  import { isRecentLocal, markLocalMutation } from "../../files/fs-events";
33
29
  import {
@@ -275,39 +271,38 @@ interface EntryFileSourceOptions {
275
271
  /** Inferred columns to front-load, in order (e.g. title/description for pages). */
276
272
  priorityFields?: string[];
277
273
  /**
278
- * Whether this listing is subject to the draft perspective (site-architecture.md §7.6) —
279
- * collections are, the pages tree is not. A draft is a property of a content ENTRY: a page
280
- * carrying `draft: true` would vanish from the only tree that lists it, with no collection schema
281
- * to explain why.
274
+ * The collection's definition, when this listing is subject to the hold perspective
275
+ * (site-architecture.md §7.6). Collections are and the pages tree is not: `hold` rules are
276
+ * written on a content type, and a page belongs to none.
282
277
  */
283
- drafts?: boolean;
278
+ holdDef?: () => HoldBearer | null | undefined;
284
279
  }
285
280
 
286
281
  /**
287
- * The live sources that obey the draft perspective.
282
+ * The live sources that obey the hold perspective.
288
283
  *
289
284
  * A `WeakSet` of the source objects rather than a list of tab-id prefixes: whether a listing is
290
- * draft filtered is decided by one `drafts: true` in this file, and re-deriving it from
291
- * `"grid://collection/"` in {@link reloadDraftAwareGrids} would be a second answer free to drift.
292
- * Membership is added by the engine off that same option, so the two cannot disagree. Weak, so a
293
- * closed tab's source is collectable.
285
+ * filtered is decided by one `holdDef` in this file, and re-deriving it from `"grid://collection/"`
286
+ * in {@link reloadHoldAwareGrids} would be a second answer free to drift. Membership is added by the
287
+ * engine off that same option, so the two cannot disagree. Weak, so a closed tab's source is
288
+ * collectable.
294
289
  */
295
- const draftAwareSources = new WeakSet<GridSource>();
290
+ const holdAwareSources = new WeakSet<GridSource>();
296
291
 
297
292
  /**
298
- * Reload every open grid whose rows depend on the draft perspective.
293
+ * Reload every open grid whose rows depend on the hold perspective.
299
294
  *
300
295
  * The perspective is a flag, and a grid holds its rows in controller state that was loaded once —
301
- * so `content.setIncludeDrafts` changing the flag has to say so. `load()` (not `refresh()`) is the
296
+ * so `content.setIncludeHeld` changing the flag has to say so. `load()` (not `refresh()`) is the
302
297
  * right verb: it re-pulls columns and rows and leaves the edit buffer alone, whereas `refresh()`
303
298
  * would stop to ask an author with pending edits whether to discard them, which is not a question a
304
299
  * change of perspective should provoke.
305
300
  */
306
- export async function reloadDraftAwareGrids(): Promise<void> {
301
+ export async function reloadHoldAwareGrids(): Promise<void> {
307
302
  const reloads: Promise<void>[] = [];
308
303
  for (const tab of workspace.tabs.values()) {
309
304
  const controller = getGridController(tab);
310
- if (controller && draftAwareSources.has(controller.source)) {
305
+ if (controller && holdAwareSources.has(controller.source)) {
311
306
  reloads.push(controller.load());
312
307
  }
313
308
  }
@@ -317,8 +312,8 @@ export async function reloadDraftAwareGrids(): Promise<void> {
317
312
  /** Shared engine for frontmatter-file sources (collections, pages). */
318
313
  function createEntryFileSource(opts: EntryFileSourceOptions): GridSource {
319
314
  const source = buildEntryFileSource(opts);
320
- if (opts.drafts) {
321
- draftAwareSources.add(source);
315
+ if (opts.holdDef) {
316
+ holdAwareSources.add(source);
322
317
  }
323
318
  return source;
324
319
  }
@@ -420,43 +415,26 @@ function buildEntryFileSource(opts: EntryFileSourceOptions): GridSource {
420
415
  extraColumns.sort((a, b) => rank(a) - rank(b));
421
416
  }
422
417
 
423
- columnsCache = withDraftColumn([pathColumn, ...schemaColumns, ...extraColumns]);
418
+ columnsCache = withHoldColumn([pathColumn, ...schemaColumns, ...extraColumns]);
424
419
  return columnsCache;
425
420
  };
426
421
 
427
- /** Whether this listing has a draft axis at all — the schema declares one, or an entry claims one. */
428
- const draftAxis = (): boolean => {
429
- if (!opts.drafts) {
430
- return false;
431
- }
432
- const schema = opts.schema();
433
- return (
434
- hasDraftAxis(schema) ||
435
- [...entries.values()].some((entry) => hasDraftAxis(schema, entry.frontmatter))
436
- );
437
- };
438
-
439
422
  /**
440
- * Put the Draft column immediately after Path, when this collection has a draft axis.
441
- *
442
- * A MOVE, and deliberately not a synthesis: wherever there is an axis there is already a column.
443
- * A schema that declares `draft` gave `columnsFromSchema` one, and an entry that carries the
444
- * field without the schema declaring it gave `inferColumnsFromRows` one — the two conditions of
445
- * {@link hasDraftAxis} are exactly the two conditions that produce a column. Building a third here
446
- * would be an unreachable branch pretending to be a safety net.
423
+ * Put the hold switch's column immediately after Path, when the collection's rules offer one.
447
424
  *
425
+ * A MOVE, and deliberately not a synthesis: a field the rules name but neither the schema nor any
426
+ * entry states has no column to move, and inventing one would show a state nobody wrote down.
448
427
  * What the move buys is that the one field deciding whether an entry appears in its own list is
449
428
  * not sitting out at column nine, where declaration order or first-sight order happened to put
450
- * it. A collection whose entries only ever say `draft: false` has NO axis and is left alone: the
451
- * column is ordinary data there, and promoting it would invent a workflow the project never
452
- * declared.
429
+ * it.
453
430
  */
454
- const withDraftColumn = (columns: GridColumn[]): GridColumn[] => {
455
- const index = columns.findIndex((column) => column.field === DRAFT_FIELD);
456
- if (index < 1 || !draftAxis()) {
431
+ const withHoldColumn = (columns: GridColumn[]): GridColumn[] => {
432
+ const field = holdSwitch(opts.holdDef?.())?.field;
433
+ const index = columns.findIndex((column) => column.field === field);
434
+ if (index < 1) {
457
435
  return columns;
458
436
  }
459
- const rest = columns.filter((column) => column.field !== DRAFT_FIELD);
437
+ const rest = columns.filter((column) => column.field !== field);
460
438
  return [rest[0]!, columns[index]!, ...rest.slice(1)];
461
439
  };
462
440
 
@@ -661,15 +639,13 @@ function buildEntryFileSource(opts: EntryFileSourceOptions): GridSource {
661
639
  async rows(): Promise<GridRowsResult> {
662
640
  await load();
663
641
  // The perspective is applied to the LISTING, not the load: `entries` keeps every parsed file,
664
- // So the Draft column still knows the collection has an axis while its drafts are hidden, and
665
- // Turning the perspective back on is a re-list rather than a re-read of the directory.
666
- const listed = opts.drafts
667
- ? applyDraftFilter(
668
- [...entries.entries()],
669
- ([, entry]) => entry.frontmatter,
670
- includingDrafts(),
671
- )
672
- : [...entries.entries()];
642
+ // So turning the perspective back on is a re-list rather than a re-read of the directory.
643
+ const listed = applyHoldFilter(
644
+ [...entries.entries()],
645
+ opts.holdDef?.(),
646
+ ([, entry]) => entry.frontmatter,
647
+ includingHeld(),
648
+ );
673
649
  const rows = listed.map(([path, entry]) => rowFor(path, entry));
674
650
  return { rows, total: rows.length };
675
651
  },
@@ -687,7 +663,7 @@ export function createCollectionSource(typeName: string): GridSource {
687
663
  };
688
664
 
689
665
  return createEntryFileSource({
690
- drafts: true,
666
+ holdDef: () => collectionInfo(typeName)?.def,
691
667
  id: makeGridTabId({ kind: "collection", name: typeName }),
692
668
  insertFormatName: (path) => {
693
669
  const info = requireInfo();
@@ -1193,11 +1193,10 @@ function openMergeTagMenu(anchor: HTMLElement): void {
1193
1193
  // No live `$map` in edit mode — the perimeter renders one glyph template — so fields resolve
1194
1194
  // Parent-side from schema, keyed off the selected element's doc path (which carries a `map` segment).
1195
1195
  const selPath = getEditSnapshot().snapshot?.path ?? primarySelection(tab?.session.selection);
1196
- const arrayNode = tab && selPath ? findEnclosingRepeater(tab.doc.document, selPath) : null;
1197
- if (arrayNode && tab) {
1196
+ if (tab && selPath && findEnclosingRepeater(tab.doc.document, selPath)) {
1198
1197
  const tokens = resolveRepeaterItemFields(
1199
- arrayNode,
1200
- tab.doc.document.state,
1198
+ tab.doc.document,
1199
+ selPath,
1201
1200
  projectState?.projectConfig,
1202
1201
  );
1203
1202
  tags.push(...buildRepeaterTagsFromFields(tokens));