@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,185 @@
1
+ import { createEditedSource, UnsafeSourceEditError } from "./source-edit.js";
2
+
3
+ // Browser-side API for the publishing Worker.
4
+ //
5
+ // It validates connection settings, converts staged operations into complete
6
+ // verified file updates, and keeps repository path/branch handling out of the UI.
7
+ export class ConnectorError extends Error {
8
+ constructor(message, status = 0) {
9
+ super(message);
10
+ this.name = "ConnectorError";
11
+ this.status = status;
12
+ }
13
+ }
14
+
15
+ export function createConnectorClient(config) {
16
+ const connector = normalizeConnectorUrl(config?.connector);
17
+ if (!connector) throw new ConnectorError("A CharlesCMS connector URL is required.");
18
+ if (!config?.repo || !config.repo.includes("/")) {
19
+ throw new ConnectorError("Repository must be in owner/name form.");
20
+ }
21
+ const repo = config.repo;
22
+ let branch = config.branch || "main";
23
+ const sourceRoot = String(config.sourceRoot || "").replace(/^\/+|\/+$/g, "");
24
+ const editorKey = String(config.editorKey || "").trim();
25
+ const liveConfig = { connector, repo, branch, sourceRoot };
26
+ let branchHealed = false;
27
+
28
+ const toRepoPath = (path) => {
29
+ if (!sourceRoot || !path) return path;
30
+ return path === sourceRoot || path.startsWith(`${sourceRoot}/`) ? path : `${sourceRoot}/${path.replace(/^\/+/, "")}`;
31
+ };
32
+
33
+ const once = (path, body) => fetch(`${connector}/api/${path}`, {
34
+ method: "POST",
35
+ headers: {
36
+ "content-type": "application/json",
37
+ ...(editorKey ? { "x-charlescms-auth": editorKey } : {})
38
+ },
39
+ body: JSON.stringify({ repo, branch, ...body })
40
+ });
41
+
42
+ // The #1 cause of "nothing works" is a configured branch that doesn't exist
43
+ // (e.g. "main" saved against a "master" repo) — every read/write 404s. Heal it
44
+ // once: ask the connector for the repo's real default branch, adopt it, persist
45
+ // it for next time, and retry the request. After that the right branch sticks.
46
+ const call = async (path, body = {}) => {
47
+ let response = await once(path, body);
48
+ if (response.status === 404 && path !== "verify" && !branchHealed) {
49
+ branchHealed = true;
50
+ const fixed = await defaultBranch();
51
+ if (fixed && fixed !== branch) {
52
+ branch = fixed;
53
+ liveConfig.branch = fixed;
54
+ persistBranch(fixed);
55
+ const retryBody = { ...body };
56
+ if (retryBody.ref) retryBody.ref = fixed;
57
+ response = await once(path, retryBody);
58
+ }
59
+ }
60
+ const data = await response.json().catch(() => ({}));
61
+ if (!response.ok) {
62
+ if (response.status === 401) {
63
+ throw new ConnectorError(data.error || "Incorrect or missing editor password.", 401);
64
+ }
65
+ throw new ConnectorError(data.error || `Connector request failed (${response.status}).`, response.status);
66
+ }
67
+ return data;
68
+ };
69
+
70
+ const defaultBranch = async () => {
71
+ try {
72
+ const response = await once("verify", {});
73
+ const data = await response.json().catch(() => ({}));
74
+ return response.ok && typeof data.defaultBranch === "string" ? data.defaultBranch : "";
75
+ } catch {
76
+ return "";
77
+ }
78
+ };
79
+
80
+ const persistBranch = (value) => {
81
+ try {
82
+ const key = "charlescms_connector";
83
+ const saved = JSON.parse(globalThis.sessionStorage?.getItem(key) || "{}");
84
+ if (saved?.connector) {
85
+ saved.branch = value;
86
+ globalThis.sessionStorage.setItem(key, JSON.stringify(saved));
87
+ }
88
+ } catch {}
89
+ };
90
+
91
+ const getFile = async (path, ref = branch) => {
92
+ path = toRepoPath(path);
93
+ assertSafeRepoPath(path);
94
+ return call("file", { path, ref });
95
+ };
96
+
97
+ const putFile = async (path, source, { sha, message } = {}) => {
98
+ path = toRepoPath(path);
99
+ assertSafeRepoPath(path);
100
+ return call("write", { path, source, sha, message: message || `Update content: ${path}` });
101
+ };
102
+
103
+ const putBase64 = async (path, base64, { sha, message } = {}) => {
104
+ path = toRepoPath(path);
105
+ assertSafeRepoPath(path);
106
+ const clean = String(base64).replace(/\s/g, "");
107
+ const result = await call("upload", { path, base64: clean, sha, message: message || `Upload media: ${path}` });
108
+ // Dev nicety: the commit above is the source of truth, but the dev server
109
+ // serves public/ from the local disk, where the new file doesn't exist yet —
110
+ // the preview would 404 until the next pull. Mirror the bytes locally via a
111
+ // dev-only endpoint. Gated to dev so a deployed site never calls it (which
112
+ // logged a noisy 405 in the browser console).
113
+ if (import.meta.env?.DEV && path.startsWith("public/uploads/")) {
114
+ fetch("/_charlescms/mirror-upload", {
115
+ method: "POST",
116
+ headers: { "content-type": "application/json" },
117
+ body: JSON.stringify({ path, base64: clean })
118
+ }).catch(() => {});
119
+ }
120
+ return result;
121
+ };
122
+
123
+ const applyEdit = async (entry, edit) => {
124
+ const file = toRepoPath(entry.file);
125
+ assertSafeRepoPath(file);
126
+ for (let attempt = 0; attempt < 2; attempt++) {
127
+ const current = await getFile(file);
128
+ if (!current) throw new ConnectorError(`Source file not found: ${file}`, 404);
129
+ const next = createEditedSource(current.source, { ...entry, file }, edit);
130
+ if (next === current.source) {
131
+ return { id: edit.id, file, commit: null, unchanged: true };
132
+ }
133
+ try {
134
+ const commit = await putFile(file, next, { sha: current.sha, message: edit.message });
135
+ mirrorSourceToDisk(entry.file, next);
136
+ return { id: edit.id, file, commit };
137
+ } catch (error) {
138
+ if (error instanceof ConnectorError && error.status === 409 && attempt === 0) continue;
139
+ throw error;
140
+ }
141
+ }
142
+ throw new ConnectorError("Connector write kept conflicting; try again.", 409);
143
+ };
144
+
145
+ const listCommits = async (limit = 20) => {
146
+ const data = await call("commits", { limit });
147
+ return Array.isArray(data.commits) ? data.commits : [];
148
+ };
149
+
150
+ const revertCommit = async (sha) => call("revert", { sha });
151
+
152
+ const verifyAccess = async () => call("verify");
153
+
154
+ return { getFile, putFile, putBase64, applyEdit, listCommits, revertCommit, verifyAccess, config: liveConfig };
155
+ }
156
+
157
+ // Dev nicety (mirrors putBase64's upload mirror): the commit above is the source
158
+ // of truth, but the dev server reads source from the LOCAL disk. Without this the
159
+ // local file — and the source map built from it — stays frozen at the last build
160
+ // while GitHub moves ahead each publish, so the next edit to the same file fails
161
+ // byte-verification ("source changed since the CMS build"): the CMS never notices
162
+ // it already published. Write the published bytes locally; the dev watcher then
163
+ // refreshes the source map. On a deployed site the endpoint is absent and this
164
+ // fails silently. `path` is the project-relative source path (no sourceRoot).
165
+ function mirrorSourceToDisk(path, source) {
166
+ if (!path || !import.meta.env?.DEV) return;
167
+ // The reload that follows mirroring is the editor's post-publish dev sync; the
168
+ // few tests that keep editing within one session disable both with this flag.
169
+ if (typeof window !== "undefined" && window.__CHARLESCMS_NO_RELOAD__) return;
170
+ fetch("/_charlescms/mirror-source", {
171
+ method: "POST",
172
+ headers: { "content-type": "application/json" },
173
+ body: JSON.stringify({ path, source })
174
+ }).catch(() => {});
175
+ }
176
+
177
+ function normalizeConnectorUrl(value) {
178
+ return String(value || "").trim().replace(/\/+$/, "");
179
+ }
180
+
181
+ function assertSafeRepoPath(path) {
182
+ if (!path || path.startsWith("/") || path.split(/[\\/]/).includes("..")) {
183
+ throw new UnsafeSourceEditError("Source path points outside the repository.");
184
+ }
185
+ }
@@ -0,0 +1,162 @@
1
+ // Connect dynamically rendered data/frontmatter values to their deterministic
2
+ // Content-panel editors. Matching is only a navigation aid: source writes still
3
+ // use exact source-map operations and never depend on these DOM guesses.
4
+ export function createContentBridge({
5
+ dataModules,
6
+ frontmatterGroups,
7
+ openDataModulePanel,
8
+ openFrontmatterPanel,
9
+ attachLinkEditChip,
10
+ isVisible
11
+ }) {
12
+ function installContentBridge() {
13
+ for (const [file, entries] of dataModules()) {
14
+ for (const entry of entries) {
15
+ for (const operation of entry.operations) {
16
+ for (const element of findBridgeTargets(operation.oldValue)) {
17
+ bindBridge(element, file, entries, entry.id, groupKeyForName(operation.name), operation.name);
18
+ }
19
+ }
20
+ for (const media of entry.media || []) {
21
+ const image = findBridgeImage(media.path);
22
+ if (image) bindBridge(image, file, entries, entry.id, groupKeyForName(media.name), media.name);
23
+ }
24
+ }
25
+ }
26
+
27
+ // A value can be part of a larger line, such as a name in a copyright.
28
+ // Prefer longer candidates so the most specific value claims the element.
29
+ const candidates = [];
30
+ for (const [file, entries] of dataModules()) {
31
+ for (const entry of entries) {
32
+ for (const operation of entry.operations) {
33
+ const value = String(operation.oldValue || "").trim();
34
+ if (value.length < 8 || !/[A-Za-z]/.test(value)) continue;
35
+ candidates.push({ file, entries, entry, operation, value });
36
+ }
37
+ }
38
+ }
39
+ candidates.sort((a, b) => b.value.length - a.value.length);
40
+ for (const { file, entries, entry, operation } of candidates) {
41
+ const element = findBridgeContainer(operation.oldValue.trim());
42
+ if (element) bindBridge(element, file, entries, entry.id, groupKeyForName(operation.name), operation.name);
43
+ }
44
+
45
+ for (const [file, entries] of frontmatterGroups()) {
46
+ for (const entry of entries) {
47
+ for (const operation of entry.operations) {
48
+ for (const element of findFrontmatterTargets(operation.oldValue)) {
49
+ bindFrontmatter(element, file, entries, entry.id, operation.name);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ function findFrontmatterTargets(value) {
57
+ const normalize = (input) => String(input || "")
58
+ .replace(/[\u2018\u2019\u201a\u201b]/g, "'")
59
+ .replace(/[\u201c\u201d\u201e\u201f]/g, '"')
60
+ .replace(/[\u2013\u2014]/g, "-")
61
+ .replace(/\\[ntr]/g, "")
62
+ .replace(/\s+/g, "")
63
+ .toLowerCase();
64
+ const target = normalize(value);
65
+ if (target.length < 3) return [];
66
+ return [...document.querySelectorAll("h1,h2,h3,h4,h5,h6,p,span,li,dd,dt,div,label,a")]
67
+ .filter((element) => !element.dataset.charlescmsId
68
+ && !element.dataset.charlescmsBridge
69
+ && !element.closest("[data-charlescms-ui]")
70
+ && !element.closest("[data-charlescms-preview-block]")
71
+ && !element.querySelector("[data-charlescms-id]")
72
+ && normalize(element.textContent) === target
73
+ && isVisible(element));
74
+ }
75
+
76
+ function bindFrontmatter(element, file, entries, entryId, entryOp) {
77
+ element.dataset.charlescmsBridge = "true";
78
+ element.dataset.charlescmsBridgeEntry = entryId;
79
+ element.dataset.charlescmsBridgeOp = entryOp;
80
+ const open = () => openFrontmatterPanel(file, entries, "Edit content");
81
+ if (element.tagName === "A" || element.closest("a[href]")) {
82
+ attachLinkEditChip(element, open, "✎ Edit content");
83
+ return;
84
+ }
85
+ element.dataset.charlescmsEditable = "true";
86
+ element.charlescmsEdit = { open, label: "✎ Edit content" };
87
+ }
88
+
89
+ function findBridgeContainer(value) {
90
+ return [...document.querySelectorAll("p,h1,h2,h3,h4,h5,h6,a,span,li,address,figcaption,blockquote,dd")]
91
+ .find((element) => !element.dataset.charlescmsId
92
+ && !element.dataset.charlescmsBridge
93
+ && !element.closest("[data-charlescms-id]")
94
+ && !element.closest("[data-charlescms-ui]")
95
+ && !element.closest("[data-charlescms-preview-block]")
96
+ && !element.querySelector("[data-charlescms-id]")
97
+ && element.textContent.includes(value)
98
+ && element.textContent.trim().length < value.length + 60
99
+ && isVisible(element));
100
+ }
101
+
102
+ function findBridgeTargets(value) {
103
+ const text = String(value || "").trim();
104
+ if (text.length < 2) return [];
105
+ const directText = (element) => [...element.childNodes]
106
+ .filter((node) => node.nodeType === 3)
107
+ .map((node) => node.textContent)
108
+ .join("")
109
+ .trim();
110
+ return [...document.querySelectorAll("h1,h2,h3,h4,h5,h6,p,span,li,dd,dt,strong,em,small,figcaption,blockquote,a,button")]
111
+ .filter((element) => !element.dataset.charlescmsId
112
+ && !element.dataset.charlescmsBridge
113
+ && !element.closest("[data-charlescms-ui]")
114
+ && !element.closest("[data-charlescms-preview-block]")
115
+ && [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent.trim())
116
+ && (
117
+ directText(element) === text
118
+ || (element.textContent.trim() === text && !element.querySelector("[data-charlescms-id]"))
119
+ )
120
+ && isVisible(element));
121
+ }
122
+
123
+ function findBridgeImage(path) {
124
+ const clean = String(path || "").split(/[?#]/)[0];
125
+ if (!clean) return null;
126
+ const matches = [...document.querySelectorAll("img")]
127
+ .filter((image) => !image.dataset.charlescmsBridge
128
+ && !image.dataset.charlescmsId
129
+ && !image.closest("[data-charlescms-id]")
130
+ && !image.closest("[data-charlescms-preview-block]")
131
+ && (image.getAttribute("src") || "").split(/[?#]/)[0] === clean);
132
+ return matches.find(isVisible) || matches[0] || null;
133
+ }
134
+
135
+ function bindBridge(element, file, entries, entryId, groupKey, operationName) {
136
+ element.dataset.charlescmsBridge = "true";
137
+ // Record WHICH source entry this element edits. The Content panel uses this
138
+ // to list only entries that have no clickable element on the page.
139
+ element.dataset.charlescmsBridgeEntry = entryId;
140
+ // The op name lets a panel save push the new value back into this element,
141
+ // so the page updates the moment Save is pressed (before publish).
142
+ element.dataset.charlescmsBridgeOp = operationName;
143
+ const open = () => openDataModulePanel(file, entries, { entryId, groupKey, opName: operationName });
144
+ if (element.tagName === "A" || element.closest("a[href]")) {
145
+ attachLinkEditChip(element, open, "✎ Edit content");
146
+ return;
147
+ }
148
+ element.dataset.charlescmsEditable = "true";
149
+ element.charlescmsEdit = { open, label: "✎ Edit content" };
150
+ }
151
+
152
+ return { installContentBridge };
153
+ }
154
+
155
+ function groupKeyForName(name) {
156
+ const item = name.match(/^item(\d+)_/);
157
+ if (item) return `i${item[1]}`;
158
+ if (/^item_\d+$/.test(name)) return "links";
159
+ if (/^prop\d+_/.test(name)) return "page";
160
+ if (/^field\d+_/.test(name)) return "fields";
161
+ return "fields";
162
+ }