@charlescms/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/SECURITY.md +77 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/connector/worker.js +505 -0
  6. package/connector/wrangler.toml +15 -0
  7. package/package.json +92 -0
  8. package/scripts/check-licenses.js +45 -0
  9. package/scripts/check-package.js +62 -0
  10. package/scripts/setup.js +719 -0
  11. package/scripts/update-vendored-site.js +71 -0
  12. package/src/admin.astro +314 -0
  13. package/src/analyzer.js +639 -0
  14. package/src/asset-images.js +130 -0
  15. package/src/astro-frontmatter.js +17 -0
  16. package/src/boot.js +35 -0
  17. package/src/client.js +347 -0
  18. package/src/connector-client.js +185 -0
  19. package/src/content-bridge.js +162 -0
  20. package/src/content-panel.js +440 -0
  21. package/src/data-analyzer.js +304 -0
  22. package/src/edit-affordance.js +463 -0
  23. package/src/editor-styles.js +243 -0
  24. package/src/element-editor.js +355 -0
  25. package/src/fields.js +6 -0
  26. package/src/frontmatter.js +153 -0
  27. package/src/ids.js +20 -0
  28. package/src/index.js +681 -0
  29. package/src/js-ast.js +140 -0
  30. package/src/markdown-analyzer.js +95 -0
  31. package/src/media-preview.js +58 -0
  32. package/src/panel-manager.js +133 -0
  33. package/src/publishing.js +457 -0
  34. package/src/rich-text-editor.js +209 -0
  35. package/src/routes.js +21 -0
  36. package/src/runtime-controller.js +206 -0
  37. package/src/sanitize.js +150 -0
  38. package/src/section-editor.js +437 -0
  39. package/src/source-edit.js +310 -0
  40. package/src/source-map-runtime.js +184 -0
  41. package/src/staged-panel.js +145 -0
  42. package/src/toolbar.js +128 -0
  43. package/src/versions-panel.js +112 -0
