@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/analyzer.js
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { parse } from "@astrojs/compiler";
|
|
2
|
+
import { getFrontmatter } from "./astro-frontmatter.js";
|
|
3
|
+
import { isDisplayTextField } from "./data-analyzer.js";
|
|
4
|
+
export { getFrontmatter } from "./astro-frontmatter.js";
|
|
5
|
+
export { extractDataCollections, extractNavCollections } from "./data-analyzer.js";
|
|
6
|
+
import { fieldName } from "./fields.js";
|
|
7
|
+
import { createEntryId } from "./ids.js";
|
|
8
|
+
import { buildConstantTrees, resolveExpressionTree } from "./js-ast.js";
|
|
9
|
+
|
|
10
|
+
// Builds the byte-exact edit map for an Astro template.
|
|
11
|
+
//
|
|
12
|
+
// The Astro compiler provides the syntax tree; this module only accepts static
|
|
13
|
+
// source regions that can be located and verified exactly. Dynamic expressions
|
|
14
|
+
// remain code and are deliberately never turned into editable content.
|
|
15
|
+
const editableTags = new Set([
|
|
16
|
+
"a",
|
|
17
|
+
"address",
|
|
18
|
+
"audio",
|
|
19
|
+
"button",
|
|
20
|
+
"blockquote",
|
|
21
|
+
"caption",
|
|
22
|
+
"cite",
|
|
23
|
+
"figcaption",
|
|
24
|
+
"legend",
|
|
25
|
+
"summary",
|
|
26
|
+
"td",
|
|
27
|
+
"th",
|
|
28
|
+
"h1",
|
|
29
|
+
"h2",
|
|
30
|
+
"h3",
|
|
31
|
+
"h4",
|
|
32
|
+
"h5",
|
|
33
|
+
"h6",
|
|
34
|
+
"iframe",
|
|
35
|
+
"img",
|
|
36
|
+
"li",
|
|
37
|
+
"dd",
|
|
38
|
+
"dt",
|
|
39
|
+
"object",
|
|
40
|
+
"p",
|
|
41
|
+
"source",
|
|
42
|
+
"span",
|
|
43
|
+
"strong",
|
|
44
|
+
"em",
|
|
45
|
+
"b",
|
|
46
|
+
"i",
|
|
47
|
+
"small",
|
|
48
|
+
// SVG <text> (e.g. an inline logo monogram). It is NOT a rich-text block, so it
|
|
49
|
+
// edits through the simple input panel rather than inline contentEditable (which
|
|
50
|
+
// SVG does not support) — still a byte-exact edit of the text in the source.
|
|
51
|
+
"text",
|
|
52
|
+
"video"
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Link destinations are structure, not content. In particular, never create an
|
|
56
|
+
// editable operation for href/target/rel. Anchors can still expose their static
|
|
57
|
+
// visible text through buildTextOperation below.
|
|
58
|
+
const editableAttributes = new Set(["src", "alt", "title", "poster", "data"]);
|
|
59
|
+
const mediaTags = new Set(["audio", "embed", "iframe", "img", "object", "source", "video"]);
|
|
60
|
+
const voidLikeMediaTags = new Set(["embed", "img", "source"]);
|
|
61
|
+
|
|
62
|
+
// Role-based classification (see plan "Nested markup & rich text"):
|
|
63
|
+
// a rich-text block owns its inline descendants and is edited as one fragment;
|
|
64
|
+
// inline marks/text inside it get no separate entries.
|
|
65
|
+
// `div` and `label` are included so a container whose ENTIRE content is a single
|
|
66
|
+
// static text run (a styled note like `<div class="hero-note">…</div>`, a form
|
|
67
|
+
// `<label>Email address</label>`) is editable too. The isPureInlineSubtree guard
|
|
68
|
+
// keeps this safe: a div/label that wraps child ELEMENTS (a layout container, or
|
|
69
|
+
// a label around an <input>) is never matched — only direct-text ones are.
|
|
70
|
+
const richTextBlockTags = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "figcaption", "div", "label"]);
|
|
71
|
+
// Marks the content-only serializer can round-trip losslessly. <span> is left
|
|
72
|
+
// out on purpose: it usually carries styling (a class or inline style) the editor
|
|
73
|
+
// must not strip, so a block wrapping text in a <span> is not offered as one
|
|
74
|
+
// editable rich-text region (the serializer would drop the span on save).
|
|
75
|
+
const inlineMarkTags = new Set(["strong", "em", "b", "i", "a", "code", "small", "u", "mark", "br"]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Locate every editable region in an `.astro` file and return its source map.
|
|
79
|
+
*
|
|
80
|
+
* Markup walking is AST-driven (the Astro compiler), so tag/text/attribute
|
|
81
|
+
* boundaries are exact instead of guessed. Frontmatter `const` resolution stays
|
|
82
|
+
* a focused JS-source pass (collectStaticConstants) — that is value extraction
|
|
83
|
+
* from JS, not markup parsing, and is independent of how we walk the template.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} source Raw `.astro` file contents.
|
|
86
|
+
* @param {string} file File path; non-`.astro` or `node_modules` inputs are returned untouched.
|
|
87
|
+
* @returns {Promise<{code: string, entries: Object}>} The unchanged source plus a
|
|
88
|
+
* map of entry id → descriptor ({ file, tag, operations, ... }) for each editable region.
|
|
89
|
+
*/
|
|
90
|
+
export async function transformAstroSource(source, file) {
|
|
91
|
+
if (!file.endsWith(".astro") || file.includes("/node_modules/")) {
|
|
92
|
+
return { code: source, entries: {} };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { ast } = await parse(source, { position: true });
|
|
96
|
+
// @astrojs/compiler reports UTF-8 BYTE offsets; JS strings are UTF-16. Any
|
|
97
|
+
// multibyte char (—, ©, umlauts) before a position would otherwise drift every
|
|
98
|
+
// span. Remap all AST offsets to char indices once, up front.
|
|
99
|
+
normalizeAstOffsets(ast, buildByteToCharMap(source));
|
|
100
|
+
const constants = collectStaticConstants(source);
|
|
101
|
+
const entries = {};
|
|
102
|
+
const insertions = [];
|
|
103
|
+
|
|
104
|
+
const addEntry = (element, operations) => {
|
|
105
|
+
const id = createEntryId(file, operations);
|
|
106
|
+
entries[id] = { id, file, tag: element.name, operations };
|
|
107
|
+
if (mediaTags.has(element.name) || hasAttribute(element, "data-charlescms-block")) {
|
|
108
|
+
const outer = buildOuterSpan(source, element);
|
|
109
|
+
if (outer) Object.assign(entries[id], outer);
|
|
110
|
+
}
|
|
111
|
+
const fields = operations.map((operation) => fieldName(operation)).join(",");
|
|
112
|
+
const openTagEnd = findOpenTagEnd(source, element.position.start.offset);
|
|
113
|
+
const selfClosing = source[openTagEnd - 2] === "/";
|
|
114
|
+
insertions.push({
|
|
115
|
+
index: selfClosing ? openTagEnd - 2 : openTagEnd - 1,
|
|
116
|
+
value: ` data-charlescms-id="${id}" data-charlescms-fields="${fields}"`
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Display-text passed as a string-literal prop to a component
|
|
121
|
+
// (`<PageHero title="…" intro="…" />`) is content that lives at an exact byte
|
|
122
|
+
// span in this .astro, but renders inside the component — so it can't be an
|
|
123
|
+
// inline source-mapped element here. It is collected as a page-text entry and
|
|
124
|
+
// edited (byte-verified) from the Content panel, never bound in the DOM.
|
|
125
|
+
const propOps = [];
|
|
126
|
+
const collectComponentProps = (node) => {
|
|
127
|
+
for (const attribute of node.attributes || []) {
|
|
128
|
+
if (attribute.kind !== "quoted") continue;
|
|
129
|
+
if (!isDisplayTextProp(attribute.name, attribute.value)) continue;
|
|
130
|
+
const span = locateAttributeValue(source, attribute);
|
|
131
|
+
if (!span) continue;
|
|
132
|
+
propOps.push({
|
|
133
|
+
kind: "attribute",
|
|
134
|
+
name: `prop${propOps.length}_${attribute.name}`,
|
|
135
|
+
quote: span.quote,
|
|
136
|
+
start: span.start,
|
|
137
|
+
end: span.end,
|
|
138
|
+
oldValue: attribute.value
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// A static text label passed as a component's child — `<Button>Get started</Button>`,
|
|
144
|
+
// `<Button><Icon/> Star on GitHub</Button>` — is display copy that lives at an
|
|
145
|
+
// exact byte span in THIS .astro, but renders through the component's <slot/>
|
|
146
|
+
// into a tag we cannot know. So it is collected as page text (byte-verified
|
|
147
|
+
// edit, surfaced inline by the content bridge's text match), never DOM-bound by
|
|
148
|
+
// guessing the rendered tag. Only a clean label qualifies: a single static text
|
|
149
|
+
// node, optionally beside decorative icons/marks. Anything with an expression,
|
|
150
|
+
// a block child, or a word-bearing nested component is structure or dynamic
|
|
151
|
+
// content and stays locked.
|
|
152
|
+
const collectComponentSlotText = (node) => {
|
|
153
|
+
const meaningful = (node.children || []).filter((child) => !isBlankText(child));
|
|
154
|
+
const textNodes = meaningful.filter((child) => child.type === "text");
|
|
155
|
+
const others = meaningful.filter((child) => child.type !== "text");
|
|
156
|
+
if (textNodes.length !== 1) return;
|
|
157
|
+
if (others.length > 0 && !others.every(isDecorativeInline)) return;
|
|
158
|
+
const operation = buildPlainTextOperation(textNodes[0], `prop${propOps.length}_label`);
|
|
159
|
+
if (operation && isEditableProse(operation.oldValue)) propOps.push(operation);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const visit = (node) => {
|
|
163
|
+
if (node.type !== "element" || node.position?.start?.offset == null) {
|
|
164
|
+
if ((node.type === "component" || node.type === "custom-element") && node.position?.start?.offset != null) {
|
|
165
|
+
// Astro's <Image>/<Picture> render an <img> and FORWARD extra attributes
|
|
166
|
+
// to it (verified), so they edit like any media element: build the same
|
|
167
|
+
// src/alt operations (string OR a src/alt that resolves to a static const)
|
|
168
|
+
// and inject an id that reaches the rendered <img>. This is universal —
|
|
169
|
+
// it covers every Astro site using astro:assets, with no per-site rules.
|
|
170
|
+
if (node.name === "Image" || node.name === "Picture") {
|
|
171
|
+
const operations = buildOperations(source, node, constants);
|
|
172
|
+
if (operations.length > 0) addEntry(node, operations);
|
|
173
|
+
} else {
|
|
174
|
+
collectComponentProps(node);
|
|
175
|
+
collectComponentSlotText(node);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const child of node.children || []) visit(child);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (hasAttribute(node, "data-charlescms-sections")) {
|
|
183
|
+
const directChildren = (node.children || []).filter((child) => !isBlankText(child));
|
|
184
|
+
if (directChildren.length > 0 && directChildren.every(isStaticSectionChild)) {
|
|
185
|
+
const anchors = directChildren
|
|
186
|
+
.map((child, index) => {
|
|
187
|
+
const outer = buildOuterSpan(source, child);
|
|
188
|
+
return outer ? { anchorId: `anchor_${index}`, tag: child.name, ...outer } : null;
|
|
189
|
+
})
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (anchors.length === directChildren.length) {
|
|
192
|
+
const id = createEntryId(file, anchors);
|
|
193
|
+
entries[id] = {
|
|
194
|
+
id,
|
|
195
|
+
file,
|
|
196
|
+
tag: node.name,
|
|
197
|
+
kind: "section-container",
|
|
198
|
+
operations: [],
|
|
199
|
+
anchors,
|
|
200
|
+
sectionClass: staticAttributeValue(node, "data-charlescms-section-class"),
|
|
201
|
+
tools: normalizeSectionTools(staticAttributeValue(node, "data-charlescms-section-tools"))
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (hasAttribute(node, "data-charlescms-block")) {
|
|
208
|
+
// The block entry identifies the WHOLE section (so it can be removed as
|
|
209
|
+
// one), but we still descend: a published section's headings, paragraphs
|
|
210
|
+
// and images are ordinary page content, edited inline like everything
|
|
211
|
+
// else — there is no separate block editor.
|
|
212
|
+
const operation = buildBlockHtmlOperation(source, node);
|
|
213
|
+
if (operation) addEntry(node, [operation]);
|
|
214
|
+
for (const child of node.children || []) visit(child);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A rich-text block owns its whole inner fragment; do not descend into it.
|
|
219
|
+
if (isRichTextRegion(node)) {
|
|
220
|
+
const operation = buildRichTextOperation(source, node);
|
|
221
|
+
if (operation) {
|
|
222
|
+
addEntry(node, [operation]);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (editableTags.has(node.name)) {
|
|
228
|
+
const operations = buildOperations(source, node, constants);
|
|
229
|
+
if (operations.length > 0) addEntry(node, operations);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const child of node.children || []) visit(child);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
visit(ast);
|
|
236
|
+
if (propOps.length > 0) {
|
|
237
|
+
const id = createEntryId(file, propOps);
|
|
238
|
+
entries[id] = { id, file, tag: "page", kind: "data", label: "page text", operations: propOps };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
code: applyInsertions(source, insertions),
|
|
243
|
+
entries
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// A component prop carries display copy when its name is not a structural one
|
|
248
|
+
// (class/id/href/src/style/slot/…) and its value is plain text, not a path,
|
|
249
|
+
// link, asset, or multi-line value. A newline is excluded so an edited prop can
|
|
250
|
+
// never reshape the component tag.
|
|
251
|
+
function isDisplayTextProp(name, value) {
|
|
252
|
+
if (!isDisplayTextField(name, value)) return false;
|
|
253
|
+
if (/^(?:slot|set:|is:|client:|server:|transition:|data-)/i.test(name)) return false;
|
|
254
|
+
return !/[\r\n]/.test(String(value || ""));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildBlockHtmlOperation(source, element) {
|
|
258
|
+
const innerStart = findOpenTagEnd(source, element.position.start.offset);
|
|
259
|
+
const closeTag = `</${element.name}>`;
|
|
260
|
+
const endOffset = element.position?.end?.offset;
|
|
261
|
+
const innerEnd = endOffset != null && source.slice(endOffset - closeTag.length, endOffset).toLowerCase() === closeTag
|
|
262
|
+
? endOffset - closeTag.length
|
|
263
|
+
: source.indexOf(`</${element.name}`, innerStart);
|
|
264
|
+
if (innerEnd < innerStart) return null;
|
|
265
|
+
return {
|
|
266
|
+
kind: "block-html",
|
|
267
|
+
name: "body",
|
|
268
|
+
start: innerStart,
|
|
269
|
+
end: innerEnd,
|
|
270
|
+
oldValue: source.slice(innerStart, innerEnd)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function hasAttribute(element, name) {
|
|
275
|
+
return (element.attributes || []).some((attribute) => attribute.name === name);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function staticAttributeValue(element, name) {
|
|
279
|
+
const attribute = (element.attributes || []).find((item) => item.name === name);
|
|
280
|
+
return attribute?.kind === "quoted" ? String(attribute.value || "") : "";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isStaticSectionChild(node) {
|
|
284
|
+
return (node.type === "element" || node.type === "component" || node.type === "custom-element")
|
|
285
|
+
&& node.position?.start?.offset != null
|
|
286
|
+
&& node.position?.end?.offset != null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function normalizeSectionTools(value) {
|
|
290
|
+
const allowed = new Set(["header", "paragraph", "image", "list", "quote", "delimiter"]);
|
|
291
|
+
const tools = String(value || "")
|
|
292
|
+
.split(",")
|
|
293
|
+
.map((item) => item.trim().toLowerCase())
|
|
294
|
+
.filter((item) => allowed.has(item));
|
|
295
|
+
return tools.length ? [...new Set(tools)] : [...allowed];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildOuterSpan(source, element) {
|
|
299
|
+
const start = element.position?.start?.offset;
|
|
300
|
+
let end = element.position?.end?.offset;
|
|
301
|
+
if (start == null || end == null || end <= start) return null;
|
|
302
|
+
const openTagEnd = findOpenTagEnd(source, start);
|
|
303
|
+
const selfClosing = source[openTagEnd - 2] === "/";
|
|
304
|
+
if (selfClosing || voidLikeMediaTags.has(element.name)) {
|
|
305
|
+
end = openTagEnd;
|
|
306
|
+
} else {
|
|
307
|
+
const closeTag = `</${element.name}>`;
|
|
308
|
+
if (source.slice(end - closeTag.length, end).toLowerCase() !== closeTag) {
|
|
309
|
+
const closeStart = source.indexOf(`</${element.name}`, openTagEnd);
|
|
310
|
+
if (closeStart !== -1) end = source.indexOf(">", closeStart) + 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
outerStart: start,
|
|
315
|
+
outerEnd: end,
|
|
316
|
+
outerValue: source.slice(start, end)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A rich-text region is a block-level text container whose entire subtree is
|
|
321
|
+
// static text + allowed inline marks (no expressions, components, media, or
|
|
322
|
+
// block children). It is edited as one fragment, enabling formatting on any
|
|
323
|
+
// paragraph/heading — including ones that are currently plain text.
|
|
324
|
+
function isRichTextRegion(node) {
|
|
325
|
+
if (!richTextBlockTags.has(node.name)) return false;
|
|
326
|
+
if (!hasMeaningfulText(node)) return false;
|
|
327
|
+
// div/label are permissive — they cover a styled note or a form label, which is
|
|
328
|
+
// a genuine TEXT RUN (it has direct text). A div/label that only wraps inline
|
|
329
|
+
// elements is a layout container (e.g. a grid of <a> buttons), not copy, and
|
|
330
|
+
// must NOT collapse into one rich-text region — its children stay individually
|
|
331
|
+
// editable. The semantic block tags (p, h1–h6, li, …) keep the looser rule.
|
|
332
|
+
if ((node.name === "div" || node.name === "label") && !hasDirectText(node)) return false;
|
|
333
|
+
return isPureInlineSubtree(node);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function hasDirectText(node) {
|
|
337
|
+
return (node.children || []).some((child) => child.type === "text" && (child.value ?? "").trim());
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isPureInlineSubtree(node) {
|
|
341
|
+
for (const child of node.children || []) {
|
|
342
|
+
if (child.type === "text") continue;
|
|
343
|
+
if (child.type !== "element") return false; // expression, component, comment
|
|
344
|
+
if (!inlineMarkTags.has(child.name)) return false;
|
|
345
|
+
if ((child.attributes || []).some((attribute) => attribute.kind === "expression")) return false;
|
|
346
|
+
if (!isPureInlineSubtree(child)) return false;
|
|
347
|
+
}
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function hasMeaningfulText(node) {
|
|
352
|
+
if (node.type === "text") return Boolean((node.value ?? "").trim());
|
|
353
|
+
if (node.type === "expression") return false;
|
|
354
|
+
return (node.children || []).some(hasMeaningfulText);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function buildRichTextOperation(source, element) {
|
|
358
|
+
const innerStart = findOpenTagEnd(source, element.position.start.offset);
|
|
359
|
+
const closeTag = `</${element.name}>`;
|
|
360
|
+
const endOffset = element.position?.end?.offset;
|
|
361
|
+
let innerEnd;
|
|
362
|
+
if (endOffset != null && source.slice(endOffset - closeTag.length, endOffset).toLowerCase() === closeTag) {
|
|
363
|
+
innerEnd = endOffset - closeTag.length;
|
|
364
|
+
} else {
|
|
365
|
+
innerEnd = source.indexOf(`</${element.name}`, innerStart);
|
|
366
|
+
}
|
|
367
|
+
if (innerEnd < innerStart) return null;
|
|
368
|
+
const oldValue = source.slice(innerStart, innerEnd);
|
|
369
|
+
if (!oldValue.trim()) return null;
|
|
370
|
+
return {
|
|
371
|
+
kind: "rich-text",
|
|
372
|
+
name: "text",
|
|
373
|
+
start: innerStart,
|
|
374
|
+
end: innerEnd,
|
|
375
|
+
oldValue
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildOperations(source, element, constants) {
|
|
380
|
+
const operations = [];
|
|
381
|
+
|
|
382
|
+
for (const attribute of element.attributes || []) {
|
|
383
|
+
if (!editableAttributes.has(attribute.name)) continue;
|
|
384
|
+
if (attribute.kind === "quoted") {
|
|
385
|
+
const span = locateAttributeValue(source, attribute);
|
|
386
|
+
if (!span) continue;
|
|
387
|
+
operations.push({
|
|
388
|
+
kind: "attribute",
|
|
389
|
+
name: attribute.name,
|
|
390
|
+
quote: span.quote,
|
|
391
|
+
start: span.start,
|
|
392
|
+
end: span.end,
|
|
393
|
+
oldValue: attribute.value
|
|
394
|
+
});
|
|
395
|
+
} else if (attribute.kind === "expression") {
|
|
396
|
+
const resolved = constants.resolve(String(attribute.value).trim());
|
|
397
|
+
if (!resolved) continue;
|
|
398
|
+
operations.push({
|
|
399
|
+
kind: "attribute-expression",
|
|
400
|
+
name: attribute.name,
|
|
401
|
+
// The resolved value lives inside a JS string literal in frontmatter, so
|
|
402
|
+
// the write re-escapes for THAT quote, not for HTML. The char just before
|
|
403
|
+
// the value span is the literal's opening quote.
|
|
404
|
+
quote: source[resolved.start - 1],
|
|
405
|
+
start: resolved.start,
|
|
406
|
+
end: resolved.end,
|
|
407
|
+
oldValue: resolved.value,
|
|
408
|
+
expression: String(attribute.value).trim()
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
operations.push(...buildTextOperations(source, element, constants));
|
|
414
|
+
|
|
415
|
+
return operations;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Returns the text operation(s) for an element. A direct text edit is only safe
|
|
419
|
+
// when the content is a single static text node, or a single expression that
|
|
420
|
+
// resolves to a static const. When static text is INTERLEAVED with {expressions}
|
|
421
|
+
// (e.g. `© {year} Company`), each static run becomes its own editable segment and
|
|
422
|
+
// the expressions stay locked. Rich-text regions are handled before this path.
|
|
423
|
+
function buildTextOperations(source, element, constants) {
|
|
424
|
+
const children = element.children || [];
|
|
425
|
+
const meaningful = children.filter((child) => !isBlankText(child));
|
|
426
|
+
|
|
427
|
+
if (meaningful.length === 1 && meaningful[0].type === "expression") {
|
|
428
|
+
const expression = readExpressionSource(meaningful[0]);
|
|
429
|
+
if (expression == null) return [];
|
|
430
|
+
const resolved = constants.resolve(expression);
|
|
431
|
+
if (!resolved) return [];
|
|
432
|
+
return [{ kind: "text-expression", name: "text", start: resolved.start, end: resolved.end, oldValue: resolved.value, expression, quote: source[resolved.start - 1] }];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (meaningful.length === 1 && meaningful[0].type === "text") {
|
|
436
|
+
return asArray(buildPlainTextOperation(meaningful[0]));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// One real text node beside only decorative inline children — a trailing "↗"
|
|
440
|
+
// arrow, an icon <svg>, a chevron <span> carrying no words. The visible label
|
|
441
|
+
// is that single text node, byte-mapped exactly; the icon is left untouched.
|
|
442
|
+
const textNodes = meaningful.filter((child) => child.type === "text");
|
|
443
|
+
const others = meaningful.filter((child) => child.type !== "text");
|
|
444
|
+
if (textNodes.length === 1 && others.length > 0 && others.every(isDecorativeInline)) {
|
|
445
|
+
return asArray(buildPlainTextOperation(textNodes[0]));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (meaningful.length === 0 && children.length === 1 && children[0].type === "text") {
|
|
449
|
+
return asArray(buildPlainTextOperation(children[0]));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Static text interleaved with {expressions} — only when every child is text
|
|
453
|
+
// or an expression (any inline element would make the fragment ambiguous, and
|
|
454
|
+
// is left to the rich-text path). Each static run carrying words becomes its
|
|
455
|
+
// own editable segment; the live expressions between them stay untouched.
|
|
456
|
+
if (meaningful.length > 1
|
|
457
|
+
&& meaningful.every((child) => child.type === "text" || child.type === "expression")
|
|
458
|
+
&& meaningful.some((child) => child.type === "expression")) {
|
|
459
|
+
return meaningful
|
|
460
|
+
.filter((child) => child.type === "text" && isEditableProse(child.value))
|
|
461
|
+
.map((child, index) => buildPlainTextOperation(child, `seg${index}`))
|
|
462
|
+
.filter(Boolean);
|
|
463
|
+
}
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function asArray(operation) {
|
|
468
|
+
return operation ? [operation] : [];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// A static run worth offering as a segment: it carries real words, not just a
|
|
472
|
+
// lone symbol or a bare HTML entity (e.g. `©`, `·`, `—`).
|
|
473
|
+
function isEditableProse(value) {
|
|
474
|
+
const text = String(value || "").trim();
|
|
475
|
+
if (/^&[a-z]+\d*;$/i.test(text)) return false;
|
|
476
|
+
return /[\p{L}\p{N}]{2,}/u.test(text.replace(/&[a-z]+\d*;/gi, ""));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// An inline child that carries no words — an icon or a symbol like "↗", "→",
|
|
480
|
+
// "×". Editing the sibling text must never disturb it, and it never counts as
|
|
481
|
+
// editable text itself. This covers two forms with identical safety:
|
|
482
|
+
// • an inline ELEMENT (a chevron <svg>, an arrow <span>, a mark like <strong>);
|
|
483
|
+
// • an icon COMPONENT (<ChevronRight/>, <Icon name="arrow"/>) — pervasive in
|
|
484
|
+
// modern themes next to button/link labels. We only ever edit the sibling
|
|
485
|
+
// text node's exact byte span, never the component, and the runtime binds
|
|
486
|
+
// only when the element's rendered textContent matches that text — so an
|
|
487
|
+
// icon that unexpectedly renders words simply stays unbound, never miswritten.
|
|
488
|
+
function isDecorativeInline(node) {
|
|
489
|
+
if (node.type === "component" || node.type === "custom-element") {
|
|
490
|
+
// An icon/decoration component carries no words in source, so editing the
|
|
491
|
+
// sibling text never touches it. Its attributes (commonly expressions like
|
|
492
|
+
// size={16} or name={dir}) have no bearing on that text edit, and the runtime
|
|
493
|
+
// binds only while the element's rendered textContent still matches — so an
|
|
494
|
+
// icon that renders words just stays unbound, never miswritten.
|
|
495
|
+
return !/[\p{L}\p{N}]/u.test(nodeText(node));
|
|
496
|
+
}
|
|
497
|
+
if (node.type !== "element") return false;
|
|
498
|
+
if (!inlineMarkTags.has(node.name) && !["span", "svg", "i", "use", "path"].includes(node.name)) return false;
|
|
499
|
+
if ((node.attributes || []).some((attribute) => attribute.kind === "expression")) return false;
|
|
500
|
+
return !/[\p{L}\p{N}]/u.test(nodeText(node));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function nodeText(node) {
|
|
504
|
+
if (node.type === "text") return node.value ?? "";
|
|
505
|
+
return (node.children || []).map(nodeText).join("");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function buildPlainTextOperation(textNode, name = "text") {
|
|
509
|
+
const start = textNode.position?.start?.offset;
|
|
510
|
+
const end = textNode.position?.end?.offset;
|
|
511
|
+
if (start == null || end == null) return null;
|
|
512
|
+
const raw = textNode.value ?? "";
|
|
513
|
+
const oldValue = raw.trim();
|
|
514
|
+
if (!oldValue) return null;
|
|
515
|
+
const leading = raw.match(/^\s*/)?.[0].length || 0;
|
|
516
|
+
const trailing = raw.match(/\s*$/)?.[0].length || 0;
|
|
517
|
+
return {
|
|
518
|
+
kind: "text",
|
|
519
|
+
name,
|
|
520
|
+
start: start + leading,
|
|
521
|
+
end: end - trailing,
|
|
522
|
+
oldValue
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// The compiler models `{expr}` as an expression node whose token children carry
|
|
527
|
+
// the JS (its own position offsets are unreliable, so we read the children, not
|
|
528
|
+
// the source). A non-text token (nested element/expression) means it is not a
|
|
529
|
+
// simple value expression — return null so it is treated as dynamic.
|
|
530
|
+
function readExpressionSource(node) {
|
|
531
|
+
const parts = [];
|
|
532
|
+
for (const child of node.children || []) {
|
|
533
|
+
if (child.type !== "text") return null;
|
|
534
|
+
parts.push(child.value ?? "");
|
|
535
|
+
}
|
|
536
|
+
const expression = parts.join("").trim();
|
|
537
|
+
return expression || null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Attribute nodes expose only a start offset (the attribute name). The exact
|
|
541
|
+
// value span is derived from the raw quoted token, located from that offset.
|
|
542
|
+
function locateAttributeValue(source, attribute) {
|
|
543
|
+
if (String(attribute.value).includes("{") || String(attribute.value).includes("}")) return null;
|
|
544
|
+
const from = attribute.position?.start?.offset;
|
|
545
|
+
if (from == null) return null;
|
|
546
|
+
const raw = attribute.raw;
|
|
547
|
+
if (!raw || (raw[0] !== '"' && raw[0] !== "'")) return null;
|
|
548
|
+
const rawStart = source.indexOf(raw, from);
|
|
549
|
+
if (rawStart === -1) return null;
|
|
550
|
+
const quote = raw[0];
|
|
551
|
+
const valueStart = rawStart + 1;
|
|
552
|
+
return {
|
|
553
|
+
quote,
|
|
554
|
+
start: valueStart,
|
|
555
|
+
end: valueStart + attribute.value.length
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Build a lookup from UTF-8 byte offset → UTF-16 char index. Every byte of a
|
|
560
|
+
// multibyte char maps to that char's index; the final entry maps total length.
|
|
561
|
+
function buildByteToCharMap(source) {
|
|
562
|
+
const map = [];
|
|
563
|
+
let byte = 0;
|
|
564
|
+
for (let i = 0; i < source.length; ) {
|
|
565
|
+
const codePoint = source.codePointAt(i);
|
|
566
|
+
const byteLength = codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4;
|
|
567
|
+
for (let k = 0; k < byteLength; k++) map[byte + k] = i;
|
|
568
|
+
byte += byteLength;
|
|
569
|
+
i += codePoint > 0xffff ? 2 : 1;
|
|
570
|
+
}
|
|
571
|
+
map[byte] = source.length;
|
|
572
|
+
return map;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function normalizeAstOffsets(node, map) {
|
|
576
|
+
const convert = (position) => {
|
|
577
|
+
if (position?.start && typeof position.start.offset === "number") {
|
|
578
|
+
position.start.offset = map[position.start.offset] ?? position.start.offset;
|
|
579
|
+
}
|
|
580
|
+
if (position?.end && typeof position.end.offset === "number") {
|
|
581
|
+
position.end.offset = map[position.end.offset] ?? position.end.offset;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
convert(node.position);
|
|
585
|
+
for (const attribute of node.attributes || []) convert(attribute.position);
|
|
586
|
+
for (const child of node.children || []) normalizeAstOffsets(child, map);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function isBlankText(node) {
|
|
590
|
+
return node.type === "text" && !(node.value ?? "").trim();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Scan from the opening `<` to the `>` that closes the open tag, ignoring `>`
|
|
594
|
+
// inside quoted or `{expression}` attribute values.
|
|
595
|
+
function findOpenTagEnd(source, start) {
|
|
596
|
+
let quote = null;
|
|
597
|
+
let braceDepth = 0;
|
|
598
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
599
|
+
const ch = source[i];
|
|
600
|
+
if (quote) {
|
|
601
|
+
if (ch === quote) quote = null;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
605
|
+
else if (ch === "{") braceDepth++;
|
|
606
|
+
else if (ch === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
607
|
+
else if (ch === ">" && braceDepth === 0) return i + 1;
|
|
608
|
+
}
|
|
609
|
+
return source.length;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function collectStaticConstants(source) {
|
|
613
|
+
const frontmatter = getFrontmatter(source);
|
|
614
|
+
if (!frontmatter) return EMPTY_CONSTANTS;
|
|
615
|
+
// Resolution is delegated to the Babel parser (js-ast.js): the full JS/TS
|
|
616
|
+
// grammar with exact source offsets, so const data structures resolve at any
|
|
617
|
+
// nesting depth (`team[0].links.site`). It is still NOT an evaluator — only
|
|
618
|
+
// string/object/array literal shapes become resolvable; calls, template
|
|
619
|
+
// literals, conditions and identifiers stay dynamic. If the frontmatter cannot
|
|
620
|
+
// be parsed it is invalid TypeScript, so the page does not build anyway: we
|
|
621
|
+
// resolve nothing rather than guess, and every write stays byte-verified
|
|
622
|
+
// downstream regardless.
|
|
623
|
+
const trees = buildConstantTrees(frontmatter.value, frontmatter.offset);
|
|
624
|
+
if (!trees) return EMPTY_CONSTANTS;
|
|
625
|
+
return { resolve: (expression) => resolveExpressionTree(trees, expression) };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const EMPTY_CONSTANTS = { resolve: () => null };
|
|
629
|
+
|
|
630
|
+
function applyInsertions(source, insertions) {
|
|
631
|
+
let code = source;
|
|
632
|
+
// Work backwards so each earlier source offset remains valid after inserting
|
|
633
|
+
// attributes at later offsets. This is the only template rewrite performed by
|
|
634
|
+
// the analyzer; expressions and rendered control flow remain byte-identical.
|
|
635
|
+
for (const insertion of insertions.sort((a, b) => b.index - a.index)) {
|
|
636
|
+
code = `${code.slice(0, insertion.index)}${insertion.value}${code.slice(insertion.index)}`;
|
|
637
|
+
}
|
|
638
|
+
return code;
|
|
639
|
+
}
|