@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
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
+ }