@caretcms/core 0.1.2 → 0.3.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 +109 -13
- package/dist/contracts.d.ts +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +1 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +31 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +146 -30
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +17 -6
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +57 -14
- package/dist/loader.js.map +1 -1
- package/dist/markdown/block-context.d.ts +21 -0
- package/dist/markdown/block-context.d.ts.map +1 -0
- package/dist/markdown/block-context.js +27 -0
- package/dist/markdown/block-context.js.map +1 -0
- package/dist/markdown/canonical-body.d.ts +30 -0
- package/dist/markdown/canonical-body.d.ts.map +1 -0
- package/dist/markdown/canonical-body.js +54 -0
- package/dist/markdown/canonical-body.js.map +1 -0
- package/dist/markdown/contracts.d.ts +66 -0
- package/dist/markdown/contracts.d.ts.map +1 -0
- package/dist/markdown/contracts.js +102 -0
- package/dist/markdown/contracts.js.map +1 -0
- package/dist/markdown/html-to-snodes.d.ts +26 -0
- package/dist/markdown/html-to-snodes.d.ts.map +1 -0
- package/dist/markdown/html-to-snodes.js +118 -0
- package/dist/markdown/html-to-snodes.js.map +1 -0
- package/dist/markdown/remark.d.ts +35 -0
- package/dist/markdown/remark.d.ts.map +1 -0
- package/dist/markdown/remark.js +66 -0
- package/dist/markdown/remark.js.map +1 -0
- package/dist/markdown/satteri.d.ts +19 -0
- package/dist/markdown/satteri.d.ts.map +1 -0
- package/dist/markdown/satteri.js +144 -0
- package/dist/markdown/satteri.js.map +1 -0
- package/dist/markdown/serialize.d.ts +60 -0
- package/dist/markdown/serialize.d.ts.map +1 -0
- package/dist/markdown/serialize.js +264 -0
- package/dist/markdown/serialize.js.map +1 -0
- package/dist/markdown/splice.d.ts +32 -0
- package/dist/markdown/splice.d.ts.map +1 -0
- package/dist/markdown/splice.js +44 -0
- package/dist/markdown/splice.js.map +1 -0
- package/dist/markdown/stamp.d.ts +68 -0
- package/dist/markdown/stamp.d.ts.map +1 -0
- package/dist/markdown/stamp.js +131 -0
- package/dist/markdown/stamp.js.map +1 -0
- package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
- package/dist/runtime/auth/rate-limiter.js +5 -0
- package/dist/runtime/auth/rate-limiter.js.map +1 -1
- package/dist/runtime/auth/session.d.ts +3 -3
- package/dist/runtime/auth/session.d.ts.map +1 -1
- package/dist/runtime/auth/session.js +68 -21
- package/dist/runtime/auth/session.js.map +1 -1
- package/dist/runtime/config.d.ts +3 -0
- package/dist/runtime/config.d.ts.map +1 -1
- package/dist/runtime/config.js +4 -1
- package/dist/runtime/config.js.map +1 -1
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +4 -2
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/i18n.d.ts +86 -0
- package/dist/runtime/i18n.d.ts.map +1 -0
- package/dist/runtime/i18n.js +168 -0
- package/dist/runtime/i18n.js.map +1 -0
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +61 -6
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +28 -1
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +76 -0
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +6 -1
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +271 -51
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +2 -1
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +7 -1
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish.d.ts +13 -1
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +80 -4
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/request-context.d.ts +14 -2
- package/dist/runtime/request-context.d.ts.map +1 -1
- package/dist/runtime/request-context.js +7 -1
- package/dist/runtime/request-context.js.map +1 -1
- package/dist/runtime/rewrite.d.ts.map +1 -1
- package/dist/runtime/rewrite.js +109 -3
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.d.ts.map +1 -1
- package/dist/runtime/rich-allowlist.js +5 -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 +114 -18
- package/dist/runtime/routes/admin-entry.js.map +1 -1
- package/dist/runtime/routes/admin.d.ts.map +1 -1
- package/dist/runtime/routes/admin.js +33 -15
- 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 +4 -0
- 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 +7 -2
- package/dist/runtime/routes/auth-logout.js.map +1 -1
- package/dist/runtime/routes/auth-session.d.ts.map +1 -1
- package/dist/runtime/routes/auth-session.js +4 -2
- 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 +64 -41
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +16 -6
- 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 +50 -22
- 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 +40 -6
- 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 +5 -2
- 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 +11 -3
- 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 +38 -19
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
- package/dist/runtime/routes/theme-stylesheet.js +11 -1
- package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
- package/dist/runtime/schema-registry.d.ts +8 -1
- package/dist/runtime/schema-registry.d.ts.map +1 -1
- package/dist/runtime/schema-registry.js +47 -3
- package/dist/runtime/schema-registry.js.map +1 -1
- package/dist/runtime/storage/atomic-write.d.ts.map +1 -1
- package/dist/runtime/storage/atomic-write.js +8 -3
- package/dist/runtime/storage/atomic-write.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts +19 -0
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +41 -0
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +8 -0
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +24 -2
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/themes/preset-tokens.js +4 -4
- package/dist/runtime/themes/preset-tokens.js.map +1 -1
- package/dist/runtime/utils.d.ts +10 -0
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +16 -0
- package/dist/runtime/utils.js.map +1 -1
- package/dist/runtime/views/studio-layout.d.ts +3 -0
- package/dist/runtime/views/studio-layout.d.ts.map +1 -1
- package/dist/runtime/views/studio-layout.js +36 -13
- package/dist/runtime/views/studio-layout.js.map +1 -1
- package/dist/schema-utils.d.ts +7 -0
- package/dist/schema-utils.d.ts.map +1 -1
- package/dist/schema-utils.js +143 -0
- package/dist/schema-utils.js.map +1 -1
- package/dist/types.d.ts +83 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/cms/admin-entry.css +135 -13
- package/static/cms/admin-entry.js +509 -86
- package/static/cms/editor/guards.js +1 -1
- package/static/cms/editor/image-edit.js +42 -11
- package/static/cms/editor/image.css +7 -1
- package/static/cms/editor/md-block-edit.js +176 -0
- package/static/cms/editor/md-serialize.js +301 -0
- package/static/cms/editor/panel.css +69 -25
- package/static/cms/editor/panel.js +58 -1
- package/static/cms/editor/rich-toolbar.js +4 -3
- package/static/cms/editor/sanitize.js +2 -1
- package/static/cms/editor/sync.js +277 -56
- package/static/cms/editor/text-edit.js +68 -17
- package/static/cms/editor/text-link-interactions.js +4 -4
- package/static/cms/editor/toast.css +114 -2
- package/static/cms/editor/toast.js +121 -22
- package/static/cms/editor/tokens-and-text.css +73 -3
- package/static/cms/editor/toolbar.css +19 -6
- package/static/cms/editor/toolbar.js +64 -23
- package/static/cms/editor.css +1 -1
- package/static/cms/editor.js +14 -3
- package/static/cms/studio-tokens.css +4 -4
- package/static/cms/studio.css +24 -0
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
/* ---------------------------------------------------------------------------
|
|
2
|
-
Studio Panel — sliding left
|
|
2
|
+
Studio Panel — sliding left drawer with iframe
|
|
3
|
+
|
|
4
|
+
The panel is an OVERLAY drawer: it slides over the page and does not reflow
|
|
5
|
+
host layout. Earlier versions pushed `body` and shifted a hard-coded
|
|
6
|
+
`#main-header`, which silently broke on any site not using that selector or
|
|
7
|
+
using absolute/transformed layout. The only element still shifted is the CMS
|
|
8
|
+
toolbar, which is our own chrome (always present) — no host coupling.
|
|
9
|
+
|
|
10
|
+
Hosts that DO want content pushed aside (rather than overlaid) can opt in by
|
|
11
|
+
adding the `caret-push-content` class to <body>. See docs.
|
|
3
12
|
--------------------------------------------------------------------------- */
|
|
4
13
|
|
|
14
|
+
:root {
|
|
15
|
+
--cms-panel-width: 380px;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
.cms-studio-panel {
|
|
6
19
|
position: fixed;
|
|
7
20
|
top: 0;
|
|
8
21
|
left: 0;
|
|
9
22
|
bottom: 0;
|
|
10
|
-
width:
|
|
23
|
+
width: var(--cms-panel-width);
|
|
24
|
+
max-width: 100vw;
|
|
11
25
|
z-index: 100000;
|
|
12
26
|
background: #0c0e12;
|
|
13
27
|
border-right: 1px solid rgba(59, 130, 246, 0.12);
|
|
@@ -31,29 +45,46 @@
|
|
|
31
45
|
display: block;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
/*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
/* Loading skeleton shown until the Studio iframe reports load. */
|
|
49
|
+
.cms-studio-panel-loading {
|
|
50
|
+
position: absolute;
|
|
51
|
+
inset: 0;
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
background: #0c0e12;
|
|
56
|
+
color: rgba(255, 255, 255, 0.5);
|
|
57
|
+
font-family: var(--cms-font);
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
z-index: 1;
|
|
60
|
+
transition: opacity 0.3s ease;
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
.cms-studio-panel-loading[hidden] {
|
|
64
|
+
opacity: 0;
|
|
65
|
+
pointer-events: none;
|
|
42
66
|
}
|
|
43
67
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
left: 380px;
|
|
47
|
-
transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
68
|
+
.cms-studio-panel-loading-text {
|
|
69
|
+
margin-left: 10px;
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
.cms-studio-panel-spinner {
|
|
73
|
+
width: 22px;
|
|
74
|
+
height: 22px;
|
|
75
|
+
border: 2px solid rgba(255, 255, 255, 0.15);
|
|
76
|
+
border-top-color: var(--cms-accent, #3b82f6);
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
animation: cms-panel-spin 0.7s linear infinite;
|
|
52
79
|
}
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
@keyframes cms-panel-spin {
|
|
82
|
+
to { transform: rotate(360deg); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Shift the CMS toolbar (our chrome) so the drawer doesn't cover it. */
|
|
55
86
|
body.cms-panel-open .cms-toolbar {
|
|
56
|
-
left:
|
|
87
|
+
left: var(--cms-panel-width);
|
|
57
88
|
transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
58
89
|
}
|
|
59
90
|
|
|
@@ -61,6 +92,16 @@ body.cms-panel-open.no-transition .cms-toolbar {
|
|
|
61
92
|
transition: none;
|
|
62
93
|
}
|
|
63
94
|
|
|
95
|
+
/* Opt-in: push host content aside instead of overlaying it. */
|
|
96
|
+
body.cms-panel-open.caret-push-content {
|
|
97
|
+
padding-left: var(--cms-panel-width);
|
|
98
|
+
transition: padding-left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
body.cms-panel-open.caret-push-content.no-transition {
|
|
102
|
+
transition: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
/* Studio button active state */
|
|
65
106
|
.cms-studio-btn.active {
|
|
66
107
|
background: rgba(59, 130, 246, 0.3);
|
|
@@ -69,23 +110,26 @@ body.cms-panel-open.no-transition .cms-toolbar {
|
|
|
69
110
|
box-shadow: 0 0 8px rgba(59, 130, 246, 0.15);
|
|
70
111
|
}
|
|
71
112
|
|
|
72
|
-
|
|
73
|
-
/* Mobile: full-width overlay, no content push */
|
|
113
|
+
/* Mobile: full-width overlay, no toolbar shift. */
|
|
74
114
|
@media (max-width: 768px) {
|
|
75
115
|
.cms-studio-panel {
|
|
76
116
|
width: 100%;
|
|
77
117
|
box-shadow: none;
|
|
78
118
|
}
|
|
79
119
|
|
|
80
|
-
body.cms-panel-open
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
body.cms-panel-open #main-header {
|
|
120
|
+
body.cms-panel-open .cms-toolbar,
|
|
121
|
+
body.cms-panel-open.caret-push-content {
|
|
85
122
|
left: 0;
|
|
123
|
+
padding-left: 0;
|
|
86
124
|
}
|
|
125
|
+
}
|
|
87
126
|
|
|
88
|
-
|
|
89
|
-
|
|
127
|
+
@media (prefers-reduced-motion: reduce) {
|
|
128
|
+
.cms-studio-panel,
|
|
129
|
+
body.cms-panel-open .cms-toolbar,
|
|
130
|
+
body.cms-panel-open.caret-push-content,
|
|
131
|
+
.cms-studio-panel-spinner {
|
|
132
|
+
transition: none;
|
|
133
|
+
animation: none;
|
|
90
134
|
}
|
|
91
135
|
}
|
|
@@ -9,22 +9,69 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
9
9
|
|
|
10
10
|
const panel = document.createElement('div');
|
|
11
11
|
panel.className = 'cms-studio-panel';
|
|
12
|
+
// A dialog-like region so AT users understand the drawer context.
|
|
13
|
+
panel.setAttribute('role', 'dialog');
|
|
14
|
+
panel.setAttribute('aria-label', 'Content Studio');
|
|
15
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
16
|
+
|
|
17
|
+
// Loading skeleton shown until the iframe reports load — the iframe is blank
|
|
18
|
+
// until then, which reads as "broken".
|
|
19
|
+
const loading = document.createElement('div');
|
|
20
|
+
loading.className = 'cms-studio-panel-loading';
|
|
21
|
+
loading.innerHTML =
|
|
22
|
+
'<span class="cms-studio-panel-spinner" aria-hidden="true"></span>' +
|
|
23
|
+
'<span class="cms-studio-panel-loading-text">Loading Studio…</span>';
|
|
24
|
+
panel.appendChild(loading);
|
|
12
25
|
|
|
13
26
|
const iframe = document.createElement('iframe');
|
|
14
|
-
|
|
27
|
+
let rememberedPath = '';
|
|
28
|
+
try {
|
|
29
|
+
const storedPath = sessionStorage.getItem('cms-panel-path') || '';
|
|
30
|
+
if (storedPath === STUDIO_PATH || storedPath.startsWith(`${STUDIO_PATH}/`)) {
|
|
31
|
+
rememberedPath = storedPath;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Session storage is optional; the Studio root remains a safe fallback.
|
|
35
|
+
}
|
|
36
|
+
iframe.src = rememberedPath || STUDIO_PATH;
|
|
15
37
|
iframe.title = 'Content Studio';
|
|
38
|
+
iframe.addEventListener('load', () => {
|
|
39
|
+
loading.setAttribute('hidden', 'hidden');
|
|
40
|
+
try {
|
|
41
|
+
const currentPath = `${iframe.contentWindow.location.pathname}${iframe.contentWindow.location.search}`;
|
|
42
|
+
if (currentPath === STUDIO_PATH || currentPath.startsWith(`${STUDIO_PATH}/`)) {
|
|
43
|
+
sessionStorage.setItem('cms-panel-path', currentPath);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// The configured Studio may be cross-origin, in which case its route is
|
|
47
|
+
// intentionally unavailable to the parent page.
|
|
48
|
+
}
|
|
49
|
+
});
|
|
16
50
|
panel.appendChild(iframe);
|
|
17
51
|
document.body.appendChild(panel);
|
|
18
52
|
|
|
53
|
+
// Remember what had focus so we can restore it on close.
|
|
54
|
+
let lastFocused = null;
|
|
55
|
+
|
|
19
56
|
function openPanel(animate = true) {
|
|
57
|
+
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
20
58
|
if (!animate) {
|
|
21
59
|
panel.classList.add('no-transition');
|
|
22
60
|
document.body.classList.add('no-transition');
|
|
23
61
|
}
|
|
24
62
|
panel.classList.add('open');
|
|
63
|
+
panel.setAttribute('aria-hidden', 'false');
|
|
25
64
|
document.body.classList.add('cms-panel-open');
|
|
26
65
|
studioButton?.classList.add('active');
|
|
66
|
+
studioButton?.setAttribute('aria-expanded', 'true');
|
|
27
67
|
sessionStorage.setItem('cms-panel-open', '1');
|
|
68
|
+
// Move focus into the drawer so keyboard users aren't left behind the
|
|
69
|
+
// covering panel. The iframe is focusable and hands focus to its own content.
|
|
70
|
+
if (animate) {
|
|
71
|
+
try {
|
|
72
|
+
iframe.focus({ preventScroll: true });
|
|
73
|
+
} catch (e) {}
|
|
74
|
+
}
|
|
28
75
|
if (!animate) {
|
|
29
76
|
requestAnimationFrame(() => {
|
|
30
77
|
requestAnimationFrame(() => {
|
|
@@ -37,9 +84,19 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
37
84
|
|
|
38
85
|
function closePanel() {
|
|
39
86
|
panel.classList.remove('open');
|
|
87
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
40
88
|
document.body.classList.remove('cms-panel-open');
|
|
41
89
|
studioButton?.classList.remove('active');
|
|
90
|
+
studioButton?.setAttribute('aria-expanded', 'false');
|
|
42
91
|
sessionStorage.setItem('cms-panel-open', '0');
|
|
92
|
+
// Return focus to the trigger (or wherever it was) so it isn't lost.
|
|
93
|
+
const restore = lastFocused || studioButton;
|
|
94
|
+
if (restore instanceof HTMLElement) {
|
|
95
|
+
try {
|
|
96
|
+
restore.focus({ preventScroll: true });
|
|
97
|
+
} catch (e) {}
|
|
98
|
+
}
|
|
99
|
+
lastFocused = null;
|
|
43
100
|
}
|
|
44
101
|
|
|
45
102
|
function togglePanel() {
|
|
@@ -175,11 +175,12 @@ function onSelectionChange() {
|
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
// Check if selection is inside a rich element
|
|
178
|
+
// Check if selection is inside a rich element (or a markdown body block,
|
|
179
|
+
// which shares the same inline formatting commands)
|
|
179
180
|
const anchor = sel.anchorNode;
|
|
180
181
|
const richEl = anchor instanceof Element
|
|
181
|
-
? anchor.closest('[data-caret-rich]')
|
|
182
|
-
: anchor?.parentElement?.closest('[data-caret-rich]');
|
|
182
|
+
? anchor.closest('[data-caret-rich], [data-caret-md]')
|
|
183
|
+
: anchor?.parentElement?.closest('[data-caret-rich], [data-caret-md]');
|
|
183
184
|
|
|
184
185
|
if (!richEl) {
|
|
185
186
|
hide();
|
|
@@ -17,7 +17,8 @@ export const ALLOWED_ATTRS = {
|
|
|
17
17
|
a: new Set(['href', 'target', 'rel']),
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// `(?!\/)` rejects protocol-relative URLs (`//evil.com`). Mirrors core exactly.
|
|
21
|
+
export const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/(?!\/))/i;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Does `cls` match any pattern? (exact, `prefix-*`, or lone `*`)
|
|
@@ -1,72 +1,293 @@
|
|
|
1
1
|
import { sanitizeHtml } from './sanitize.js';
|
|
2
|
+
import { parseCaretAttr, resolveBinding } from './helpers.js';
|
|
3
|
+
import { STUDIO_PATH } from './config.js';
|
|
4
|
+
|
|
5
|
+
const SYNC_CHANNEL = 'caretcms:content';
|
|
2
6
|
|
|
3
7
|
export function mountStudioSync({ studioIframe, state, showToast, clientLinkify }) {
|
|
4
|
-
|
|
8
|
+
let refreshTimer = 0;
|
|
9
|
+
let selectedInlineEl = null;
|
|
10
|
+
let linkedSelectionTimer = 0;
|
|
11
|
+
let pendingStudioSelection = null;
|
|
12
|
+
let activeStudioSelection = null;
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
function matchingElements(collection, id, field) {
|
|
15
|
+
const fullKey = `${collection}::${id}::${field}`;
|
|
16
|
+
const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(fullKey)}"]`));
|
|
17
|
+
document.querySelectorAll(`[data-caret="${CSS.escape(field)}"]`).forEach((el) => {
|
|
18
|
+
const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
|
|
19
|
+
if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
|
|
20
|
+
});
|
|
21
|
+
return matches;
|
|
22
|
+
}
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
24
|
+
function selectInlineField(collection, id, field, shouldScroll) {
|
|
25
|
+
if (typeof collection !== 'string' || typeof id !== 'string' || typeof field !== 'string') return;
|
|
26
|
+
const target = matchingElements(collection, id, field)[0];
|
|
27
|
+
if (!target) return;
|
|
28
|
+
|
|
29
|
+
window.clearTimeout(linkedSelectionTimer);
|
|
30
|
+
if (selectedInlineEl && selectedInlineEl !== target) {
|
|
31
|
+
selectedInlineEl.classList.remove('cms-linked-selection');
|
|
32
|
+
}
|
|
33
|
+
selectedInlineEl = target;
|
|
34
|
+
target.classList.add('cms-linked-selection');
|
|
35
|
+
if (shouldScroll) {
|
|
36
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Blue confirms that the paired field was located. Green remains the
|
|
40
|
+
// distinct confirmation that an edit was actually saved.
|
|
41
|
+
linkedSelectionTimer = window.setTimeout(() => {
|
|
42
|
+
if (selectedInlineEl !== target) return;
|
|
43
|
+
target.classList.remove('cms-linked-selection');
|
|
44
|
+
selectedInlineEl = null;
|
|
45
|
+
}, 1500);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function publishSelection(message) {
|
|
49
|
+
if (!('BroadcastChannel' in window)) return;
|
|
50
|
+
try {
|
|
51
|
+
const channel = new BroadcastChannel(SYNC_CHANNEL);
|
|
52
|
+
channel.postMessage(message);
|
|
53
|
+
window.setTimeout(() => channel.close(), 0);
|
|
54
|
+
} catch {
|
|
55
|
+
// Cross-tab selection linking is a progressive enhancement.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function studioEntryPath(message) {
|
|
60
|
+
return `${STUDIO_PATH}/${encodeURIComponent(message.collection)}/${encodeURIComponent(message.id)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function postEmbeddedSelection(message) {
|
|
64
|
+
if (!studioIframe?.contentWindow) return;
|
|
65
|
+
try {
|
|
66
|
+
studioIframe.contentWindow.postMessage({ ...message, embedded: true }, window.location.origin);
|
|
67
|
+
} catch {
|
|
68
|
+
// Embedded field linking is a progressive enhancement.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function deliverEmbeddedSelection(message) {
|
|
73
|
+
if (!studioIframe?.contentWindow) return;
|
|
74
|
+
const targetPath = studioEntryPath(message);
|
|
75
|
+
|
|
76
|
+
// A normal mouse interaction can emit pointerdown, focusin, and click.
|
|
77
|
+
// Keep the latest field without restarting the same iframe navigation for
|
|
78
|
+
// each event in that sequence.
|
|
79
|
+
if (pendingStudioSelection && studioIframe.getAttribute('src') === targetPath) {
|
|
80
|
+
pendingStudioSelection = message;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (studioIframe.contentWindow.location.pathname !== targetPath) {
|
|
86
|
+
pendingStudioSelection = message;
|
|
87
|
+
studioIframe.src = targetPath;
|
|
88
|
+
try {
|
|
89
|
+
sessionStorage.setItem('cms-panel-path', targetPath);
|
|
90
|
+
} catch {
|
|
91
|
+
// Remembering the route is optional; the current navigation is not.
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Cross-origin Studio panels cannot be inspected or navigated safely;
|
|
97
|
+
// preserve the existing postMessage-only behavior for those setups.
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
postEmbeddedSelection(message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
studioIframe?.addEventListener('load', () => {
|
|
104
|
+
if (!pendingStudioSelection) return;
|
|
105
|
+
const message = pendingStudioSelection;
|
|
106
|
+
pendingStudioSelection = null;
|
|
107
|
+
window.requestAnimationFrame(() => postEmbeddedSelection(message));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function selectStudioField(el) {
|
|
111
|
+
const resolved = resolveBinding(el, parseCaretAttr(el.getAttribute('data-caret') || ''));
|
|
112
|
+
if (!resolved) return;
|
|
113
|
+
|
|
114
|
+
selectInlineField(resolved.collection, resolved.id, resolved.field, false);
|
|
115
|
+
const message = {
|
|
116
|
+
type: 'cms:field-selected',
|
|
117
|
+
collection: resolved.collection,
|
|
118
|
+
id: resolved.id,
|
|
119
|
+
field: resolved.field,
|
|
120
|
+
source: 'inline',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
activeStudioSelection = message;
|
|
124
|
+
deliverEmbeddedSelection(message);
|
|
125
|
+
publishSelection(message);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Pointerdown covers image-like bindings; focusin covers keyboard navigation
|
|
129
|
+
// and text editing. Click catches non-focusable bound elements.
|
|
130
|
+
document.addEventListener('pointerdown', (event) => {
|
|
131
|
+
const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
|
|
132
|
+
if (el) selectStudioField(el);
|
|
133
|
+
});
|
|
134
|
+
document.addEventListener('click', (event) => {
|
|
135
|
+
const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
|
|
136
|
+
if (el) selectStudioField(el);
|
|
137
|
+
});
|
|
138
|
+
document.addEventListener('focusin', (event) => {
|
|
139
|
+
const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
|
|
140
|
+
if (el) selectStudioField(el);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function rememberStudioPath(msg) {
|
|
144
|
+
if (typeof msg.studioPath !== 'string') return;
|
|
145
|
+
try {
|
|
146
|
+
sessionStorage.setItem('cms-panel-path', msg.studioPath);
|
|
147
|
+
} catch {
|
|
148
|
+
// Remembering the current Studio route is a progressive enhancement.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function scheduleEmbeddedRefresh(msg) {
|
|
153
|
+
rememberStudioPath(msg);
|
|
154
|
+
|
|
155
|
+
if (state.dirtyEls.size > 0) {
|
|
156
|
+
showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
showToast('Saved — refreshing visual preview…', 'success');
|
|
161
|
+
window.clearTimeout(refreshTimer);
|
|
162
|
+
refreshTimer = window.setTimeout(() => window.location.reload(), 280);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function applySavedMessage(msg) {
|
|
166
|
+
const { collection, id, data } = msg;
|
|
167
|
+
if (!data || typeof data !== 'object') return 0;
|
|
168
|
+
|
|
169
|
+
let updated = 0;
|
|
170
|
+
|
|
171
|
+
function boundElements(selector, path) {
|
|
172
|
+
const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(selector)}"]`));
|
|
173
|
+
document.querySelectorAll(`[data-caret="${CSS.escape(path)}"]`).forEach((el) => {
|
|
174
|
+
const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
|
|
175
|
+
if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
|
|
176
|
+
});
|
|
177
|
+
return matches;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function walkData(obj, prefix) {
|
|
181
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
182
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
183
|
+
const selector = `${collection}::${id}::${path}`;
|
|
184
|
+
|
|
185
|
+
if (typeof value === 'string') {
|
|
186
|
+
const els = boundElements(selector, path);
|
|
187
|
+
els.forEach((el) => {
|
|
188
|
+
// Skip elements being actively edited to prevent overwriting unsaved work
|
|
189
|
+
if (state.dirtyEls.has(el) || el === document.activeElement) return;
|
|
190
|
+
|
|
191
|
+
if (el instanceof HTMLImageElement) {
|
|
192
|
+
if (el.src !== value && el.getAttribute('src') !== value) {
|
|
193
|
+
el.src = value;
|
|
194
|
+
updated++;
|
|
195
|
+
}
|
|
196
|
+
} else if (el.hasAttribute('data-caret-rich')) {
|
|
197
|
+
const sanitized = sanitizeHtml(value);
|
|
198
|
+
if (el.innerHTML !== sanitized) {
|
|
199
|
+
el.innerHTML = sanitized;
|
|
200
|
+
updated++;
|
|
201
|
+
}
|
|
202
|
+
} else if (el.hasAttribute('data-caret-raw')) {
|
|
203
|
+
if (el.getAttribute('data-caret-raw') !== value) {
|
|
204
|
+
el.setAttribute('data-caret-raw', value);
|
|
205
|
+
el.innerHTML = clientLinkify(value);
|
|
50
206
|
updated++;
|
|
51
207
|
}
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
208
|
+
} else if (el.textContent !== value) {
|
|
209
|
+
el.textContent = value;
|
|
210
|
+
updated++;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
214
|
+
walkData(value, path);
|
|
56
215
|
}
|
|
57
216
|
}
|
|
217
|
+
}
|
|
58
218
|
|
|
59
|
-
|
|
219
|
+
walkData(data, '');
|
|
220
|
+
return updated;
|
|
221
|
+
}
|
|
60
222
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
223
|
+
window.addEventListener('message', (e) => {
|
|
224
|
+
// Only accept messages from our iframe
|
|
225
|
+
if (!studioIframe || e.source !== studioIframe.contentWindow) return;
|
|
226
|
+
const msg = e.data;
|
|
227
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
228
|
+
|
|
229
|
+
if (
|
|
230
|
+
msg.type === 'cms:entry-ready'
|
|
231
|
+
&& activeStudioSelection
|
|
232
|
+
&& msg.collection === activeStudioSelection.collection
|
|
233
|
+
&& msg.id === activeStudioSelection.id
|
|
234
|
+
) {
|
|
235
|
+
postEmbeddedSelection(activeStudioSelection);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (msg.type === 'cms:preview') {
|
|
240
|
+
applySavedMessage(msg);
|
|
241
|
+
return;
|
|
66
242
|
}
|
|
67
243
|
|
|
68
|
-
if (msg.type === 'cms:
|
|
69
|
-
|
|
244
|
+
if (msg.type === 'cms:field-selected') {
|
|
245
|
+
selectInlineField(msg.collection, msg.id, msg.field, true);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (msg.type === 'cms:saved') {
|
|
250
|
+
applySavedMessage(msg);
|
|
251
|
+
scheduleEmbeddedRefresh(msg);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (msg.type === 'cms:created' || msg.type === 'cms:deleted') {
|
|
255
|
+
scheduleEmbeddedRefresh(msg);
|
|
70
256
|
}
|
|
71
257
|
});
|
|
258
|
+
|
|
259
|
+
// Standalone Studio pages live in a separate tab, so postMessage cannot
|
|
260
|
+
// reach the site preview. BroadcastChannel provides a same-origin bridge.
|
|
261
|
+
// Reload only after a confirmed mutation. The server remains the source of
|
|
262
|
+
// truth for lists, routing, and derived UI; sites can make the refresh feel
|
|
263
|
+
// continuous with their own cross-document view transitions.
|
|
264
|
+
if ('BroadcastChannel' in window) {
|
|
265
|
+
try {
|
|
266
|
+
const channel = new BroadcastChannel(SYNC_CHANNEL);
|
|
267
|
+
channel.addEventListener('message', (event) => {
|
|
268
|
+
const msg = event.data;
|
|
269
|
+
if (!msg || typeof msg.type !== 'string' || msg.embedded === true) return;
|
|
270
|
+
|
|
271
|
+
if (msg.type === 'cms:field-selected') {
|
|
272
|
+
selectInlineField(msg.collection, msg.id, msg.field, true);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!['cms:saved', 'cms:created', 'cms:deleted'].includes(msg.type)) return;
|
|
276
|
+
|
|
277
|
+
if (state.dirtyEls.size > 0) {
|
|
278
|
+
showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (msg.type === 'cms:saved') applySavedMessage(msg);
|
|
283
|
+
showToast('Saved — refreshing preview…', 'success');
|
|
284
|
+
window.clearTimeout(refreshTimer);
|
|
285
|
+
refreshTimer = window.setTimeout(() => window.location.reload(), 280);
|
|
286
|
+
});
|
|
287
|
+
window.addEventListener('pagehide', () => channel.close(), { once: true });
|
|
288
|
+
} catch {
|
|
289
|
+
// Cross-tab preview sync is progressive enhancement; iframe sync above
|
|
290
|
+
// remains available in browsers without BroadcastChannel.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
72
293
|
}
|