@caretcms/core 0.1.0 → 0.1.1

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 (191) hide show
  1. package/README.md +117 -6
  2. package/dist/browser-runtime.d.ts.map +1 -1
  3. package/dist/browser-runtime.js +34 -20
  4. package/dist/browser-runtime.js.map +1 -1
  5. package/dist/contracts.d.ts +14 -0
  6. package/dist/contracts.d.ts.map +1 -0
  7. package/dist/contracts.js +14 -0
  8. package/dist/contracts.js.map +1 -0
  9. package/dist/editable.d.ts +28 -0
  10. package/dist/editable.d.ts.map +1 -0
  11. package/dist/editable.js +122 -0
  12. package/dist/editable.js.map +1 -0
  13. package/dist/index.d.ts +30 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +134 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/loader.d.ts.map +1 -1
  18. package/dist/loader.js +45 -3
  19. package/dist/loader.js.map +1 -1
  20. package/dist/providers/storage/markdown.d.ts +7 -0
  21. package/dist/providers/storage/markdown.d.ts.map +1 -0
  22. package/dist/providers/storage/markdown.js +6 -0
  23. package/dist/providers/storage/markdown.js.map +1 -0
  24. package/dist/runtime/auth/cookie-utils.d.ts +1 -0
  25. package/dist/runtime/auth/cookie-utils.d.ts.map +1 -1
  26. package/dist/runtime/auth/cookie-utils.js +1 -1
  27. package/dist/runtime/auth/cookie-utils.js.map +1 -1
  28. package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
  29. package/dist/runtime/auth/rate-limiter.js +19 -13
  30. package/dist/runtime/auth/rate-limiter.js.map +1 -1
  31. package/dist/runtime/auth/session.d.ts +8 -0
  32. package/dist/runtime/auth/session.d.ts.map +1 -1
  33. package/dist/runtime/auth/session.js +72 -11
  34. package/dist/runtime/auth/session.js.map +1 -1
  35. package/dist/runtime/config.d.ts +1 -0
  36. package/dist/runtime/config.d.ts.map +1 -1
  37. package/dist/runtime/config.js +2 -1
  38. package/dist/runtime/config.js.map +1 -1
  39. package/dist/runtime/git-journal.d.ts +19 -0
  40. package/dist/runtime/git-journal.d.ts.map +1 -0
  41. package/dist/runtime/git-journal.js +64 -0
  42. package/dist/runtime/git-journal.js.map +1 -0
  43. package/dist/runtime/index.d.ts +1 -1
  44. package/dist/runtime/index.d.ts.map +1 -1
  45. package/dist/runtime/index.js +1 -1
  46. package/dist/runtime/index.js.map +1 -1
  47. package/dist/runtime/middleware.d.ts.map +1 -1
  48. package/dist/runtime/middleware.js +125 -8
  49. package/dist/runtime/middleware.js.map +1 -1
  50. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  51. package/dist/runtime/mutations/contracts.js +7 -4
  52. package/dist/runtime/mutations/contracts.js.map +1 -1
  53. package/dist/runtime/mutations/engine.d.ts +7 -0
  54. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  55. package/dist/runtime/mutations/engine.js +30 -17
  56. package/dist/runtime/mutations/engine.js.map +1 -1
  57. package/dist/runtime/providers.d.ts +7 -0
  58. package/dist/runtime/providers.d.ts.map +1 -1
  59. package/dist/runtime/providers.js +10 -0
  60. package/dist/runtime/providers.js.map +1 -1
  61. package/dist/runtime/publish.d.ts +38 -0
  62. package/dist/runtime/publish.d.ts.map +1 -0
  63. package/dist/runtime/publish.js +71 -0
  64. package/dist/runtime/publish.js.map +1 -0
  65. package/dist/runtime/request-context.d.ts +19 -0
  66. package/dist/runtime/request-context.d.ts.map +1 -1
  67. package/dist/runtime/request-context.js.map +1 -1
  68. package/dist/runtime/rewrite.d.ts +11 -1
  69. package/dist/runtime/rewrite.d.ts.map +1 -1
  70. package/dist/runtime/rewrite.js +21 -15
  71. package/dist/runtime/rewrite.js.map +1 -1
  72. package/dist/runtime/rich-allowlist.d.ts +28 -0
  73. package/dist/runtime/rich-allowlist.d.ts.map +1 -0
  74. package/dist/runtime/rich-allowlist.js +43 -0
  75. package/dist/runtime/rich-allowlist.js.map +1 -0
  76. package/dist/runtime/routes/_helpers.d.ts +26 -0
  77. package/dist/runtime/routes/_helpers.d.ts.map +1 -1
  78. package/dist/runtime/routes/_helpers.js +83 -0
  79. package/dist/runtime/routes/_helpers.js.map +1 -1
  80. package/dist/runtime/routes/admin.d.ts.map +1 -1
  81. package/dist/runtime/routes/admin.js +55 -6
  82. package/dist/runtime/routes/admin.js.map +1 -1
  83. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  84. package/dist/runtime/routes/auth-login.js +25 -8
  85. package/dist/runtime/routes/auth-login.js.map +1 -1
  86. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  87. package/dist/runtime/routes/auth-logout.js +4 -0
  88. package/dist/runtime/routes/auth-logout.js.map +1 -1
  89. package/dist/runtime/routes/collections-metadata.d.ts.map +1 -1
  90. package/dist/runtime/routes/collections-metadata.js +4 -2
  91. package/dist/runtime/routes/collections-metadata.js.map +1 -1
  92. package/dist/runtime/routes/draft.d.ts +8 -0
  93. package/dist/runtime/routes/draft.d.ts.map +1 -0
  94. package/dist/runtime/routes/draft.js +38 -0
  95. package/dist/runtime/routes/draft.js.map +1 -0
  96. package/dist/runtime/routes/history.d.ts.map +1 -1
  97. package/dist/runtime/routes/history.js +21 -8
  98. package/dist/runtime/routes/history.js.map +1 -1
  99. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  100. package/dist/runtime/routes/mutate.js +5 -9
  101. package/dist/runtime/routes/mutate.js.map +1 -1
  102. package/dist/runtime/routes/publish.d.ts +9 -0
  103. package/dist/runtime/routes/publish.d.ts.map +1 -0
  104. package/dist/runtime/routes/publish.js +66 -0
  105. package/dist/runtime/routes/publish.js.map +1 -0
  106. package/dist/runtime/routes/schema.d.ts.map +1 -1
  107. package/dist/runtime/routes/schema.js +4 -2
  108. package/dist/runtime/routes/schema.js.map +1 -1
  109. package/dist/runtime/routes/upload.d.ts.map +1 -1
  110. package/dist/runtime/routes/upload.js +16 -3
  111. package/dist/runtime/routes/upload.js.map +1 -1
  112. package/dist/runtime/sanitize-html.d.ts +8 -2
  113. package/dist/runtime/sanitize-html.d.ts.map +1 -1
  114. package/dist/runtime/sanitize-html.js +57 -38
  115. package/dist/runtime/sanitize-html.js.map +1 -1
  116. package/dist/runtime/stega.d.ts +38 -0
  117. package/dist/runtime/stega.d.ts.map +1 -0
  118. package/dist/runtime/stega.js +98 -0
  119. package/dist/runtime/stega.js.map +1 -0
  120. package/dist/runtime/storage/atomic-write.d.ts +22 -0
  121. package/dist/runtime/storage/atomic-write.d.ts.map +1 -0
  122. package/dist/runtime/storage/atomic-write.js +44 -0
  123. package/dist/runtime/storage/atomic-write.js.map +1 -0
  124. package/dist/runtime/storage/filesystem-adapter.d.ts +14 -11
  125. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  126. package/dist/runtime/storage/filesystem-adapter.js +71 -201
  127. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  128. package/dist/runtime/storage/frontmatter-codec.d.ts +47 -0
  129. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -0
  130. package/dist/runtime/storage/frontmatter-codec.js +515 -0
  131. package/dist/runtime/storage/frontmatter-codec.js.map +1 -0
  132. package/dist/runtime/storage/fs-runtime.d.ts +8 -0
  133. package/dist/runtime/storage/fs-runtime.d.ts.map +1 -0
  134. package/dist/runtime/storage/fs-runtime.js +40 -0
  135. package/dist/runtime/storage/fs-runtime.js.map +1 -0
  136. package/dist/runtime/storage/id-contracts.d.ts +22 -0
  137. package/dist/runtime/storage/id-contracts.d.ts.map +1 -0
  138. package/dist/runtime/storage/id-contracts.js +27 -0
  139. package/dist/runtime/storage/id-contracts.js.map +1 -0
  140. package/dist/runtime/storage/image-validation.d.ts +10 -0
  141. package/dist/runtime/storage/image-validation.d.ts.map +1 -1
  142. package/dist/runtime/storage/image-validation.js +17 -4
  143. package/dist/runtime/storage/image-validation.js.map +1 -1
  144. package/dist/runtime/storage/in-memory-adapter.d.ts +4 -0
  145. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  146. package/dist/runtime/storage/in-memory-adapter.js +11 -0
  147. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  148. package/dist/runtime/storage/markdown-adapter.d.ts +63 -0
  149. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -0
  150. package/dist/runtime/storage/markdown-adapter.js +227 -0
  151. package/dist/runtime/storage/markdown-adapter.js.map +1 -0
  152. package/dist/runtime/storage/quota-upload-handler.d.ts +2 -0
  153. package/dist/runtime/storage/quota-upload-handler.d.ts.map +1 -1
  154. package/dist/runtime/storage/quota-upload-handler.js +34 -8
  155. package/dist/runtime/storage/quota-upload-handler.js.map +1 -1
  156. package/dist/runtime/storage/session-overlay-adapter.d.ts +3 -0
  157. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  158. package/dist/runtime/storage/session-overlay-adapter.js +3 -1
  159. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  160. package/dist/runtime/storage/sidecar-meta-store.d.ts +51 -0
  161. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -0
  162. package/dist/runtime/storage/sidecar-meta-store.js +209 -0
  163. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -0
  164. package/dist/runtime/themes/preset-tokens.d.ts +0 -1
  165. package/dist/runtime/themes/preset-tokens.d.ts.map +1 -1
  166. package/dist/runtime/themes/preset-tokens.js +1 -1
  167. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  168. package/dist/runtime/utils.d.ts +9 -1
  169. package/dist/runtime/utils.d.ts.map +1 -1
  170. package/dist/runtime/utils.js +21 -1
  171. package/dist/runtime/utils.js.map +1 -1
  172. package/dist/schema-utils.d.ts.map +1 -1
  173. package/dist/schema-utils.js +1 -0
  174. package/dist/schema-utils.js.map +1 -1
  175. package/dist/types.d.ts +17 -0
  176. package/dist/types.d.ts.map +1 -1
  177. package/package.json +9 -1
  178. package/static/cms/dev-toolbar/app.js +263 -0
  179. package/static/cms/editor/highlight.js +46 -0
  180. package/static/cms/editor/sanitize.js +48 -5
  181. package/static/cms/editor/stega-hydrate.js +80 -0
  182. package/static/cms/editor/text-edit.js +22 -0
  183. package/static/cms/editor/tokens-and-text.css +1 -1
  184. package/static/cms/editor/toolbar.css +42 -0
  185. package/static/cms/editor/toolbar.js +97 -15
  186. package/static/cms/editor.js +15 -0
  187. package/static/cms/signin-hint.css +74 -0
  188. package/dist/runtime/schema/template.d.ts +0 -15
  189. package/dist/runtime/schema/template.d.ts.map +0 -1
  190. package/dist/runtime/schema/template.js +0 -38
  191. package/dist/runtime/schema/template.js.map +0 -1
