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