1/**
2 * Source rewriting for the dev-only copy editor.
3 *
4 * Elements marked with a bare `data-copy` attribute in HTML-like templates
5 * (`.astro`, `.html`, JSX, Vue, Svelte) are editable in dev. This module locates those elements in the raw
6 * source, stamps them with their location (dev), strips the attribute
7 * (build), and writes edited text back into the source.
8 *
9 * Only the text between tags is ever rewritten; tags, attributes, and
10 * unchanged words (including their line wrapping) are preserved verbatim.
11 */
12
13import { marked } from "marked";
14import {
15 applyRuns,
16 CopyEditError,
17 decodeEntities,
18 htmlDialect,
19 type Run,
20 type TextEdit,
21} from "./text.ts";
22
23export const ATTR = "data-copy";
24
25const VOID_ELEMENTS = new Set([
26 "area",
27 "base",
28 "br",
29 "col",
30 "embed",
31 "hr",
32 "img",
33 "input",
34 "link",
35 "meta",
36 "param",
37 "source",
38 "track",
39 "wbr",
40]);
41
42interface Attr {
43 name: string;
44 /** Start of the attribute name. */
45 start: number;
46 /** End of the attribute (after its value, if any). */
47 end: number;
48}
49
50type Token =
51 | {
52 kind: "open";
53 name: string;
54 start: number;
55 end: number;
56 selfClosing: boolean;
57 attrs: Attr[];
58 }
59 | { kind: "close"; name: string; start: number; end: number }
60 | { kind: "comment"; start: number; end: number };
61
62const frontmatterEnd = (code: string): number => {
63 const match = /^\s*---\r?\n[\s\S]*?\r?\n---[^\n]*\n?/.exec(code);
64 return match ? match[0].length : 0;
65};
66
67/** Skips a balanced `{...}` expression starting at `i` (which must be `{`). */
68const skipBraces = (code: string, i: number): number => {
69 let depth = 0;
70 while (i < code.length) {
71 const ch = code[i];
72 if (ch === '"' || ch === "'" || ch === "`") {
73 i = skipString(code, i);
74 continue;
75 }
76 if (ch === "{") depth++;
77 else if (ch === "}") {
78 depth--;
79 if (depth === 0) return i + 1;
80 }
81 i++;
82 }
83 return i;
84};
85
86const skipString = (code: string, i: number): number => {
87 const quote = code[i];
88 i++;
89 while (i < code.length && code[i] !== quote) {
90 if (code[i] === "\\") i++;
91 i++;
92 }
93 return i + 1;
94};
95
96const NAME_START = /[A-Za-z]/;
97
98const parseOpenTag = (
99 code: string,
100 start: number,
101): Token & { kind: "open" } => {
102 let i = start + 1;
103 const nameMatch = /^[A-Za-z][\w:.-]*/.exec(code.slice(i, i + 200));
104 const name = nameMatch ? nameMatch[0] : "";
105 i += name.length;
106 const attrs: Attr[] = [];
107 while (i < code.length) {
108 const ch = code[i];
109 if (/\s/.test(ch)) {
110 i++;
111 } else if (ch === ">") {
112 return {
113 kind: "open",
114 name,
115 start,
116 end: i + 1,
117 selfClosing: false,
118 attrs,
119 };
120 } else if (ch === "/" && code[i + 1] === ">") {
121 return {
122 kind: "open",
123 name,
124 start,
125 end: i + 2,
126 selfClosing: true,
127 attrs,
128 };
129 } else if (ch === "{") {
130 i = skipBraces(code, i);
131 } else {
132 const attrStart = i;
133 while (
134 i < code.length &&
135 !/[\s=>{]/.test(code[i]) &&
136 !(code[i] === "/" && code[i + 1] === ">")
137 ) {
138 i++;
139 }
140 const attrName = code.slice(attrStart, i);
141 let j = i;
142 while (/\s/.test(code[j] ?? "")) j++;
143 if (code[j] === "=") {
144 j++;
145 while (/\s/.test(code[j] ?? "")) j++;
146 const q = code[j];
147 if (q === '"' || q === "'" || q === "`") j = skipString(code, j);
148 else if (q === "{") j = skipBraces(code, j);
149 else while (j < code.length && !/[\s>]/.test(code[j])) j++;
150 i = j;
151 }
152 attrs.push({ name: attrName, start: attrStart, end: i });
153 if (i === attrStart) i++;
154 }
155 }
156 return {
157 kind: "open",
158 name,
159 start,
160 end: code.length,
161 selfClosing: true,
162 attrs,
163 };
164};
165
166/** Tokenizes the template part of an `.astro` file into tags and comments. */
167const tokenize = (code: string): Token[] => {
168 const tokens: Token[] = [];
169 let i = frontmatterEnd(code);
170 while (i < code.length) {
171 if (code[i] !== "<") {
172 i++;
173 continue;
174 }
175 if (code.startsWith("<!--", i)) {
176 const end = code.indexOf("-->", i);
177 const stop = end === -1 ? code.length : end + 3;
178 tokens.push({ kind: "comment", start: i, end: stop });
179 i = stop;
180 continue;
181 }
182 const next = code[i + 1] ?? "";
183 if (next === "/") {
184 const m = /^<\/([A-Za-z][\w:.-]*)?\s*>/.exec(code.slice(i, i + 200));
185 if (m) {
186 tokens.push({
187 kind: "close",
188 name: m[1] ?? "",
189 start: i,
190 end: i + m[0].length,
191 });
192 i += m[0].length;
193 continue;
194 }
195 i++;
196 continue;
197 }
198 if (next === ">" || NAME_START.test(next)) {
199 const tag = parseOpenTag(code, i);
200 if (VOID_ELEMENTS.has(tag.name.toLowerCase())) tag.selfClosing = true;
201 tokens.push(tag);
202 i = tag.end;
203 const raw = tag.name.toLowerCase();
204 if (!tag.selfClosing && (raw === "script" || raw === "style")) {
205 const close = code.toLowerCase().indexOf(`</${raw}`, i);
206 i = close === -1 ? code.length : close;
207 }
208 continue;
209 }
210 i++;
211 }
212 return tokens;
213};
214
215const copyTags = (tokens: Token[]) =>
216 tokens.flatMap((token, index) => {
217 if (token.kind !== "open") return [];
218 const attr = token.attrs.find((a) => a.name === ATTR);
219 return attr ? [{ token, index, attr }] : [];
220 });
221
222/**
223 * Replaces each `data-copy` attribute with `data-copy="<file>#<n>"`, plus
224 * `data-copy-format="inline"` when the element's content can round-trip
225 * through inline markdown (text, `code`, `strong`, `em`, `del`, `a`).
226 */
227export const annotate = (code: string, file: string): string => {
228 if (!code.includes(ATTR)) return code;
229 const tags = copyTags(tokenize(code));
230 let out = code;
231 for (let n = tags.length - 1; n >= 0; n--) {
232 const { attr } = tags[n];
233 let inline = true;
234 try {
235 inlineSource(code, n);
236 } catch {
237 inline = false;
238 }
239 const format = inline ? ` ${ATTR}-format="inline"` : "";
240 out = `${out.slice(0, attr.start)}${ATTR}="${file}#${n}"${format}${out.slice(attr.end)}`;
241 }
242 return out;
243};
244
245/** Removes every `data-copy` attribute (and the whitespace before it). */
246export const strip = (code: string): string => {
247 if (!code.includes(ATTR)) return code;
248 const tags = copyTags(tokenize(code));
249 let out = code;
250 for (let n = tags.length - 1; n >= 0; n--) {
251 const { attr } = tags[n];
252 let start = attr.start;
253 while (start > 0 && /\s/.test(out[start - 1])) start--;
254 out = out.slice(0, start) + out.slice(attr.end);
255 }
256 return out;
257};
258
259/** The `n`-th `data-copy` element: its open tag and the tokens inside it. */
260const elementOf = (code: string, n: number) => {
261 const tokens = tokenize(code);
262 const tags = copyTags(tokens);
263 const target = tags[n];
264 if (!target) {
265 throw new CopyEditError(
266 `No data-copy element #${n} in this file. Reload the page.`,
267 409,
268 );
269 }
270 const { token: open, index } = target;
271 if (open.selfClosing)
272 throw new CopyEditError("data-copy is on an empty element.");
273 let depth = 1;
274 let closeIndex = -1;
275 for (let k = index + 1; k < tokens.length; k++) {
276 const t = tokens[k];
277 if (t.kind === "open" && t.name === open.name && !t.selfClosing) depth++;
278 if (t.kind === "close" && t.name === open.name && --depth === 0) {
279 closeIndex = k;
280 break;
281 }
282 }
283 if (closeIndex === -1)
284 throw new CopyEditError(`No closing </${open.name}> found.`);
285 return {
286 open,
287 inner: tokens.slice(index + 1, closeIndex),
288 close: tokens[closeIndex] as Token & { kind: "close" },
289 };
290};
291
292/** The text runs between tags inside the `n`-th `data-copy` element. */
293const segmentsOf = (code: string, n: number): Run[] => {
294 const { open, inner, close } = elementOf(code, n);
295 const segments: Run[] = [];
296 let cursor = open.end;
297 for (const t of [...inner, close]) {
298 if (t.kind === "comment")
299 throw new CopyEditError("Editable copy can't contain HTML comments.");
300 if ((t.kind === "open" || t.kind === "close") && /^[A-Z]/.test(t.name)) {
301 throw new CopyEditError(
302 `Editable copy can't contain components (<${t.name}>).`,
303 );
304 }
305 if (t.kind === "open" && /^(script|style)$/i.test(t.name)) {
306 throw new CopyEditError(
307 "Editable copy can't contain <script> or <style>.",
308 );
309 }
310 segments.push({ start: cursor, end: t.start });
311 cursor = t.end;
312 }
313 return segments;
314};
315
316export interface EditRequest extends TextEdit {
317 /** Index of the `data-copy` element within the file. */
318 index: number;
319}
320
321/** Applies an edit to an HTML-like template and returns the new source. */
322export const applyEdit = (
323 code: string,
324 { index, before, after }: EditRequest,
325): string =>
326 applyRuns(code, segmentsOf(code, index), htmlDialect, { before, after });
327
328// ── inline markdown ───────────────────────────────────────────────────────
329
330const INLINE_TAGS: Record<string, [open: string, close: string]> = {
331 code: ["`", "`"],
332 strong: ["**", "**"],
333 b: ["**", "**"],
334 em: ["*", "*"],
335 i: ["*", "*"],
336 del: ["~~", "~~"],
337 s: ["~~", "~~"],
338 a: ["[", "]"],
339};
340/** The tag inline markdown produces for each markdown construct. */
341const MARKDOWN_TAG: Record<string, string> = {
342 b: "strong",
343 i: "em",
344 s: "del",
345};
346
347const escapeMarkdown = (text: string) =>
348 text
349 .replace(/[\\`*[\]<]/g, "\\$&")
350 .replace(/(^|\W)_|_(?=\W|$)/g, (m) => m.replace("_", "\\_"))
351 .replace(/~~/g, "\\~\\~");
352
353const hrefOf = (code: string, attrs: Attr[]) => {
354 const attr = attrs.find((a) => a.name === "href");
355 if (!attr) return undefined;
356 const m = /^href\s*=\s*(["'])([^"'{}]*)\1$/.exec(
357 code.slice(attr.start, attr.end),
358 );
359 if (!m)
360 throw new CopyEditError("Links need a literal href to edit as markdown.");
361 return decodeEntities(m[2]!);
362};
363
364/**
365 * The `n`-th `data-copy` element's content as inline markdown, plus what's
366 * needed to write markdown back: the content's source range and the
367 * attributes each tag carries in the source.
368 */
369export const inlineSource = (code: string, n: number) => {
370 const { open, inner, close } = elementOf(code, n);
371 const templates: Record<string, string> = {};
372 const links: string[] = [];
373 let md = "";
374 let inCode = false;
375 let skipSpace = false;
376 let cursor = open.end;
377 const text = (raw: string) => {
378 const words = htmlDialect
379 .tokenize(raw, "text")
380 .map((t) => t.text)
381 .join("");
382 let collapsed = words.replace(/\s+/g, " ");
383 if (skipSpace) collapsed = collapsed.replace(/^ /, "");
384 if (collapsed) skipSpace = false;
385 md += inCode ? collapsed : escapeMarkdown(collapsed);
386 };
387 for (const t of inner) {
388 text(code.slice(cursor, t.start));
389 cursor = t.end;
390 if (t.kind === "comment")
391 throw new CopyEditError("HTML comments can't be edited as markdown.");
392 const name = t.name.toLowerCase();
393 if (name === "br" && t.kind === "open" && !inCode) {
394 // A line break: the whitespace around it isn't content.
395 md = `${md.replace(/ +$/, "")}\n`;
396 skipSpace = true;
397 continue;
398 }
399 const syntax = INLINE_TAGS[name];
400 if (!syntax || (inCode && t.kind === "open")) {
401 throw new CopyEditError(`<${t.name}> can't be edited as markdown.`);
402 }
403 if (t.kind === "open") {
404 if (t.selfClosing)
405 throw new CopyEditError(`<${t.name}/> can't be edited as markdown.`);
406 const tag = MARKDOWN_TAG[name] ?? name;
407 if (!(tag in templates)) {
408 const attrs = t.attrs
409 .filter((a) => a.name !== "href")
410 .map((a) => code.slice(a.start, a.end))
411 .join(" ");
412 templates[tag] = attrs ? ` ${attrs}` : "";
413 }
414 if (name === "a") links.push(hrefOf(code, t.attrs) ?? "");
415 if (name === "code") inCode = true;
416 md += syntax[0];
417 } else {
418 if (name === "code") inCode = false;
419 md += name === "a" ? `](${links.pop() ?? ""})` : syntax[1];
420 }
421 }
422 text(code.slice(cursor, close.start));
423 const raw = code.slice(open.end, close.start);
424 return {
425 markdown: md.trim(),
426 start: open.end,
427 end: close.start,
428 lead: /^\s*/.exec(raw)![0],
429 trail: /\s*$/.exec(raw)![0],
430 templates,
431 };
432};
433
434/** Inline markdown as template HTML, reusing each tag's source attributes. */
435export const inlineHtml = (
436 markdown: string,
437 templates: Record<string, string>,
438) => {
439 // Each line break in the markdown becomes a <br>.
440 const html = marked.parseInline(markdown.replace(/[ \t]*\n\s*/g, "\n"), {
441 async: false,
442 gfm: true,
443 breaks: true,
444 });
445 return html
446 .split(/(<[^>]+>)/)
447 .map((part, i) => {
448 if (i % 2 === 0) {
449 return part
450 .replace(/&quot;/g, '"')
451 .replace(/&#39;/g, "'")
452 .replace(/\{/g, "&#123;")
453 .replace(/\}/g, "&#125;");
454 }
455 const tag = /^<\/?([a-z]+)(\s[^>]*)?>$/.exec(part);
456 if (!tag || !(tag[1]! in INLINE_TAGS || tag[1] === "br")) {
457 // Typed HTML that isn't an inline markdown construct stays text.
458 return part.replace(/</g, "&lt;").replace(/>/g, "&gt;");
459 }
460 if (part.startsWith("</")) return part;
461 const [, name, attrs = ""] = tag;
462 if (!(name! in templates)) return part.replace(/[{}]/g, "");
463 const href = /\shref="[^"]*"/.exec(attrs)?.[0] ?? "";
464 return `<${name}${href}${templates[name!]}>`;
465 })
466 .join("");
467};
468
469/** Replaces the `n`-th element's content with `markdown` rendered as HTML. */
470export const writeInline = (
471 code: string,
472 n: number,
473 { markdown, base }: { markdown: string; base: string },
474): string => {
475 const current = inlineSource(code, n);
476 if (current.markdown !== base.trim()) {
477 throw new CopyEditError(
478 "This copy changed since you started editing. Copy your text, reload, and try again.",
479 409,
480 );
481 }
482 if (markdown.trim() === current.markdown) return code;
483 const html = inlineHtml(markdown.trim(), current.templates);
484 return (
485 code.slice(0, current.start) +
486 current.lead +
487 html +
488 current.trail +
489 code.slice(current.end)
490 );
491};