@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,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
|
+
}
|