@charlescms/astro 0.1.0 → 0.2.1
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/README.md +72 -54
- package/package.json +1 -1
- package/scripts/setup.js +7 -1
- package/src/admin.astro +118 -90
- package/src/asset-images.js +2 -2
- package/src/boot.js +0 -2
- package/src/client.js +32 -19
- package/src/connector-client.js +2 -1
- package/src/content-panel.js +3 -6
- package/src/element-editor.js +0 -19
- package/src/index.js +117 -6
- package/src/local-client.js +50 -0
- package/src/publishing.js +10 -6
- package/src/runtime-controller.js +10 -2
- package/src/toolbar.js +10 -12
package/README.md
CHANGED
|
@@ -8,31 +8,14 @@ Static-first visual editing for Astro sites. Free and open source (MIT).
|
|
|
8
8
|
> [report anything that breaks](https://github.com/Charles-CMS/astro/issues).
|
|
9
9
|
> APIs may change before 1.0.
|
|
10
10
|
|
|
11
|
-
## Quickstart
|
|
11
|
+
## Quickstart — edit in 60 seconds
|
|
12
12
|
|
|
13
|
-
Requires Node.js 22.12 or newer and Astro 6.2.
|
|
14
|
-
|
|
15
|
-
Local preview needs no service account. To publish edits, you only need a GitHub
|
|
16
|
-
repository and a Cloudflare account; the connector is designed to fit within the
|
|
17
|
-
Cloudflare Workers free tier for typical small-site use.
|
|
13
|
+
Requires Node.js 22.12 or newer and Astro 6.2. **No account, no service, no config.**
|
|
18
14
|
|
|
19
15
|
```bash
|
|
20
16
|
npm install @charlescms/astro
|
|
21
17
|
```
|
|
22
18
|
|
|
23
|
-
Astro 6 currently permits an `esbuild` release affected by June 2026 security
|
|
24
|
-
advisories. Until Astro/Vite require the patched release themselves, pin it in
|
|
25
|
-
the consuming site's root `package.json`, reinstall, and verify with
|
|
26
|
-
`npm audit --omit=dev`:
|
|
27
|
-
|
|
28
|
-
```json
|
|
29
|
-
{
|
|
30
|
-
"overrides": {
|
|
31
|
-
"esbuild": "^0.28.1"
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
```
|
|
35
|
-
|
|
36
19
|
```js
|
|
37
20
|
// astro.config.mjs
|
|
38
21
|
import { defineConfig } from "astro/config";
|
|
@@ -45,44 +28,64 @@ export default defineConfig({ integrations: [charlesCMS()] });
|
|
|
45
28
|
npm run dev
|
|
46
29
|
```
|
|
47
30
|
|
|
48
|
-
Open **`/cms`**, then click any text or image on
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
Edits are a local preview until you connect the connector (below) — then
|
|
52
|
-
**Publish** commits them straight to your Git repo.
|
|
31
|
+
Open **`/cms`**, click **Start editing locally**, then click any text or image on
|
|
32
|
+
the page to change it. No schema, no content models, no setup.
|
|
53
33
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
name, branch, and an editor password):
|
|
34
|
+
In `npm run dev` your changes are written **straight to your real source files** —
|
|
35
|
+
`src/pages/index.astro`, your Markdown, your data — byte-for-byte and conservative
|
|
36
|
+
by design. Press **Save to local files**, then commit with your own Git when you're
|
|
37
|
+
happy:
|
|
59
38
|
|
|
60
39
|
```bash
|
|
61
|
-
|
|
40
|
+
git add -A && git commit -m "Edit copy in CharlesCMS"
|
|
62
41
|
```
|
|
63
42
|
|
|
64
|
-
|
|
65
|
-
|
|
43
|
+
That's the whole loop: **click → edit → Save → `git commit`**. No GitHub App, no
|
|
44
|
+
Cloudflare, nothing to deploy — this alone is a complete local visual editor.
|
|
66
45
|
|
|
67
|
-
>
|
|
68
|
-
>
|
|
69
|
-
>
|
|
70
|
-
> ```js
|
|
71
|
-
> charlesCMS({
|
|
72
|
-
> connector: "https://<your-worker>.workers.dev",
|
|
73
|
-
> repo: "owner/name",
|
|
74
|
-
> branch: "main",
|
|
75
|
-
> })
|
|
76
|
-
> ```
|
|
46
|
+
> Want non-technical people to edit the **live** site themselves (no local checkout,
|
|
47
|
+
> no Git)? That's the optional connector below — about two more minutes.
|
|
77
48
|
|
|
78
|
-
|
|
49
|
+
## Publish from your live site (optional)
|
|
79
50
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
51
|
+
> **This deploys the _connector_, not your website.** Your site stays hosted wherever you
|
|
52
|
+
> already put it (Vercel, Netlify, Cloudflare Pages, GitHub Pages…). The connector is a
|
|
53
|
+
> tiny *extra* Cloudflare Worker whose only job is to commit editors' changes to GitHub —
|
|
54
|
+
> then your existing host rebuilds the site as usual. The loop is:
|
|
55
|
+
> **edit in `/cms` → connector commits to GitHub → your host redeploys → live.**
|
|
83
56
|
|
|
84
|
-
|
|
85
|
-
|
|
57
|
+
Local editing needs nothing. To let people edit your **live** site — each save committed
|
|
58
|
+
to GitHub for you — deploy that connector. You need a **GitHub repo** and a **free
|
|
59
|
+
Cloudflare account** (it fits the Workers free tier). Nothing to install first — `npx`
|
|
60
|
+
fetches the tools; just say yes if it offers to install one.
|
|
61
|
+
|
|
62
|
+
**One-time setup (~5 min).** Do it in this order so you never have to come back:
|
|
63
|
+
|
|
64
|
+
1. Put your site in a GitHub repo and **deploy it** to your host (push → it builds; any
|
|
65
|
+
host works — see [Deploy your site](#deploy-your-site)). You now have your site's URL.
|
|
66
|
+
2. `npx wrangler login` — opens a browser to sign in to Cloudflare.
|
|
67
|
+
3. `npx charlescms setup --deploy --open` — answer the prompts; when it asks for your
|
|
68
|
+
**live site URL**, paste the one from step 1 (and set an editor password). A browser
|
|
69
|
+
opens to **create the GitHub App** (click create); it then deploys the Worker and
|
|
70
|
+
prints your **connector URL**.
|
|
71
|
+
4. Click the **install** link it prints, to add the GitHub App to your repo.
|
|
72
|
+
5. On your live site, open `/cms` → **Connect a publisher** → paste the connector URL,
|
|
73
|
+
repo (`owner/name`), and password.
|
|
74
|
+
|
|
75
|
+
Now **Edit → Publish → live** — each Publish commits to GitHub and your host rebuilds.
|
|
76
|
+
Verify anytime: `npx charlescms doctor <connector-url>`.
|
|
77
|
+
|
|
78
|
+
> **Not deployed yet?** You can still set up the connector — leave the live-site URL blank,
|
|
79
|
+
> finish, then add it later by re-running `npx charlescms setup --deploy` once your site is
|
|
80
|
+
> live (it remembers your answers). Deploying first just saves that second trip.
|
|
81
|
+
|
|
82
|
+
> **Skip the form:** bake the connection into `astro.config` so editors land straight on
|
|
83
|
+
> **Start editing** (only the password is asked):
|
|
84
|
+
> ```js
|
|
85
|
+
> charlesCMS({ connector: "https://<worker>.workers.dev", repo: "owner/name", branch: "main" })
|
|
86
|
+
> ```
|
|
87
|
+
|
|
88
|
+
Prefer clicking / no CLI? See [Manual setup](#manual-setup).
|
|
86
89
|
|
|
87
90
|
### CLI reference
|
|
88
91
|
|
|
@@ -243,7 +246,6 @@ charlesCMS({
|
|
|
243
246
|
sourceRoot: "website", // monorepos only
|
|
244
247
|
|
|
245
248
|
editablePaths: ["/demo", "/blog"], // limit where the editor activates
|
|
246
|
-
previewOnly: false, // true = local preview only, no publishing
|
|
247
249
|
adminPath: "/cms", // change the editor route
|
|
248
250
|
})
|
|
249
251
|
```
|
|
@@ -310,11 +312,27 @@ Skip the assistant and set up the Worker by hand:
|
|
|
310
312
|
## How it works & safety
|
|
311
313
|
|
|
312
314
|
The editor ships as static HTML/JS, reads a build-time source map, and stages edits
|
|
313
|
-
as a live preview.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
as a live preview. In `npm run dev` with no connector, **Save** applies the edit to
|
|
316
|
+
your local source file on disk through a dev-only endpoint — the same byte-verified
|
|
317
|
+
transform, no network. With a connector, **Publish** sends the exact source span to
|
|
318
|
+
the Worker, which commits to GitHub — credentials stay in the Worker, never in the
|
|
319
|
+
site or `localStorage`. Either way the file is re-read first and the edit refused if
|
|
320
|
+
the source changed, and rich text and section HTML are re-sanitized before the
|
|
321
|
+
complete updated file is written.
|
|
322
|
+
|
|
323
|
+
## esbuild dev-server pin (Windows only)
|
|
324
|
+
|
|
325
|
+
A path-traversal advisory ([GHSA-g7r4-m6w7-qqqr](https://github.com/advisories/GHSA-g7r4-m6w7-qqqr))
|
|
326
|
+
affects esbuild's **development server on Windows** in versions before `0.28.1`.
|
|
327
|
+
It is dev-only and Windows-only — it does **not** affect your built or published
|
|
328
|
+
site. Vite 7 still permits `esbuild@0.27.x`, so if you run `astro dev` on Windows,
|
|
329
|
+
pin the patched release in your site's **root** `package.json` and reinstall:
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{ "overrides": { "esbuild": "^0.28.1" } }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
You can drop this once your installed Vite requires `esbuild >= 0.28.1`.
|
|
318
336
|
|
|
319
337
|
## Checks
|
|
320
338
|
|
package/package.json
CHANGED
package/scripts/setup.js
CHANGED
|
@@ -321,7 +321,13 @@ function renderGitHubManifestPage({ config, state, redirectUrl }) {
|
|
|
321
321
|
const repoOwner = config.repo.split("/")[0];
|
|
322
322
|
const manifest = {
|
|
323
323
|
name: appName,
|
|
324
|
-
url
|
|
324
|
+
// GitHub validates `url` as ONE homepage URL. config.origin can be a
|
|
325
|
+
// comma-separated allow-list (localhost + the live site), which GitHub rejects
|
|
326
|
+
// with "Url must be a valid URL" — so pick a single valid URL: the public site
|
|
327
|
+
// if given, else the first origin, else localhost.
|
|
328
|
+
url: parseOrigins(config.origin).find((origin) => !isLocalOrigin(origin))
|
|
329
|
+
|| parseOrigins(config.origin)[0]
|
|
330
|
+
|| "http://localhost:4321",
|
|
325
331
|
redirect_url: `${redirectUrl}/callback`,
|
|
326
332
|
callback_urls: [`${redirectUrl}/callback`],
|
|
327
333
|
public: false,
|
package/src/admin.astro
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
import config from "virtual:charlescms-config";
|
|
3
3
|
|
|
4
|
-
const previewOnly = Boolean(config.previewOnly);
|
|
5
4
|
const defaultEditPath = String(config.defaultEditPath || config.editablePaths?.[0] || "/");
|
|
6
5
|
const connection = config.connection || {};
|
|
6
|
+
const projectId = config.projectId || "default";
|
|
7
|
+
// In `astro dev` the editor can save straight to disk with no connector, so /cms
|
|
8
|
+
// leads with "Start editing locally" instead of a setup form. A built/hosted site
|
|
9
|
+
// has no dev server, so there it still needs the connector (rung 2).
|
|
10
|
+
const dev = import.meta.env.DEV;
|
|
7
11
|
---
|
|
8
12
|
<!doctype html>
|
|
9
13
|
<html lang="en">
|
|
@@ -41,7 +45,9 @@ const connection = config.connection || {};
|
|
|
41
45
|
.demo-actions { display: grid; gap: 12px; }
|
|
42
46
|
.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
47
|
.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; }
|
|
48
|
+
.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; box-shadow: none; }
|
|
49
|
+
.secondary:hover { background: #eef1f4; color: #172026; box-shadow: none; }
|
|
50
|
+
.secondary:active { transform: translateY(1px); }
|
|
45
51
|
.footnote { color: rgba(255,255,255,.76); font-size: 13px; text-align: center; }
|
|
46
52
|
.advanced { border-top: 1px solid #e4e8ec; padding-top: 8px; }
|
|
47
53
|
.advanced summary { cursor: pointer; font-weight: 800; color: #3a4a55; padding: 4px 0; }
|
|
@@ -56,90 +62,81 @@ const connection = config.connection || {};
|
|
|
56
62
|
<main>
|
|
57
63
|
<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
64
|
<section class="panel">
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
</
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<div class="error" id="error"></div>
|
|
122
|
-
<button>Connect</button>
|
|
123
|
-
</form>
|
|
124
|
-
</div>
|
|
125
|
-
</>
|
|
126
|
-
)}
|
|
65
|
+
<div id="local" hidden>
|
|
66
|
+
<div>
|
|
67
|
+
<h1>Start editing</h1>
|
|
68
|
+
<p>Click any text or image on your site to edit it — your changes are written straight to your source files. Commit them with git when you're happy. No account, no setup.</p>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="demo-actions">
|
|
71
|
+
<button id="start-local">Start editing locally</button>
|
|
72
|
+
<button type="button" id="show-connect" class="secondary">Connect a publisher (to edit your live site)</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div id="ready" hidden>
|
|
76
|
+
<div>
|
|
77
|
+
<h1>Ready to edit</h1>
|
|
78
|
+
<p>You're connected to <strong id="ready-repo">this site</strong>. Open the site and click any text to start editing.</p>
|
|
79
|
+
</div>
|
|
80
|
+
<form id="ready-form" class="demo-actions">
|
|
81
|
+
<label>
|
|
82
|
+
Editor password
|
|
83
|
+
<input id="ready-password" name="editorKey" type="password" autocomplete="current-password" placeholder="Required Worker AUTH_SECRET" required />
|
|
84
|
+
<span class="sub">Must match the connector's required AUTH_SECRET.</span>
|
|
85
|
+
</label>
|
|
86
|
+
<button id="start-editing">Start editing</button>
|
|
87
|
+
<button type="button" id="switch-connection" class="secondary">Use a different connection</button>
|
|
88
|
+
</form>
|
|
89
|
+
<div class="error" id="ready-error"></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="connect" hidden>
|
|
92
|
+
<div>
|
|
93
|
+
<h1>Connect CharlesCMS</h1>
|
|
94
|
+
<p>Paste the details printed by <code>npx charlescms setup --deploy --open</code>. Git credentials stay in the Worker, not in this browser.</p>
|
|
95
|
+
</div>
|
|
96
|
+
<form id="connect-form">
|
|
97
|
+
<label>
|
|
98
|
+
Connector URL
|
|
99
|
+
<input name="connector" placeholder="https://site-charlescms.workers.dev" autocomplete="off" required />
|
|
100
|
+
</label>
|
|
101
|
+
<label>
|
|
102
|
+
Repository
|
|
103
|
+
<input name="repo" placeholder="owner/name" autocomplete="off" required />
|
|
104
|
+
</label>
|
|
105
|
+
<label>
|
|
106
|
+
Branch
|
|
107
|
+
<input name="branch" placeholder="default branch (e.g. main)" autocomplete="off" />
|
|
108
|
+
<span class="sub">Leave blank to use the repository's default branch.</span>
|
|
109
|
+
</label>
|
|
110
|
+
<label>
|
|
111
|
+
Editor password
|
|
112
|
+
<input name="editorKey" type="password" autocomplete="current-password" placeholder="Required Worker AUTH_SECRET" required />
|
|
113
|
+
<span class="sub">Must match the connector's required AUTH_SECRET.</span>
|
|
114
|
+
</label>
|
|
115
|
+
<details class="advanced">
|
|
116
|
+
<summary>Advanced</summary>
|
|
117
|
+
<label>
|
|
118
|
+
Source root
|
|
119
|
+
<input name="sourceRoot" placeholder="website" autocomplete="off" />
|
|
120
|
+
<span class="sub">Only for monorepos — the subfolder your Astro site lives in (e.g. website). Leave blank otherwise.</span>
|
|
121
|
+
</label>
|
|
122
|
+
</details>
|
|
123
|
+
<div class="error" id="error"></div>
|
|
124
|
+
<button>Connect</button>
|
|
125
|
+
</form>
|
|
126
|
+
</div>
|
|
127
127
|
</section>
|
|
128
128
|
<div class="footnote">Protected visual editing for Astro.</div>
|
|
129
129
|
</main>
|
|
130
|
-
<script type="module" define:vars={{ defaultEditPath,
|
|
131
|
-
|
|
130
|
+
<script type="module" define:vars={{ defaultEditPath, connection, projectId, dev }}>
|
|
131
|
+
// Storage is namespaced per project so two local sites sharing a dev port
|
|
132
|
+
// (origin is just localhost:<port>) never read each other's connection.
|
|
133
|
+
const configKey = `charlescms_connector::${projectId}`;
|
|
132
134
|
const lastKey = `${configKey}_last`;
|
|
135
|
+
const LEGACY_CONFIG_KEY = "charlescms_connector";
|
|
133
136
|
const explicitNext = new URLSearchParams(location.search).get("next");
|
|
134
137
|
const nextPath = safeLocalPath(explicitNext || defaultEditPath, defaultEditPath);
|
|
135
|
-
const previewDemo = document.querySelector("#preview-demo");
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
if (!explicitNext) location.replace(defaultEditPath);
|
|
139
|
-
previewDemo?.addEventListener("click", () => { location.href = nextPath; });
|
|
140
|
-
} else {
|
|
141
|
-
setupConnect();
|
|
142
|
-
}
|
|
139
|
+
setupConnect();
|
|
143
140
|
|
|
144
141
|
function setupConnect() {
|
|
145
142
|
const saved = readSession();
|
|
@@ -157,11 +154,29 @@ const connection = config.connection || {};
|
|
|
157
154
|
showReady(preset);
|
|
158
155
|
return;
|
|
159
156
|
}
|
|
160
|
-
// No connection
|
|
161
|
-
//
|
|
157
|
+
// No connection. In dev, lead with instant local editing (writes to disk,
|
|
158
|
+
// no setup); the connector form is a secondary path for publishing a live
|
|
159
|
+
// site. In a built/hosted site there's no dev server, so the connector is
|
|
160
|
+
// required — show the form straight away.
|
|
161
|
+
if (dev) {
|
|
162
|
+
showLocal({ ...preset, ...last });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
162
165
|
showForm({ ...preset, ...last });
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
function showLocal(prefill) {
|
|
169
|
+
const local = document.querySelector("#local");
|
|
170
|
+
local.hidden = false;
|
|
171
|
+
document.querySelector("#start-local")?.addEventListener("click", () => {
|
|
172
|
+
location.href = nextPath;
|
|
173
|
+
});
|
|
174
|
+
document.querySelector("#show-connect")?.addEventListener("click", () => {
|
|
175
|
+
local.hidden = true;
|
|
176
|
+
showForm(prefill);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
165
180
|
function showReady(preset) {
|
|
166
181
|
const ready = document.querySelector("#ready");
|
|
167
182
|
const repoEl = document.querySelector("#ready-repo");
|
|
@@ -285,21 +300,34 @@ const connection = config.connection || {};
|
|
|
285
300
|
}
|
|
286
301
|
}
|
|
287
302
|
|
|
303
|
+
function parseStore(store, key) {
|
|
304
|
+
try { return JSON.parse(store.getItem(key) || "{}") || {}; } catch { return {}; }
|
|
305
|
+
}
|
|
306
|
+
|
|
288
307
|
function readSession() {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (current.connector && current.repo) return current;
|
|
308
|
+
const current = parseStore(sessionStorage, configKey);
|
|
309
|
+
if (current.connector && current.repo) return current;
|
|
292
310
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
311
|
+
// Adopt a connection saved in persistent localStorage, or under the old
|
|
312
|
+
// un-namespaced key, into this project's session — then clear the old
|
|
313
|
+
// copies (the un-namespaced ones leaked between projects on a shared port).
|
|
314
|
+
const legacy = [
|
|
315
|
+
parseStore(localStorage, configKey),
|
|
316
|
+
parseStore(sessionStorage, LEGACY_CONFIG_KEY),
|
|
317
|
+
parseStore(localStorage, LEGACY_CONFIG_KEY)
|
|
318
|
+
].find((value) => value.connector && value.repo);
|
|
319
|
+
if (legacy) {
|
|
320
|
+
try {
|
|
296
321
|
sessionStorage.setItem(configKey, JSON.stringify(legacy));
|
|
297
322
|
const { editorKey, ...prefill } = legacy;
|
|
298
323
|
localStorage.setItem(lastKey, JSON.stringify(prefill));
|
|
299
324
|
localStorage.removeItem(configKey);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
325
|
+
sessionStorage.removeItem(LEGACY_CONFIG_KEY);
|
|
326
|
+
localStorage.removeItem(LEGACY_CONFIG_KEY);
|
|
327
|
+
localStorage.removeItem(`${LEGACY_CONFIG_KEY}_last`);
|
|
328
|
+
} catch {}
|
|
329
|
+
return legacy;
|
|
330
|
+
}
|
|
303
331
|
return {};
|
|
304
332
|
}
|
|
305
333
|
|
package/src/asset-images.js
CHANGED
|
@@ -96,8 +96,8 @@ export function createAssetImages({ state, sourceMapData, fileToBase64, setToolb
|
|
|
96
96
|
if (other.closest("[data-charlescms-ui]")) continue;
|
|
97
97
|
if (sourceAssetForImg(other, assetIndex) === repoPath) showPreview(other, blobUrl);
|
|
98
98
|
}
|
|
99
|
-
if (
|
|
100
|
-
setToolbarStatus("
|
|
99
|
+
if (!state.connector) {
|
|
100
|
+
setToolbarStatus("Connect a publisher to save image changes.");
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
103
|
try {
|
package/src/boot.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const adminPath = window.__CHARLESCMS_PATH__ || "/cms";
|
|
2
2
|
const editPaths = Array.isArray(window.__CHARLESCMS_EDIT_PATHS__) ? window.__CHARLESCMS_EDIT_PATHS__ : [];
|
|
3
|
-
const previewOnly = Boolean(window.__CHARLESCMS_PREVIEW_ONLY__);
|
|
4
3
|
|
|
5
4
|
// When editablePaths is configured, the editor only activates on those route
|
|
6
5
|
// prefixes. This keeps non-content pages (e.g. a marketing landing page) free of
|
|
@@ -26,7 +25,6 @@ function isAdminRoute() {
|
|
|
26
25
|
|
|
27
26
|
export function bootCharlesCMS() {
|
|
28
27
|
if (!isAdminRoute() && isEditableHere()) {
|
|
29
|
-
window.__CHARLESCMS_PREVIEW_ONLY__ = previewOnly;
|
|
30
28
|
// Keep this a STATIC specifier: Vite only rewrites bare-specifier dynamic
|
|
31
29
|
// imports when the string is literal. A template (e.g. a ?v= cache-bust)
|
|
32
30
|
// leaves the bare specifier unresolved and the browser throws.
|
package/src/client.js
CHANGED
|
@@ -2,6 +2,7 @@ import sourceMapData from "virtual:charlescms-source-map";
|
|
|
2
2
|
import sectionTemplatesData from "virtual:charlescms-section-templates";
|
|
3
3
|
import { createAssetImages } from "./asset-images.js";
|
|
4
4
|
import { createConnectorClient } from "./connector-client.js";
|
|
5
|
+
import { createLocalClient } from "./local-client.js";
|
|
5
6
|
import { createContentBridge } from "./content-bridge.js";
|
|
6
7
|
import { createContentPanel } from "./content-panel.js";
|
|
7
8
|
import { createEditAffordance } from "./edit-affordance.js";
|
|
@@ -30,10 +31,16 @@ import { createStagedPanel } from "./staged-panel.js";
|
|
|
30
31
|
// only shared state, module wiring, session helpers, and the direct Tiptap
|
|
31
32
|
// dynamic imports required by Vite for vendored `file:` installations.
|
|
32
33
|
const adminPath = window.__CHARLESCMS_PATH__ || "/cms";
|
|
33
|
-
|
|
34
|
+
// Storage is namespaced per project so two local sites sharing a dev port (where
|
|
35
|
+
// the origin is just `localhost:<port>`) never read each other's saved connection.
|
|
36
|
+
const configKey = `charlescms_connector::${window.__CHARLESCMS_PROJECT_ID__ || "default"}`;
|
|
37
|
+
const lastKey = `${configKey}_last`;
|
|
38
|
+
// The key used before storage was namespaced per project — adopted once, then
|
|
39
|
+
// cleared, so a pre-upgrade connection isn't lost but also stops leaking.
|
|
40
|
+
const LEGACY_CONFIG_KEY = "charlescms_connector";
|
|
34
41
|
const state = {
|
|
35
42
|
authenticated: false,
|
|
36
|
-
|
|
43
|
+
local: false,
|
|
37
44
|
active: null,
|
|
38
45
|
edits: [],
|
|
39
46
|
allSourceMap: [],
|
|
@@ -197,6 +204,7 @@ const {
|
|
|
197
204
|
state,
|
|
198
205
|
readConnectorConfig,
|
|
199
206
|
createConnectorClient,
|
|
207
|
+
createLocalClient,
|
|
200
208
|
closeEditor,
|
|
201
209
|
loadSourceMap,
|
|
202
210
|
applySourceMapIds,
|
|
@@ -257,25 +265,30 @@ async function waitForLivePage(markers = []) {
|
|
|
257
265
|
return false;
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
function readStoredJSON(store, key) {
|
|
269
|
+
try { return JSON.parse(store.getItem(key) || "null"); } catch { return null; }
|
|
270
|
+
}
|
|
271
|
+
|
|
260
272
|
function readConnectorConfig() {
|
|
261
273
|
const preset = (typeof window !== "undefined" && window.__CHARLESCMS_CONNECTION__) || {};
|
|
262
|
-
let saved =
|
|
263
|
-
try {
|
|
264
|
-
saved = JSON.parse(sessionStorage.getItem(configKey) || "null");
|
|
265
|
-
} catch {
|
|
266
|
-
saved = null;
|
|
267
|
-
}
|
|
274
|
+
let saved = readStoredJSON(sessionStorage, configKey);
|
|
268
275
|
if (!saved?.connector || !saved?.repo) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
276
|
+
// Adopt a connection saved in persistent localStorage, or under the old
|
|
277
|
+
// un-namespaced key, into this project's session — then remove the old copies
|
|
278
|
+
// (the un-namespaced ones are what leaked between projects on a shared port).
|
|
279
|
+
const legacy = readStoredJSON(localStorage, configKey)
|
|
280
|
+
|| readStoredJSON(sessionStorage, LEGACY_CONFIG_KEY)
|
|
281
|
+
|| readStoredJSON(localStorage, LEGACY_CONFIG_KEY);
|
|
282
|
+
if (legacy?.connector && legacy?.repo) {
|
|
283
|
+
sessionStorage.setItem(configKey, JSON.stringify(legacy));
|
|
284
|
+
const { editorKey, ...prefill } = legacy;
|
|
285
|
+
localStorage.setItem(lastKey, JSON.stringify(prefill));
|
|
286
|
+
localStorage.removeItem(configKey);
|
|
287
|
+
sessionStorage.removeItem(LEGACY_CONFIG_KEY);
|
|
288
|
+
localStorage.removeItem(LEGACY_CONFIG_KEY);
|
|
289
|
+
localStorage.removeItem(`${LEGACY_CONFIG_KEY}_last`);
|
|
290
|
+
saved = legacy;
|
|
291
|
+
}
|
|
279
292
|
}
|
|
280
293
|
// A connection the editor saved in this browser wins; otherwise fall back to
|
|
281
294
|
// the owner's baked-in preset so editors are connected without any form.
|
|
@@ -330,7 +343,7 @@ async function logout() {
|
|
|
330
343
|
const current = sessionStorage.getItem(configKey);
|
|
331
344
|
if (current) {
|
|
332
345
|
const { editorKey, ...prefill } = JSON.parse(current) || {};
|
|
333
|
-
localStorage.setItem(
|
|
346
|
+
localStorage.setItem(lastKey, JSON.stringify(prefill));
|
|
334
347
|
}
|
|
335
348
|
} catch {}
|
|
336
349
|
sessionStorage.removeItem(configKey);
|
package/src/connector-client.js
CHANGED
|
@@ -79,7 +79,8 @@ export function createConnectorClient(config) {
|
|
|
79
79
|
|
|
80
80
|
const persistBranch = (value) => {
|
|
81
81
|
try {
|
|
82
|
-
const
|
|
82
|
+
const projectId = globalThis.window?.__CHARLESCMS_PROJECT_ID__ || "default";
|
|
83
|
+
const key = `charlescms_connector::${projectId}`;
|
|
83
84
|
const saved = JSON.parse(globalThis.sessionStorage?.getItem(key) || "{}");
|
|
84
85
|
if (saved?.connector) {
|
|
85
86
|
saved.branch = value;
|
package/src/content-panel.js
CHANGED
|
@@ -318,16 +318,13 @@ export function createContentPanel({
|
|
|
318
318
|
function mediaFieldHtml(media) {
|
|
319
319
|
const repoPath = downloadRepoPath(media.path);
|
|
320
320
|
const thumb = `<img src="${escapeAttribute(media.path)}" alt="" style="width:44px;height:44px;object-fit:cover;border-radius:6px;margin-right:10px;vertical-align:middle;border:1px solid rgba(0,0,0,.1)">`;
|
|
321
|
-
const control =
|
|
322
|
-
? "
|
|
323
|
-
:
|
|
324
|
-
? `<input type="file" accept="image/*" data-data-media="${escapeAttribute(repoPath)}">`
|
|
325
|
-
: "<span>External or dynamic image — not replaceable.</span>";
|
|
321
|
+
const control = repoPath
|
|
322
|
+
? `<input type="file" accept="image/*" data-data-media="${escapeAttribute(repoPath)}">`
|
|
323
|
+
: "<span>External or dynamic image — not replaceable.</span>";
|
|
326
324
|
return `<label>${escapeHtml(media.label)} · image\n<span style="display:flex;align-items:center">${thumb}${control}</span></label>`;
|
|
327
325
|
}
|
|
328
326
|
|
|
329
327
|
async function replaceDataMedia(event) {
|
|
330
|
-
if (state.previewOnly) return;
|
|
331
328
|
const file = event.target.files?.[0];
|
|
332
329
|
const repoPath = event.target.dataset.dataMedia;
|
|
333
330
|
if (!file || !repoPath) return;
|
package/src/element-editor.js
CHANGED
|
@@ -232,17 +232,6 @@ export function createElementEditor({
|
|
|
232
232
|
|
|
233
233
|
function renderMediaTools(item) {
|
|
234
234
|
if (!isMediaItem(item)) return "";
|
|
235
|
-
if (state.previewOnly) {
|
|
236
|
-
return `
|
|
237
|
-
<div class="charlescms-media-tools">
|
|
238
|
-
<p>Uploads are disabled in this public demo. The real installed version can upload through your connector.</p>
|
|
239
|
-
<div>
|
|
240
|
-
<button type="button" data-upload-disabled disabled title="Uploads are disabled in this public demo">Upload replacement</button>
|
|
241
|
-
<button type="button" data-remove>Remove</button>
|
|
242
|
-
</div>
|
|
243
|
-
</div>
|
|
244
|
-
`;
|
|
245
|
-
}
|
|
246
235
|
return `
|
|
247
236
|
<div class="charlescms-media-tools">
|
|
248
237
|
<label>Upload replacement<input type="file" data-upload></label>
|
|
@@ -256,14 +245,6 @@ export function createElementEditor({
|
|
|
256
245
|
if (!repoPath) {
|
|
257
246
|
return '<div class="charlescms-media-tools"><p>This link points to an external or dynamic file, so it can\'t be replaced here.</p></div>';
|
|
258
247
|
}
|
|
259
|
-
if (state.previewOnly) {
|
|
260
|
-
return `
|
|
261
|
-
<div class="charlescms-media-tools">
|
|
262
|
-
<p>Uploads are disabled in this public demo. The real installed version can replace files through your connector.</p>
|
|
263
|
-
<div><button type="button" disabled title="Uploads are disabled in this public demo">Replace file</button></div>
|
|
264
|
-
</div>
|
|
265
|
-
`;
|
|
266
|
-
}
|
|
267
248
|
return `
|
|
268
249
|
<div class="charlescms-media-tools">
|
|
269
250
|
<label>Replace file<input type="file" data-replace-download></label>
|
package/src/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { transformAstroSource, extractNavCollections, extractDataCollections } f
|
|
|
31
31
|
import { collectFrontmatterFields } from "./frontmatter.js";
|
|
32
32
|
import { transformMarkdownSource } from "./markdown-analyzer.js";
|
|
33
33
|
import { normalizeBlockHtml, sanitizeBlockHtml } from "./sanitize.js";
|
|
34
|
+
import { createEditedSource, UnsafeSourceEditError } from "./source-edit.js";
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* CharlesCMS Astro integration — adds a source-native visual editor to an Astro
|
|
@@ -43,8 +44,6 @@ import { normalizeBlockHtml, sanitizeBlockHtml } from "./sanitize.js";
|
|
|
43
44
|
* Defaults to all pages. Accepts a single string or an array.
|
|
44
45
|
* @param {string} [options.defaultEditPath] Page the editor opens first.
|
|
45
46
|
* Defaults to the first `editablePaths` entry, else `/`.
|
|
46
|
-
* @param {boolean} [options.previewOnly=false] Run the editor read-only with no
|
|
47
|
-
* publishing (e.g. a public demo).
|
|
48
47
|
* @param {string} [options.connector] Preset Cloudflare connector worker URL,
|
|
49
48
|
* so editors never type connection details into a form.
|
|
50
49
|
* @param {string} [options.repo] Preset `owner/name` GitHub repository.
|
|
@@ -57,7 +56,11 @@ export default function charlesCMS(options = {}) {
|
|
|
57
56
|
const adminPath = normalizePath(options.adminPath || "/cms");
|
|
58
57
|
const editablePaths = normalizeEditablePaths(options.editablePaths);
|
|
59
58
|
const defaultEditPath = normalizePath(options.defaultEditPath || editablePaths[0] || "/");
|
|
60
|
-
|
|
59
|
+
// A stable id for THIS project, derived from its directory. Browser storage is
|
|
60
|
+
// keyed by origin, which on localhost is just the port — so two local sites on
|
|
61
|
+
// the same dev port would otherwise share one saved connection. Namespacing the
|
|
62
|
+
// storage keys with this id keeps each project's connection to itself.
|
|
63
|
+
const projectId = hashId(process.cwd());
|
|
61
64
|
// Optional connection baked in by the site owner so editors never have to type
|
|
62
65
|
// connector/repo/branch (or the jargon-y source root) into a form. When set,
|
|
63
66
|
// /cms shows a clean "Start editing" screen instead of the developer form.
|
|
@@ -98,7 +101,7 @@ export default function charlesCMS(options = {}) {
|
|
|
98
101
|
adminPath,
|
|
99
102
|
editablePaths,
|
|
100
103
|
defaultEditPath,
|
|
101
|
-
|
|
104
|
+
projectId,
|
|
102
105
|
connection
|
|
103
106
|
}));
|
|
104
107
|
updateConfig({
|
|
@@ -162,7 +165,7 @@ export default function charlesCMS(options = {}) {
|
|
|
162
165
|
return `export default ${JSON.stringify(await buildSerializableSourceMap(projectRoot, sourceMap))};`;
|
|
163
166
|
}
|
|
164
167
|
if (id === resolvedVirtualConfigId) {
|
|
165
|
-
return `export default ${JSON.stringify({ adminPath, editablePaths, defaultEditPath,
|
|
168
|
+
return `export default ${JSON.stringify({ adminPath, editablePaths, defaultEditPath, projectId, connection })};`;
|
|
166
169
|
}
|
|
167
170
|
if (id === resolvedVirtualRuntimeId) {
|
|
168
171
|
return `import { bootCharlesCMS } from "@charlescms/astro/src/boot.js"; bootCharlesCMS();`;
|
|
@@ -284,6 +287,84 @@ export default function charlesCMS(options = {}) {
|
|
|
284
287
|
});
|
|
285
288
|
});
|
|
286
289
|
|
|
290
|
+
// Local-first editing: with no connector configured, `astro dev`
|
|
291
|
+
// applies a staged edit straight to the project's source on disk
|
|
292
|
+
// here — the same byte-verified transform the connector runs, just
|
|
293
|
+
// written locally instead of committed to GitHub. The server reads
|
|
294
|
+
// the current file, re-verifies the recorded bytes still match
|
|
295
|
+
// (so a hand-edit since the last build fails safely, never a blind
|
|
296
|
+
// overwrite), writes the result, and lets the editor own the single
|
|
297
|
+
// reload. There is no production counterpart: a deployed site has no
|
|
298
|
+
// dev server, so saving requires the connector (rung 2).
|
|
299
|
+
server.middlewares.use("/_charlescms/apply-edit", (request, response) => {
|
|
300
|
+
if (request.method !== "POST") { response.statusCode = 405; response.end(); return; }
|
|
301
|
+
let body = "";
|
|
302
|
+
request.on("data", (chunk) => { body += chunk; });
|
|
303
|
+
request.on("end", async () => {
|
|
304
|
+
const fail = (status, error) => {
|
|
305
|
+
response.statusCode = status;
|
|
306
|
+
response.setHeader("content-type", "application/json");
|
|
307
|
+
response.end(JSON.stringify({ error }));
|
|
308
|
+
};
|
|
309
|
+
try {
|
|
310
|
+
const { file, entry, edit } = JSON.parse(body || "{}");
|
|
311
|
+
const target = safeProjectSourcePath(projectRoot, file);
|
|
312
|
+
if (!target || !entry || !edit) { fail(400, "Invalid edit request."); return; }
|
|
313
|
+
let current;
|
|
314
|
+
try {
|
|
315
|
+
current = await readFile(target, "utf8");
|
|
316
|
+
} catch {
|
|
317
|
+
fail(404, `Source file not found: ${file}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
let next;
|
|
321
|
+
try {
|
|
322
|
+
next = createEditedSource(current, { ...entry, file }, edit);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof UnsafeSourceEditError) { fail(409, error.message); return; }
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
if (next === current) {
|
|
328
|
+
response.statusCode = 200;
|
|
329
|
+
response.setHeader("content-type", "application/json");
|
|
330
|
+
response.end(JSON.stringify({ unchanged: true }));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
mirrorReloadGuardUntil = Date.now() + 2000;
|
|
334
|
+
await writeFile(target, next);
|
|
335
|
+
response.statusCode = 200;
|
|
336
|
+
response.setHeader("content-type", "application/json");
|
|
337
|
+
response.end(JSON.stringify({ ok: true }));
|
|
338
|
+
} catch (error) {
|
|
339
|
+
fail(500, String(error?.message || error));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Local-first media: write a replaced image/upload straight to disk
|
|
345
|
+
// (the connector path commits to GitHub instead). Limited to images
|
|
346
|
+
// under src/ (astro:assets source overwrite, re-optimized on reload)
|
|
347
|
+
// and files under public/ (uploads and replaced downloads).
|
|
348
|
+
server.middlewares.use("/_charlescms/write-binary", (request, response) => {
|
|
349
|
+
if (request.method !== "POST") { response.statusCode = 405; response.end(); return; }
|
|
350
|
+
let body = "";
|
|
351
|
+
request.on("data", (chunk) => { body += chunk; });
|
|
352
|
+
request.on("end", async () => {
|
|
353
|
+
try {
|
|
354
|
+
const { path, base64 } = JSON.parse(body || "{}");
|
|
355
|
+
const target = safeProjectBinaryPath(projectRoot, path);
|
|
356
|
+
if (!target) { response.statusCode = 400; response.end("invalid path"); return; }
|
|
357
|
+
await mkdir(dirname(target), { recursive: true });
|
|
358
|
+
await writeFile(target, Buffer.from(String(base64 || ""), "base64"));
|
|
359
|
+
response.statusCode = 204;
|
|
360
|
+
response.end();
|
|
361
|
+
} catch (error) {
|
|
362
|
+
response.statusCode = 500;
|
|
363
|
+
response.end(String(error?.message || error));
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
287
368
|
let refreshTimer;
|
|
288
369
|
let refreshQueue = Promise.resolve();
|
|
289
370
|
const scheduleRefresh = (file) => {
|
|
@@ -394,7 +475,7 @@ function renderPageScript(config) {
|
|
|
394
475
|
return `window.__CHARLESCMS_PATH__=${JSON.stringify(config.adminPath)};
|
|
395
476
|
window.__CHARLESCMS_EDIT_PATHS__=${JSON.stringify(config.editablePaths)};
|
|
396
477
|
window.__CHARLESCMS_DEFAULT_EDIT_PATH__=${JSON.stringify(config.defaultEditPath)};
|
|
397
|
-
window.
|
|
478
|
+
window.__CHARLESCMS_PROJECT_ID__=${JSON.stringify(config.projectId)};
|
|
398
479
|
window.__CHARLESCMS_CONNECTION__=${JSON.stringify(config.connection || {})};
|
|
399
480
|
import("virtual:charlescms-runtime");`;
|
|
400
481
|
}
|
|
@@ -534,6 +615,36 @@ function safeProjectSourcePath(root, relativePath) {
|
|
|
534
615
|
return target;
|
|
535
616
|
}
|
|
536
617
|
|
|
618
|
+
// Resolve a project-relative binary path (a replaced image or upload) for the dev
|
|
619
|
+
// write endpoint, only if it stays inside the project and names a file we allow:
|
|
620
|
+
// images under src/ (astro:assets source overwrite) or any file under public/.
|
|
621
|
+
// Anything traversing out, absolute, or elsewhere is rejected.
|
|
622
|
+
function safeProjectBinaryPath(root, relativePath) {
|
|
623
|
+
const value = String(relativePath || "").replace(/\\/g, "/");
|
|
624
|
+
if (!value || value.startsWith("/") || value.split("/").includes("..")) return null;
|
|
625
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(value)) return null;
|
|
626
|
+
const inSrc = value.startsWith("src/");
|
|
627
|
+
const inPublic = value.startsWith("public/");
|
|
628
|
+
if (!inSrc && !inPublic) return null;
|
|
629
|
+
if (inSrc && !IMAGE_EXT_RE.test(value)) return null;
|
|
630
|
+
if (inPublic && !/\.[A-Za-z0-9]+$/.test(value)) return null;
|
|
631
|
+
const target = resolve(root, value);
|
|
632
|
+
const base = resolve(root);
|
|
633
|
+
if (target !== base && !target.startsWith(base + sep)) return null;
|
|
634
|
+
return target;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A short, stable id for a project directory (djb2). Dependency-free; only used to
|
|
638
|
+
// namespace per-project browser storage, so collision resistance isn't critical.
|
|
639
|
+
function hashId(value) {
|
|
640
|
+
let hash = 5381;
|
|
641
|
+
const text = String(value || "");
|
|
642
|
+
for (let index = 0; index < text.length; index++) {
|
|
643
|
+
hash = ((hash << 5) + hash + text.charCodeAt(index)) >>> 0;
|
|
644
|
+
}
|
|
645
|
+
return hash.toString(36);
|
|
646
|
+
}
|
|
647
|
+
|
|
537
648
|
function isSectionTemplateFile(file, root) {
|
|
538
649
|
const path = String(file || "").replace(/\\/g, "/");
|
|
539
650
|
const directory = join(root, "src/charlescms/templates").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ConnectorError } from "./connector-client.js";
|
|
2
|
+
|
|
3
|
+
// Local (no-connector) client used in `astro dev` to write visual edits straight
|
|
4
|
+
// to the project's source files on disk, through the dev-only endpoints in
|
|
5
|
+
// index.js. It mirrors the slice of the connector-client API the editor calls, so
|
|
6
|
+
// publishing.js and the media tools work unchanged — they just write locally
|
|
7
|
+
// instead of committing to GitHub. The user keeps their edits with their own git.
|
|
8
|
+
//
|
|
9
|
+
// Version history (listCommits/revertCommit) has no local equivalent, so the UI
|
|
10
|
+
// hides those affordances via `supportsVersions: false` / `local: true`.
|
|
11
|
+
export function createLocalClient() {
|
|
12
|
+
const post = async (path, body) => {
|
|
13
|
+
const response = await fetch(path, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "content-type": "application/json" },
|
|
16
|
+
body: JSON.stringify(body)
|
|
17
|
+
});
|
|
18
|
+
const data = await response.json().catch(() => ({}));
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new ConnectorError(data.error || `Local save failed (${response.status}).`, response.status);
|
|
21
|
+
}
|
|
22
|
+
return data;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const applyEdit = async (entry, edit) => {
|
|
26
|
+
const data = await post("/_charlescms/apply-edit", { file: entry.file, entry, edit });
|
|
27
|
+
return { id: edit.id, file: entry.file, commit: null, unchanged: Boolean(data.unchanged) };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const putBase64 = async (path, base64) => {
|
|
31
|
+
await post("/_charlescms/write-binary", { path, base64: String(base64).replace(/\s/g, "") });
|
|
32
|
+
return { commit: null };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// No GitHub locally: getFile is only ever called to fetch a blob SHA before an
|
|
36
|
+
// overwrite, which doesn't apply on disk — return null so callers skip it.
|
|
37
|
+
const getFile = async () => null;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
applyEdit,
|
|
41
|
+
putBase64,
|
|
42
|
+
getFile,
|
|
43
|
+
listCommits: async () => [],
|
|
44
|
+
revertCommit: async () => { throw new ConnectorError("Version history needs a connector.", 0); },
|
|
45
|
+
verifyAccess: async () => ({ local: true }),
|
|
46
|
+
local: true,
|
|
47
|
+
supportsVersions: false,
|
|
48
|
+
config: { local: true, branch: "" }
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/publishing.js
CHANGED
|
@@ -176,16 +176,20 @@ export function createPublishing({
|
|
|
176
176
|
renderPendingTray();
|
|
177
177
|
const count = published.length;
|
|
178
178
|
const plural = count === 1 ? "" : "s";
|
|
179
|
+
// Local mode writes to the project's source files; the connector publishes.
|
|
180
|
+
const done = state.local
|
|
181
|
+
? `Saved ${count} change${plural} to your source files.`
|
|
182
|
+
: `Published ${count} change${plural}.`;
|
|
179
183
|
|
|
180
|
-
// Dev mirrors the new source to disk immediately
|
|
181
|
-
// fresh, byte-consistent map.
|
|
184
|
+
// Dev mirrors the new source to disk immediately (and local mode writes it
|
|
185
|
+
// there directly), so a quick reload gives a fresh, byte-consistent map.
|
|
182
186
|
if (import.meta.env?.DEV && count && !window.__CHARLESCMS_NO_RELOAD__ && !window.__CHARLESCMS_FORCE_PUBLISH_WAIT__) {
|
|
183
|
-
setToolbarStatus(
|
|
187
|
+
setToolbarStatus(done);
|
|
184
188
|
setTimeout(() => location.reload(), 700);
|
|
185
189
|
return;
|
|
186
190
|
}
|
|
187
191
|
if (window.__CHARLESCMS_NO_RELOAD__) {
|
|
188
|
-
setToolbarStatus(
|
|
192
|
+
setToolbarStatus(done);
|
|
189
193
|
return;
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -229,7 +233,7 @@ export function createPublishing({
|
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
async function uploadActiveMedia(event) {
|
|
232
|
-
if (!state.active
|
|
236
|
+
if (!state.active) return;
|
|
233
237
|
const file = event.target.files?.[0];
|
|
234
238
|
if (!file) return;
|
|
235
239
|
const { dialog, item } = state.active;
|
|
@@ -260,7 +264,7 @@ export function createPublishing({
|
|
|
260
264
|
}
|
|
261
265
|
|
|
262
266
|
async function replaceDownloadFile(event) {
|
|
263
|
-
if (!state.active
|
|
267
|
+
if (!state.active) return;
|
|
264
268
|
const file = event.target.files?.[0];
|
|
265
269
|
if (!file) return;
|
|
266
270
|
const { dialog, item } = state.active;
|
|
@@ -10,6 +10,7 @@ export function createRuntimeController({
|
|
|
10
10
|
state,
|
|
11
11
|
readConnectorConfig,
|
|
12
12
|
createConnectorClient,
|
|
13
|
+
createLocalClient,
|
|
13
14
|
closeEditor,
|
|
14
15
|
loadSourceMap,
|
|
15
16
|
applySourceMapIds,
|
|
@@ -31,10 +32,17 @@ export function createRuntimeController({
|
|
|
31
32
|
if (state.initializedPath === location.pathname && state.toolbar?.isConnected) return;
|
|
32
33
|
resetEditorUi();
|
|
33
34
|
const config = readConnectorConfig();
|
|
34
|
-
|
|
35
|
+
// No connector configured? In `astro dev` fall back to local mode: the editor
|
|
36
|
+
// boots and saves edits straight to the project's source files on disk, no
|
|
37
|
+
// account or form required. A connector, when present, always wins (it can
|
|
38
|
+
// publish from a deployed site). Without either, in a non-dev build, the editor
|
|
39
|
+
// stays dormant and /cms shows the connect form (rung 2).
|
|
40
|
+
const local = !config && Boolean(import.meta.env?.DEV);
|
|
41
|
+
state.local = local;
|
|
42
|
+
state.authenticated = local || Boolean(config);
|
|
35
43
|
if (!state.authenticated) return;
|
|
36
44
|
|
|
37
|
-
state.connector =
|
|
45
|
+
state.connector = config ? createConnectorClient(config) : createLocalClient();
|
|
38
46
|
loadSourceMap();
|
|
39
47
|
applySourceMapIds();
|
|
40
48
|
// Bring any edits staged before a reload/navigation back into memory, then
|
package/src/toolbar.js
CHANGED
|
@@ -27,10 +27,13 @@ export function createToolbar({
|
|
|
27
27
|
<button type="button" data-charlescms-publish hidden>Publish</button>
|
|
28
28
|
<button type="button" data-charlescms-page-settings hidden title="Fields that don't show on the page itself, like the Google title and description">Page info</button>
|
|
29
29
|
<button type="button" data-charlescms-highlight title="Outline everything you can edit on this page">Show editable</button>
|
|
30
|
-
<button type="button" data-charlescms-versions ${state.
|
|
31
|
-
${state.
|
|
30
|
+
<button type="button" data-charlescms-versions ${state.local ? 'disabled title="Connect a publisher to use version history"' : ""}>Versions</button>
|
|
31
|
+
${state.local ? "" : '<button type="button" data-charlescms-logout>Logout</button>'}
|
|
32
32
|
</div>
|
|
33
33
|
`;
|
|
34
|
+
// Local mode writes to disk, so the action saves files rather than publishing.
|
|
35
|
+
const publishButton = toolbar.querySelector("[data-charlescms-publish]");
|
|
36
|
+
if (publishButton && state.local) publishButton.textContent = "Save to local files";
|
|
34
37
|
toolbar.querySelector("[data-charlescms-logout]")?.addEventListener("click", logout);
|
|
35
38
|
|
|
36
39
|
// Editors start with all editable regions outlined; the toggle provides an
|
|
@@ -43,7 +46,7 @@ export function createToolbar({
|
|
|
43
46
|
};
|
|
44
47
|
setHighlight(true);
|
|
45
48
|
highlight.addEventListener("click", () => setHighlight(!document.documentElement.classList.contains("charlescms-show-all")));
|
|
46
|
-
toolbar.querySelector("[data-charlescms-publish]")?.addEventListener("click",
|
|
49
|
+
toolbar.querySelector("[data-charlescms-publish]")?.addEventListener("click", publishPending);
|
|
47
50
|
toolbar.querySelector("[data-charlescms-discard]").addEventListener("click", discardPending);
|
|
48
51
|
toolbar.querySelector("[data-charlescms-review]")?.addEventListener("click", openStagedPanel);
|
|
49
52
|
|
|
@@ -53,7 +56,7 @@ export function createToolbar({
|
|
|
53
56
|
const pageSettings = toolbar.querySelector("[data-charlescms-page-settings]");
|
|
54
57
|
pageSettings.hidden = !currentPageHasFrontmatter();
|
|
55
58
|
pageSettings.addEventListener("click", openPageSettings);
|
|
56
|
-
toolbar.querySelector("[data-charlescms-versions]")?.addEventListener("click",
|
|
59
|
+
toolbar.querySelector("[data-charlescms-versions]")?.addEventListener("click", openVersionsPanel);
|
|
57
60
|
|
|
58
61
|
document.body.append(toolbar);
|
|
59
62
|
state.toolbar = toolbar;
|
|
@@ -81,9 +84,8 @@ export function createToolbar({
|
|
|
81
84
|
const count = state.pending.size;
|
|
82
85
|
if (publish) {
|
|
83
86
|
publish.hidden = false;
|
|
84
|
-
publish.disabled = state.
|
|
85
|
-
|
|
86
|
-
else publish.title = count === 0 ? "No changes to publish" : "";
|
|
87
|
+
publish.disabled = state.busy;
|
|
88
|
+
publish.title = count === 0 ? (state.local ? "No changes to save" : "No changes to publish") : "";
|
|
87
89
|
}
|
|
88
90
|
discard.hidden = count === 0;
|
|
89
91
|
const review = state.toolbar.querySelector("[data-charlescms-review]");
|
|
@@ -92,7 +94,7 @@ export function createToolbar({
|
|
|
92
94
|
// staged-change indicator. Transient messages reuse the same slot.
|
|
93
95
|
setToolbarInfo(count > 0
|
|
94
96
|
? `${count} change${count === 1 ? "" : "s"} staged`
|
|
95
|
-
:
|
|
97
|
+
: `${scanEditableElements().length} editable`);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
function setToolbarInfo(text) {
|
|
@@ -102,10 +104,6 @@ export function createToolbar({
|
|
|
102
104
|
info.classList.toggle("charlescms-info-pending", state.pending.size > 0);
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
function explainPreviewOnly() {
|
|
106
|
-
setToolbarStatus("Disabled in this public demo. Real installations can publish through your connector.");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
107
|
function setBusy(value, message = "") {
|
|
110
108
|
state.busy = value;
|
|
111
109
|
if (message) setToolbarStatus(message);
|