@charlescms/astro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +366 -0
- package/SECURITY.md +77 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/connector/worker.js +505 -0
- package/connector/wrangler.toml +15 -0
- package/package.json +92 -0
- package/scripts/check-licenses.js +45 -0
- package/scripts/check-package.js +62 -0
- package/scripts/setup.js +719 -0
- package/scripts/update-vendored-site.js +71 -0
- package/src/admin.astro +314 -0
- package/src/analyzer.js +639 -0
- package/src/asset-images.js +130 -0
- package/src/astro-frontmatter.js +17 -0
- package/src/boot.js +35 -0
- package/src/client.js +347 -0
- package/src/connector-client.js +185 -0
- package/src/content-bridge.js +162 -0
- package/src/content-panel.js +440 -0
- package/src/data-analyzer.js +304 -0
- package/src/edit-affordance.js +463 -0
- package/src/editor-styles.js +243 -0
- package/src/element-editor.js +355 -0
- package/src/fields.js +6 -0
- package/src/frontmatter.js +153 -0
- package/src/ids.js +20 -0
- package/src/index.js +681 -0
- package/src/js-ast.js +140 -0
- package/src/markdown-analyzer.js +95 -0
- package/src/media-preview.js +58 -0
- package/src/panel-manager.js +133 -0
- package/src/publishing.js +457 -0
- package/src/rich-text-editor.js +209 -0
- package/src/routes.js +21 -0
- package/src/runtime-controller.js +206 -0
- package/src/sanitize.js +150 -0
- package/src/section-editor.js +437 -0
- package/src/source-edit.js +310 -0
- package/src/source-map-runtime.js +184 -0
- package/src/staged-panel.js +145 -0
- package/src/toolbar.js +128 -0
- package/src/versions-panel.js +112 -0
|
@@ -0,0 +1,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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function escapeAttribute(value) {
|
|
346
|
+
return escapeHtml(value);
|
|
347
|
+
}
|