@charlescms/astro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -0
  3. package/SECURITY.md +77 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/connector/worker.js +505 -0
  6. package/connector/wrangler.toml +15 -0
  7. package/package.json +92 -0
  8. package/scripts/check-licenses.js +45 -0
  9. package/scripts/check-package.js +62 -0
  10. package/scripts/setup.js +719 -0
  11. package/scripts/update-vendored-site.js +71 -0
  12. package/src/admin.astro +314 -0
  13. package/src/analyzer.js +639 -0
  14. package/src/asset-images.js +130 -0
  15. package/src/astro-frontmatter.js +17 -0
  16. package/src/boot.js +35 -0
  17. package/src/client.js +347 -0
  18. package/src/connector-client.js +185 -0
  19. package/src/content-bridge.js +162 -0
  20. package/src/content-panel.js +440 -0
  21. package/src/data-analyzer.js +304 -0
  22. package/src/edit-affordance.js +463 -0
  23. package/src/editor-styles.js +243 -0
  24. package/src/element-editor.js +355 -0
  25. package/src/fields.js +6 -0
  26. package/src/frontmatter.js +153 -0
  27. package/src/ids.js +20 -0
  28. package/src/index.js +681 -0
  29. package/src/js-ast.js +140 -0
  30. package/src/markdown-analyzer.js +95 -0
  31. package/src/media-preview.js +58 -0
  32. package/src/panel-manager.js +133 -0
  33. package/src/publishing.js +457 -0
  34. package/src/rich-text-editor.js +209 -0
  35. package/src/routes.js +21 -0
  36. package/src/runtime-controller.js +206 -0
  37. package/src/sanitize.js +150 -0
  38. package/src/section-editor.js +437 -0
  39. package/src/source-edit.js +310 -0
  40. package/src/source-map-runtime.js +184 -0
  41. package/src/staged-panel.js +145 -0
  42. package/src/toolbar.js +128 -0
  43. package/src/versions-panel.js +112 -0
