@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,71 @@
1
+ #!/usr/bin/env node
2
+ import { copyFile, mkdir, readFile, readdir, rename, rm } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const siteRoot = resolve(process.argv[2] || "");
9
+
10
+ if (!process.argv[2]) {
11
+ console.error("Usage: npm run update:vendored-site -- /path/to/astro-site");
12
+ process.exit(1);
13
+ }
14
+
15
+ const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
16
+ const vendorDir = join(siteRoot, "vendor");
17
+ const connectorDir = join(siteRoot, ".charlescms", "connector");
18
+
19
+ await mkdir(vendorDir, { recursive: true });
20
+ await mkdir(connectorDir, { recursive: true });
21
+
22
+ const packed = JSON.parse(await run("npm", [
23
+ "pack",
24
+ packageRoot,
25
+ "--json",
26
+ "--pack-destination",
27
+ vendorDir
28
+ ], siteRoot));
29
+ const packedName = packed[0]?.filename;
30
+ if (!packedName) throw new Error("npm pack did not report a tarball filename.");
31
+
32
+ // npm keys a `file:` tarball by name+version and caches the extracted result, so
33
+ // re-installing the same version (the normal case while iterating) often leaves
34
+ // STALE code installed even after editing the package. Give every build a unique
35
+ // tarball filename so npm always sees a new source and unpacks it for real.
36
+ const stamp = new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14);
37
+ const filename = packedName.replace(/\.tgz$/, `-${stamp}.tgz`);
38
+ await rename(join(vendorDir, packedName), join(vendorDir, filename));
39
+
40
+ // Drop any older vendored tarballs so the folder doesn't accumulate them.
41
+ for (const name of await readdir(vendorDir)) {
42
+ if (name.endsWith(".tgz") && name !== filename) await rm(join(vendorDir, name), { force: true });
43
+ }
44
+
45
+ // Remove the installed copy + Vite's cache of the package's transformed browser
46
+ // code, so neither npm nor the dev server can serve the previous build.
47
+ await rm(join(siteRoot, "node_modules", ...packageJson.name.split("/")), { recursive: true, force: true });
48
+ await rm(join(siteRoot, "node_modules", ".vite"), { recursive: true, force: true });
49
+ await run("npm", ["install", `file:vendor/${filename}`, "--save-exact"], siteRoot);
50
+ await copyFile(join(packageRoot, "connector", "worker.js"), join(connectorDir, "worker.js"));
51
+
52
+ console.log(`Updated ${siteRoot}`);
53
+ console.log(` ${packageJson.name}: file:vendor/${filename}`);
54
+ console.log(" connector worker: .charlescms/connector/worker.js");
55
+ console.log("Redeploy the connector if worker.js changed.");
56
+
57
+ function run(command, args, cwd) {
58
+ return new Promise((resolve, reject) => {
59
+ // On Windows, npm is a .cmd batch file that only resolves via a shell.
60
+ const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
61
+ let stdout = "";
62
+ let stderr = "";
63
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
64
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
65
+ child.on("error", reject);
66
+ child.on("close", (code) => {
67
+ if (code === 0) resolve(stdout);
68
+ else reject(new Error(`${command} ${args.join(" ")} failed (${code}): ${stderr.trim()}`));
69
+ });
70
+ });
71
+ }
@@ -0,0 +1,314 @@
1
+ ---
2
+ import config from "virtual:charlescms-config";
3
+
4
+ const previewOnly = Boolean(config.previewOnly);
5
+ const defaultEditPath = String(config.defaultEditPath || config.editablePaths?.[0] || "/");
6
+ const connection = config.connection || {};
7
+ ---
8
+ <!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8" />
12
+ <meta name="viewport" content="width=device-width" />
13
+ <title>CharlesCMS</title>
14
+ <style>
15
+ :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f4f2eb; color: #172026; }
16
+ * { box-sizing: border-box; }
17
+ body {
18
+ margin: 0;
19
+ min-height: 100vh;
20
+ display: grid;
21
+ place-items: center;
22
+ padding: 24px;
23
+ background: linear-gradient(135deg, #1c262c 0%, #11181d 100%);
24
+ }
25
+ main { width: min(460px, 100%); display: grid; gap: 18px; }
26
+ .brand { display: flex; align-items: center; gap: 10px; color: white; font-weight: 900; }
27
+ .mark { width: 34px; height: 34px; display: block; }
28
+ .panel { display: grid; gap: 22px; background: rgba(255,255,255,.94); border: 1px solid rgba(255, 255, 255, .46); border-radius: 8px; padding: 30px; box-shadow: 0 28px 84px rgba(8, 14, 18, .28); backdrop-filter: blur(18px); }
29
+ h1 { margin: 0 0 8px; font-size: 34px; line-height: 1.03; letter-spacing: 0; }
30
+ p { margin: 0; color: #5b6970; line-height: 1.55; }
31
+ form { display: grid; gap: 14px; }
32
+ label { display: grid; gap: 7px; font-weight: 700; color: #26333d; }
33
+ input { width: 100%; font: inherit; padding: 13px 14px; border-radius: 7px; border: 1px solid #bdc7d0; background: #fbfcfd; color: #172026; outline: none; transition: border-color .16s ease, box-shadow .16s ease, background .16s ease; }
34
+ input:focus { border-color: #176b5c; background: white; box-shadow: 0 0 0 4px rgba(23, 107, 92, .14); }
35
+ button { width: 100%; margin-top: 4px; border: 0; border-radius: 7px; padding: 13px 14px; background: #172026; color: white; font: inherit; font-weight: 900; cursor: pointer; transition: transform .12s ease, background .12s ease, box-shadow .12s ease; box-shadow: 0 12px 26px rgba(23, 32, 38, .18); }
36
+ button:hover { background: #0f171d; box-shadow: 0 14px 32px rgba(23, 32, 38, .22); }
37
+ button:active { transform: translateY(1px); }
38
+ .error { min-height: 22px; color: #a61b1b; font-size: 14px; }
39
+ .hint { font-size: 13px; }
40
+ .hint a { color: #176b5c; font-weight: 800; }
41
+ .demo-actions { display: grid; gap: 12px; }
42
+ .access { display: grid; gap: 3px; padding: 10px 12px; border: 1px solid #d8ded8; border-radius: 7px; background: #f8faf7; color: #5b6970; font-size: 13px; line-height: 1.4; }
43
+ .access strong { color: #172026; }
44
+ .secondary { display: inline-flex; justify-content: center; color: #172026; text-decoration: none; border: 1px solid #bdc7d0; border-radius: 7px; padding: 12px 14px; font-weight: 850; background: #fbfcfd; }
45
+ .footnote { color: rgba(255,255,255,.76); font-size: 13px; text-align: center; }
46
+ .advanced { border-top: 1px solid #e4e8ec; padding-top: 8px; }
47
+ .advanced summary { cursor: pointer; font-weight: 800; color: #3a4a55; padding: 4px 0; }
48
+ .sub { display: block; margin-top: 6px; color: #6a7782; font-size: 12px; font-weight: 500; }
49
+ @media (max-width: 520px) {
50
+ body { align-items: end; padding: 18px; }
51
+ .panel { padding: 24px; }
52
+ }
53
+ </style>
54
+ </head>
55
+ <body>
56
+ <main>
57
+ <div class="brand"><svg class="mark" viewBox="0 0 80 80" width="34" height="34" aria-hidden="true"><path d="M55 22 A24 24 0 1 0 55 58" fill="none" stroke="#f2c14e" stroke-width="10" stroke-linecap="round"/><rect x="47" y="30" width="7" height="20" rx="3.5" fill="#f6f4ee"/></svg><span>CharlesCMS</span></div>
58
+ <section class="panel">
59
+ {previewOnly ? (
60
+ <>
61
+ <div>
62
+ <h1>Preview the editor</h1>
63
+ <p>This public demo lets you edit pages locally in your browser. Publish, versions, and uploads are disabled here.</p>
64
+ </div>
65
+ <div class="access"><strong>Demo access</strong><span>No username or password required.</span></div>
66
+ <div class="demo-actions">
67
+ <button id="preview-demo">Open preview-only editor</button>
68
+ <a class="secondary" href={defaultEditPath}>Open demo without editor</a>
69
+ </div>
70
+ </>
71
+ ) : (
72
+ <>
73
+ <div id="ready" hidden>
74
+ <div>
75
+ <h1>Ready to edit</h1>
76
+ <p>You're connected to <strong id="ready-repo">this site</strong>. Open the site and click any text to start editing.</p>
77
+ </div>
78
+ <form id="ready-form" class="demo-actions">
79
+ <label>
80
+ Editor password
81
+ <input id="ready-password" name="editorKey" type="password" autocomplete="current-password" placeholder="Required Worker AUTH_SECRET" required />
82
+ <span class="sub">Must match the connector's required AUTH_SECRET.</span>
83
+ </label>
84
+ <button id="start-editing">Start editing</button>
85
+ <button type="button" id="switch-connection" class="secondary">Use a different connection</button>
86
+ </form>
87
+ <div class="error" id="ready-error"></div>
88
+ </div>
89
+ <div id="connect" hidden>
90
+ <div>
91
+ <h1>Connect CharlesCMS</h1>
92
+ <p>Paste the details printed by <code>npx charlescms setup --deploy --open</code>. Git credentials stay in the Worker, not in this browser.</p>
93
+ </div>
94
+ <form id="connect-form">
95
+ <label>
96
+ Connector URL
97
+ <input name="connector" placeholder="https://site-charlescms.workers.dev" autocomplete="off" required />
98
+ </label>
99
+ <label>
100
+ Repository
101
+ <input name="repo" placeholder="owner/name" autocomplete="off" required />
102
+ </label>
103
+ <label>
104
+ Branch
105
+ <input name="branch" placeholder="default branch (e.g. main)" autocomplete="off" />
106
+ <span class="sub">Leave blank to use the repository's default branch.</span>
107
+ </label>
108
+ <label>
109
+ Editor password
110
+ <input name="editorKey" type="password" autocomplete="current-password" placeholder="Required Worker AUTH_SECRET" required />
111
+ <span class="sub">Must match the connector's required AUTH_SECRET.</span>
112
+ </label>
113
+ <details class="advanced">
114
+ <summary>Advanced</summary>
115
+ <label>
116
+ Source root
117
+ <input name="sourceRoot" placeholder="website" autocomplete="off" />
118
+ <span class="sub">Only for monorepos — the subfolder your Astro site lives in (e.g. website). Leave blank otherwise.</span>
119
+ </label>
120
+ </details>
121
+ <div class="error" id="error"></div>
122
+ <button>Connect</button>
123
+ </form>
124
+ </div>
125
+ </>
126
+ )}
127
+ </section>
128
+ <div class="footnote">Protected visual editing for Astro.</div>
129
+ </main>
130
+ <script type="module" define:vars={{ defaultEditPath, previewOnly, connection }}>
131
+ const configKey = "charlescms_connector";
132
+ const lastKey = `${configKey}_last`;
133
+ const explicitNext = new URLSearchParams(location.search).get("next");
134
+ const nextPath = safeLocalPath(explicitNext || defaultEditPath, defaultEditPath);
135
+ const previewDemo = document.querySelector("#preview-demo");
136
+
137
+ if (previewOnly) {
138
+ if (!explicitNext) location.replace(defaultEditPath);
139
+ previewDemo?.addEventListener("click", () => { location.href = nextPath; });
140
+ } else {
141
+ setupConnect();
142
+ }
143
+
144
+ function setupConnect() {
145
+ const saved = readSession();
146
+ const last = readJSON(lastKey);
147
+ const preset = connection || {};
148
+
149
+ // Already connected in this browser → go straight into the editor.
150
+ if (saved.connector && saved.repo) {
151
+ location.replace(nextPath);
152
+ return;
153
+ }
154
+ // Owner baked a connection into astro.config → clean "Start editing"
155
+ // screen, no developer form, no jargon.
156
+ if (preset.connector && preset.repo) {
157
+ showReady(preset);
158
+ return;
159
+ }
160
+ // No connection anywhere → show the form, prefilled from the last
161
+ // connection used (so Logout never forces a full retype) or a partial preset.
162
+ showForm({ ...preset, ...last });
163
+ }
164
+
165
+ function showReady(preset) {
166
+ const ready = document.querySelector("#ready");
167
+ const repoEl = document.querySelector("#ready-repo");
168
+ const readyError = document.querySelector("#ready-error");
169
+ if (repoEl && preset.repo) repoEl.textContent = preset.repo;
170
+ ready.hidden = false;
171
+ const form = document.querySelector("#ready-form");
172
+ const startButton = document.querySelector("#start-editing");
173
+ form?.addEventListener("submit", async (event) => {
174
+ event.preventDefault();
175
+ readyError.textContent = "";
176
+ startButton.disabled = true;
177
+ startButton.textContent = "Checking...";
178
+ const config = normalizeConfig({ ...preset, editorKey: form.elements.editorKey.value });
179
+ try {
180
+ const access = await verifyAccess(config);
181
+ // Owner didn't pin a branch → use the repository's real default
182
+ // (e.g. master), so a wrong "main" guess never silently 404s publishes.
183
+ if (!preset.branch && access.defaultBranch) config.branch = access.defaultBranch;
184
+ // The connection lives in astro.config, but the editor password is a
185
+ // secret that is not baked in — persist it (with the connection) so
186
+ // the editor runtime can send it on every publish.
187
+ saveConfig(config);
188
+ location.href = nextPath;
189
+ } catch (caught) {
190
+ readyError.textContent = readableError(caught);
191
+ startButton.disabled = false;
192
+ startButton.textContent = "Start editing";
193
+ }
194
+ });
195
+ document.querySelector("#switch-connection")?.addEventListener("click", () => {
196
+ ready.hidden = true;
197
+ showForm({ ...preset });
198
+ });
199
+ }
200
+
201
+ function showForm(prefill) {
202
+ const wrap = document.querySelector("#connect");
203
+ const form = document.querySelector("#connect-form");
204
+ const error = document.querySelector("#error");
205
+ wrap.hidden = false;
206
+ for (const [name, value] of Object.entries(prefill || {})) {
207
+ const input = form.elements[name];
208
+ if (input && value != null && value !== "") input.value = value;
209
+ }
210
+ form.addEventListener("submit", async (event) => {
211
+ event.preventDefault();
212
+ error.textContent = "";
213
+ const button = form.querySelector("button");
214
+ button.disabled = true;
215
+ button.textContent = "Checking...";
216
+ const config = normalizeConfig(Object.fromEntries(new FormData(form)));
217
+ try {
218
+ const access = await verifyAccess(config);
219
+ if (!form.elements.branch.value && access.defaultBranch) {
220
+ config.branch = access.defaultBranch;
221
+ }
222
+ saveConfig(config);
223
+ location.href = nextPath;
224
+ } catch (caught) {
225
+ error.textContent = readableError(caught);
226
+ } finally {
227
+ button.disabled = false;
228
+ button.textContent = "Connect";
229
+ }
230
+ });
231
+ }
232
+
233
+ function saveConfig(config) {
234
+ // Active credentials live only for this browser tab.
235
+ sessionStorage.setItem(configKey, JSON.stringify(config));
236
+ localStorage.removeItem(configKey);
237
+ // Remembered prefill copy must NOT carry the secret — it survives Logout.
238
+ const { editorKey, ...prefill } = config;
239
+ localStorage.setItem(lastKey, JSON.stringify(prefill));
240
+ }
241
+
242
+ function normalizeConfig(value) {
243
+ return {
244
+ repo: String(value.repo || "").trim(),
245
+ branch: String(value.branch || "main").trim() || "main",
246
+ connector: String(value.connector || "").trim().replace(/\/+$/g, ""),
247
+ sourceRoot: String(value.sourceRoot || "").trim().replace(/^\/+|\/+$/g, ""),
248
+ editorKey: String(value.editorKey || "").trim()
249
+ };
250
+ }
251
+
252
+ function safeLocalPath(value, fallback) {
253
+ const path = String(value || "");
254
+ return path.startsWith("/") && !path.startsWith("//") ? path : fallback;
255
+ }
256
+
257
+ async function verifyAccess(config) {
258
+ if (!config.connector) throw new ConnectorError("A CharlesCMS connector URL is required.");
259
+ if (!config.repo || !config.repo.includes("/")) throw new ConnectorError("Repository must be in owner/name form.");
260
+ const response = await fetch(`${config.connector}/api/verify`, {
261
+ method: "POST",
262
+ headers: {
263
+ "content-type": "application/json",
264
+ ...(config.editorKey ? { "x-charlescms-auth": config.editorKey } : {})
265
+ },
266
+ body: JSON.stringify({ repo: config.repo, branch: config.branch })
267
+ });
268
+ const data = await response.json().catch(() => ({}));
269
+ if (!response.ok) throw new ConnectorError(data.error || `Connector request failed (${response.status}).`, response.status);
270
+ return data;
271
+ }
272
+
273
+ class ConnectorError extends Error {
274
+ constructor(message, status = 0) {
275
+ super(message);
276
+ this.status = status;
277
+ }
278
+ }
279
+
280
+ function readJSON(key) {
281
+ try {
282
+ return JSON.parse(localStorage.getItem(key) || "{}") || {};
283
+ } catch {
284
+ return {};
285
+ }
286
+ }
287
+
288
+ function readSession() {
289
+ try {
290
+ const current = JSON.parse(sessionStorage.getItem(configKey) || "{}") || {};
291
+ if (current.connector && current.repo) return current;
292
+
293
+ // Migrate older CharlesCMS sessions away from persistent localStorage.
294
+ const legacy = JSON.parse(localStorage.getItem(configKey) || "{}") || {};
295
+ if (legacy.connector && legacy.repo) {
296
+ sessionStorage.setItem(configKey, JSON.stringify(legacy));
297
+ const { editorKey, ...prefill } = legacy;
298
+ localStorage.setItem(lastKey, JSON.stringify(prefill));
299
+ localStorage.removeItem(configKey);
300
+ return legacy;
301
+ }
302
+ } catch {}
303
+ return {};
304
+ }
305
+
306
+ function readableError(caught) {
307
+ if (caught instanceof ConnectorError && caught.status === 401) return "Incorrect editor password.";
308
+ if (caught instanceof ConnectorError && caught.status === 403) return "This connector does not allow this site origin or repository.";
309
+ if (caught instanceof ConnectorError && caught.status === 404) return "Repository not found, or the Git provider app/token cannot access it.";
310
+ return caught?.message || "Could not connect to the CharlesCMS connector.";
311
+ }
312
+ </script>
313
+ </body>
314
+ </html>