| 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 | |
| 15 | export 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. */ |
| 25 | export type RunKind = "text" | "code" | "locked"; |
| 26 | |
| 27 | export 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 | |
| 37 | export 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 | |
| 44 | export interface EscapeContext { |
| 45 | kind: RunKind; |
| 46 | /** True when the escaped word will begin a source line. */ |
| 47 | lineStart: boolean; |
| 48 | } |
| 49 | |
| 50 | export 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 | |
| 58 | export 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). */ |
| 66 | export const WS = /[ \t\n\r\f]+/; |
| 67 | |
| 68 | export const normalize = (text: string) => |
| 69 | text.split(WS).filter(Boolean).join(" "); |
| 70 | |
| 71 | const 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 | */ |
| 87 | const 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 | */ |
| 158 | export 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 | |
| 221 | export 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 | |
| 229 | const 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 | |
| 252 | export 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. */ |
| 265 | export 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 | }; |