1/**
2 * Format-agnostic text rewriting.
3 *
4 * An editable element renders as a sequence of DOM text nodes. The source
5 * that produced it is described as a matching sequence of {@link Run}s
6 * (source ranges between markup). An edit pairs each changed text node with
7 * its run and rewrites only the words that changed, so markup, escapes, and
8 * line wrapping outside the edited words are preserved verbatim.
9 *
10 * A {@link Dialect} supplies the source-format specifics: how a run splits
11 * into words and whitespace, how words compare with rendered text, and how
12 * new words are escaped.
13 */
14
15export class CopyEditError extends Error {
16 constructor(
17 message: string,
18 readonly status = 400,
19 ) {
20 super(message);
21 }
22}
23
24/** `text` is editable prose, `code` is literal (no escaping), `locked` can't be edited. */
25export type RunKind = "text" | "code" | "locked";
26
27export interface Run {
28 start: number;
29 end: number;
30 kind?: RunKind;
31 /** Rendered text of a `locked` run (its source is not literal text). */
32 expected?: string;
33 /** True when the run starts a source line (block syntax applies there). */
34 lineStart?: boolean;
35}
36
37export interface TextToken {
38 raw: string;
39 ws: boolean;
40 /** Rendered text of the token (whitespace tokens render as a space). */
41 text: string;
42}
43
44export interface EscapeContext {
45 kind: RunKind;
46 /** True when the escaped word will begin a source line. */
47 lineStart: boolean;
48}
49
50export interface Dialect {
51 tokenize(raw: string, kind: RunKind): TextToken[];
52 /** Canonical form used to compare source text with rendered text. */
53 compare(text: string): string;
54 /** Escapes a word typed in the browser for insertion into the source. */
55 escape(word: string, context: EscapeContext): string;
56}
57
58export interface TextEdit {
59 /** The element's DOM text nodes when editing started. */
60 before: string[];
61 /** The element's DOM text nodes after editing. */
62 after: string[];
63}
64
65/** HTML whitespace (deliberately excludes the non-breaking space). */
66export const WS = /[ \t\n\r\f]+/;
67
68export const normalize = (text: string) =>
69 text.split(WS).filter(Boolean).join(" ");
70
71const runText = (code: string, run: Run, dialect: Dialect) =>
72 dialect.compare(
73 normalize(
74 run.kind === "locked"
75 ? (run.expected ?? "")
76 : dialect
77 .tokenize(code.slice(run.start, run.end), run.kind ?? "text")
78 .map((t) => t.text)
79 .join(""),
80 ),
81 );
82
83/**
84 * Rewrites one raw source run so it renders as `next`, touching only the
85 * words that changed.
86 */
87const rewriteRun = (
88 raw: string,
89 next: string,
90 dialect: Dialect,
91 kind: RunKind,
92 runLineStart: boolean,
93): string => {
94 const tokens = dialect.tokenize(raw, kind);
95 const wordIdx = tokens.flatMap((t, i) => (t.ws ? [] : [i]));
96 const oldWords = wordIdx.map((i) => dialect.compare(tokens[i].text));
97 const typed = next.split(WS).filter(Boolean);
98 const newWords = typed.map((w) => dialect.compare(w));
99
100 const lineStartAt = (tokenIndex: number) =>
101 tokenIndex === 0 ? runLineStart : tokens[tokenIndex - 1].raw.includes("\n");
102 const render = (words: string[], lineStart: boolean) =>
103 words
104 .map((w, i) =>
105 dialect.escape(w, { kind, lineStart: lineStart && i === 0 }),
106 )
107 .join(" ");
108
109 if (oldWords.length === 0) {
110 if (typed.length === 0) return raw;
111 const lead = tokens[0]?.raw ?? "";
112 const trail = tokens.length > 1 ? tokens[tokens.length - 1].raw : lead;
113 return `${lead}${render(typed, runLineStart || lead.includes("\n"))}${trail}`;
114 }
115
116 let p = 0;
117 const max = Math.min(oldWords.length, newWords.length);
118 while (p < max && oldWords[p] === newWords[p]) p++;
119 let s = 0;
120 while (
121 s < max - p &&
122 oldWords[oldWords.length - 1 - s] === newWords[newWords.length - 1 - s]
123 )
124 s++;
125
126 const middleWords = typed.slice(p, typed.length - s);
127 const oldCount = oldWords.length - s - p;
128 const raws = tokens.map((t) => t.raw);
129
130 if (oldCount > 0) {
131 let from = wordIdx[p];
132 let to = wordIdx[oldWords.length - s - 1];
133 const middle = render(middleWords, lineStartAt(from));
134 if (!middle) {
135 // Drop one whitespace neighbour so the surrounding words don't collide.
136 if (p > 0) from = wordIdx[p - 1] + 1;
137 else if (s > 0) to = wordIdx[oldWords.length - s] - 1;
138 }
139 raws.splice(from, to - from + 1, middle);
140 } else if (middleWords.length > 0) {
141 if (p > 0) {
142 raws.splice(wordIdx[p - 1] + 1, 0, ` ${render(middleWords, false)}`);
143 } else {
144 raws.splice(
145 wordIdx[0],
146 0,
147 `${render(middleWords, lineStartAt(wordIdx[0]))} `,
148 );
149 }
150 }
151 return raws.join("");
152};
153
154/**
155 * Applies a browser edit to `code`, given the source runs that produced the
156 * edited element's text nodes. Returns the new source.
157 */
158export const applyRuns = (
159 code: string,
160 runs: Run[],
161 dialect: Dialect,
162 { before, after }: TextEdit,
163): string => {
164 if (before.length !== after.length) {
165 throw new CopyEditError(
166 "The edit removed or added markup (for example, deleted a whole <code>). Edit within text runs, or change the markup in code.",
167 );
168 }
169 const texts = runs.map((run) => runText(code, run, dialect));
170 const rendered = (t: string) => dialect.compare(normalize(t));
171
172 // Pair DOM text nodes with source runs: one-to-one when the counts agree,
173 // otherwise (renderers drop empty and whitespace-only runs) only the
174 // non-blank ones. The first pairing whose texts all match wins.
175 type Pairs = [run: number, node: number][];
176 const candidates: Pairs[] = [];
177 if (runs.length === before.length)
178 candidates.push(runs.map((_, i) => [i, i]));
179 const runIdx = texts.flatMap((t, i) => (t === "" ? [] : [i]));
180 const domIdx = before.flatMap((t, i) => (normalize(t) === "" ? [] : [i]));
181 if (runIdx.length === domIdx.length) {
182 candidates.push(runIdx.map((r, i) => [r, domIdx[i]]));
183 }
184 const pairs = candidates.find((c) =>
185 c.every(([r, d]) => texts[r] === rendered(before[d])),
186 );
187 if (!pairs) throw stale();
188 const paired = new Set(pairs.map(([, d]) => d));
189 if (
190 before.some((t, d) => !paired.has(d) && rendered(t) !== rendered(after[d]))
191 ) {
192 throw new CopyEditError(
193 "That spot (between two pieces of markup) has no text in the source. Type inside an existing word run instead.",
194 );
195 }
196
197 let out = code;
198 // Apply from the end so earlier offsets stay valid.
199 for (const [r, d] of [...pairs].reverse()) {
200 if (rendered(after[d]) === rendered(before[d])) continue;
201 const run = runs[r];
202 if (run.kind === "locked") {
203 throw new CopyEditError(
204 `"${normalize(before[d])}" is generated (for example, a link to another page) and can't be edited here. Change it in the source.`,
205 );
206 }
207 out =
208 out.slice(0, run.start) +
209 rewriteRun(
210 out.slice(run.start, run.end),
211 after[d],
212 dialect,
213 run.kind ?? "text",
214 run.lineStart ?? false,
215 ) +
216 out.slice(run.end);
217 }
218 return out;
219};
220
221export const stale = () =>
222 new CopyEditError(
223 "The text on the page doesn't match the source (the file changed, or a script rewrote this element). Reload and try again.",
224 409,
225 );
226
227// ── HTML templates (.astro, .html, JSX, Vue, Svelte) ──────────────────────
228
229const ENTITIES: Record<string, string> = {
230 amp: "&",
231 lt: "<",
232 gt: ">",
233 quot: '"',
234 apos: "'",
235 nbsp: "\u00a0",
236 mdash: "—",
237 ndash: "–",
238 rarr: "→",
239 larr: "←",
240 hellip: "…",
241 middot: "·",
242 copy: "©",
243 trade: "™",
244 rsquo: "’",
245 lsquo: "‘",
246 ldquo: "“",
247 rdquo: "”",
248 times: "×",
249 bull: "•",
250};
251
252export const decodeEntities = (raw: string): string =>
253 raw.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, body: string) => {
254 if (body[0] === "#") {
255 const code =
256 body[1] === "x" || body[1] === "X"
257 ? parseInt(body.slice(2), 16)
258 : parseInt(body.slice(1), 10);
259 return Number.isNaN(code) ? m : String.fromCodePoint(code);
260 }
261 return ENTITIES[body.toLowerCase()] ?? m;
262 });
263
264/** Text between tags in HTML-like templates; `{" "}` counts as whitespace. */
265export const htmlDialect: Dialect = {
266 tokenize(raw) {
267 const tokens: TextToken[] = [];
268 const re =
269 /(\{\s*(?:"\s+"|'\s+')\s*\})|([ \t\n\r\f]+)|([^ \t\n\r\f{]+)|(\{)/g;
270 for (const m of raw.matchAll(re)) {
271 if (m[4]) {
272 throw new CopyEditError(
273 "Editable copy can't contain {expressions}. Move data-copy to a plain-text element.",
274 );
275 }
276 const ws = Boolean(m[1] || m[2]);
277 tokens.push({ raw: m[0], ws, text: ws ? " " : decodeEntities(m[0]) });
278 }
279 return tokens;
280 },
281 compare: (text) => text,
282 escape: (word) =>
283 word
284 .replace(/&/g, "&amp;")
285 .replace(/</g, "&lt;")
286 .replace(/>/g, "&gt;")
287 .replace(/\{/g, "&#123;")
288 .replace(/\}/g, "&#125;")
289 .replace(/\u00a0/g, "&nbsp;"),
290};