@@ -0,0 +1,304 @@
1
+ import { getFrontmatter } from "./astro-frontmatter.js";
2
+ import { createEntryId } from "./ids.js";
3
+ import { parseFrontmatterModule, parseValueExpression, unwrapValue } from "./js-ast.js";
4
+
5
+ // Extracts editable display strings from JavaScript/TypeScript/JSON data
6
+ // structures using the Babel AST (full grammar, exact offsets). It is not a
7
+ // JavaScript evaluator: only string literals inside object/array structures
8
+ // become editable, and only when they are display copy. Link targets, asset
9
+ // paths and structural fields stay locked, and any value Babel cannot parse is
10
+ // simply not offered — a parse problem degrades to a safe skip, never a write.
11
+ const navLabelKeys = ["label", "text", "title", "name"];
12
+ const navHrefKeys = ["href", "url", "to", "link", "path"];
13
+ const structuralFieldKeys = new Set([
14
+ "href", "url", "uri", "src", "image", "img", "icon", "to", "link", "path",
15
+ "slug", "id", "key", "type", "kind", "variant", "category", "tag", "tags",
16
+ "status", "state", "color", "colour", "lang", "locale", "date", "datetime",
17
+ "time", "target", "rel", "file", "value", "class", "classname", "style"
18
+ ]);
19
+
20
+ export function extractNavCollections(source, file) {
21
+ const entries = {};
22
+ for (const root of dataRoots(source, file)) {
23
+ forEachArray(root.node, root.name, (arrayNode, name) => {
24
+ const operations = navOperations(arrayNode, source, root.base);
25
+ if (!operations) return;
26
+ const id = createEntryId(file, operations);
27
+ entries[id] = { id, file, tag: "nav", kind: "nav", label: name || "navigation", operations };
28
+ });
29
+ }
30
+ return entries;
31
+ }
32
+
33
+ export function extractDataCollections(source, file) {
34
+ const entries = {};
35
+ const roots = dataRoots(source, file);
36
+
37
+ // Object arrays (cards, people, FAQs, menu dishes) and positional tuples
38
+ // (`[["Name","Desc","8"], …]`). Link lists are left to the nav extractor.
39
+ for (const root of roots) {
40
+ forEachArray(root.node, root.name, (arrayNode, name) => {
41
+ const elements = arrayNode.elements.filter(Boolean);
42
+ if (elements.length === 0) return;
43
+ if (elements.every((element) => unwrapValue(element).type === "ObjectExpression")) {
44
+ if (isLinkList(arrayNode)) return;
45
+ const { operations, media } = collectItems(elements, source, root.base);
46
+ if (operations.length || media.length) addDataEntry(entries, file, name || "content", operations, media);
47
+ } else if (elements.every((element) => unwrapValue(element).type === "ArrayExpression")) {
48
+ const operations = collectTuples(elements, source, root.base);
49
+ if (operations.length) addDataEntry(entries, file, name || "content", operations, []);
50
+ }
51
+ });
52
+ }
53
+
54
+ // Quoted object fields that are NOT inside an array represent site/config
55
+ // details (`site.phone`, address, hours). Nested objects are followed.
56
+ const operations = [];
57
+ const media = [];
58
+ const counter = { value: 0 };
59
+ for (const root of roots) collectConfigFields(root.node, "", source, root.base, operations, media, counter);
60
+ if (operations.length || media.length) {
61
+ const id = createEntryId(file, operations.length ? operations : media);
62
+ entries[id] = { id, file, tag: "data", kind: "data", label: "details", operations };
63
+ if (media.length) entries[id].media = media;
64
+ }
65
+ return entries;
66
+ }
67
+
68
+ // Shared with the Astro component analyzer so quoted component props and data
69
+ // fields follow the same conservative definition of editable display copy.
70
+ export function isDisplayTextField(key, value) {
71
+ if (structuralFieldKeys.has(key.toLowerCase())) return false;
72
+ if (/href|url|uri|src|slug|icon|image|img|path|id$|^id|class/i.test(key)) return false;
73
+ const text = String(value || "").trim();
74
+ if (!text || text.includes("\\")) return false;
75
+ if (/^(?:\/|#|https?:|mailto:|tel:|\.\.?\/|data:)/i.test(text)) return false;
76
+ return !/\.(?:png|jpe?g|svg|webp|gif|avif|pdf|mp4|webm|woff2?|ttf|css|js)$/i.test(text);
77
+ }
78
+
79
+ function isMediaPathField(key, value) {
80
+ if (!/(^|_)(image|img|photo|picture|cover|thumb|thumbnail|poster|banner|avatar|logo|icon)s?$/i.test(key)) return false;
81
+ return /^\/[^\s?#]+\.(?:png|jpe?g|svg|webp|gif|avif)$/i.test(String(value || "").trim());
82
+ }
83
+
84
+ function isLinkHref(value) {
85
+ return /^(?:\/|#|https?:|mailto:|tel:|\.\.?\/)/i.test(String(value || "").trim());
86
+ }
87
+
88
+ // An array is a navigation/link list when every item is an object carrying both
89
+ // a label-like field and a static link destination.
90
+ function isLinkList(arrayNode) {
91
+ const elements = arrayNode.elements.filter(Boolean);
92
+ return elements.length > 0 && elements.every((element) => {
93
+ const object = unwrapValue(element);
94
+ if (object.type !== "ObjectExpression") return false;
95
+ const props = objectProps(object);
96
+ const labelKey = navLabelKeys.find((key) => isStringProp(props, key));
97
+ const hrefKey = navHrefKeys.find((key) => props.has(key));
98
+ const href = hrefKey && props.get(hrefKey);
99
+ return Boolean(labelKey && href && href.type === "StringLiteral" && isLinkHref(href.value));
100
+ });
101
+ }
102
+
103
+ function navOperations(arrayNode, source, base) {
104
+ if (!isLinkList(arrayNode)) return null;
105
+ const elements = arrayNode.elements.filter(Boolean);
106
+ const operations = [];
107
+ elements.forEach((element, index) => {
108
+ const props = objectProps(unwrapValue(element));
109
+ const labelKey = navLabelKeys.find((key) => isStringProp(props, key));
110
+ const hrefKey = navHrefKeys.find((key) => props.has(key));
111
+ const span = literalSpan(props.get(labelKey), source, base);
112
+ if (!span) return;
113
+ operations.push({
114
+ kind: "data-string",
115
+ name: `item_${index}`,
116
+ quote: span.quote,
117
+ start: span.start,
118
+ end: span.end,
119
+ oldValue: span.value,
120
+ href: props.get(hrefKey).value
121
+ });
122
+ });
123
+ return operations.length === elements.length ? operations : null;
124
+ }
125
+
126
+ function collectItems(elements, source, base) {
127
+ const operations = [];
128
+ const media = [];
129
+ elements.forEach((element, index) => {
130
+ collectFields(unwrapValue(element), `item${index}`, source, base, operations, media);
131
+ });
132
+ return { operations, media };
133
+ }
134
+
135
+ // Walks one object's fields (any depth) into editable text operations and media
136
+ // replacements. The field name encodes the path so a value round-trips to the
137
+ // right control and the right source span.
138
+ function collectFields(objectNode, prefix, source, base, operations, media) {
139
+ for (const property of objectNode.properties || []) {
140
+ if (property.type !== "ObjectProperty" || property.computed) continue;
141
+ const key = keyName(property.key);
142
+ if (key == null) continue;
143
+ collectValue(unwrapValue(property.value), `${prefix}_${key}`, key, source, base, operations, media);
144
+ }
145
+ }
146
+
147
+ function collectValue(node, name, key, source, base, operations, media) {
148
+ if (node.type === "ObjectExpression") {
149
+ collectFields(node, name, source, base, operations, media);
150
+ return;
151
+ }
152
+ if (node.type === "ArrayExpression") {
153
+ node.elements.forEach((element, index) => {
154
+ if (element) collectValue(unwrapValue(element), `${name}_${index}`, key, source, base, operations, media);
155
+ });
156
+ return;
157
+ }
158
+ if (node.type !== "StringLiteral") return;
159
+ const span = literalSpan(node, source, base);
160
+ if (!span) return;
161
+ if (isMediaPathField(key, span.value)) {
162
+ media.push({ name, label: key, path: span.value });
163
+ } else if (isDisplayTextField(key, span.value)) {
164
+ operations.push({ kind: "data-string", name, quote: span.quote, start: span.start, end: span.end, oldValue: span.value });
165
+ }
166
+ }
167
+
168
+ function collectTuples(rows, source, base) {
169
+ const operations = [];
170
+ rows.forEach((rowNode, row) => {
171
+ const cells = unwrapValue(rowNode).elements.filter(Boolean);
172
+ if (cells.length === 0 || !cells.every((cell) => unwrapValue(cell).type === "StringLiteral")) return;
173
+ cells.forEach((cell, column) => {
174
+ const span = literalSpan(unwrapValue(cell), source, base);
175
+ if (!span || !isDisplayTextField("", span.value)) return;
176
+ operations.push({
177
+ kind: "data-string",
178
+ name: `row${row}_col${column}`,
179
+ quote: span.quote,
180
+ start: span.start,
181
+ end: span.end,
182
+ oldValue: span.value
183
+ });
184
+ });
185
+ });
186
+ return operations;
187
+ }
188
+
189
+ // Object string fields that are not inside any array. Nested objects are
190
+ // followed; arrays are skipped here because their contents are handled as data
191
+ // collections (object arrays / tuples) above.
192
+ function collectConfigFields(node, path, source, base, operations, media, counter) {
193
+ if (!node || node.type !== "ObjectExpression") return;
194
+ for (const property of node.properties || []) {
195
+ if (property.type !== "ObjectProperty" || property.computed) continue;
196
+ const key = keyName(property.key);
197
+ if (key == null) continue;
198
+ const value = unwrapValue(property.value);
199
+ const fieldPath = path ? `${path}_${key}` : key;
200
+ if (value.type === "ObjectExpression") {
201
+ collectConfigFields(value, fieldPath, source, base, operations, media, counter);
202
+ } else if (value.type === "StringLiteral") {
203
+ const span = literalSpan(value, source, base);
204
+ if (!span) continue;
205
+ const name = `field${counter.value++}_${fieldPath}`;
206
+ if (isMediaPathField(key, span.value)) {
207
+ media.push({ name, label: key, path: span.value });
208
+ } else if (isDisplayTextField(key, span.value)) {
209
+ operations.push({ kind: "data-string", name, quote: span.quote, start: span.start, end: span.end, oldValue: span.value });
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ function addDataEntry(entries, file, label, operations, media) {
216
+ const id = createEntryId(file, operations.length ? operations : media);
217
+ entries[id] = { id, file, tag: "data", kind: "data", label, operations };
218
+ if (media.length > 0) entries[id].media = media;
219
+ }
220
+
221
+ // --- AST helpers -------------------------------------------------------------
222
+
223
+ // Parses a source file into the top-level value roots worth walking, carrying
224
+ // the declaration name (used to label a collection) and the base offset that
225
+ // maps AST positions back to the original source.
226
+ function dataRoots(source, file) {
227
+ if (/\.json$/i.test(file)) {
228
+ const node = parseValueExpression(source);
229
+ return node ? [{ name: "", node, base: 0 }] : [];
230
+ }
231
+ let code = source;
232
+ let base = 0;
233
+ if (file.endsWith(".astro")) {
234
+ const frontmatter = getFrontmatter(source);
235
+ if (!frontmatter) return [];
236
+ code = frontmatter.value;
237
+ base = frontmatter.offset;
238
+ }
239
+ const ast = parseFrontmatterModule(code);
240
+ if (!ast) return [];
241
+ const roots = [];
242
+ for (const statement of ast.program.body) {
243
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
244
+ if (declaration?.type === "VariableDeclaration") {
245
+ for (const declarator of declaration.declarations) {
246
+ if (declarator.id.type === "Identifier" && declarator.init) {
247
+ roots.push({ name: declarator.id.name, node: unwrapValue(declarator.init), base });
248
+ }
249
+ }
250
+ } else if (statement.type === "ExportDefaultDeclaration") {
251
+ roots.push({ name: "", node: unwrapValue(statement.declaration), base });
252
+ }
253
+ }
254
+ return roots;
255
+ }
256
+
257
+ // Visits every array literal reachable through objects, carrying the nearest
258
+ // declaration/property name so a collection can be labelled.
259
+ function forEachArray(node, name, visit) {
260
+ if (!node || typeof node.type !== "string") return;
261
+ if (node.type === "ArrayExpression") {
262
+ visit(node, name);
263
+ for (const element of node.elements) forEachArray(unwrapValue(element), name, visit);
264
+ } else if (node.type === "ObjectExpression") {
265
+ for (const property of node.properties) {
266
+ if (property.type === "ObjectProperty") {
267
+ forEachArray(unwrapValue(property.value), keyName(property.key) || name, visit);
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ function objectProps(objectNode) {
274
+ const props = new Map();
275
+ for (const property of objectNode.properties || []) {
276
+ if (property.type !== "ObjectProperty" || property.computed) continue;
277
+ const key = keyName(property.key);
278
+ if (key != null) props.set(key, unwrapValue(property.value));
279
+ }
280
+ return props;
281
+ }
282
+
283
+ function isStringProp(props, key) {
284
+ return props.get(key)?.type === "StringLiteral";
285
+ }
286
+
287
+ function keyName(key) {
288
+ if (key.type === "Identifier") return key.name;
289
+ if (key.type === "StringLiteral") return key.value;
290
+ if (key.type === "NumericLiteral") return String(key.value);
291
+ return null;
292
+ }
293
+
294
+ // The exact source span of a string literal's value (inside the quotes). Escaped
295
+ // strings are skipped: their raw bytes carry backslashes the write path would
296
+ // have to re-encode, so they stay locked instead of binding to a partial value.
297
+ function literalSpan(node, source, base) {
298
+ if (!node || node.type !== "StringLiteral") return null;
299
+ const start = base + node.start + 1;
300
+ const end = base + node.end - 1;
301
+ const value = source.slice(start, end);
302
+ if (value.includes("\\")) return null;
303
+ return { value, quote: source[base + node.start], start, end };
304
+ }