@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 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 (≈2 minutes)
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 the page to edit it. That's the
49
- whole setup no schema, no content models, no config.
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
- ## Publish your edits
55
-
56
- To go live, deploy the connector a small Cloudflare Worker that commits edits to
57
- GitHub. One command walks you through it (GitHub repo, where you'll edit, Worker
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
- npx charlescms setup --deploy
40
+ git add -A && git commit -m "Edit copy in CharlesCMS"
62
41
  ```
63
42
 
64
- Then **connect the site to it once on `/cms`** paste the connector URL and the
65
- browser remembers it. No config editing needed.
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
- > **Optional:** to skip even that form, bake the connection into `astro.config` so
68
- > editors land straight on **Start editing**:
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
- Verify anytime:
49
+ ## Publish from your live site (optional)
79
50
 
80
- ```bash
81
- npx charlescms doctor https://<your-worker>.workers.dev
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
- No Wrangler install needed `npx` fetches it. Prefer clicking or no CLI? See
85
- [Manual setup](#manual-setup).
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. Publish sends the exact source span to the Worker, which commits
314
- to GitHub credentials stay in the Worker, never in the site or `localStorage`.
315
- Before each commit the file is re-read and the edit refused if the source changed;
316
- rich text and section HTML are re-sanitized before the authenticated connector
317
- writes the complete updated file.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charlescms/astro",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Source-native live CMS editor for Astro sites.",
5
5
  "type": "module",
6
6
  "repository": {
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: config.origin,
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
- {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
- )}
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, previewOnly, connection }}>
131
- const configKey = "charlescms_connector";
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
- if (previewOnly) {
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 anywhere show the form, prefilled from the last
161
- // connection used (so Logout never forces a full retype) or a partial preset.
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
- try {
290
- const current = JSON.parse(sessionStorage.getItem(configKey) || "{}") || {};
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
- // Migrate older CharlesCMS sessions away from persistent localStorage.
294
- const legacy = JSON.parse(localStorage.getItem(configKey) || "{}") || {};
295
- if (legacy.connector && legacy.repo) {
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
- return legacy;
301
- }
302
- } catch {}
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
 
@@ -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 (state.previewOnly || !state.connector) {
100
- setToolbarStatus("Preview only image not saved in this demo.");
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
- const configKey = "charlescms_connector";
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
- previewOnly: Boolean(window.__CHARLESCMS_PREVIEW_ONLY__),
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 = null;
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
- try {
270
- const legacy = JSON.parse(localStorage.getItem(configKey) || "null");
271
- if (legacy?.connector && legacy?.repo) {
272
- sessionStorage.setItem(configKey, JSON.stringify(legacy));
273
- const { editorKey, ...prefill } = legacy;
274
- localStorage.setItem(`${configKey}_last`, JSON.stringify(prefill));
275
- localStorage.removeItem(configKey);
276
- saved = legacy;
277
- }
278
- } catch {}
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(`${configKey}_last`, JSON.stringify(prefill));
346
+ localStorage.setItem(lastKey, JSON.stringify(prefill));
334
347
  }
335
348
  } catch {}
336
349
  sessionStorage.removeItem(configKey);
@@ -79,7 +79,8 @@ export function createConnectorClient(config) {
79
79
 
80
80
  const persistBranch = (value) => {
81
81
  try {
82
- const key = "charlescms_connector";
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;
@@ -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 = state.previewOnly
322
- ? "<span>Uploads are disabled in this demo.</span>"
323
- : repoPath
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;
@@ -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
- const previewOnly = Boolean(options.previewOnly);
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
- previewOnly,
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, previewOnly, connection })};`;
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.__CHARLESCMS_PREVIEW_ONLY__=${JSON.stringify(config.previewOnly)};
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, so a quick reload gives a
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(`Published ${count} change${plural}.`);
187
+ setToolbarStatus(done);
184
188
  setTimeout(() => location.reload(), 700);
185
189
  return;
186
190
  }
187
191
  if (window.__CHARLESCMS_NO_RELOAD__) {
188
- setToolbarStatus(`Published ${count} change${plural}.`);
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 || state.previewOnly) return;
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 || state.previewOnly) return;
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
- state.authenticated = state.previewOnly || Boolean(config);
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 = state.previewOnly ? null : createConnectorClient(config);
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.previewOnly ? 'disabled title="Disabled in this public demo"' : ""}>Versions</button>
31
- ${state.previewOnly ? "" : '<button type="button" data-charlescms-logout>Logout</button>'}
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", state.previewOnly ? explainPreviewOnly : publishPending);
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", state.previewOnly ? explainPreviewOnly : openVersionsPanel);
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.previewOnly || state.busy;
85
- if (state.previewOnly) publish.title = "Disabled in this public demo";
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
- : state.previewOnly ? "Preview only" : `${scanEditableElements().length} editable`);
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);