@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,206 @@
1
+ import { applyMediaPreviews } from "./media-preview.js";
2
+
3
+ // Coordinates editor startup and repeatable DOM binding.
4
+ //
5
+ // Astro navigation and client-side hydration can replace page content without a
6
+ // full reload. The controller therefore treats binding as an idempotent process:
7
+ // source ids are resolved again, new elements receive edit actions, and a short
8
+ // settle loop stops once the page has finished rendering.
9
+ export function createRuntimeController({
10
+ state,
11
+ readConnectorConfig,
12
+ createConnectorClient,
13
+ closeEditor,
14
+ loadSourceMap,
15
+ applySourceMapIds,
16
+ restorePending,
17
+ applyValue,
18
+ applyBridgedValues,
19
+ addStyles,
20
+ installEditAffordance,
21
+ installToolbar,
22
+ installSectionControls,
23
+ scanEditableElements,
24
+ openEditor,
25
+ installContentBridge,
26
+ bindAssetImages,
27
+ downloadRepoPath,
28
+ isVisible
29
+ }) {
30
+ async function start() {
31
+ if (state.initializedPath === location.pathname && state.toolbar?.isConnected) return;
32
+ resetEditorUi();
33
+ const config = readConnectorConfig();
34
+ state.authenticated = state.previewOnly || Boolean(config);
35
+ if (!state.authenticated) return;
36
+
37
+ state.connector = state.previewOnly ? null : createConnectorClient(config);
38
+ loadSourceMap();
39
+ applySourceMapIds();
40
+ // Bring any edits staged before a reload/navigation back into memory, then
41
+ // re-bind them to this page's DOM so the staged count and preview survive.
42
+ restorePending?.();
43
+ reconnectPendingEdits();
44
+ applyInitialEdits();
45
+ installEditor();
46
+ state.initializedPath = location.pathname;
47
+ }
48
+
49
+ function resetEditorUi() {
50
+ closeEditor();
51
+ state.toolbarObserver?.disconnect();
52
+ state.toolbarObserver = null;
53
+ state.lateObserver?.disconnect();
54
+ state.lateObserver = null;
55
+ for (const element of document.querySelectorAll("[data-charlescms-ui]")) element.remove();
56
+ for (const element of document.querySelectorAll(".charlescms-bridge")) {
57
+ element.classList.remove("charlescms-bridge");
58
+ delete element.dataset.charlescmsBridge;
59
+ }
60
+ state.affordanceCleanup?.();
61
+ state.affordanceCleanup = null;
62
+ state.affordanceInstalled = false;
63
+ document.documentElement.classList.remove("charlescms-active", "charlescms-links-mode");
64
+ state.toolbar = null;
65
+ state.sectionControls = [];
66
+ }
67
+
68
+ function reconnectPendingEdits() {
69
+ for (const pending of state.pending.values()) {
70
+ const element = document.querySelector(`[data-charlescms-id="${CSS.escape(pending.id)}"]`);
71
+ if (!element) continue;
72
+ pending.item.element = element;
73
+ applyValue(element, { type: pending.type, value: pending.value });
74
+ }
75
+ }
76
+
77
+ function applyInitialEdits() {
78
+ for (const edit of state.edits) {
79
+ const element = document.querySelector(`[data-charlescms-id="${CSS.escape(edit.id)}"]`);
80
+ if (element) applyValue(element, edit);
81
+ }
82
+ }
83
+
84
+ function installEditor() {
85
+ document.documentElement.classList.add("charlescms-active");
86
+ addStyles();
87
+ bindEditables();
88
+ installEditAffordance();
89
+ installToolbar();
90
+ installSectionControls();
91
+ watchForLateContent();
92
+ }
93
+
94
+ function bindEditables() {
95
+ applySourceMapIds();
96
+ for (const item of scanEditableElements()) {
97
+ if (item.element.dataset.charlescmsEditable === "true") continue;
98
+ item.element.dataset.charlescmsEditable = "true";
99
+ if (item.type === "link" || item.type === "download") {
100
+ attachLinkEditAction(item.element, () => openEditor(item));
101
+ } else {
102
+ item.element.charlescmsEdit = { open: () => openEditor(item), label: "✎ Edit" };
103
+ }
104
+ }
105
+ installDownloadReplacers();
106
+ // Astro-optimized images (astro:assets) aren't in the source map — their src is
107
+ // a hashed pipeline URL, not a literal path — so they're bound separately, by
108
+ // replacing the source asset in place.
109
+ bindAssetImages?.();
110
+ installContentBridge();
111
+ reapplyBridgedPending();
112
+ // Re-show any locally-previewed images on this (possibly newly navigated) page,
113
+ // so a replaced picture keeps showing until the real asset is served.
114
+ applyMediaPreviews();
115
+ }
116
+
117
+ // Bridged edits (data / nav / frontmatter) bind to elements by value, not by a
118
+ // source id, so reconnectPendingEdits can't restore their preview. Once the
119
+ // bridge has tagged this page's elements, push any staged values back into them
120
+ // so a reloaded or revisited page shows the staged preview, not the old text.
121
+ function reapplyBridgedPending() {
122
+ for (const pending of state.pending.values()) {
123
+ if (["data", "nav", "frontmatter"].includes(pending.type)) {
124
+ applyBridgedValues?.(pending.id, pending.value || {});
125
+ }
126
+ }
127
+ }
128
+
129
+ function watchForLateContent() {
130
+ const rebindIfActive = () => {
131
+ if (document.documentElement.classList.contains("charlescms-active")) bindEditables();
132
+ };
133
+ window.addEventListener("load", rebindIfActive, { once: true });
134
+
135
+ // A bounded settle loop covers streaming and delayed layout. It ends after
136
+ // three stable counts or forty attempts, so it cannot poll forever.
137
+ let last = -1;
138
+ let stableFor = 0;
139
+ let passes = 0;
140
+ const settle = () => {
141
+ if (!document.documentElement.classList.contains("charlescms-active")) return;
142
+ bindEditables();
143
+ const count = document.querySelectorAll('[data-charlescms-editable="true"]').length;
144
+ if (count === last) stableFor += 1;
145
+ else {
146
+ stableFor = 0;
147
+ last = count;
148
+ }
149
+ if (stableFor < 3 && (passes += 1) < 40) setTimeout(settle, 300);
150
+ };
151
+ setTimeout(settle, 120);
152
+
153
+ if (state.lateObserver) return;
154
+ let scheduled = false;
155
+ const observer = new MutationObserver((records) => {
156
+ const added = records.some((record) => [...record.addedNodes].some(
157
+ (node) => node.nodeType === 1 && !node.closest?.("[data-charlescms-ui]")
158
+ ));
159
+ if (!added || scheduled) return;
160
+ scheduled = true;
161
+ setTimeout(() => {
162
+ scheduled = false;
163
+ rebindIfActive();
164
+ }, 150);
165
+ });
166
+ observer.observe(document.body, { childList: true, subtree: true });
167
+ state.lateObserver = observer;
168
+ }
169
+
170
+ function installDownloadReplacers() {
171
+ for (const link of document.querySelectorAll("a[href]")) {
172
+ if (link.closest("[data-charlescms-ui]")) continue;
173
+ if (link.dataset.charlescmsEditable === "true") continue;
174
+ if (!isReplaceableDownload(link) || !isVisible(link)) continue;
175
+ link.dataset.charlescmsEditable = "true";
176
+ link.title = link.title || "Click to replace this file. Cmd/Ctrl+click opens it.";
177
+ link.addEventListener("click", (event) => {
178
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
179
+ event.preventDefault();
180
+ event.stopPropagation();
181
+ openEditor({
182
+ id: null,
183
+ element: link,
184
+ fields: [],
185
+ type: "download",
186
+ value: {},
187
+ label: link.textContent.trim() || link.getAttribute("href") || "File"
188
+ });
189
+ });
190
+ }
191
+ }
192
+
193
+ function isReplaceableDownload(link) {
194
+ const href = link.getAttribute("href") || "";
195
+ if (!downloadRepoPath(href)) return false;
196
+ return link.hasAttribute("download")
197
+ || /\.(?:pdf|docx?|xlsx?|pptx?|csv|zip|rtf|odt|txt|pages|key|numbers)(?:[?#]|$)/i.test(href);
198
+ }
199
+
200
+ function attachLinkEditAction(element, open, label = "✎ Edit text") {
201
+ element.dataset.charlescmsEditable = "true";
202
+ element.charlescmsEdit = { open, label };
203
+ }
204
+
205
+ return { start, attachLinkEditAction };
206
+ }
@@ -0,0 +1,150 @@
1
+ import { parseFragment, serialize } from "parse5";
2
+
3
+ // Authoritative write-boundary allowlist sanitizer for rich-text fragments.
4
+ // Client output is NEVER trusted: this is the security boundary that prevents
5
+ // stored XSS from reaching the published site. Anything outside the allowlist
6
+ // is unwrapped (markup dropped, text kept) or, for script/style, removed whole.
7
+ const allowedTags = new Set(["strong", "em", "b", "i", "a", "span", "code", "small", "u", "mark", "br"]);
8
+ const dropWithContentTags = new Set(["script", "style", "template", "iframe", "object", "embed", "noscript"]);
9
+ const allowedAttributes = {
10
+ a: new Set(["href", "title"]),
11
+ span: new Set(["title"]),
12
+ abbr: new Set(["title"])
13
+ };
14
+ const urlAttributes = new Set(["href", "src"]);
15
+ const allowedUrlSchemes = new Set(["http:", "https:", "mailto:", "tel:"]);
16
+ const blockTags = new Set([
17
+ "h2", "h3", "h4", "p", "ul", "ol", "li", "blockquote", "figure",
18
+ "figcaption", "img", "a", "hr", "strong", "em", "code", "br"
19
+ ]);
20
+ const blockAttributes = {
21
+ "*": new Set(["class", "title"]),
22
+ a: new Set(["class", "href", "title"]),
23
+ img: new Set(["class", "src", "alt", "title"])
24
+ };
25
+
26
+ /**
27
+ * Sanitize an inline rich-text fragment to the inline allowlist (bold, italic,
28
+ * links, …). Disallowed markup is unwrapped (text kept); script/style removed whole.
29
+ * @param {string} html Untrusted HTML from the editor.
30
+ * @returns {string} Safe HTML containing only allowlisted inline tags/attributes.
31
+ */
32
+ export function sanitizeRichText(html) {
33
+ const fragment = parseFragment(String(html ?? ""));
34
+ sanitizeChildNodes(fragment, allowedTags, allowedAttributes);
35
+ return serialize(fragment);
36
+ }
37
+
38
+ /**
39
+ * Sanitize a block-level rich-text fragment (headings, lists, images, …) to the
40
+ * block allowlist, then neutralize Astro `{…}` braces so the result is content,
41
+ * never an expression.
42
+ * @param {string} html Untrusted HTML from the editor.
43
+ * @returns {string} Safe, brace-escaped block HTML.
44
+ */
45
+ export function sanitizeBlockHtml(html) {
46
+ const fragment = parseFragment(String(html ?? ""));
47
+ sanitizeChildNodes(fragment, blockTags, blockAttributes);
48
+ return escapeAstroBraces(serialize(fragment));
49
+ }
50
+
51
+ /**
52
+ * Pure parse→serialize round-trip with NO filtering. Comparing
53
+ * `sanitizeBlockHtml(source)` against this tells whether the sanitizer actually
54
+ * CHANGED anything — without false alarms from harmless formatting the parser
55
+ * normalizes anyway (`<img />` vs `<img>`, attribute quoting, whitespace).
56
+ * @param {string} html HTML to normalize.
57
+ * @returns {string} The normalized, brace-escaped HTML.
58
+ */
59
+ export function normalizeBlockHtml(html) {
60
+ return escapeAstroBraces(serialize(parseFragment(String(html ?? ""))));
61
+ }
62
+
63
+ /**
64
+ * Neutralize literal `{`/`}` so editor content is never read as an Astro
65
+ * expression. Astro reads `{…}` in markup as JS; editor values are content, never
66
+ * code, so braces reaching `.astro` source become HTML entities (the browser still
67
+ * renders a literal brace). Without this, typing "plans from {X}" would commit a
68
+ * file whose next `astro build` fails on an undefined-variable expression. Run
69
+ * only AFTER any `&`-escaping so the `&` these entities introduce isn't double-escaped.
70
+ * @param {string} value Text or serialized HTML to escape.
71
+ * @returns {string} The value with braces replaced by `&#123;`/`&#125;`.
72
+ */
73
+ export function escapeAstroBraces(value) {
74
+ return String(value ?? "").replaceAll("{", "&#123;").replaceAll("}", "&#125;");
75
+ }
76
+
77
+ function sanitizeChildNodes(parent, tags, attributes) {
78
+ const result = [];
79
+ for (const node of parent.childNodes || []) {
80
+ if (node.nodeName === "#text") {
81
+ result.push(node);
82
+ continue;
83
+ }
84
+ if (!node.tagName) {
85
+ // Comments, doctypes, etc. are dropped.
86
+ continue;
87
+ }
88
+ const tag = node.tagName.toLowerCase();
89
+ if (dropWithContentTags.has(tag)) {
90
+ continue;
91
+ }
92
+ sanitizeChildNodes(node, tags, attributes);
93
+ if (!tags.has(tag)) {
94
+ // Unwrap: keep the (already sanitized) children, drop the tag itself.
95
+ result.push(...node.childNodes);
96
+ continue;
97
+ }
98
+ node.attrs = filterAttributes(tag, node.attrs || [], attributes);
99
+ result.push(node);
100
+ }
101
+ parent.childNodes = result;
102
+ }
103
+
104
+ function filterAttributes(tag, attrs, attributes) {
105
+ const allowed = new Set([
106
+ ...(attributes["*"] || []),
107
+ ...(attributes[tag] || [])
108
+ ]);
109
+ return attrs.filter((attribute) => {
110
+ const name = attribute.name.toLowerCase();
111
+ if (name.startsWith("on")) return false; // event handlers, always rejected
112
+ if (!allowed.has(name)) return false;
113
+ if (urlAttributes.has(name) && !isSafeUrl(attribute.value)) return false;
114
+ if (name === "class") {
115
+ if (!isSafeClassList(attribute.value)) return false;
116
+ // Strip the editor's own runtime classes (charlescms-editing, charlescms-pm,
117
+ // …) so they can never persist into committed source. Left behind, an
118
+ // element would read as "already editing" and become silently uneditable.
119
+ attribute.value = stripEditorClasses(attribute.value);
120
+ if (!attribute.value) return false; // nothing left → drop the empty class
121
+ }
122
+ return true;
123
+ });
124
+ }
125
+
126
+ function stripEditorClasses(value) {
127
+ return String(value || "")
128
+ .split(/\s+/)
129
+ .filter((token) => token && !token.startsWith("charlescms-"))
130
+ .join(" ");
131
+ }
132
+
133
+ function isSafeClassList(value) {
134
+ return String(value || "")
135
+ .split(/\s+/)
136
+ .filter(Boolean)
137
+ .every((token) => /^[A-Za-z0-9_:/.[\]%-]+$/.test(token));
138
+ }
139
+
140
+ // Relative URLs and anchors are safe. For absolute URLs, only an explicit scheme
141
+ // allowlist passes — blocking javascript:, data:, vbscript: (incl. obfuscation
142
+ // with control characters/whitespace, which browsers ignore before the scheme).
143
+ function isSafeUrl(value) {
144
+ const stripped = Array.from(String(value ?? ""))
145
+ .filter((char) => char.charCodeAt(0) > 0x20)
146
+ .join("");
147
+ const scheme = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
148
+ if (!scheme) return true;
149
+ return allowedUrlSchemes.has(`${scheme[1].toLowerCase()}:`);
150
+ }