@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
package/src/index.js
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { searchForWorkspaceRoot } from "vite";
|
|
6
|
+
|
|
7
|
+
// Astro's View Transitions (ClientRouter) pull in these runtime modules. Vite
|
|
8
|
+
// only discovers them on the FIRST navigation, which forces a mid-session
|
|
9
|
+
// dependency re-optimize + full reload ("504 Outdated Optimize Dep") — that
|
|
10
|
+
// reload tears the editor out from under the user. Pre-bundling them at startup
|
|
11
|
+
// (when they exist) keeps navigation seamless. Resolved against the project so
|
|
12
|
+
// older Astro versions without them are simply skipped.
|
|
13
|
+
function viewTransitionDeps() {
|
|
14
|
+
const ids = [
|
|
15
|
+
"astro/virtual-modules/transitions-router.js",
|
|
16
|
+
"astro/virtual-modules/transitions-types.js",
|
|
17
|
+
"astro/virtual-modules/transitions-events.js",
|
|
18
|
+
"astro/virtual-modules/transitions-swap-functions.js"
|
|
19
|
+
];
|
|
20
|
+
let resolve;
|
|
21
|
+
try {
|
|
22
|
+
resolve = createRequire(join(process.cwd(), "noop.js")).resolve;
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return ids.filter((id) => {
|
|
27
|
+
try { resolve(id); return true; } catch { return false; }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
import { transformAstroSource, extractNavCollections, extractDataCollections } from "./analyzer.js";
|
|
31
|
+
import { collectFrontmatterFields } from "./frontmatter.js";
|
|
32
|
+
import { transformMarkdownSource } from "./markdown-analyzer.js";
|
|
33
|
+
import { normalizeBlockHtml, sanitizeBlockHtml } from "./sanitize.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CharlesCMS Astro integration — adds a source-native visual editor to an Astro
|
|
37
|
+
* site. Drop it into `astro.config.mjs` (`integrations: [charlesCMS()]`); the
|
|
38
|
+
* editor lives at `adminPath` and writes changes back to the project's source.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {string} [options.adminPath="/cms"] Route the editor is served from.
|
|
42
|
+
* @param {string[]} [options.editablePaths] Page paths where the editor activates.
|
|
43
|
+
* Defaults to all pages. Accepts a single string or an array.
|
|
44
|
+
* @param {string} [options.defaultEditPath] Page the editor opens first.
|
|
45
|
+
* 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
|
+
* @param {string} [options.connector] Preset Cloudflare connector worker URL,
|
|
49
|
+
* so editors never type connection details into a form.
|
|
50
|
+
* @param {string} [options.repo] Preset `owner/name` GitHub repository.
|
|
51
|
+
* @param {string} [options.branch] Preset branch to commit to.
|
|
52
|
+
* @param {string} [options.sourceRoot] Preset repo-relative path to the Astro
|
|
53
|
+
* project root (for monorepos where the app is in a subfolder).
|
|
54
|
+
* @returns {import("astro").AstroIntegration} The Astro integration object.
|
|
55
|
+
*/
|
|
56
|
+
export default function charlesCMS(options = {}) {
|
|
57
|
+
const adminPath = normalizePath(options.adminPath || "/cms");
|
|
58
|
+
const editablePaths = normalizeEditablePaths(options.editablePaths);
|
|
59
|
+
const defaultEditPath = normalizePath(options.defaultEditPath || editablePaths[0] || "/");
|
|
60
|
+
const previewOnly = Boolean(options.previewOnly);
|
|
61
|
+
// Optional connection baked in by the site owner so editors never have to type
|
|
62
|
+
// connector/repo/branch (or the jargon-y source root) into a form. When set,
|
|
63
|
+
// /cms shows a clean "Start editing" screen instead of the developer form.
|
|
64
|
+
const connection = normalizeConnection(options);
|
|
65
|
+
const packageRoot = new URL(".", import.meta.url);
|
|
66
|
+
let projectRoot = process.cwd();
|
|
67
|
+
let sourceMapPath = "";
|
|
68
|
+
const sourceMap = {};
|
|
69
|
+
const sectionTemplates = {};
|
|
70
|
+
const virtualSourceMapId = "virtual:charlescms-source-map";
|
|
71
|
+
const resolvedVirtualSourceMapId = "\0charlescms-source-map";
|
|
72
|
+
const virtualConfigId = "virtual:charlescms-config";
|
|
73
|
+
const resolvedVirtualConfigId = "\0charlescms-config";
|
|
74
|
+
const virtualRuntimeId = "virtual:charlescms-runtime";
|
|
75
|
+
const resolvedVirtualRuntimeId = "\0charlescms-runtime";
|
|
76
|
+
const virtualSectionTemplatesId = "virtual:charlescms-section-templates";
|
|
77
|
+
const resolvedVirtualSectionTemplatesId = "\0charlescms-section-templates";
|
|
78
|
+
// After the editor mirrors a published file to disk it owns the single reload
|
|
79
|
+
// (it shows the success, then reloads once the refreshed map is ready). For a
|
|
80
|
+
// brief window after that write, suppress Vite's own HMR for the file so it
|
|
81
|
+
// doesn't fire an earlier reload that would re-read a not-yet-refreshed map.
|
|
82
|
+
let mirrorReloadGuardUntil = 0;
|
|
83
|
+
let logger = console;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
name: "@charlescms/astro",
|
|
87
|
+
hooks: {
|
|
88
|
+
"astro:config:done": async ({ config }) => {
|
|
89
|
+
projectRoot = fileURLToPath(config.root);
|
|
90
|
+
process.env.CHARLESCMS_PROJECT_ROOT = projectRoot;
|
|
91
|
+
sourceMapPath = `${projectRoot}.charlescms/source-map.json`;
|
|
92
|
+
await refreshSourceMap(projectRoot, sourceMapPath, sourceMap);
|
|
93
|
+
await refreshSectionTemplates(projectRoot, sectionTemplates, logger);
|
|
94
|
+
},
|
|
95
|
+
"astro:config:setup": ({ injectRoute, injectScript, updateConfig, logger: astroLogger }) => {
|
|
96
|
+
logger = astroLogger || logger;
|
|
97
|
+
injectScript("page", renderPageScript({
|
|
98
|
+
adminPath,
|
|
99
|
+
editablePaths,
|
|
100
|
+
defaultEditPath,
|
|
101
|
+
previewOnly,
|
|
102
|
+
connection
|
|
103
|
+
}));
|
|
104
|
+
updateConfig({
|
|
105
|
+
vite: {
|
|
106
|
+
// Allow Vite's dev server to serve this package's runtime files
|
|
107
|
+
// (boot.js, client.js) over /@fs — needed when the package resolves
|
|
108
|
+
// through a symlink outside the project root (a file: link, a
|
|
109
|
+
// monorepo workspace, or pnpm's store). Crucially, KEEP the project's
|
|
110
|
+
// own workspace root allowed too: setting `fs.allow` replaces Vite's
|
|
111
|
+
// default, and dropping the workspace root 403s the site's own CSS,
|
|
112
|
+
// fonts and assets in dev.
|
|
113
|
+
server: { fs: { allow: [searchForWorkspaceRoot(process.cwd()), fileURLToPath(new URL("..", import.meta.url))] } },
|
|
114
|
+
// Don't let Vite pre-bundle the editor package. Its browser files
|
|
115
|
+
// import a virtual module (virtual:charlescms-source-map) that the
|
|
116
|
+
// optimizer's esbuild pass can't resolve, so optimizing it forces a
|
|
117
|
+
// "504 Outdated Optimize Dep" + full reload every time the runtime is
|
|
118
|
+
// dynamically imported — a page-reload (blink) loop in dev.
|
|
119
|
+
optimizeDeps: {
|
|
120
|
+
exclude: ["@charlescms/astro"],
|
|
121
|
+
// micromark imports debug's CommonJS browser build. Because this
|
|
122
|
+
// package is excluded above, Vite needs the transitive dependency
|
|
123
|
+
// named explicitly so Git installs get a valid default export.
|
|
124
|
+
include: [
|
|
125
|
+
"debug",
|
|
126
|
+
"js-yaml",
|
|
127
|
+
"mdast-util-from-markdown",
|
|
128
|
+
"mdast-util-gfm",
|
|
129
|
+
"mdast-util-to-markdown",
|
|
130
|
+
"mdast-util-to-string",
|
|
131
|
+
"micromark-extension-gfm",
|
|
132
|
+
"parse5",
|
|
133
|
+
// Pre-bundle the Tiptap rich-text editor (lazy-loaded on the first
|
|
134
|
+
// rich edit) so its first import never triggers a mid-session
|
|
135
|
+
// re-optimize + reload that would close the editor.
|
|
136
|
+
"@tiptap/core",
|
|
137
|
+
"@tiptap/extension-document",
|
|
138
|
+
"@tiptap/extension-text",
|
|
139
|
+
"@tiptap/extension-bold",
|
|
140
|
+
"@tiptap/extension-italic",
|
|
141
|
+
"@tiptap/extension-code",
|
|
142
|
+
"@tiptap/extension-hard-break",
|
|
143
|
+
"@tiptap/extension-link",
|
|
144
|
+
// Pre-bundle Astro's View Transitions runtime so navigation never
|
|
145
|
+
// triggers a mid-session re-optimize that reloads away the editor.
|
|
146
|
+
...viewTransitionDeps()
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
plugins: [
|
|
150
|
+
{
|
|
151
|
+
name: "charlescms-virtual-source-map",
|
|
152
|
+
enforce: "pre",
|
|
153
|
+
resolveId(id) {
|
|
154
|
+
if (id === virtualSourceMapId) return resolvedVirtualSourceMapId;
|
|
155
|
+
if (id === virtualConfigId) return resolvedVirtualConfigId;
|
|
156
|
+
if (id === virtualRuntimeId) return resolvedVirtualRuntimeId;
|
|
157
|
+
if (id === virtualSectionTemplatesId) return resolvedVirtualSectionTemplatesId;
|
|
158
|
+
return null;
|
|
159
|
+
},
|
|
160
|
+
async load(id) {
|
|
161
|
+
if (id === resolvedVirtualSourceMapId) {
|
|
162
|
+
return `export default ${JSON.stringify(await buildSerializableSourceMap(projectRoot, sourceMap))};`;
|
|
163
|
+
}
|
|
164
|
+
if (id === resolvedVirtualConfigId) {
|
|
165
|
+
return `export default ${JSON.stringify({ adminPath, editablePaths, defaultEditPath, previewOnly, connection })};`;
|
|
166
|
+
}
|
|
167
|
+
if (id === resolvedVirtualRuntimeId) {
|
|
168
|
+
return `import { bootCharlesCMS } from "@charlescms/astro/src/boot.js"; bootCharlesCMS();`;
|
|
169
|
+
}
|
|
170
|
+
if (id === resolvedVirtualSectionTemplatesId) {
|
|
171
|
+
return `export default ${JSON.stringify(sectionTemplates)};`;
|
|
172
|
+
}
|
|
173
|
+
// Inject editable ids into .astro at LOAD time — before Astro
|
|
174
|
+
// compiles it. A transform hook is too late for full-document
|
|
175
|
+
// pages (`<!doctype html>…`): Astro compiles those to JS first,
|
|
176
|
+
// so a transform only ever sees generated JS with no markup to
|
|
177
|
+
// annotate. Loading the raw source ourselves guarantees the
|
|
178
|
+
// ids reach Astro's compiler for every .astro, page or fragment.
|
|
179
|
+
if (id.endsWith(".astro") && !id.includes("?")
|
|
180
|
+
&& !id.includes("/node_modules/")
|
|
181
|
+
&& !id.startsWith(fileURLToPath(packageRoot))) {
|
|
182
|
+
try {
|
|
183
|
+
const source = await readFile(id, "utf8");
|
|
184
|
+
if (source.includes("__CHARLESCMS_PATH__")) return null;
|
|
185
|
+
return (await transformAstroSource(source, id)).code;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
},
|
|
192
|
+
// Right after the editor mirrors a published file to disk, swallow
|
|
193
|
+
// Vite's own HMR for that file: the editor reloads once on its own
|
|
194
|
+
// after the source map has refreshed, and Vite's earlier reload
|
|
195
|
+
// would otherwise re-read a stale map (the second edit to the same
|
|
196
|
+
// file would then fail byte-verification). A normal IDE edit, made
|
|
197
|
+
// outside this short window, still hot-updates as usual.
|
|
198
|
+
handleHotUpdate(ctx) {
|
|
199
|
+
if (Date.now() < mirrorReloadGuardUntil && isProjectSourceFile(ctx.file, projectRoot)) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
configureServer(server) {
|
|
204
|
+
const bootPath = new URL("boot.js", packageRoot).pathname;
|
|
205
|
+
server.middlewares.use("/_charlescms/runtime.js", (_request, response) => {
|
|
206
|
+
response.setHeader("content-type", "text/javascript");
|
|
207
|
+
response.end(`import { bootCharlesCMS } from ${JSON.stringify(`/@fs${bootPath}`)}; bootCharlesCMS();`);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Uploads publish to GitHub through the connector, but the dev
|
|
211
|
+
// server serves public/ from the LOCAL disk — so a freshly
|
|
212
|
+
// uploaded image would 404 locally until the next git pull.
|
|
213
|
+
// This dev-only endpoint mirrors the uploaded bytes into the
|
|
214
|
+
// local public/uploads folder, so the preview shows the image
|
|
215
|
+
// immediately. Strictly limited to that one folder.
|
|
216
|
+
//
|
|
217
|
+
// Astro's dev server only serves public/ files that existed at
|
|
218
|
+
// startup, so mirrored files also need serving: read /uploads/*
|
|
219
|
+
// straight from disk here, falling through when absent.
|
|
220
|
+
server.middlewares.use("/uploads", async (request, response, next) => {
|
|
221
|
+
const name = decodeURIComponent(String(request.url || "").split("?")[0].replace(/^\/+/, ""));
|
|
222
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) return next();
|
|
223
|
+
try {
|
|
224
|
+
const bytes = await readFile(join(projectRoot, "public/uploads", name));
|
|
225
|
+
const types = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", avif: "image/avif", pdf: "application/pdf" };
|
|
226
|
+
response.setHeader("content-type", types[name.split(".").pop().toLowerCase()] || "application/octet-stream");
|
|
227
|
+
response.end(bytes);
|
|
228
|
+
} catch {
|
|
229
|
+
next();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
server.middlewares.use("/_charlescms/mirror-upload", (request, response) => {
|
|
234
|
+
if (request.method !== "POST") { response.statusCode = 405; response.end(); return; }
|
|
235
|
+
let body = "";
|
|
236
|
+
request.on("data", (chunk) => { body += chunk; });
|
|
237
|
+
request.on("end", async () => {
|
|
238
|
+
try {
|
|
239
|
+
const { path, base64 } = JSON.parse(body || "{}");
|
|
240
|
+
const safe = /^public\/uploads\/[A-Za-z0-9._-]+$/.test(String(path || ""));
|
|
241
|
+
if (!safe) { response.statusCode = 400; response.end("invalid path"); return; }
|
|
242
|
+
const target = join(projectRoot, path);
|
|
243
|
+
await mkdir(dirname(target), { recursive: true });
|
|
244
|
+
await writeFile(target, Buffer.from(String(base64 || ""), "base64"));
|
|
245
|
+
response.statusCode = 204;
|
|
246
|
+
response.end();
|
|
247
|
+
} catch (error) {
|
|
248
|
+
response.statusCode = 500;
|
|
249
|
+
response.end(String(error?.message || error));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Source edits publish to GitHub through the connector, but the
|
|
255
|
+
// dev server reads source from the LOCAL disk. Without mirroring,
|
|
256
|
+
// the local files (and the source map built from them) stay frozen
|
|
257
|
+
// at the last build while GitHub moves ahead with each publish — so
|
|
258
|
+
// the NEXT edit to the same file fails byte-verification ("source
|
|
259
|
+
// changed since the CMS build"): the CMS never notices it already
|
|
260
|
+
// published. Writing the published bytes back to disk keeps the dev
|
|
261
|
+
// source map in lockstep; the watcher below then refreshes it.
|
|
262
|
+
// Restricted to source files inside the project root. The write
|
|
263
|
+
// opens a short guard window: the editor owns the single reload
|
|
264
|
+
// after publishing, so the watcher and Vite's HMR both skip their
|
|
265
|
+
// own reload during it; a hand edit in the IDE still reloads.
|
|
266
|
+
server.middlewares.use("/_charlescms/mirror-source", (request, response) => {
|
|
267
|
+
if (request.method !== "POST") { response.statusCode = 405; response.end(); return; }
|
|
268
|
+
let body = "";
|
|
269
|
+
request.on("data", (chunk) => { body += chunk; });
|
|
270
|
+
request.on("end", async () => {
|
|
271
|
+
try {
|
|
272
|
+
const { path, source } = JSON.parse(body || "{}");
|
|
273
|
+
const target = safeProjectSourcePath(projectRoot, path);
|
|
274
|
+
if (!target) { response.statusCode = 400; response.end("invalid path"); return; }
|
|
275
|
+
mirrorReloadGuardUntil = Date.now() + 2000;
|
|
276
|
+
await mkdir(dirname(target), { recursive: true });
|
|
277
|
+
await writeFile(target, String(source ?? ""));
|
|
278
|
+
response.statusCode = 204;
|
|
279
|
+
response.end();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
response.statusCode = 500;
|
|
282
|
+
response.end(String(error?.message || error));
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
let refreshTimer;
|
|
288
|
+
let refreshQueue = Promise.resolve();
|
|
289
|
+
const scheduleRefresh = (file) => {
|
|
290
|
+
if (isSectionTemplateFile(file, projectRoot)) {
|
|
291
|
+
clearTimeout(refreshTimer);
|
|
292
|
+
refreshTimer = setTimeout(() => {
|
|
293
|
+
refreshQueue = refreshQueue
|
|
294
|
+
.then(async () => {
|
|
295
|
+
await refreshSectionTemplates(projectRoot, sectionTemplates, logger);
|
|
296
|
+
const virtualModule = server.moduleGraph.getModuleById(resolvedVirtualSectionTemplatesId);
|
|
297
|
+
if (virtualModule) server.moduleGraph.invalidateModule(virtualModule);
|
|
298
|
+
server.ws.send({ type: "full-reload" });
|
|
299
|
+
})
|
|
300
|
+
.catch((error) => {
|
|
301
|
+
server.config.logger.error(`[CharlesCMS] Could not refresh section templates: ${error.message}`);
|
|
302
|
+
});
|
|
303
|
+
}, 80);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!isProjectSourceFile(file, projectRoot)) return;
|
|
307
|
+
// A publish the editor just mirrored to disk: refresh the map so
|
|
308
|
+
// the next build/edit is byte-consistent, but let the editor do
|
|
309
|
+
// the single reload (it shows the success first).
|
|
310
|
+
const editorMirrored = Date.now() < mirrorReloadGuardUntil;
|
|
311
|
+
clearTimeout(refreshTimer);
|
|
312
|
+
refreshTimer = setTimeout(() => {
|
|
313
|
+
refreshQueue = refreshQueue
|
|
314
|
+
.then(async () => {
|
|
315
|
+
await refreshSourceMap(projectRoot, sourceMapPath, sourceMap);
|
|
316
|
+
const virtualModule = server.moduleGraph.getModuleById(resolvedVirtualSourceMapId);
|
|
317
|
+
if (virtualModule) server.moduleGraph.invalidateModule(virtualModule);
|
|
318
|
+
if (!editorMirrored) server.ws.send({ type: "full-reload" });
|
|
319
|
+
})
|
|
320
|
+
.catch((error) => {
|
|
321
|
+
server.config.logger.error(`[CharlesCMS] Could not refresh the source map: ${error.message}`);
|
|
322
|
+
});
|
|
323
|
+
}, 80);
|
|
324
|
+
};
|
|
325
|
+
server.watcher.on("add", scheduleRefresh);
|
|
326
|
+
server.watcher.on("change", scheduleRefresh);
|
|
327
|
+
server.watcher.on("unlink", scheduleRefresh);
|
|
328
|
+
},
|
|
329
|
+
// .astro id injection now happens in load() above, before Astro
|
|
330
|
+
// compiles the source — see the note there.
|
|
331
|
+
}
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
injectRoute({
|
|
336
|
+
pattern: adminPath,
|
|
337
|
+
entrypoint: new URL("admin.astro", packageRoot).pathname
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Load the project's static section templates from `src/charlescms/templates`
|
|
346
|
+
* into `target` (mutated in place), skipping any non-static `.astro` file.
|
|
347
|
+
* @param {string} root Project root directory.
|
|
348
|
+
* @param {Object} target Template map to clear and repopulate.
|
|
349
|
+
* @param {Console} [logger=console] Logger for skipped-template warnings.
|
|
350
|
+
* @returns {Promise<void>}
|
|
351
|
+
*/
|
|
352
|
+
export async function refreshSectionTemplates(root, target, logger = console) {
|
|
353
|
+
const directory = join(root, "src/charlescms/templates");
|
|
354
|
+
const next = {};
|
|
355
|
+
let files = [];
|
|
356
|
+
try {
|
|
357
|
+
files = (await readdir(directory, { withFileTypes: true }))
|
|
358
|
+
.filter((entry) => entry.isFile() && /\.(?:html|astro)$/i.test(entry.name))
|
|
359
|
+
.map((entry) => entry.name)
|
|
360
|
+
.sort();
|
|
361
|
+
} catch {
|
|
362
|
+
files = [];
|
|
363
|
+
}
|
|
364
|
+
for (const name of files) {
|
|
365
|
+
const source = (await readFile(join(directory, name), "utf8")).trim();
|
|
366
|
+
// A template must be PURE static markup: it is inserted verbatim as page
|
|
367
|
+
// content. An .astro file is welcome as long as it carries no frontmatter,
|
|
368
|
+
// no {expressions} and no components — those cannot become a static section.
|
|
369
|
+
if (/\.astro$/i.test(name) && (/^---/.test(source) || /[{}]/.test(source) || /<[A-Z]/.test(source))) {
|
|
370
|
+
logger.warn?.(`[CharlesCMS] Skipped dynamic section template (frontmatter/expressions/components are not supported): src/charlescms/templates/${name}`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
// Compare against the parser-normalized source, not the raw text: harmless
|
|
374
|
+
// formatting (`<img />`, attribute quoting) must not flag a template as
|
|
375
|
+
// unsafe — only content the sanitizer actually removes or rewrites does.
|
|
376
|
+
const html = sanitizeBlockHtml(source).trim();
|
|
377
|
+
if (!html || html !== normalizeBlockHtml(source).trim()) {
|
|
378
|
+
logger.warn?.(`[CharlesCMS] Skipped unsafe section template: src/charlescms/templates/${name}`);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const id = name.replace(/\.(?:html|astro)$/i, "");
|
|
382
|
+
next[id] = {
|
|
383
|
+
id,
|
|
384
|
+
label: id.split(/[-_]+/).filter(Boolean).map((word) => word[0].toUpperCase() + word.slice(1)).join(" "),
|
|
385
|
+
html
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
for (const key of Object.keys(target)) delete target[key];
|
|
389
|
+
Object.assign(target, next);
|
|
390
|
+
return target;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function renderPageScript(config) {
|
|
394
|
+
return `window.__CHARLESCMS_PATH__=${JSON.stringify(config.adminPath)};
|
|
395
|
+
window.__CHARLESCMS_EDIT_PATHS__=${JSON.stringify(config.editablePaths)};
|
|
396
|
+
window.__CHARLESCMS_DEFAULT_EDIT_PATH__=${JSON.stringify(config.defaultEditPath)};
|
|
397
|
+
window.__CHARLESCMS_PREVIEW_ONLY__=${JSON.stringify(config.previewOnly)};
|
|
398
|
+
window.__CHARLESCMS_CONNECTION__=${JSON.stringify(config.connection || {})};
|
|
399
|
+
import("virtual:charlescms-runtime");`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Pull an optional preset connection out of the integration options. Only
|
|
403
|
+
// non-empty fields are kept so partial config still prefills the form.
|
|
404
|
+
function normalizeConnection(options) {
|
|
405
|
+
const out = {};
|
|
406
|
+
const connector = String(options.connector || "").trim().replace(/\/+$/, "");
|
|
407
|
+
const repo = String(options.repo || "").trim().replace(/^\/+|\/+$/g, "");
|
|
408
|
+
const branch = String(options.branch || "").trim();
|
|
409
|
+
const sourceRoot = String(options.sourceRoot || "").trim().replace(/^\/+|\/+$/g, "");
|
|
410
|
+
if (connector) out.connector = connector;
|
|
411
|
+
if (repo) out.repo = repo;
|
|
412
|
+
if (branch) out.branch = branch;
|
|
413
|
+
if (sourceRoot) out.sourceRoot = sourceRoot;
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Scan a project's `src` (and conventional root `data/`) for all editable
|
|
419
|
+
* regions, building the full source map of entry id → descriptor.
|
|
420
|
+
* @param {string} root Project root directory.
|
|
421
|
+
* @returns {Promise<Object>} The combined source-map entries for the project.
|
|
422
|
+
*/
|
|
423
|
+
export async function scanProjectSource(root) {
|
|
424
|
+
const sourceRoot = join(root, "src");
|
|
425
|
+
const files = await listSourceFiles(sourceRoot);
|
|
426
|
+
const dataRoots = projectDataRoots(root);
|
|
427
|
+
const entries = {};
|
|
428
|
+
// Data modules imported by a SHARED file (layout/component) — their content is
|
|
429
|
+
// rendered on every page, so it is site-wide regardless of the page's markup.
|
|
430
|
+
const sharedModules = new Set();
|
|
431
|
+
for (const file of files) {
|
|
432
|
+
const source = await readFile(file, "utf8");
|
|
433
|
+
if (file.endsWith(".astro")) {
|
|
434
|
+
Object.assign(entries, (await transformAstroSource(source, file)).entries);
|
|
435
|
+
// Menus defined inline in a component's frontmatter.
|
|
436
|
+
Object.assign(entries, extractNavCollections(source, file));
|
|
437
|
+
Object.assign(entries, extractDataCollections(source, file));
|
|
438
|
+
} else {
|
|
439
|
+
Object.assign(entries, collectFrontmatterFields(source, file).entries);
|
|
440
|
+
Object.assign(entries, transformMarkdownSource(source, file).entries);
|
|
441
|
+
}
|
|
442
|
+
if (isSharedSourcePath(file)) {
|
|
443
|
+
for (const match of source.matchAll(/from\s*["']([^"'\n]+)["']/g)) {
|
|
444
|
+
const base = match[1].split("/").pop().replace(/\.(?:ts|js|mjs|cjs|json)$/i, "");
|
|
445
|
+
if (base) sharedModules.add(base);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Menus imported from a data/config module live in plain JS/TS under src
|
|
450
|
+
// (src/data, src/consts.ts, src/config.ts, ...), and many Astro themes also
|
|
451
|
+
// keep site data in a conventional root-level data/ directory.
|
|
452
|
+
for (const file of await listProjectDataFiles(dataRoots)) {
|
|
453
|
+
const source = await readFile(file, "utf8");
|
|
454
|
+
Object.assign(entries, extractNavCollections(source, file));
|
|
455
|
+
Object.assign(entries, extractDataCollections(source, file));
|
|
456
|
+
}
|
|
457
|
+
// Markup-INDEPENDENT scope from Astro's file convention: content from
|
|
458
|
+
// src/components or src/layouts (or a data module they import) is shared across
|
|
459
|
+
// the site; content from src/pages belongs to that page. The Content panel uses
|
|
460
|
+
// this instead of guessing from <header>/<main>, which a dev may not use.
|
|
461
|
+
for (const entry of Object.values(entries)) {
|
|
462
|
+
const path = String(entry.file || "").replace(/\\/g, "/");
|
|
463
|
+
const base = (path.split("/").pop() || "").replace(/\.(?:ts|js|mjs|cjs|json|astro|md|mdx)$/i, "");
|
|
464
|
+
if (isSharedSourcePath(path) || sharedModules.has(base)) entry.scope = "shared";
|
|
465
|
+
else if (/\/src\/pages\//.test(path)) entry.scope = "page";
|
|
466
|
+
}
|
|
467
|
+
return entries;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isSharedSourcePath(file) {
|
|
471
|
+
return /\/src\/(?:components|layouts)\//.test(String(file).replace(/\\/g, "/"));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function projectDataRoots(root) {
|
|
475
|
+
const sourceRoot = join(root, "src");
|
|
476
|
+
const rootData = join(root, "data");
|
|
477
|
+
return [sourceRoot, rootData];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function listProjectDataFiles(roots) {
|
|
481
|
+
const seen = new Set();
|
|
482
|
+
const files = [];
|
|
483
|
+
for (const root of roots) {
|
|
484
|
+
for (const file of await listDataFiles(root)) {
|
|
485
|
+
const key = resolve(file);
|
|
486
|
+
if (seen.has(key)) continue;
|
|
487
|
+
seen.add(key);
|
|
488
|
+
files.push(file);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return files;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Rebuild the source map for `root` and write it both into `target` (mutated in
|
|
496
|
+
* place) and to `path` on disk for the running editor to read.
|
|
497
|
+
* @param {string} root Project root directory.
|
|
498
|
+
* @param {string} path File path to write the serialized source map to.
|
|
499
|
+
* @param {Object} target Source-map object to clear and repopulate.
|
|
500
|
+
* @returns {Promise<void>}
|
|
501
|
+
*/
|
|
502
|
+
export async function refreshSourceMap(root, path, target) {
|
|
503
|
+
const next = await scanProjectSource(root);
|
|
504
|
+
for (const key of Object.keys(target)) delete target[key];
|
|
505
|
+
Object.assign(target, next);
|
|
506
|
+
await writeSourceMap(path, root, target);
|
|
507
|
+
return target;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isProjectSourceFile(file, root) {
|
|
511
|
+
const path = String(file || "").replace(/\\/g, "/");
|
|
512
|
+
const sourceRoot = join(root, "src").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
513
|
+
const rootData = join(root, "data").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
514
|
+
const inSource = path.startsWith(`${sourceRoot}/`);
|
|
515
|
+
const inRootData = path.startsWith(`${rootData}/`);
|
|
516
|
+
if (!inSource && !inRootData) return false;
|
|
517
|
+
if (/\.(?:astro|md|mdx)$/.test(path)) return true;
|
|
518
|
+
// Any JS/TS module under src or conventional root-level data/ may define
|
|
519
|
+
// navigation/link-list data.
|
|
520
|
+
return /\.(?:ts|js|mjs|cjs)$/.test(path) && !/\.d\.ts$/.test(path);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Resolve a connector-supplied, project-relative source path to an absolute file
|
|
524
|
+
// for the dev mirror — but only if it stays inside the project root and names a
|
|
525
|
+
// source file the analyzer actually edits. Anything traversing out, absolute, or
|
|
526
|
+
// of another type is rejected, so the dev endpoint can't write arbitrary files.
|
|
527
|
+
function safeProjectSourcePath(root, relativePath) {
|
|
528
|
+
const value = String(relativePath || "").replace(/\\/g, "/");
|
|
529
|
+
if (!value || value.startsWith("/") || value.split("/").includes("..")) return null;
|
|
530
|
+
if (!/\.(?:astro|md|mdx|ts|js|mjs|cjs)$/i.test(value) || /\.d\.ts$/i.test(value)) return null;
|
|
531
|
+
const target = resolve(root, value);
|
|
532
|
+
const base = resolve(root);
|
|
533
|
+
if (target !== base && !target.startsWith(base + sep)) return null;
|
|
534
|
+
return target;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isSectionTemplateFile(file, root) {
|
|
538
|
+
const path = String(file || "").replace(/\\/g, "/");
|
|
539
|
+
const directory = join(root, "src/charlescms/templates").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
540
|
+
return path.startsWith(`${directory}/`) && /\.(?:html|astro)$/i.test(path);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function listDataFiles(dir) {
|
|
544
|
+
let files = [];
|
|
545
|
+
try {
|
|
546
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
const path = join(dir, entry.name);
|
|
549
|
+
if (entry.isDirectory()) {
|
|
550
|
+
files = files.concat(await listDataFiles(path));
|
|
551
|
+
} else if (entry.isFile() && isDataModule(entry.name)) {
|
|
552
|
+
files.push(path);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
} catch {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
return files;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isDataModule(name) {
|
|
562
|
+
// Data sources that can hold editable content: plain JS/TS modules and JSON
|
|
563
|
+
// data files (including Astro content-collection `type: 'data'` entries).
|
|
564
|
+
// Type-declaration files carry no runtime values, so they are skipped.
|
|
565
|
+
return /\.(?:ts|js|mjs|cjs|json)$/.test(name) && !/\.d\.ts$/.test(name);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function listSourceFiles(dir) {
|
|
569
|
+
let files = [];
|
|
570
|
+
try {
|
|
571
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
572
|
+
for (const entry of entries) {
|
|
573
|
+
const path = join(dir, entry.name);
|
|
574
|
+
if (entry.isDirectory()) {
|
|
575
|
+
files = files.concat(await listSourceFiles(path));
|
|
576
|
+
} else if (entry.isFile() && /\.(?:astro|md|mdx)$/.test(entry.name)) {
|
|
577
|
+
files.push(path);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
return [];
|
|
582
|
+
}
|
|
583
|
+
return files;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function writeSourceMap(path, root, entries) {
|
|
587
|
+
if (!path) return;
|
|
588
|
+
const sourceMap = toSerializableSourceMap(root, entries);
|
|
589
|
+
await mkdir(dirname(path), { recursive: true });
|
|
590
|
+
await writeFile(path, `${JSON.stringify(sourceMap, null, 2)}\n`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// The runtime form of the source map, plus an asset index so optimized
|
|
594
|
+
// (astro:assets) images can be matched to their source file in production. Async
|
|
595
|
+
// because it walks src/ for image files; only the virtual module needs it.
|
|
596
|
+
async function buildSerializableSourceMap(root, entries) {
|
|
597
|
+
return { ...toSerializableSourceMap(root, entries), assetIndex: await buildAssetIndex(root) };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|avif|svg)$/i;
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Map each image's basename → its repo-relative path for every image under `src/`.
|
|
604
|
+
*
|
|
605
|
+
* Astro keeps the original basename in optimized output (team1.HASH.webp), so the
|
|
606
|
+
* runtime can map a rendered, hashed `<img>` back to its source file. A basename
|
|
607
|
+
* used by more than one file is dropped: overwriting the wrong image is never
|
|
608
|
+
* acceptable, so ambiguous names stay non-editable (dev still resolves them
|
|
609
|
+
* exactly via the `/_image` href, which carries the full path).
|
|
610
|
+
*
|
|
611
|
+
* @param {string} root Project root directory.
|
|
612
|
+
* @returns {Promise<Object<string,string>>} Map of lowercase basename → repo path.
|
|
613
|
+
*/
|
|
614
|
+
export async function buildAssetIndex(root) {
|
|
615
|
+
const files = await listImageFiles(join(root, "src"));
|
|
616
|
+
const index = {};
|
|
617
|
+
const seen = new Set();
|
|
618
|
+
for (const file of files) {
|
|
619
|
+
const base = (file.split(/[\\/]/).pop() || "").replace(IMAGE_EXT_RE, "").toLowerCase();
|
|
620
|
+
if (!base) continue;
|
|
621
|
+
if (seen.has(base)) {
|
|
622
|
+
delete index[base];
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
seen.add(base);
|
|
626
|
+
index[base] = relative(root, file).replace(/\\/g, "/");
|
|
627
|
+
}
|
|
628
|
+
return index;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function listImageFiles(dir) {
|
|
632
|
+
let files = [];
|
|
633
|
+
try {
|
|
634
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
635
|
+
for (const entry of entries) {
|
|
636
|
+
const path = join(dir, entry.name);
|
|
637
|
+
if (entry.isDirectory()) files = files.concat(await listImageFiles(path));
|
|
638
|
+
else if (entry.isFile() && IMAGE_EXT_RE.test(entry.name)) files.push(path);
|
|
639
|
+
}
|
|
640
|
+
} catch {
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
return files;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function toSerializableSourceMap(root, entries) {
|
|
647
|
+
const sorted = Object.fromEntries(
|
|
648
|
+
Object.entries(entries)
|
|
649
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
650
|
+
.map(([id, entry]) => [
|
|
651
|
+
id,
|
|
652
|
+
{
|
|
653
|
+
...entry,
|
|
654
|
+
file: relative(root, entry.file)
|
|
655
|
+
}
|
|
656
|
+
])
|
|
657
|
+
);
|
|
658
|
+
return { version: 1, entries: sorted };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function normalizePath(path) {
|
|
662
|
+
const value = String(path || "/cms").trim();
|
|
663
|
+
const withSlash = value.startsWith("/") ? value : `/${value}`;
|
|
664
|
+
// Strip a trailing slash, but keep the root path as "/" — collapsing it to
|
|
665
|
+
// the admin-route fallback would make a single-page site's defaultEditPath
|
|
666
|
+
// equal adminPath ("/cms"), causing /cms to redirect to itself forever.
|
|
667
|
+
return withSlash.replace(/\/+$/, "") || "/";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Optional allowlist of route prefixes where the in-page editor may activate.
|
|
671
|
+
// Empty (default) keeps the original behavior: editable on every page. When set,
|
|
672
|
+
// non-matching pages (e.g. a marketing landing page) never load the editor.
|
|
673
|
+
function normalizeEditablePaths(value) {
|
|
674
|
+
if (!value) return [];
|
|
675
|
+
const list = Array.isArray(value) ? value : [value];
|
|
676
|
+
return list
|
|
677
|
+
.map((path) => String(path || "").trim())
|
|
678
|
+
.filter(Boolean)
|
|
679
|
+
.map((path) => (path.startsWith("/") ? path : `/${path}`))
|
|
680
|
+
.map((path) => path.replace(/\/+$/, "") || "/");
|
|
681
|
+
}
|