astro-dev-edit 0.11.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,792 @@
1
+ import { parse } from '@astrojs/compiler';
2
+ import type { AttrState, ClassifyResult } from '../shared/protocol.ts';
3
+ import type { ApplyResult, Patcher, PatchRequest } from './types.ts';
4
+ import {
5
+ encodeLiteral,
6
+ hasCandidates,
7
+ locateValue,
8
+ traceExpression,
9
+ } from './expression-trace.ts';
10
+
11
+ /**
12
+ * .astro source patcher — resolves a `data-astro-source-loc` back to the AST
13
+ * element it was emitted for, classifies it, and patches literal text or
14
+ * static `src`/`alt` attribute values. Pure string-in/string-out: no fs here.
15
+ *
16
+ * Position semantics (empirically verified against @astrojs/compiler 2.13 on
17
+ * this repo — 810/812 annotated elements resolve uniquely, see spec §16.1):
18
+ *
19
+ * - The compiler's `position.{line,column}` counts columns in UTF-16 code
20
+ * units, which is exactly JS string indexing — so all offset math here is
21
+ * plain (line, column) → string index. The `offset` field is BYTE-based
22
+ * (Go compiler) and must not be mixed with JS indices; we never use it.
23
+ * - `data-astro-source-loc` is NOT the element's own start. It is the start of
24
+ * the element's first child's *content*: a text child's start as-is, an
25
+ * element/expression child's start + 1 (the tag name / the `{`). For
26
+ * childless elements it is the element's own start + 1 (its tag name).
27
+ * - If two elements produce the same candidate loc (e.g. `<span><span></span>`
28
+ * nested empty spans) Astro's own annotations are ambiguous; we refuse.
29
+ */
30
+
31
+ // The compiler's node types, loosely — we only touch what we verify.
32
+ interface Pos {
33
+ line: number;
34
+ column: number;
35
+ }
36
+ export interface AstNode {
37
+ type: string;
38
+ name?: string;
39
+ value?: string;
40
+ kind?: string;
41
+ position?: { start: Pos; end?: Pos };
42
+ attributes?: AstNode[];
43
+ children?: AstNode[];
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Position helpers (JS string space)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function lineStartIndices(source: string): number[] {
51
+ const starts = [0];
52
+ for (let i = 0; i < source.length; i++) {
53
+ if (source[i] === '\n') starts.push(i + 1);
54
+ }
55
+ return starts;
56
+ }
57
+
58
+ function indexOfPos(starts: number[], pos: Pos): number {
59
+ const lineStart = starts[pos.line - 1];
60
+ if (lineStart === undefined) return -1;
61
+ return lineStart + pos.column - 1;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Entity decoding / escaping
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const NAMED_ENTITIES: Record<string, string> = {
69
+ amp: '&',
70
+ lt: '<',
71
+ gt: '>',
72
+ quot: '"',
73
+ apos: "'",
74
+ nbsp: ' ',
75
+ // Common typographic entities, so source that spells them as references
76
+ // still verify-matches the rendered text the client sends. Unknown names
77
+ // still pass through undecoded and fail safe with a mismatch refusal.
78
+ mdash: '—',
79
+ ndash: '–',
80
+ hellip: '…',
81
+ lsquo: '‘',
82
+ rsquo: '’',
83
+ ldquo: '“',
84
+ rdquo: '”',
85
+ laquo: '«',
86
+ raquo: '»',
87
+ middot: '·',
88
+ bull: '•',
89
+ copy: '©',
90
+ reg: '®',
91
+ trade: '™',
92
+ sect: '§',
93
+ deg: '°',
94
+ times: '×',
95
+ euro: '€',
96
+ pound: '£',
97
+ };
98
+
99
+ /** Decode the entities a source region may contain so it compares equal to the
100
+ * rendered DOM text the client sends. Unknown entities pass through. */
101
+ function decodeEntities(s: string): string {
102
+ return s.replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body: string) => {
103
+ if (body[0] === '#') {
104
+ const cp =
105
+ body[1] === 'x' || body[1] === 'X'
106
+ ? parseInt(body.slice(2), 16)
107
+ : parseInt(body.slice(1), 10);
108
+ try {
109
+ return Number.isFinite(cp) ? String.fromCodePoint(cp) : m;
110
+ } catch {
111
+ return m;
112
+ }
113
+ }
114
+ return NAMED_ENTITIES[body] ?? m;
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Every form the source could be presenting as, by entity-decoding depth —
120
+ * `['&amp;', '&']` for a source value of `&amp;amp;`, shallowest first,
121
+ * stopping when a pass changes nothing. The raw source spelling is not among
122
+ * them: the page always shows at least one decode.
123
+ *
124
+ * One decode is normally the whole story: the browser decodes what is served,
125
+ * and what is served is the source. Attribute values are the exception,
126
+ * because Astro's compiler decodes them *itself* while parsing the template
127
+ * and then emits the result without re-escaping — so a source `&amp;amp;`
128
+ * reaches the DOM as `&`, two decodes deep, and a verify that decoded once saw
129
+ * a mismatch and reported it as somebody else editing the file. Comparing
130
+ * against every depth is what makes the check independent of how many passes
131
+ * the renderer between the file and the page happened to perform.
132
+ *
133
+ * The extra tolerance is confined to how a value is *spelled*, never to what
134
+ * it says: no two depths of the same value differ except in entity references.
135
+ * The cap is belt-and-braces — decoding strictly shortens the string, so the
136
+ * loop terminates on its own.
137
+ */
138
+ function decodeDepths(s: string): string[] {
139
+ const forms = [decodeEntities(s)];
140
+ for (let i = 0; i < 8; i++) {
141
+ const last = forms[forms.length - 1]!;
142
+ const next = decodeEntities(last);
143
+ if (next === last) break;
144
+ forms.push(next);
145
+ }
146
+ return forms;
147
+ }
148
+
149
+ /** Escape text for insertion as literal template content. `<` cannot open a
150
+ * tag, `{` cannot open an expression, `&` cannot form an entity — the write
151
+ * can change words but never structure or behaviour. (spec §7.2, §8) */
152
+ function escapeText(s: string): string {
153
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\{/g, '&#123;');
154
+ }
155
+
156
+ /**
157
+ * The markup path deliberately lets `<` and `&` through — the popup shows raw
158
+ * source and tags are the point — so only `{` is neutralised, keeping the one
159
+ * guarantee that matters: an edit can add formatting, never an expression.
160
+ * Tag and attribute names are vetted separately by `validateInlineMarkup`.
161
+ */
162
+ function escapeMarkup(s: string): string {
163
+ return s.replace(/\{/g, '&#123;');
164
+ }
165
+
166
+ /** Escape an attribute value for insertion inside `quote`-delimited quotes. */
167
+ function escapeAttrValue(s: string, quote: string): string {
168
+ let out = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\{/g, '&#123;');
169
+ out = quote === '"' ? out.replace(/"/g, '&quot;') : out.replace(/'/g, '&#39;');
170
+ return out;
171
+ }
172
+
173
+ /** Whitespace-insensitive comparison form. (spec §7.5) */
174
+ function normalize(s: string): string {
175
+ return s.replace(/[\s ]+/g, ' ').trim();
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Inline markup safelist
180
+ // ---------------------------------------------------------------------------
181
+
182
+ /**
183
+ * Text with a `<br>` or a `<strong>` in it is still copy, but the literal-text
184
+ * path can't carry it: that path escapes `<`, so a round-trip would turn the
185
+ * tag into visible punctuation. These elements are instead classified as
186
+ * `markup` and edited as raw source through a popup.
187
+ *
188
+ * The safelist is intentionally small and inline-only — formatting a phrase,
189
+ * not restructuring a page. Anything outside it (a nested `<div>`, a component,
190
+ * an expression) keeps today's refusal and points at the source.
191
+ */
192
+ const INLINE_TAGS = new Set([
193
+ 'a', 'b', 'br', 'code', 'em', 'i', 'small', 'span', 'strong', 'sub', 'sup', 'u',
194
+ ]);
195
+
196
+ /** The one safelisted tag that never closes. */
197
+ const VOID_INLINE_TAGS = new Set(['br']);
198
+
199
+ /** Attributes accepted on any safelisted tag. Presentational only: nothing
200
+ * here can run code or load a resource. */
201
+ const GLOBAL_ATTRS = new Set(['class', 'id', 'title', 'lang', 'dir']);
202
+
203
+ /** Extra attributes accepted on specific tags. */
204
+ const TAG_ATTRS: Record<string, Set<string>> = {
205
+ a: new Set(['href', 'target', 'rel']),
206
+ };
207
+
208
+ function attrAllowed(tag: string, attr: string): boolean {
209
+ return GLOBAL_ATTRS.has(attr) || (TAG_ATTRS[tag]?.has(attr) ?? false);
210
+ }
211
+
212
+ const ALLOWED_LIST = [...INLINE_TAGS].map((t) => `<${t}>`).join(', ');
213
+
214
+ /**
215
+ * Whether a child node may appear inside a `markup` element: literal text, or a
216
+ * safelisted inline element (recursively) whose attributes are all statically
217
+ * quoted and allowed. An expression-valued attribute fails here on purpose —
218
+ * the popup rewrites the whole region as text, which would destroy it.
219
+ */
220
+ function isInlineSafe(n: AstNode): boolean {
221
+ if (n.type === 'text') return true;
222
+ if (n.type !== 'element') return false;
223
+ const tag = (n.name ?? '').toLowerCase();
224
+ if (!INLINE_TAGS.has(tag)) return false;
225
+ const attrsOk = (n.attributes ?? []).every(
226
+ (a) => a.kind === 'quoted' && attrAllowed(tag, (a.name ?? '').toLowerCase()),
227
+ );
228
+ return attrsOk && (n.children ?? []).every(isInlineSafe);
229
+ }
230
+
231
+ /** An expression anywhere in the subtree — checked ahead of the markup rule so
232
+ * `<p><strong>{x}</strong></p>` refuses with the expression reason, which is
233
+ * the one that tells the user what to do about it. */
234
+ function hasExpressionDeep(n: AstNode): boolean {
235
+ return (n.children ?? []).some((c) => c.type === 'expression' || hasExpressionDeep(c));
236
+ }
237
+
238
+ /**
239
+ * Vet a raw-markup replacement before it is written. Returns an error message,
240
+ * or null when the string is nothing but text and well-nested safelisted inline
241
+ * tags. Written as a scanner rather than a regex sweep because it also has to
242
+ * hold the open-tag stack: unbalanced markup would corrupt the rest of the
243
+ * page, so it is refused rather than repaired.
244
+ */
245
+ function validateInlineMarkup(html: string): string | null {
246
+ const stack: string[] = [];
247
+ let i = 0;
248
+ while (i < html.length) {
249
+ const lt = html.indexOf('<', i);
250
+ if (lt < 0) break;
251
+
252
+ let j = lt + 1;
253
+ const closing = html[j] === '/';
254
+ if (closing) j++;
255
+ const nameStart = j;
256
+ while (j < html.length && /[a-zA-Z0-9]/.test(html[j])) j++;
257
+ const tag = html.slice(nameStart, j).toLowerCase();
258
+ if (!tag) {
259
+ return 'A “<” here does not start a tag. Write it as &lt; if you meant the character itself.';
260
+ }
261
+ if (!INLINE_TAGS.has(tag)) {
262
+ return `<${tag}> can’t be added here. Allowed inline tags: ${ALLOWED_LIST}. Edit this element in the source instead.`;
263
+ }
264
+
265
+ // Scan to the closing `>`, stepping over quoted attribute values so a `>`
266
+ // inside one doesn't end the tag early.
267
+ let k = j;
268
+ let attrText = '';
269
+ while (k < html.length && html[k] !== '>') {
270
+ const ch = html[k];
271
+ if (ch === '"' || ch === "'") {
272
+ const close = html.indexOf(ch, k + 1);
273
+ if (close < 0) return `An attribute value on <${tag}> is missing its closing quote.`;
274
+ attrText += html.slice(k, close + 1);
275
+ k = close + 1;
276
+ continue;
277
+ }
278
+ attrText += ch;
279
+ k++;
280
+ }
281
+ if (k >= html.length) return `The <${tag}> tag is missing its closing “>”.`;
282
+
283
+ if (closing) {
284
+ if (attrText.trim()) return `A closing </${tag}> tag can’t carry attributes.`;
285
+ if (stack.pop() !== tag) {
286
+ return `</${tag}> doesn’t close the tag it should — check the tags nest correctly.`;
287
+ }
288
+ } else {
289
+ const bad = validateAttrs(tag, attrText);
290
+ if (bad) return bad;
291
+ const selfClosing = /\/\s*$/.test(attrText);
292
+ if (!VOID_INLINE_TAGS.has(tag) && !selfClosing) stack.push(tag);
293
+ }
294
+ i = k + 1;
295
+ }
296
+ if (stack.length) return `<${stack[stack.length - 1]}> is never closed.`;
297
+ return null;
298
+ }
299
+
300
+ /** Attribute-level vetting for one opening tag's attribute text. */
301
+ function validateAttrs(tag: string, attrText: string): string | null {
302
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(=\s*("[^"]*"|'[^']*'|[^\s"'>]+))?/g;
303
+ let m: RegExpExecArray | null;
304
+ while ((m = re.exec(attrText)) !== null) {
305
+ const name = m[1].toLowerCase();
306
+ if (!attrAllowed(tag, name)) {
307
+ return `The ${name} attribute isn’t allowed on <${tag}> here. Edit this element in the source instead.`;
308
+ }
309
+ const value = (m[3] ?? '').trim().replace(/^["']|["']$/g, '');
310
+ if (name === 'href' && /^\s*javascript:/i.test(value)) {
311
+ return 'A javascript: link can’t be added here. Edit this element in the source instead.';
312
+ }
313
+ }
314
+ return null;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Element resolution
319
+ // ---------------------------------------------------------------------------
320
+
321
+ function collectElements(root: AstNode): AstNode[] {
322
+ const out: AstNode[] = [];
323
+ const visit = (n: AstNode): void => {
324
+ if (n.type === 'element') out.push(n);
325
+ for (const c of n.children ?? []) visit(c);
326
+ };
327
+ visit(root);
328
+ return out;
329
+ }
330
+
331
+ /** Child → parent for every node under `root`. The compiler's AST has no
332
+ * upward links, and tracing `{b.title}` has to reach the `.map()` that bound
333
+ * `b`. Built alongside the element sweep so resolution stays one pass. */
334
+ function parentMap(root: AstNode): Map<AstNode, AstNode> {
335
+ const parents = new Map<AstNode, AstNode>();
336
+ const visit = (n: AstNode): void => {
337
+ for (const c of n.children ?? []) {
338
+ parents.set(c, n);
339
+ visit(c);
340
+ }
341
+ };
342
+ visit(root);
343
+ return parents;
344
+ }
345
+
346
+ /** The frontmatter body and where it starts in the source, or null when the
347
+ * file has none. The node's own `offset` is byte-based and unusable here; the
348
+ * body begins immediately after the opening `---`. */
349
+ function frontmatterOf(
350
+ root: AstNode,
351
+ starts: number[],
352
+ ): { text: string; at: number } | null {
353
+ const node = (root.children ?? []).find((c) => c.type === 'frontmatter');
354
+ if (!node?.position || typeof node.value !== 'string') return null;
355
+ const at = indexOfPos(starts, node.position.start);
356
+ if (at < 0) return null;
357
+ return { text: node.value, at: at + 3 };
358
+ }
359
+
360
+ /** The loc Astro would annotate this element with (see header comment). */
361
+ function annotationLoc(el: AstNode): Pos | null {
362
+ const first = (el.children ?? []).find((c) => c.position);
363
+ if (!first) {
364
+ if (!el.position) return null;
365
+ return { line: el.position.start.line, column: el.position.start.column + 1 };
366
+ }
367
+ const s = first.position!.start;
368
+ return first.type === 'text' ? { line: s.line, column: s.column } : { line: s.line, column: s.column + 1 };
369
+ }
370
+
371
+ interface Resolution {
372
+ status: 'ok' | 'ambiguous' | 'unresolved';
373
+ element?: AstNode;
374
+ /** Present when resolution succeeded — the ancestor links and frontmatter
375
+ * that expression tracing needs. */
376
+ parents?: Map<AstNode, AstNode>;
377
+ frontmatter?: { text: string; at: number } | null;
378
+ }
379
+
380
+ async function resolveElement(source: string, loc: string, tag: string): Promise<Resolution> {
381
+ const m = /^(\d+):(\d+)$/.exec(loc);
382
+ if (!m) return { status: 'unresolved' };
383
+ const line = Number(m[1]);
384
+ const column = Number(m[2]);
385
+
386
+ const { ast } = await parse(source, { position: true });
387
+ const hits = collectElements(ast as AstNode).filter((el) => {
388
+ if ((el.name ?? '').toLowerCase() !== tag.toLowerCase()) return false;
389
+ const cand = annotationLoc(el);
390
+ return cand !== null && cand.line === line && cand.column === column;
391
+ });
392
+
393
+ if (hits.length !== 1) return { status: hits.length ? 'ambiguous' : 'unresolved' };
394
+ const root = ast as AstNode;
395
+ return {
396
+ status: 'ok',
397
+ element: hits[0],
398
+ parents: parentMap(root),
399
+ frontmatter: frontmatterOf(root, lineStartIndices(source)),
400
+ };
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Classification
405
+ // ---------------------------------------------------------------------------
406
+
407
+ function attrState(el: AstNode, name: string): AttrState {
408
+ const attr = (el.attributes ?? []).find((a) => a.name === name);
409
+ if (!attr) return 'missing';
410
+ return attr.kind === 'quoted' ? 'static' : 'dynamic';
411
+ }
412
+
413
+ /** The source span covering all of an element's children — the region a text
414
+ * or markup edit replaces. */
415
+ function innerSpan(starts: number[], el: AstNode): { from: number; to: number } | null {
416
+ const children = el.children ?? [];
417
+ const first = children[0]?.position;
418
+ const last = children[children.length - 1]?.position;
419
+ if (!first || !last?.end) return null;
420
+ const from = indexOfPos(starts, first.start);
421
+ const to = indexOfPos(starts, last.end);
422
+ if (from < 0 || to < 0 || to < from) return null;
423
+ return { from, to };
424
+ }
425
+
426
+ function classifyResolved(
427
+ el: AstNode,
428
+ source: string,
429
+ starts: number[],
430
+ res?: Resolution,
431
+ ): ClassifyResult {
432
+ const tag = (el.name ?? '').toLowerCase();
433
+ const children = el.children ?? [];
434
+
435
+ if (tag === 'img') {
436
+ return {
437
+ kind: 'image',
438
+ reason: 'image element',
439
+ attrs: { src: attrState(el, 'src'), alt: attrState(el, 'alt') },
440
+ };
441
+ }
442
+
443
+ if (!children.length) {
444
+ return { kind: 'empty', reason: 'This element has no text content in the source.' };
445
+ }
446
+ if (hasExpressionDeep(el)) {
447
+ // A `{title}` or a `{b.title}` inside a `.map()` still renders words that
448
+ // live in this file's frontmatter — traceable ones become editable through
449
+ // the value popup; everything else keeps the refusal.
450
+ const trace = res && traceExpression(el, res.parents!);
451
+ if (trace && res?.frontmatter && hasCandidates(res.frontmatter.text, trace)) {
452
+ return {
453
+ kind: 'expression',
454
+ reason: 'traced to a frontmatter string',
455
+ expression: { property: trace.property, label: trace.label },
456
+ };
457
+ }
458
+ return {
459
+ kind: 'dynamic',
460
+ reason:
461
+ 'This text comes from a template expression (e.g. a frontmatter field or a variable), so editing it here would change code, not copy.',
462
+ };
463
+ }
464
+ if (children.every((c) => c.type === 'text')) {
465
+ return { kind: 'text', reason: 'literal text' };
466
+ }
467
+ // Literal text carrying inline formatting: editable, but as raw source in a
468
+ // popup rather than inline, since the tags have to survive the round-trip.
469
+ if (children.every(isInlineSafe)) {
470
+ const span = innerSpan(starts, el);
471
+ if (span) {
472
+ return {
473
+ kind: 'markup',
474
+ reason: 'literal text with inline markup',
475
+ markup: { html: source.slice(span.from, span.to).trim() },
476
+ };
477
+ }
478
+ }
479
+ return {
480
+ kind: 'dynamic',
481
+ reason: 'This element contains nested markup, so its text cannot be edited as one block.',
482
+ };
483
+ }
484
+
485
+ export async function classifyAstro(source: string, loc: string, tag: string): Promise<ClassifyResult> {
486
+ const res = await resolveElement(source, loc, tag);
487
+ if (res.status === 'ambiguous') {
488
+ return {
489
+ kind: 'ambiguous',
490
+ reason: 'Two elements in the source share this location marker, so the edit target cannot be identified safely.',
491
+ };
492
+ }
493
+ if (res.status === 'unresolved') {
494
+ return {
495
+ kind: 'unresolved',
496
+ reason: 'No element matches this source location — the file may have changed since the page loaded. Try reloading.',
497
+ };
498
+ }
499
+ return classifyResolved(res.element!, source, lineStartIndices(source), res);
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Patching
504
+ // ---------------------------------------------------------------------------
505
+
506
+ function patchTextContent(
507
+ source: string,
508
+ starts: number[],
509
+ el: AstNode,
510
+ original: string,
511
+ newText: string,
512
+ ): ApplyResult {
513
+ const cls = classifyResolved(el, source, starts);
514
+ if (cls.kind !== 'text') {
515
+ return { ok: false, code: 'dynamic', error: cls.reason };
516
+ }
517
+
518
+ const span = innerSpan(starts, el);
519
+ if (!span) {
520
+ return { ok: false, code: 'unsupported', error: 'The source positions for this text are invalid.' };
521
+ }
522
+
523
+ const region = source.slice(span.from, span.to);
524
+ // Verify the source still says what the client saw. (spec §7.5)
525
+ if (normalize(decodeEntities(region)) !== normalize(original)) {
526
+ return {
527
+ ok: false,
528
+ code: 'mismatch',
529
+ error: 'The source no longer matches the text on the page (it may have been edited elsewhere). Reload and try again.',
530
+ };
531
+ }
532
+
533
+ return { ok: true, newSource: splice(source, span, region, escapeText(newText.trim())) };
534
+ }
535
+
536
+ /**
537
+ * Replace an element's inner source with raw inline markup. Unlike the text
538
+ * path this compares source against source — `original` is the very string
539
+ * /classify handed the popup — so no entity decoding is involved on either
540
+ * side, and a file edited out-of-band still fails safe with a mismatch.
541
+ */
542
+ function patchMarkupContent(
543
+ source: string,
544
+ starts: number[],
545
+ el: AstNode,
546
+ original: string,
547
+ newHtml: string,
548
+ ): ApplyResult {
549
+ const cls = classifyResolved(el, source, starts);
550
+ // `text` is accepted too: an element that holds only literal text today can
551
+ // legitimately gain its first <br> or <strong> through this path.
552
+ if (cls.kind !== 'markup' && cls.kind !== 'text') {
553
+ return { ok: false, code: 'dynamic', error: cls.reason };
554
+ }
555
+
556
+ const span = innerSpan(starts, el);
557
+ if (!span) {
558
+ return { ok: false, code: 'unsupported', error: 'The source positions for this text are invalid.' };
559
+ }
560
+
561
+ const region = source.slice(span.from, span.to);
562
+ if (normalize(region) !== normalize(original)) {
563
+ return {
564
+ ok: false,
565
+ code: 'mismatch',
566
+ error: 'The source no longer matches the markup on the page (it may have been edited elsewhere). Reload and try again.',
567
+ };
568
+ }
569
+
570
+ const trimmed = newHtml.trim();
571
+ const bad = validateInlineMarkup(trimmed);
572
+ if (bad) return { ok: false, code: 'unsupported', error: bad };
573
+
574
+ return { ok: true, newSource: splice(source, span, region, escapeMarkup(trimmed)) };
575
+ }
576
+
577
+ /** Swap `replacement` into `span`, keeping the region's own leading/trailing
578
+ * whitespace so the file's indentation survives. (spec §6.1) */
579
+ function splice(
580
+ source: string,
581
+ span: { from: number; to: number },
582
+ region: string,
583
+ replacement: string,
584
+ ): string {
585
+ const lead = /^\s*/.exec(region)![0];
586
+ const trail = lead.length === region.length ? '' : /\s*$/.exec(region)![0];
587
+ return source.slice(0, span.from) + lead + replacement + trail + source.slice(span.to);
588
+ }
589
+
590
+ /**
591
+ * Find the value span of a `quoted` attribute by scanning forward from the
592
+ * attribute's name position: name, `=`, opening quote, closing quote. Refuses
593
+ * (returns null) on anything unexpected — including unquoted values.
594
+ */
595
+ function attrValueSpan(
596
+ source: string,
597
+ starts: number[],
598
+ attr: AstNode,
599
+ ): { from: number; to: number; quote: string } | null {
600
+ if (!attr.position || !attr.name) return null;
601
+ let i = indexOfPos(starts, attr.position.start);
602
+ if (i < 0 || !source.startsWith(attr.name, i)) return null;
603
+ i += attr.name.length;
604
+ while (i < source.length && /\s/.test(source[i])) i++;
605
+ if (source[i] !== '=') return null;
606
+ i++;
607
+ while (i < source.length && /\s/.test(source[i])) i++;
608
+ const quote = source[i];
609
+ if (quote !== '"' && quote !== "'") return null;
610
+ const close = source.indexOf(quote, i + 1);
611
+ if (close < 0) return null;
612
+ return { from: i + 1, to: close, quote };
613
+ }
614
+
615
+ /**
616
+ * Find the index just before the `>` (or `/>`) that closes an element's
617
+ * opening tag, skipping quoted attribute values and `{...}` expressions.
618
+ * Returns null on anything it does not fully understand.
619
+ */
620
+ function openTagInsertionPoint(source: string, starts: number[], el: AstNode): number | null {
621
+ if (!el.position) return null;
622
+ let i = indexOfPos(starts, el.position.start);
623
+ if (i < 0 || source[i] !== '<') return null;
624
+ i++;
625
+ let depth = 0;
626
+ while (i < source.length) {
627
+ const ch = source[i];
628
+ if (ch === '"' || ch === "'") {
629
+ const close = source.indexOf(ch, i + 1);
630
+ if (close < 0) return null;
631
+ i = close + 1;
632
+ continue;
633
+ }
634
+ if (ch === '{') {
635
+ depth++;
636
+ i++;
637
+ continue;
638
+ }
639
+ if (ch === '}') {
640
+ if (depth === 0) return null;
641
+ depth--;
642
+ i++;
643
+ continue;
644
+ }
645
+ if (ch === '>' && depth === 0) {
646
+ // Step back over a self-closing slash and trailing whitespace.
647
+ let at = i;
648
+ let back = i - 1;
649
+ if (source[back] === '/') back--;
650
+ while (back > 0 && /\s/.test(source[back])) back--;
651
+ at = back + 1;
652
+ return at;
653
+ }
654
+ if (ch === '<' && depth === 0) return null; // ran into another tag: lost
655
+ i++;
656
+ }
657
+ return null;
658
+ }
659
+
660
+ function patchAttribute(
661
+ source: string,
662
+ starts: number[],
663
+ el: AstNode,
664
+ attrName: 'src' | 'alt',
665
+ original: string,
666
+ newValue: string,
667
+ ): ApplyResult {
668
+ const attr = (el.attributes ?? []).find((a) => a.name === attrName);
669
+
670
+ if (!attr) {
671
+ // Insert-if-missing is allowed for `alt` on <img> only: adding alt text is
672
+ // still strictly content. A missing `src` is never inserted. (spec §6.3)
673
+ if (attrName !== 'alt' || (el.name ?? '').toLowerCase() !== 'img' || original !== '') {
674
+ return { ok: false, code: 'unsupported', error: `This element has no ${attrName} attribute in the source.` };
675
+ }
676
+ const at = openTagInsertionPoint(source, starts, el);
677
+ if (at === null) {
678
+ return { ok: false, code: 'unsupported', error: 'Could not find a safe place to add the alt attribute.' };
679
+ }
680
+ const insertion = ` alt="${escapeAttrValue(newValue, '"')}"`;
681
+ return { ok: true, newSource: source.slice(0, at) + insertion + source.slice(at) };
682
+ }
683
+
684
+ if (attr.kind !== 'quoted') {
685
+ return {
686
+ ok: false,
687
+ code: 'dynamic',
688
+ error: `The ${attrName} attribute is set from an expression, so it must be edited in the source.`,
689
+ };
690
+ }
691
+
692
+ const span = attrValueSpan(source, starts, attr);
693
+ if (!span) {
694
+ return { ok: false, code: 'unsupported', error: `Could not locate the ${attrName} value in the source.` };
695
+ }
696
+
697
+ // Deliberately exact — no whitespace normalization, unlike text content.
698
+ // The DOM preserves attribute values verbatim, so source and page only
699
+ // diverge when the file really changed out-of-band; refusing is safe. The one
700
+ // thing that is not exact is entity-decoding depth: see `decodeDepths` for
701
+ // why the number of passes between this file and the page is not ours to
702
+ // assume.
703
+ const forms = decodeDepths(source.slice(span.from, span.to));
704
+ if (!forms.includes(original)) {
705
+ return {
706
+ ok: false,
707
+ code: 'mismatch',
708
+ error: `The source ${attrName} no longer matches the page (it may have been edited elsewhere). Reload and try again.`,
709
+ };
710
+ }
711
+
712
+ const replacement = escapeAttrValue(newValue, span.quote);
713
+ return { ok: true, newSource: source.slice(0, span.from) + replacement + source.slice(span.to) };
714
+ }
715
+
716
+ /**
717
+ * Patch the frontmatter string an `{expression}` renders. The whole write is
718
+ * one string literal: the element's own markup is never touched, so a loop
719
+ * keeps rendering exactly as it did and only the words change.
720
+ *
721
+ * `original` is the text the page showed, and it is the *only* thing that says
722
+ * which item of a `.map()` was clicked — see `expression-trace.ts` on why the
723
+ * DOM index is not used. It therefore doubles as the verify step: no item still
724
+ * reading that way means the file moved on, and nothing is written.
725
+ */
726
+ function patchExpression(
727
+ source: string,
728
+ el: AstNode,
729
+ res: Resolution,
730
+ original: string,
731
+ newText: string,
732
+ ): ApplyResult {
733
+ const trace = traceExpression(el, res.parents!);
734
+ if (!trace || !res.frontmatter) {
735
+ return {
736
+ ok: false,
737
+ code: 'dynamic',
738
+ error: 'This text can’t be traced back to a string in the frontmatter, so it must be edited in the source.',
739
+ };
740
+ }
741
+
742
+ const { text: frontmatter, at } = res.frontmatter;
743
+ const found = locateValue(frontmatter, trace, original);
744
+ if (!found.ok) {
745
+ return {
746
+ ok: false,
747
+ code: found.code === 'untraceable' ? 'dynamic' : found.code,
748
+ error: found.error,
749
+ };
750
+ }
751
+
752
+ const { from, to, quote } = found.span;
753
+ const replacement = quote + encodeLiteral(newText.trim(), quote) + quote;
754
+ return {
755
+ ok: true,
756
+ newSource: source.slice(0, at + from) + replacement + source.slice(at + to),
757
+ };
758
+ }
759
+
760
+ export async function applyAstro(source: string, req: PatchRequest): Promise<ApplyResult> {
761
+ const res = await resolveElement(source, req.loc, req.tag);
762
+ if (res.status === 'ambiguous') {
763
+ return { ok: false, code: 'ambiguous', error: 'Two elements share this source location; refusing to guess.' };
764
+ }
765
+ if (res.status === 'unresolved') {
766
+ return {
767
+ ok: false,
768
+ code: 'unresolved',
769
+ error: 'No element matches this source location — the file may have changed. Reload and try again.',
770
+ };
771
+ }
772
+
773
+ const starts = lineStartIndices(source);
774
+ const el = res.element!;
775
+ if (req.targetType === 'text') {
776
+ return patchTextContent(source, starts, el, req.original, req.newText);
777
+ }
778
+ if (req.targetType === 'markup') {
779
+ return patchMarkupContent(source, starts, el, req.original, req.newText);
780
+ }
781
+ if (req.targetType === 'expression') {
782
+ return patchExpression(source, el, res, req.original, req.newText);
783
+ }
784
+ return patchAttribute(source, starts, el, req.targetType, req.original, req.newText);
785
+ }
786
+
787
+ /** This file's classify/apply pair, packaged for the extension registry. */
788
+ export const astroPatcher: Patcher = {
789
+ extensions: ['.astro'],
790
+ classify: (source, { loc, tag }) => classifyAstro(source, loc, tag),
791
+ apply: applyAstro,
792
+ };