@@ -0,0 +1,263 @@
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
+ if (parsed.collection && parsed.id) return parsed;
23
+ let node = el.parentElement;
24
+ while (node) {
25
+ const scope = node.getAttribute("data-caret-scope");
26
+ if (scope) {
27
+ const parts = scope.split("::");
28
+ if (parts.length === 2) return { collection: parts[0], id: parts[1], field: parsed.field };
29
+ }
30
+ node = node.parentElement;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function devConfig() {
36
+ const c = (typeof window !== "undefined" && window.__CARET_DEV__) || {};
37
+ return {
38
+ mountPath: typeof c.mountPath === "string" ? c.mountPath : "/admin",
39
+ apiBasePath: typeof c.apiBasePath === "string" ? c.apiBasePath : "/api/cms",
40
+ };
41
+ }
42
+
43
+ function classify(el) {
44
+ if (el.tagName.toLowerCase() === "img") return "image";
45
+ if (el.hasAttribute("data-caret-rich")) return "rich";
46
+ return "text";
47
+ }
48
+
49
+ // Walk the page and resolve every data-caret element to a full key, splitting
50
+ // out orphans (field-only attrs with no resolvable scope) and duplicate keys.
51
+ function scanBindings() {
52
+ const els = Array.from(document.querySelectorAll("[data-caret]"));
53
+ const bindings = [];
54
+ const orphans = [];
55
+
56
+ els.forEach((el, i) => {
57
+ const attr = el.getAttribute("data-caret") || "";
58
+ const parsed = parseCaretAttr(attr);
59
+ const resolved = resolveBinding(el, parsed);
60
+ const kind = classify(el);
61
+ if (!resolved) {
62
+ orphans.push({ el, attr, kind, index: i });
63
+ return;
64
+ }
65
+ bindings.push({
66
+ el,
67
+ index: i,
68
+ collection: resolved.collection,
69
+ id: resolved.id,
70
+ field: resolved.field,
71
+ key: `${resolved.collection}::${resolved.id}::${resolved.field}`,
72
+ kind,
73
+ });
74
+ });
75
+
76
+ const counts = new Map();
77
+ bindings.forEach((b) => counts.set(b.key, (counts.get(b.key) || 0) + 1));
78
+ const duplicates = bindings.filter((b) => counts.get(b.key) > 1);
79
+
80
+ // Stega-encoded bindings (live-loader content) only become real data-caret
81
+ // attributes after the editor's stega-hydrate.js runs, which needs auth — so
82
+ // a logged-out scan can undercount. Detect the U+E0000 markers to warn.
83
+ const stega = /\u{E0000}/u.test((document.body && document.body.textContent) || "");
84
+
85
+ return { bindings, orphans, duplicates, stega, total: els.length };
86
+ }
87
+
88
+ // --- login-free highlight: our own outline CSS keyed on [data-caret] directly,
89
+ // so it does NOT depend on the editor's .cms-editable class or editor.css.
90
+ // Colors are deliberately distinct from the editor's blue scheme so the two can
91
+ // be told apart when both are active at once.
92
+ const HIGHLIGHT_STYLE_ID = "__caret-devtoolbar-highlight__";
93
+
94
+ function applyHighlight(active) {
95
+ const existing = document.getElementById(HIGHLIGHT_STYLE_ID);
96
+ if (existing) existing.remove();
97
+ if (!active) return;
98
+ const style = document.createElement("style");
99
+ style.id = HIGHLIGHT_STYLE_ID;
100
+ style.textContent = `
101
+ [data-caret]{outline:2px solid #6366f1 !important;outline-offset:2px;}
102
+ [data-caret][data-caret-rich]{outline-color:#10b981 !important;}
103
+ img[data-caret]{outline:2px dashed #f59e0b !important;outline-offset:4px;}
104
+ `;
105
+ document.head.appendChild(style);
106
+ }
107
+
108
+ function scrollTo(el) {
109
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
110
+ const prev = el.style.outline;
111
+ const prevOffset = el.style.outlineOffset;
112
+ el.style.outline = "3px solid #6366f1";
113
+ el.style.outlineOffset = "3px";
114
+ setTimeout(() => {
115
+ el.style.outline = prev;
116
+ el.style.outlineOffset = prevOffset;
117
+ }, 1500);
118
+ }
119
+
120
+ const KIND_LABEL = { text: "text", image: "image", rich: "rich" };
121
+
122
+ function esc(s) {
123
+ return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
124
+ }
125
+
126
+ const PANEL_CSS = `
127
+ :host{all:initial;}
128
+ astro-dev-toolbar-window{width:min(440px,100%) !important;height:auto !important;}
129
+ .wrap{font-family:system-ui,-apple-system,sans-serif;color:#e2e8f0;width:100%;max-height:min(70vh,520px);display:flex;flex-direction:column;}
130
+ .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);}
131
+ .title{font-size:13px;font-weight:600;}
132
+ .count{font-size:11px;color:#94a3b8;}
133
+ .actions{display:flex;gap:6px;padding:10px 2px;}
134
+ 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;}
135
+ button.btn:hover{background:rgba(255,255,255,.12);}
136
+ button.btn.on{background:#6366f1;border-color:#6366f1;color:#fff;}
137
+ .scroll{overflow-y:auto;flex:1;padding-right:2px;}
138
+ .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;}
139
+ .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;}
140
+ .problem b{color:#fca5a5;}
141
+ .group{margin:10px 0 0;}
142
+ .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);}
143
+ .ghead a{color:#818cf8;text-decoration:none;font-weight:500;white-space:nowrap;}
144
+ .ghead a:hover{text-decoration:underline;}
145
+ .row{display:flex;align-items:center;gap:8px;padding:5px 2px;border-bottom:1px solid rgba(255,255,255,.04);}
146
+ .field{flex:1;font-size:12px;color:#e2e8f0;font-family:ui-monospace,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
147
+ .kind{font-size:9px;text-transform:uppercase;letter-spacing:.04em;padding:1px 5px;border-radius:4px;font-weight:600;}
148
+ .kind.text{background:rgba(99,102,241,.2);color:#a5b4fc;}
149
+ .kind.image{background:rgba(245,158,11,.2);color:#fcd34d;}
150
+ .kind.rich{background:rgba(16,185,129,.2);color:#6ee7b7;}
151
+ .jump{font:inherit;font-size:11px;cursor:pointer;border:none;background:transparent;color:#94a3b8;padding:2px 4px;border-radius:4px;}
152
+ .jump:hover{color:#e2e8f0;background:rgba(255,255,255,.08);}
153
+ .empty{font-size:12px;color:#94a3b8;padding:16px 2px;text-align:center;}
154
+ .dup{color:#fca5a5;}
155
+ `;
156
+
157
+ export default defineToolbarApp({
158
+ init(canvas, app) {
159
+ const style = document.createElement("style");
160
+ style.textContent = PANEL_CSS;
161
+ canvas.appendChild(style);
162
+
163
+ const win = document.createElement("astro-dev-toolbar-window");
164
+ const wrap = document.createElement("div");
165
+ wrap.className = "wrap";
166
+ win.appendChild(wrap);
167
+ canvas.appendChild(win);
168
+
169
+ let highlightOn = false;
170
+ let scan = { bindings: [], orphans: [], duplicates: [], stega: false, total: 0 };
171
+
172
+ function render() {
173
+ scan = scanBindings();
174
+ const { mountPath } = devConfig();
175
+ const { bindings, orphans, duplicates, stega, total } = scan;
176
+
177
+ // Group bindings by collection::id, preserving first-seen order.
178
+ const groups = new Map();
179
+ for (const b of bindings) {
180
+ const gk = `${b.collection}::${b.id}`;
181
+ if (!groups.has(gk)) groups.set(gk, []);
182
+ groups.get(gk).push(b);
183
+ }
184
+
185
+ const dupKeys = new Set(duplicates.map((d) => d.key));
186
+
187
+ let html = `
188
+ <div class="head">
189
+ <span class="title">CaretCMS bindings</span>
190
+ <span class="count">${bindings.length} on page${orphans.length ? ` · ${orphans.length} orphan${orphans.length > 1 ? "s" : ""}` : ""}</span>
191
+ </div>
192
+ <div class="actions">
193
+ <button class="btn ${highlightOn ? "on" : ""}" data-action="highlight">${highlightOn ? "Hide" : "Highlight"} all</button>
194
+ <button class="btn" data-action="rescan">Rescan</button>
195
+ </div>
196
+ <div class="scroll">`;
197
+
198
+ if (stega) {
199
+ 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>`;
200
+ }
201
+ if (orphans.length) {
202
+ 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>`;
203
+ }
204
+ if (duplicates.length) {
205
+ const uniq = Array.from(new Set(duplicates.map((d) => d.key)));
206
+ 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>`;
207
+ }
208
+
209
+ if (!bindings.length && !orphans.length) {
210
+ html += `<div class="empty">No <code>data-caret</code> bindings found on this page.${total ? "" : ""}</div>`;
211
+ }
212
+
213
+ for (const [gk, rows] of groups) {
214
+ const [collection, id] = gk.split("::");
215
+ const studioHref = `${mountPath}/cms/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
216
+ html += `<div class="group">
217
+ <div class="ghead"><span>${esc(gk)}</span><a href="${esc(studioHref)}" target="_blank" rel="noopener">Open in Studio ↗</a></div>`;
218
+ for (const b of rows) {
219
+ html += `<div class="row">
220
+ <span class="field ${dupKeys.has(b.key) ? "dup" : ""}">${esc(b.field)}</span>
221
+ <span class="kind ${b.kind}">${KIND_LABEL[b.kind]}</span>
222
+ <button class="jump" data-action="scroll" data-index="${b.index}">scroll to</button>
223
+ </div>`;
224
+ }
225
+ html += `</div>`;
226
+ }
227
+
228
+ html += `</div>`;
229
+ wrap.innerHTML = html;
230
+ }
231
+
232
+ function elementByIndex(index) {
233
+ const b = scan.bindings.find((x) => x.index === Number(index));
234
+ return b ? b.el : null;
235
+ }
236
+
237
+ wrap.addEventListener("click", (event) => {
238
+ const target = event.target.closest("[data-action]");
239
+ if (!target) return;
240
+ const action = target.getAttribute("data-action");
241
+ if (action === "highlight") {
242
+ highlightOn = !highlightOn;
243
+ applyHighlight(highlightOn);
244
+ app.toggleState({ state: highlightOn });
245
+ target.textContent = `${highlightOn ? "Hide" : "Highlight"} all`;
246
+ target.classList.toggle("on", highlightOn);
247
+ } else if (action === "rescan") {
248
+ render();
249
+ } else if (action === "scroll") {
250
+ const el = elementByIndex(target.getAttribute("data-index"));
251
+ if (el) scrollTo(el);
252
+ }
253
+ });
254
+
255
+ // Re-render each time the panel opens; flag problems with a notification dot.
256
+ app.onToggled(({ state }) => {
257
+ if (!state) return;
258
+ render();
259
+ const probs = scan.orphans.length + scan.duplicates.length;
260
+ app.toggleNotification({ state: probs > 0, level: "warning" });
261
+ });
262
+ },
263
+ });
@@ -0,0 +1,46 @@
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 textNode = Array.from(highlightBtn.childNodes).find(
23
+ (n) => n.nodeType === 3 && n.textContent?.trim(),
24
+ );
25
+
26
+ if (highlightActive) {
27
+ if (textNode) textNode.textContent = ' Hide All';
28
+ showToast('Showing all editable regions', 'success');
29
+ } else {
30
+ if (textNode) textNode.textContent = ' Show All';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * One-time reveal: flash every editable region, then fade out after `durationMs`.
36
+ * No-op if the user has already turned highlighting on manually, so it never
37
+ * desyncs the toolbar button's state.
38
+ */
39
+ export function revealAndFade(durationMs = 2500) {
40
+ if (highlightActive) return;
41
+ document.body.classList.add('cms-highlight-all');
42
+ setTimeout(() => {
43
+ if (highlightActive) return;
44
+ document.body.classList.remove('cms-highlight-all');
45
+ }, durationMs);
46
+ }
@@ -15,24 +15,52 @@ const ALLOWED_ATTRS = {
15
15
 
16
16
  const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/)/i;
17
17
 
18
+ /**
19
+ * Does `cls` match any pattern? (exact, `prefix-*`, or lone `*`)
20
+ * MUST stay identical to classAllowed() in src/runtime/sanitize-html.ts.
21
+ * @param {string} cls
22
+ * @param {readonly string[]} patterns
23
+ * @returns {boolean}
24
+ */
25
+ function classAllowed(cls, patterns) {
26
+ for (const p of patterns) {
27
+ if (p === '*') return true;
28
+ if (p.endsWith('*')) {
29
+ if (cls.startsWith(p.slice(0, -1))) return true;
30
+ } else if (cls === p) {
31
+ return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
36
+
18
37
  /**
19
38
  * Sanitize an HTML string, keeping only allowed inline tags and attributes.
39
+ * Per-tag class allowlist comes from `options.allowedClasses`, falling back to
40
+ * `window.__CARET__.allowedClasses` so the client matches the server config.
20
41
  * @param {string} html
42
+ * @param {{ allowedClasses?: Record<string, readonly string[]> }} [options]
21
43
  * @returns {string}
22
44
  */
23
- export function sanitizeHtml(html) {
45
+ export function sanitizeHtml(html, options) {
24
46
  if (!html) return '';
25
47
 
48
+ const allowedClasses =
49
+ options?.allowedClasses ??
50
+ (typeof window !== 'undefined' ? window.__CARET__?.allowedClasses : undefined) ??
51
+ null;
52
+
26
53
  const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
27
- sanitizeNode(doc.body);
54
+ sanitizeNode(doc.body, allowedClasses);
28
55
  return doc.body.innerHTML;
29
56
  }
30
57
 
31
58
  /**
32
59
  * Recursively sanitize a DOM node in place.
33
60
  * @param {Node} parent
61
+ * @param {Record<string, readonly string[]> | null} allowedClasses
34
62
  */
35
- function sanitizeNode(parent) {
63
+ function sanitizeNode(parent, allowedClasses) {
36
64
  const children = Array.from(parent.childNodes);
37
65
 
38
66
  for (const node of children) {
@@ -57,14 +85,29 @@ function sanitizeNode(parent) {
57
85
  while (el.firstChild) frag.appendChild(el.firstChild);
58
86
  parent.replaceChild(frag, el);
59
87
  // Re-sanitize the newly promoted children
60
- sanitizeNode(parent);
88
+ sanitizeNode(parent, allowedClasses);
61
89
  return;
62
90
  }
63
91
 
64
92
  // Strip disallowed attributes
65
93
  const allowed = ALLOWED_ATTRS[tag];
94
+ const classPatterns = allowedClasses ? allowedClasses[tag] : undefined;
66
95
  const attrs = Array.from(el.attributes);
67
96
  for (const attr of attrs) {
97
+ // class is gated by the per-tag allowlist, not ALLOWED_ATTRS
98
+ if (attr.name === 'class') {
99
+ if (!classPatterns) {
100
+ el.removeAttribute('class');
101
+ continue;
102
+ }
103
+ const kept = attr.value
104
+ .split(/\s+/)
105
+ .filter((c) => c !== '' && classAllowed(c, classPatterns))
106
+ .join(' ');
107
+ if (kept) el.setAttribute('class', kept);
108
+ else el.removeAttribute('class');
109
+ continue;
110
+ }
68
111
  if (!allowed || !allowed.has(attr.name)) {
69
112
  el.removeAttribute(attr.name);
70
113
  }
@@ -84,6 +127,6 @@ function sanitizeNode(parent) {
84
127
  }
85
128
 
86
129
  // Recurse into children
87
- sanitizeNode(el);
130
+ sanitizeNode(el, allowedClasses);
88
131
  }
89
132
  }
@@ -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;
@@ -175,6 +175,39 @@
175
175
  color: rgba(59, 130, 246, 0.9);
176
176
  }
177
177
 
178
+ /* The base `.cms-toolbar button` sets `display`, which overrides the default
179
+ [hidden] behavior — restore it so hidden draft actions actually disappear. */
180
+ .cms-toolbar button[hidden] {
181
+ display: none;
182
+ }
183
+
184
+ /* Draft preview active — amber, signalling "unpublished" */
185
+ .cms-toolbar button.cms-preview-btn-active {
186
+ background: rgba(234, 179, 8, 0.12);
187
+ border-color: rgba(234, 179, 8, 0.4);
188
+ color: var(--cms-yellow);
189
+ }
190
+
191
+ /* Publish — green */
192
+ .cms-toolbar button.cms-publish-btn {
193
+ background: rgba(34, 197, 94, 0.12);
194
+ border-color: rgba(34, 197, 94, 0.3);
195
+ color: var(--cms-green);
196
+ }
197
+
198
+ .cms-toolbar button.cms-publish-btn:hover {
199
+ background: rgba(34, 197, 94, 0.25);
200
+ border-color: rgba(34, 197, 94, 0.5);
201
+ color: var(--cms-green);
202
+ }
203
+
204
+ /* Discard — red on hover */
205
+ .cms-toolbar button.cms-discard-btn:hover {
206
+ background: rgba(248, 113, 113, 0.08);
207
+ border-color: rgba(248, 113, 113, 0.3);
208
+ color: var(--cms-red);
209
+ }
210
+
178
211
  /* Highlight all mode — show every editable region */
179
212
  .cms-highlight-all [data-caret].cms-editable {
180
213
  outline-color: var(--cms-blue-hover);
@@ -186,6 +219,15 @@
186
219
  outline-offset: 4px;
187
220
  }
188
221
 
222
+ /* The welcome reveal still flashes editable regions, but without animating the
223
+ transition — motion is suppressed, the information is not. */
224
+ @media (prefers-reduced-motion: reduce) {
225
+ .cms-highlight-all [data-caret].cms-editable,
226
+ .cms-highlight-all img[data-caret] {
227
+ transition: none;
228
+ }
229
+ }
230
+
189
231
  /* ---------------------------------------------------------------------------
190
232
  Toolbar — page navigation links
191
233
  --------------------------------------------------------------------------- */