@caretcms/core 0.1.0 → 0.1.2

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 (243) hide show
  1. package/README.md +182 -6
  2. package/dist/browser-runtime.d.ts +16 -0
  3. package/dist/browser-runtime.d.ts.map +1 -1
  4. package/dist/browser-runtime.js +42 -23
  5. package/dist/browser-runtime.js.map +1 -1
  6. package/dist/contracts.d.ts +14 -0
  7. package/dist/contracts.d.ts.map +1 -0
  8. package/dist/contracts.js +14 -0
  9. package/dist/contracts.js.map +1 -0
  10. package/dist/editable.d.ts +28 -0
  11. package/dist/editable.d.ts.map +1 -0
  12. package/dist/editable.js +122 -0
  13. package/dist/editable.js.map +1 -0
  14. package/dist/index.d.ts +53 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +274 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/loader.d.ts.map +1 -1
  19. package/dist/loader.js +45 -3
  20. package/dist/loader.js.map +1 -1
  21. package/dist/providers/storage/markdown.d.ts +7 -0
  22. package/dist/providers/storage/markdown.d.ts.map +1 -0
  23. package/dist/providers/storage/markdown.js +6 -0
  24. package/dist/providers/storage/markdown.js.map +1 -0
  25. package/dist/runtime/auth/cookie-utils.d.ts +1 -0
  26. package/dist/runtime/auth/cookie-utils.d.ts.map +1 -1
  27. package/dist/runtime/auth/cookie-utils.js +1 -1
  28. package/dist/runtime/auth/cookie-utils.js.map +1 -1
  29. package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
  30. package/dist/runtime/auth/rate-limiter.js +19 -13
  31. package/dist/runtime/auth/rate-limiter.js.map +1 -1
  32. package/dist/runtime/auth/session.d.ts +8 -0
  33. package/dist/runtime/auth/session.d.ts.map +1 -1
  34. package/dist/runtime/auth/session.js +72 -11
  35. package/dist/runtime/auth/session.js.map +1 -1
  36. package/dist/runtime/config.d.ts +1 -0
  37. package/dist/runtime/config.d.ts.map +1 -1
  38. package/dist/runtime/config.js +2 -1
  39. package/dist/runtime/config.js.map +1 -1
  40. package/dist/runtime/git-journal.d.ts +19 -0
  41. package/dist/runtime/git-journal.d.ts.map +1 -0
  42. package/dist/runtime/git-journal.js +64 -0
  43. package/dist/runtime/git-journal.js.map +1 -0
  44. package/dist/runtime/index.d.ts +1 -1
  45. package/dist/runtime/index.d.ts.map +1 -1
  46. package/dist/runtime/index.js +1 -1
  47. package/dist/runtime/index.js.map +1 -1
  48. package/dist/runtime/middleware.d.ts.map +1 -1
  49. package/dist/runtime/middleware.js +125 -8
  50. package/dist/runtime/middleware.js.map +1 -1
  51. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  52. package/dist/runtime/mutations/contracts.js +7 -4
  53. package/dist/runtime/mutations/contracts.js.map +1 -1
  54. package/dist/runtime/mutations/engine.d.ts +7 -0
  55. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  56. package/dist/runtime/mutations/engine.js +30 -17
  57. package/dist/runtime/mutations/engine.js.map +1 -1
  58. package/dist/runtime/providers.d.ts +16 -0
  59. package/dist/runtime/providers.d.ts.map +1 -1
  60. package/dist/runtime/providers.js +16 -0
  61. package/dist/runtime/providers.js.map +1 -1
  62. package/dist/runtime/publish.d.ts +40 -0
  63. package/dist/runtime/publish.d.ts.map +1 -0
  64. package/dist/runtime/publish.js +75 -0
  65. package/dist/runtime/publish.js.map +1 -0
  66. package/dist/runtime/rebuild-webhook.d.ts +29 -0
  67. package/dist/runtime/rebuild-webhook.d.ts.map +1 -0
  68. package/dist/runtime/rebuild-webhook.js +37 -0
  69. package/dist/runtime/rebuild-webhook.js.map +1 -0
  70. package/dist/runtime/request-context.d.ts +19 -0
  71. package/dist/runtime/request-context.d.ts.map +1 -1
  72. package/dist/runtime/request-context.js.map +1 -1
  73. package/dist/runtime/rewrite.d.ts +29 -1
  74. package/dist/runtime/rewrite.d.ts.map +1 -1
  75. package/dist/runtime/rewrite.js +30 -16
  76. package/dist/runtime/rewrite.js.map +1 -1
  77. package/dist/runtime/rich-allowlist.d.ts +29 -0
  78. package/dist/runtime/rich-allowlist.d.ts.map +1 -0
  79. package/dist/runtime/rich-allowlist.js +49 -0
  80. package/dist/runtime/rich-allowlist.js.map +1 -0
  81. package/dist/runtime/routes/_helpers.d.ts +33 -0
  82. package/dist/runtime/routes/_helpers.d.ts.map +1 -1
  83. package/dist/runtime/routes/_helpers.js +117 -1
  84. package/dist/runtime/routes/_helpers.js.map +1 -1
  85. package/dist/runtime/routes/admin-entry.d.ts +1 -0
  86. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  87. package/dist/runtime/routes/admin-entry.js +3 -2
  88. package/dist/runtime/routes/admin-entry.js.map +1 -1
  89. package/dist/runtime/routes/admin.d.ts +1 -0
  90. package/dist/runtime/routes/admin.d.ts.map +1 -1
  91. package/dist/runtime/routes/admin.js +61 -9
  92. package/dist/runtime/routes/admin.js.map +1 -1
  93. package/dist/runtime/routes/auth-login.d.ts +1 -0
  94. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  95. package/dist/runtime/routes/auth-login.js +26 -8
  96. package/dist/runtime/routes/auth-login.js.map +1 -1
  97. package/dist/runtime/routes/auth-logout.d.ts +1 -0
  98. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  99. package/dist/runtime/routes/auth-logout.js +5 -0
  100. package/dist/runtime/routes/auth-logout.js.map +1 -1
  101. package/dist/runtime/routes/auth-session.d.ts +1 -0
  102. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  103. package/dist/runtime/routes/auth-session.js +1 -0
  104. package/dist/runtime/routes/auth-session.js.map +1 -1
  105. package/dist/runtime/routes/collection-list.d.ts +1 -0
  106. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  107. package/dist/runtime/routes/collection-list.js +3 -2
  108. package/dist/runtime/routes/collection-list.js.map +1 -1
  109. package/dist/runtime/routes/collections-metadata.d.ts +1 -0
  110. package/dist/runtime/routes/collections-metadata.d.ts.map +1 -1
  111. package/dist/runtime/routes/collections-metadata.js +7 -4
  112. package/dist/runtime/routes/collections-metadata.js.map +1 -1
  113. package/dist/runtime/routes/draft.d.ts +13 -0
  114. package/dist/runtime/routes/draft.d.ts.map +1 -0
  115. package/dist/runtime/routes/draft.js +55 -0
  116. package/dist/runtime/routes/draft.js.map +1 -0
  117. package/dist/runtime/routes/editor-assets.d.ts +1 -0
  118. package/dist/runtime/routes/editor-assets.d.ts.map +1 -1
  119. package/dist/runtime/routes/editor-assets.js +1 -0
  120. package/dist/runtime/routes/editor-assets.js.map +1 -1
  121. package/dist/runtime/routes/entries.d.ts +1 -0
  122. package/dist/runtime/routes/entries.d.ts.map +1 -1
  123. package/dist/runtime/routes/entries.js +3 -2
  124. package/dist/runtime/routes/entries.js.map +1 -1
  125. package/dist/runtime/routes/history.d.ts +1 -0
  126. package/dist/runtime/routes/history.d.ts.map +1 -1
  127. package/dist/runtime/routes/history.js +24 -10
  128. package/dist/runtime/routes/history.js.map +1 -1
  129. package/dist/runtime/routes/mutate.d.ts +1 -0
  130. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  131. package/dist/runtime/routes/mutate.js +7 -10
  132. package/dist/runtime/routes/mutate.js.map +1 -1
  133. package/dist/runtime/routes/publish.d.ts +10 -0
  134. package/dist/runtime/routes/publish.d.ts.map +1 -0
  135. package/dist/runtime/routes/publish.js +75 -0
  136. package/dist/runtime/routes/publish.js.map +1 -0
  137. package/dist/runtime/routes/schema.d.ts +1 -0
  138. package/dist/runtime/routes/schema.d.ts.map +1 -1
  139. package/dist/runtime/routes/schema.js +7 -4
  140. package/dist/runtime/routes/schema.js.map +1 -1
  141. package/dist/runtime/routes/studio-home.d.ts +1 -0
  142. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  143. package/dist/runtime/routes/studio-home.js +10 -9
  144. package/dist/runtime/routes/studio-home.js.map +1 -1
  145. package/dist/runtime/routes/theme-stylesheet.d.ts +1 -0
  146. package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
  147. package/dist/runtime/routes/theme-stylesheet.js +1 -0
  148. package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
  149. package/dist/runtime/routes/upload.d.ts +1 -0
  150. package/dist/runtime/routes/upload.d.ts.map +1 -1
  151. package/dist/runtime/routes/upload.js +17 -3
  152. package/dist/runtime/routes/upload.js.map +1 -1
  153. package/dist/runtime/sanitize-html.d.ts +8 -2
  154. package/dist/runtime/sanitize-html.d.ts.map +1 -1
  155. package/dist/runtime/sanitize-html.js +57 -38
  156. package/dist/runtime/sanitize-html.js.map +1 -1
  157. package/dist/runtime/static-bake.d.ts +20 -0
  158. package/dist/runtime/static-bake.d.ts.map +1 -0
  159. package/dist/runtime/static-bake.js +58 -0
  160. package/dist/runtime/static-bake.js.map +1 -0
  161. package/dist/runtime/stega.d.ts +38 -0
  162. package/dist/runtime/stega.d.ts.map +1 -0
  163. package/dist/runtime/stega.js +98 -0
  164. package/dist/runtime/stega.js.map +1 -0
  165. package/dist/runtime/storage/atomic-write.d.ts +22 -0
  166. package/dist/runtime/storage/atomic-write.d.ts.map +1 -0
  167. package/dist/runtime/storage/atomic-write.js +44 -0
  168. package/dist/runtime/storage/atomic-write.js.map +1 -0
  169. package/dist/runtime/storage/filesystem-adapter.d.ts +14 -11
  170. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  171. package/dist/runtime/storage/filesystem-adapter.js +71 -201
  172. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  173. package/dist/runtime/storage/frontmatter-codec.d.ts +47 -0
  174. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -0
  175. package/dist/runtime/storage/frontmatter-codec.js +523 -0
  176. package/dist/runtime/storage/frontmatter-codec.js.map +1 -0
  177. package/dist/runtime/storage/fs-runtime.d.ts +8 -0
  178. package/dist/runtime/storage/fs-runtime.d.ts.map +1 -0
  179. package/dist/runtime/storage/fs-runtime.js +40 -0
  180. package/dist/runtime/storage/fs-runtime.js.map +1 -0
  181. package/dist/runtime/storage/id-contracts.d.ts +22 -0
  182. package/dist/runtime/storage/id-contracts.d.ts.map +1 -0
  183. package/dist/runtime/storage/id-contracts.js +27 -0
  184. package/dist/runtime/storage/id-contracts.js.map +1 -0
  185. package/dist/runtime/storage/image-validation.d.ts +10 -0
  186. package/dist/runtime/storage/image-validation.d.ts.map +1 -1
  187. package/dist/runtime/storage/image-validation.js +17 -4
  188. package/dist/runtime/storage/image-validation.js.map +1 -1
  189. package/dist/runtime/storage/in-memory-adapter.d.ts +4 -0
  190. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  191. package/dist/runtime/storage/in-memory-adapter.js +11 -0
  192. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  193. package/dist/runtime/storage/markdown-adapter.d.ts +63 -0
  194. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -0
  195. package/dist/runtime/storage/markdown-adapter.js +227 -0
  196. package/dist/runtime/storage/markdown-adapter.js.map +1 -0
  197. package/dist/runtime/storage/quota-upload-handler.d.ts +2 -0
  198. package/dist/runtime/storage/quota-upload-handler.d.ts.map +1 -1
  199. package/dist/runtime/storage/quota-upload-handler.js +34 -8
  200. package/dist/runtime/storage/quota-upload-handler.js.map +1 -1
  201. package/dist/runtime/storage/session-overlay-adapter.d.ts +3 -0
  202. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  203. package/dist/runtime/storage/session-overlay-adapter.js +3 -1
  204. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  205. package/dist/runtime/storage/sidecar-meta-store.d.ts +51 -0
  206. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -0
  207. package/dist/runtime/storage/sidecar-meta-store.js +209 -0
  208. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -0
  209. package/dist/runtime/themes/preset-tokens.d.ts +0 -1
  210. package/dist/runtime/themes/preset-tokens.d.ts.map +1 -1
  211. package/dist/runtime/themes/preset-tokens.js +1 -1
  212. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  213. package/dist/runtime/utils.d.ts +9 -1
  214. package/dist/runtime/utils.d.ts.map +1 -1
  215. package/dist/runtime/utils.js +21 -1
  216. package/dist/runtime/utils.js.map +1 -1
  217. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  218. package/dist/runtime/views/studio-layout.js +11 -10
  219. package/dist/runtime/views/studio-layout.js.map +1 -1
  220. package/dist/schema-utils.d.ts.map +1 -1
  221. package/dist/schema-utils.js +1 -0
  222. package/dist/schema-utils.js.map +1 -1
  223. package/dist/types.d.ts +17 -0
  224. package/dist/types.d.ts.map +1 -1
  225. package/package.json +9 -1
  226. package/static/cms/dev-toolbar/app.js +265 -0
  227. package/static/cms/editor/config.js +10 -0
  228. package/static/cms/editor/helpers.js +5 -1
  229. package/static/cms/editor/highlight.js +44 -0
  230. package/static/cms/editor/sanitize.js +56 -9
  231. package/static/cms/editor/stega-hydrate.js +80 -0
  232. package/static/cms/editor/text-edit.js +22 -0
  233. package/static/cms/editor/tokens-and-text.css +1 -1
  234. package/static/cms/editor/toolbar.css +101 -4
  235. package/static/cms/editor/toolbar.js +171 -21
  236. package/static/cms/editor.js +18 -1
  237. package/static/cms/signin-hint.css +74 -0
  238. package/static/cms/studio-login.css +70 -0
  239. package/static/cms/studio.css +80 -13
  240. package/dist/runtime/schema/template.d.ts +0 -15
  241. package/dist/runtime/schema/template.d.ts.map +0 -1
  242. package/dist/runtime/schema/template.js +0 -38
  243. package/dist/runtime/schema/template.js.map +0 -1
