@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,437 @@
1
+ import { registerMediaPreview, applyMediaPreviews } from "./media-preview.js";
2
+
3
+ // Owns section insertion, preview placement, and deletion.
4
+ //
5
+ // Sections are TEMPLATE-ONLY by design: the developer ships ready-made,
6
+ // site-styled snippets (src/charlescms/templates/), the editor picks one and it
7
+ // is staged as a draft on the page. There is no free-form block builder — after
8
+ // publishing, the new section is ordinary page content, and its text and images
9
+ // are edited right on the page like everything else. One editing model, and
10
+ // editors can never produce markup the developer didn't design.
11
+ //
12
+ // Section edits are staged against verified container anchors. The browser
13
+ // preview is deliberately separate from the source operation: publishing still
14
+ // uses the build-time entry and the server re-sanitizes the submitted HTML.
15
+ export function createSectionEditor({
16
+ state,
17
+ sectionTemplatesData,
18
+ closeEditor,
19
+ mountPanel,
20
+ stageEdit,
21
+ renderPendingTray,
22
+ setToolbarStatus,
23
+ openRichTextInline,
24
+ fileToBase64,
25
+ safeMediaFileName,
26
+ isVisible,
27
+ escapeHtml
28
+ }) {
29
+ function installSectionControls() {
30
+ const entries = state.sourceMap
31
+ .filter((entry) => entry.kind === "section-container")
32
+ .sort((a, b) => (a.anchors?.[0]?.outerStart || 0) - (b.anchors?.[0]?.outerStart || 0));
33
+ const containers = [...document.querySelectorAll("[data-charlescms-sections]")]
34
+ .filter((element) => !element.closest("[data-charlescms-ui]"))
35
+ .filter(isVisible);
36
+ if (entries.length !== containers.length) return;
37
+
38
+ entries.forEach((entry, index) => {
39
+ const container = containers[index];
40
+ const children = [...container.children].filter((child) => !child.closest("[data-charlescms-ui]"));
41
+ if (!entry.anchors?.length || children.length !== entry.anchors.length) return;
42
+ createGapControl(entry, container, children, 0, "before");
43
+ children.forEach((_, childIndex) => createGapControl(entry, container, children, childIndex, "after"));
44
+ });
45
+ }
46
+
47
+ function createGapControl(entry, container, children, childIndex, position) {
48
+ const button = document.createElement("button");
49
+ button.type = "button";
50
+ button.dataset.charlescmsUi = "true";
51
+ button.className = "charlescms-section-gap";
52
+ button.innerHTML = '<span aria-hidden="true">+</span><span>Add section here</span>';
53
+ button.setAttribute("aria-label", "Add a section at this position");
54
+ document.body.append(button);
55
+
56
+ const control = {
57
+ button,
58
+ entry,
59
+ container,
60
+ anchor: entry.anchors[childIndex],
61
+ anchorElement: children[childIndex],
62
+ nextElement: position === "after" ? children[childIndex + 1] || null : children[0],
63
+ position
64
+ };
65
+ state.sectionControls.push(control);
66
+ const update = () => positionGap(control);
67
+ button.addEventListener("click", () => openSectionPicker(control));
68
+ window.addEventListener("resize", update);
69
+ window.addEventListener("scroll", update, true);
70
+ requestAnimationFrame(update);
71
+ }
72
+
73
+ function positionGap(control) {
74
+ const { button, container, anchorElement, nextElement, position } = control;
75
+ // While an insert is staged, all gap controls disappear: only one insert can
76
+ // be staged at a time, and a control hovering over the staged preview (whose
77
+ // height the original geometry never accounted for) only confuses.
78
+ const hasPendingInsert = [...state.pending.values()].some((pendingEdit) => pendingEdit.action === "insert");
79
+ if (hasPendingInsert || !isVisible(container) || !isVisible(anchorElement)) {
80
+ button.hidden = true;
81
+ return;
82
+ }
83
+ const containerRect = container.getBoundingClientRect();
84
+ const anchorRect = anchorElement.getBoundingClientRect();
85
+ const nextRect = nextElement?.getBoundingClientRect();
86
+ const toolbarTop = state.toolbar?.getBoundingClientRect().top || window.innerHeight;
87
+ const y = position === "before"
88
+ ? anchorRect.top
89
+ : nextRect ? (anchorRect.bottom + nextRect.top) / 2 : anchorRect.bottom;
90
+ if (y < 8 || y > toolbarTop - 8 || containerRect.right < 0 || containerRect.left > window.innerWidth) {
91
+ button.hidden = true;
92
+ return;
93
+ }
94
+ button.hidden = false;
95
+ button.style.left = `${Math.max(12, containerRect.left)}px`;
96
+ button.style.top = `${y}px`;
97
+ button.style.width = `${Math.max(180, Math.min(containerRect.width, window.innerWidth - 24))}px`;
98
+ }
99
+
100
+ function openSectionPicker(control) {
101
+ closeEditor();
102
+ // A second unbuilt insertion would not have a verified source anchor yet.
103
+ // Requiring publish/discard keeps every pending insert deterministic.
104
+ if ([...state.pending.values()].some((pending) => pending.action === "insert")) {
105
+ setToolbarStatus("Publish or discard the staged section before adding another.");
106
+ return;
107
+ }
108
+ const templates = Object.values(sectionTemplatesData || {});
109
+ const dialog = document.createElement("div");
110
+ dialog.dataset.charlescmsUi = "true";
111
+ dialog.className = "charlescms-panel charlescms-section-picker";
112
+ dialog.innerHTML = `
113
+ <div class="charlescms-panel-header">
114
+ <div>
115
+ <div class="charlescms-kicker">Add section</div>
116
+ <div class="charlescms-title">Choose a section</div>
117
+ </div>
118
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
119
+ </div>
120
+ <p class="charlescms-panel-copy">Sections are designed to match this site. After publishing, edit their text and images right on the page.</p>
121
+ <div class="charlescms-template-grid">
122
+ ${templates.map((template) => `
123
+ <button type="button" class="charlescms-template-card" data-template="${escapeAttribute(template.id)}">
124
+ <span class="charlescms-template-preview">${template.html}</span>
125
+ <strong>${escapeHtml(template.label)}</strong>
126
+ </button>
127
+ `).join("")}
128
+ </div>
129
+ ${templates.length ? "" : `<p class="charlescms-content-empty">No section templates yet. A developer adds them as small HTML files in <code>src/charlescms/templates/</code>.</p>`}
130
+ `;
131
+ mountPanel(dialog, { item: { element: control.anchorElement } });
132
+ dialog.querySelector("[data-close]").addEventListener("click", closeEditor);
133
+ for (const button of dialog.querySelectorAll("[data-template]")) {
134
+ button.addEventListener("click", () => {
135
+ const template = sectionTemplatesData[button.dataset.template];
136
+ if (template) stageSectionInsert(control, template.html, template.label);
137
+ });
138
+ }
139
+ }
140
+
141
+ // For an EXISTING published section (a data-charlescms-block in the source):
142
+ // its inner text and images are ordinary on-page content with their own
143
+ // editors, so this panel only owns what the page can't — removing the whole
144
+ // section. (Kept under the openSectionBuilder name: it is the injected entry
145
+ // point the element editor routes type:"section" clicks to.)
146
+ function openSectionBuilder({ item = null } = {}) {
147
+ if (!item) return;
148
+ closeEditor();
149
+ const dialog = document.createElement("div");
150
+ dialog.dataset.charlescmsUi = "true";
151
+ dialog.className = "charlescms-panel";
152
+ dialog.innerHTML = `
153
+ <div class="charlescms-panel-header">
154
+ <div>
155
+ <div class="charlescms-kicker">Section</div>
156
+ <div class="charlescms-title">${escapeHtml(item.label || "Section")}</div>
157
+ </div>
158
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
159
+ </div>
160
+ <p class="charlescms-panel-copy">The text and images in this section are edited directly on the page — just click them. Removing deletes the whole section from the page.</p>
161
+ <div class="charlescms-actions">
162
+ <button type="button" class="charlescms-danger-button" data-delete-section>Remove section</button>
163
+ <button type="button" data-close>Close</button>
164
+ </div>
165
+ `;
166
+ mountPanel(dialog, { item });
167
+ for (const close of dialog.querySelectorAll("[data-close]")) close.addEventListener("click", closeEditor);
168
+ dialog.querySelector("[data-delete-section]").addEventListener("click", () => stageSectionDelete(item));
169
+ }
170
+
171
+ // The staged draft: explain its lifecycle and offer the one action the page
172
+ // can't — un-staging it. Editing its words/images happens after publish, on
173
+ // the page, like all other content.
174
+ function openDraftPanel(pending) {
175
+ closeEditor();
176
+ const dialog = document.createElement("div");
177
+ dialog.dataset.charlescmsUi = "true";
178
+ dialog.className = "charlescms-panel";
179
+ dialog.innerHTML = `
180
+ <div class="charlescms-panel-header">
181
+ <div>
182
+ <div class="charlescms-kicker">New section — not published yet</div>
183
+ <div class="charlescms-title">${escapeHtml(pending.label || "New section")}</div>
184
+ </div>
185
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
186
+ </div>
187
+ <p class="charlescms-panel-copy">Click any text or image in this draft to edit it — just like the rest of the page. Publish to add the section to the site.</p>
188
+ <div class="charlescms-actions">
189
+ <button type="button" class="charlescms-danger-button" data-remove-draft>Remove section</button>
190
+ <button type="button" data-close>Close</button>
191
+ </div>
192
+ `;
193
+ mountPanel(dialog, { item: { element: pending.preview } });
194
+ for (const close of dialog.querySelectorAll("[data-close]")) close.addEventListener("click", closeEditor);
195
+ dialog.querySelector("[data-remove-draft]").addEventListener("click", () => removePendingInsert(pending));
196
+ }
197
+
198
+ // Removing a staged insert fully un-stages it — page and pending tray return
199
+ // to exactly the state before "Add section".
200
+ function removePendingInsert(pending) {
201
+ state.pending.delete(pending.key);
202
+ pending.preview.remove();
203
+ closeEditor();
204
+ renderPendingTray();
205
+ repositionSectionGaps();
206
+ setToolbarStatus("New section removed.");
207
+ }
208
+
209
+ function repositionSectionGaps() {
210
+ for (const sectionControl of state.sectionControls) positionGap(sectionControl);
211
+ }
212
+
213
+ function stageSectionInsert(control, html, label) {
214
+ const preview = document.createElement("div");
215
+ preview.dataset.charlescmsPreviewBlock = "true";
216
+ preview.className = control.entry.sectionClass || "";
217
+ preview.innerHTML = sanitizeSectionPreview(html);
218
+ applyAstroScope(preview, control.anchorElement);
219
+ if (control.position === "before") control.anchorElement.before(preview);
220
+ else control.anchorElement.after(preview);
221
+
222
+ const pendingKey = `${control.entry.id}:insert`;
223
+ const item = { id: control.entry.id, element: null, fields: ["body"], type: "section", value: {}, label };
224
+ stageEdit(item, html, {
225
+ pendingKey,
226
+ id: control.entry.id,
227
+ type: "section",
228
+ action: "insert",
229
+ anchorId: control.anchor.anchorId,
230
+ position: control.position,
231
+ route: location.pathname,
232
+ value: html,
233
+ label,
234
+ original: "",
235
+ preview: { kind: "remove", element: preview }
236
+ });
237
+ // The draft stays a first-class citizen until publish: its texts and images
238
+ // edit exactly like the rest of the page, and clicking its frame opens the
239
+ // draft panel (explanation + remove).
240
+ const pending = { key: pendingKey, preview, control, label };
241
+ preview.dataset.charlescmsEditable = "true";
242
+ preview.charlescmsEdit = { open: () => openDraftPanel(pending), label: "✎ New section" };
243
+ enableDraftEditing(pending);
244
+ closeEditor();
245
+ repositionSectionGaps();
246
+ preview.scrollIntoView({ block: "center", behavior: "smooth" });
247
+ // No status override here: the pending tray already announces
248
+ // "1 change staged." — one consistent signal for every kind of edit.
249
+ renderPendingTray();
250
+ }
251
+
252
+ // Make the DRAFT's contents behave like any other page content: headings and
253
+ // paragraphs open the same inline rich-text editor, images open a replace
254
+ // panel. The difference is invisible to the editor: instead of staging a
255
+ // source edit, saves are absorbed into the pending insert's HTML (the draft's
256
+ // single source of truth) via syncDraft.
257
+ function enableDraftEditing(pending) {
258
+ const texts = pending.preview.querySelectorAll("h2, h3, h4, p, li, blockquote, figcaption");
259
+ texts.forEach((element, index) => {
260
+ element.dataset.charlescmsEditable = "true";
261
+ element.charlescmsEdit = {
262
+ label: "✎ Edit text",
263
+ open: () => openRichTextInline({
264
+ id: `${pending.key}:draft:${index}`,
265
+ element,
266
+ fields: ["text"],
267
+ type: "richtext",
268
+ value: { text: element.innerHTML },
269
+ label: pending.label,
270
+ draftSave: (html) => {
271
+ element.innerHTML = html;
272
+ syncDraft(pending);
273
+ renderPendingTray();
274
+ }
275
+ })
276
+ };
277
+ });
278
+ for (const image of pending.preview.querySelectorAll("img")) {
279
+ image.dataset.charlescmsEditable = "true";
280
+ image.charlescmsEdit = { label: "⤢ Replace image", open: () => openDraftImagePanel(image, pending) };
281
+ }
282
+ }
283
+
284
+ // Re-serialize the draft DOM back into the pending insert. Editor-only
285
+ // attributes (bindings, Astro style scopes, contenteditable leftovers) AND
286
+ // editor-only classes (charlescms-editing, charlescms-pm, …) are stripped so the
287
+ // published source stays clean static markup. Leaving the editing class behind
288
+ // also made the element read as "already editing" and silently uneditable.
289
+ function syncDraft(pending) {
290
+ const clone = pending.preview.cloneNode(true);
291
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
292
+ // A previewed image shows a blob URL; restore the committed path before
293
+ // serializing so the source never gets a blob: URL.
294
+ const mediaPath = node.getAttribute?.("data-charlescms-media-path");
295
+ if (mediaPath) node.setAttribute("src", mediaPath);
296
+ for (const attribute of [...node.attributes]) {
297
+ if (/^data-(?:charlescms|astro)-/.test(attribute.name) || attribute.name === "contenteditable" || attribute.name === "spellcheck") {
298
+ node.removeAttribute(attribute.name);
299
+ }
300
+ }
301
+ if (node.classList?.length) {
302
+ for (const cls of [...node.classList]) {
303
+ if (cls.startsWith("charlescms-")) node.classList.remove(cls);
304
+ }
305
+ if (!node.classList.length) node.removeAttribute("class");
306
+ }
307
+ }
308
+ const html = clone.innerHTML.trim();
309
+ const staged = state.pending.get(pending.key);
310
+ if (staged) staged.value = html;
311
+ }
312
+
313
+ function openDraftImagePanel(image, pending) {
314
+ closeEditor();
315
+ const dialog = document.createElement("div");
316
+ dialog.dataset.charlescmsUi = "true";
317
+ dialog.className = "charlescms-panel";
318
+ dialog.innerHTML = `
319
+ <div class="charlescms-panel-header">
320
+ <div>
321
+ <div class="charlescms-kicker">New section — image</div>
322
+ <div class="charlescms-title">Replace image</div>
323
+ </div>
324
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
325
+ </div>
326
+ <label>Alt text
327
+ <input data-draft-alt value="${escapeAttribute(image.getAttribute("alt") || "")}">
328
+ </label>
329
+ <label>Upload replacement
330
+ <input type="file" accept="image/*" data-draft-upload>
331
+ </label>
332
+ <div class="charlescms-actions"><button type="button" data-close>Cancel</button><button type="button" data-draft-save>Save</button></div>
333
+ `;
334
+ mountPanel(dialog, { item: { element: image } });
335
+ for (const close of dialog.querySelectorAll("[data-close]")) close.addEventListener("click", closeEditor);
336
+ dialog.querySelector("[data-draft-upload]").addEventListener("change", async (event) => {
337
+ const file = event.target.files?.[0];
338
+ if (!file || !state.connector) return;
339
+ event.target.disabled = true;
340
+ try {
341
+ const name = `${Date.now()}-${safeMediaFileName(file.name || "section-image.bin")}`;
342
+ // The upload commits immediately (like every media upload); the dev
343
+ // server mirrors it locally so the draft preview shows it right away.
344
+ await state.connector.putBase64(`public/uploads/${name}`, await fileToBase64(file), { message: `Upload media: public/uploads/${name}` });
345
+ image.src = `/uploads/${name}`;
346
+ registerMediaPreview(`/uploads/${name}`, file);
347
+ applyMediaPreviews(pending.preview);
348
+ syncDraft(pending);
349
+ renderPendingTray();
350
+ } catch (error) {
351
+ setToolbarStatus(error.message || "Upload failed.");
352
+ } finally {
353
+ event.target.disabled = false;
354
+ }
355
+ });
356
+ dialog.querySelector("[data-draft-save]").addEventListener("click", () => {
357
+ image.setAttribute("alt", dialog.querySelector("[data-draft-alt]").value);
358
+ syncDraft(pending);
359
+ renderPendingTray();
360
+ closeEditor();
361
+ });
362
+ }
363
+
364
+ function stageSectionDelete(item) {
365
+ if (!item || !window.confirm("Delete this CMS section from the page preview?")) return;
366
+ state.pending.set(`${item.id}:delete`, {
367
+ id: item.id,
368
+ type: "section",
369
+ action: "delete",
370
+ route: location.pathname,
371
+ value: {},
372
+ label: item.label,
373
+ original: item.value.body,
374
+ item,
375
+ originalValue: cloneValue(item.value),
376
+ preview: { kind: "hide", element: item.element }
377
+ });
378
+ item.element.hidden = true;
379
+ closeEditor();
380
+ renderPendingTray();
381
+ setToolbarStatus(`${state.pending.size} change${state.pending.size === 1 ? "" : "s"} staged.`);
382
+ }
383
+
384
+ return { installSectionControls, openSectionBuilder };
385
+ }
386
+
387
+ // Inserted previews must inherit Astro's scoped-style attributes from their
388
+ // sibling, or scoped CSS wouldn't apply and the draft would look unstyled.
389
+ function applyAstroScope(element, reference) {
390
+ const scopeAttributes = [...(reference?.attributes || [])]
391
+ .map((attribute) => attribute.name)
392
+ .filter((name) => name.startsWith("data-astro-cid-"));
393
+ for (const node of [element, ...element.querySelectorAll("*")]) {
394
+ for (const name of scopeAttributes) node.setAttribute(name, "");
395
+ }
396
+ }
397
+
398
+ function sanitizeSectionPreview(html) {
399
+ const template = document.createElement("template");
400
+ template.innerHTML = String(html || "");
401
+ const allowed = new Set(["h2", "h3", "h4", "p", "ul", "ol", "li", "blockquote", "figure", "figcaption", "img", "a", "hr", "strong", "em", "code", "br"]);
402
+ for (const node of [...template.content.querySelectorAll("*")]) {
403
+ const tag = node.tagName.toLowerCase();
404
+ if (!allowed.has(tag)) {
405
+ node.replaceWith(...node.childNodes);
406
+ continue;
407
+ }
408
+ for (const attribute of [...node.attributes]) {
409
+ const safe = attribute.name === "class"
410
+ || attribute.name === "title"
411
+ || (tag === "a" && attribute.name === "href" && isSafeClientUrl(attribute.value))
412
+ || (tag === "img" && ["src", "alt"].includes(attribute.name) && (attribute.name !== "src" || isSafeClientUrl(attribute.value)));
413
+ if (!safe) node.removeAttribute(attribute.name);
414
+ }
415
+ }
416
+ return template.innerHTML;
417
+ }
418
+
419
+ function isSafeClientUrl(value) {
420
+ const stripped = Array.from(String(value ?? "")).filter((character) => character.charCodeAt(0) > 0x20).join("");
421
+ const scheme = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
422
+ return !scheme || ["http:", "https:", "mailto:", "tel:"].includes(`${scheme[1].toLowerCase()}:`);
423
+ }
424
+
425
+ function cloneValue(value) {
426
+ return JSON.parse(JSON.stringify(value || {}));
427
+ }
428
+
429
+ function escapeAttribute(value) {
430
+ return String(value).replace(/[&<>"']/g, (character) => ({
431
+ "&": "&amp;",
432
+ "<": "&lt;",
433
+ ">": "&gt;",
434
+ '"': "&quot;",
435
+ "'": "&#039;"
436
+ })[character]);
437
+ }