@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,440 @@
1
+ // Owns content discovery and deterministic forms for data and frontmatter.
2
+ //
3
+ // The panel holds only what has no clickable spot on the page (page settings,
4
+ // stored data that doesn't render here); search spans everything. Selecting a
5
+ // row opens a form whose inputs are bound to exact source-map operation ids; DOM
6
+ // text matching is never used to decide what source bytes should be written.
7
+ import { routeFromAstroFile } from "./routes.js";
8
+ import { registerMediaPreview, publicPathFromRepoPath, applyMediaPreviews } from "./media-preview.js";
9
+
10
+ export function createContentPanel({
11
+ state,
12
+ routeFromMarkdownFile,
13
+ closeEditor,
14
+ mountPanel,
15
+ stageEdit,
16
+ downloadRepoPath,
17
+ fileToBase64,
18
+ setToolbarStatus,
19
+ escapeHtml,
20
+ escapeAttribute
21
+ }) {
22
+ function isFrontmatterEntry(entry) {
23
+ return entry.operations?.length === 1 && entry.operations[0].kind === "frontmatter";
24
+ }
25
+
26
+ function isMarkdownBodyEntry(entry) {
27
+ const kind = entry.operations?.[0]?.kind;
28
+ return kind === "markdown-text" || kind === "markdown-block";
29
+ }
30
+
31
+ function frontmatterGroups() {
32
+ // Keep fields in source order so the form follows the document rather than
33
+ // an arbitrary object/map iteration order.
34
+ const groups = new Map();
35
+ for (const entry of state.allSourceMap.filter(isFrontmatterEntry).filter(isEditorFacingFrontmatter)) {
36
+ if (!groups.has(entry.file)) groups.set(entry.file, []);
37
+ groups.get(entry.file).push(entry);
38
+ }
39
+ for (const entries of groups.values()) {
40
+ entries.sort((a, b) => a.operations[0].start - b.operations[0].start);
41
+ }
42
+ return groups;
43
+ }
44
+
45
+ function isEditorFacingFrontmatter(entry) {
46
+ return entry.operations?.[0]?.name !== "layout";
47
+ }
48
+
49
+ // "Page info" edits the fields that render nowhere visible on the page —
50
+ // the Google/browser-tab title, the meta description, and any other
51
+ // frontmatter — for Markdown pages AND .astro pages alike.
52
+ function openPageSettings() {
53
+ const markdown = markdownPageInfo();
54
+ if (markdown) {
55
+ openFrontmatterPanel(markdown.file, markdown.entries, "Page info");
56
+ return;
57
+ }
58
+ const astro = astroPageInfo();
59
+ if (astro) {
60
+ openDataModulePanel(astro.file, astro.entries, null, "Page info");
61
+ return;
62
+ }
63
+ setToolbarStatus("This page has no hidden fields to edit.");
64
+ }
65
+
66
+ function markdownPageInfo() {
67
+ const here = location.pathname.replace(/\/+$/, "") || "/";
68
+ const groups = frontmatterGroups();
69
+ const matchedFile = state.sourceMap.find(isMarkdownBodyEntry)?.file;
70
+ const group = matchedFile && groups.has(matchedFile)
71
+ ? [matchedFile, groups.get(matchedFile)]
72
+ : [...groups.entries()].find(([file]) => routeFromMarkdownFile(file) === here);
73
+ return group ? { file: group[0], entries: group[1] } : null;
74
+ }
75
+
76
+ // The current page's prop fields whose value never shows in the page's
77
+ // visible text — on a typical .astro page that is exactly the <head> title
78
+ // and description. Values that DO show on the page are edited by clicking
79
+ // them there, so they stay out of this form. Presentation-only filtering:
80
+ // every write still goes through the entry's exact source operations.
81
+ function astroPageInfo() {
82
+ const here = location.pathname.replace(/\/+$/, "") || "/";
83
+ const visible = normalizeForCompare(document.body.innerText);
84
+ for (const [file, entries] of dataModules()) {
85
+ if (!/\.astro$/i.test(file) || routeFromAstroFile(file, "") !== here) continue;
86
+ const picked = [];
87
+ for (const entry of entries) {
88
+ const hiddenOps = entry.operations.filter((operation) => {
89
+ const value = normalizeForCompare(operation.oldValue);
90
+ return value.length > 1 && !visible.includes(value.slice(0, 80));
91
+ });
92
+ if (hiddenOps.length) picked.push({ ...entry, operations: hiddenOps });
93
+ }
94
+ if (picked.length) return { file, entries: picked };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function normalizeForCompare(value) {
100
+ return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
101
+ }
102
+
103
+ function dataModules() {
104
+ // Navigation and general data share one deterministic form model. Their kind
105
+ // is retained because the source writer applies different safety rules.
106
+ const groups = new Map();
107
+ for (const entry of (state.allSourceMap || []).filter((item) => item.kind === "data" || item.kind === "nav")) {
108
+ if (!groups.has(entry.file)) groups.set(entry.file, []);
109
+ groups.get(entry.file).push(entry);
110
+ }
111
+ return groups;
112
+ }
113
+
114
+ // Whether the toolbar should offer the "Page info" button at all.
115
+ function currentPageHasFrontmatter() {
116
+ return Boolean(markdownPageInfo() || astroPageInfo());
117
+ }
118
+
119
+ function groupEntryFields(entry) {
120
+ // Analyzer-generated operation names encode array item boundaries. Turning
121
+ // them back into groups makes a menu/card collection readable to editors
122
+ // without exposing source filenames or implementation terminology.
123
+ const groups = new Map();
124
+ const ensure = (key, heading) => {
125
+ if (!groups.has(key)) groups.set(key, { key, heading, ops: [], media: [] });
126
+ return groups.get(key);
127
+ };
128
+ for (const operation of entry.operations) {
129
+ const item = operation.name.match(/^item(\d+)_/);
130
+ if (item) ensure(`i${item[1]}`, `#${Number(item[1]) + 1}`).ops.push(operation);
131
+ else if (/^item_\d+$/.test(operation.name)) ensure("links", "Menu links").ops.push(operation);
132
+ else if (/^prop\d+_/.test(operation.name)) ensure("page", "Page text").ops.push(operation);
133
+ else ensure("fields", "Details").ops.push(operation);
134
+ }
135
+ for (const media of entry.media || []) {
136
+ const item = media.name.match(/^item(\d+)_/);
137
+ ensure(item ? `i${item[1]}` : "images", item ? `#${Number(item[1]) + 1}` : "Images").media.push(media);
138
+ }
139
+ for (const group of groups.values()) {
140
+ const titleOperation = group.ops.find((operation) => /_(?:name|title|label|heading)$/i.test(operation.name));
141
+ if (!titleOperation) continue;
142
+ const value = state.pending.get(entry.id)?.value?.[titleOperation.name] ?? titleOperation.oldValue;
143
+ if (String(value || "").trim()) group.heading = value;
144
+ }
145
+ return [...groups.values()];
146
+ }
147
+
148
+ function dataFieldLabel(operation) {
149
+ if (operation.href) return operation.href;
150
+ let match = operation.name.match(/^item\d+_(.+)$/);
151
+ if (match) return fieldLabel(match[1]);
152
+ match = operation.name.match(/^item_(\d+)$/);
153
+ if (match) return `Link ${Number(match[1]) + 1}`;
154
+ match = operation.name.match(/^(?:field|prop)\d+_(.+)$/);
155
+ if (match) return fieldLabel(match[1]);
156
+ return fieldLabel(operation.name);
157
+ }
158
+
159
+ function openDataModulePanel(file, entries, focus, panelTitle) {
160
+ closeEditor();
161
+ const dialog = document.createElement("div");
162
+ dialog.dataset.charlescmsUi = "true";
163
+ dialog.className = "charlescms-panel charlescms-frontmatter";
164
+ const fieldHtml = (entry, operation) => {
165
+ const value = state.pending.get(entry.id)?.value?.[operation.name] ?? operation.oldValue;
166
+ const rows = Math.min(5, String(value).split("\n").length);
167
+ const control = String(value).length > 60
168
+ ? `<textarea data-data-entry="${escapeAttribute(entry.id)}" data-data-name="${escapeAttribute(operation.name)}" rows="${rows}">${escapeHtml(value)}</textarea>`
169
+ : `<input data-data-entry="${escapeAttribute(entry.id)}" data-data-name="${escapeAttribute(operation.name)}" value="${escapeAttribute(value)}">`;
170
+ const label = dataFieldLabel(operation);
171
+ const hint = panelTitle ? pageInfoHint(label) : "";
172
+ // The title attribute is the developer's x-ray: hovering a field reveals
173
+ // the technical operation name without adding any visible noise.
174
+ return `<label title="${escapeAttribute(operation.name)}">${escapeHtml(label)}${hint ? `<small class="charlescms-field-hint">${escapeHtml(hint)}</small>` : ""}\n${control}</label>`;
175
+ };
176
+ dialog.innerHTML = `
177
+ <div class="charlescms-panel-header">
178
+ <div>
179
+ <div class="charlescms-title">${escapeHtml(panelTitle || contentFileLabel(file))}</div>
180
+ ${panelTitle ? `<p class="charlescms-panel-subtitle">Fields that don't appear on the page itself.</p>` : ""}
181
+ </div>
182
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
183
+ </div>
184
+ <div class="charlescms-frontmatter-fields">
185
+ ${entries.map((entry) => groupEntryFields(entry).map((group) => `
186
+ <div class="charlescms-data-block" data-block="${escapeAttribute(`${entry.id}:${group.key}`)}">
187
+ ${group.heading && !panelTitle ? `<div class="charlescms-data-group">${escapeHtml(group.heading)}</div>` : ""}
188
+ ${group.ops.map((operation) => fieldHtml(entry, operation)).join("")}
189
+ ${group.media.map((media) => mediaFieldHtml(media)).join("")}
190
+ </div>
191
+ `).join("")).join("")}
192
+ </div>
193
+ <p class="charlescms-panel-note">Editing the content here updates every place it appears on the site. Link destinations and image paths are fixed; replacing an image overwrites the file in place. Changes apply after you publish and the site redeploys.</p>
194
+ <div class="charlescms-panel-source">${escapeHtml(sourceBadge(file, panelTitle ? "page props" : "data"))}</div>
195
+ <div class="charlescms-actions"><button data-close>Cancel</button><button data-save>Save changes</button></div>
196
+ `;
197
+ mountPanel(dialog);
198
+ if (focus) {
199
+ const block = dialog.querySelector(`[data-block="${CSS.escape(`${focus.entryId}:${focus.groupKey}`)}"]`);
200
+ if (block) {
201
+ block.scrollIntoView({ block: "center" });
202
+ block.classList.add("charlescms-flash");
203
+ const field = (focus.opName && block.querySelector(`[data-data-name="${CSS.escape(focus.opName)}"]`))
204
+ || block.querySelector("input, textarea");
205
+ field?.focus();
206
+ field?.select?.();
207
+ }
208
+ }
209
+ for (const input of dialog.querySelectorAll("[data-data-media]")) input.addEventListener("change", replaceDataMedia);
210
+ for (const close of dialog.querySelectorAll("[data-close]")) close.addEventListener("click", closeEditor);
211
+ dialog.querySelector("[data-save]").addEventListener("click", () => saveDataModule(dialog, file));
212
+ }
213
+
214
+ // A live "this is how the page appears on Google" preview. It mirrors the
215
+ // form's Title and Description fields as the user types, which explains these
216
+ // otherwise-invisible fields better than any label could.
217
+ // A whisper-quiet provenance line for developers ("about.md · frontmatter",
218
+ // "contact.astro · page props") — tiny and tucked under the form so it never
219
+ // distracts a non-technical editor.
220
+ function sourceBadge(file, kind) {
221
+ return `${String(file).replace(/\\/g, "/").split("/").pop()} · ${kind}`;
222
+ }
223
+
224
+ // Field hints for the Page info form: each field says where it shows up, so
225
+ // the form never needs to be decoded ("what is this title for?").
226
+ function pageInfoHint(label) {
227
+ if (/^title$/i.test(label)) return "The blue headline on Google — and the name of the browser tab.";
228
+ if (/^description$/i.test(label)) return "The grey text under the headline on Google.";
229
+ return "";
230
+ }
231
+
232
+
233
+ function saveDataModule(dialog, file) {
234
+ // Stage only entries with changed inputs. Each staged value still includes
235
+ // all fields in that entry so the writer receives a complete, stable shape.
236
+ const touched = new Set();
237
+ for (const input of dialog.querySelectorAll("[data-data-entry]")) {
238
+ const entry = state.sourceMapById.get(input.dataset.dataEntry);
239
+ const operation = entry?.operations?.find((item) => item.name === input.dataset.dataName);
240
+ if (!entry || !operation) continue;
241
+ const current = state.pending.get(entry.id)?.value?.[operation.name] ?? operation.oldValue;
242
+ if (input.value !== current) touched.add(entry);
243
+ }
244
+ for (const entry of touched) {
245
+ const original = {};
246
+ const value = {};
247
+ for (const operation of entry.operations) {
248
+ original[operation.name] = operation.oldValue;
249
+ const input = dialog.querySelector(`[data-data-entry="${CSS.escape(entry.id)}"][data-data-name="${CSS.escape(operation.name)}"]`);
250
+ value[operation.name] = input ? input.value : operation.oldValue;
251
+ }
252
+ const item = {
253
+ id: entry.id,
254
+ element: null,
255
+ fields: entry.operations.map((operation) => operation.name),
256
+ type: entry.kind === "nav" ? "nav" : "data",
257
+ value: original,
258
+ label: `${contentFileLabel(file)}: ${entry.label}`
259
+ };
260
+ stageEdit(item, value, {
261
+ id: entry.id,
262
+ type: item.type,
263
+ route: location.pathname,
264
+ value,
265
+ label: item.label,
266
+ original: JSON.stringify(original)
267
+ });
268
+ applyBridgedValues(entry.id, value);
269
+ }
270
+ closeEditor();
271
+ }
272
+
273
+ // Push saved values back into the page immediately: every element the bridge
274
+ // bound for this entry carries the operation name it renders, so the preview
275
+ // updates the moment Save is pressed — publish is only for going live.
276
+ function applyBridgedValues(entryId, value) {
277
+ for (const node of document.querySelectorAll(`[data-charlescms-bridge-entry="${CSS.escape(entryId)}"]`)) {
278
+ const operationName = node.dataset.charlescmsBridgeOp;
279
+ if (operationName === undefined || value[operationName] === undefined) continue;
280
+ if (node.tagName === "IMG") continue; // media swaps run through their own flow
281
+ setBridgedText(node, String(value[operationName]));
282
+ }
283
+ }
284
+
285
+ function setBridgedText(node, text) {
286
+ // Elements with child markup keep their structure: the FIRST non-empty text
287
+ // node — at any depth — is replaced. Walking the whole subtree (not just direct
288
+ // children) is what makes a menu link reflect its edit: an active nav link
289
+ // renders its label next to an indicator element, and a link can wrap its label
290
+ // in a <span>, so the text we must update is nested, not a direct child. The old
291
+ // direct-child-only check missed those and prepended a duplicate instead.
292
+ if (!node.children.length) {
293
+ node.textContent = text;
294
+ return;
295
+ }
296
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
297
+ let textNode;
298
+ while ((textNode = walker.nextNode())) {
299
+ if (textNode.textContent.trim()) {
300
+ textNode.textContent = text;
301
+ return;
302
+ }
303
+ }
304
+ node.prepend(document.createTextNode(text));
305
+ }
306
+
307
+ function decodeFrontmatterValue(value, quote) {
308
+ // Source-map values preserve source escaping. Forms display the human value;
309
+ // the source writer later re-encodes it using the original quote style.
310
+ const raw = String(value ?? "");
311
+ if (quote === '"') {
312
+ try { return JSON.parse(`"${raw}"`); } catch { return raw; }
313
+ }
314
+ if (quote === "'") return raw.replaceAll("''", "'");
315
+ return raw;
316
+ }
317
+
318
+ function mediaFieldHtml(media) {
319
+ const repoPath = downloadRepoPath(media.path);
320
+ const thumb = `<img src="${escapeAttribute(media.path)}" alt="" style="width:44px;height:44px;object-fit:cover;border-radius:6px;margin-right:10px;vertical-align:middle;border:1px solid rgba(0,0,0,.1)">`;
321
+ const control = state.previewOnly
322
+ ? "<span>Uploads are disabled in this demo.</span>"
323
+ : repoPath
324
+ ? `<input type="file" accept="image/*" data-data-media="${escapeAttribute(repoPath)}">`
325
+ : "<span>External or dynamic image — not replaceable.</span>";
326
+ return `<label>${escapeHtml(media.label)} · image\n<span style="display:flex;align-items:center">${thumb}${control}</span></label>`;
327
+ }
328
+
329
+ async function replaceDataMedia(event) {
330
+ if (state.previewOnly) return;
331
+ const file = event.target.files?.[0];
332
+ const repoPath = event.target.dataset.dataMedia;
333
+ if (!file || !repoPath) return;
334
+ event.target.disabled = true;
335
+ try {
336
+ const existing = await state.connector.getFile(repoPath).catch(() => null);
337
+ await state.connector.putBase64(repoPath, await fileToBase64(file), {
338
+ sha: existing?.sha,
339
+ message: `Replace image: ${repoPath}`
340
+ });
341
+ // Show the new image right away (on the page and the thumbnail) — the
342
+ // committed file isn't served until the site rebuilds, so a local blob
343
+ // bridges the gap and survives navigation within the session.
344
+ registerMediaPreview(publicPathFromRepoPath(repoPath), file);
345
+ applyMediaPreviews();
346
+ setToolbarStatus(`Replaced ${repoPath}. Redeploy to serve the new file.`);
347
+ } catch (error) {
348
+ setToolbarStatus(error.message || "Replace failed.");
349
+ } finally {
350
+ event.target.disabled = false;
351
+ }
352
+ }
353
+
354
+ function openFrontmatterPanel(file, entries, panelTitle) {
355
+ closeEditor();
356
+ const dialog = document.createElement("div");
357
+ dialog.dataset.charlescmsUi = "true";
358
+ dialog.className = "charlescms-panel charlescms-frontmatter";
359
+ dialog.innerHTML = `
360
+ <div class="charlescms-panel-header">
361
+ <div>
362
+ <div class="charlescms-title">${escapeHtml(panelTitle || contentFileLabel(file))}</div>
363
+ ${panelTitle ? `<p class="charlescms-panel-subtitle">Fields that don't appear on the page itself.</p>` : ""}
364
+ </div>
365
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
366
+ </div>
367
+ <div class="charlescms-frontmatter-fields">
368
+ ${entries.map((entry) => {
369
+ const operation = entry.operations[0];
370
+ const value = state.pending.get(entry.id)?.value?.[operation.name]
371
+ ?? decodeFrontmatterValue(operation.oldValue, operation.quote);
372
+ const rows = Math.min(6, value.split("\n").length);
373
+ const label = fieldLabel(operation.name);
374
+ const hint = panelTitle ? pageInfoHint(label) : "";
375
+ return `<label title="${escapeAttribute(operation.name)}">${escapeHtml(label)}${hint ? `<small class="charlescms-field-hint">${escapeHtml(hint)}</small>` : ""}
376
+ <textarea data-frontmatter-id="${escapeAttribute(entry.id)}" rows="${rows}">${escapeHtml(value)}</textarea>
377
+ </label>`;
378
+ }).join("")}
379
+ </div>
380
+ <div class="charlescms-panel-source">${escapeHtml(sourceBadge(file, "frontmatter"))}</div>
381
+ <div class="charlescms-actions"><button data-close>Cancel</button><button data-save>Save changes</button></div>
382
+ `;
383
+ mountPanel(dialog);
384
+ for (const close of dialog.querySelectorAll("[data-close]")) close.addEventListener("click", closeEditor);
385
+ dialog.querySelector("[data-save]").addEventListener("click", () => {
386
+ for (const input of dialog.querySelectorAll("[data-frontmatter-id]")) {
387
+ const entry = state.sourceMapById.get(input.dataset.frontmatterId);
388
+ const operation = entry?.operations?.[0];
389
+ if (!entry || !operation) continue;
390
+ const current = state.pending.get(entry.id)?.value?.[operation.name]
391
+ ?? decodeFrontmatterValue(operation.oldValue, operation.quote);
392
+ if (input.value === current) continue;
393
+ const value = { [operation.name]: input.value };
394
+ const item = {
395
+ id: entry.id,
396
+ element: null,
397
+ fields: [operation.name],
398
+ type: "frontmatter",
399
+ value: { [operation.name]: current },
400
+ label: `${contentFileLabel(file)}: ${fieldLabel(operation.name)}`
401
+ };
402
+ stageEdit(item, value, {
403
+ id: entry.id,
404
+ type: "frontmatter",
405
+ route: routeFromMarkdownFile(file) || location.pathname,
406
+ value,
407
+ label: item.label,
408
+ original: current
409
+ });
410
+ applyBridgedValues(entry.id, value);
411
+ }
412
+ closeEditor();
413
+ });
414
+ }
415
+
416
+ return {
417
+ isFrontmatterEntry,
418
+ isMarkdownBodyEntry,
419
+ frontmatterGroups,
420
+ dataModules,
421
+ currentPageHasFrontmatter,
422
+ applyBridgedValues,
423
+ openPageSettings,
424
+ openDataModulePanel,
425
+ openFrontmatterPanel
426
+ };
427
+ }
428
+
429
+ function contentFileLabel(file) {
430
+ const name = (String(file).replace(/\\/g, "/").split("/").pop() || file)
431
+ .replace(/\.(?:md|mdx|ts|js|mjs|cjs|json|astro)$/i, "");
432
+ return name.charAt(0).toUpperCase() + name.slice(1);
433
+ }
434
+
435
+ function fieldLabel(name) {
436
+ return String(name)
437
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
438
+ .replace(/[-_.]+/g, " ")
439
+ .replace(/^./, (character) => character.toUpperCase());
440
+ }