@charlescms/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/SECURITY.md +77 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/connector/worker.js +505 -0
  6. package/connector/wrangler.toml +15 -0
  7. package/package.json +92 -0
  8. package/scripts/check-licenses.js +45 -0
  9. package/scripts/check-package.js +62 -0
  10. package/scripts/setup.js +719 -0
  11. package/scripts/update-vendored-site.js +71 -0
  12. package/src/admin.astro +314 -0
  13. package/src/analyzer.js +639 -0
  14. package/src/asset-images.js +130 -0
  15. package/src/astro-frontmatter.js +17 -0
  16. package/src/boot.js +35 -0
  17. package/src/client.js +347 -0
  18. package/src/connector-client.js +185 -0
  19. package/src/content-bridge.js +162 -0
  20. package/src/content-panel.js +440 -0
  21. package/src/data-analyzer.js +304 -0
  22. package/src/edit-affordance.js +463 -0
  23. package/src/editor-styles.js +243 -0
  24. package/src/element-editor.js +355 -0
  25. package/src/fields.js +6 -0
  26. package/src/frontmatter.js +153 -0
  27. package/src/ids.js +20 -0
  28. package/src/index.js +681 -0
  29. package/src/js-ast.js +140 -0
  30. package/src/markdown-analyzer.js +95 -0
  31. package/src/media-preview.js +58 -0
  32. package/src/panel-manager.js +133 -0
  33. package/src/publishing.js +457 -0
  34. package/src/rich-text-editor.js +209 -0
  35. package/src/routes.js +21 -0
  36. package/src/runtime-controller.js +206 -0
  37. package/src/sanitize.js +150 -0
  38. package/src/section-editor.js +437 -0
  39. package/src/source-edit.js +310 -0
  40. package/src/source-map-runtime.js +184 -0
  41. package/src/staged-panel.js +145 -0
  42. package/src/toolbar.js +128 -0
  43. package/src/versions-panel.js +112 -0