@@ -0,0 +1,457 @@
1
+ import { registerMediaPreview } from "./media-preview.js";
2
+
3
+ // Manages the edit transaction from local preview to connector commit.
4
+ //
5
+ // Feature editors call `stageEdit` and update the live DOM. This module keeps
6
+ // the original value for discard, serializes verified publish payloads, and owns
7
+ // immediate binary uploads whose source path must remain unchanged.
8
+ export function createPublishing({
9
+ state,
10
+ applyValue,
11
+ applyBridgedValues,
12
+ closeEditor,
13
+ renderPendingTray,
14
+ setToolbarStatus,
15
+ setBusy,
16
+ waitForLivePage
17
+ }) {
18
+ // While a deployed site rebuilds after a publish, editing must pause: the page
19
+ // is still serving the OLD build (old source map, old assets), so any further
20
+ // edit would be made against stale data and fail or look wrong. This full-screen
21
+ // lock makes the wait explicit and blocks interaction until the new build lands.
22
+ function showPublishingLock(message, onDismiss) {
23
+ let overlay = document.querySelector(".charlescms-publishing");
24
+ if (!overlay) {
25
+ overlay = document.createElement("div");
26
+ overlay.dataset.charlescmsUi = "true";
27
+ overlay.className = "charlescms-publishing";
28
+ document.body.append(overlay);
29
+ }
30
+ overlay.innerHTML = `
31
+ <div class="charlescms-publishing-card">
32
+ <div class="charlescms-spinner" aria-hidden="true"></div>
33
+ <strong>Publishing your changes…</strong>
34
+ <span>${message}</span>
35
+ ${onDismiss ? `<button type="button" class="charlescms-publishing-dismiss" data-dismiss>Keep editing — I'll wait</button>` : ""}
36
+ </div>`;
37
+ if (onDismiss) overlay.querySelector("[data-dismiss]")?.addEventListener("click", onDismiss);
38
+ return overlay;
39
+ }
40
+ function hidePublishingLock() {
41
+ document.querySelector(".charlescms-publishing")?.remove();
42
+ }
43
+ // Staged edits are mirrored into sessionStorage so they survive navigation, a
44
+ // full reload, and same-origin new tabs — instead of evaporating the moment the
45
+ // editor leaves the page. The DOM element and any live preview node are dropped
46
+ // (they belong to one page); the source-map entry IS kept so a staged edit can
47
+ // still publish after the page it was made on is no longer the current one.
48
+ const PENDING_STORAGE_KEY = "charlescms_pending";
49
+
50
+ function persistPending() {
51
+ try {
52
+ const out = [];
53
+ for (const [key, pending] of state.pending.entries()) {
54
+ const { item, preview, ...rest } = pending;
55
+ out.push({ key, ...rest, entry: rest.entry || state.sourceMapById.get(pending.id) || null, itemValue: item?.value });
56
+ }
57
+ if (out.length) sessionStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(out));
58
+ else sessionStorage.removeItem(PENDING_STORAGE_KEY);
59
+ } catch {}
60
+ }
61
+
62
+ // Rehydrate staged edits from storage into state.pending. The runtime's existing
63
+ // reconnectPendingEdits() then re-binds them to this page's DOM and re-applies
64
+ // the preview. In-memory entries (still present after an SPA transition) win.
65
+ function restorePending() {
66
+ let saved = null;
67
+ try { saved = JSON.parse(sessionStorage.getItem(PENDING_STORAGE_KEY) || "null"); } catch { saved = null; }
68
+ if (!Array.isArray(saved)) return;
69
+ for (const snapshot of saved) {
70
+ if (!snapshot?.key || state.pending.has(snapshot.key)) continue;
71
+ const { key, itemValue, ...rest } = snapshot;
72
+ state.pending.set(key, { ...rest, item: { id: rest.id, value: itemValue, element: null } });
73
+ }
74
+ }
75
+
76
+ function stageEdit(item, value, payload) {
77
+ const key = payload.pendingKey || item.id;
78
+ const existing = state.pending.get(key);
79
+ state.pending.set(key, {
80
+ ...payload,
81
+ value: cloneValue(value),
82
+ item,
83
+ entry: state.sourceMapById.get(payload.id || item.id) || existing?.entry,
84
+ originalValue: existing?.originalValue || cloneValue(item.value)
85
+ });
86
+ item.value = cloneValue(value);
87
+ persistPending();
88
+ renderPendingTray();
89
+ reportStagedCount();
90
+ }
91
+
92
+ function discardPending() {
93
+ if (state.busy || state.pending.size === 0) return;
94
+ for (const pending of state.pending.values()) {
95
+ if (pending.preview?.kind === "hide" && pending.preview.element) {
96
+ pending.preview.element.hidden = false;
97
+ } else if (pending.preview?.kind === "remove" && pending.preview.element) {
98
+ pending.preview.element.remove();
99
+ } else if (pending.item?.element) {
100
+ applyValue(pending.item.element, { type: pending.type, value: pending.originalValue });
101
+ } else if (["data", "nav", "frontmatter"].includes(pending.type)) {
102
+ // Bridged form edits updated the page on Save; restore those elements
103
+ // from the staged original so Discard really rewinds the preview.
104
+ applyBridgedValues?.(pending.id, pending.originalValue || {});
105
+ }
106
+ if (pending.item) pending.item.value = cloneValue(pending.originalValue);
107
+ }
108
+ state.pending.clear();
109
+ persistPending();
110
+ renderPendingTray();
111
+ setToolbarStatus("Preview discarded.");
112
+ }
113
+
114
+ async function publishPending() {
115
+ if (state.busy) return;
116
+ if (state.pending.size === 0) {
117
+ setToolbarStatus("No changes to publish.");
118
+ return;
119
+ }
120
+ setBusy(true, "Publishing changes...");
121
+ const published = [];
122
+ const commits = [];
123
+ const here = normalizeRoute(location.pathname);
124
+ const liveMarkers = [];
125
+ try {
126
+ // Resolve each edit's verified source entry. For plain content edits, commit
127
+ // same-file edits BOTTOM-UP (highest byte offset first): committing a later
128
+ // edit only shifts bytes after it, so an earlier edit's offsets stay valid
129
+ // against the freshly re-read file — without this, the 2nd+ content edit in
130
+ // one file drifts and fails. Section/media ops carry an `action` and can
131
+ // depend on each other's order, so if ANY are present we keep staging order.
132
+ const queue = [...state.pending.entries()].map(([key, pending]) => ({
133
+ key,
134
+ pending,
135
+ entry: state.sourceMapById.get(pending.id) || pending.entry
136
+ }));
137
+ if (queue.every(({ pending }) => !pending.action)) {
138
+ queue.sort((a, b) => {
139
+ const fa = a.entry?.file || "";
140
+ const fb = b.entry?.file || "";
141
+ if (fa !== fb) return fa < fb ? -1 : 1;
142
+ return entryStart(b.entry) - entryStart(a.entry);
143
+ });
144
+ }
145
+ for (const { key, pending, entry } of queue) {
146
+ if (!entry) throw new Error("No verified source-map entry exists for this element.");
147
+ const result = await state.connector.applyEdit(entry, {
148
+ id: pending.id,
149
+ type: pending.type,
150
+ action: pending.action,
151
+ anchorId: pending.anchorId,
152
+ position: pending.position,
153
+ route: pending.route,
154
+ value: pending.value,
155
+ label: pending.label,
156
+ original: pending.original,
157
+ message: commitMessage(pending)
158
+ });
159
+ if (result.commit) commits.push(result.commit);
160
+ published.push(key);
161
+ // Markers from edits on THIS page let us detect when the rebuilt site is
162
+ // actually serving the change — using the part of each field that DIFFERS
163
+ // from its old value, so we never match the old build and reload into
164
+ // stale content (the "it reset after publish" bug).
165
+ if (normalizeRoute(pending.route) === here) {
166
+ const next = pending.value || {};
167
+ const prev = pending.originalValue || {};
168
+ for (const key of Object.keys(next)) {
169
+ const marker = liveMarker(next[key], prev[key]);
170
+ if (marker) liveMarkers.push(marker);
171
+ }
172
+ }
173
+ }
174
+ state.pending.clear();
175
+ persistPending();
176
+ renderPendingTray();
177
+ const count = published.length;
178
+ const plural = count === 1 ? "" : "s";
179
+
180
+ // Dev mirrors the new source to disk immediately, so a quick reload gives a
181
+ // fresh, byte-consistent map.
182
+ if (import.meta.env?.DEV && count && !window.__CHARLESCMS_NO_RELOAD__ && !window.__CHARLESCMS_FORCE_PUBLISH_WAIT__) {
183
+ setToolbarStatus(`Published ${count} change${plural}.`);
184
+ setTimeout(() => location.reload(), 700);
185
+ return;
186
+ }
187
+ if (window.__CHARLESCMS_NO_RELOAD__) {
188
+ setToolbarStatus(`Published ${count} change${plural}.`);
189
+ return;
190
+ }
191
+
192
+ // Deployed: the live site still serves the OLD build until it rebuilds. Only
193
+ // reload once we can CONFIRM the new content is being served (a change marker
194
+ // appears) — reloading on mere reachability would drop us back onto the stale
195
+ // build and look like the edit "reset". The applied preview stays on screen
196
+ // meanwhile, so the user keeps seeing their change. If we can't confirm (no
197
+ // marker, or it times out), we keep the preview and let them reload when ready.
198
+ if (liveMarkers.length && waitForLivePage) {
199
+ let dismissed = false;
200
+ const dismiss = () => {
201
+ dismissed = true;
202
+ hidePublishingLock();
203
+ setBusy(false);
204
+ setToolbarStatus(`Published ${count} change${plural}. Your site is updating in the background — reload when you want the latest.`);
205
+ };
206
+ showPublishingLock("Your site is rebuilding — usually under a minute. The editor refreshes automatically when your changes are live.", dismiss);
207
+ const live = await waitForLivePage(liveMarkers);
208
+ if (dismissed) return; // user chose to keep editing; don't yank the page out
209
+ if (live) {
210
+ showPublishingLock("Changes are live — refreshing…");
211
+ location.reload();
212
+ return;
213
+ }
214
+ hidePublishingLock();
215
+ }
216
+ setBusy(false);
217
+ setToolbarStatus(`Published ${count} change${plural}. Your site is updating — reload the page in a minute to keep editing.`);
218
+ return;
219
+ } catch (error) {
220
+ // Successfully committed entries are removed; failed and later entries
221
+ // stay staged so the editor can retry without recreating their preview.
222
+ for (const key of published) state.pending.delete(key);
223
+ persistPending();
224
+ renderPendingTray();
225
+ setToolbarStatus(readablePublishError(error));
226
+ } finally {
227
+ setBusy(false);
228
+ }
229
+ }
230
+
231
+ async function uploadActiveMedia(event) {
232
+ if (!state.active || state.previewOnly) return;
233
+ const file = event.target.files?.[0];
234
+ if (!file) return;
235
+ const { dialog, item } = state.active;
236
+ const fieldName = primaryMediaField(item);
237
+ const input = dialog.querySelector(`[data-field="${CSS.escape(fieldName)}"]`);
238
+ if (!input) return;
239
+ // Block Save while the upload is in flight. Saving mid-upload stages the OLD
240
+ // path (the field isn't updated until the commit returns), so the new image
241
+ // wouldn't appear — that's the "I had to press Save twice / only every second
242
+ // change works" bug. Disabling Save means the first Save always has the upload.
243
+ const saveButton = dialog.querySelector("[data-save]");
244
+ event.target.disabled = true;
245
+ if (saveButton) saveButton.disabled = true;
246
+ setToolbarStatus("Uploading image…");
247
+ try {
248
+ const name = `${Date.now()}-${safeMediaFileName(file.name || "upload.bin")}`;
249
+ const repoPath = `public/uploads/${name}`;
250
+ await state.connector.putBase64(repoPath, await fileToBase64(file), { message: `Upload media: ${repoPath}` });
251
+ registerMediaPreview(`/uploads/${name}`, file);
252
+ input.value = `/uploads/${name}`;
253
+ setToolbarStatus("Image uploaded — click Save to keep it.");
254
+ } catch (error) {
255
+ showPanelError(dialog, error.message || "Upload failed.");
256
+ } finally {
257
+ event.target.disabled = false;
258
+ if (saveButton) saveButton.disabled = false;
259
+ }
260
+ }
261
+
262
+ async function replaceDownloadFile(event) {
263
+ if (!state.active || state.previewOnly) return;
264
+ const file = event.target.files?.[0];
265
+ if (!file) return;
266
+ const { dialog, item } = state.active;
267
+ const repoPath = downloadRepoPath(item.element?.getAttribute("href") || item.element?.getAttribute("src") || "");
268
+ if (!repoPath) {
269
+ showPanelError(dialog, "This link points to an external or dynamic file that can't be replaced here.");
270
+ return;
271
+ }
272
+ event.target.disabled = true;
273
+ try {
274
+ // GitHub needs the current blob SHA when an existing file is overwritten.
275
+ const existing = await state.connector.getFile(repoPath).catch(() => null);
276
+ await state.connector.putBase64(repoPath, await fileToBase64(file), {
277
+ sha: existing?.sha,
278
+ message: `Replace download: ${repoPath}`
279
+ });
280
+ setToolbarStatus(`Replaced ${repoPath}. Redeploy to serve the new file.`);
281
+ } catch (error) {
282
+ showPanelError(dialog, error.message || "Replace failed.");
283
+ } finally {
284
+ event.target.disabled = false;
285
+ }
286
+ }
287
+
288
+ function removeActiveMedia() {
289
+ if (!state.active || state.busy) return;
290
+ const { item } = state.active;
291
+ if (!isMediaItem(item) || !window.confirm("Remove this media item from the page preview?")) return;
292
+ const key = `${item.id}:delete`;
293
+ const existing = state.pending.get(key);
294
+ state.pending.set(key, {
295
+ id: item.id,
296
+ type: item.type,
297
+ action: "delete",
298
+ route: location.pathname,
299
+ value: {},
300
+ label: item.label,
301
+ original: JSON.stringify(item.value),
302
+ item,
303
+ entry: state.sourceMapById.get(item.id),
304
+ originalValue: existing?.originalValue || cloneValue(item.value),
305
+ preview: { kind: "hide", element: item.element }
306
+ });
307
+ item.element.hidden = true;
308
+ closeEditor();
309
+ persistPending();
310
+ renderPendingTray();
311
+ reportStagedCount();
312
+ }
313
+
314
+ function branchHint() {
315
+ const branch = state.connector?.config?.branch;
316
+ return branch
317
+ ? `the branch "${branch}" was not found (log out and reconnect, leaving the branch blank to use the repository default)`
318
+ : "the branch or repository was not found";
319
+ }
320
+
321
+ function readablePublishError(error) {
322
+ const message = readableSaveError(error?.message || "Publish failed.");
323
+ if (error?.status === 401) return "Publish failed: your editor session is no longer authorized. Log in again.";
324
+ if (error?.status === 403) return `Publish failed: this origin or repository is not allowed. ${message}`;
325
+ if (error?.status === 404) return `Publish failed: ${branchHint()} or the GitHub App isn't installed on the repository.`;
326
+ if (error?.status === 409) return `Publish conflicted with a newer source version. ${message}`;
327
+ if (error?.status === 422) return `GitHub rejected the publish request. ${message}`;
328
+ return `Publish failed: ${message}`;
329
+ }
330
+
331
+ function reportStagedCount() {
332
+ setToolbarStatus(`${state.pending.size} change${state.pending.size === 1 ? "" : "s"} staged.`);
333
+ }
334
+
335
+ return {
336
+ stageEdit,
337
+ discardPending,
338
+ publishPending,
339
+ restorePending,
340
+ uploadActiveMedia,
341
+ replaceDownloadFile,
342
+ removeActiveMedia,
343
+ branchHint
344
+ };
345
+ }
346
+
347
+ function normalizeRoute(path) {
348
+ return String(path || "").replace(/[?#].*$/, "").replace(/\/+$/, "") || "/";
349
+ }
350
+
351
+ // The smallest source byte offset an entry touches — used to publish same-file
352
+ // edits bottom-up so their offsets stay valid as the file is rewritten.
353
+ function entryStart(entry) {
354
+ const starts = (entry?.operations || []).map((operation) => operation.start).filter((value) => Number.isFinite(value));
355
+ return starts.length ? Math.min(...starts) : 0;
356
+ }
357
+
358
+ // Plain text of a (possibly HTML) value: strips tags and decodes entities so a
359
+ // marker matches the rendered page, not the raw markup.
360
+ function plainText(value) {
361
+ const raw = String(value ?? "");
362
+ if (!/[<&]/.test(raw)) return raw;
363
+ const el = document.createElement("div");
364
+ el.innerHTML = raw;
365
+ return (el.textContent || "").replace(/\s+/g, " ").trim();
366
+ }
367
+
368
+ // A marker the REBUILT page will contain but the OLD build won't: the region of
369
+ // the new value that diverges from the old. This is what makes "wait until the
370
+ // deploy serves my change" reliable — matching the unchanged start of a field
371
+ // (e.g. "Good food…") would falsely report the old build as live and reload into
372
+ // stale content.
373
+ export function liveMarker(newValue, oldValue) {
374
+ const next = plainText(newValue);
375
+ const prev = plainText(oldValue);
376
+ if (!next || next === prev) return "";
377
+ let i = 0;
378
+ while (i < next.length && i < prev.length && next[i] === prev[i]) i++;
379
+ for (let len = 24; len >= 4; len -= 4) {
380
+ const candidate = next.slice(Math.max(0, i - 4), Math.max(0, i - 4) + len).trim();
381
+ if (candidate.length >= 4 && !prev.includes(candidate)) return candidate;
382
+ }
383
+ return !prev.includes(next) && next.length >= 4 ? next.slice(0, 40) : "";
384
+ }
385
+
386
+ export function downloadRepoPath(href) {
387
+ const value = String(href || "").trim();
388
+ if (!value || !value.startsWith("/") || value.startsWith("//")) return null;
389
+ const clean = value.split(/[?#]/)[0].replace(/^\/+/, "");
390
+ if (!clean || clean.split("/").includes("..")) return null;
391
+ return `public/${clean}`;
392
+ }
393
+
394
+ export function fileToBase64(file) {
395
+ return new Promise((resolve, reject) => {
396
+ const reader = new FileReader();
397
+ reader.addEventListener("load", () => {
398
+ const result = String(reader.result || "");
399
+ resolve(result.includes(",") ? result.split(",").pop() : result);
400
+ });
401
+ reader.addEventListener("error", () => reject(reader.error || new Error("Could not read file.")));
402
+ reader.readAsDataURL(file);
403
+ });
404
+ }
405
+
406
+ export function safeMediaFileName(name) {
407
+ const cleaned = String(name)
408
+ .split(/[\\/]/)
409
+ .pop()
410
+ .toLowerCase()
411
+ .replace(/[^a-z0-9._-]+/g, "-")
412
+ .replace(/^-+|-+$/g, "")
413
+ .slice(0, 96);
414
+ return cleaned || "upload.bin";
415
+ }
416
+
417
+ export function showPanelError(dialog, message) {
418
+ let error = dialog.querySelector("[data-error]");
419
+ if (!error) {
420
+ error = document.createElement("div");
421
+ error.dataset.error = "true";
422
+ error.className = "charlescms-error";
423
+ dialog.insertBefore(error, dialog.querySelector(".charlescms-actions"));
424
+ }
425
+ error.textContent = message;
426
+ }
427
+
428
+ function readableSaveError(message) {
429
+ if (/Source changed since the CMS build|No verified source-map entry|Source-map entry/i.test(message)) {
430
+ return "This source location changed after the last CMS build. Redeploy the site, then edit it again.";
431
+ }
432
+ if (/Build validation failed/i.test(message)) return "The change was rolled back because the build failed afterwards.";
433
+ if (/Too many/i.test(message)) return "Too many attempts. Wait briefly and try again.";
434
+ return message;
435
+ }
436
+
437
+ function commitMessage(pending) {
438
+ if (pending.action === "delete" && pending.type === "section") return `Remove section: ${pending.label || pending.id}`;
439
+ if (pending.action === "delete") return `Remove media: ${pending.label || pending.id}`;
440
+ if (pending.action === "insert") return `Add section: ${pending.label || pending.id}`;
441
+ if (pending.type === "section") return `Update section: ${pending.label || pending.id}`;
442
+ return `Update content: ${pending.label || pending.id}`;
443
+ }
444
+
445
+ function primaryMediaField(item) {
446
+ if (item.fields.includes("src")) return "src";
447
+ if (item.fields.includes("data")) return "data";
448
+ return item.fields[0] || "src";
449
+ }
450
+
451
+ function isMediaItem(item) {
452
+ return item?.type === "image" || item?.type === "asset";
453
+ }
454
+
455
+ function cloneValue(value) {
456
+ return JSON.parse(JSON.stringify(value || {}));
457
+ }
@@ -0,0 +1,209 @@
1
+ // Runs inline rich-text editing while preserving the page's existing block tag.
2
+ //
3
+ // `loadTiptap` is injected from client.js so the package's dynamic imports stay
4
+ // directly in Vite's entry module. This avoids the known file-install optimizer
5
+ // issue without mixing editor behavior back into the main orchestrator.
6
+ export function createRichTextEditor({
7
+ state,
8
+ loadTiptap,
9
+ stageEdit,
10
+ setToolbarStatus,
11
+ escapeHtml,
12
+ escapeAttribute
13
+ }) {
14
+ async function openRichTextInline(item) {
15
+ const element = item.element;
16
+ const original = item.value.text;
17
+ element.classList.add("charlescms-editing");
18
+ if (hasInvisibleTextFill(element)) element.classList.add("charlescms-editing-plain");
19
+
20
+ let editor;
21
+ try {
22
+ const { Editor, Document, Text, Bold, Italic, Code, HardBreak, Link } = await loadTiptap();
23
+ element.innerHTML = "";
24
+ editor = new Editor({
25
+ element,
26
+ content: original || "",
27
+ // An inline-only schema prevents Tiptap from adding a paragraph wrapper
28
+ // inside a heading, paragraph, label, or other existing page element.
29
+ extensions: [
30
+ Document.extend({ content: "inline*" }),
31
+ Text,
32
+ Bold,
33
+ Italic,
34
+ Code,
35
+ HardBreak,
36
+ Link.configure({ openOnClick: false, autolink: false, protocols: ["http", "https", "mailto", "tel"] })
37
+ ],
38
+ editorProps: { attributes: { class: "charlescms-pm", spellcheck: "true" } }
39
+ });
40
+ editor.commands.focus("end");
41
+ } catch (error) {
42
+ console.error("[CharlesCMS] rich-text editor failed to load:", error);
43
+ element.classList.remove("charlescms-editing", "charlescms-editing-plain");
44
+ element.innerHTML = original;
45
+ setToolbarStatus("Could not open the editor - please retry.");
46
+ return;
47
+ }
48
+
49
+ const toolbar = createRichTextToolbar();
50
+ document.body.append(toolbar);
51
+ const reposition = () => positionToolbar(toolbar, element);
52
+ reposition();
53
+ window.addEventListener("scroll", reposition, true);
54
+ window.addEventListener("resize", reposition);
55
+
56
+ const finish = (saved, savedHtml) => {
57
+ window.removeEventListener("scroll", reposition, true);
58
+ window.removeEventListener("resize", reposition);
59
+ toolbar.remove();
60
+ editor?.destroy();
61
+ element.classList.remove("charlescms-editing", "charlescms-editing-plain");
62
+ element.innerHTML = saved ? savedHtml : original;
63
+ state.active = null;
64
+ };
65
+
66
+ // Keep focus in ProseMirror when toolbar buttons are pressed.
67
+ toolbar.addEventListener("mousedown", (event) => event.preventDefault());
68
+ toolbar.addEventListener("click", async (event) => {
69
+ const command = event.target.closest("[data-cmd]")?.dataset.cmd;
70
+ if (command) {
71
+ applyRichCommand(editor, command);
72
+ return;
73
+ }
74
+ if (event.target.closest("[data-rt-cancel]")) {
75
+ finish(false);
76
+ return;
77
+ }
78
+ if (event.target.closest("[data-rt-save]")) {
79
+ const html = serializeTiptapHtml(editor.getHTML(), escapeHtml, escapeAttribute);
80
+ stageRichText(item, html);
81
+ finish(true, html);
82
+ }
83
+ });
84
+
85
+ state.active = { inline: true, finish };
86
+ }
87
+
88
+ function stageRichText(item, html) {
89
+ // A DRAFT element (inside a not-yet-published section) has no source span to
90
+ // stage against — its single source of truth is the pending insert's HTML.
91
+ // The caller provides draftSave to absorb the edit there instead.
92
+ if (item.draftSave) {
93
+ item.draftSave(html);
94
+ return;
95
+ }
96
+ const value = item.fields.includes("body") ? { text: html, body: html } : { text: html };
97
+ stageEdit(item, value, {
98
+ id: item.id,
99
+ type: "richtext",
100
+ route: location.pathname,
101
+ value,
102
+ label: item.label,
103
+ original: item.value.text
104
+ });
105
+ }
106
+
107
+ function positionToolbar(toolbar, element) {
108
+ const rect = element.getBoundingClientRect();
109
+ const margin = 8;
110
+ const toolbarTop = state.toolbar?.getBoundingClientRect().top || window.innerHeight;
111
+ const above = rect.top - toolbar.offsetHeight - margin;
112
+ const below = rect.bottom + margin;
113
+ const top = above >= margin ? above : Math.min(below, toolbarTop - toolbar.offsetHeight - margin);
114
+ const left = Math.max(margin, Math.min(rect.left, window.innerWidth - toolbar.offsetWidth - margin));
115
+ toolbar.style.top = `${Math.max(margin, top)}px`;
116
+ toolbar.style.left = `${left}px`;
117
+ }
118
+
119
+ return { openRichTextInline };
120
+ }
121
+
122
+ function createRichTextToolbar() {
123
+ const toolbar = document.createElement("div");
124
+ toolbar.dataset.charlescmsUi = "true";
125
+ toolbar.className = "charlescms-rt-toolbar";
126
+ toolbar.innerHTML = `
127
+ <div class="charlescms-rt-row">
128
+ <button type="button" data-cmd="bold" title="Bold"><b>B</b></button>
129
+ <button type="button" data-cmd="italic" title="Italic"><i>I</i></button>
130
+ <button type="button" data-cmd="link" title="Link">&#128279;</button>
131
+ <button type="button" data-cmd="clear" title="Clear formatting">⌫</button>
132
+ <span class="charlescms-rt-sep"></span>
133
+ <button type="button" data-rt-cancel>Cancel</button>
134
+ <button type="button" data-rt-save>Save</button>
135
+ </div>
136
+ `;
137
+ return toolbar;
138
+ }
139
+
140
+ function hasInvisibleTextFill(element) {
141
+ const style = getComputedStyle(element);
142
+ const clip = `${style.webkitBackgroundClip || ""} ${style.backgroundClip || ""}`;
143
+ if (clip.includes("text")) return true;
144
+ const fill = style.webkitTextFillColor || style.color || "";
145
+ return /,\s*0(?:\.0+)?\)\s*$/.test(fill);
146
+ }
147
+
148
+ function applyRichCommand(editor, command) {
149
+ if (!editor) return;
150
+ const chain = editor.chain().focus();
151
+ if (command === "bold") chain.toggleBold().run();
152
+ else if (command === "italic") chain.toggleItalic().run();
153
+ else if (command === "clear") chain.unsetAllMarks().run();
154
+ else if (command === "link") {
155
+ const previous = editor.getAttributes("link")?.href || "";
156
+ const url = window.prompt("Link URL", previous);
157
+ if (url === null) chain.run();
158
+ else if (url.trim() === "") chain.unsetLink().run();
159
+ else if (isSafeClientUrl(url)) chain.extendMarkRange("link").setLink({ href: url.trim() }).run();
160
+ else chain.run();
161
+ }
162
+ }
163
+
164
+ function serializeTiptapHtml(html, escapeHtml, escapeAttribute) {
165
+ const container = document.createElement("div");
166
+ container.innerHTML = html;
167
+ return serializeRichText(container, escapeHtml, escapeAttribute);
168
+ }
169
+
170
+ // Canonicalize browser/Tiptap HTML to the same small inline subset accepted by
171
+ // the server sanitizer. Unknown wrappers are removed but their text is retained.
172
+ function serializeRichText(element, escapeHtml, escapeAttribute) {
173
+ const mapping = {
174
+ strong: "strong",
175
+ b: "strong",
176
+ em: "em",
177
+ i: "em",
178
+ a: "a",
179
+ code: "code",
180
+ br: "br",
181
+ small: "small",
182
+ mark: "mark",
183
+ u: "u"
184
+ };
185
+ const serializeNode = (node) => {
186
+ if (node.nodeType === Node.TEXT_NODE) return escapeHtml(node.nodeValue);
187
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
188
+ const tag = node.tagName.toLowerCase();
189
+ const inner = [...node.childNodes].map(serializeNode).join("");
190
+ const mapped = mapping[tag];
191
+ if (!mapped) return inner;
192
+ if (mapped === "br") return "<br>";
193
+ if (mapped === "a") {
194
+ const href = node.getAttribute("href") || "";
195
+ return isSafeClientUrl(href) ? `<a href="${escapeAttribute(href)}">${inner}</a>` : inner;
196
+ }
197
+ return `<${mapped}>${inner}</${mapped}>`;
198
+ };
199
+ return [...element.childNodes].map(serializeNode).join("").trim();
200
+ }
201
+
202
+ function isSafeClientUrl(value) {
203
+ const stripped = Array.from(String(value ?? ""))
204
+ .filter((character) => character.charCodeAt(0) > 0x20)
205
+ .join("");
206
+ const scheme = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
207
+ if (!scheme) return true;
208
+ return ["http:", "https:", "mailto:", "tel:"].includes(`${scheme[1].toLowerCase()}:`);
209
+ }
package/src/routes.js ADDED
@@ -0,0 +1,21 @@
1
+ // Converts source files under src/pages into the routes where their content can
2
+ // appear. Dynamic route syntax is preserved; callers decide whether it matches
3
+ // the current browser route.
4
+ export function routeFromAstroFile(file, fallback = "/") {
5
+ const path = String(file || "").replace(/\\/g, "/");
6
+ const match = path.match(/(?:^|\/)src\/pages\/(.+)\.astro$/);
7
+ if (!match) return fallback;
8
+ return routeFromPagePath(match[1]);
9
+ }
10
+
11
+ export function routeFromMarkdownFile(file) {
12
+ const path = String(file || "").replace(/\\/g, "/");
13
+ const match = path.match(/(?:^|\/)src\/pages\/(.+)\.(?:md|mdx)$/);
14
+ if (!match) return null;
15
+ return routeFromPagePath(match[1]);
16
+ }
17
+
18
+ function routeFromPagePath(pagePath) {
19
+ const page = pagePath.replace(/\/index$/, "");
20
+ return page === "index" || page === "" ? "/" : `/${page}`;
21
+ }