1// Dev-only: makes `[data-copy]` elements editable and saves edits to source.
2// Served by the copy-editor Vite plugin during `vite serve`; never built.
3//
4// Two editing modes:
5// - inline: the element's rendered text is edited in place and mapped back
6// onto its source text runs;
7// - markdown (`data-copy-format="markdown"`): focusing the section swaps in
8// its markdown source; leaving it saves the markdown and shows a preview
9// rendered with the section's style (`data-copy-style`) until the page's
10// own renderer catches up.
11
12import config from "virtual:copy-editor/config";
13import { marked } from "marked";
14import type { MarkdownStyle } from "./index.ts";
15
16const SAVE_ENDPOINT = "/__copy-editor/save";
17const SOURCE_ENDPOINT = "/__copy-editor/source";
18const REFRESH_EVENT = "copy-editor:refresh";
19const SELECTOR = "[data-copy]";
20const styles = config.styles as Record<string, MarkdownStyle>;
21
22const controller = new AbortController();
23const { signal } = controller;
24const listen = <K extends keyof DocumentEventMap>(
25 type: K,
26 handler: (event: DocumentEventMap[K]) => void,
27 capture = false,
28) => document.addEventListener(type, handler, { signal, capture });
29
30const style = document.createElement("style");
31style.textContent = `
32 ${SELECTOR} { cursor: text; }
33 ${SELECTOR}:hover { outline: 1px dashed rgb(255 120 80 / 0.7); outline-offset: 3px; }
34 ${SELECTOR}:focus {
35 outline: 2px solid rgb(255 120 80);
36 outline-offset: 3px;
37 background: rgb(255 120 80 / 0.08);
38 }
39 /* Source keeps the element's own font so the text doesn't jump; only
40 multi-line markdown needs its line breaks shown. */
41 :is([data-copy-editing], [data-copy-draft]) {
42 white-space: pre-wrap;
43 }
44 [data-copy-draft] { outline: 2px dashed #d33 !important; }
45 [data-copy-empty]:not([data-copy-editing]) { min-height: 1em; }
46 [data-copy-empty]:not([data-copy-editing]):is(:hover, :focus)::before {
47 content: "Add markdown…"; opacity: 0.45; font-style: italic;
48 }
49 #copy-editor-toast {
50 position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;
51 max-width: 360px; padding: 8px 12px; border-radius: 8px;
52 font: 600 12px/1.3 system-ui, sans-serif; color: #fff; background: #1f6f3a;
53 box-shadow: 0 2px 10px rgb(0 0 0 / 0.4);
54 opacity: 0; transition: opacity 0.2s; pointer-events: none;
55 }
56 #copy-editor-toast[data-error] { background: #8c1d18; }
57 #copy-editor-toast[data-show] { opacity: 1; }
58 ${Object.values(styles)
59 .map((s) => s.css ?? "")
60 .join("\n")}
61`;
62const toast = document.createElement("div");
63toast.id = "copy-editor-toast";
64document.head.append(style);
65document.body.append(toast);
66
67let toastTimer: ReturnType<typeof setTimeout> | undefined;
68const notify = (message: string, error = false) => {
69 toast.textContent = message;
70 toast.toggleAttribute("data-error", error);
71 toast.setAttribute("data-show", "");
72 clearTimeout(toastTimer);
73 toastTimer = setTimeout(
74 () => toast.removeAttribute("data-show"),
75 error ? 6000 : 1800,
76 );
77};
78
79const isMarkdown = (el: HTMLElement) =>
80 el.dataset.copyFormat === "markdown" || isInline(el);
81/** Template copy edited as one line of inline markdown. */
82const isInline = (el: HTMLElement) => el.dataset.copyFormat === "inline";
83
84/**
85 * Renders inline markdown, copying attributes (classes, scoped styles) from
86 * the same tags in the element's previous rendering.
87 */
88const renderInline = (source: string, rendered: string) => {
89 const previous = document.createElement("template");
90 previous.innerHTML = rendered;
91 const template = document.createElement("template");
92 template.innerHTML = marked.parseInline(source, {
93 async: false,
94 gfm: true,
95 breaks: true,
96 });
97 for (const node of template.content.querySelectorAll("*")) {
98 const sample = previous.content.querySelector(node.tagName);
99 for (const attr of sample?.attributes ?? []) {
100 if (attr.name !== "href") node.setAttribute(attr.name, attr.value);
101 }
102 }
103 return template.innerHTML;
104};
105
106const markEmpty = (el: HTMLElement) => {
107 if (isMarkdown(el) && !el.dataset.copyEditing) {
108 el.toggleAttribute("data-copy-empty", el.textContent?.trim() === "");
109 }
110};
111
112const makeEditable = (el: HTMLElement) => {
113 markEmpty(el);
114 if (el.isContentEditable) return;
115 // "true", not "plaintext-only": Chrome forces `white-space: pre-wrap` on
116 // plaintext-only elements, which renders source line breaks. Rich-text
117 // input is blocked below instead.
118 el.contentEditable = "true";
119 el.spellcheck = false;
120};
121
122// Content rendered outside Vite transforms (markdown, generated docs) marks
123// its element with a trailing `<!--copy:ID-->` comment instead of an attribute.
124const MARKER = /^copy:(.+)$/;
125const claimMarkers = (root: Node) => {
126 const doc = root.ownerDocument ?? (root as Document);
127 const walker = doc.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
128 const markers: Comment[] = [];
129 if (root.nodeType === Node.COMMENT_NODE) markers.push(root as Comment);
130 while (walker.nextNode()) markers.push(walker.currentNode as Comment);
131 for (const marker of markers) {
132 const id = MARKER.exec(marker.data)?.[1];
133 const parent = marker.parentElement;
134 if (!id || !parent) continue;
135 parent.setAttribute("data-copy", id);
136 marker.remove();
137 }
138};
139
140const enableWithin = (root: ParentNode) => {
141 claimMarkers(root as Node);
142 if (root instanceof HTMLElement && root.matches(SELECTOR)) makeEditable(root);
143 for (const el of root.querySelectorAll<HTMLElement>(SELECTOR))
144 makeEditable(el);
145};
146enableWithin(document);
147
148// Elements rendered later (client routing, framework HMR) become editable too.
149const observer = new MutationObserver((records) => {
150 for (const record of records) {
151 if (record.type === "attributes")
152 enableWithin(record.target as HTMLElement);
153 for (const node of record.addedNodes) {
154 if (node instanceof HTMLElement) enableWithin(node);
155 else if (node.nodeType === Node.COMMENT_NODE) {
156 const parent = node.parentElement;
157 claimMarkers(node);
158 if (parent) enableWithin(parent);
159 }
160 }
161 }
162});
163observer.observe(document.body, {
164 subtree: true,
165 childList: true,
166 attributes: true,
167 attributeFilter: ["data-copy"],
168});
169
170const target = (event: Event) =>
171 event.target instanceof Element
172 ? event.target.closest<HTMLElement>(SELECTOR)
173 : null;
174
175const post = async (url: string, body: object) => {
176 const res = await fetch(url, {
177 method: "POST",
178 headers: { "content-type": "application/json" },
179 body: JSON.stringify(body),
180 }).catch((error: unknown) => ({
181 ok: false,
182 json: async () => ({ error: String(error) }),
183 }));
184 const json = (await res.json().catch(() => ({}))) as {
185 file?: string;
186 changed?: boolean;
187 reload?: boolean;
188 error?: string;
189 };
190 return { ok: res.ok, ...json };
191};
192
193// ── inline text edits ─────────────────────────────────────────────────────
194
195const textNodes = (el: HTMLElement): string[] => {
196 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
197 const out: string[] = [];
198 while (walker.nextNode()) out.push((walker.currentNode as Text).data);
199 return out;
200};
201
202/** Browsers type spaces as non-breaking ones at run edges; keep real ones. */
203const plainSpaces = (after: string[], before: string[]) =>
204 after.map((text, i) =>
205 before[i]?.includes("\u00a0") ? text : text.replace(/\u00a0/g, " "),
206 );
207
208interface Snapshot {
209 html: string;
210 texts: string[];
211}
212const snapshots = new WeakMap<HTMLElement, Snapshot>();
213
214const saveInline = async (el: HTMLElement) => {
215 const snap = snapshots.get(el);
216 if (!snap) return;
217 snapshots.delete(el);
218 const after = plainSpaces(textNodes(el), snap.texts);
219 if (after.join("\u0000") === snap.texts.join("\u0000")) return;
220 const result = await post(SAVE_ENDPOINT, {
221 id: el.dataset.copy,
222 before: snap.texts,
223 after,
224 });
225 if (result.ok) {
226 if (result.changed) notify(`Saved to ${result.file}`);
227 if (result.reload) needsReload();
228 } else {
229 el.innerHTML = snap.html;
230 notify(`Not saved: ${result.error ?? "unknown error"}`, true);
231 }
232};
233
234// ── markdown sections ─────────────────────────────────────────────────────
235
236interface MarkdownState {
237 /** Rendered HTML to restore when leaving without changes. */
238 rendered: string;
239 /** The source the edit started from (sent back to detect staleness). */
240 base?: string;
241 /** Unsaved source kept after a failed save. */
242 draft?: string;
243 /** The page as served when editing started, to tell when it's rebuilt. */
244 servedAtFocus?: Promise<string>;
245}
246const sections = new WeakMap<HTMLElement, MarkdownState>();
247/** Elements that are themselves a single markdown block. */
248const BLOCK_TAGS = new Set([
249 "P",
250 "H1",
251 "H2",
252 "H3",
253 "H4",
254 "H5",
255 "H6",
256 "UL",
257 "OL",
258 "BLOCKQUOTE",
259 "TABLE",
260 "PRE",
261]);
262/** Sections showing a preview until the page's renderer catches up. */
263const previews = new Map<
264 HTMLElement,
265 { staleHtml: string; attempts: number }
266>();
267/** An edit changed the page's structure; reload once the server has it. */
268let reloadPending = false;
269/** The server has it, but an edit was in progress. */
270let reloadWhenIdle = false;
271/** The page as served before a structural edit, to tell when it changed. */
272let servedBefore: Promise<string> | undefined;
273const fetchPage = () =>
274 fetch(location.href, { cache: "no-store" })
275 .then((res) => (res.ok ? res.text() : ""))
276 .catch(() => "");
277/** `before`: the page as served before the edit, when already fetched. */
278const needsReload = (before?: Promise<string>) => {
279 reloadPending = true;
280 servedBefore ??= before ?? fetchPage();
281};
282const busy = () =>
283 document.querySelector("[data-copy-editing], [data-copy-draft]") !== null;
284
285/**
286 * Moves stylesheets out of a section before its content is replaced:
287 * renderers such as Expressive Code load page-wide styles from inside the
288 * first block that needs them.
289 */
290const hoistStyles = (el: HTMLElement) => {
291 for (const node of el.querySelectorAll<HTMLElement>(
292 'link[rel="stylesheet"], style',
293 )) {
294 const href = node.getAttribute("href");
295 const present = href
296 ? document.head.querySelector(
297 `link[rel="stylesheet"][href="${CSS.escape(href)}"]`,
298 )
299 : null;
300 if (!present) document.head.append(node.cloneNode(true));
301 }
302};
303
304/** Normalized text of a code block, for matching rendered and preview code. */
305const codeText = (pre: Element) =>
306 (
307 pre.querySelector("[data-code]")?.getAttribute("data-code") ??
308 (pre as HTMLElement).innerText ??
309 pre.textContent ??
310 ""
311 ).replace(/\s+/g, "");
312
313const renderPreview = (
314 source: string,
315 styleName: string | undefined,
316 rendered?: string,
317) => {
318 const preset = styleName ? styles[styleName] : undefined;
319 let markdown = source;
320 for (const [pattern, replacement] of preset?.rewrite ?? []) {
321 markdown = markdown.replace(new RegExp(pattern, "g"), replacement);
322 }
323 const template = document.createElement("template");
324 template.innerHTML = marked.parse(markdown, { async: false, gfm: true });
325 for (const [tag, attrs] of Object.entries(preset?.elements ?? {})) {
326 const selector = tag === "code" ? "code:not(pre code)" : tag;
327 for (const node of template.content.querySelectorAll(selector)) {
328 for (const [name, value] of Object.entries(attrs ?? {})) {
329 if (name === "class")
330 node.classList.add(...value.split(/\s+/).filter(Boolean));
331 else node.setAttribute(name, value);
332 }
333 }
334 }
335 // Keep unchanged code blocks exactly as the page rendered them
336 // (highlighting, frames, copy buttons) instead of the plain preview.
337 if (rendered) {
338 const previous = document.createElement("template");
339 previous.innerHTML = rendered;
340 const blocks = [...previous.content.children].filter((child) =>
341 child.matches("pre, :has(pre)"),
342 );
343 for (const pre of template.content.querySelectorAll(":scope > pre")) {
344 const text = codeText(pre);
345 const match = blocks.findIndex((block) => codeText(block) === text);
346 if (match === -1) continue;
347 pre.replaceWith(blocks[match]!);
348 blocks.splice(match, 1);
349 }
350 }
351 return template.innerHTML;
352};
353
354/** Text just before the caret, to find the same spot in the source. */
355const caretContext = (el: HTMLElement) => {
356 const selection = getSelection();
357 const node = selection?.anchorNode;
358 if (!node || node.nodeType !== Node.TEXT_NODE || !el.contains(node))
359 return "";
360 return (node as Text).data.slice(0, selection!.anchorOffset);
361};
362
363const placeCaret = (el: HTMLElement, source: string, context: string) => {
364 let at = 0;
365 for (const size of [24, 12, 6]) {
366 const needle = context.slice(-size);
367 const i = needle.trim() ? source.indexOf(needle) : -1;
368 if (i !== -1) {
369 at = i + needle.length;
370 break;
371 }
372 }
373 const node = el.firstChild;
374 if (!node) return;
375 const range = document.createRange();
376 range.setStart(node, Math.min(at, node.textContent?.length ?? 0));
377 range.collapse(true);
378 getSelection()?.removeAllRanges();
379 getSelection()?.addRange(range);
380};
381
382const enterSource = async (el: HTMLElement) => {
383 if (el.dataset.copyEditing) return;
384 const state: MarkdownState = sections.get(el) ?? { rendered: el.innerHTML };
385 sections.set(el, state);
386 el.dataset.copyEditing = "loading";
387
388 if (state.draft === undefined) {
389 const res = await fetch(
390 `${SOURCE_ENDPOINT}?id=${encodeURIComponent(el.dataset.copy ?? "")}`,
391 ).catch(() => undefined);
392 const body = (await res?.json().catch(() => ({}))) as {
393 source?: string;
394 error?: string;
395 };
396 if (!res?.ok || body.source === undefined) {
397 delete el.dataset.copyEditing;
398 notify(`Can't edit: ${body.error ?? "unknown error"}`, true);
399 return;
400 }
401 state.base = body.source;
402 state.rendered = el.innerHTML;
403 state.servedAtFocus = fetchPage();
404 }
405 if (document.activeElement !== el) {
406 delete el.dataset.copyEditing;
407 return;
408 }
409 const context = caretContext(el);
410 const source = state.draft ?? state.base ?? "";
411 hoistStyles(el);
412 el.dataset.copyEditing = "source";
413 el.removeAttribute("data-copy-draft");
414 el.removeAttribute("data-copy-empty");
415 el.textContent = source;
416 placeCaret(el, source, context);
417};
418
419const cancelSource = (el: HTMLElement) => {
420 const state = sections.get(el);
421 if (!state || !el.dataset.copyEditing) return;
422 state.draft = undefined;
423 delete el.dataset.copyEditing;
424 el.removeAttribute("data-copy-draft");
425 el.innerHTML = state.rendered;
426 markEmpty(el);
427};
428
429const leaveSource = async (el: HTMLElement) => {
430 const state = sections.get(el);
431 if (!state || el.dataset.copyEditing !== "source") {
432 if (el.dataset.copyEditing === "loading") delete el.dataset.copyEditing;
433 return;
434 }
435 const source = el.innerText.replace(/\u00a0/g, " ").replace(/\s+$/, "");
436 delete el.dataset.copyEditing;
437 if (source === (state.base ?? "").replace(/\s+$/, "")) {
438 state.draft = undefined;
439 el.innerHTML = state.rendered;
440 markEmpty(el);
441 return;
442 }
443
444 const id = el.dataset.copy ?? "";
445 const staleHtml = state.rendered;
446 const result = await post(SOURCE_ENDPOINT, { id, source, base: state.base });
447 if (!result.ok) {
448 state.draft = source;
449 el.setAttribute("data-copy-draft", "");
450 el.textContent = source;
451 notify(`Not saved: ${result.error ?? "unknown error"}`, true);
452 return;
453 }
454 const preview = isInline(el)
455 ? renderInline(source, staleHtml)
456 : renderPreview(source, el.dataset.copyStyle, staleHtml);
457 if (isInline(el)) {
458 el.innerHTML = preview;
459 } else if (BLOCK_TAGS.has(el.tagName)) {
460 // The section IS one block (e.g. a paragraph): show the preview's
461 // contents in place, or reload if the edit turned it into other blocks.
462 const template = document.createElement("template");
463 template.innerHTML = preview;
464 const [only, ...rest] = [...template.content.children];
465 if (only?.tagName === el.tagName && rest.length === 0) {
466 el.innerHTML = only.innerHTML;
467 } else {
468 // Now several blocks (e.g. a paragraph and a list): show them in its
469 // place, then reload once the page is rebuilt so every block gets an id.
470 notify(`Saved to ${result.file}`);
471 el.replaceWith(template.content);
472 needsReload(state.servedAtFocus);
473 return;
474 }
475 } else {
476 el.innerHTML = preview;
477 }
478 state.draft = undefined;
479 state.base = source;
480 state.rendered = el.innerHTML;
481 markEmpty(el);
482 if (result.changed) {
483 notify(`Saved to ${result.file}`);
484 previews.set(el, { staleHtml, attempts: 0 });
485 }
486 if (result.reload) needsReload();
487};
488
489/**
490 * Swaps previews for the page's own rendering once the server has caught
491 * up, or reloads when an edit changed the page's structure.
492 */
493const refresh = async () => {
494 if (reloadPending) {
495 // Wait until the server serves the changed page (its watchers may lag
496 // behind the regeneration that triggered this refresh).
497 const before = await servedBefore;
498 for (let attempt = 0; attempt < 10; attempt++) {
499 if ((await fetchPage()) !== before) break;
500 await new Promise((resolve) => setTimeout(resolve, 1000));
501 }
502 if (busy()) reloadWhenIdle = true;
503 else location.reload();
504 return;
505 }
506 const res = await fetch(location.href, { cache: "no-store" }).catch(
507 () => undefined,
508 );
509 if (!res?.ok) return;
510 const fresh = new DOMParser().parseFromString(await res.text(), "text/html");
511 claimMarkers(fresh.body);
512 // Pair editable elements by position: ids encode source locations, which
513 // shift when an edit changes the length of the text before them.
514 const current = [...document.querySelectorAll<HTMLElement>(SELECTOR)];
515 const next = [...fresh.querySelectorAll<HTMLElement>(SELECTOR)];
516 if (current.length !== next.length) {
517 // The edit added or removed blocks.
518 if (busy()) reloadWhenIdle = true;
519 else location.reload();
520 return;
521 }
522 let retry = false;
523 current.forEach((el, i) => {
524 const incoming = next[i]!;
525 if (el.dataset.copyEditing) return;
526 if (el.dataset.copy !== incoming.dataset.copy) {
527 el.dataset.copy = incoming.dataset.copy;
528 }
529 const preview = previews.get(el);
530 if (!preview) return;
531 if (incoming.innerHTML === preview.staleHtml && preview.attempts < 5) {
532 // The server hasn't picked up the edited source yet.
533 preview.attempts++;
534 retry = true;
535 return;
536 }
537 el.innerHTML = incoming.innerHTML;
538 sections.set(el, { rendered: el.innerHTML });
539 markEmpty(el);
540 previews.delete(el);
541 });
542 if (retry) setTimeout(refresh, 1000);
543};
544
545// ── events ────────────────────────────────────────────────────────────────
546
547listen("focusin", (event) => {
548 const el = target(event);
549 if (!el) return;
550 if (isMarkdown(el)) void enterSource(el);
551 else snapshots.set(el, { html: el.innerHTML, texts: textNodes(el) });
552});
553
554listen("keydown", (event) => {
555 const el = target(event);
556 if (!el) return;
557 if (isMarkdown(el)) {
558 if (event.key === "Escape") {
559 cancelSource(el);
560 el.blur();
561 } else if (event.key === "Enter") {
562 event.preventDefault();
563 // Markdown sections: Enter adds a line, Ctrl/Cmd+Enter saves.
564 // One-line copy: Enter saves, Shift+Enter adds a line break (<br>).
565 const save =
566 event.metaKey || event.ctrlKey || (isInline(el) && !event.shiftKey);
567 if (save) el.blur();
568 else document.execCommand("insertLineBreak");
569 } else if (event.key === "Tab") {
570 event.preventDefault();
571 document.execCommand("insertText", false, " ");
572 }
573 return;
574 }
575 if (event.key === "Enter" && !event.shiftKey) {
576 event.preventDefault();
577 el.blur();
578 } else if (event.key === "Escape") {
579 const snap = snapshots.get(el);
580 if (snap) el.innerHTML = snap.html;
581 el.blur();
582 }
583});
584
585// Clicking copy inside a link or button places the caret; Ctrl/Cmd+click
586// still follows the link.
587listen(
588 "click",
589 (event) => {
590 if (!target(event) || event.ctrlKey || event.metaKey) return;
591 if ((event.target as Element).closest("a, button")) event.preventDefault();
592 },
593 true,
594);
595
596// Editable elements are plain text: no bold/italic shortcuts or rich drops.
597listen("beforeinput", (event) => {
598 if (!target(event)) return;
599 if (
600 event.inputType.startsWith("format") ||
601 event.inputType === "insertFromDrop"
602 ) {
603 event.preventDefault();
604 }
605});
606
607// Paste as plain text.
608listen("paste", (event) => {
609 if (!target(event) || !event.clipboardData) return;
610 event.preventDefault();
611 document.execCommand(
612 "insertText",
613 false,
614 event.clipboardData.getData("text/plain"),
615 );
616});
617
618listen("focusout", async (event) => {
619 const el = target(event);
620 if (!el) return;
621 if (isMarkdown(el)) await leaveSource(el);
622 else await saveInline(el);
623 if (reloadWhenIdle && !busy()) location.reload();
624});
625
626// ── hot reload ────────────────────────────────────────────────────────────
627
628if (import.meta.hot) {
629 import.meta.hot.on(REFRESH_EVENT, () => void refresh());
630 // Editing this file hot-swaps the editor without a page reload.
631 import.meta.hot.accept();
632 import.meta.hot.dispose(() => {
633 controller.abort();
634 observer.disconnect();
635 clearTimeout(toastTimer);
636 style.remove();
637 toast.remove();
638 });
639}