@caretcms/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -11
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -5
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +8 -0
- package/dist/loader.js.map +1 -1
- package/dist/markdown/paragraphs.d.ts +14 -0
- package/dist/markdown/paragraphs.d.ts.map +1 -0
- package/dist/markdown/paragraphs.js +61 -0
- package/dist/markdown/paragraphs.js.map +1 -0
- package/dist/markdown/remark.d.ts.map +1 -1
- package/dist/markdown/remark.js +1 -0
- package/dist/markdown/remark.js.map +1 -1
- package/dist/markdown/satteri.d.ts.map +1 -1
- package/dist/markdown/satteri.js +3 -1
- package/dist/markdown/satteri.js.map +1 -1
- package/dist/markdown/stamp.d.ts +0 -9
- package/dist/markdown/stamp.d.ts.map +1 -1
- package/dist/markdown/stamp.js +6 -0
- package/dist/markdown/stamp.js.map +1 -1
- package/dist/providers/deployment/github.d.ts +27 -0
- package/dist/providers/deployment/github.d.ts.map +1 -0
- package/dist/providers/deployment/github.js +180 -0
- package/dist/providers/deployment/github.js.map +1 -0
- package/dist/providers/deployment/simulated.d.ts +10 -0
- package/dist/providers/deployment/simulated.d.ts.map +1 -0
- package/dist/providers/deployment/simulated.js +29 -0
- package/dist/providers/deployment/simulated.js.map +1 -0
- package/dist/runtime/authorization.d.ts +11 -0
- package/dist/runtime/authorization.d.ts.map +1 -0
- package/dist/runtime/authorization.js +29 -0
- package/dist/runtime/authorization.js.map +1 -0
- package/dist/runtime/collection-policy.d.ts +11 -0
- package/dist/runtime/collection-policy.d.ts.map +1 -0
- package/dist/runtime/collection-policy.js +44 -0
- package/dist/runtime/collection-policy.js.map +1 -0
- package/dist/runtime/content-errors.d.ts +9 -0
- package/dist/runtime/content-errors.d.ts.map +1 -0
- package/dist/runtime/content-errors.js +17 -0
- package/dist/runtime/content-errors.js.map +1 -0
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +13 -4
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/deployment-status.d.ts +14 -0
- package/dist/runtime/deployment-status.d.ts.map +1 -0
- package/dist/runtime/deployment-status.js +57 -0
- package/dist/runtime/deployment-status.js.map +1 -0
- package/dist/runtime/draft-state.d.ts +16 -0
- package/dist/runtime/draft-state.d.ts.map +1 -0
- package/dist/runtime/draft-state.js +29 -0
- package/dist/runtime/draft-state.js.map +1 -0
- package/dist/runtime/i18n.d.ts +13 -2
- package/dist/runtime/i18n.d.ts.map +1 -1
- package/dist/runtime/i18n.js +26 -4
- package/dist/runtime/i18n.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +21 -2
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +9 -0
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +45 -4
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +2 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +115 -63
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +3 -1
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +12 -1
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish-recovery.d.ts +32 -0
- package/dist/runtime/publish-recovery.d.ts.map +1 -0
- package/dist/runtime/publish-recovery.js +98 -0
- package/dist/runtime/publish-recovery.js.map +1 -0
- package/dist/runtime/publish.d.ts +10 -4
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +104 -67
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/rebuild-webhook.d.ts +4 -0
- package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
- package/dist/runtime/rebuild-webhook.js +12 -2
- package/dist/runtime/rebuild-webhook.js.map +1 -1
- package/dist/runtime/request-context.d.ts +3 -1
- 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.map +1 -1
- package/dist/runtime/rewrite.js +28 -2
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.js +1 -1
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
- package/dist/runtime/routes/admin-entry.js +24 -12
- package/dist/runtime/routes/admin-entry.js.map +1 -1
- package/dist/runtime/routes/auth-session.d.ts.map +1 -1
- package/dist/runtime/routes/auth-session.js +6 -0
- package/dist/runtime/routes/auth-session.js.map +1 -1
- package/dist/runtime/routes/collection-list.d.ts.map +1 -1
- package/dist/runtime/routes/collection-list.js +164 -40
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/deployment.d.ts +5 -0
- package/dist/runtime/routes/deployment.d.ts.map +1 -0
- package/dist/runtime/routes/deployment.js +38 -0
- package/dist/runtime/routes/deployment.js.map +1 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -1
- package/dist/runtime/routes/draft.js +10 -3
- package/dist/runtime/routes/draft.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +74 -39
- package/dist/runtime/routes/entries.js.map +1 -1
- package/dist/runtime/routes/history.d.ts.map +1 -1
- package/dist/runtime/routes/history.js +37 -21
- 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 +4 -1
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts.map +1 -1
- package/dist/runtime/routes/publish.js +127 -33
- package/dist/runtime/routes/publish.js.map +1 -1
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +4 -0
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/studio-home.d.ts.map +1 -1
- package/dist/runtime/routes/studio-home.js +3 -1
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +3 -0
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/schema-registry.d.ts.map +1 -1
- package/dist/runtime/schema-registry.js +2 -0
- package/dist/runtime/schema-registry.js.map +1 -1
- package/dist/runtime/storage/entry-commit.d.ts +8 -0
- package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
- package/dist/runtime/storage/entry-commit.js +45 -0
- package/dist/runtime/storage/entry-commit.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/filesystem-adapter.js +12 -3
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
- package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.js +156 -13
- package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.js +6 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +18 -20
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +100 -13
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
- package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.js +48 -5
- package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +3 -4
- package/dist/runtime/utils.js.map +1 -1
- package/dist/types.d.ts +123 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -3
- package/static/cms/admin-entry.css +9 -0
- package/static/cms/admin-entry.js +251 -687
- package/static/cms/dev-toolbar/app.js +78 -19
- package/static/cms/editor/config.js +35 -15
- package/static/cms/editor/content-map.css +8 -1
- package/static/cms/editor/content-map.js +62 -23
- package/static/cms/editor/deployment-status.js +66 -0
- package/static/cms/editor/draft-client.js +87 -0
- package/static/cms/editor/guards.js +20 -0
- package/static/cms/editor/helpers.js +30 -1
- package/static/cms/editor/highlight.js +1 -0
- package/static/cms/editor/image-edit.js +43 -1
- package/static/cms/editor/image-utils.js +23 -7
- package/static/cms/editor/link-follow.js +122 -41
- package/static/cms/editor/link-ui.css +10 -1
- package/static/cms/editor/linkify.js +2 -0
- package/static/cms/editor/md-block-edit.js +140 -37
- package/static/cms/editor/md-paragraphs.js +98 -0
- package/static/cms/editor/md-serialize.js +1 -1
- package/static/cms/editor/panel.js +43 -15
- package/static/cms/editor/preview-mode.js +44 -0
- package/static/cms/editor/publish-result.js +100 -0
- package/static/cms/editor/rich-toolbar.js +58 -15
- package/static/cms/editor/sanitize.js +6 -2
- package/static/cms/editor/save-queue.js +75 -17
- package/static/cms/editor/section-controls/activation.js +3 -0
- package/static/cms/editor/section-controls/api.js +25 -7
- package/static/cms/editor/section-controls/index.js +60 -8
- package/static/cms/editor/section-controls/model.js +39 -4
- package/static/cms/editor/section-controls/mutations.js +15 -0
- package/static/cms/editor/section-controls/page-context.js +9 -0
- package/static/cms/editor/section-controls/pickers.js +53 -2
- package/static/cms/editor/section-controls/reorder.js +23 -0
- package/static/cms/editor/section-controls/selection.js +6 -0
- package/static/cms/editor/section-controls/spacing-drag.js +16 -0
- package/static/cms/editor/section-controls/ui-elements.js +7 -0
- package/static/cms/editor/section-controls/utils.js +7 -0
- package/static/cms/editor/section-controls.css +18 -3
- package/static/cms/editor/security.js +13 -2
- package/static/cms/editor/stega-hydrate.js +17 -4
- package/static/cms/editor/sync.js +208 -10
- package/static/cms/editor/text-edit.js +37 -3
- package/static/cms/editor/text-link-interactions.js +59 -17
- package/static/cms/editor/toast.js +23 -9
- package/static/cms/editor/toolbar.css +5 -0
- package/static/cms/editor/toolbar.js +91 -101
- package/static/cms/editor.js +2 -1
- package/static/cms/studio/entry-loader.js +102 -0
- package/static/cms/studio/field-model.js +110 -0
- package/static/cms/studio/fields.js +319 -0
- package/static/cms/studio/history-client.js +74 -0
- package/static/cms/studio/mutation-client.js +74 -0
- package/static/cms/studio/sync-client.js +107 -0
- package/static/cms/studio/upload-client.js +109 -0
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
* cross-browser approach for contenteditable formatting).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** @typedef {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} RichEditorState */
|
|
10
|
+
/** @typedef {(url: string | null) => void} LinkApplyCallback */
|
|
11
|
+
/** @typedef {(rect: DOMRect | DOMRectReadOnly, initialUrl: string, onApply: LinkApplyCallback) => void} ShowLinkPopup */
|
|
12
|
+
/** @typedef {{ sep?: boolean, cmd?: string, label?: string, title?: string, style?: string, isLink?: boolean }} ToolbarItem */
|
|
10
13
|
|
|
14
|
+
/** @type {HTMLDivElement | null} */
|
|
11
15
|
let toolbar = null;
|
|
16
|
+
/** @type {HTMLElement | null} */
|
|
12
17
|
let currentRichEl = null;
|
|
18
|
+
/** @type {ShowLinkPopup | null} */
|
|
13
19
|
let showLinkPopupFn = null;
|
|
20
|
+
/** @type {RichEditorState | null} */
|
|
21
|
+
let editorState = null;
|
|
14
22
|
|
|
23
|
+
/** @type {ToolbarItem[]} */
|
|
15
24
|
const BUTTONS = [
|
|
16
25
|
{ cmd: 'bold', label: 'B', title: 'Bold (Ctrl+B)', style: 'font-weight:700' },
|
|
17
26
|
{ cmd: 'italic', label: 'I', title: 'Italic (Ctrl+I)', style: 'font-style:italic' },
|
|
@@ -20,6 +29,7 @@ const BUTTONS = [
|
|
|
20
29
|
{ cmd: 'removeFormat', label: 'T\u2093', title: 'Clear formatting' },
|
|
21
30
|
];
|
|
22
31
|
|
|
32
|
+
/** @returns {HTMLDivElement} */
|
|
23
33
|
function createToolbar() {
|
|
24
34
|
const el = document.createElement('div');
|
|
25
35
|
el.className = 'cms-rich-toolbar';
|
|
@@ -32,19 +42,21 @@ function createToolbar() {
|
|
|
32
42
|
el.appendChild(sep);
|
|
33
43
|
continue;
|
|
34
44
|
}
|
|
45
|
+
if (!btn.cmd) continue;
|
|
46
|
+
const command = btn.cmd;
|
|
35
47
|
|
|
36
48
|
const button = document.createElement('button');
|
|
37
49
|
button.type = 'button';
|
|
38
50
|
button.className = 'cms-rich-toolbar-btn';
|
|
39
|
-
button.title = btn.title;
|
|
40
|
-
button.dataset.cmd =
|
|
51
|
+
button.title = btn.title || '';
|
|
52
|
+
button.dataset.cmd = command;
|
|
41
53
|
if (btn.style) button.style.cssText = btn.style;
|
|
42
54
|
|
|
43
55
|
// Use text for simple labels, innerHTML for the link icon
|
|
44
56
|
if (btn.isLink) {
|
|
45
57
|
button.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>`;
|
|
46
58
|
} else {
|
|
47
|
-
button.textContent = btn.label;
|
|
59
|
+
button.textContent = btn.label || '';
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
button.addEventListener('mousedown', (e) => {
|
|
@@ -52,7 +64,8 @@ function createToolbar() {
|
|
|
52
64
|
if (btn.isLink) {
|
|
53
65
|
handleLinkButton();
|
|
54
66
|
} else {
|
|
55
|
-
document.execCommand(
|
|
67
|
+
document.execCommand(command);
|
|
68
|
+
if (currentRichEl) editorState?.dirtyEls.add(currentRichEl);
|
|
56
69
|
updateActiveStates();
|
|
57
70
|
}
|
|
58
71
|
});
|
|
@@ -69,10 +82,12 @@ function createToolbar() {
|
|
|
69
82
|
|
|
70
83
|
function handleLinkButton() {
|
|
71
84
|
const sel = window.getSelection();
|
|
72
|
-
if (!sel || sel.isCollapsed || !currentRichEl) return;
|
|
85
|
+
if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !currentRichEl) return;
|
|
73
86
|
|
|
74
87
|
const range = sel.getRangeAt(0).cloneRange();
|
|
75
88
|
const selRect = range.getBoundingClientRect();
|
|
89
|
+
const target = currentRichEl;
|
|
90
|
+
const selectedText = sel.toString();
|
|
76
91
|
|
|
77
92
|
// Check if selection is already a link
|
|
78
93
|
const anchorNode = sel.anchorNode;
|
|
@@ -81,18 +96,32 @@ function handleLinkButton() {
|
|
|
81
96
|
: anchorNode instanceof Element ? anchorNode.closest('a') : null;
|
|
82
97
|
const initialUrl = existingLink?.getAttribute('href') || '';
|
|
83
98
|
|
|
99
|
+
function liveExistingLink() {
|
|
100
|
+
if (!existingLink) return null;
|
|
101
|
+
if (target.contains(existingLink)) return existingLink;
|
|
102
|
+
return Array.from(target.querySelectorAll('a')).find((link) =>
|
|
103
|
+
link.getAttribute('href') === initialUrl && link.textContent === selectedText
|
|
104
|
+
) || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
84
107
|
if (showLinkPopupFn) {
|
|
85
108
|
showLinkPopupFn(selRect, initialUrl, (url) => {
|
|
86
|
-
|
|
109
|
+
target.focus();
|
|
110
|
+
const link = liveExistingLink();
|
|
87
111
|
|
|
88
112
|
if (url) {
|
|
89
113
|
const newSel = window.getSelection();
|
|
114
|
+
if (!newSel) {
|
|
115
|
+
if (editorState) editorState.linkPopupEl = null;
|
|
116
|
+
hide();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
90
119
|
newSel.removeAllRanges();
|
|
91
120
|
newSel.addRange(range);
|
|
92
121
|
|
|
93
|
-
if (
|
|
122
|
+
if (link) {
|
|
94
123
|
// Update existing link
|
|
95
|
-
|
|
124
|
+
link.setAttribute('href', url);
|
|
96
125
|
} else {
|
|
97
126
|
document.execCommand('createLink', false, url);
|
|
98
127
|
}
|
|
@@ -109,13 +138,19 @@ function handleLinkButton() {
|
|
|
109
138
|
newLink.setAttribute('rel', 'noopener noreferrer');
|
|
110
139
|
}
|
|
111
140
|
}
|
|
112
|
-
|
|
141
|
+
editorState?.dirtyEls.add(target);
|
|
142
|
+
} else if (url === '' && link) {
|
|
113
143
|
// Empty URL = unlink
|
|
114
|
-
document.execCommand('unlink'
|
|
144
|
+
document.execCommand('unlink');
|
|
145
|
+
editorState?.dirtyEls.add(target);
|
|
115
146
|
}
|
|
116
147
|
|
|
148
|
+
if (editorState) editorState.linkPopupEl = null;
|
|
117
149
|
hide();
|
|
118
150
|
});
|
|
151
|
+
// showLinkPopup dismisses any prior popover first, which clears this state.
|
|
152
|
+
// Claim the new popover only after it has completed that cleanup.
|
|
153
|
+
if (editorState) editorState.linkPopupEl = target;
|
|
119
154
|
}
|
|
120
155
|
}
|
|
121
156
|
|
|
@@ -123,8 +158,9 @@ function updateActiveStates() {
|
|
|
123
158
|
if (!toolbar) return;
|
|
124
159
|
const buttons = toolbar.querySelectorAll('.cms-rich-toolbar-btn[data-cmd]');
|
|
125
160
|
for (const btn of buttons) {
|
|
161
|
+
if (!(btn instanceof HTMLButtonElement)) continue;
|
|
126
162
|
const cmd = btn.dataset.cmd;
|
|
127
|
-
if (cmd === 'createLink' || cmd === 'removeFormat') continue;
|
|
163
|
+
if (!cmd || cmd === 'createLink' || cmd === 'removeFormat') continue;
|
|
128
164
|
try {
|
|
129
165
|
btn.classList.toggle('active', document.queryCommandState(cmd));
|
|
130
166
|
} catch {
|
|
@@ -133,8 +169,9 @@ function updateActiveStates() {
|
|
|
133
169
|
}
|
|
134
170
|
}
|
|
135
171
|
|
|
172
|
+
/** @param {Selection} sel */
|
|
136
173
|
function position(sel) {
|
|
137
|
-
if (!toolbar ||
|
|
174
|
+
if (!toolbar || sel.isCollapsed || sel.rangeCount === 0) return;
|
|
138
175
|
|
|
139
176
|
const range = sel.getRangeAt(0);
|
|
140
177
|
const rect = range.getBoundingClientRect();
|
|
@@ -154,6 +191,7 @@ function position(sel) {
|
|
|
154
191
|
toolbar.style.top = `${top}px`;
|
|
155
192
|
}
|
|
156
193
|
|
|
194
|
+
/** @param {Selection} sel */
|
|
157
195
|
function show(sel) {
|
|
158
196
|
if (!toolbar) toolbar = createToolbar();
|
|
159
197
|
toolbar.style.display = 'flex';
|
|
@@ -182,7 +220,7 @@ function onSelectionChange() {
|
|
|
182
220
|
? anchor.closest('[data-caret-rich], [data-caret-md]')
|
|
183
221
|
: anchor?.parentElement?.closest('[data-caret-rich], [data-caret-md]');
|
|
184
222
|
|
|
185
|
-
if (!richEl) {
|
|
223
|
+
if (!(richEl instanceof HTMLElement)) {
|
|
186
224
|
hide();
|
|
187
225
|
return;
|
|
188
226
|
}
|
|
@@ -193,9 +231,14 @@ function onSelectionChange() {
|
|
|
193
231
|
|
|
194
232
|
/**
|
|
195
233
|
* Mount the rich text toolbar. Call once during editor boot.
|
|
196
|
-
* @param {
|
|
234
|
+
* @param {object} options
|
|
235
|
+
* @param {RichEditorState} options.state
|
|
236
|
+
* @param {(message: string, kind: 'success' | 'error') => unknown} options.showToast
|
|
237
|
+
* @param {ShowLinkPopup} [options.showLinkPopup]
|
|
197
238
|
*/
|
|
198
239
|
export function mountRichToolbar({ state, showToast, showLinkPopup }) {
|
|
240
|
+
editorState = state;
|
|
241
|
+
void showToast;
|
|
199
242
|
showLinkPopupFn = showLinkPopup || null;
|
|
200
243
|
document.addEventListener('selectionchange', onSelectionChange);
|
|
201
244
|
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// import it). They are `export`ed so tests/unit/sanitizer-parity.test.ts can
|
|
11
11
|
// assert they never drift from the source of truth — CI fails on divergence.
|
|
12
12
|
export const ALLOWED_TAGS = new Set([
|
|
13
|
-
'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup', 'span',
|
|
13
|
+
'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup', 'span', 'code',
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
+
/** @type {Readonly<Record<string, ReadonlySet<string>>>} */
|
|
16
17
|
export const ALLOWED_ATTRS = {
|
|
17
18
|
a: new Set(['href', 'target', 'rel']),
|
|
18
19
|
};
|
|
@@ -50,9 +51,12 @@ export function classAllowed(cls, patterns) {
|
|
|
50
51
|
export function sanitizeHtml(html, options) {
|
|
51
52
|
if (!html) return '';
|
|
52
53
|
|
|
54
|
+
const injected = typeof window !== 'undefined'
|
|
55
|
+
? /** @type {Window & { __CARET__?: { allowedClasses?: Record<string, readonly string[]> } }} */ (window).__CARET__
|
|
56
|
+
: undefined;
|
|
53
57
|
const allowedClasses =
|
|
54
58
|
options?.allowedClasses ??
|
|
55
|
-
|
|
59
|
+
injected?.allowedClasses ??
|
|
56
60
|
null;
|
|
57
61
|
|
|
58
62
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
@@ -1,26 +1,55 @@
|
|
|
1
|
-
import { buildCmsUrl } from './config.js';
|
|
1
|
+
import { buildCmsUrl, isPolicyDraftMode } from './config.js';
|
|
2
2
|
import { mutateHeaders, readHeaders } from './security.js';
|
|
3
3
|
|
|
4
|
+
/** @typedef {{ revision: number, data: Record<string, unknown> }} EntrySnapshot */
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{ ok: true, revision?: number }
|
|
7
|
+
* | { ok: false, reason: 'conflict', currentRevision?: number, latestValue?: string }
|
|
8
|
+
* | { ok: false, reason: 'unauthorized' | 'error' }} SaveResult
|
|
9
|
+
*/
|
|
10
|
+
/** @typedef {'saving' | 'idle' | 'error'} SaveStatus */
|
|
11
|
+
/** @typedef {{ type: 'save_field', collection: string, id: string, field: string, value: string, expectedRevision?: number }} SaveFieldPayload */
|
|
12
|
+
|
|
13
|
+
/** @param {string} collection @param {string} id @returns {string} */
|
|
4
14
|
function entryKey(collection, id) {
|
|
5
15
|
return `${collection}::${id}`;
|
|
6
16
|
}
|
|
7
17
|
|
|
18
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
8
19
|
function isRecord(value) {
|
|
9
|
-
return typeof value === 'object' && value !== null;
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {unknown} value @returns {value is number} */
|
|
24
|
+
function isRevision(value) {
|
|
25
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
10
26
|
}
|
|
11
27
|
|
|
28
|
+
/** @param {unknown} data @param {string} path @returns {string | undefined} */
|
|
12
29
|
function getNestedStringValue(data, path) {
|
|
13
30
|
if (!isRecord(data)) return undefined;
|
|
14
31
|
|
|
32
|
+
/** @type {unknown} */
|
|
15
33
|
let current = data;
|
|
16
34
|
for (const segment of path.split('.')) {
|
|
17
|
-
if (
|
|
18
|
-
|
|
35
|
+
if (Array.isArray(current)) {
|
|
36
|
+
if (!/^\d+$/.test(segment)) return undefined;
|
|
37
|
+
current = current[Number(segment)];
|
|
38
|
+
} else if (isRecord(current)) {
|
|
39
|
+
if (!Object.hasOwn(current, segment)) return undefined;
|
|
40
|
+
current = current[segment];
|
|
41
|
+
} else return undefined;
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
return typeof current === 'string' ? current : undefined;
|
|
22
45
|
}
|
|
23
46
|
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} collection
|
|
49
|
+
* @param {string} id
|
|
50
|
+
* @param {() => void} onUnauthorized
|
|
51
|
+
* @returns {Promise<EntrySnapshot | null>}
|
|
52
|
+
*/
|
|
24
53
|
async function fetchEntrySnapshot(collection, id, onUnauthorized) {
|
|
25
54
|
const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
|
|
26
55
|
headers: readHeaders(),
|
|
@@ -32,27 +61,42 @@ async function fetchEntrySnapshot(collection, id, onUnauthorized) {
|
|
|
32
61
|
if (!res.ok) throw new Error('Failed to load entry');
|
|
33
62
|
|
|
34
63
|
const json = await res.json();
|
|
35
|
-
|
|
36
|
-
|
|
64
|
+
if (!isRecord(json) || !Array.isArray(json.entries)) {
|
|
65
|
+
throw new Error('Invalid entry response');
|
|
66
|
+
}
|
|
67
|
+
const entry = json.entries[0];
|
|
68
|
+
if (entry === undefined) {
|
|
37
69
|
return { revision: 0, data: {} };
|
|
38
70
|
}
|
|
71
|
+
if (!isRecord(entry) || !isRecord(entry.data) || !isRevision(entry.revision)) {
|
|
72
|
+
throw new Error('Invalid entry response');
|
|
73
|
+
}
|
|
39
74
|
|
|
40
75
|
return {
|
|
41
|
-
revision:
|
|
76
|
+
revision: entry.revision,
|
|
42
77
|
data: entry.data,
|
|
43
78
|
};
|
|
44
79
|
}
|
|
45
80
|
|
|
81
|
+
/**
|
|
82
|
+
* @param {object} options
|
|
83
|
+
* @param {(status: SaveStatus, message: string) => void} options.setStatus
|
|
84
|
+
* @param {() => void} options.onUnauthorized
|
|
85
|
+
* @returns {(collection: string, id: string, field: string, value: string) => Promise<SaveResult>}
|
|
86
|
+
*/
|
|
46
87
|
export function createSaveField({ setStatus, onUnauthorized }) {
|
|
88
|
+
/** @type {Promise<unknown>} */
|
|
47
89
|
let saveQueue = Promise.resolve();
|
|
90
|
+
/** @type {Map<string, number>} */
|
|
48
91
|
const revisionByEntry = new Map();
|
|
92
|
+
/** @type {Map<string, Promise<number | null>>} */
|
|
49
93
|
const revisionLoadByEntry = new Map();
|
|
50
94
|
|
|
95
|
+
/** @param {string} collection @param {string} id @returns {Promise<number | null>} */
|
|
51
96
|
async function ensureRevision(collection, id) {
|
|
52
97
|
const key = entryKey(collection, id);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
98
|
+
const cached = revisionByEntry.get(key);
|
|
99
|
+
if (cached !== undefined) return cached;
|
|
56
100
|
|
|
57
101
|
const inFlight = revisionLoadByEntry.get(key);
|
|
58
102
|
if (inFlight) return inFlight;
|
|
@@ -70,6 +114,10 @@ export function createSaveField({ setStatus, onUnauthorized }) {
|
|
|
70
114
|
return task;
|
|
71
115
|
}
|
|
72
116
|
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} collection @param {string} id @param {string} field
|
|
119
|
+
* @returns {Promise<{ currentRevision: number | undefined, latestValue: string | undefined }>}
|
|
120
|
+
*/
|
|
73
121
|
async function readLatestFieldValue(collection, id, field) {
|
|
74
122
|
const snapshot = await fetchEntrySnapshot(collection, id, onUnauthorized);
|
|
75
123
|
if (!snapshot) return { currentRevision: undefined, latestValue: undefined };
|
|
@@ -81,6 +129,10 @@ export function createSaveField({ setStatus, onUnauthorized }) {
|
|
|
81
129
|
};
|
|
82
130
|
}
|
|
83
131
|
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} collection @param {string} id @param {string} field @param {string} value
|
|
134
|
+
* @returns {Promise<SaveResult>}
|
|
135
|
+
*/
|
|
84
136
|
async function execSave(collection, id, field, value) {
|
|
85
137
|
setStatus('saving', 'Saving...');
|
|
86
138
|
const key = entryKey(collection, id);
|
|
@@ -91,6 +143,7 @@ export function createSaveField({ setStatus, onUnauthorized }) {
|
|
|
91
143
|
return { ok: false, reason: 'unauthorized' };
|
|
92
144
|
}
|
|
93
145
|
|
|
146
|
+
/** @type {SaveFieldPayload} */
|
|
94
147
|
const body = { type: 'save_field', collection, id, field, value };
|
|
95
148
|
if (typeof expectedRevision === 'number') {
|
|
96
149
|
body.expectedRevision = expectedRevision;
|
|
@@ -106,16 +159,18 @@ export function createSaveField({ setStatus, onUnauthorized }) {
|
|
|
106
159
|
return { ok: false, reason: 'unauthorized' };
|
|
107
160
|
}
|
|
108
161
|
|
|
109
|
-
const
|
|
162
|
+
const parsed = await res.json().catch(() => null);
|
|
163
|
+
const json = isRecord(parsed) ? parsed : {};
|
|
110
164
|
if (!res.ok) {
|
|
111
165
|
if (res.status === 409) {
|
|
112
|
-
const currentRevision =
|
|
166
|
+
const currentRevision = isRevision(json.currentRevision)
|
|
113
167
|
? json.currentRevision
|
|
114
168
|
: undefined;
|
|
115
169
|
if (typeof currentRevision === 'number') {
|
|
116
170
|
revisionByEntry.set(key, currentRevision);
|
|
117
171
|
}
|
|
118
172
|
|
|
173
|
+
/** @type {{ currentRevision: number | undefined, latestValue: string | undefined }} */
|
|
119
174
|
let latest = { currentRevision: undefined, latestValue: undefined };
|
|
120
175
|
try {
|
|
121
176
|
latest = await readLatestFieldValue(collection, id, field);
|
|
@@ -138,16 +193,18 @@ export function createSaveField({ setStatus, onUnauthorized }) {
|
|
|
138
193
|
return { ok: false, reason: 'error' };
|
|
139
194
|
}
|
|
140
195
|
|
|
141
|
-
const
|
|
196
|
+
const fallbackRevision = typeof expectedRevision === 'number' && expectedRevision < Number.MAX_SAFE_INTEGER
|
|
197
|
+
? expectedRevision + 1
|
|
198
|
+
: undefined;
|
|
199
|
+
const nextRevision = isRevision(json.revision)
|
|
142
200
|
? json.revision
|
|
143
|
-
:
|
|
144
|
-
? expectedRevision + 1
|
|
145
|
-
: undefined;
|
|
201
|
+
: fallbackRevision;
|
|
146
202
|
if (typeof nextRevision === 'number') {
|
|
147
203
|
revisionByEntry.set(key, nextRevision);
|
|
148
204
|
}
|
|
149
205
|
|
|
150
|
-
setStatus('idle', 'Saved');
|
|
206
|
+
setStatus('idle', isPolicyDraftMode() ? 'Draft saved' : 'Saved');
|
|
207
|
+
if (isPolicyDraftMode()) window.dispatchEvent(new CustomEvent('cms:draftSaved'));
|
|
151
208
|
return { ok: true, revision: nextRevision };
|
|
152
209
|
} catch {
|
|
153
210
|
setStatus('error', 'Save failed');
|
|
@@ -155,6 +212,7 @@ export function createSaveField({ setStatus, onUnauthorized }) {
|
|
|
155
212
|
}
|
|
156
213
|
}
|
|
157
214
|
|
|
215
|
+
/** @param {string} collection @param {string} id @param {string} field @param {string} value */
|
|
158
216
|
return function saveField(collection, id, field, value) {
|
|
159
217
|
const task = saveQueue.then(() => execSave(collection, id, field, value));
|
|
160
218
|
saveQueue = task.then(
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {{ sectionNode: HTMLElement, sectionId: string, setActiveSection: (sectionId: string) => void }} options
|
|
3
|
+
*/
|
|
1
4
|
export function bindSectionActivation({ sectionNode, sectionId, setActiveSection }) {
|
|
2
5
|
sectionNode.addEventListener('mouseenter', () => setActiveSection(sectionId));
|
|
3
6
|
sectionNode.addEventListener('focusin', () => setActiveSection(sectionId));
|
|
@@ -2,6 +2,17 @@ import { buildCmsUrl } from '../config.js';
|
|
|
2
2
|
import { isRecord } from './utils.js';
|
|
3
3
|
import { mutateHeaders, readHeaders } from '../security.js';
|
|
4
4
|
|
|
5
|
+
/** @typedef {import('./model.js').Section} Section */
|
|
6
|
+
|
|
7
|
+
/** @param {unknown} value @returns {value is number} */
|
|
8
|
+
function isRevision(value) {
|
|
9
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {{ collection: string, id: string, onUnauthorized: () => void }} options
|
|
14
|
+
* @returns {Promise<{ data: Record<string, unknown>, revision: number } | null>}
|
|
15
|
+
*/
|
|
5
16
|
export async function fetchPageEntry({ collection, id, onUnauthorized }) {
|
|
6
17
|
const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
|
|
7
18
|
headers: readHeaders(),
|
|
@@ -13,18 +24,23 @@ export async function fetchPageEntry({ collection, id, onUnauthorized }) {
|
|
|
13
24
|
if (!res.ok) throw new Error('Failed to load page data');
|
|
14
25
|
|
|
15
26
|
const json = await res.json();
|
|
16
|
-
const entry = Array.isArray(json.entries) ? json.entries[0] : null;
|
|
17
|
-
if (!entry || !isRecord(entry.data)) {
|
|
27
|
+
const entry = isRecord(json) && Array.isArray(json.entries) ? json.entries[0] : null;
|
|
28
|
+
if (!isRecord(entry) || !isRecord(entry.data)) {
|
|
18
29
|
throw new Error('Page entry not found');
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
return {
|
|
22
33
|
data: entry.data,
|
|
23
|
-
revision:
|
|
34
|
+
revision: isRevision(entry.revision) ? entry.revision : 0,
|
|
24
35
|
};
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
/**
|
|
39
|
+
* @param {{ collection: string, id: string, sections: Section[], expectedRevision: number, onUnauthorized: () => void }} options
|
|
40
|
+
* @returns {Promise<{ ok: true, revision: number } | { ok: false, reason: 'unauthorized' | 'conflict' | 'error', currentRevision?: number | null, error?: string }>}
|
|
41
|
+
*/
|
|
27
42
|
export async function savePageLayout({
|
|
43
|
+
collection,
|
|
28
44
|
id,
|
|
29
45
|
sections,
|
|
30
46
|
expectedRevision,
|
|
@@ -35,6 +51,7 @@ export async function savePageLayout({
|
|
|
35
51
|
headers: await mutateHeaders(),
|
|
36
52
|
body: JSON.stringify({
|
|
37
53
|
type: 'update_page_layout',
|
|
54
|
+
collection,
|
|
38
55
|
id,
|
|
39
56
|
sections,
|
|
40
57
|
expectedRevision,
|
|
@@ -46,13 +63,14 @@ export async function savePageLayout({
|
|
|
46
63
|
return { ok: false, reason: 'unauthorized' };
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
const
|
|
66
|
+
const rawJson = await res.json().catch(() => ({}));
|
|
67
|
+
const json = isRecord(rawJson) ? rawJson : {};
|
|
50
68
|
if (!res.ok) {
|
|
51
69
|
if (res.status === 409) {
|
|
52
70
|
return {
|
|
53
71
|
ok: false,
|
|
54
72
|
reason: 'conflict',
|
|
55
|
-
currentRevision:
|
|
73
|
+
currentRevision: isRevision(json.currentRevision) ? json.currentRevision : null,
|
|
56
74
|
};
|
|
57
75
|
}
|
|
58
76
|
const issue = Array.isArray(json.issues) ? json.issues[0] : null;
|
|
@@ -78,9 +96,9 @@ export async function savePageLayout({
|
|
|
78
96
|
return {
|
|
79
97
|
ok: true,
|
|
80
98
|
revision:
|
|
81
|
-
|
|
99
|
+
isRevision(json.revision)
|
|
82
100
|
? json.revision
|
|
83
|
-
:
|
|
101
|
+
: isRevision(expectedRevision)
|
|
84
102
|
? expectedRevision + 1
|
|
85
103
|
: 1,
|
|
86
104
|
};
|