@caretcms/core 0.1.1 → 0.2.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 +101 -18
- package/dist/browser-runtime.d.ts +16 -0
- package/dist/browser-runtime.d.ts.map +1 -1
- package/dist/browser-runtime.js +8 -3
- package/dist/browser-runtime.js.map +1 -1
- 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 +34 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -13
- 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 +33 -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 +120 -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 +2 -3
- package/dist/runtime/auth/session.d.ts.map +1 -1
- package/dist/runtime/auth/session.js +52 -21
- package/dist/runtime/auth/session.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/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +21 -4
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +25 -1
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +67 -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 +151 -42
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +9 -0
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +6 -0
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish.d.ts +15 -1
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +79 -4
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/rebuild-webhook.d.ts +29 -0
- package/dist/runtime/rebuild-webhook.d.ts.map +1 -0
- package/dist/runtime/rebuild-webhook.js +37 -0
- package/dist/runtime/rebuild-webhook.js.map +1 -0
- package/dist/runtime/request-context.d.ts +9 -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 +18 -0
- package/dist/runtime/rewrite.d.ts.map +1 -1
- package/dist/runtime/rewrite.js +118 -4
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.d.ts +3 -2
- package/dist/runtime/rich-allowlist.d.ts.map +1 -1
- package/dist/runtime/rich-allowlist.js +14 -4
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/_helpers.d.ts +7 -0
- package/dist/runtime/routes/_helpers.d.ts.map +1 -1
- package/dist/runtime/routes/_helpers.js +34 -1
- package/dist/runtime/routes/_helpers.js.map +1 -1
- package/dist/runtime/routes/admin-entry.d.ts +1 -0
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
- package/dist/runtime/routes/admin-entry.js +3 -2
- package/dist/runtime/routes/admin-entry.js.map +1 -1
- package/dist/runtime/routes/admin.d.ts +1 -0
- package/dist/runtime/routes/admin.d.ts.map +1 -1
- package/dist/runtime/routes/admin.js +20 -13
- package/dist/runtime/routes/admin.js.map +1 -1
- package/dist/runtime/routes/auth-login.d.ts +1 -0
- package/dist/runtime/routes/auth-login.d.ts.map +1 -1
- package/dist/runtime/routes/auth-login.js +1 -0
- package/dist/runtime/routes/auth-login.js.map +1 -1
- package/dist/runtime/routes/auth-logout.d.ts +1 -0
- package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
- package/dist/runtime/routes/auth-logout.js +1 -0
- package/dist/runtime/routes/auth-logout.js.map +1 -1
- package/dist/runtime/routes/auth-session.d.ts +1 -0
- package/dist/runtime/routes/auth-session.d.ts.map +1 -1
- package/dist/runtime/routes/auth-session.js +1 -0
- package/dist/runtime/routes/auth-session.js.map +1 -1
- package/dist/runtime/routes/collection-list.d.ts +1 -0
- package/dist/runtime/routes/collection-list.d.ts.map +1 -1
- package/dist/runtime/routes/collection-list.js +3 -2
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/collections-metadata.d.ts +1 -0
- package/dist/runtime/routes/collections-metadata.d.ts.map +1 -1
- package/dist/runtime/routes/collections-metadata.js +3 -2
- package/dist/runtime/routes/collections-metadata.js.map +1 -1
- package/dist/runtime/routes/draft.d.ts +5 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -1
- package/dist/runtime/routes/draft.js +18 -1
- package/dist/runtime/routes/draft.js.map +1 -1
- package/dist/runtime/routes/editor-assets.d.ts +1 -0
- package/dist/runtime/routes/editor-assets.d.ts.map +1 -1
- package/dist/runtime/routes/editor-assets.js +1 -0
- package/dist/runtime/routes/editor-assets.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts +1 -0
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +6 -4
- package/dist/runtime/routes/entries.js.map +1 -1
- package/dist/runtime/routes/history.d.ts +1 -0
- package/dist/runtime/routes/history.d.ts.map +1 -1
- package/dist/runtime/routes/history.js +50 -24
- package/dist/runtime/routes/history.js.map +1 -1
- package/dist/runtime/routes/mutate.d.ts +1 -0
- package/dist/runtime/routes/mutate.d.ts.map +1 -1
- package/dist/runtime/routes/mutate.js +42 -7
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts +1 -0
- package/dist/runtime/routes/publish.d.ts.map +1 -1
- package/dist/runtime/routes/publish.js +15 -3
- package/dist/runtime/routes/publish.js.map +1 -1
- package/dist/runtime/routes/schema.d.ts +1 -0
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +7 -3
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/studio-home.d.ts +1 -0
- package/dist/runtime/routes/studio-home.d.ts.map +1 -1
- package/dist/runtime/routes/studio-home.js +10 -9
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/theme-stylesheet.d.ts +1 -0
- package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
- package/dist/runtime/routes/theme-stylesheet.js +12 -1
- package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts +1 -0
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +1 -0
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/static-bake.d.ts +20 -0
- package/dist/runtime/static-bake.d.ts.map +1 -0
- package/dist/runtime/static-bake.js +58 -0
- package/dist/runtime/static-bake.js.map +1 -0
- 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/frontmatter-codec.d.ts.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.js +8 -0
- package/dist/runtime/storage/frontmatter-codec.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/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.map +1 -1
- package/dist/runtime/views/studio-layout.js +11 -10
- package/dist/runtime/views/studio-layout.js.map +1 -1
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/cms/admin-entry.js +36 -2
- package/static/cms/dev-toolbar/app.js +3 -1
- package/static/cms/editor/config.js +10 -0
- package/static/cms/editor/guards.js +1 -1
- package/static/cms/editor/helpers.js +5 -1
- package/static/cms/editor/highlight.js +4 -6
- package/static/cms/editor/image-edit.js +41 -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 +39 -0
- package/static/cms/editor/rich-toolbar.js +4 -3
- package/static/cms/editor/sanitize.js +10 -5
- package/static/cms/editor/sync.js +1 -1
- 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 +64 -3
- package/static/cms/editor/toolbar.css +76 -8
- package/static/cms/editor/toolbar.js +173 -64
- package/static/cms/editor.js +16 -3
- package/static/cms/studio-login.css +70 -0
- package/static/cms/studio.css +80 -13
|
@@ -80,6 +80,34 @@ export function mountImageEditors({
|
|
|
80
80
|
input.click();
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
// Save-conflict resolution for images: the upload itself already succeeded
|
|
84
|
+
// (we hold the stored `mineUrl`); only writing it into the entry conflicted.
|
|
85
|
+
// Keep the uploaded image shown and let the user choose, rather than
|
|
86
|
+
// silently swapping in whatever landed elsewhere.
|
|
87
|
+
const runImageConflict = (resolved, marker, mineUrl, latestValue) => {
|
|
88
|
+
showToast.conflict({
|
|
89
|
+
message: 'This image changed elsewhere while you were editing.',
|
|
90
|
+
onKeepMine: async () => {
|
|
91
|
+
setStatus('saving', 'Saving...');
|
|
92
|
+
const retry = await saveField(resolved.collection, resolved.id, resolved.field, mineUrl);
|
|
93
|
+
flash(img, retry.ok);
|
|
94
|
+
if (retry.ok) {
|
|
95
|
+
syncBoundImages(marker, mineUrl);
|
|
96
|
+
showToast('Your image saved', 'success');
|
|
97
|
+
} else if (retry.reason === 'conflict') {
|
|
98
|
+
runImageConflict(resolved, marker, mineUrl, retry.latestValue);
|
|
99
|
+
} else if (retry.reason !== 'unauthorized') {
|
|
100
|
+
showToast('Failed to save image. Try again.', 'error');
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
onLoadLatest: () => {
|
|
104
|
+
const src = typeof latestValue === 'string' ? latestValue : mineUrl;
|
|
105
|
+
syncBoundImages(marker, src);
|
|
106
|
+
showToast('Loaded latest image', 'success');
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
83
111
|
input.addEventListener('change', async () => {
|
|
84
112
|
const file = input.files?.[0];
|
|
85
113
|
if (!file) return;
|
|
@@ -110,27 +138,29 @@ export function mountImageEditors({
|
|
|
110
138
|
body: formData,
|
|
111
139
|
});
|
|
112
140
|
if (uploadRes.status === 401) {
|
|
141
|
+
// Restore the real image before the redirect — the optimistic blob is
|
|
142
|
+
// revoked in `finally`, so leaving it as src would flash a broken image.
|
|
143
|
+
img.src = originalSrc;
|
|
113
144
|
onUnauthorized();
|
|
114
145
|
return;
|
|
115
146
|
}
|
|
116
147
|
if (!uploadRes.ok) throw new Error('Upload failed');
|
|
117
148
|
const { url } = await uploadRes.json();
|
|
118
149
|
|
|
150
|
+
// Point at the durable uploaded URL now, so the shown image survives the
|
|
151
|
+
// optimistic blob being revoked in `finally` (and any conflict prompt).
|
|
152
|
+
img.src = url;
|
|
153
|
+
|
|
119
154
|
const result = await saveField(resolved.collection, resolved.id, resolved.field, url);
|
|
120
155
|
if (result.ok) {
|
|
121
156
|
syncBoundImages(marker, url);
|
|
122
157
|
showToast('Image updated', 'success');
|
|
123
|
-
} else {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (result.reason === 'conflict') {
|
|
130
|
-
showToast('Image changed elsewhere. Loaded latest image.', 'error');
|
|
131
|
-
} else if (result.reason !== 'unauthorized') {
|
|
132
|
-
showToast('Failed to save image. Changes reverted.', 'error');
|
|
133
|
-
}
|
|
158
|
+
} else if (result.reason === 'conflict') {
|
|
159
|
+
// Keep the uploaded image shown; let the user choose.
|
|
160
|
+
runImageConflict(resolved, marker, url, result.latestValue);
|
|
161
|
+
} else if (result.reason !== 'unauthorized') {
|
|
162
|
+
syncBoundImages(marker, originalSrc);
|
|
163
|
+
showToast('Failed to save image. Changes reverted.', 'error');
|
|
134
164
|
}
|
|
135
165
|
|
|
136
166
|
flash(img, result.ok);
|
|
@@ -65,10 +65,16 @@
|
|
|
65
65
|
z-index: 11;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
.cms-img-wrapper:hover .cms-img-overlay
|
|
68
|
+
.cms-img-wrapper:hover .cms-img-overlay,
|
|
69
|
+
.cms-img-wrapper:focus-within .cms-img-overlay {
|
|
69
70
|
opacity: 1;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
.cms-img-overlay:focus-visible {
|
|
74
|
+
outline: 2px solid var(--cms-accent, #3b82f6);
|
|
75
|
+
outline-offset: -2px;
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
.cms-img-overlay-content {
|
|
73
79
|
display: flex;
|
|
74
80
|
flex-direction: column;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline editing for markdown body blocks (`data-caret-md`).
|
|
3
|
+
*
|
|
4
|
+
* A stamped block (paragraph / heading / list item, bound by the render-time
|
|
5
|
+
* mdast plugin) becomes contenteditable; the floating rich toolbar provides
|
|
6
|
+
* bold/italic/link. On blur the block's sanitized inner HTML is sent as an
|
|
7
|
+
* `md_block` mutation — the server derives the markdown, verifies the source
|
|
8
|
+
* range hash, and stores the draft in the editor's overlay. The `.md` file is
|
|
9
|
+
* only touched at Publish.
|
|
10
|
+
*
|
|
11
|
+
* No `expectedRevision` is sent: body drafts live in a PER-EDITOR overlay, so
|
|
12
|
+
* there is no cross-editor race on the draft itself — the source-range hash in
|
|
13
|
+
* `data-caret-md-src` is the staleness guard that matters, and a 409 here
|
|
14
|
+
* means the underlying file changed (publish, external edit) and the page must
|
|
15
|
+
* be reloaded to pick up fresh stamps.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { serializeBlock, domToSNodes, SerializeError } from './md-serialize.js';
|
|
19
|
+
import { sanitizeHtml } from './sanitize.js';
|
|
20
|
+
import { buildCmsUrl } from './config.js';
|
|
21
|
+
import { mutateHeaders } from './security.js';
|
|
22
|
+
|
|
23
|
+
const snapshots = new WeakMap();
|
|
24
|
+
|
|
25
|
+
// All md-block saves are chained through one queue (same pattern as
|
|
26
|
+
// save-queue.js): two fast blur-saves of the same block can otherwise race on
|
|
27
|
+
// the network and land out of order — both return ok, and the older content
|
|
28
|
+
// silently wins. A per-element generation counter additionally drops a queued
|
|
29
|
+
// save that a newer blur has superseded.
|
|
30
|
+
let saveChain = Promise.resolve();
|
|
31
|
+
const generations = new WeakMap();
|
|
32
|
+
|
|
33
|
+
function enqueueSave(task) {
|
|
34
|
+
const run = saveChain.then(task, task);
|
|
35
|
+
saveChain = run.then(
|
|
36
|
+
() => undefined,
|
|
37
|
+
() => undefined,
|
|
38
|
+
);
|
|
39
|
+
return run;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Parse `collection::id::body::blockPath`, or null. */
|
|
43
|
+
function parseMdBinding(raw) {
|
|
44
|
+
const parts = (raw || '').split('::');
|
|
45
|
+
if (parts.length !== 4 || parts[2] !== 'body') return null;
|
|
46
|
+
if (!/^\d+(\.\d+)*$/.test(parts[3])) return null;
|
|
47
|
+
return { collection: parts[0], id: parts[1], blockPath: parts[3] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Serialization context from the DOM — mirrors the server's derivation. */
|
|
51
|
+
function blockContextOf(el) {
|
|
52
|
+
const heading = /^h([1-6])$/.exec(el.tagName.toLowerCase());
|
|
53
|
+
if (heading) return { block: 'heading', level: Number(heading[1]) };
|
|
54
|
+
const nested = el.tagName.toLowerCase() === 'li' || el.closest('li, blockquote') !== null;
|
|
55
|
+
return { block: 'paragraph', nested };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function saveMdBlock(binding, src, html) {
|
|
59
|
+
const res = await fetch(buildCmsUrl('/mutate'), {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: await mutateHeaders(),
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
type: 'md_block',
|
|
64
|
+
collection: binding.collection,
|
|
65
|
+
id: binding.id,
|
|
66
|
+
blockPath: binding.blockPath,
|
|
67
|
+
src,
|
|
68
|
+
html,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
if (res.status === 401) return { ok: false, reason: 'unauthorized' };
|
|
72
|
+
if (res.status === 409) return { ok: false, reason: 'stale' };
|
|
73
|
+
if (!res.ok) return { ok: false, reason: 'error' };
|
|
74
|
+
return { ok: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized }) {
|
|
78
|
+
document.querySelectorAll('[data-caret-md]').forEach((el) => {
|
|
79
|
+
if (!(el instanceof HTMLElement)) return;
|
|
80
|
+
if (el.dataset.caretMdMounted === 'true') return;
|
|
81
|
+
|
|
82
|
+
const binding = parseMdBinding(el.getAttribute('data-caret-md'));
|
|
83
|
+
const src = el.getAttribute('data-caret-md-src') || '';
|
|
84
|
+
if (!binding || !src) return;
|
|
85
|
+
el.dataset.caretMdMounted = 'true';
|
|
86
|
+
|
|
87
|
+
const ctx = blockContextOf(el);
|
|
88
|
+
|
|
89
|
+
el.setAttribute('contenteditable', 'true');
|
|
90
|
+
el.classList.add('cms-editable', 'cms-md-block');
|
|
91
|
+
|
|
92
|
+
el.addEventListener('keydown', (event) => {
|
|
93
|
+
if (event.key !== 'Enter') return;
|
|
94
|
+
if (event.shiftKey && ctx.block === 'paragraph' && !ctx.nested) {
|
|
95
|
+
return; // hard break in a top-level paragraph — allowed
|
|
96
|
+
}
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
showToast(
|
|
99
|
+
event.shiftKey
|
|
100
|
+
? 'Line breaks are not supported in this block'
|
|
101
|
+
: 'Adding new blocks is coming in a future version',
|
|
102
|
+
'error',
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
el.addEventListener('focus', () => {
|
|
107
|
+
snapshots.set(el, el.innerHTML);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
el.addEventListener('input', () => {
|
|
111
|
+
if (el.innerHTML !== snapshots.get(el)) state.dirtyEls.add(el);
|
|
112
|
+
else state.dirtyEls.delete(el);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
el.addEventListener('blur', async () => {
|
|
116
|
+
if (el._caretSkipSave) {
|
|
117
|
+
el._caretSkipSave = false;
|
|
118
|
+
state.dirtyEls.delete(el);
|
|
119
|
+
el.innerHTML = snapshots.get(el) || '';
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const snapshot = snapshots.get(el) ?? '';
|
|
124
|
+
const clean = sanitizeHtml(el.innerHTML);
|
|
125
|
+
el.innerHTML = clean; // normalize what the user sees to what will save
|
|
126
|
+
if (clean === snapshot) return;
|
|
127
|
+
|
|
128
|
+
// Pre-flight the serialization locally so unrepresentable content fails
|
|
129
|
+
// with an immediate message instead of a server round-trip. The server
|
|
130
|
+
// re-derives independently — this is UX, not the trust boundary.
|
|
131
|
+
try {
|
|
132
|
+
serializeBlock(domToSNodes(el), ctx);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof SerializeError) {
|
|
135
|
+
showToast('This formatting cannot be saved in a markdown block', 'error');
|
|
136
|
+
state.dirtyEls.add(el);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const generation = (generations.get(el) ?? 0) + 1;
|
|
143
|
+
generations.set(el, generation);
|
|
144
|
+
|
|
145
|
+
el.classList.add('cms-saving');
|
|
146
|
+
const result = await enqueueSave(() => {
|
|
147
|
+
// A newer blur queued behind us supersedes this content — skip so the
|
|
148
|
+
// older payload can't overwrite the newer draft.
|
|
149
|
+
if (generations.get(el) !== generation) return { ok: true, skipped: true };
|
|
150
|
+
return saveMdBlock(binding, src, clean);
|
|
151
|
+
});
|
|
152
|
+
el.classList.remove('cms-saving');
|
|
153
|
+
if (result.skipped) return;
|
|
154
|
+
flash(el, result.ok);
|
|
155
|
+
|
|
156
|
+
if (result.ok) {
|
|
157
|
+
state.dirtyEls.delete(el);
|
|
158
|
+
snapshots.set(el, clean);
|
|
159
|
+
showToast('Draft saved — publish to update the file', 'success');
|
|
160
|
+
// Signal the toolbar so server-delivery mode reveals its Publish/Discard
|
|
161
|
+
// controls (hidden until a body draft is pending).
|
|
162
|
+
window.dispatchEvent(new CustomEvent('cms:draftSaved'));
|
|
163
|
+
} else if (result.reason === 'stale') {
|
|
164
|
+
state.dirtyEls.delete(el);
|
|
165
|
+
el.innerHTML = snapshot;
|
|
166
|
+
showToast('This content changed since the page loaded. Reload to edit it.', 'error');
|
|
167
|
+
} else if (result.reason === 'unauthorized') {
|
|
168
|
+
onUnauthorized();
|
|
169
|
+
} else {
|
|
170
|
+
state.dirtyEls.delete(el);
|
|
171
|
+
el.innerHTML = snapshot;
|
|
172
|
+
showToast('Failed to save. Changes reverted.', 'error');
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM → markdown block serializer (browser side).
|
|
3
|
+
*
|
|
4
|
+
* NOTE: `serializeBlock` and its helpers below are a HAND MIRROR of
|
|
5
|
+
* src/markdown/serialize.ts (this file ships as raw unbundled JS and can't
|
|
6
|
+
* import it). tests/unit/md-serialize-parity.test.ts runs both implementations
|
|
7
|
+
* over a shared corpus of node trees and fails CI on any divergence.
|
|
8
|
+
*
|
|
9
|
+
* `domToSNodes` is the only DOM-touching part: it converts a block element's
|
|
10
|
+
* child nodes into the same minimal tree the TS serializer is tested against,
|
|
11
|
+
* so the serialization logic itself stays DOM-free and parity-checkable.
|
|
12
|
+
*
|
|
13
|
+
* @typedef {{ type: 'text', value: string }
|
|
14
|
+
* | { type: 'element', tag: string, attrs: Record<string,string>, children: SNode[] }} SNode
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class SerializeError extends Error {
|
|
18
|
+
/** @param {string} message */
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'SerializeError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const INLINE_ESCAPE_RE = /[\\`*_[\]<&>~]/g;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} value
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function escapeText(value) {
|
|
32
|
+
// Collapse ASCII whitespace runs to a single space FIRST — matches HTML
|
|
33
|
+
// rendering and makes <br> the serializer's only newline source. nbsp is
|
|
34
|
+
// deliberately NOT collapsed.
|
|
35
|
+
return value.replace(/[ \t\r\n]+/g, ' ').replace(INLINE_ESCAPE_RE, (c) => `\\${c}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} md
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function escapeLineStart(md) {
|
|
43
|
+
const ws = /^\s*/.exec(md)[0];
|
|
44
|
+
const rest = md.slice(ws.length);
|
|
45
|
+
|
|
46
|
+
const ordered = /^(\d{1,9})([.)])(\s|$)/.exec(rest);
|
|
47
|
+
if (ordered) {
|
|
48
|
+
return `${ws}${ordered[1]}\\${ordered[2]}${rest.slice(ordered[1].length + 1)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Dash/equals runs cover setext underlines (ONE-or-more `-`/`=`) and
|
|
52
|
+
// thematic breaks with interior spaces; `*`/`_` variants are already
|
|
53
|
+
// neutralized by escapeText.
|
|
54
|
+
const marker =
|
|
55
|
+
/^(?:#{1,6}(?=\s|$)|>|[-+*](?=\s|$)|-(?:[ \t]*-)*[ \t]*$|=(?:[ \t]*=)*[ \t]*$|~{3,}|`{3,}|\|)/.test(
|
|
56
|
+
rest,
|
|
57
|
+
);
|
|
58
|
+
return marker ? `${ws}\\${rest}` : md;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {Extract<SNode, { type: 'element' }>} node
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function tagOf(node) {
|
|
66
|
+
const t = node.tag.toLowerCase();
|
|
67
|
+
if (t === 'b') return 'strong';
|
|
68
|
+
if (t === 'i') return 'em';
|
|
69
|
+
return t;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {readonly SNode[]} nodes
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function rawText(nodes) {
|
|
77
|
+
let out = '';
|
|
78
|
+
for (const n of nodes) {
|
|
79
|
+
if (n.type === 'text') out += n.value;
|
|
80
|
+
else if (n.tag.toLowerCase() === 'br') out += ' ';
|
|
81
|
+
else out += rawText(n.children);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} content
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function codeSpan(content) {
|
|
91
|
+
if (content === '') return '``';
|
|
92
|
+
const runs = content.match(/`+/g) ?? [];
|
|
93
|
+
let fenceLen = 1;
|
|
94
|
+
while (runs.some((r) => r.length === fenceLen)) fenceLen++;
|
|
95
|
+
const fence = '`'.repeat(fenceLen);
|
|
96
|
+
const needsPad =
|
|
97
|
+
content.startsWith('`') ||
|
|
98
|
+
content.endsWith('`') ||
|
|
99
|
+
(content.startsWith(' ') && content.endsWith(' ') && content.trim() !== '');
|
|
100
|
+
return needsPad ? `${fence} ${content} ${fence}` : `${fence}${content}${fence}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {string} href
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function linkDestination(href) {
|
|
108
|
+
// Control chars (incl. \n, \r) have no representable escape in either
|
|
109
|
+
// destination form — hard-fail rather than inject markdown structure.
|
|
110
|
+
// eslint-disable-next-line no-control-regex
|
|
111
|
+
if (/[\u0000-\u001f\u007f]/u.test(href)) {
|
|
112
|
+
throw new SerializeError('control character in link destination');
|
|
113
|
+
}
|
|
114
|
+
if (/^[^\s<>()\\]*$/.test(href)) return href;
|
|
115
|
+
return `<${href.replace(/([<>\\])/g, '\\$1')}>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Record<string,string>} a
|
|
120
|
+
* @param {Record<string,string>} b
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
function sameAttrs(a, b) {
|
|
124
|
+
const ka = Object.keys(a);
|
|
125
|
+
const kb = Object.keys(b);
|
|
126
|
+
return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {readonly SNode[]} nodes
|
|
131
|
+
* @returns {SNode[]}
|
|
132
|
+
*/
|
|
133
|
+
function mergeAdjacent(nodes) {
|
|
134
|
+
/** @type {SNode[]} */
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const node of nodes) {
|
|
137
|
+
const prev = out[out.length - 1];
|
|
138
|
+
if (node.type === 'text' && prev?.type === 'text') {
|
|
139
|
+
prev.value += node.value;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
node.type === 'element' &&
|
|
144
|
+
prev?.type === 'element' &&
|
|
145
|
+
tagOf(prev) === tagOf(node) &&
|
|
146
|
+
tagOf(node) !== 'br' &&
|
|
147
|
+
sameAttrs(prev.attrs, node.attrs)
|
|
148
|
+
) {
|
|
149
|
+
prev.children = [...prev.children, ...node.children];
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
out.push(node.type === 'text' ? { ...node } : { ...node, children: [...node.children] });
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {readonly SNode[]} nodes
|
|
159
|
+
* @param {number} i
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
function nextLeadChar(nodes, i) {
|
|
163
|
+
const next = nodes[i + 1];
|
|
164
|
+
if (!next) return '';
|
|
165
|
+
if (next.type === 'text') return next.value[0] ?? '';
|
|
166
|
+
return '<';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {readonly SNode[]} nodes
|
|
171
|
+
* @param {number} i
|
|
172
|
+
* @param {'*' | '_' | null} prevMarker
|
|
173
|
+
* @returns {'*' | '_'}
|
|
174
|
+
*/
|
|
175
|
+
function emphasisMarker(nodes, i, prevMarker) {
|
|
176
|
+
if (prevMarker === null) return '*';
|
|
177
|
+
const flipped = prevMarker === '*' ? '_' : '*';
|
|
178
|
+
if (flipped === '_' && /[A-Za-z0-9_]/.test(nextLeadChar(nodes, i))) return '*';
|
|
179
|
+
return flipped;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {readonly SNode[]} rawNodes
|
|
184
|
+
* @param {{ allowBreak: boolean }} opts
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
function serializeChildren(rawNodes, opts) {
|
|
188
|
+
const nodes = mergeAdjacent(rawNodes);
|
|
189
|
+
let out = '';
|
|
190
|
+
/** @type {'*' | '_' | null} */
|
|
191
|
+
let prevMarker = null;
|
|
192
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
193
|
+
const node = nodes[i];
|
|
194
|
+
if (node.type === 'text') {
|
|
195
|
+
out += escapeText(node.value);
|
|
196
|
+
prevMarker = null;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const tag = tagOf(node);
|
|
200
|
+
switch (tag) {
|
|
201
|
+
case 'strong': {
|
|
202
|
+
const inner = serializeChildren(node.children, opts);
|
|
203
|
+
if (inner === '') {
|
|
204
|
+
prevMarker = null;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
const m = emphasisMarker(nodes, i, prevMarker);
|
|
208
|
+
out += `${m}${m}${inner}${m}${m}`;
|
|
209
|
+
prevMarker = m;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'em': {
|
|
213
|
+
const inner = serializeChildren(node.children, opts);
|
|
214
|
+
if (inner === '') {
|
|
215
|
+
prevMarker = null;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
const m = emphasisMarker(nodes, i, prevMarker);
|
|
219
|
+
out += `${m}${inner}${m}`;
|
|
220
|
+
prevMarker = m;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case 'code': {
|
|
224
|
+
prevMarker = null;
|
|
225
|
+
out += codeSpan(rawText(node.children));
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'a': {
|
|
229
|
+
const href = node.attrs.href;
|
|
230
|
+
if (href === undefined) throw new SerializeError('<a> without href');
|
|
231
|
+
prevMarker = null;
|
|
232
|
+
const inner = serializeChildren(node.children, opts);
|
|
233
|
+
out += `[${inner}](${linkDestination(href)})`;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'br': {
|
|
237
|
+
if (!opts.allowBreak) {
|
|
238
|
+
throw new SerializeError('hard break not allowed in a nested block');
|
|
239
|
+
}
|
|
240
|
+
prevMarker = null;
|
|
241
|
+
out += '\\\n';
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
throw new SerializeError(`unsupported inline element <${node.tag}>`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Serialize the inline children of one block to markdown source.
|
|
253
|
+
* @param {readonly SNode[]} children
|
|
254
|
+
* @param {{ block: 'heading', level: number } | { block: 'paragraph', nested: boolean }} ctx
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
export function serializeBlock(children, ctx) {
|
|
258
|
+
if (ctx.block === 'heading') {
|
|
259
|
+
if (!Number.isInteger(ctx.level) || ctx.level < 1 || ctx.level > 6) {
|
|
260
|
+
throw new SerializeError(`invalid heading level: ${ctx.level}`);
|
|
261
|
+
}
|
|
262
|
+
const inline = serializeChildren(children, { allowBreak: false });
|
|
263
|
+
return `${'#'.repeat(ctx.level)} ${inline}`;
|
|
264
|
+
}
|
|
265
|
+
const inline = serializeChildren(children, { allowBreak: !ctx.nested });
|
|
266
|
+
// Every line (hard breaks are the only line boundary) must be prevented
|
|
267
|
+
// from opening a new block — CommonMark's block scanner runs before inline
|
|
268
|
+
// parsing sees the trailing backslash.
|
|
269
|
+
return inline.split('\\\n').map(escapeLineStart).join('\\\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Convert a block element's DOM children into the minimal SNode tree the
|
|
274
|
+
* serializer consumes. Only `href` is carried (the sole attribute the closed
|
|
275
|
+
* set represents); every other element passes through so the serializer can
|
|
276
|
+
* reject anything the sanitizer failed to strip.
|
|
277
|
+
* @param {Node} element
|
|
278
|
+
* @returns {SNode[]}
|
|
279
|
+
*/
|
|
280
|
+
export function domToSNodes(element) {
|
|
281
|
+
/** @type {SNode[]} */
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const child of element.childNodes) {
|
|
284
|
+
if (child.nodeType === 3 /* TEXT_NODE */) {
|
|
285
|
+
out.push({ type: 'text', value: /** @type {Text} */ (child).data });
|
|
286
|
+
} else if (child.nodeType === 1 /* ELEMENT_NODE */) {
|
|
287
|
+
const elem = /** @type {Element} */ (child);
|
|
288
|
+
/** @type {Record<string,string>} */
|
|
289
|
+
const attrs = {};
|
|
290
|
+
const href = elem.getAttribute('href');
|
|
291
|
+
if (href !== null) attrs.href = href;
|
|
292
|
+
out.push({
|
|
293
|
+
type: 'element',
|
|
294
|
+
tag: elem.tagName.toLowerCase(),
|
|
295
|
+
attrs,
|
|
296
|
+
children: domToSNodes(elem),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|