@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.
- package/README.md +117 -6
- package/dist/browser-runtime.d.ts.map +1 -1
- package/dist/browser-runtime.js +34 -20
- package/dist/browser-runtime.js.map +1 -1
- package/dist/contracts.d.ts +14 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +14 -0
- package/dist/contracts.js.map +1 -0
- package/dist/editable.d.ts +28 -0
- package/dist/editable.d.ts.map +1 -0
- package/dist/editable.js +122 -0
- package/dist/editable.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +134 -2
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +45 -3
- package/dist/loader.js.map +1 -1
- package/dist/providers/storage/markdown.d.ts +7 -0
- package/dist/providers/storage/markdown.d.ts.map +1 -0
- package/dist/providers/storage/markdown.js +6 -0
- package/dist/providers/storage/markdown.js.map +1 -0
- package/dist/runtime/auth/cookie-utils.d.ts +1 -0
- package/dist/runtime/auth/cookie-utils.d.ts.map +1 -1
- package/dist/runtime/auth/cookie-utils.js +1 -1
- package/dist/runtime/auth/cookie-utils.js.map +1 -1
- package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
- package/dist/runtime/auth/rate-limiter.js +19 -13
- package/dist/runtime/auth/rate-limiter.js.map +1 -1
- package/dist/runtime/auth/session.d.ts +8 -0
- package/dist/runtime/auth/session.d.ts.map +1 -1
- package/dist/runtime/auth/session.js +72 -11
- package/dist/runtime/auth/session.js.map +1 -1
- package/dist/runtime/config.d.ts +1 -0
- package/dist/runtime/config.d.ts.map +1 -1
- package/dist/runtime/config.js +2 -1
- package/dist/runtime/config.js.map +1 -1
- package/dist/runtime/git-journal.d.ts +19 -0
- package/dist/runtime/git-journal.d.ts.map +1 -0
- package/dist/runtime/git-journal.js +64 -0
- package/dist/runtime/git-journal.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +125 -8
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +7 -4
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +7 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +30 -17
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +7 -0
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +10 -0
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish.d.ts +38 -0
- package/dist/runtime/publish.d.ts.map +1 -0
- package/dist/runtime/publish.js +71 -0
- package/dist/runtime/publish.js.map +1 -0
- package/dist/runtime/request-context.d.ts +19 -0
- package/dist/runtime/request-context.d.ts.map +1 -1
- package/dist/runtime/request-context.js.map +1 -1
- package/dist/runtime/rewrite.d.ts +11 -1
- package/dist/runtime/rewrite.d.ts.map +1 -1
- package/dist/runtime/rewrite.js +21 -15
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.d.ts +28 -0
- package/dist/runtime/rich-allowlist.d.ts.map +1 -0
- package/dist/runtime/rich-allowlist.js +43 -0
- package/dist/runtime/rich-allowlist.js.map +1 -0
- package/dist/runtime/routes/_helpers.d.ts +26 -0
- package/dist/runtime/routes/_helpers.d.ts.map +1 -1
- package/dist/runtime/routes/_helpers.js +83 -0
- package/dist/runtime/routes/_helpers.js.map +1 -1
- package/dist/runtime/routes/admin.d.ts.map +1 -1
- package/dist/runtime/routes/admin.js +55 -6
- package/dist/runtime/routes/admin.js.map +1 -1
- package/dist/runtime/routes/auth-login.d.ts.map +1 -1
- package/dist/runtime/routes/auth-login.js +25 -8
- package/dist/runtime/routes/auth-login.js.map +1 -1
- package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
- package/dist/runtime/routes/auth-logout.js +4 -0
- package/dist/runtime/routes/auth-logout.js.map +1 -1
- package/dist/runtime/routes/collections-metadata.d.ts.map +1 -1
- package/dist/runtime/routes/collections-metadata.js +4 -2
- package/dist/runtime/routes/collections-metadata.js.map +1 -1
- package/dist/runtime/routes/draft.d.ts +8 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -0
- package/dist/runtime/routes/draft.js +38 -0
- package/dist/runtime/routes/draft.js.map +1 -0
- package/dist/runtime/routes/history.d.ts.map +1 -1
- package/dist/runtime/routes/history.js +21 -8
- package/dist/runtime/routes/history.js.map +1 -1
- package/dist/runtime/routes/mutate.d.ts.map +1 -1
- package/dist/runtime/routes/mutate.js +5 -9
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts +9 -0
- package/dist/runtime/routes/publish.d.ts.map +1 -0
- package/dist/runtime/routes/publish.js +66 -0
- package/dist/runtime/routes/publish.js.map +1 -0
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +4 -2
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +16 -3
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/sanitize-html.d.ts +8 -2
- package/dist/runtime/sanitize-html.d.ts.map +1 -1
- package/dist/runtime/sanitize-html.js +57 -38
- package/dist/runtime/sanitize-html.js.map +1 -1
- package/dist/runtime/stega.d.ts +38 -0
- package/dist/runtime/stega.d.ts.map +1 -0
- package/dist/runtime/stega.js +98 -0
- package/dist/runtime/stega.js.map +1 -0
- package/dist/runtime/storage/atomic-write.d.ts +22 -0
- package/dist/runtime/storage/atomic-write.d.ts.map +1 -0
- package/dist/runtime/storage/atomic-write.js +44 -0
- package/dist/runtime/storage/atomic-write.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +14 -11
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/filesystem-adapter.js +71 -201
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.d.ts +47 -0
- package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -0
- package/dist/runtime/storage/frontmatter-codec.js +515 -0
- package/dist/runtime/storage/frontmatter-codec.js.map +1 -0
- package/dist/runtime/storage/fs-runtime.d.ts +8 -0
- package/dist/runtime/storage/fs-runtime.d.ts.map +1 -0
- package/dist/runtime/storage/fs-runtime.js +40 -0
- package/dist/runtime/storage/fs-runtime.js.map +1 -0
- package/dist/runtime/storage/id-contracts.d.ts +22 -0
- package/dist/runtime/storage/id-contracts.d.ts.map +1 -0
- package/dist/runtime/storage/id-contracts.js +27 -0
- package/dist/runtime/storage/id-contracts.js.map +1 -0
- package/dist/runtime/storage/image-validation.d.ts +10 -0
- package/dist/runtime/storage/image-validation.d.ts.map +1 -1
- package/dist/runtime/storage/image-validation.js +17 -4
- package/dist/runtime/storage/image-validation.js.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.d.ts +4 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.js +11 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts +63 -0
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -0
- package/dist/runtime/storage/markdown-adapter.js +227 -0
- package/dist/runtime/storage/markdown-adapter.js.map +1 -0
- package/dist/runtime/storage/quota-upload-handler.d.ts +2 -0
- package/dist/runtime/storage/quota-upload-handler.d.ts.map +1 -1
- package/dist/runtime/storage/quota-upload-handler.js +34 -8
- package/dist/runtime/storage/quota-upload-handler.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +3 -0
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +3 -1
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.d.ts +51 -0
- package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -0
- package/dist/runtime/storage/sidecar-meta-store.js +209 -0
- package/dist/runtime/storage/sidecar-meta-store.js.map +1 -0
- package/dist/runtime/themes/preset-tokens.d.ts +0 -1
- package/dist/runtime/themes/preset-tokens.d.ts.map +1 -1
- package/dist/runtime/themes/preset-tokens.js +1 -1
- package/dist/runtime/themes/preset-tokens.js.map +1 -1
- package/dist/runtime/utils.d.ts +9 -1
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +21 -1
- package/dist/runtime/utils.js.map +1 -1
- package/dist/schema-utils.d.ts.map +1 -1
- package/dist/schema-utils.js +1 -0
- package/dist/schema-utils.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -1
- package/static/cms/dev-toolbar/app.js +263 -0
- package/static/cms/editor/highlight.js +46 -0
- package/static/cms/editor/sanitize.js +48 -5
- package/static/cms/editor/stega-hydrate.js +80 -0
- package/static/cms/editor/text-edit.js +22 -0
- package/static/cms/editor/tokens-and-text.css +1 -1
- package/static/cms/editor/toolbar.css +42 -0
- package/static/cms/editor/toolbar.js +97 -15
- package/static/cms/editor.js +15 -0
- package/static/cms/signin-hint.css +74 -0
- package/dist/runtime/schema/template.d.ts +0 -15
- package/dist/runtime/schema/template.d.ts.map +0 -1
- package/dist/runtime/schema/template.js +0 -38
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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);
|
|
@@ -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
|
--------------------------------------------------------------------------- */
|