@caretcms/core 0.1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/browser-runtime.d.ts +12 -0
- package/dist/browser-runtime.d.ts.map +1 -0
- package/dist/browser-runtime.js +298 -0
- package/dist/browser-runtime.js.map +1 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +343 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +61 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +80 -0
- package/dist/loader.js.map +1 -0
- package/dist/providers/storage/filesystem.d.ts +7 -0
- package/dist/providers/storage/filesystem.d.ts.map +1 -0
- package/dist/providers/storage/filesystem.js +6 -0
- package/dist/providers/storage/filesystem.js.map +1 -0
- package/dist/providers/uploads/local.d.ts +6 -0
- package/dist/providers/uploads/local.d.ts.map +1 -0
- package/dist/providers/uploads/local.js +6 -0
- package/dist/providers/uploads/local.js.map +1 -0
- package/dist/runtime/auth/cookie-utils.d.ts +3 -0
- package/dist/runtime/auth/cookie-utils.d.ts.map +1 -0
- package/dist/runtime/auth/cookie-utils.js +33 -0
- package/dist/runtime/auth/cookie-utils.js.map +1 -0
- package/dist/runtime/auth/demo-session.d.ts +24 -0
- package/dist/runtime/auth/demo-session.d.ts.map +1 -0
- package/dist/runtime/auth/demo-session.js +42 -0
- package/dist/runtime/auth/demo-session.js.map +1 -0
- package/dist/runtime/auth/rate-limiter.d.ts +26 -0
- package/dist/runtime/auth/rate-limiter.d.ts.map +1 -0
- package/dist/runtime/auth/rate-limiter.js +65 -0
- package/dist/runtime/auth/rate-limiter.js.map +1 -0
- package/dist/runtime/auth/session.d.ts +14 -0
- package/dist/runtime/auth/session.d.ts.map +1 -0
- package/dist/runtime/auth/session.js +128 -0
- package/dist/runtime/auth/session.js.map +1 -0
- package/dist/runtime/bind.d.ts +14 -0
- package/dist/runtime/bind.d.ts.map +1 -0
- package/dist/runtime/bind.js +19 -0
- package/dist/runtime/bind.js.map +1 -0
- package/dist/runtime/config.d.ts +20 -0
- package/dist/runtime/config.d.ts.map +1 -0
- package/dist/runtime/config.js +40 -0
- package/dist/runtime/config.js.map +1 -0
- package/dist/runtime/content.d.ts +28 -0
- package/dist/runtime/content.d.ts.map +1 -0
- package/dist/runtime/content.js +37 -0
- package/dist/runtime/content.js.map +1 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +16 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/middleware.d.ts +14 -0
- package/dist/runtime/middleware.d.ts.map +1 -0
- package/dist/runtime/middleware.js +80 -0
- package/dist/runtime/middleware.js.map +1 -0
- package/dist/runtime/mutations/contracts.d.ts +91 -0
- package/dist/runtime/mutations/contracts.d.ts.map +1 -0
- package/dist/runtime/mutations/contracts.js +320 -0
- package/dist/runtime/mutations/contracts.js.map +1 -0
- package/dist/runtime/mutations/engine.d.ts +23 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -0
- package/dist/runtime/mutations/engine.js +255 -0
- package/dist/runtime/mutations/engine.js.map +1 -0
- package/dist/runtime/providers.d.ts +9 -0
- package/dist/runtime/providers.d.ts.map +1 -0
- package/dist/runtime/providers.js +44 -0
- package/dist/runtime/providers.js.map +1 -0
- package/dist/runtime/request-context.d.ts +11 -0
- package/dist/runtime/request-context.d.ts.map +1 -0
- package/dist/runtime/request-context.js +16 -0
- package/dist/runtime/request-context.js.map +1 -0
- package/dist/runtime/rewrite.d.ts +13 -0
- package/dist/runtime/rewrite.d.ts.map +1 -0
- package/dist/runtime/rewrite.js +253 -0
- package/dist/runtime/rewrite.js.map +1 -0
- package/dist/runtime/routes/_helpers.d.ts +17 -0
- package/dist/runtime/routes/_helpers.d.ts.map +1 -0
- package/dist/runtime/routes/_helpers.js +38 -0
- package/dist/runtime/routes/_helpers.js.map +1 -0
- package/dist/runtime/routes/admin-entry.d.ts +3 -0
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -0
- package/dist/runtime/routes/admin-entry.js +170 -0
- package/dist/runtime/routes/admin-entry.js.map +1 -0
- package/dist/runtime/routes/admin.d.ts +3 -0
- package/dist/runtime/routes/admin.d.ts.map +1 -0
- package/dist/runtime/routes/admin.js +189 -0
- package/dist/runtime/routes/admin.js.map +1 -0
- package/dist/runtime/routes/auth-login.d.ts +4 -0
- package/dist/runtime/routes/auth-login.d.ts.map +1 -0
- package/dist/runtime/routes/auth-login.js +71 -0
- package/dist/runtime/routes/auth-login.js.map +1 -0
- package/dist/runtime/routes/auth-logout.d.ts +4 -0
- package/dist/runtime/routes/auth-logout.d.ts.map +1 -0
- package/dist/runtime/routes/auth-logout.js +47 -0
- package/dist/runtime/routes/auth-logout.js.map +1 -0
- package/dist/runtime/routes/auth-session.d.ts +3 -0
- package/dist/runtime/routes/auth-session.d.ts.map +1 -0
- package/dist/runtime/routes/auth-session.js +8 -0
- package/dist/runtime/routes/auth-session.js.map +1 -0
- package/dist/runtime/routes/collection-list.d.ts +3 -0
- package/dist/runtime/routes/collection-list.d.ts.map +1 -0
- package/dist/runtime/routes/collection-list.js +420 -0
- package/dist/runtime/routes/collection-list.js.map +1 -0
- package/dist/runtime/routes/collections-metadata.d.ts +3 -0
- package/dist/runtime/routes/collections-metadata.d.ts.map +1 -0
- package/dist/runtime/routes/collections-metadata.js +17 -0
- package/dist/runtime/routes/collections-metadata.js.map +1 -0
- package/dist/runtime/routes/editor-assets.d.ts +3 -0
- package/dist/runtime/routes/editor-assets.d.ts.map +1 -0
- package/dist/runtime/routes/editor-assets.js +73 -0
- package/dist/runtime/routes/editor-assets.js.map +1 -0
- package/dist/runtime/routes/entries.d.ts +3 -0
- package/dist/runtime/routes/entries.d.ts.map +1 -0
- package/dist/runtime/routes/entries.js +96 -0
- package/dist/runtime/routes/entries.js.map +1 -0
- package/dist/runtime/routes/history.d.ts +4 -0
- package/dist/runtime/routes/history.d.ts.map +1 -0
- package/dist/runtime/routes/history.js +54 -0
- package/dist/runtime/routes/history.js.map +1 -0
- package/dist/runtime/routes/mutate.d.ts +4 -0
- package/dist/runtime/routes/mutate.d.ts.map +1 -0
- package/dist/runtime/routes/mutate.js +38 -0
- package/dist/runtime/routes/mutate.js.map +1 -0
- package/dist/runtime/routes/schema.d.ts +3 -0
- package/dist/runtime/routes/schema.d.ts.map +1 -0
- package/dist/runtime/routes/schema.js +58 -0
- package/dist/runtime/routes/schema.js.map +1 -0
- package/dist/runtime/routes/studio-home.d.ts +3 -0
- package/dist/runtime/routes/studio-home.d.ts.map +1 -0
- package/dist/runtime/routes/studio-home.js +99 -0
- package/dist/runtime/routes/studio-home.js.map +1 -0
- package/dist/runtime/routes/theme-stylesheet.d.ts +3 -0
- package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -0
- package/dist/runtime/routes/theme-stylesheet.js +32 -0
- package/dist/runtime/routes/theme-stylesheet.js.map +1 -0
- package/dist/runtime/routes/upload.d.ts +3 -0
- package/dist/runtime/routes/upload.d.ts.map +1 -0
- package/dist/runtime/routes/upload.js +33 -0
- package/dist/runtime/routes/upload.js.map +1 -0
- package/dist/runtime/sanitize-html.d.ts +8 -0
- package/dist/runtime/sanitize-html.d.ts.map +1 -0
- package/dist/runtime/sanitize-html.js +86 -0
- package/dist/runtime/sanitize-html.js.map +1 -0
- package/dist/runtime/schema/template.d.ts +15 -0
- package/dist/runtime/schema/template.d.ts.map +1 -0
- package/dist/runtime/schema/template.js +38 -0
- package/dist/runtime/schema/template.js.map +1 -0
- package/dist/runtime/schema-registry.d.ts +17 -0
- package/dist/runtime/schema-registry.d.ts.map +1 -0
- package/dist/runtime/schema-registry.js +16 -0
- package/dist/runtime/schema-registry.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +36 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.js +291 -0
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -0
- package/dist/runtime/storage/filesystem-upload-handler.d.ts +11 -0
- package/dist/runtime/storage/filesystem-upload-handler.d.ts.map +1 -0
- package/dist/runtime/storage/filesystem-upload-handler.js +20 -0
- package/dist/runtime/storage/filesystem-upload-handler.js.map +1 -0
- package/dist/runtime/storage/image-validation.d.ts +28 -0
- package/dist/runtime/storage/image-validation.d.ts.map +1 -0
- package/dist/runtime/storage/image-validation.js +94 -0
- package/dist/runtime/storage/image-validation.js.map +1 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts +29 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -0
- package/dist/runtime/storage/in-memory-adapter.js +110 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -0
- package/dist/runtime/storage/quota-upload-handler.d.ts +27 -0
- package/dist/runtime/storage/quota-upload-handler.d.ts.map +1 -0
- package/dist/runtime/storage/quota-upload-handler.js +34 -0
- package/dist/runtime/storage/quota-upload-handler.js.map +1 -0
- package/dist/runtime/storage/session-overlay-adapter.d.ts +33 -0
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -0
- package/dist/runtime/storage/session-overlay-adapter.js +121 -0
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -0
- package/dist/runtime/themes/preset-tokens.d.ts +17 -0
- package/dist/runtime/themes/preset-tokens.d.ts.map +1 -0
- package/dist/runtime/themes/preset-tokens.js +47 -0
- package/dist/runtime/themes/preset-tokens.js.map +1 -0
- package/dist/runtime/utils.d.ts +6 -0
- package/dist/runtime/utils.d.ts.map +1 -0
- package/dist/runtime/utils.js +29 -0
- package/dist/runtime/utils.js.map +1 -0
- package/dist/runtime/views/studio-layout.d.ts +25 -0
- package/dist/runtime/views/studio-layout.d.ts.map +1 -0
- package/dist/runtime/views/studio-layout.js +108 -0
- package/dist/runtime/views/studio-layout.js.map +1 -0
- package/dist/schema-utils.d.ts +16 -0
- package/dist/schema-utils.d.ts.map +1 -0
- package/dist/schema-utils.js +62 -0
- package/dist/schema-utils.js.map +1 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/editor-runtime.d.ts +6 -0
- package/editor-runtime.js +2 -0
- package/package.json +88 -0
- package/static/cms/admin-entry.css +207 -0
- package/static/cms/admin-entry.js +1083 -0
- package/static/cms/editor/config.js +118 -0
- package/static/cms/editor/content-map.css +141 -0
- package/static/cms/editor/content-map.js +172 -0
- package/static/cms/editor/guards.js +72 -0
- package/static/cms/editor/helpers.js +51 -0
- package/static/cms/editor/image-edit.js +151 -0
- package/static/cms/editor/image-utils.js +84 -0
- package/static/cms/editor/image.css +133 -0
- package/static/cms/editor/link-follow.js +144 -0
- package/static/cms/editor/link-ui.css +199 -0
- package/static/cms/editor/linkify.js +55 -0
- package/static/cms/editor/panel.css +91 -0
- package/static/cms/editor/panel.js +70 -0
- package/static/cms/editor/rich-toolbar.css +67 -0
- package/static/cms/editor/rich-toolbar.js +200 -0
- package/static/cms/editor/sanitize.js +89 -0
- package/static/cms/editor/save-queue.js +166 -0
- package/static/cms/editor/section-controls/activation.js +10 -0
- package/static/cms/editor/section-controls/api.js +87 -0
- package/static/cms/editor/section-controls/constants.js +24 -0
- package/static/cms/editor/section-controls/index.js +622 -0
- package/static/cms/editor/section-controls/model.js +76 -0
- package/static/cms/editor/section-controls/mutations.js +112 -0
- package/static/cms/editor/section-controls/page-context.js +34 -0
- package/static/cms/editor/section-controls/pickers.js +196 -0
- package/static/cms/editor/section-controls/reorder.js +92 -0
- package/static/cms/editor/section-controls/selection.js +54 -0
- package/static/cms/editor/section-controls/spacing-drag.js +83 -0
- package/static/cms/editor/section-controls/ui-elements.js +54 -0
- package/static/cms/editor/section-controls/utils.js +35 -0
- package/static/cms/editor/section-controls.css +349 -0
- package/static/cms/editor/section-controls.js +1 -0
- package/static/cms/editor/security.js +48 -0
- package/static/cms/editor/sync.js +72 -0
- package/static/cms/editor/text-edit.js +151 -0
- package/static/cms/editor/text-link-interactions.js +254 -0
- package/static/cms/editor/toast.css +50 -0
- package/static/cms/editor/toast.js +29 -0
- package/static/cms/editor/tokens-and-text.css +93 -0
- package/static/cms/editor/toolbar.css +249 -0
- package/static/cms/editor/toolbar.js +117 -0
- package/static/cms/editor.css +12 -0
- package/static/cms/editor.js +116 -0
- package/static/cms/studio-login.css +300 -0
- package/static/cms/studio-tokens.css +85 -0
- package/static/cms/studio.css +437 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { STUDIO_PATH } from './config.js';
|
|
2
|
+
|
|
3
|
+
export function mountStudioPanel({ studioButton }) {
|
|
4
|
+
if (!STUDIO_PATH) {
|
|
5
|
+
studioButton?.setAttribute('hidden', 'hidden');
|
|
6
|
+
studioButton?.setAttribute('aria-hidden', 'true');
|
|
7
|
+
return { iframe: null, closePanel() {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const panel = document.createElement('div');
|
|
11
|
+
panel.className = 'cms-studio-panel';
|
|
12
|
+
|
|
13
|
+
const iframe = document.createElement('iframe');
|
|
14
|
+
iframe.src = STUDIO_PATH;
|
|
15
|
+
iframe.title = 'Content Studio';
|
|
16
|
+
panel.appendChild(iframe);
|
|
17
|
+
document.body.appendChild(panel);
|
|
18
|
+
|
|
19
|
+
function openPanel(animate = true) {
|
|
20
|
+
if (!animate) {
|
|
21
|
+
panel.classList.add('no-transition');
|
|
22
|
+
document.body.classList.add('no-transition');
|
|
23
|
+
}
|
|
24
|
+
panel.classList.add('open');
|
|
25
|
+
document.body.classList.add('cms-panel-open');
|
|
26
|
+
studioButton?.classList.add('active');
|
|
27
|
+
sessionStorage.setItem('cms-panel-open', '1');
|
|
28
|
+
if (!animate) {
|
|
29
|
+
requestAnimationFrame(() => {
|
|
30
|
+
requestAnimationFrame(() => {
|
|
31
|
+
panel.classList.remove('no-transition');
|
|
32
|
+
document.body.classList.remove('no-transition');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function closePanel() {
|
|
39
|
+
panel.classList.remove('open');
|
|
40
|
+
document.body.classList.remove('cms-panel-open');
|
|
41
|
+
studioButton?.classList.remove('active');
|
|
42
|
+
sessionStorage.setItem('cms-panel-open', '0');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function togglePanel() {
|
|
46
|
+
if (panel.classList.contains('open')) {
|
|
47
|
+
closePanel();
|
|
48
|
+
} else {
|
|
49
|
+
openPanel();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
studioButton?.addEventListener('click', togglePanel);
|
|
54
|
+
|
|
55
|
+
// Restore panel state from sessionStorage (without animation)
|
|
56
|
+
if (sessionStorage.getItem('cms-panel-open') === '1') {
|
|
57
|
+
openPanel(false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Escape key closes panel (only when no CMS element is focused)
|
|
61
|
+
document.addEventListener('keydown', (e) => {
|
|
62
|
+
if (e.key !== 'Escape' || !panel.classList.contains('open')) return;
|
|
63
|
+
const focused = document.activeElement;
|
|
64
|
+
if (!focused || !focused.matches('[data-caret]')) {
|
|
65
|
+
closePanel();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return { iframe, closePanel };
|
|
70
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* ---------------------------------------------------------------------------
|
|
2
|
+
Rich text toolbar — floating format bar for [data-caret-rich] elements
|
|
3
|
+
--------------------------------------------------------------------------- */
|
|
4
|
+
|
|
5
|
+
.cms-rich-toolbar {
|
|
6
|
+
position: fixed;
|
|
7
|
+
z-index: 100002;
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
gap: 2px;
|
|
11
|
+
background: var(--cms-surface);
|
|
12
|
+
border: 1px solid rgba(59, 130, 246, 0.15);
|
|
13
|
+
border-radius: 4px;
|
|
14
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
15
|
+
padding: 4px;
|
|
16
|
+
font-family: var(--cms-font);
|
|
17
|
+
animation: cms-popover-in 0.15s ease-out;
|
|
18
|
+
pointer-events: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.cms-rich-toolbar-btn {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
width: 28px;
|
|
26
|
+
height: 28px;
|
|
27
|
+
border: 1px solid transparent;
|
|
28
|
+
border-radius: 3px;
|
|
29
|
+
background: transparent;
|
|
30
|
+
color: rgba(255, 255, 255, 0.6);
|
|
31
|
+
font-family: var(--cms-font);
|
|
32
|
+
font-size: 13px;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
transition: all 0.15s;
|
|
36
|
+
padding: 0;
|
|
37
|
+
line-height: 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.cms-rich-toolbar-btn:hover {
|
|
41
|
+
color: white;
|
|
42
|
+
background: rgba(255, 255, 255, 0.08);
|
|
43
|
+
border-color: rgba(255, 255, 255, 0.1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.cms-rich-toolbar-btn.active {
|
|
47
|
+
color: var(--cms-accent);
|
|
48
|
+
background: var(--cms-accent-soft);
|
|
49
|
+
border-color: rgba(59, 130, 246, 0.2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.cms-rich-toolbar-sep {
|
|
53
|
+
width: 1px;
|
|
54
|
+
height: 18px;
|
|
55
|
+
background: rgba(255, 255, 255, 0.08);
|
|
56
|
+
margin: 0 2px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Rich text editable visual indicator */
|
|
60
|
+
[data-caret-rich].cms-editable:hover {
|
|
61
|
+
outline-color: rgba(59, 130, 246, 0.3);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[data-caret-rich].cms-editable:focus {
|
|
65
|
+
outline-color: var(--cms-accent);
|
|
66
|
+
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
|
67
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating rich text format toolbar for [data-caret-rich] elements.
|
|
3
|
+
*
|
|
4
|
+
* Singleton toolbar appears on text selection within rich-text fields.
|
|
5
|
+
* Uses document.execCommand() for formatting (still the most reliable
|
|
6
|
+
* cross-browser approach for contenteditable formatting).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { sanitizeHtml } from './sanitize.js';
|
|
10
|
+
|
|
11
|
+
let toolbar = null;
|
|
12
|
+
let currentRichEl = null;
|
|
13
|
+
let showLinkPopupFn = null;
|
|
14
|
+
|
|
15
|
+
const BUTTONS = [
|
|
16
|
+
{ cmd: 'bold', label: 'B', title: 'Bold (Ctrl+B)', style: 'font-weight:700' },
|
|
17
|
+
{ cmd: 'italic', label: 'I', title: 'Italic (Ctrl+I)', style: 'font-style:italic' },
|
|
18
|
+
{ sep: true },
|
|
19
|
+
{ cmd: 'createLink', label: '\u{1F517}', title: 'Link (Ctrl+K)', isLink: true },
|
|
20
|
+
{ cmd: 'removeFormat', label: 'T\u2093', title: 'Clear formatting' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function createToolbar() {
|
|
24
|
+
const el = document.createElement('div');
|
|
25
|
+
el.className = 'cms-rich-toolbar';
|
|
26
|
+
el.style.display = 'none';
|
|
27
|
+
|
|
28
|
+
for (const btn of BUTTONS) {
|
|
29
|
+
if (btn.sep) {
|
|
30
|
+
const sep = document.createElement('div');
|
|
31
|
+
sep.className = 'cms-rich-toolbar-sep';
|
|
32
|
+
el.appendChild(sep);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const button = document.createElement('button');
|
|
37
|
+
button.type = 'button';
|
|
38
|
+
button.className = 'cms-rich-toolbar-btn';
|
|
39
|
+
button.title = btn.title;
|
|
40
|
+
button.dataset.cmd = btn.cmd;
|
|
41
|
+
if (btn.style) button.style.cssText = btn.style;
|
|
42
|
+
|
|
43
|
+
// Use text for simple labels, innerHTML for the link icon
|
|
44
|
+
if (btn.isLink) {
|
|
45
|
+
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
|
+
} else {
|
|
47
|
+
button.textContent = btn.label;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
button.addEventListener('mousedown', (e) => {
|
|
51
|
+
e.preventDefault(); // Prevent blur on the rich element
|
|
52
|
+
if (btn.isLink) {
|
|
53
|
+
handleLinkButton();
|
|
54
|
+
} else {
|
|
55
|
+
document.execCommand(btn.cmd, false, null);
|
|
56
|
+
updateActiveStates();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
el.appendChild(button);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Prevent toolbar clicks from stealing focus
|
|
64
|
+
el.addEventListener('mousedown', (e) => e.preventDefault());
|
|
65
|
+
|
|
66
|
+
document.body.appendChild(el);
|
|
67
|
+
return el;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleLinkButton() {
|
|
71
|
+
const sel = window.getSelection();
|
|
72
|
+
if (!sel || sel.isCollapsed || !currentRichEl) return;
|
|
73
|
+
|
|
74
|
+
const range = sel.getRangeAt(0).cloneRange();
|
|
75
|
+
const selRect = range.getBoundingClientRect();
|
|
76
|
+
|
|
77
|
+
// Check if selection is already a link
|
|
78
|
+
const anchorNode = sel.anchorNode;
|
|
79
|
+
const existingLink = anchorNode?.nodeType === Node.TEXT_NODE
|
|
80
|
+
? anchorNode.parentElement?.closest('a')
|
|
81
|
+
: anchorNode instanceof Element ? anchorNode.closest('a') : null;
|
|
82
|
+
const initialUrl = existingLink?.getAttribute('href') || '';
|
|
83
|
+
|
|
84
|
+
if (showLinkPopupFn) {
|
|
85
|
+
showLinkPopupFn(selRect, initialUrl, (url) => {
|
|
86
|
+
currentRichEl?.focus();
|
|
87
|
+
|
|
88
|
+
if (url) {
|
|
89
|
+
const newSel = window.getSelection();
|
|
90
|
+
newSel.removeAllRanges();
|
|
91
|
+
newSel.addRange(range);
|
|
92
|
+
|
|
93
|
+
if (existingLink) {
|
|
94
|
+
// Update existing link
|
|
95
|
+
existingLink.setAttribute('href', url);
|
|
96
|
+
} else {
|
|
97
|
+
document.execCommand('createLink', false, url);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Set security attrs on external links
|
|
101
|
+
const freshSel = window.getSelection();
|
|
102
|
+
if (freshSel && freshSel.anchorNode) {
|
|
103
|
+
const textNode = freshSel.anchorNode;
|
|
104
|
+
const newLink = textNode.nodeType === Node.TEXT_NODE
|
|
105
|
+
? textNode.parentElement?.closest('a')
|
|
106
|
+
: textNode instanceof Element ? textNode.closest('a') : null;
|
|
107
|
+
if (newLink && /^https?:/i.test(url)) {
|
|
108
|
+
newLink.setAttribute('target', '_blank');
|
|
109
|
+
newLink.setAttribute('rel', 'noopener noreferrer');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} else if (url === '' && existingLink) {
|
|
113
|
+
// Empty URL = unlink
|
|
114
|
+
document.execCommand('unlink', false, null);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
hide();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function updateActiveStates() {
|
|
123
|
+
if (!toolbar) return;
|
|
124
|
+
const buttons = toolbar.querySelectorAll('.cms-rich-toolbar-btn[data-cmd]');
|
|
125
|
+
for (const btn of buttons) {
|
|
126
|
+
const cmd = btn.dataset.cmd;
|
|
127
|
+
if (cmd === 'createLink' || cmd === 'removeFormat') continue;
|
|
128
|
+
try {
|
|
129
|
+
btn.classList.toggle('active', document.queryCommandState(cmd));
|
|
130
|
+
} catch {
|
|
131
|
+
// queryCommandState can throw for unsupported commands
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function position(sel) {
|
|
137
|
+
if (!toolbar || !sel || sel.isCollapsed) return;
|
|
138
|
+
|
|
139
|
+
const range = sel.getRangeAt(0);
|
|
140
|
+
const rect = range.getBoundingClientRect();
|
|
141
|
+
if (!rect.width && !rect.height) return;
|
|
142
|
+
|
|
143
|
+
const tbRect = toolbar.getBoundingClientRect();
|
|
144
|
+
let left = rect.left + rect.width / 2 - tbRect.width / 2;
|
|
145
|
+
left = Math.max(8, Math.min(left, window.innerWidth - tbRect.width - 8));
|
|
146
|
+
|
|
147
|
+
// Prefer above selection, flip below if near top
|
|
148
|
+
let top = rect.top - tbRect.height - 8;
|
|
149
|
+
if (top < 8) {
|
|
150
|
+
top = rect.bottom + 8;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
toolbar.style.left = `${left}px`;
|
|
154
|
+
toolbar.style.top = `${top}px`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function show(sel) {
|
|
158
|
+
if (!toolbar) toolbar = createToolbar();
|
|
159
|
+
toolbar.style.display = 'flex';
|
|
160
|
+
updateActiveStates();
|
|
161
|
+
position(sel);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hide() {
|
|
165
|
+
if (toolbar) {
|
|
166
|
+
toolbar.style.display = 'none';
|
|
167
|
+
}
|
|
168
|
+
currentRichEl = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function onSelectionChange() {
|
|
172
|
+
const sel = window.getSelection();
|
|
173
|
+
if (!sel || sel.isCollapsed) {
|
|
174
|
+
hide();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check if selection is inside a rich element
|
|
179
|
+
const anchor = sel.anchorNode;
|
|
180
|
+
const richEl = anchor instanceof Element
|
|
181
|
+
? anchor.closest('[data-caret-rich]')
|
|
182
|
+
: anchor?.parentElement?.closest('[data-caret-rich]');
|
|
183
|
+
|
|
184
|
+
if (!richEl) {
|
|
185
|
+
hide();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
currentRichEl = richEl;
|
|
190
|
+
show(sel);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Mount the rich text toolbar. Call once during editor boot.
|
|
195
|
+
* @param {{ state: object, showToast: Function, showLinkPopup?: Function }} opts
|
|
196
|
+
*/
|
|
197
|
+
export function mountRichToolbar({ state, showToast, showLinkPopup }) {
|
|
198
|
+
showLinkPopupFn = showLinkPopup || null;
|
|
199
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
200
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side HTML sanitizer for rich text fields.
|
|
3
|
+
*
|
|
4
|
+
* Uses DOMParser + TreeWalker to sanitize HTML against an allowlist.
|
|
5
|
+
* Only inline formatting elements are permitted — no block-level tags.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ALLOWED_TAGS = new Set([
|
|
9
|
+
'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const ALLOWED_ATTRS = {
|
|
13
|
+
a: new Set(['href', 'target', 'rel']),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/)/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize an HTML string, keeping only allowed inline tags and attributes.
|
|
20
|
+
* @param {string} html
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function sanitizeHtml(html) {
|
|
24
|
+
if (!html) return '';
|
|
25
|
+
|
|
26
|
+
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
|
27
|
+
sanitizeNode(doc.body);
|
|
28
|
+
return doc.body.innerHTML;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Recursively sanitize a DOM node in place.
|
|
33
|
+
* @param {Node} parent
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeNode(parent) {
|
|
36
|
+
const children = Array.from(parent.childNodes);
|
|
37
|
+
|
|
38
|
+
for (const node of children) {
|
|
39
|
+
if (node.nodeType === Node.TEXT_NODE) continue;
|
|
40
|
+
|
|
41
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
42
|
+
parent.removeChild(node);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
47
|
+
parent.removeChild(node);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const el = /** @type {Element} */ (node);
|
|
52
|
+
const tag = el.tagName.toLowerCase();
|
|
53
|
+
|
|
54
|
+
if (!ALLOWED_TAGS.has(tag)) {
|
|
55
|
+
// Unwrap: keep children, remove the disallowed element
|
|
56
|
+
const frag = document.createDocumentFragment();
|
|
57
|
+
while (el.firstChild) frag.appendChild(el.firstChild);
|
|
58
|
+
parent.replaceChild(frag, el);
|
|
59
|
+
// Re-sanitize the newly promoted children
|
|
60
|
+
sanitizeNode(parent);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Strip disallowed attributes
|
|
65
|
+
const allowed = ALLOWED_ATTRS[tag];
|
|
66
|
+
const attrs = Array.from(el.attributes);
|
|
67
|
+
for (const attr of attrs) {
|
|
68
|
+
if (!allowed || !allowed.has(attr.name)) {
|
|
69
|
+
el.removeAttribute(attr.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Validate href on <a>
|
|
74
|
+
if (tag === 'a') {
|
|
75
|
+
const href = el.getAttribute('href') || '';
|
|
76
|
+
if (!SAFE_HREF_RE.test(href)) {
|
|
77
|
+
el.removeAttribute('href');
|
|
78
|
+
}
|
|
79
|
+
// Force security attrs on external links
|
|
80
|
+
if (href && /^https?:/i.test(href)) {
|
|
81
|
+
el.setAttribute('target', '_blank');
|
|
82
|
+
el.setAttribute('rel', 'noopener noreferrer');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Recurse into children
|
|
87
|
+
sanitizeNode(el);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { buildCmsUrl } from './config.js';
|
|
2
|
+
import { mutateHeaders, readHeaders } from './security.js';
|
|
3
|
+
|
|
4
|
+
function entryKey(collection, id) {
|
|
5
|
+
return `${collection}::${id}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === 'object' && value !== null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getNestedStringValue(data, path) {
|
|
13
|
+
if (!isRecord(data)) return undefined;
|
|
14
|
+
|
|
15
|
+
let current = data;
|
|
16
|
+
for (const segment of path.split('.')) {
|
|
17
|
+
if (!isRecord(current) && !Array.isArray(current)) return undefined;
|
|
18
|
+
current = current[segment];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return typeof current === 'string' ? current : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchEntrySnapshot(collection, id, onUnauthorized) {
|
|
25
|
+
const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
|
|
26
|
+
headers: readHeaders(),
|
|
27
|
+
});
|
|
28
|
+
if (res.status === 401) {
|
|
29
|
+
onUnauthorized();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) throw new Error('Failed to load entry');
|
|
33
|
+
|
|
34
|
+
const json = await res.json();
|
|
35
|
+
const entry = Array.isArray(json.entries) ? json.entries[0] : null;
|
|
36
|
+
if (!entry || !isRecord(entry.data)) {
|
|
37
|
+
return { revision: 0, data: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
revision: Number.isInteger(entry.revision) ? entry.revision : 0,
|
|
42
|
+
data: entry.data,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createSaveField({ setStatus, onUnauthorized }) {
|
|
47
|
+
let saveQueue = Promise.resolve();
|
|
48
|
+
const revisionByEntry = new Map();
|
|
49
|
+
const revisionLoadByEntry = new Map();
|
|
50
|
+
|
|
51
|
+
async function ensureRevision(collection, id) {
|
|
52
|
+
const key = entryKey(collection, id);
|
|
53
|
+
if (revisionByEntry.has(key)) {
|
|
54
|
+
return revisionByEntry.get(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const inFlight = revisionLoadByEntry.get(key);
|
|
58
|
+
if (inFlight) return inFlight;
|
|
59
|
+
|
|
60
|
+
const task = (async () => {
|
|
61
|
+
const snapshot = await fetchEntrySnapshot(collection, id, onUnauthorized);
|
|
62
|
+
if (!snapshot) return null;
|
|
63
|
+
revisionByEntry.set(key, snapshot.revision);
|
|
64
|
+
return snapshot.revision;
|
|
65
|
+
})().finally(() => {
|
|
66
|
+
revisionLoadByEntry.delete(key);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
revisionLoadByEntry.set(key, task);
|
|
70
|
+
return task;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readLatestFieldValue(collection, id, field) {
|
|
74
|
+
const snapshot = await fetchEntrySnapshot(collection, id, onUnauthorized);
|
|
75
|
+
if (!snapshot) return { currentRevision: undefined, latestValue: undefined };
|
|
76
|
+
|
|
77
|
+
revisionByEntry.set(entryKey(collection, id), snapshot.revision);
|
|
78
|
+
return {
|
|
79
|
+
currentRevision: snapshot.revision,
|
|
80
|
+
latestValue: getNestedStringValue(snapshot.data, field),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function execSave(collection, id, field, value) {
|
|
85
|
+
setStatus('saving', 'Saving...');
|
|
86
|
+
const key = entryKey(collection, id);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const expectedRevision = await ensureRevision(collection, id);
|
|
90
|
+
if (expectedRevision === null) {
|
|
91
|
+
return { ok: false, reason: 'unauthorized' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const body = { type: 'save_field', collection, id, field, value };
|
|
95
|
+
if (typeof expectedRevision === 'number') {
|
|
96
|
+
body.expectedRevision = expectedRevision;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const res = await fetch(buildCmsUrl('/mutate'), {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: await mutateHeaders(),
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
});
|
|
104
|
+
if (res.status === 401) {
|
|
105
|
+
onUnauthorized();
|
|
106
|
+
return { ok: false, reason: 'unauthorized' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const json = await res.json().catch(() => ({}));
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
if (res.status === 409) {
|
|
112
|
+
const currentRevision = Number.isInteger(json.currentRevision)
|
|
113
|
+
? json.currentRevision
|
|
114
|
+
: undefined;
|
|
115
|
+
if (typeof currentRevision === 'number') {
|
|
116
|
+
revisionByEntry.set(key, currentRevision);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let latest = { currentRevision: undefined, latestValue: undefined };
|
|
120
|
+
try {
|
|
121
|
+
latest = await readLatestFieldValue(collection, id, field);
|
|
122
|
+
} catch {
|
|
123
|
+
// Keep conflict semantics even if a follow-up refresh fails.
|
|
124
|
+
}
|
|
125
|
+
setStatus('error', 'Revision conflict');
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: 'conflict',
|
|
129
|
+
currentRevision:
|
|
130
|
+
typeof latest.currentRevision === 'number'
|
|
131
|
+
? latest.currentRevision
|
|
132
|
+
: currentRevision,
|
|
133
|
+
latestValue: latest.latestValue,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setStatus('error', 'Save failed');
|
|
138
|
+
return { ok: false, reason: 'error' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const nextRevision = Number.isInteger(json.revision)
|
|
142
|
+
? json.revision
|
|
143
|
+
: typeof expectedRevision === 'number'
|
|
144
|
+
? expectedRevision + 1
|
|
145
|
+
: undefined;
|
|
146
|
+
if (typeof nextRevision === 'number') {
|
|
147
|
+
revisionByEntry.set(key, nextRevision);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setStatus('idle', 'Saved');
|
|
151
|
+
return { ok: true, revision: nextRevision };
|
|
152
|
+
} catch {
|
|
153
|
+
setStatus('error', 'Save failed');
|
|
154
|
+
return { ok: false, reason: 'error' };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return function saveField(collection, id, field, value) {
|
|
159
|
+
const task = saveQueue.then(() => execSave(collection, id, field, value));
|
|
160
|
+
saveQueue = task.then(
|
|
161
|
+
() => undefined,
|
|
162
|
+
() => undefined,
|
|
163
|
+
);
|
|
164
|
+
return task;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function bindSectionActivation({ sectionNode, sectionId, setActiveSection }) {
|
|
2
|
+
sectionNode.addEventListener('mouseenter', () => setActiveSection(sectionId));
|
|
3
|
+
sectionNode.addEventListener('focusin', () => setActiveSection(sectionId));
|
|
4
|
+
sectionNode.addEventListener('pointerdown', (event) => {
|
|
5
|
+
const target = event.target;
|
|
6
|
+
if (!(target instanceof Element)) return;
|
|
7
|
+
if (target.closest('.cms-section-add-picker')) return;
|
|
8
|
+
setActiveSection(sectionId);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { buildCmsUrl } from '../config.js';
|
|
2
|
+
import { isRecord } from './utils.js';
|
|
3
|
+
import { mutateHeaders, readHeaders } from '../security.js';
|
|
4
|
+
|
|
5
|
+
export async function fetchPageEntry({ collection, id, onUnauthorized }) {
|
|
6
|
+
const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
|
|
7
|
+
headers: readHeaders(),
|
|
8
|
+
});
|
|
9
|
+
if (res.status === 401) {
|
|
10
|
+
onUnauthorized();
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
if (!res.ok) throw new Error('Failed to load page data');
|
|
14
|
+
|
|
15
|
+
const json = await res.json();
|
|
16
|
+
const entry = Array.isArray(json.entries) ? json.entries[0] : null;
|
|
17
|
+
if (!entry || !isRecord(entry.data)) {
|
|
18
|
+
throw new Error('Page entry not found');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
data: entry.data,
|
|
23
|
+
revision: Number.isInteger(entry.revision) ? entry.revision : 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function savePageLayout({
|
|
28
|
+
id,
|
|
29
|
+
sections,
|
|
30
|
+
expectedRevision,
|
|
31
|
+
onUnauthorized,
|
|
32
|
+
}) {
|
|
33
|
+
const res = await fetch(buildCmsUrl('/mutate'), {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: await mutateHeaders(),
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
type: 'update_page_layout',
|
|
38
|
+
id,
|
|
39
|
+
sections,
|
|
40
|
+
expectedRevision,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (res.status === 401) {
|
|
45
|
+
onUnauthorized();
|
|
46
|
+
return { ok: false, reason: 'unauthorized' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const json = await res.json().catch(() => ({}));
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
if (res.status === 409) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: 'conflict',
|
|
55
|
+
currentRevision: Number.isInteger(json.currentRevision) ? json.currentRevision : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const issue = Array.isArray(json.issues) ? json.issues[0] : null;
|
|
59
|
+
const issueDetail =
|
|
60
|
+
issue &&
|
|
61
|
+
typeof issue === 'object' &&
|
|
62
|
+
typeof issue.path === 'string' &&
|
|
63
|
+
typeof issue.message === 'string'
|
|
64
|
+
? `${issue.path}: ${issue.message}`
|
|
65
|
+
: null;
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: 'error',
|
|
69
|
+
error:
|
|
70
|
+
typeof json.error === 'string'
|
|
71
|
+
? issueDetail
|
|
72
|
+
? `${json.error} (${issueDetail})`
|
|
73
|
+
: json.error
|
|
74
|
+
: 'Failed to save layout',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
revision:
|
|
81
|
+
Number.isInteger(json.revision)
|
|
82
|
+
? json.revision
|
|
83
|
+
: Number.isInteger(expectedRevision)
|
|
84
|
+
? expectedRevision + 1
|
|
85
|
+
: 1,
|
|
86
|
+
};
|
|
87
|
+
}
|