@@ -0,0 +1,265 @@
1
+ import { defineToolbarApp } from "astro/toolbar";
2
+
3
+ // CaretCMS Dev Toolbar app — a login-free, dev-only view of the data-caret
4
+ // bindings on the current page. It complements the in-editor "Show All" button
5
+ // (static/cms/editor/toolbar.js), which only works once an editor session is
6
+ // authenticated. This runs in `astro dev` for anyone, with no login.
7
+ //
8
+ // The binding resolver below mirrors static/cms/editor/helpers.js. It is
9
+ // inlined (rather than imported from /__caret/editor/helpers.js) so the toolbar
10
+ // keeps working in `output: 'static'` projects where /__caret/ is not served.
11
+ // If helpers.js changes, keep these two in sync.
12
+
13
+ function parseCaretAttr(attr) {
14
+ const parts = attr.split("::");
15
+ if (parts.length === 3) return { collection: parts[0], id: parts[1], field: parts[2] };
16
+ if (parts.length === 1 && !attr.includes("::")) return { collection: null, id: null, field: attr };
17
+ return null;
18
+ }
19
+
20
+ function resolveBinding(el, parsed) {
21
+ if (!parsed) return null;
22
+ // Explicit null check, not truthiness — mirrors helpers.js; see the parity
23
+ // test (tests/unit/caret-parser-parity.test.ts).
24
+ if (parsed.collection !== null && parsed.id !== null) return parsed;
25
+ let node = el.parentElement;
26
+ while (node) {
27
+ const scope = node.getAttribute("data-caret-scope");
28
+ if (scope) {
29
+ const parts = scope.split("::");
30
+ if (parts.length === 2) return { collection: parts[0], id: parts[1], field: parsed.field };
31
+ }
32
+ node = node.parentElement;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function devConfig() {
38
+ const c = (typeof window !== "undefined" && window.__CARET_DEV__) || {};
39
+ return {
40
+ mountPath: typeof c.mountPath === "string" ? c.mountPath : "/admin",
41
+ apiBasePath: typeof c.apiBasePath === "string" ? c.apiBasePath : "/api/cms",
42
+ };
43
+ }
44
+
45
+ function classify(el) {
46
+ if (el.tagName.toLowerCase() === "img") return "image";
47
+ if (el.hasAttribute("data-caret-rich")) return "rich";
48
+ return "text";
49
+ }
50
+
51
+ // Walk the page and resolve every data-caret element to a full key, splitting
52
+ // out orphans (field-only attrs with no resolvable scope) and duplicate keys.
53
+ function scanBindings() {
54
+ const els = Array.from(document.querySelectorAll("[data-caret]"));
55
+ const bindings = [];
56
+ const orphans = [];
57
+
58
+ els.forEach((el, i) => {
59
+ const attr = el.getAttribute("data-caret") || "";
60
+ const parsed = parseCaretAttr(attr);
61
+ const resolved = resolveBinding(el, parsed);
62
+ const kind = classify(el);
63
+ if (!resolved) {
64
+ orphans.push({ el, attr, kind, index: i });
65
+ return;
66
+ }
67
+ bindings.push({
68
+ el,
69
+ index: i,
70
+ collection: resolved.collection,
71
+ id: resolved.id,
72
+ field: resolved.field,
73
+ key: `${resolved.collection}::${resolved.id}::${resolved.field}`,
74
+ kind,
75
+ });
76
+ });
77
+
78
+ const counts = new Map();
79
+ bindings.forEach((b) => counts.set(b.key, (counts.get(b.key) || 0) + 1));
80
+ const duplicates = bindings.filter((b) => counts.get(b.key) > 1);
81
+
82
+ // Stega-encoded bindings (live-loader content) only become real data-caret
83
+ // attributes after the editor's stega-hydrate.js runs, which needs auth — so
84
+ // a logged-out scan can undercount. Detect the U+E0000 markers to warn.
85
+ const stega = /\u{E0000}/u.test((document.body && document.body.textContent) || "");
86
+
87
+ return { bindings, orphans, duplicates, stega, total: els.length };
88
+ }
89
+
90
+ // --- login-free highlight: our own outline CSS keyed on [data-caret] directly,
91
+ // so it does NOT depend on the editor's .cms-editable class or editor.css.
92
+ // Colors are deliberately distinct from the editor's blue scheme so the two can
93
+ // be told apart when both are active at once.
94
+ const HIGHLIGHT_STYLE_ID = "__caret-devtoolbar-highlight__";
95
+
96
+ function applyHighlight(active) {
97
+ const existing = document.getElementById(HIGHLIGHT_STYLE_ID);
98
+ if (existing) existing.remove();
99
+ if (!active) return;
100
+ const style = document.createElement("style");
101
+ style.id = HIGHLIGHT_STYLE_ID;
102
+ style.textContent = `
103
+ [data-caret]{outline:2px solid #6366f1 !important;outline-offset:2px;}
104
+ [data-caret][data-caret-rich]{outline-color:#10b981 !important;}
105
+ img[data-caret]{outline:2px dashed #f59e0b !important;outline-offset:4px;}
106
+ `;
107
+ document.head.appendChild(style);
108
+ }
109
+
110
+ function scrollTo(el) {
111
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
112
+ const prev = el.style.outline;
113
+ const prevOffset = el.style.outlineOffset;
114
+ el.style.outline = "3px solid #6366f1";
115
+ el.style.outlineOffset = "3px";
116
+ setTimeout(() => {
117
+ el.style.outline = prev;
118
+ el.style.outlineOffset = prevOffset;
119
+ }, 1500);
120
+ }
121
+
122
+ const KIND_LABEL = { text: "text", image: "image", rich: "rich" };
123
+
124
+ function esc(s) {
125
+ return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
126
+ }
127
+
128
+ const PANEL_CSS = `
129
+ :host{all:initial;}
130
+ astro-dev-toolbar-window{width:min(440px,100%) !important;height:auto !important;}
131
+ .wrap{font-family:system-ui,-apple-system,sans-serif;color:#e2e8f0;width:100%;max-height:min(70vh,520px);display:flex;flex-direction:column;}
132
+ .head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:2px 2px 10px;border-bottom:1px solid rgba(255,255,255,.1);}
133
+ .title{font-size:13px;font-weight:600;}
134
+ .count{font-size:11px;color:#94a3b8;}
135
+ .actions{display:flex;gap:6px;padding:10px 2px;}
136
+ button.btn{font:inherit;font-size:12px;cursor:pointer;border:1px solid rgba(255,255,255,.15);background:rgba(255,255,255,.05);color:#e2e8f0;border-radius:6px;padding:5px 10px;}
137
+ button.btn:hover{background:rgba(255,255,255,.12);}
138
+ button.btn.on{background:#6366f1;border-color:#6366f1;color:#fff;}
139
+ .scroll{overflow-y:auto;flex:1;padding-right:2px;}
140
+ .note{font-size:11px;color:#fbbf24;background:rgba(251,191,36,.08);border:1px solid rgba(251,191,36,.2);border-radius:6px;padding:6px 8px;margin:8px 0;}
141
+ .problem{font-size:11px;color:#f87171;background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.2);border-radius:6px;padding:6px 8px;margin:8px 0;}
142
+ .problem b{color:#fca5a5;}
143
+ .group{margin:10px 0 0;}
144
+ .ghead{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:11px;font-weight:600;color:#cbd5e1;padding:4px 0;border-bottom:1px solid rgba(255,255,255,.06);}
145
+ .ghead a{color:#818cf8;text-decoration:none;font-weight:500;white-space:nowrap;}
146
+ .ghead a:hover{text-decoration:underline;}
147
+ .row{display:flex;align-items:center;gap:8px;padding:5px 2px;border-bottom:1px solid rgba(255,255,255,.04);}
148
+ .field{flex:1;font-size:12px;color:#e2e8f0;font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
149
+ .kind{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 5px;border-radius:4px;font-weight:600;}
150
+ .kind.text{background:rgba(99,102,241,.2);color:#a5b4fc;}
151
+ .kind.image{background:rgba(245,158,11,.2);color:#fcd34d;}
152
+ .kind.rich{background:rgba(16,185,129,.2);color:#6ee7b7;}
153
+ .jump{font:inherit;font-size:11px;cursor:pointer;border:none;background:transparent;color:#94a3b8;padding:2px 4px;border-radius:4px;}
154
+ .jump:hover{color:#e2e8f0;background:rgba(255,255,255,.08);}
155
+ .empty{font-size:12px;color:#94a3b8;padding:16px 2px;text-align:center;}
156
+ .dup{color:#fca5a5;}
157
+ `;
158
+
159
+ export default defineToolbarApp({
160
+ init(canvas, app) {
161
+ const style = document.createElement("style");
162
+ style.textContent = PANEL_CSS;
163
+ canvas.appendChild(style);
164
+
165
+ const win = document.createElement("astro-dev-toolbar-window");
166
+ const wrap = document.createElement("div");
167
+ wrap.className = "wrap";
168
+ win.appendChild(wrap);
169
+ canvas.appendChild(win);
170
+
171
+ let highlightOn = false;
172
+ let scan = { bindings: [], orphans: [], duplicates: [], stega: false, total: 0 };
173
+
174
+ function render() {
175
+ scan = scanBindings();
176
+ const { mountPath } = devConfig();
177
+ const { bindings, orphans, duplicates, stega, total } = scan;
178
+
179
+ // Group bindings by collection::id, preserving first-seen order.
180
+ const groups = new Map();
181
+ for (const b of bindings) {
182
+ const gk = `${b.collection}::${b.id}`;
183
+ if (!groups.has(gk)) groups.set(gk, []);
184
+ groups.get(gk).push(b);
185
+ }
186
+
187
+ const dupKeys = new Set(duplicates.map((d) => d.key));
188
+
189
+ let html = `
190
+ <div class="head">
191
+ <span class="title">CaretCMS bindings</span>
192
+ <span class="count">${bindings.length} on page${orphans.length ? ` · ${orphans.length} orphan${orphans.length > 1 ? "s" : ""}` : ""}</span>
193
+ </div>
194
+ <div class="actions">
195
+ <button class="btn ${highlightOn ? "on" : ""}" data-action="highlight">${highlightOn ? "Hide" : "Highlight"} all</button>
196
+ <button class="btn" data-action="rescan">Rescan</button>
197
+ </div>
198
+ <div class="scroll">`;
199
+
200
+ if (stega) {
201
+ html += `<div class="note">This page has stega-encoded bindings (live-loader content). They become visible only when the editor is loaded — sign in to see them here.</div>`;
202
+ }
203
+ if (orphans.length) {
204
+ html += `<div class="problem"><b>${orphans.length} orphan binding${orphans.length > 1 ? "s" : ""}</b> — field-only <code>data-caret</code> with no resolvable <code>data-caret-scope</code> ancestor: ${orphans.map((o) => `<code>${esc(o.attr)}</code>`).join(", ")}</div>`;
205
+ }
206
+ if (duplicates.length) {
207
+ const uniq = Array.from(new Set(duplicates.map((d) => d.key)));
208
+ html += `<div class="problem"><b>${uniq.length} duplicate key${uniq.length > 1 ? "s" : ""}</b> — same binding used more than once: ${uniq.map((k) => `<code>${esc(k)}</code>`).join(", ")}</div>`;
209
+ }
210
+
211
+ if (!bindings.length && !orphans.length) {
212
+ html += `<div class="empty">No <code>data-caret</code> bindings found on this page.${total ? "" : ""}</div>`;
213
+ }
214
+
215
+ for (const [gk, rows] of groups) {
216
+ const [collection, id] = gk.split("::");
217
+ const studioHref = `${mountPath}/cms/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
218
+ html += `<div class="group">
219
+ <div class="ghead"><span>${esc(gk)}</span><a href="${esc(studioHref)}" target="_blank" rel="noopener">Open in Studio ↗</a></div>`;
220
+ for (const b of rows) {
221
+ html += `<div class="row">
222
+ <span class="field ${dupKeys.has(b.key) ? "dup" : ""}">${esc(b.field)}</span>
223
+ <span class="kind ${b.kind}">${KIND_LABEL[b.kind]}</span>
224
+ <button class="jump" data-action="scroll" data-index="${b.index}">scroll to</button>
225
+ </div>`;
226
+ }
227
+ html += `</div>`;
228
+ }
229
+
230
+ html += `</div>`;
231
+ wrap.innerHTML = html;
232
+ }
233
+
234
+ function elementByIndex(index) {
235
+ const b = scan.bindings.find((x) => x.index === Number(index));
236
+ return b ? b.el : null;
237
+ }
238
+
239
+ wrap.addEventListener("click", (event) => {
240
+ const target = event.target.closest("[data-action]");
241
+ if (!target) return;
242
+ const action = target.getAttribute("data-action");
243
+ if (action === "highlight") {
244
+ highlightOn = !highlightOn;
245
+ applyHighlight(highlightOn);
246
+ app.toggleState({ state: highlightOn });
247
+ target.textContent = `${highlightOn ? "Hide" : "Highlight"} all`;
248
+ target.classList.toggle("on", highlightOn);
249
+ } else if (action === "rescan") {
250
+ render();
251
+ } else if (action === "scroll") {
252
+ const el = elementByIndex(target.getAttribute("data-index"));
253
+ if (el) scrollTo(el);
254
+ }
255
+ });
256
+
257
+ // Re-render each time the panel opens; flag problems with a notification dot.
258
+ app.onToggled(({ state }) => {
259
+ if (!state) return;
260
+ render();
261
+ const probs = scan.orphans.length + scan.duplicates.length;
262
+ app.toggleNotification({ state: probs > 0, level: "warning" });
263
+ });
264
+ },
265
+ });
@@ -54,6 +54,16 @@ function getStorageKey(cloud) {
54
54
 
55
55
  export const CMS_CONFIG = cfg;
56
56
  export const CMS_MODE = cfg.mode === 'cloud' || cfg.mode === 'hybrid' ? cfg.mode : 'embedded';
57
+ export const DELIVERY_MODE =
58
+ cfg.delivery && cfg.delivery.mode === 'static' ? 'static' : 'server';
59
+
60
+ export function isStaticDelivery() {
61
+ return DELIVERY_MODE === 'static';
62
+ }
63
+
64
+ export function isServerDelivery() {
65
+ return !isStaticDelivery();
66
+ }
57
67
  export const MOUNT_PATH = normalizePath(cfg.mountPath, defaults.mountPath);
58
68
  export const API_BASE = normalizePath(cfg.apiBasePath, defaults.apiBasePath);
59
69
  export const CLOUD = getCloudConfig();
@@ -14,7 +14,11 @@ export function parseCaretAttr(attr) {
14
14
  */
15
15
  export function resolveBinding(el, parsed) {
16
16
  if (!parsed) return null;
17
- if (parsed.collection && parsed.id) return parsed;
17
+ // Explicit null check, not truthiness: a malformed triple with an EMPTY
18
+ // segment ("::x::y") must stay a (rejected) triple like the server treats
19
+ // it, not silently fall back to scope resolution and save elsewhere.
20
+ // Held to the rewrite engine by tests/unit/caret-parser-parity.test.ts.
21
+ if (parsed.collection !== null && parsed.id !== null) return parsed;
18
22
 
19
23
  // Walk up to find nearest data-caret-scope
20
24
  let node = el.parentElement;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Owns the `cms-highlight-all` body class — the single source of truth for
3
+ * "show every editable region". Two callers share it:
4
+ * - the toolbar "Show all" button, via the persistent `toggleHighlight` toggle
5
+ * - the post-login welcome reveal, via the ephemeral `revealAndFade`
6
+ *
7
+ * Keeping `highlightActive` here (rather than in the toolbar closure) lets the
8
+ * ephemeral reveal defer to an active manual toggle instead of clobbering it.
9
+ *
10
+ * The fade itself is pure CSS: `[data-caret].cms-editable` already transitions
11
+ * its outline, so adding/removing the class animates for free. Motion is
12
+ * suppressed for `prefers-reduced-motion` in toolbar.css, not here.
13
+ */
14
+
15
+ let highlightActive = false;
16
+
17
+ export function toggleHighlight(highlightBtn, showToast) {
18
+ highlightActive = !highlightActive;
19
+ document.body.classList.toggle('cms-highlight-all', highlightActive);
20
+ highlightBtn.classList.toggle('cms-highlight-btn-active', highlightActive);
21
+
22
+ const label = highlightBtn.querySelector('.cms-highlight-label');
23
+
24
+ if (highlightActive) {
25
+ if (label) label.textContent = 'Hide all';
26
+ showToast('Showing all editable regions', 'success');
27
+ } else {
28
+ if (label) label.textContent = 'Show all';
29
+ }
30
+ }
31
+
32
+ /**
33
+ * One-time reveal: flash every editable region, then fade out after `durationMs`.
34
+ * No-op if the user has already turned highlighting on manually, so it never
35
+ * desyncs the toolbar button's state.
36
+ */
37
+ export function revealAndFade(durationMs = 2500) {
38
+ if (highlightActive) return;
39
+ document.body.classList.add('cms-highlight-all');
40
+ setTimeout(() => {
41
+ if (highlightActive) return;
42
+ document.body.classList.remove('cms-highlight-all');
43
+ }, durationMs);
44
+ }
@@ -5,34 +5,66 @@
5
5
  * Only inline formatting elements are permitted — no block-level tags.
6
6
  */
7
7
 
8
- const ALLOWED_TAGS = new Set([
9
- 'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup',
8
+ // NOTE: these three constants + classAllowed() below are a HAND MIRROR of
9
+ // src/runtime/rich-allowlist.ts (this file ships as raw unbundled JS and can't
10
+ // import it). They are `export`ed so tests/unit/sanitizer-parity.test.ts can
11
+ // assert they never drift from the source of truth — CI fails on divergence.
12
+ export const ALLOWED_TAGS = new Set([
13
+ 'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup', 'span',
10
14
  ]);
11
15
 
12
- const ALLOWED_ATTRS = {
16
+ export const ALLOWED_ATTRS = {
13
17
  a: new Set(['href', 'target', 'rel']),
14
18
  };
15
19
 
16
- const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/)/i;
20
+ export const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/)/i;
21
+
22
+ /**
23
+ * Does `cls` match any pattern? (exact, `prefix-*`, or lone `*`)
24
+ * MUST stay identical to classAllowed() in src/runtime/sanitize-html.ts.
25
+ * @param {string} cls
26
+ * @param {readonly string[]} patterns
27
+ * @returns {boolean}
28
+ */
29
+ export function classAllowed(cls, patterns) {
30
+ for (const p of patterns) {
31
+ if (p === '*') return true;
32
+ if (p.endsWith('*')) {
33
+ if (cls.startsWith(p.slice(0, -1))) return true;
34
+ } else if (cls === p) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
17
40
 
18
41
  /**
19
42
  * Sanitize an HTML string, keeping only allowed inline tags and attributes.
43
+ * Per-tag class allowlist comes from `options.allowedClasses`, falling back to
44
+ * `window.__CARET__.allowedClasses` so the client matches the server config.
20
45
  * @param {string} html
46
+ * @param {{ allowedClasses?: Record<string, readonly string[]> }} [options]
21
47
  * @returns {string}
22
48
  */
23
- export function sanitizeHtml(html) {
49
+ export function sanitizeHtml(html, options) {
24
50
  if (!html) return '';
25
51
 
52
+ const allowedClasses =
53
+ options?.allowedClasses ??
54
+ (typeof window !== 'undefined' ? window.__CARET__?.allowedClasses : undefined) ??
55
+ null;
56
+
26
57
  const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
27
- sanitizeNode(doc.body);
58
+ sanitizeNode(doc.body, allowedClasses);
28
59
  return doc.body.innerHTML;
29
60
  }
30
61
 
31
62
  /**
32
63
  * Recursively sanitize a DOM node in place.
33
64
  * @param {Node} parent
65
+ * @param {Record<string, readonly string[]> | null} allowedClasses
34
66
  */
35
- function sanitizeNode(parent) {
67
+ function sanitizeNode(parent, allowedClasses) {
36
68
  const children = Array.from(parent.childNodes);
37
69
 
38
70
  for (const node of children) {
@@ -57,14 +89,29 @@ function sanitizeNode(parent) {
57
89
  while (el.firstChild) frag.appendChild(el.firstChild);
58
90
  parent.replaceChild(frag, el);
59
91
  // Re-sanitize the newly promoted children
60
- sanitizeNode(parent);
92
+ sanitizeNode(parent, allowedClasses);
61
93
  return;
62
94
  }
63
95
 
64
96
  // Strip disallowed attributes
65
97
  const allowed = ALLOWED_ATTRS[tag];
98
+ const classPatterns = allowedClasses ? allowedClasses[tag] : undefined;
66
99
  const attrs = Array.from(el.attributes);
67
100
  for (const attr of attrs) {
101
+ // class is gated by the per-tag allowlist, not ALLOWED_ATTRS
102
+ if (attr.name === 'class') {
103
+ if (!classPatterns) {
104
+ el.removeAttribute('class');
105
+ continue;
106
+ }
107
+ const kept = attr.value
108
+ .split(/\s+/)
109
+ .filter((c) => c !== '' && classAllowed(c, classPatterns))
110
+ .join(' ');
111
+ if (kept) el.setAttribute('class', kept);
112
+ else el.removeAttribute('class');
113
+ continue;
114
+ }
68
115
  if (!allowed || !allowed.has(attr.name)) {
69
116
  el.removeAttribute(attr.name);
70
117
  }
@@ -84,6 +131,6 @@ function sanitizeNode(parent) {
84
131
  }
85
132
 
86
133
  // Recurse into children
87
- sanitizeNode(el);
134
+ sanitizeNode(el, allowedClasses);
88
135
  }
89
136
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Stega hydration (browser side).
3
+ *
4
+ * Content fetched through the live loaders in draft mode carries its binding key
5
+ * (`collection::id::field`) hidden inside the string as invisible Unicode "tag"
6
+ * characters. This pass walks the rendered DOM, decodes that key from each text
7
+ * node, strips the invisible characters, and PROMOTES the containing element to
8
+ * a normal `data-caret` binding — so the existing text/image editors pick it up
9
+ * with zero changes. This is what makes prop-fed / component / loop content
10
+ * click-to-edit without any author-written attribute.
11
+ *
12
+ * Decode logic mirrors runtime/stega.ts (the editor bundle is hand-authored JS,
13
+ * separate from the TS runtime, so the few lines are intentionally duplicated).
14
+ */
15
+
16
+ const TAG_BASE = 0xe0000;
17
+ const TAG_MAX = 0xe007f;
18
+ const START = String.fromCodePoint(TAG_BASE + 0x00);
19
+ const END = String.fromCodePoint(TAG_BASE + 0x01);
20
+ const TAG_RE = /[\u{E0000}-\u{E007F}]/gu;
21
+
22
+ function fromBase64(b64) {
23
+ const binary = atob(b64);
24
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
25
+ return new TextDecoder().decode(bytes);
26
+ }
27
+
28
+ export function stegaDecode(value) {
29
+ const start = value.indexOf(START);
30
+ if (start === -1) return undefined;
31
+ const end = value.indexOf(END, start + START.length);
32
+ if (end === -1) return undefined;
33
+ let b64 = '';
34
+ for (const ch of value.slice(start + START.length, end)) {
35
+ const cp = ch.codePointAt(0);
36
+ if (cp === undefined || cp < TAG_BASE || cp > TAG_MAX) return undefined;
37
+ b64 += String.fromCharCode(cp - TAG_BASE);
38
+ }
39
+ try {
40
+ return JSON.parse(fromBase64(b64));
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ export function stegaClean(value) {
47
+ return value.replace(TAG_RE, '');
48
+ }
49
+
50
+ /**
51
+ * Find every stega-tagged text node under `root`, decode its key, strip the
52
+ * invisible characters, and tag the containing element with `data-caret`.
53
+ * Returns the number of elements newly promoted.
54
+ */
55
+ export function hydrateStega(root) {
56
+ const scope = root || document.body;
57
+ if (!scope) return 0;
58
+
59
+ const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
60
+ const tagged = [];
61
+ let node = walker.nextNode();
62
+ while (node) {
63
+ if (node.nodeValue && node.nodeValue.indexOf(START) !== -1) tagged.push(node);
64
+ node = walker.nextNode();
65
+ }
66
+
67
+ let promoted = 0;
68
+ for (const textNode of tagged) {
69
+ const key = stegaDecode(textNode.nodeValue);
70
+ // Always strip the invisible characters so they never affect layout/copy.
71
+ textNode.nodeValue = stegaClean(textNode.nodeValue);
72
+ if (typeof key !== 'string' || key.indexOf('::') === -1) continue;
73
+
74
+ const el = textNode.parentElement;
75
+ if (!el || el.hasAttribute('data-caret')) continue; // explicit attr wins
76
+ el.setAttribute('data-caret', key);
77
+ promoted++;
78
+ }
79
+ return promoted;
80
+ }
@@ -57,6 +57,28 @@ export function mountTextEditors({
57
57
  if (isRich) el.classList.add('cms-rich');
58
58
  if (isLinkable) el.classList.add('cms-linkable');
59
59
 
60
+ // Plain-text fields render with HTML whitespace collapsing, but a
61
+ // contenteditable element exposes the raw source whitespace (newlines +
62
+ // indentation) verbatim. A paragraph authored across several indented
63
+ // source lines then shows large gaps in the editor that aren't in the
64
+ // final page — and, if edited, the raw whitespace leaks into the saved
65
+ // value. Collapse it once on mount so the editable text matches the
66
+ // rendered text and the snapshot/saved value stays clean. Rich fields are
67
+ // left alone — their whitespace is governed by their own HTML.
68
+ if (!isRich) {
69
+ if (isLinkable) {
70
+ const rawAttr = el.getAttribute('data-caret-raw') || '';
71
+ const collapsed = rawAttr.replace(/\s+/g, ' ').trim();
72
+ if (rawAttr !== collapsed) {
73
+ el.setAttribute('data-caret-raw', collapsed);
74
+ el.innerHTML = clientLinkify(collapsed);
75
+ }
76
+ } else {
77
+ const collapsed = (el.textContent || '').replace(/\s+/g, ' ').trim();
78
+ if (el.textContent !== collapsed) el.textContent = collapsed;
79
+ }
80
+ }
81
+
60
82
  el.addEventListener('focus', () => {
61
83
  if (isRich) {
62
84
  snapshots.set(el, el.innerHTML);
@@ -25,7 +25,7 @@
25
25
 
26
26
  [data-caret].cms-editable {
27
27
  cursor: text;
28
- transition: outline 0.2s, box-shadow 0.2s;
28
+ transition: outline 0.2s, box-shadow 0.2s, background 0.4s;
29
29
  outline: 2px dashed transparent;
30
30
  outline-offset: 4px;
31
31
  border-radius: 2px;