@@ -0,0 +1,130 @@
1
+ // Makes Astro-optimized images (astro:assets) editable.
2
+ //
3
+ // astro:assets runs <Image src={…}>/<Picture> through Astro's image pipeline, so
4
+ // the rendered <img> points at /_image?href=… (dev) or /_astro/name.HASH.ext
5
+ // (prod) — never the literal source path the rest of the editor rewrites. The src
6
+ // is also usually an expression ({data.thumbnail}, an imported constant), so there
7
+ // is no string attribute to repoint at a fresh upload. The universal, Astro-native
8
+ // fix is therefore to replace the SOURCE asset in place: overwrite
9
+ // src/assets/…/team1.jpg and let Astro re-optimize on the next build — exactly how
10
+ // a developer would swap the picture. No per-site rules, works for every Astro site.
11
+ //
12
+ // Binding a rendered <img> back to its source file:
13
+ // • dev: the source path is inside the /_image?href= URL — decode it.
14
+ // • prod: the optimized filename keeps the original basename (team1.HASH.webp),
15
+ // so it resolves through a build-time index of image basenames -> repo
16
+ // paths. Ambiguous basenames are omitted from the index, so a wrong file
17
+ // is never overwritten.
18
+
19
+ const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|avif|svg)$/i;
20
+
21
+ // The source asset (repo-relative path) a rendered <img> was optimized from, or
22
+ // null when it isn't an astro:assets image we can resolve. Pure, so it is unit
23
+ // tested directly.
24
+ export function sourceAssetForImg(img, assetIndex = {}) {
25
+ const raw = img.getAttribute("src") || firstSrcsetUrl(img) || "";
26
+ if (!raw) return null;
27
+
28
+ // dev: /_image?href=<encoded source>&w=…&f=webp
29
+ if (raw.includes("/_image")) {
30
+ try {
31
+ const base = globalThis.location?.origin || "http://localhost";
32
+ const href = new URL(raw, base).searchParams.get("href");
33
+ if (href) {
34
+ const decoded = decodeURIComponent(href).split("?")[0];
35
+ const match = decoded.match(/(?:^|\/)(src\/.+?\.(?:png|jpe?g|gif|webp|avif|svg))$/i);
36
+ if (match) return match[1];
37
+ }
38
+ } catch {
39
+ // fall through to the production form
40
+ }
41
+ }
42
+
43
+ // prod: /_astro/<name>.<hash>.<ext> — recover the original basename, look it up.
44
+ const astro = raw.match(/\/_astro\/(.+)\.[A-Za-z0-9_-]{6,}\.[a-z0-9]+(?:[?#].*)?$/i);
45
+ if (astro) {
46
+ const name = astro[1];
47
+ return assetIndex[name.toLowerCase()] || assetIndex[name.split(".")[0].toLowerCase()] || null;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ function firstSrcsetUrl(img) {
53
+ // <Picture> renders <picture><source srcset/><img/></picture>; the <img> is the
54
+ // fallback. Prefer its src, but fall back to the first srcset candidate.
55
+ const srcset = img.getAttribute("srcset") || img.closest("picture")?.querySelector("source[srcset]")?.getAttribute("srcset");
56
+ if (!srcset) return null;
57
+ return srcset.split(",")[0].trim().split(/\s+/)[0] || null;
58
+ }
59
+
60
+ export function createAssetImages({ state, sourceMapData, fileToBase64, setToolbarStatus, registerAssetPreview }) {
61
+ const assetIndex = sourceMapData?.assetIndex || {};
62
+ // repoPath -> blob: URL, so a replaced image keeps showing across re-binds and
63
+ // Astro navigation until the rebuilt, optimized asset is served.
64
+ const previews = new Map();
65
+
66
+ function bindAssetImages() {
67
+ for (const img of document.querySelectorAll("img")) {
68
+ if (img.closest("[data-charlescms-ui]")) continue;
69
+ if (img.dataset.charlescmsEditable === "true" && !img.dataset.charlescmsAsset) continue;
70
+ const repoPath = sourceAssetForImg(img, assetIndex);
71
+ if (!repoPath) continue;
72
+ if (previews.has(repoPath)) showPreview(img, previews.get(repoPath));
73
+ if (img.dataset.charlescmsAsset === repoPath) continue;
74
+ img.dataset.charlescmsEditable = "true";
75
+ img.dataset.charlescmsAsset = repoPath;
76
+ img.charlescmsEdit = { open: () => pickAndReplace(img, repoPath), label: "⤢ Replace image" };
77
+ }
78
+ }
79
+
80
+ function pickAndReplace(img, repoPath) {
81
+ const input = document.createElement("input");
82
+ input.type = "file";
83
+ input.accept = "image/*";
84
+ input.dataset.charlescmsUi = "true";
85
+ input.style.display = "none";
86
+ document.body.append(input);
87
+ input.addEventListener("change", async () => {
88
+ const file = input.files?.[0];
89
+ input.remove();
90
+ if (!file) return;
91
+ const blobUrl = URL.createObjectURL(file);
92
+ previews.set(repoPath, blobUrl);
93
+ // Apply the preview to every <img> sharing this source asset, so repeated
94
+ // uses of the same picture all update together.
95
+ for (const other of document.querySelectorAll("img")) {
96
+ if (other.closest("[data-charlescms-ui]")) continue;
97
+ if (sourceAssetForImg(other, assetIndex) === repoPath) showPreview(other, blobUrl);
98
+ }
99
+ if (state.previewOnly || !state.connector) {
100
+ setToolbarStatus("Preview only — image not saved in this demo.");
101
+ return;
102
+ }
103
+ try {
104
+ setToolbarStatus(`Replacing ${repoPath}…`);
105
+ // GitHub needs the current blob SHA to overwrite an existing file.
106
+ const existing = await state.connector.getFile(repoPath).catch(() => null);
107
+ await state.connector.putBase64(repoPath, await fileToBase64(file), {
108
+ sha: existing?.sha,
109
+ message: `Replace image: ${repoPath}`
110
+ });
111
+ registerAssetPreview?.(repoPath, file);
112
+ setToolbarStatus(`Replaced ${repoPath}. Redeploy to serve the re-optimized image.`);
113
+ } catch (error) {
114
+ setToolbarStatus(error?.message || "Image replace failed.");
115
+ }
116
+ }, { once: true });
117
+ input.click();
118
+ }
119
+
120
+ return { bindAssetImages };
121
+ }
122
+
123
+ // Force a local preview to win over Astro's optimized output: the responsive
124
+ // <source>/srcset candidates would otherwise override a plain src.
125
+ function showPreview(img, blobUrl) {
126
+ if (img.getAttribute("src") === blobUrl) return;
127
+ img.removeAttribute("srcset");
128
+ img.closest("picture")?.querySelectorAll("source[srcset]").forEach((source) => source.removeAttribute("srcset"));
129
+ img.src = blobUrl;
130
+ }
@@ -0,0 +1,17 @@
1
+ // Returns the raw Astro frontmatter body and its source offsets.
2
+ //
3
+ // Keeping this tiny parser shared prevents data analysis and template analysis
4
+ // from disagreeing about where frontmatter ends, including CRLF files.
5
+ export function getFrontmatter(source) {
6
+ if (!source.startsWith("---")) return null;
7
+ const end = source.indexOf("\n---", 3);
8
+ if (end === -1) return null;
9
+ const delimiterStart = end + 1;
10
+ const delimiterEnd = delimiterStart + 3;
11
+ const lineEnd = source.slice(delimiterEnd).match(/^(?:\r\n|\n|\r)/)?.[0].length || 0;
12
+ return {
13
+ value: source.slice(3, end),
14
+ offset: 3,
15
+ bodyOffset: delimiterEnd + lineEnd
16
+ };
17
+ }
package/src/boot.js ADDED
@@ -0,0 +1,35 @@
1
+ const adminPath = window.__CHARLESCMS_PATH__ || "/cms";
2
+ const editPaths = Array.isArray(window.__CHARLESCMS_EDIT_PATHS__) ? window.__CHARLESCMS_EDIT_PATHS__ : [];
3
+ const previewOnly = Boolean(window.__CHARLESCMS_PREVIEW_ONLY__);
4
+
5
+ // When editablePaths is configured, the editor only activates on those route
6
+ // prefixes. This keeps non-content pages (e.g. a marketing landing page) free of
7
+ // the editor even while logged in. Empty list = editable everywhere (default).
8
+ function isEditableHere() {
9
+ if (editPaths.length === 0) return true;
10
+ const here = location.pathname.replace(/\/+$/, "") || "/";
11
+ return editPaths.some((raw) => {
12
+ const path = String(raw).replace(/\/+$/, "") || "/";
13
+ return here === path || here.startsWith(`${path}/`);
14
+ });
15
+ }
16
+
17
+ // The admin route check must be SEGMENT-exact: "/cms-playground" starts with
18
+ // "/cms" as a string but is a regular page, not the admin screen. A plain
19
+ // startsWith silently disabled the editor on every page whose name shares the
20
+ // admin prefix.
21
+ function isAdminRoute() {
22
+ const here = location.pathname.replace(/\/+$/, "") || "/";
23
+ const admin = adminPath.replace(/\/+$/, "") || "/";
24
+ return here === admin || here.startsWith(`${admin}/`);
25
+ }
26
+
27
+ export function bootCharlesCMS() {
28
+ if (!isAdminRoute() && isEditableHere()) {
29
+ window.__CHARLESCMS_PREVIEW_ONLY__ = previewOnly;
30
+ // Keep this a STATIC specifier: Vite only rewrites bare-specifier dynamic
31
+ // imports when the string is literal. A template (e.g. a ?v= cache-bust)
32
+ // leaves the bare specifier unresolved and the browser throws.
33
+ import("@charlescms/astro/src/client.js");
34
+ }
35
+ }
package/src/client.js ADDED
@@ -0,0 +1,347 @@
1
+ import sourceMapData from "virtual:charlescms-source-map";
2
+ import sectionTemplatesData from "virtual:charlescms-section-templates";
3
+ import { createAssetImages } from "./asset-images.js";
4
+ import { createConnectorClient } from "./connector-client.js";
5
+ import { createContentBridge } from "./content-bridge.js";
6
+ import { createContentPanel } from "./content-panel.js";
7
+ import { createEditAffordance } from "./edit-affordance.js";
8
+ import { createElementEditor } from "./element-editor.js";
9
+ import { EDITOR_CSS } from "./editor-styles.js";
10
+ import { createPanelManager } from "./panel-manager.js";
11
+ import {
12
+ createPublishing,
13
+ downloadRepoPath,
14
+ fileToBase64,
15
+ safeMediaFileName,
16
+ showPanelError
17
+ } from "./publishing.js";
18
+ import { routeFromAstroFile, routeFromMarkdownFile } from "./routes.js";
19
+ import { createRichTextEditor } from "./rich-text-editor.js";
20
+ import { createRuntimeController } from "./runtime-controller.js";
21
+ import { createSectionEditor } from "./section-editor.js";
22
+ import { createSourceMapRuntime } from "./source-map-runtime.js";
23
+ import { createToolbar } from "./toolbar.js";
24
+ import { createVersionsPanel } from "./versions-panel.js";
25
+ import { createStagedPanel } from "./staged-panel.js";
26
+
27
+ // Browser entry point and dependency-composition root.
28
+ //
29
+ // Feature behavior lives in focused modules below. This file intentionally keeps
30
+ // only shared state, module wiring, session helpers, and the direct Tiptap
31
+ // dynamic imports required by Vite for vendored `file:` installations.
32
+ const adminPath = window.__CHARLESCMS_PATH__ || "/cms";
33
+ const configKey = "charlescms_connector";
34
+ const state = {
35
+ authenticated: false,
36
+ previewOnly: Boolean(window.__CHARLESCMS_PREVIEW_ONLY__),
37
+ active: null,
38
+ edits: [],
39
+ allSourceMap: [],
40
+ sourceMap: [],
41
+ sourceMapById: new Map(),
42
+ connector: null,
43
+ pending: new Map(),
44
+ toolbar: null,
45
+ toolbarObserver: null,
46
+ sectionControls: [],
47
+ busy: false,
48
+ initializedPath: ""
49
+ };
50
+
51
+ const { closeEditor, mountPanel } = createPanelManager({ state });
52
+ const {
53
+ stageEdit,
54
+ discardPending,
55
+ publishPending,
56
+ restorePending,
57
+ uploadActiveMedia,
58
+ replaceDownloadFile,
59
+ removeActiveMedia,
60
+ branchHint
61
+ } = createPublishing({
62
+ state,
63
+ applyValue: (...args) => applyValue(...args),
64
+ applyBridgedValues: (...args) => applyBridgedValues(...args),
65
+ closeEditor,
66
+ renderPendingTray: (...args) => renderPendingTray(...args),
67
+ setToolbarStatus: (...args) => setToolbarStatus(...args),
68
+ setBusy: (...args) => setBusy(...args),
69
+ waitForLivePage
70
+ });
71
+ const {
72
+ isFrontmatterEntry,
73
+ isMarkdownBodyEntry,
74
+ frontmatterGroups,
75
+ dataModules,
76
+ currentPageHasFrontmatter,
77
+ applyBridgedValues,
78
+ openPageSettings,
79
+ openDataModulePanel,
80
+ openFrontmatterPanel
81
+ } = createContentPanel({
82
+ state,
83
+ routeFromMarkdownFile,
84
+ closeEditor,
85
+ mountPanel,
86
+ stageEdit,
87
+ downloadRepoPath,
88
+ fileToBase64,
89
+ setToolbarStatus: (...args) => setToolbarStatus(...args),
90
+ escapeHtml,
91
+ escapeAttribute
92
+ });
93
+ const { openRichTextInline } = createRichTextEditor({
94
+ state,
95
+ loadTiptap,
96
+ stageEdit: (...args) => stageEdit(...args),
97
+ setToolbarStatus: (...args) => setToolbarStatus(...args),
98
+ escapeHtml,
99
+ escapeAttribute
100
+ });
101
+ const {
102
+ installSectionControls,
103
+ openSectionBuilder
104
+ } = createSectionEditor({
105
+ state,
106
+ sectionTemplatesData,
107
+ closeEditor,
108
+ mountPanel,
109
+ stageEdit: (...args) => stageEdit(...args),
110
+ renderPendingTray: (...args) => renderPendingTray(...args),
111
+ setToolbarStatus: (...args) => setToolbarStatus(...args),
112
+ openRichTextInline: (...args) => openRichTextInline(...args),
113
+ fileToBase64,
114
+ safeMediaFileName,
115
+ isVisible,
116
+ escapeHtml
117
+ });
118
+ const {
119
+ loadSourceMap,
120
+ applySourceMapIds,
121
+ scanEditableElements: scanSourceMapElements
122
+ } = createSourceMapRuntime({
123
+ state,
124
+ sourceMapData,
125
+ routeFromAstroFile,
126
+ routeFromMarkdownFile,
127
+ isFrontmatterEntry,
128
+ isMarkdownBodyEntry,
129
+ isVisible
130
+ });
131
+ const { installEditAffordance } = createEditAffordance({ state, isVisible });
132
+ const {
133
+ describeElement,
134
+ openEditor,
135
+ applyValue
136
+ } = createElementEditor({
137
+ state,
138
+ closeEditor,
139
+ mountPanel,
140
+ stageEdit: (...args) => stageEdit(...args),
141
+ openRichTextInline: (...args) => openRichTextInline(...args),
142
+ openSectionBuilder: (...args) => openSectionBuilder(...args),
143
+ uploadActiveMedia: (...args) => uploadActiveMedia(...args),
144
+ replaceDownloadFile: (...args) => replaceDownloadFile(...args),
145
+ removeActiveMedia: (...args) => removeActiveMedia(...args),
146
+ downloadRepoPath,
147
+ escapeHtml,
148
+ escapeAttribute
149
+ });
150
+ const { openVersionsPanel } = createVersionsPanel({
151
+ state,
152
+ closeEditor,
153
+ mountPanel,
154
+ setBusy: (...args) => setBusy(...args),
155
+ setToolbarStatus: (...args) => setToolbarStatus(...args),
156
+ waitForLivePage,
157
+ showPanelError,
158
+ branchHint,
159
+ escapeHtml,
160
+ escapeAttribute
161
+ });
162
+ const { openStagedPanel } = createStagedPanel({
163
+ state,
164
+ closeEditor,
165
+ mountPanel,
166
+ discardPending,
167
+ publishPending,
168
+ escapeHtml,
169
+ escapeAttribute
170
+ });
171
+ const {
172
+ installToolbar,
173
+ renderPendingTray,
174
+ setBusy,
175
+ setToolbarStatus
176
+ } = createToolbar({
177
+ state,
178
+ currentPageHasFrontmatter,
179
+ openPageSettings,
180
+ openVersionsPanel,
181
+ openStagedPanel,
182
+ publishPending,
183
+ discardPending,
184
+ logout,
185
+ scanEditableElements
186
+ });
187
+ const { bindAssetImages } = createAssetImages({
188
+ state,
189
+ sourceMapData,
190
+ fileToBase64,
191
+ setToolbarStatus: (...args) => setToolbarStatus(...args)
192
+ });
193
+ const {
194
+ start,
195
+ attachLinkEditAction
196
+ } = createRuntimeController({
197
+ state,
198
+ readConnectorConfig,
199
+ createConnectorClient,
200
+ closeEditor,
201
+ loadSourceMap,
202
+ applySourceMapIds,
203
+ restorePending,
204
+ applyValue: (...args) => applyValue(...args),
205
+ applyBridgedValues: (...args) => applyBridgedValues(...args),
206
+ addStyles,
207
+ installEditAffordance,
208
+ installToolbar,
209
+ installSectionControls,
210
+ scanEditableElements,
211
+ openEditor,
212
+ installContentBridge: (...args) => installContentBridge(...args),
213
+ bindAssetImages,
214
+ downloadRepoPath,
215
+ isVisible
216
+ });
217
+ const { installContentBridge } = createContentBridge({
218
+ dataModules,
219
+ frontmatterGroups,
220
+ openDataModulePanel,
221
+ openFrontmatterPanel,
222
+ attachLinkEditChip: attachLinkEditAction,
223
+ isVisible
224
+ });
225
+
226
+ start();
227
+ document.addEventListener("astro:page-load", start);
228
+
229
+ function scanEditableElements() {
230
+ return scanSourceMapElements(describeElement);
231
+ }
232
+
233
+ // Polls the deployed page until it is reachable AND (if markers are given) the new
234
+ // build is actually serving them — i.e. the rebuild that follows a commit is live.
235
+ // Returns true when live, false on timeout, so callers can lock/unlock the editor.
236
+ async function waitForLivePage(markers = []) {
237
+ const deadline = Date.now() + 120000;
238
+ const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim();
239
+ const wanted = (Array.isArray(markers) ? markers : [markers]).map(normalize).filter((m) => m.length >= 4);
240
+ while (Date.now() < deadline) {
241
+ const url = new URL(location.href);
242
+ url.searchParams.set("_charlescms", String(Date.now()));
243
+ const response = await fetch(url, { cache: "no-store" }).catch(() => null);
244
+ if (response?.ok) {
245
+ if (!wanted.length) return true;
246
+ const html = (await response.text().catch(() => "")) || "";
247
+ // Compare against the page's PLAIN text, not raw HTML: the change lives
248
+ // between tags and with different whitespace in the markup, so a raw
249
+ // substring match would miss it and wait out the whole timeout.
250
+ let plain = html;
251
+ try { plain = new DOMParser().parseFromString(html, "text/html").body?.textContent || html; } catch {}
252
+ plain = normalize(plain);
253
+ if (wanted.some((marker) => plain.includes(marker))) return true;
254
+ }
255
+ await new Promise((resolve) => setTimeout(resolve, 2500));
256
+ }
257
+ return false;
258
+ }
259
+
260
+ function readConnectorConfig() {
261
+ 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
+ }
268
+ 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 {}
279
+ }
280
+ // A connection the editor saved in this browser wins; otherwise fall back to
281
+ // the owner's baked-in preset so editors are connected without any form.
282
+ if (saved?.connector && saved?.repo) return saved;
283
+ if (preset.connector && preset.repo) return preset;
284
+ return null;
285
+ }
286
+
287
+ // Keep these imports directly in client.js. Vite can otherwise stall while
288
+ // optimizing file-installed packages that dynamically import through a helper.
289
+ async function loadTiptap() {
290
+ const [
291
+ { Editor },
292
+ { default: Document },
293
+ { default: Text },
294
+ { default: Bold },
295
+ { default: Italic },
296
+ { default: Code },
297
+ { default: HardBreak },
298
+ { default: Link }
299
+ ] = await Promise.all([
300
+ import("@tiptap/core"),
301
+ import("@tiptap/extension-document"),
302
+ import("@tiptap/extension-text"),
303
+ import("@tiptap/extension-bold"),
304
+ import("@tiptap/extension-italic"),
305
+ import("@tiptap/extension-code"),
306
+ import("@tiptap/extension-hard-break"),
307
+ import("@tiptap/extension-link")
308
+ ]);
309
+ return { Editor, Document, Text, Bold, Italic, Code, HardBreak, Link };
310
+ }
311
+
312
+ function isVisible(element) {
313
+ const rect = element.getBoundingClientRect();
314
+ const style = getComputedStyle(element);
315
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
316
+ }
317
+
318
+ function addStyles() {
319
+ const style = document.createElement("style");
320
+ style.dataset.charlescmsUi = "true";
321
+ style.textContent = EDITOR_CSS;
322
+ document.head.append(style);
323
+ }
324
+
325
+ async function logout() {
326
+ // Remember the last connection (connector/repo/branch aren't secrets) so the
327
+ // connect form comes back prefilled — disconnecting shouldn't mean retyping.
328
+ // The editor password IS a secret: drop it so it never lands in the prefill.
329
+ try {
330
+ const current = sessionStorage.getItem(configKey);
331
+ if (current) {
332
+ const { editorKey, ...prefill } = JSON.parse(current) || {};
333
+ localStorage.setItem(`${configKey}_last`, JSON.stringify(prefill));
334
+ }
335
+ } catch {}
336
+ sessionStorage.removeItem(configKey);
337
+ localStorage.removeItem(configKey);
338
+ location.href = adminPath;
339
+ }
340
+
341
+ function escapeHtml(value) {
342
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" })[char]);
343
+ }
344
+
345
+ function escapeAttribute(value) {
346
+ return escapeHtml(value);
347
+ }