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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- 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
|
+
* `['&', '&']` for a source value of `&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;`
|
|
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, '&').replace(/</g, '<').replace(/\{/g, '{');
|
|
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, '{');
|
|
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, '&').replace(/</g, '<').replace(/\{/g, '{');
|
|
169
|
+
out = quote === '"' ? out.replace(/"/g, '"') : out.replace(/'/g, ''');
|
|
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 < 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
|
+
};
|