@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,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. `&copy;`, `·`, `—`).
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
+ }