@caretcms/core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -12
- package/dist/index.d.ts +26 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +113 -12
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +35 -3
- package/dist/loader.js.map +1 -1
- package/dist/markdown/paragraphs.d.ts +14 -0
- package/dist/markdown/paragraphs.d.ts.map +1 -0
- package/dist/markdown/paragraphs.js +61 -0
- package/dist/markdown/paragraphs.js.map +1 -0
- package/dist/markdown/remark.d.ts.map +1 -1
- package/dist/markdown/remark.js +1 -0
- package/dist/markdown/remark.js.map +1 -1
- package/dist/markdown/satteri.d.ts.map +1 -1
- package/dist/markdown/satteri.js +35 -9
- package/dist/markdown/satteri.js.map +1 -1
- package/dist/markdown/stamp.d.ts +0 -9
- package/dist/markdown/stamp.d.ts.map +1 -1
- package/dist/markdown/stamp.js +6 -0
- package/dist/markdown/stamp.js.map +1 -1
- package/dist/providers/deployment/github.d.ts +27 -0
- package/dist/providers/deployment/github.d.ts.map +1 -0
- package/dist/providers/deployment/github.js +180 -0
- package/dist/providers/deployment/github.js.map +1 -0
- package/dist/providers/deployment/simulated.d.ts +10 -0
- package/dist/providers/deployment/simulated.d.ts.map +1 -0
- package/dist/providers/deployment/simulated.js +29 -0
- package/dist/providers/deployment/simulated.js.map +1 -0
- package/dist/runtime/auth/session.d.ts +1 -0
- package/dist/runtime/auth/session.d.ts.map +1 -1
- package/dist/runtime/auth/session.js +16 -0
- package/dist/runtime/auth/session.js.map +1 -1
- package/dist/runtime/authorization.d.ts +11 -0
- package/dist/runtime/authorization.d.ts.map +1 -0
- package/dist/runtime/authorization.js +29 -0
- package/dist/runtime/authorization.js.map +1 -0
- package/dist/runtime/collection-policy.d.ts +11 -0
- package/dist/runtime/collection-policy.d.ts.map +1 -0
- package/dist/runtime/collection-policy.js +44 -0
- package/dist/runtime/collection-policy.js.map +1 -0
- package/dist/runtime/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-errors.d.ts +9 -0
- package/dist/runtime/content-errors.d.ts.map +1 -0
- package/dist/runtime/content-errors.js +17 -0
- package/dist/runtime/content-errors.js.map +1 -0
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +13 -4
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/deployment-status.d.ts +14 -0
- package/dist/runtime/deployment-status.d.ts.map +1 -0
- package/dist/runtime/deployment-status.js +57 -0
- package/dist/runtime/deployment-status.js.map +1 -0
- package/dist/runtime/draft-state.d.ts +16 -0
- package/dist/runtime/draft-state.d.ts.map +1 -0
- package/dist/runtime/draft-state.js +29 -0
- package/dist/runtime/draft-state.js.map +1 -0
- package/dist/runtime/i18n.d.ts +97 -0
- package/dist/runtime/i18n.d.ts.map +1 -0
- package/dist/runtime/i18n.js +190 -0
- package/dist/runtime/i18n.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +61 -4
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +12 -0
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +54 -4
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +2 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +220 -57
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +4 -1
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +18 -1
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish-recovery.d.ts +32 -0
- package/dist/runtime/publish-recovery.d.ts.map +1 -0
- package/dist/runtime/publish-recovery.js +98 -0
- package/dist/runtime/publish-recovery.js.map +1 -0
- package/dist/runtime/publish.d.ts +10 -4
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +105 -63
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/rebuild-webhook.d.ts +4 -0
- package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
- package/dist/runtime/rebuild-webhook.js +12 -2
- package/dist/runtime/rebuild-webhook.js.map +1 -1
- package/dist/runtime/request-context.d.ts +7 -1
- 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 +28 -2
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.js +1 -1
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
- package/dist/runtime/routes/admin-entry.js +129 -21
- 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 +28 -14
- 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 +10 -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 +213 -66
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/deployment.d.ts +5 -0
- package/dist/runtime/routes/deployment.d.ts.map +1 -0
- package/dist/runtime/routes/deployment.js +38 -0
- package/dist/runtime/routes/deployment.js.map +1 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -1
- package/dist/runtime/routes/draft.js +10 -3
- package/dist/runtime/routes/draft.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +87 -43
- 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 +40 -21
- package/dist/runtime/routes/history.js.map +1 -1
- package/dist/runtime/routes/mutate.d.ts.map +1 -1
- package/dist/runtime/routes/mutate.js +4 -1
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts.map +1 -1
- package/dist/runtime/routes/publish.js +127 -33
- package/dist/runtime/routes/publish.js.map +1 -1
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +11 -2
- 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 +41 -20
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +3 -0
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/schema-registry.d.ts +8 -1
- package/dist/runtime/schema-registry.d.ts.map +1 -1
- package/dist/runtime/schema-registry.js +49 -3
- package/dist/runtime/schema-registry.js.map +1 -1
- package/dist/runtime/storage/entry-commit.d.ts +8 -0
- package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
- package/dist/runtime/storage/entry-commit.js +45 -0
- package/dist/runtime/storage/entry-commit.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/filesystem-adapter.js +12 -3
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
- package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.js +156 -13
- package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.js +6 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +18 -20
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +100 -13
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
- package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.js +48 -5
- package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
- package/dist/runtime/themes/preset-tokens.js +4 -4
- package/dist/runtime/themes/preset-tokens.js.map +1 -1
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +3 -4
- package/dist/runtime/utils.js.map +1 -1
- package/dist/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 +165 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -3
- package/static/cms/admin-entry.css +144 -13
- package/static/cms/admin-entry.js +539 -586
- package/static/cms/dev-toolbar/app.js +78 -19
- package/static/cms/editor/config.js +35 -15
- package/static/cms/editor/content-map.css +8 -1
- package/static/cms/editor/content-map.js +62 -23
- package/static/cms/editor/deployment-status.js +66 -0
- package/static/cms/editor/draft-client.js +87 -0
- package/static/cms/editor/guards.js +20 -0
- package/static/cms/editor/helpers.js +30 -1
- package/static/cms/editor/highlight.js +1 -0
- package/static/cms/editor/image-edit.js +44 -1
- package/static/cms/editor/image-utils.js +23 -7
- package/static/cms/editor/link-follow.js +122 -41
- package/static/cms/editor/link-ui.css +10 -1
- package/static/cms/editor/linkify.js +2 -0
- package/static/cms/editor/md-block-edit.js +140 -37
- package/static/cms/editor/md-paragraphs.js +98 -0
- package/static/cms/editor/md-serialize.js +1 -1
- package/static/cms/editor/panel.js +53 -7
- package/static/cms/editor/preview-mode.js +44 -0
- package/static/cms/editor/publish-result.js +100 -0
- package/static/cms/editor/rich-toolbar.js +58 -15
- package/static/cms/editor/sanitize.js +6 -2
- package/static/cms/editor/save-queue.js +75 -17
- package/static/cms/editor/section-controls/activation.js +3 -0
- package/static/cms/editor/section-controls/api.js +25 -7
- package/static/cms/editor/section-controls/index.js +60 -8
- package/static/cms/editor/section-controls/model.js +39 -4
- package/static/cms/editor/section-controls/mutations.js +15 -0
- package/static/cms/editor/section-controls/page-context.js +9 -0
- package/static/cms/editor/section-controls/pickers.js +53 -2
- package/static/cms/editor/section-controls/reorder.js +23 -0
- package/static/cms/editor/section-controls/selection.js +6 -0
- package/static/cms/editor/section-controls/spacing-drag.js +16 -0
- package/static/cms/editor/section-controls/ui-elements.js +7 -0
- package/static/cms/editor/section-controls/utils.js +7 -0
- package/static/cms/editor/section-controls.css +18 -3
- package/static/cms/editor/security.js +13 -2
- package/static/cms/editor/stega-hydrate.js +17 -4
- package/static/cms/editor/sync.js +475 -56
- package/static/cms/editor/text-edit.js +37 -3
- package/static/cms/editor/text-link-interactions.js +59 -17
- package/static/cms/editor/toast.js +23 -9
- package/static/cms/editor/tokens-and-text.css +9 -0
- package/static/cms/editor/toolbar.css +5 -0
- package/static/cms/editor/toolbar.js +91 -101
- package/static/cms/editor.css +1 -1
- package/static/cms/editor.js +1 -0
- package/static/cms/studio/entry-loader.js +102 -0
- package/static/cms/studio/field-model.js +110 -0
- package/static/cms/studio/fields.js +319 -0
- package/static/cms/studio/history-client.js +74 -0
- package/static/cms/studio/mutation-client.js +74 -0
- package/static/cms/studio/sync-client.js +107 -0
- package/static/cms/studio/upload-client.js +109 -0
- package/static/cms/studio-tokens.css +4 -4
- package/static/cms/studio.css +24 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mountParagraphGroups, paragraphHtml, pasteParagraphText } from './md-paragraphs.js';
|
|
1
2
|
/**
|
|
2
3
|
* Inline editing for markdown body blocks (`data-caret-md`).
|
|
3
4
|
*
|
|
@@ -20,6 +21,15 @@ import { sanitizeHtml } from './sanitize.js';
|
|
|
20
21
|
import { buildCmsUrl } from './config.js';
|
|
21
22
|
import { mutateHeaders } from './security.js';
|
|
22
23
|
|
|
24
|
+
/** @typedef {HTMLElement & { _caretSkipSave?: boolean }} MdEditableElement */
|
|
25
|
+
/** @typedef {{ collection: string, id: string, blockPath: string }} MdBinding */
|
|
26
|
+
/** @typedef {{ blockPath: string, src: string, html: string }} ParagraphSource */
|
|
27
|
+
/** @typedef {{ sources: ParagraphSource[], paragraphs: string[] }} ParagraphGroup */
|
|
28
|
+
/** @typedef {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} MdEditorState */
|
|
29
|
+
/** @typedef {{ ok: true, skipped?: boolean } | { ok: false, reason: 'unauthorized' | 'stale' | 'error' }} MdSaveResult */
|
|
30
|
+
/** @typedef {{ block: 'heading', level: number } | { block: 'paragraph', nested: boolean }} MdBlockContext */
|
|
31
|
+
|
|
32
|
+
/** @type {WeakMap<HTMLElement, string>} */
|
|
23
33
|
const snapshots = new WeakMap();
|
|
24
34
|
|
|
25
35
|
// All md-block saves are chained through one queue (same pattern as
|
|
@@ -27,9 +37,12 @@ const snapshots = new WeakMap();
|
|
|
27
37
|
// the network and land out of order — both return ok, and the older content
|
|
28
38
|
// silently wins. A per-element generation counter additionally drops a queued
|
|
29
39
|
// save that a newer blur has superseded.
|
|
40
|
+
/** @type {Promise<unknown>} */
|
|
30
41
|
let saveChain = Promise.resolve();
|
|
42
|
+
/** @type {WeakMap<HTMLElement, number>} */
|
|
31
43
|
const generations = new WeakMap();
|
|
32
44
|
|
|
45
|
+
/** @param {() => Promise<MdSaveResult> | MdSaveResult} task @returns {Promise<MdSaveResult>} */
|
|
33
46
|
function enqueueSave(task) {
|
|
34
47
|
const run = saveChain.then(task, task);
|
|
35
48
|
saveChain = run.then(
|
|
@@ -40,14 +53,52 @@ function enqueueSave(task) {
|
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
/** Parse `collection::id::body::blockPath`, or null. */
|
|
56
|
+
/** @param {string | null} raw @returns {MdBinding | null} */
|
|
43
57
|
function parseMdBinding(raw) {
|
|
44
58
|
const parts = (raw || '').split('::');
|
|
45
59
|
if (parts.length !== 4 || parts[2] !== 'body') return null;
|
|
60
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(parts[0]) || !/^[a-z0-9][a-z0-9_-]*$/.test(parts[1])) return null;
|
|
46
61
|
if (!/^\d+(\.\d+)*$/.test(parts[3])) return null;
|
|
47
62
|
return { collection: parts[0], id: parts[1], blockPath: parts[3] };
|
|
48
63
|
}
|
|
49
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Parse the server-authored structural paragraph source list. Attribute data
|
|
67
|
+
* is still a browser input boundary: extensions or page scripts can alter it,
|
|
68
|
+
* so malformed data must leave the group read-only instead of crashing mount
|
|
69
|
+
* or being echoed into a mutation.
|
|
70
|
+
* @param {string | null} raw
|
|
71
|
+
* @returns {ParagraphSource[] | null}
|
|
72
|
+
*/
|
|
73
|
+
export function parseParagraphSources(raw) {
|
|
74
|
+
if (!raw) return null;
|
|
75
|
+
try {
|
|
76
|
+
const value = JSON.parse(raw);
|
|
77
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 128) return null;
|
|
78
|
+
/** @type {ParagraphSource[]} */
|
|
79
|
+
const sources = [];
|
|
80
|
+
for (const source of value) {
|
|
81
|
+
if (typeof source !== 'object' || source === null ||
|
|
82
|
+
typeof source.blockPath !== 'string' || !/^\d+$/.test(source.blockPath) ||
|
|
83
|
+
typeof source.src !== 'string' || !/^\d+:\d+:[0-9a-f]{8}$/.test(source.src) ||
|
|
84
|
+
typeof source.html !== 'string') return null;
|
|
85
|
+
const [start, end] = source.src.split(':').map(Number);
|
|
86
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= end) return null;
|
|
87
|
+
const previous = sources[sources.length - 1];
|
|
88
|
+
if (previous) {
|
|
89
|
+
const previousEnd = Number(previous.src.split(':')[1]);
|
|
90
|
+
if (Number(source.blockPath) !== Number(previous.blockPath) + 1 || start <= previousEnd) return null;
|
|
91
|
+
}
|
|
92
|
+
sources.push({ blockPath: source.blockPath, src: source.src, html: source.html });
|
|
93
|
+
}
|
|
94
|
+
return sources;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
/** Serialization context from the DOM — mirrors the server's derivation. */
|
|
101
|
+
/** @param {HTMLElement} el @returns {MdBlockContext} */
|
|
51
102
|
function blockContextOf(el) {
|
|
52
103
|
const heading = /^h([1-6])$/.exec(el.tagName.toLowerCase());
|
|
53
104
|
if (heading) return { block: 'heading', level: Number(heading[1]) };
|
|
@@ -55,26 +106,46 @@ function blockContextOf(el) {
|
|
|
55
106
|
return { block: 'paragraph', nested };
|
|
56
107
|
}
|
|
57
108
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
109
|
+
/**
|
|
110
|
+
* @param {MdBinding} binding
|
|
111
|
+
* @param {string} src
|
|
112
|
+
* @param {string} html
|
|
113
|
+
* @param {ParagraphGroup | undefined} group
|
|
114
|
+
* @returns {Promise<MdSaveResult>}
|
|
115
|
+
*/
|
|
116
|
+
async function saveMdBlock(binding, src, html, group) {
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(buildCmsUrl('/mutate'), {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: await mutateHeaders(),
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
type: 'md_block',
|
|
123
|
+
collection: binding.collection,
|
|
124
|
+
id: binding.id,
|
|
125
|
+
blockPath: binding.blockPath,
|
|
126
|
+
src,
|
|
127
|
+
html,
|
|
128
|
+
...group,
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
if (res.status === 401) return { ok: false, reason: 'unauthorized' };
|
|
132
|
+
if (res.status === 409) return { ok: false, reason: 'stale' };
|
|
133
|
+
if (!res.ok) return { ok: false, reason: 'error' };
|
|
134
|
+
return { ok: true };
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, reason: 'error' };
|
|
137
|
+
}
|
|
75
138
|
}
|
|
76
139
|
|
|
140
|
+
/**
|
|
141
|
+
* @param {object} options
|
|
142
|
+
* @param {MdEditorState} options.state
|
|
143
|
+
* @param {(element: Element, success: boolean) => void} options.flash
|
|
144
|
+
* @param {(message: string, kind: 'success' | 'error') => unknown} options.showToast
|
|
145
|
+
* @param {() => void} options.onUnauthorized
|
|
146
|
+
*/
|
|
77
147
|
export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized }) {
|
|
148
|
+
mountParagraphGroups();
|
|
78
149
|
document.querySelectorAll('[data-caret-md]').forEach((el) => {
|
|
79
150
|
if (!(el instanceof HTMLElement)) return;
|
|
80
151
|
if (el.dataset.caretMdMounted === 'true') return;
|
|
@@ -82,15 +153,26 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
|
|
|
82
153
|
const binding = parseMdBinding(el.getAttribute('data-caret-md'));
|
|
83
154
|
const src = el.getAttribute('data-caret-md-src') || '';
|
|
84
155
|
if (!binding || !src) return;
|
|
85
|
-
el.dataset.caretMdMounted = 'true';
|
|
86
|
-
|
|
87
156
|
const ctx = blockContextOf(el);
|
|
157
|
+
const sourcesRaw = el.getAttribute('data-caret-md-sources');
|
|
158
|
+
const sources = sourcesRaw === null ? null : parseParagraphSources(sourcesRaw);
|
|
159
|
+
// A structural group with missing or malformed source metadata cannot be
|
|
160
|
+
// saved safely as one block. Keep it visible but do not make it editable.
|
|
161
|
+
if (sourcesRaw !== null && sources === null) return;
|
|
162
|
+
if (sources && (sources[0].blockPath !== binding.blockPath || sources[0].src !== src)) return;
|
|
163
|
+
el.dataset.caretMdMounted = 'true';
|
|
164
|
+
if (sources) {
|
|
165
|
+
el.classList.add('cms-md-paragraphs');
|
|
166
|
+
el.addEventListener('paste', pasteParagraphText);
|
|
167
|
+
// A drop can import arbitrary block structure outside the paste path.
|
|
168
|
+
el.addEventListener('drop', event => event.preventDefault());
|
|
169
|
+
}
|
|
88
170
|
|
|
89
171
|
el.setAttribute('contenteditable', 'true');
|
|
90
172
|
el.classList.add('cms-editable', 'cms-md-block');
|
|
91
173
|
|
|
92
174
|
el.addEventListener('keydown', (event) => {
|
|
93
|
-
if (event.key !== 'Enter') return;
|
|
175
|
+
if (sources || event.key !== 'Enter') return;
|
|
94
176
|
if (event.shiftKey && ctx.block === 'paragraph' && !ctx.nested) {
|
|
95
177
|
return; // hard break in a top-level paragraph — allowed
|
|
96
178
|
}
|
|
@@ -98,11 +180,20 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
|
|
|
98
180
|
showToast(
|
|
99
181
|
event.shiftKey
|
|
100
182
|
? 'Line breaks are not supported in this block'
|
|
101
|
-
: '
|
|
183
|
+
: 'Paragraph changes are not supported in this block',
|
|
102
184
|
'error',
|
|
103
185
|
);
|
|
104
186
|
});
|
|
105
187
|
|
|
188
|
+
el.addEventListener('beforeinput', (rawEvent) => {
|
|
189
|
+
const event = /** @type {InputEvent} */ (rawEvent);
|
|
190
|
+
if (!sources && (event.inputType === 'insertParagraph' ||
|
|
191
|
+
(event.inputType === 'insertLineBreak' && (ctx.block !== 'paragraph' || ctx.nested)))) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
showToast('Paragraph changes are not supported in this block', 'error');
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
106
197
|
el.addEventListener('focus', () => {
|
|
107
198
|
snapshots.set(el, el.innerHTML);
|
|
108
199
|
});
|
|
@@ -113,23 +204,34 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
|
|
|
113
204
|
});
|
|
114
205
|
|
|
115
206
|
el.addEventListener('blur', async () => {
|
|
116
|
-
if (el
|
|
117
|
-
|
|
207
|
+
if (state.linkPopupEl === el) return;
|
|
208
|
+
|
|
209
|
+
const editableEl = /** @type {MdEditableElement} */ (el);
|
|
210
|
+
if (editableEl._caretSkipSave) {
|
|
211
|
+
editableEl._caretSkipSave = false;
|
|
118
212
|
state.dirtyEls.delete(el);
|
|
119
213
|
el.innerHTML = snapshots.get(el) || '';
|
|
120
214
|
return;
|
|
121
215
|
}
|
|
122
216
|
|
|
123
217
|
const snapshot = snapshots.get(el) ?? '';
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
218
|
+
const submitted = el.innerHTML;
|
|
219
|
+
const paragraphs = sources ? paragraphHtml(el) : null;
|
|
220
|
+
const clean = paragraphs ? paragraphs.map(p => `<p>${p || '<br>'}</p>`).join('') : sanitizeHtml(submitted);
|
|
221
|
+
if (!sources) el.innerHTML = clean;
|
|
222
|
+
if (submitted === snapshot && !state.dirtyEls.has(el)) return;
|
|
127
223
|
|
|
128
224
|
// Pre-flight the serialization locally so unrepresentable content fails
|
|
129
225
|
// with an immediate message instead of a server round-trip. The server
|
|
130
226
|
// re-derives independently — this is UX, not the trust boundary.
|
|
131
227
|
try {
|
|
132
|
-
|
|
228
|
+
if (paragraphs) {
|
|
229
|
+
for (const html of paragraphs) {
|
|
230
|
+
const paragraph = document.createElement('p');
|
|
231
|
+
paragraph.innerHTML = html;
|
|
232
|
+
serializeBlock(domToSNodes(paragraph), { block: 'paragraph', nested: false });
|
|
233
|
+
}
|
|
234
|
+
} else serializeBlock(domToSNodes(el), ctx);
|
|
133
235
|
} catch (error) {
|
|
134
236
|
if (error instanceof SerializeError) {
|
|
135
237
|
showToast('This formatting cannot be saved in a markdown block', 'error');
|
|
@@ -147,29 +249,30 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
|
|
|
147
249
|
// A newer blur queued behind us supersedes this content — skip so the
|
|
148
250
|
// older payload can't overwrite the newer draft.
|
|
149
251
|
if (generations.get(el) !== generation) return { ok: true, skipped: true };
|
|
150
|
-
|
|
252
|
+
const group = sources && paragraphs ? { sources, paragraphs } : undefined;
|
|
253
|
+
return saveMdBlock(binding, src, sources ? '' : clean, group);
|
|
151
254
|
});
|
|
152
255
|
el.classList.remove('cms-saving');
|
|
153
|
-
if (result.skipped) return;
|
|
256
|
+
if (('skipped' in result && result.skipped) || generations.get(el) !== generation) return;
|
|
154
257
|
flash(el, result.ok);
|
|
155
258
|
|
|
156
259
|
if (result.ok) {
|
|
157
|
-
|
|
158
|
-
|
|
260
|
+
if (el.innerHTML === (sources ? submitted : clean)) {
|
|
261
|
+
state.dirtyEls.delete(el);
|
|
262
|
+
snapshots.set(el, el.innerHTML);
|
|
263
|
+
}
|
|
159
264
|
showToast('Draft saved — publish to update the file', 'success');
|
|
160
265
|
// Signal the toolbar so server-delivery mode reveals its Publish/Discard
|
|
161
266
|
// controls (hidden until a body draft is pending).
|
|
162
267
|
window.dispatchEvent(new CustomEvent('cms:draftSaved'));
|
|
163
268
|
} else if (result.reason === 'stale') {
|
|
164
|
-
state.dirtyEls.
|
|
165
|
-
|
|
166
|
-
showToast('This content changed since the page loaded. Reload to edit it.', 'error');
|
|
269
|
+
state.dirtyEls.add(el);
|
|
270
|
+
showToast('This content changed. Copy your edits, then reload before saving.', 'error');
|
|
167
271
|
} else if (result.reason === 'unauthorized') {
|
|
168
272
|
onUnauthorized();
|
|
169
273
|
} else {
|
|
170
|
-
state.dirtyEls.
|
|
171
|
-
|
|
172
|
-
showToast('Failed to save. Changes reverted.', 'error');
|
|
274
|
+
state.dirtyEls.add(el);
|
|
275
|
+
showToast('Failed to save. Your edits are kept here; focus and leave the block to retry.', 'error');
|
|
173
276
|
}
|
|
174
277
|
});
|
|
175
278
|
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Native contenteditable provides paragraph split/merge, selection deletion,
|
|
2
|
+
* IME and undo. Each host owns only consecutive stamped top-level paragraphs. */
|
|
3
|
+
import { sanitizeHtml } from './sanitize.js';
|
|
4
|
+
|
|
5
|
+
/** @typedef {{ binding: string, prefix: string, blockPath: string, src: string }} ParagraphBinding */
|
|
6
|
+
/** @typedef {{ blockPath: string, src: string, html: string }} ParagraphSource */
|
|
7
|
+
|
|
8
|
+
/** @param {Element | null} element @returns {element is HTMLElement} */
|
|
9
|
+
function isParagraphCandidate(element) {
|
|
10
|
+
return element instanceof HTMLElement && element.matches('p[data-caret-md-paragraph="true"]') &&
|
|
11
|
+
[...element.querySelectorAll('*')].every(child => /^(B|STRONG|I|EM|A|BR|CODE)$/.test(child.tagName));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @param {HTMLElement} element @returns {ParagraphBinding | null} */
|
|
15
|
+
function readParagraphBinding(element) {
|
|
16
|
+
const binding = element.getAttribute('data-caret-md');
|
|
17
|
+
const src = element.getAttribute('data-caret-md-src');
|
|
18
|
+
if (!binding || !src) return null;
|
|
19
|
+
const parts = binding.split('::');
|
|
20
|
+
if (parts.length !== 4 || parts[2] !== 'body' || !/^\d+$/.test(parts[3])) return null;
|
|
21
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(parts[0]) || !/^[a-z0-9][a-z0-9_-]*$/.test(parts[1])) return null;
|
|
22
|
+
if (!/^\d+:\d+:[0-9a-f]{8}$/.test(src)) return null;
|
|
23
|
+
const [start, end] = src.split(':').map(Number);
|
|
24
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= end) return null;
|
|
25
|
+
return { binding, prefix: parts.slice(0, 3).join('::'), blockPath: parts[3], src };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mountParagraphGroups() {
|
|
29
|
+
document.querySelectorAll('p[data-caret-md-paragraph="true"]').forEach(first => {
|
|
30
|
+
if (!isParagraphCandidate(first) || !first.isConnected || first.closest('li, blockquote, [data-caret-md-sources]')) return;
|
|
31
|
+
const firstBinding = readParagraphBinding(first);
|
|
32
|
+
if (!firstBinding) return;
|
|
33
|
+
const firstIndex = Number(firstBinding.blockPath);
|
|
34
|
+
/** @type {ParagraphSource[]} */
|
|
35
|
+
const sources = [];
|
|
36
|
+
/** @type {HTMLElement[]} */
|
|
37
|
+
const paragraphs = [];
|
|
38
|
+
/** @type {HTMLElement | null} */
|
|
39
|
+
let next = first;
|
|
40
|
+
let size = 0;
|
|
41
|
+
while (isParagraphCandidate(next)) {
|
|
42
|
+
const binding = readParagraphBinding(next);
|
|
43
|
+
if (!binding || binding.prefix !== firstBinding.prefix || Number(binding.blockPath) !== firstIndex + sources.length) break;
|
|
44
|
+
const html = next.getAttribute('data-caret-md-original') ?? next.innerHTML;
|
|
45
|
+
if (sources.length >= 128 || size + html.length * 2 > 48 * 1024) break;
|
|
46
|
+
size += html.length * 2;
|
|
47
|
+
sources.push({ blockPath: binding.blockPath, src: binding.src, html });
|
|
48
|
+
paragraphs.push(next);
|
|
49
|
+
// Only whitespace between adjacent elements may be crossed.
|
|
50
|
+
/** @type {ChildNode | null} */
|
|
51
|
+
let sibling = next.nextSibling;
|
|
52
|
+
while (sibling?.nodeType === Node.TEXT_NODE && !(sibling.textContent ?? '').trim()) sibling = sibling.nextSibling;
|
|
53
|
+
next = sibling instanceof HTMLElement ? sibling : null;
|
|
54
|
+
}
|
|
55
|
+
if (!sources.length) return;
|
|
56
|
+
const host = document.createElement('div');
|
|
57
|
+
host.setAttribute('data-caret-md', firstBinding.binding);
|
|
58
|
+
host.setAttribute('data-caret-md-src', sources[0].src);
|
|
59
|
+
host.setAttribute('data-caret-md-sources', JSON.stringify(sources));
|
|
60
|
+
first.before(host);
|
|
61
|
+
for (const paragraph of paragraphs) {
|
|
62
|
+
for (const attr of [...paragraph.attributes]) if (attr.name.startsWith('data-caret-md')) paragraph.removeAttribute(attr.name);
|
|
63
|
+
host.append(paragraph);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Normalize browser p/div wrappers; inline content goes through the existing
|
|
69
|
+
* sanitizer and serializer, independently for each paragraph. */
|
|
70
|
+
/** @param {HTMLElement} root @returns {string[]} */
|
|
71
|
+
export function paragraphHtml(root) {
|
|
72
|
+
/** @type {string[]} */
|
|
73
|
+
const result = [];
|
|
74
|
+
let inline = document.createElement('div');
|
|
75
|
+
const flush = () => {
|
|
76
|
+
if (inline.childNodes.length) { result.push(sanitizeHtml(inline.innerHTML)); inline = document.createElement('div'); }
|
|
77
|
+
};
|
|
78
|
+
for (const node of root.childNodes) {
|
|
79
|
+
if (node instanceof HTMLElement && /^(P|DIV)$/.test(node.tagName)) {
|
|
80
|
+
flush();
|
|
81
|
+
if (node.querySelector('p, div')) result.push(...paragraphHtml(node));
|
|
82
|
+
else result.push(sanitizeHtml(node.innerHTML));
|
|
83
|
+
} else inline.append(node.cloneNode(true));
|
|
84
|
+
}
|
|
85
|
+
flush();
|
|
86
|
+
return result.map(html => /^<br\s*\/?\s*>$/i.test(html) ? '' : html);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {ClipboardEvent} event */
|
|
90
|
+
export function pasteParagraphText(event) {
|
|
91
|
+
if (!event.clipboardData) return;
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
const text = event.clipboardData.getData('text/plain');
|
|
94
|
+
const html = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
95
|
+
.replace(/\r\n?/g, '\n').split(/\n\s*\n/).map((p) => `<p>${p.replace(/\n/g, '<br>') || '<br>'}</p>`).join('');
|
|
96
|
+
// Editing command keeps paste in the browser's undo stack.
|
|
97
|
+
document.execCommand('insertHTML', false, html);
|
|
98
|
+
}
|
|
@@ -40,7 +40,7 @@ function escapeText(value) {
|
|
|
40
40
|
* @returns {string}
|
|
41
41
|
*/
|
|
42
42
|
function escapeLineStart(md) {
|
|
43
|
-
const ws = /^\s*/.exec(md)[0];
|
|
43
|
+
const ws = /^\s*/.exec(md)?.[0] ?? '';
|
|
44
44
|
const rest = md.slice(ws.length);
|
|
45
45
|
|
|
46
46
|
const ordered = /^(\d{1,9})([.)])(\s|$)/.exec(rest);
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { STUDIO_PATH } from './config.js';
|
|
2
2
|
|
|
3
|
+
/** @param {string} key @returns {string | null} */
|
|
4
|
+
function readSessionValue(key) {
|
|
5
|
+
try {
|
|
6
|
+
return sessionStorage.getItem(key);
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** @param {string} key @param {string} value */
|
|
13
|
+
function writeSessionValue(key, value) {
|
|
14
|
+
try {
|
|
15
|
+
sessionStorage.setItem(key, value);
|
|
16
|
+
} catch {
|
|
17
|
+
// Session storage is optional.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {{ studioButton: Element | null }} options */
|
|
3
22
|
export function mountStudioPanel({ studioButton }) {
|
|
4
23
|
if (!STUDIO_PATH) {
|
|
5
24
|
studioButton?.setAttribute('hidden', 'hidden');
|
|
@@ -13,6 +32,10 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
13
32
|
panel.setAttribute('role', 'dialog');
|
|
14
33
|
panel.setAttribute('aria-label', 'Content Studio');
|
|
15
34
|
panel.setAttribute('aria-hidden', 'true');
|
|
35
|
+
panel.id = 'cms-studio-panel';
|
|
36
|
+
studioButton?.setAttribute('aria-controls', panel.id);
|
|
37
|
+
studioButton?.setAttribute('aria-haspopup', 'dialog');
|
|
38
|
+
studioButton?.setAttribute('aria-expanded', 'false');
|
|
16
39
|
|
|
17
40
|
// Loading skeleton shown until the iframe reports load — the iframe is blank
|
|
18
41
|
// until then, which reads as "broken".
|
|
@@ -24,17 +47,36 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
24
47
|
panel.appendChild(loading);
|
|
25
48
|
|
|
26
49
|
const iframe = document.createElement('iframe');
|
|
27
|
-
|
|
50
|
+
let rememberedPath = '';
|
|
51
|
+
const storedPath = readSessionValue('cms-panel-path') || '';
|
|
52
|
+
if (storedPath === STUDIO_PATH || storedPath.startsWith(`${STUDIO_PATH}/`)) {
|
|
53
|
+
rememberedPath = storedPath;
|
|
54
|
+
}
|
|
55
|
+
iframe.src = rememberedPath || STUDIO_PATH;
|
|
28
56
|
iframe.title = 'Content Studio';
|
|
29
57
|
iframe.addEventListener('load', () => {
|
|
30
58
|
loading.setAttribute('hidden', 'hidden');
|
|
59
|
+
try {
|
|
60
|
+
const iframeWindow = iframe.contentWindow;
|
|
61
|
+
if (!iframeWindow) return;
|
|
62
|
+
const currentPath = `${iframeWindow.location.pathname}${iframeWindow.location.search}`;
|
|
63
|
+
if (currentPath === STUDIO_PATH || currentPath.startsWith(`${STUDIO_PATH}/`)) {
|
|
64
|
+
writeSessionValue('cms-panel-path', currentPath);
|
|
65
|
+
}
|
|
66
|
+
iframeWindow.document.addEventListener('keydown', handleEscape);
|
|
67
|
+
} catch {
|
|
68
|
+
// The configured Studio may be cross-origin, in which case its route is
|
|
69
|
+
// intentionally unavailable to the parent page.
|
|
70
|
+
}
|
|
31
71
|
});
|
|
32
72
|
panel.appendChild(iframe);
|
|
33
73
|
document.body.appendChild(panel);
|
|
34
74
|
|
|
35
75
|
// Remember what had focus so we can restore it on close.
|
|
76
|
+
/** @type {HTMLElement | null} */
|
|
36
77
|
let lastFocused = null;
|
|
37
78
|
|
|
79
|
+
/** @param {boolean} [animate] */
|
|
38
80
|
function openPanel(animate = true) {
|
|
39
81
|
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
40
82
|
if (!animate) {
|
|
@@ -46,7 +88,7 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
46
88
|
document.body.classList.add('cms-panel-open');
|
|
47
89
|
studioButton?.classList.add('active');
|
|
48
90
|
studioButton?.setAttribute('aria-expanded', 'true');
|
|
49
|
-
|
|
91
|
+
writeSessionValue('cms-panel-open', '1');
|
|
50
92
|
// Move focus into the drawer so keyboard users aren't left behind the
|
|
51
93
|
// covering panel. The iframe is focusable and hands focus to its own content.
|
|
52
94
|
if (animate) {
|
|
@@ -70,7 +112,7 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
70
112
|
document.body.classList.remove('cms-panel-open');
|
|
71
113
|
studioButton?.classList.remove('active');
|
|
72
114
|
studioButton?.setAttribute('aria-expanded', 'false');
|
|
73
|
-
|
|
115
|
+
writeSessionValue('cms-panel-open', '0');
|
|
74
116
|
// Return focus to the trigger (or wherever it was) so it isn't lost.
|
|
75
117
|
const restore = lastFocused || studioButton;
|
|
76
118
|
if (restore instanceof HTMLElement) {
|
|
@@ -92,18 +134,22 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
92
134
|
studioButton?.addEventListener('click', togglePanel);
|
|
93
135
|
|
|
94
136
|
// Restore panel state from sessionStorage (without animation)
|
|
95
|
-
if (
|
|
137
|
+
if (readSessionValue('cms-panel-open') === '1') {
|
|
96
138
|
openPanel(false);
|
|
97
139
|
}
|
|
98
140
|
|
|
99
|
-
|
|
100
|
-
|
|
141
|
+
/** @param {KeyboardEvent} e */
|
|
142
|
+
function handleEscape(e) {
|
|
101
143
|
if (e.key !== 'Escape' || !panel.classList.contains('open')) return;
|
|
102
144
|
const focused = document.activeElement;
|
|
103
145
|
if (!focused || !focused.matches('[data-caret]')) {
|
|
104
146
|
closePanel();
|
|
105
147
|
}
|
|
106
|
-
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Listen in both the page and same-origin Studio document. Keyboard events
|
|
151
|
+
// do not cross an iframe boundary after focus moves into the Studio.
|
|
152
|
+
document.addEventListener('keydown', handleEscape);
|
|
107
153
|
|
|
108
154
|
return { iframe, closePanel };
|
|
109
155
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const PREVIEW_COOKIE = 'caret_preview';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} PreviewModeOptions
|
|
5
|
+
* @property {() => boolean} isStaticDelivery
|
|
6
|
+
* @property {() => boolean} isPolicyDraftMode
|
|
7
|
+
* @property {Pick<Document, 'cookie'>} documentRef
|
|
8
|
+
* @property {() => void} reload
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keep the preview cookie aligned with the active delivery mode.
|
|
13
|
+
*
|
|
14
|
+
* @param {PreviewModeOptions} options
|
|
15
|
+
*/
|
|
16
|
+
export function createPreviewModeController(options) {
|
|
17
|
+
const { isStaticDelivery, isPolicyDraftMode, documentRef, reload } = options;
|
|
18
|
+
|
|
19
|
+
function previewActive() {
|
|
20
|
+
return documentRef.cookie
|
|
21
|
+
.split('; ')
|
|
22
|
+
.some(cookie => cookie === `${PREVIEW_COOKIE}=1`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Static delivery always drafts, so enable preview before mounting the editor. */
|
|
26
|
+
function ensureStaticPreviewMode() {
|
|
27
|
+
if (!isStaticDelivery() || previewActive()) return false;
|
|
28
|
+
documentRef.cookie = `${PREVIEW_COOKIE}=1; path=/; SameSite=Lax`;
|
|
29
|
+
reload();
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizePreviewForDelivery() {
|
|
34
|
+
if (isPolicyDraftMode()) return false;
|
|
35
|
+
if (isStaticDelivery()) return ensureStaticPreviewMode();
|
|
36
|
+
if (!previewActive()) return false;
|
|
37
|
+
|
|
38
|
+
documentRef.cookie = `${PREVIEW_COOKIE}=; path=/; Max-Age=0; SameSite=Lax`;
|
|
39
|
+
reload();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { ensureStaticPreviewMode, normalizePreviewForDelivery };
|
|
44
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** @typedef {'idle' | 'saving' | 'error'} ToolbarStatusState */
|
|
2
|
+
/** @typedef {'success' | 'error'} ToastKind */
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} PublishPresentation
|
|
5
|
+
* @property {boolean} completed
|
|
6
|
+
* @property {boolean} reload
|
|
7
|
+
* @property {boolean} retryAvailable
|
|
8
|
+
* @property {{ state: ToolbarStatusState, text: string } | null} status
|
|
9
|
+
* @property {{ kind: ToastKind, message: string }} toast
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @param {unknown} value */
|
|
13
|
+
function record(value) {
|
|
14
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
+
? /** @type {Record<string, unknown>} */ (value)
|
|
16
|
+
: {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {unknown} value */
|
|
20
|
+
function entries(value) {
|
|
21
|
+
return Array.isArray(value) ? value.map(record) : [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turn the publish API's mutually exclusive outcomes into one toolbar action.
|
|
26
|
+
* Keeping this pure makes recovery/conflict/deploy wording testable without DOM.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} input
|
|
29
|
+
* @returns {PublishPresentation}
|
|
30
|
+
*/
|
|
31
|
+
export function presentPublishResult(input) {
|
|
32
|
+
const data = record(input);
|
|
33
|
+
const published = entries(data.published);
|
|
34
|
+
const conflicts = entries(data.conflicts);
|
|
35
|
+
const failed = entries(data.failed);
|
|
36
|
+
const rebuild = record(data.rebuild);
|
|
37
|
+
const retryAvailable = data.retryAvailable === true;
|
|
38
|
+
|
|
39
|
+
if (failed.length > 0) {
|
|
40
|
+
return {
|
|
41
|
+
completed: false,
|
|
42
|
+
reload: false,
|
|
43
|
+
retryAvailable,
|
|
44
|
+
status: { state: 'error', text: 'Publish needs recovery' },
|
|
45
|
+
toast: {
|
|
46
|
+
kind: 'error',
|
|
47
|
+
message: `Finished ${published.length} change(s); ${failed.length} need recovery. Some content may already be published. Retry Publish after checking storage. Drafts are kept.`,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (conflicts.length > 0) {
|
|
53
|
+
const labels = conflicts.map(entry => {
|
|
54
|
+
const collection = typeof entry.collection === 'string' ? entry.collection : 'unknown';
|
|
55
|
+
const id = typeof entry.id === 'string' ? entry.id : 'unknown';
|
|
56
|
+
return `${collection}/${id}`;
|
|
57
|
+
}).join(', ');
|
|
58
|
+
return {
|
|
59
|
+
completed: false,
|
|
60
|
+
reload: false,
|
|
61
|
+
retryAvailable,
|
|
62
|
+
status: { state: 'error', text: 'Draft conflict' },
|
|
63
|
+
toast: {
|
|
64
|
+
kind: 'error',
|
|
65
|
+
message: `Drafts kept for ${labels}. Published content changed, or the draft predates conflict tracking. Copy your edits before using Discard (which removes all your drafts), then reload and reapply them.`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (rebuild.triggered === true && rebuild.ok === false) {
|
|
71
|
+
return {
|
|
72
|
+
completed: false,
|
|
73
|
+
reload: false,
|
|
74
|
+
retryAvailable,
|
|
75
|
+
status: { state: 'error', text: 'Published, deploy failed' },
|
|
76
|
+
toast: {
|
|
77
|
+
kind: 'error',
|
|
78
|
+
message: 'Changes are published, but the deploy webhook failed. Check your CI settings.',
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rebuildTriggered = rebuild.triggered === true;
|
|
84
|
+
return {
|
|
85
|
+
completed: true,
|
|
86
|
+
reload: true,
|
|
87
|
+
retryAvailable,
|
|
88
|
+
status: null,
|
|
89
|
+
toast: {
|
|
90
|
+
kind: 'success',
|
|
91
|
+
message: rebuildTriggered
|
|
92
|
+
? published.length > 0
|
|
93
|
+
? `Published ${published.length} change(s) — rebuild started`
|
|
94
|
+
: 'Your site is rebuilding'
|
|
95
|
+
: published.length > 0
|
|
96
|
+
? `Published ${published.length} change(s) — rebuild and deploy to update visitors`
|
|
97
|
+
: 'Already up to date',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|