@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.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/SECURITY.md +77 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/connector/worker.js +505 -0
- package/connector/wrangler.toml +15 -0
- package/package.json +92 -0
- package/scripts/check-licenses.js +45 -0
- package/scripts/check-package.js +62 -0
- package/scripts/setup.js +719 -0
- package/scripts/update-vendored-site.js +71 -0
- package/src/admin.astro +314 -0
- package/src/analyzer.js +639 -0
- package/src/asset-images.js +130 -0
- package/src/astro-frontmatter.js +17 -0
- package/src/boot.js +35 -0
- package/src/client.js +347 -0
- package/src/connector-client.js +185 -0
- package/src/content-bridge.js +162 -0
- package/src/content-panel.js +440 -0
- package/src/data-analyzer.js +304 -0
- package/src/edit-affordance.js +463 -0
- package/src/editor-styles.js +243 -0
- package/src/element-editor.js +355 -0
- package/src/fields.js +6 -0
- package/src/frontmatter.js +153 -0
- package/src/ids.js +20 -0
- package/src/index.js +681 -0
- package/src/js-ast.js +140 -0
- package/src/markdown-analyzer.js +95 -0
- package/src/media-preview.js +58 -0
- package/src/panel-manager.js +133 -0
- package/src/publishing.js +457 -0
- package/src/rich-text-editor.js +209 -0
- package/src/routes.js +21 -0
- package/src/runtime-controller.js +206 -0
- package/src/sanitize.js +150 -0
- package/src/section-editor.js +437 -0
- package/src/source-edit.js +310 -0
- package/src/source-map-runtime.js +184 -0
- package/src/staged-panel.js +145 -0
- package/src/toolbar.js +128 -0
- 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 `&`/`<`
|
|
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("&", "&").replaceAll("<", "<"));
|
|
303
|
+
}
|
|
304
|
+
const quote = operation.quote || '"';
|
|
305
|
+
return escapeAstroBraces(raw
|
|
306
|
+
.replaceAll("&", "&")
|
|
307
|
+
.replaceAll("<", "<")
|
|
308
|
+
.replaceAll(">", ">")
|
|
309
|
+
.replaceAll(quote, quote === '"' ? """ : "'"));
|
|
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
|
+
}
|