@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,310 @@
1
+ import { escapeAstroBraces, sanitizeBlockHtml, sanitizeRichText } from "./sanitize.js";
2
+ import { load } from "js-yaml";
3
+ import { fromMarkdown } from "mdast-util-from-markdown";
4
+ import { gfmFromMarkdown, gfmToMarkdown } from "mdast-util-gfm";
5
+ import { toMarkdown } from "mdast-util-to-markdown";
6
+ import { toString } from "mdast-util-to-string";
7
+ import { gfm } from "micromark-extension-gfm";
8
+ import { parseFragment } from "parse5";
9
+
10
+ // Applies staged edits at the source boundary.
11
+ //
12
+ // Every operation first proves that its recorded bytes are still present. Rich
13
+ // content is parsed and sanitized again here, so browser previews are never
14
+ // trusted as permission to write arbitrary markup or alter surrounding syntax.
15
+ const markdownInlineTypes = new Set(["text", "emphasis", "strong", "inlineCode", "link", "break"]);
16
+
17
+ /**
18
+ * Produce the new file contents for one staged edit, applied at the source boundary.
19
+ *
20
+ * Every operation first proves its recorded bytes are still present, and rich
21
+ * content is re-parsed and re-sanitized here, so a browser preview is never
22
+ * trusted as permission to write arbitrary markup or alter surrounding syntax.
23
+ *
24
+ * @param {string} source Current file contents.
25
+ * @param {object} entry Source-map entry describing the region (operations, tag, file).
26
+ * @param {object} edit Staged change ({ action: "update"|"insert"|"delete", value, ... }).
27
+ * @returns {string} The edited file contents.
28
+ * @throws {UnsafeSourceEditError} If recorded bytes drifted or the edit is disallowed.
29
+ */
30
+ export function createEditedSource(source, entry, edit) {
31
+ if (edit.action === "insert") {
32
+ return createInsertedSource(source, entry, edit);
33
+ }
34
+ if (edit.action === "delete") {
35
+ return createDeletedSource(source, entry);
36
+ }
37
+
38
+ for (const operation of entry.operations) {
39
+ if (entry.tag === "a" && operation.kind.startsWith("attribute")) {
40
+ throw new UnsafeSourceEditError("Link attributes are not editable.");
41
+ }
42
+ const current = source.slice(operation.start, operation.end);
43
+ if (current !== operation.oldValue) {
44
+ throw new UnsafeSourceEditError("Source changed since the CMS build. Refusing unsafe edit.");
45
+ }
46
+ }
47
+
48
+ let next = source;
49
+ for (const operation of [...entry.operations].sort((a, b) => b.start - a.start)) {
50
+ const value = getOperationValue(edit.value, operation);
51
+ next = `${next.slice(0, operation.start)}${serializeOperationValue(value, operation)}${next.slice(operation.end)}`;
52
+ }
53
+ return next;
54
+ }
55
+
56
+ function createInsertedSource(source, entry, edit) {
57
+ const anchor = entry.anchors?.find((item) => item.anchorId === edit.anchorId);
58
+ if (!anchor) {
59
+ throw new UnsafeSourceEditError("No verified section anchor exists for this insertion.");
60
+ }
61
+ assertOuterSpan(source, anchor);
62
+ if (edit.position !== "before" && edit.position !== "after") {
63
+ throw new UnsafeSourceEditError("Section position must be before or after its anchor.");
64
+ }
65
+ const html = sanitizeBlockHtml(edit.value).trim();
66
+ if (!html) {
67
+ throw new UnsafeSourceEditError("A section needs at least one safe content block.");
68
+ }
69
+ const sectionClass = sanitizeClassList(entry.sectionClass);
70
+ const classAttribute = sectionClass ? ` class="${sectionClass}"` : "";
71
+ const blockId = createBlockId();
72
+ const indent = lineIndentAt(source, anchor.outerStart);
73
+ const wrapper = `<div data-charlescms-block="${blockId}"${classAttribute}>${html}</div>`;
74
+ const index = edit.position === "before" ? anchor.outerStart : anchor.outerEnd;
75
+ const insertion = edit.position === "before"
76
+ ? `${wrapper}\n${indent}`
77
+ : `\n${indent}${wrapper}`;
78
+ return `${source.slice(0, index)}${insertion}${source.slice(index)}`;
79
+ }
80
+
81
+ function createDeletedSource(source, entry) {
82
+ assertOuterSpan(source, entry);
83
+ return `${source.slice(0, entry.outerStart)}${source.slice(entry.outerEnd)}`;
84
+ }
85
+
86
+ export class UnsafeSourceEditError extends Error {
87
+ constructor(message) {
88
+ super(message);
89
+ this.name = "UnsafeSourceEditError";
90
+ }
91
+ }
92
+
93
+ function getOperationValue(value, operation) {
94
+ if (typeof value === "string") return value;
95
+ if (value && typeof value === "object") {
96
+ return value[operation.name] ?? value[operation.kind] ?? "";
97
+ }
98
+ return "";
99
+ }
100
+
101
+ function serializeOperationValue(value, operation) {
102
+ // Rich text is written verbatim, but only AFTER the authoritative sanitizer
103
+ // re-checks it here — never trust whatever produced the edit. The whole-file
104
+ // Astro-compiler validation downstream is the second gate.
105
+ if (operation.kind === "rich-text") {
106
+ // Lands directly in .astro markup, so braces must be neutralized too —
107
+ // sanitizeRichText preserves them (it is also reused for the Markdown path,
108
+ // where braces are literal and must stay).
109
+ return escapeAstroBraces(sanitizeRichText(value));
110
+ }
111
+ if (operation.kind === "block-html") {
112
+ return sanitizeBlockHtml(value);
113
+ }
114
+ if (operation.kind === "frontmatter") {
115
+ return serializeFrontmatterValue(value, operation);
116
+ }
117
+ if (operation.kind === "markdown-text") {
118
+ return serializeMarkdownText(value);
119
+ }
120
+ if (operation.kind === "markdown-block") {
121
+ return serializeMarkdownBlock(value, operation);
122
+ }
123
+ if (operation.kind === "data-string") {
124
+ return serializeDataString(value, operation);
125
+ }
126
+ // A value resolved from a `{const}` — an inline {expr} heading or a media
127
+ // src/alt that points at a frontmatter constant — is written back INTO that JS
128
+ // string literal. So it is escaped for the literal's quote exactly like a data
129
+ // string (which it now carries), and is NEVER HTML-encoded: Astro escapes the
130
+ // `{expr}` itself at render, so HTML-encoding here would surface `&amp;`/`&lt;`
131
+ // verbatim on the page. serializeDataString also refuses newlines and escapes
132
+ // the quote/backslash, so the value can never break out of the literal.
133
+ if (operation.kind === "text-expression" || operation.kind === "attribute-expression") {
134
+ return serializeDataString(value, operation);
135
+ }
136
+ return escapeSourceValue(value, operation);
137
+ }
138
+
139
+ // A navigation label is written back into a JS/TS string literal in a data
140
+ // file. Only the characters that could break out of that literal are escaped
141
+ // (the enclosing quote and backslash); newlines are refused so a label can
142
+ // never split the array across lines. The href is never an operation here, so
143
+ // the link destination cannot be reached through this path.
144
+ function serializeDataString(value, operation) {
145
+ const raw = String(value);
146
+ if (/[\r\n]/.test(raw)) {
147
+ throw new UnsafeSourceEditError("Navigation labels must stay on one line.");
148
+ }
149
+ const quote = operation.quote === "'" ? "'" : '"';
150
+ return raw.replaceAll("\\", "\\\\").replaceAll(quote, `\\${quote}`);
151
+ }
152
+
153
+ function serializeFrontmatterValue(value, operation) {
154
+ const raw = String(value);
155
+
156
+ if (operation.valueType === "boolean") {
157
+ if (!/^(?:true|false)$/i.test(raw.trim())) {
158
+ throw new UnsafeSourceEditError("Frontmatter boolean must be true or false.");
159
+ }
160
+ return raw.trim().toLowerCase();
161
+ }
162
+ if (operation.valueType === "number") {
163
+ const number = Number(raw.trim());
164
+ if (!raw.trim() || !Number.isFinite(number)) {
165
+ throw new UnsafeSourceEditError("Frontmatter number is invalid.");
166
+ }
167
+ return raw.trim();
168
+ }
169
+ if (operation.valueType === "date") {
170
+ const trimmed = raw.trim();
171
+ if (!/^\d{4}-\d{2}-\d{2}(?:[Tt][^\s]+)?$/.test(trimmed) || Number.isNaN(Date.parse(trimmed))) {
172
+ throw new UnsafeSourceEditError("Frontmatter date is invalid.");
173
+ }
174
+ return trimmed;
175
+ }
176
+
177
+ // A double-quoted YAML string can carry a line break safely as the escape
178
+ // "\n" — so the editor can show clean multi-line text and we encode it here.
179
+ if (operation.quote === '"') return JSON.stringify(raw).slice(1, -1);
180
+ // Single-quoted and plain scalars cannot, so a newline there is refused.
181
+ if (/[\r\n]/.test(raw)) {
182
+ throw new UnsafeSourceEditError("This value must stay on one line.");
183
+ }
184
+ if (operation.quote === "'") return raw.replaceAll("'", "''");
185
+ return isSafePlainYamlString(raw) ? raw : JSON.stringify(raw);
186
+ }
187
+
188
+ function isSafePlainYamlString(value) {
189
+ if (!value || value.trim() !== value) return false;
190
+ if (/^(?:[-?:,\[\]{}#&*!|>'"%@`]|\.\.\.)/.test(value)) return false;
191
+ if (/:\s|(?:^|\s)#/.test(value)) return false;
192
+ try {
193
+ return load(`value: ${value}\n`)?.value === value;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ function serializeMarkdownText(value) {
200
+ const raw = String(value);
201
+ if (/[\r\n]/.test(raw)) {
202
+ throw new UnsafeSourceEditError("Markdown text must stay on one line.");
203
+ }
204
+ if (/^(?:#{1,6}(?:\s|$)|>|[-*+](?:\s|$)|`{3,}|\||\d+\.(?:\s|$))/.test(raw.trimStart())) {
205
+ throw new UnsafeSourceEditError("Markdown text cannot introduce a block marker.");
206
+ }
207
+ const tree = parseMarkdown(raw);
208
+ const paragraph = singleMarkdownParagraph(tree);
209
+ if (!paragraph || paragraph.children.length !== 1 || paragraph.children[0].type !== "text") {
210
+ throw new UnsafeSourceEditError("Markdown text cannot introduce inline formatting.");
211
+ }
212
+ return raw;
213
+ }
214
+
215
+ function serializeMarkdownBlock(value, operation) {
216
+ const html = sanitizeRichText(value);
217
+ const fragment = parseFragment(html);
218
+ const children = fragment.childNodes.flatMap(htmlNodeToMdast);
219
+ const tree = { type: "root", children: [{ type: "paragraph", children }] };
220
+ const markdown = toMarkdown(tree, { extensions: [gfmToMarkdown()] }).trimEnd();
221
+ const parsed = parseMarkdown(markdown);
222
+ const paragraph = singleMarkdownParagraph(parsed);
223
+ if (!paragraph || !paragraph.children.every(isAllowedMarkdownInline)) {
224
+ throw new UnsafeSourceEditError("Rich Markdown did not round-trip as one safe inline block.");
225
+ }
226
+
227
+ const original = singleMarkdownParagraph(parseMarkdown(operation.oldValue));
228
+ if (original && toString(paragraph) === toString(original)) {
229
+ return operation.oldValue;
230
+ }
231
+ return markdown;
232
+ }
233
+
234
+ function parseMarkdown(value) {
235
+ try {
236
+ return fromMarkdown(value, {
237
+ extensions: [gfm()],
238
+ mdastExtensions: [gfmFromMarkdown()]
239
+ });
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ function singleMarkdownParagraph(tree) {
246
+ return tree?.children?.length === 1 && tree.children[0].type === "paragraph"
247
+ ? tree.children[0]
248
+ : null;
249
+ }
250
+
251
+ function isAllowedMarkdownInline(node) {
252
+ return markdownInlineTypes.has(node.type) && (node.children || []).every(isAllowedMarkdownInline);
253
+ }
254
+
255
+ function htmlNodeToMdast(node) {
256
+ if (node.nodeName === "#text") return node.value ? [{ type: "text", value: node.value }] : [];
257
+ if (!node.tagName) return [];
258
+ const children = (node.childNodes || []).flatMap(htmlNodeToMdast);
259
+ if (node.tagName === "strong" || node.tagName === "b") return [{ type: "strong", children }];
260
+ if (node.tagName === "em" || node.tagName === "i") return [{ type: "emphasis", children }];
261
+ if (node.tagName === "code") return [{ type: "inlineCode", value: toString({ type: "paragraph", children }) }];
262
+ if (node.tagName === "br") return [{ type: "break" }];
263
+ if (node.tagName === "a") {
264
+ const href = node.attrs?.find((attribute) => attribute.name === "href")?.value || "";
265
+ return href ? [{ type: "link", url: href, children }] : children;
266
+ }
267
+ return children;
268
+ }
269
+
270
+ function assertOuterSpan(source, entry) {
271
+ if (!Number.isInteger(entry.outerStart) || !Number.isInteger(entry.outerEnd) || typeof entry.outerValue !== "string") {
272
+ throw new UnsafeSourceEditError("No verified outer source span exists for this element.");
273
+ }
274
+ if (source.slice(entry.outerStart, entry.outerEnd) !== entry.outerValue) {
275
+ throw new UnsafeSourceEditError("Source changed since the CMS build. Refusing unsafe edit.");
276
+ }
277
+ }
278
+
279
+ function sanitizeClassList(value) {
280
+ return String(value || "")
281
+ .split(/\s+/)
282
+ .filter((token) => /^[A-Za-z0-9_:/.[\]%-]+$/.test(token))
283
+ .join(" ");
284
+ }
285
+
286
+ function lineIndentAt(source, index) {
287
+ const lineStart = source.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
288
+ return source.slice(lineStart, index).match(/^\s*/)?.[0] || "";
289
+ }
290
+
291
+ function createBlockId() {
292
+ const random = globalThis.crypto?.randomUUID?.().replaceAll("-", "");
293
+ return `block_${random || Math.random().toString(16).slice(2)}`
294
+ }
295
+
296
+ function escapeSourceValue(value, operation) {
297
+ const raw = String(value);
298
+ // Both branches write into .astro source, where a literal `{`/`}` is parsed as
299
+ // a JS expression — escapeAstroBraces neutralizes it. Braces are escaped last
300
+ // so the `&` in their entity form is not caught by the `&` replacement above.
301
+ if (operation.kind === "text") {
302
+ return escapeAstroBraces(raw.replaceAll("&", "&amp;").replaceAll("<", "&lt;"));
303
+ }
304
+ const quote = operation.quote || '"';
305
+ return escapeAstroBraces(raw
306
+ .replaceAll("&", "&amp;")
307
+ .replaceAll("<", "&lt;")
308
+ .replaceAll(">", "&gt;")
309
+ .replaceAll(quote, quote === '"' ? "&quot;" : "&#039;"));
310
+ }
@@ -0,0 +1,184 @@
1
+ import { fieldName } from "./fields.js";
2
+
3
+ // Turns the build-time source map into safe runtime bindings.
4
+ //
5
+ // The important boundary is conservative matching: an entry receives a DOM id
6
+ // only when the number and order of matching elements are unambiguous. If the
7
+ // rendered page contains extra dynamic copies, the runtime leaves them unbound
8
+ // instead of guessing which source span should be edited.
9
+ export function createSourceMapRuntime({
10
+ state,
11
+ sourceMapData,
12
+ routeFromAstroFile,
13
+ routeFromMarkdownFile,
14
+ isFrontmatterEntry,
15
+ isMarkdownBodyEntry,
16
+ isVisible
17
+ }) {
18
+ function loadSourceMap() {
19
+ const entries = sourceMapData?.entries || {};
20
+ state.allSourceMap = Object.entries(entries).map(([id, entry]) => ({
21
+ id,
22
+ ...entry,
23
+ fields: entry.fields || [...new Set((entry.operations || []).map(fieldName))]
24
+ }));
25
+ state.sourceMap = state.allSourceMap
26
+ .filter((entry) => !isFrontmatterEntry(entry))
27
+ .filter(entryMatchesCurrentRoute);
28
+ state.sourceMapById = new Map(state.sourceMap.map((entry) => [entry.id, entry]));
29
+ for (const entry of state.allSourceMap) state.sourceMapById.set(entry.id, entry);
30
+ }
31
+
32
+ function entryMatchesCurrentRoute(entry) {
33
+ const here = location.pathname.replace(/\/+$/, "") || "/";
34
+ if (isMarkdownBodyEntry(entry)) {
35
+ if (routeFromMarkdownFile(entry.file) === here) return true;
36
+ // A content-collection file renders on a route derived from its slug, not
37
+ // its path (src/content/blog/p.md → /blog/p). Matching by slug binds the
38
+ // whole post — including SHORT blocks like an "Example" heading that the
39
+ // content-presence fallback below skips (its ≥12-char guard drops them).
40
+ if (entryMatchesRouteSlug(entry, here)) return true;
41
+ // Otherwise content presence is the only route-independent signal.
42
+ return entryRenderedHere(entry);
43
+ }
44
+ return routeFromAstroFile(entry.file, here) === here;
45
+ }
46
+
47
+ function entryRenderedHere(entry) {
48
+ const pageText = normalizeRenderedText(document.body.innerText)
49
+ .replace(/[*_`#>~\[\]()]/g, "");
50
+ for (const operation of entry.operations || []) {
51
+ const clean = normalizeRenderedText(operation.oldValue).replace(/[*_`#>~\[\]()]/g, "");
52
+ if (clean.length >= 12 && pageText.includes(clean.slice(0, 40))) return true;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function applySourceMapIds() {
58
+ const groups = new Map();
59
+ for (const entry of state.sourceMap) {
60
+ if (entry.kind === "section-container") continue;
61
+ if (document.querySelector(`[data-charlescms-id="${CSS.escape(entry.id)}"]`)) continue;
62
+ const signature = entrySignature(entry);
63
+ if (!groups.has(signature)) groups.set(signature, []);
64
+ groups.get(signature).push(entry);
65
+ }
66
+
67
+ const here = location.pathname.replace(/\/+$/, "") || "/";
68
+ for (const group of groups.values()) {
69
+ let entries = group;
70
+ // Resolve same-text ambiguity by the URL: when entries with identical text
71
+ // come from different content-collection files, the one whose slug matches
72
+ // the current route is the page's content (Astro routes `/blog/b1` from
73
+ // `src/content/blog/b1.md`). This lets real collection content — and even
74
+ // duplicate-placeholder posts — bind correctly instead of being skipped.
75
+ if (entries.length > 1) {
76
+ const routed = entries.filter((entry) => entryMatchesRouteSlug(entry, here));
77
+ if (routed.length && routed.length < entries.length) entries = routed;
78
+ }
79
+ entries = [...entries].sort((a, b) => {
80
+ const byFile = String(a.file || "").localeCompare(String(b.file || ""));
81
+ return byFile || entrySourceOrder(a) - entrySourceOrder(b);
82
+ });
83
+ const candidates = [...document.querySelectorAll(entries[0].tag)]
84
+ .filter((element) => !element.closest("[data-charlescms-ui]"))
85
+ .filter((element) => !element.dataset.charlescmsId)
86
+ .filter(isVisible)
87
+ .filter((element) => elementMatchesEntry(element, entries[0]));
88
+
89
+ // Source-order pairing is safe only when both sides have the same count.
90
+ if (candidates.length !== entries.length) continue;
91
+ entries.forEach((entry, index) => {
92
+ candidates[index].dataset.charlescmsId = entry.id;
93
+ candidates[index].dataset.charlescmsFields = entry.fields.join(",");
94
+ });
95
+ }
96
+ }
97
+
98
+ function scanEditableElements(describeElement) {
99
+ // Visibility is intentionally not required here. Lazy or responsive content
100
+ // may have no layout during the first pass but still has a stable source id.
101
+ return [...document.querySelectorAll("[data-charlescms-id]")]
102
+ .filter((element) => !element.closest("[data-charlescms-ui]"))
103
+ .map(describeElement)
104
+ .filter(Boolean);
105
+ }
106
+
107
+ function entrySignature(entry) {
108
+ if (isMarkdownBodyEntry(entry)) {
109
+ return `${entry.tag}::markdown=${normalizeRenderedText(entry.text)}`;
110
+ }
111
+ if (entry.operations?.[0]?.kind === "block-html") {
112
+ return `${entry.tag}::block=${normalizeFragment(entry.operations[0].oldValue)}`;
113
+ }
114
+ return [entry.tag, ...entry.operations.map((operation) => `${operation.name}=${operation.oldValue}`)].join("::");
115
+ }
116
+
117
+ function elementMatchesEntry(element, entry) {
118
+ if (isMarkdownBodyEntry(entry)) {
119
+ return normalizeRenderedText(element.textContent) === normalizeRenderedText(entry.text);
120
+ }
121
+ for (const operation of entry.operations) {
122
+ if (operation.kind === "block-html") {
123
+ if (normalizeFragment(element.innerHTML) !== normalizeFragment(operation.oldValue)) return false;
124
+ } else if (operation.name === "text") {
125
+ if (!textMatchesSource(element, operation.oldValue)) return false;
126
+ } else {
127
+ const value = operation.name === "data" && element.tagName.toLowerCase() === "object"
128
+ ? element.data
129
+ : element.getAttribute(operation.name);
130
+ if (value !== operation.oldValue) return false;
131
+ }
132
+ }
133
+ return true;
134
+ }
135
+
136
+ return { loadSourceMap, applySourceMapIds, scanEditableElements };
137
+ }
138
+
139
+ function entrySourceOrder(entry) {
140
+ const starts = entry.operations
141
+ .map((operation) => operation.start)
142
+ .filter((value) => typeof value === "number");
143
+ return starts.length ? Math.min(...starts) : 0;
144
+ }
145
+
146
+ // A content-collection entry's slug: its path under src/content/<collection>/,
147
+ // minus the extension (e.g. "src/content/blog/b1.md" -> "blog/b1", basename "b1").
148
+ // Astro routes a collection entry by this slug, so it's how we tie a rendered
149
+ // page to the right source file when several files share identical text.
150
+ export function entryMatchesRouteSlug(entry, here) {
151
+ const match = String(entry.file || "").replace(/\\/g, "/")
152
+ .match(/src\/content\/[^/]+\/(.+?)\.(?:md|mdx|markdoc)$/i);
153
+ if (!match) return false;
154
+ const slug = match[1].replace(/^\/+|\/+$/g, "");
155
+ const route = String(here || "").replace(/^\/+|\/+$/g, "");
156
+ const segments = route.split("/").filter(Boolean);
157
+ const base = slug.split("/").pop();
158
+ return route === slug || route.endsWith(`/${slug}`) || segments.includes(base);
159
+ }
160
+
161
+ function normalizeRenderedText(value) {
162
+ return String(value || "")
163
+ .replace(/[\u2018\u2019\u201a\u201b]/g, "'")
164
+ .replace(/[\u201c\u201d\u201e\u201f]/g, '"')
165
+ .replace(/[\u2013\u2014]/g, "-")
166
+ .replace(/\s+/g, " ")
167
+ .trim();
168
+ }
169
+
170
+ function textMatchesSource(element, oldValue) {
171
+ if (element.textContent.trim() === oldValue) return true;
172
+ return normalizeFragment(element.innerHTML) === normalizeFragment(oldValue);
173
+ }
174
+
175
+ function normalizeFragment(html) {
176
+ const template = document.createElement("template");
177
+ template.innerHTML = String(html).trim();
178
+ for (const node of template.content.querySelectorAll("*")) {
179
+ for (const attribute of [...node.attributes]) {
180
+ if (attribute.name.startsWith("data-astro")) node.removeAttribute(attribute.name);
181
+ }
182
+ }
183
+ return template.innerHTML.trim();
184
+ }
@@ -0,0 +1,145 @@
1
+ // Lists every staged-but-unpublished change so the toolbar's count is never a
2
+ // mystery. Because edits now persist across navigation, a change can live on a
3
+ // page you're not currently looking at — this panel says exactly which page each
4
+ // one is on, lets you VIEW a change on the current page (scroll to it + flash it),
5
+ // or OPEN one on another page. Everything here publishes together.
6
+ export function createStagedPanel({
7
+ state,
8
+ closeEditor,
9
+ mountPanel,
10
+ discardPending,
11
+ publishPending,
12
+ escapeHtml,
13
+ escapeAttribute
14
+ }) {
15
+ function findChangeElement(pending, opName) {
16
+ // Jump to the element bound to the EXACT operation that changed (e.g. the one
17
+ // menu link that was renamed). If that operation has no element on the page,
18
+ // return null rather than flashing a DIFFERENT field of the same entry — a
19
+ // wrong highlight is more misleading than none.
20
+ if (opName) {
21
+ return document.querySelector(`[data-charlescms-bridge-entry="${CSS.escape(pending.id)}"][data-charlescms-bridge-op="${CSS.escape(opName)}"]`) || null;
22
+ }
23
+ return document.querySelector(`[data-charlescms-id="${CSS.escape(pending.id)}"]`)
24
+ || document.querySelector(`[data-charlescms-bridge-entry="${CSS.escape(pending.id)}"]`);
25
+ }
26
+
27
+ function viewChange(pending, opName) {
28
+ closeEditor();
29
+ const element = findChangeElement(pending, opName);
30
+ if (!element) return;
31
+ element.scrollIntoView({ behavior: "smooth", block: "center" });
32
+ element.classList.add("charlescms-flash");
33
+ setTimeout(() => element.classList.remove("charlescms-flash"), 1900);
34
+ }
35
+
36
+ function openStagedPanel() {
37
+ closeEditor();
38
+ const here = normalizeRoute(location.pathname);
39
+ const items = [...state.pending.values()];
40
+ const offPage = items.filter((pending) => normalizeRoute(pending.route) !== here).length;
41
+
42
+ const dialog = document.createElement("div");
43
+ dialog.dataset.charlescmsUi = "true";
44
+ dialog.className = "charlescms-panel";
45
+ dialog.innerHTML = `
46
+ <div class="charlescms-panel-header">
47
+ <div>
48
+ <div class="charlescms-kicker">Staged changes</div>
49
+ <div class="charlescms-title">${items.length} unpublished change${items.length === 1 ? "" : "s"}</div>
50
+ </div>
51
+ <button class="charlescms-icon-button" data-close aria-label="Close">×</button>
52
+ </div>
53
+ <p class="charlescms-panel-note">${items.length
54
+ ? `They all go live together when you publish.${offPage ? ` ${offPage} ${offPage === 1 ? "is" : "are"} on another page.` : ""}`
55
+ : "Nothing staged yet. Edits you make are collected here until you publish."}</p>
56
+ <div class="charlescms-versions-list" data-staged-list></div>
57
+ <div class="charlescms-actions">
58
+ ${items.length ? `<button class="charlescms-danger-button" data-discard-all>Discard all</button>` : ""}
59
+ <button data-close>Close</button>
60
+ ${items.length ? `<button data-save data-publish-all>Publish all</button>` : ""}
61
+ </div>
62
+ `;
63
+
64
+ const list = dialog.querySelector("[data-staged-list]");
65
+ for (const pending of items) {
66
+ const route = normalizeRoute(pending.route);
67
+ const onHere = route === here;
68
+ const change = describeChange(pending);
69
+ const row = document.createElement("div");
70
+ row.className = "charlescms-version";
71
+ row.innerHTML = `
72
+ <div>
73
+ <strong>${escapeHtml(change.detail)}</strong>
74
+ <span>${escapeHtml(change.title)} · ${onHere ? "on this page" : escapeHtml(route)}</span>
75
+ </div>`;
76
+ const action = document.createElement(onHere ? "button" : "a");
77
+ action.className = "charlescms-staged-go";
78
+ action.textContent = onHere ? "View" : "Open ↗";
79
+ if (onHere) {
80
+ action.type = "button";
81
+ action.addEventListener("click", () => viewChange(pending, change.key));
82
+ } else {
83
+ action.setAttribute("href", escapeAttribute(route));
84
+ action.addEventListener("click", closeEditor); // navigating shows the change on arrival
85
+ }
86
+ row.append(action);
87
+ list.append(row);
88
+ }
89
+
90
+ mountPanel(dialog);
91
+ for (const close of dialog.querySelectorAll("[data-close]")) close.addEventListener("click", closeEditor);
92
+ dialog.querySelector("[data-discard-all]")?.addEventListener("click", () => { closeEditor(); discardPending(); });
93
+ dialog.querySelector("[data-publish-all]")?.addEventListener("click", () => { closeEditor(); publishPending(); });
94
+ }
95
+
96
+ return { openStagedPanel };
97
+ }
98
+
99
+ function normalizeRoute(path) {
100
+ return String(path || "").replace(/[?#].*$/, "").replace(/\/+$/, "") || "/";
101
+ }
102
+
103
+ // Turn a staged edit into a human row: WHAT it now says (detail), a category
104
+ // (title), and the exact operation that changed (key) so View can jump to it.
105
+ function describeChange(pending) {
106
+ const next = pending.value || {};
107
+ const prev = pending.originalValue || {};
108
+ if (pending.type === "image" || pending.type === "asset" || pending.action === "delete" || "src" in next) {
109
+ return { title: "Image", detail: pending.action === "delete" ? "Removed" : "New image" };
110
+ }
111
+ if (typeof next.text === "string") {
112
+ return { title: "Text", detail: snippet(next.text) || "(empty)" };
113
+ }
114
+ const changed = Object.keys(next).filter((key) => String(next[key] ?? "") !== String(prev[key] ?? ""));
115
+ if (changed.length) {
116
+ const key = changed[0];
117
+ const more = changed.length > 1 ? ` (+${changed.length - 1} more)` : "";
118
+ const from = snippet(prev[key]);
119
+ const to = snippet(next[key]) || "(empty)";
120
+ return { title: fieldTitle(pending, key), detail: (from ? `${from} → ${to}` : to) + more, key };
121
+ }
122
+ return { title: pending.label || "Change", detail: snippet(Object.values(next)[0]) || "Updated" };
123
+ }
124
+
125
+ function fieldTitle(pending, key) {
126
+ if (/^item_?\d+(_(?:label|title|name|text))?$/i.test(key) || /link/i.test(key)) return "Menu link";
127
+ if (/title$/i.test(key)) return "Title";
128
+ if (/description$/i.test(key)) return "Description";
129
+ if (pending.type === "nav") return "Menu";
130
+ return pending.label || "Content";
131
+ }
132
+
133
+ // Plain, tag-free text for display — the user should never see raw HTML like
134
+ // "<br>" or "<em>" in the Review list.
135
+ function snippet(value) {
136
+ const raw = String(value ?? "");
137
+ let text = raw;
138
+ if (/[<&]/.test(raw)) {
139
+ const el = document.createElement("div");
140
+ el.innerHTML = raw;
141
+ text = el.textContent || "";
142
+ }
143
+ text = text.replace(/\s+/g, " ").trim();
144
+ return text.length > 42 ? `${text.slice(0, 42)}…` : text;
145
+ }