1import { promises as fs } from "node:fs";
2import type {
3 IncomingMessage,
4 OutgoingHttpHeaders,
5 ServerResponse,
6} from "node:http";
7import path from "node:path";
8import { fileURLToPath } from "node:url";
9import type { Plugin, ResolvedConfig, ViteDevServer } from "vite";
10import {
11 annotate,
12 applyEdit,
13 ATTR,
14 inlineSource,
15 strip,
16 writeInline,
17} from "./source.ts";
18import { CopyEditError, type TextEdit } from "./text.ts";
19
20export { CopyEditError, applyRuns, htmlDialect, normalize } from "./text.ts";
21export type {
22 Dialect,
23 EscapeContext,
24 Run,
25 RunKind,
26 TextEdit,
27 TextToken,
28} from "./text.ts";
29export { markdownDialect, markdownRuns } from "./markdown.ts";
30export type { Atomic, MarkdownOptions } from "./markdown.ts";
31export {
32 MARKDOWN_FILE_SCHEME,
33 markdownBlocks,
34 markdownFiles,
35} from "./markdown-files.ts";
36export type { MarkdownFilesOptions } from "./markdown-files.ts";
37
38const CLIENT_ID = "virtual:copy-editor/client";
39const CONFIG_ID = "virtual:copy-editor/config";
40const RESOLVED_CONFIG_ID = `\0${CONFIG_ID}`;
41const SAVE_ENDPOINT = "/__copy-editor/save";
42const SOURCE_ENDPOINT = "/__copy-editor/source";
43/** Sent once a handler's follow-up work settles, so previews can refresh. */
44const REFRESH_EVENT = "copy-editor:refresh";
45/** Time for watchers (e.g. a content layer) to pick up regenerated files. */
46const REFRESH_DELAY_MS = 1500;
47// Resolves to the TypeScript source under bun and the compiled file otherwise.
48const CLIENT_FILE = fileURLToPath(
49 new URL(
50 import.meta.url.endsWith(".ts") ? "./client.ts" : "./client.js",
51 import.meta.url,
52 ),
53);
54
55/** How long after a save to swallow the full-page reload it triggers. */
56const RELOAD_SUPPRESS_MS = 1500;
57
58export interface CopyEditContext {
59 /** The Vite project root. */
60 root: string;
61 logger: ResolvedConfig["logger"];
62 /**
63 * Swallows full-page reloads until `work` settles (plus a short grace
64 * period), for follow-up work such as regenerating derived files whose
65 * content the page already shows.
66 */
67 suppressReloads(work: Promise<unknown>): void;
68}
69
70export interface SaveResult {
71 /** Display path of the file that was written. */
72 file: string;
73 changed: boolean;
74 /**
75 * The edit changed the page's structure (e.g. added a section), so other
76 * editable ids on the page are stale: reload once follow-up work settles.
77 */
78 reload?: boolean;
79}
80
81/**
82 * Saves edits for ids of the form `<scheme>:<rest>`. Throw a
83 * {@link CopyEditError} to reject an edit with a message for the browser.
84 */
85export interface CopyEditHandler {
86 /** Saves an inline text edit to a `data-copy` element. */
87 edit?(
88 edit: TextEdit & { id: string },
89 context: CopyEditContext,
90 ): Promise<SaveResult>;
91 /** Returns the markdown behind a `data-copy-format="markdown"` section. */
92 readSource?(id: string, context: CopyEditContext): Promise<string>;
93 /**
94 * Replaces the markdown behind a section. `base` is the source the editor
95 * started from; reject with a 409 {@link CopyEditError} if it's stale.
96 */
97 writeSource?(
98 edit: { id: string; source: string; base: string },
99 context: CopyEditContext,
100 ): Promise<SaveResult>;
101}
102
103/** Elements a markdown preview can style, keyed like CSS selectors. */
104export type MarkdownElement =
105 | "p"
106 | "h1"
107 | "h2"
108 | "h3"
109 | "h4"
110 | "h5"
111 | "h6"
112 | "a"
113 | "strong"
114 | "em"
115 | "del"
116 /** Inline code (not inside a code block). */
117 | "code"
118 /** Code blocks. */
119 | "pre"
120 | "ul"
121 | "ol"
122 | "li"
123 | "blockquote"
124 | "hr"
125 | "img"
126 | "table"
127 | "th"
128 | "td";
129
130/**
131 * How a markdown section renders its preview after an edit, until the page's
132 * own renderer catches up. A section picks a style with
133 * `data-copy-style="<name>"`.
134 */
135export interface MarkdownStyle {
136 /** Attributes set on rendered elements; `class` values are appended. */
137 elements?: Partial<Record<MarkdownElement, Record<string, string>>>;
138 /** CSS added to the page in dev (scope it with `[data-copy-style="<name>"]`). */
139 css?: string;
140 /**
141 * Regex rewrites applied to the markdown before previewing, for syntax the
142 * page's renderer understands but plain markdown doesn't.
143 */
144 rewrite?: Array<[pattern: string, replacement: string]>;
145}
146
147export interface CopyEditorOptions {
148 /**
149 * Source files that may contain `data-copy` elements.
150 * @default /\.(astro|html|jsx|tsx|vue|svelte)$/
151 */
152 include?: RegExp;
153 /**
154 * Handlers for copy that isn't a `data-copy` element in a template, keyed
155 * by id scheme. Content rendered without Vite transforms (markdown, a CMS)
156 * marks its element with a trailing `<!--copy:<scheme>:<rest>-->` comment,
157 * and edits to it are saved by `handlers[<scheme>]`.
158 */
159 handlers?: Record<string, CopyEditHandler>;
160 /** Named preview styles for markdown sections. */
161 markdownStyles?: Record<string, MarkdownStyle>;
162}
163
164/**
165 * Inline copy editing for any Vite app.
166 *
167 * Mark a hardcoded piece of copy with a bare attribute:
168 *
169 * ```html
170 * <h1 data-copy>Reach for the better primitives.</h1>
171 * ```
172 *
173 * - **Dev** (`vite serve`): every `data-copy` element is editable in the
174 * browser. Each edit is written back to the source file and flows through
175 * the normal HMR pipeline.
176 * - **Build**: the attribute is stripped and no editor code ships.
177 *
178 * The element must contain only text and plain HTML tags (no expressions or
179 * components). Register the plugin before framework plugins so it sees the
180 * raw source. Copy from other sources (markdown, generated docs) plugs in
181 * through {@link CopyEditorOptions.handlers}.
182 */
183export const copyEditor = (options: CopyEditorOptions = {}): Plugin => {
184 const include = options.include ?? /\.(astro|html|jsx|tsx|vue|svelte)$/;
185 let config: ResolvedConfig;
186 let dev = false;
187
188 const fileOf = (id: string) => id.split("?", 1)[0];
189 const matches = (id: string) =>
190 !id.startsWith("\0") &&
191 !id.includes("/node_modules/") &&
192 include.test(fileOf(id)) &&
193 !/[?&](raw|url)\b/.test(id);
194 const relative = (file: string) =>
195 path.relative(config.root, file).split(path.sep).join("/");
196
197 const rewrite = (code: string, file: string) =>
198 dev ? annotate(code, relative(file)) : strip(code);
199
200 return {
201 name: "copy-editor",
202 enforce: "pre",
203
204 configResolved(resolved) {
205 config = resolved;
206 dev = resolved.command === "serve";
207 },
208
209 resolveId(id) {
210 if (dev && id === CLIENT_ID) return CLIENT_FILE;
211 if (dev && id === CONFIG_ID) return RESOLVED_CONFIG_ID;
212 },
213
214 load(id) {
215 if (id === RESOLVED_CONFIG_ID) {
216 return `export default ${JSON.stringify({ styles: options.markdownStyles ?? {} })};`;
217 }
218 },
219
220 transform(code, id) {
221 if (!code.includes(ATTR) || !matches(id)) return;
222 return { code: rewrite(code, fileOf(id)), map: null };
223 },
224
225 transformIndexHtml: {
226 order: "pre",
227 handler(html, ctx) {
228 if (!html.includes(ATTR)) return;
229 return rewrite(html, ctx.filename);
230 },
231 },
232
233 configureServer(server) {
234 let suppressReloadUntil = 0;
235 let pendingWork = 0;
236 suppressFullReloads(
237 server,
238 () => pendingWork > 0 || Date.now() < suppressReloadUntil,
239 );
240 const context: CopyEditContext = {
241 root: config.root,
242 logger: config.logger,
243 suppressReloads(work) {
244 pendingWork++;
245 work
246 .catch(() => {})
247 .finally(() => {
248 pendingWork--;
249 suppressReloadUntil = Math.max(
250 suppressReloadUntil,
251 Date.now() + REFRESH_DELAY_MS + RELOAD_SUPPRESS_MS,
252 );
253 // One refresh once everything saved so far has been processed.
254 if (pendingWork > 0) return;
255 setTimeout(() => {
256 if (pendingWork === 0) {
257 server.ws.send({ type: "custom", event: REFRESH_EVENT });
258 }
259 }, REFRESH_DELAY_MS);
260 });
261 },
262 };
263 const handlerFor = (id: string) => {
264 const scheme = /^([a-z][\w-]*):/.exec(id)?.[1];
265 return scheme ? options.handlers?.[scheme] : undefined;
266 };
267 const respond = (res: ServerResponse, work: () => Promise<object>) =>
268 work().then(
269 (body) => {
270 res.statusCode = 200;
271 res.setHeader("content-type", "application/json");
272 res.end(JSON.stringify(body));
273 },
274 (error: unknown) => {
275 const status = error instanceof CopyEditError ? error.status : 500;
276 const message =
277 error instanceof Error ? error.message : String(error);
278 config.logger.warn(`copy-editor: ${message}`, { timestamp: true });
279 res.statusCode = status;
280 res.setHeader("content-type", "application/json");
281 res.end(JSON.stringify({ error: message }));
282 },
283 );
284 const logSave = (result: SaveResult) => {
285 if (result.changed) {
286 config.logger.info(`copy-editor: saved ${result.file}`, {
287 timestamp: true,
288 });
289 }
290 return result;
291 };
292
293 /** Resolves a template element id (`<file>#<n>`) to its file. */
294 const templateElement = (id: string) => {
295 const hash = id.lastIndexOf("#");
296 const rel = id.slice(0, hash);
297 const index = Number(id.slice(hash + 1));
298 const file = path.resolve(config.root, rel);
299 if (
300 hash === -1 ||
301 !Number.isInteger(index) ||
302 !file.startsWith(path.resolve(config.root) + path.sep) ||
303 !matches(file)
304 ) {
305 throw new CopyEditError(`Invalid copy id: ${id}`);
306 }
307 return { rel, index, file };
308 };
309
310 server.middlewares.use(SOURCE_ENDPOINT, (req, res) =>
311 respond(res, async () => {
312 if (req.method === "GET") {
313 const id =
314 new URL(req.url ?? "", "http://x").searchParams.get("id") ?? "";
315 const handler = handlerFor(id);
316 if (!handler) {
317 const { index, file } = templateElement(id);
318 const code = await fs.readFile(file, "utf8");
319 return { source: inlineSource(code, index).markdown };
320 }
321 if (!handler.readSource) {
322 throw new CopyEditError(`No markdown source for ${id}`);
323 }
324 return { source: await handler.readSource(id, context) };
325 }
326 if (req.method !== "POST")
327 throw new CopyEditError("GET or POST only", 405);
328 const edit = JSON.parse(await readBody(req)) as {
329 id: string;
330 source: string;
331 base: string;
332 };
333 const handler = handlerFor(edit.id);
334 if (!handler) {
335 const { rel, index, file } = templateElement(edit.id);
336 const code = await fs.readFile(file, "utf8");
337 const next = writeInline(code, index, {
338 markdown: edit.source,
339 base: edit.base,
340 });
341 if (next === code) return { file: rel, changed: false };
342 // The page shows the preview; skip the full reload it triggers.
343 suppressReloadUntil = Date.now() + RELOAD_SUPPRESS_MS;
344 await fs.writeFile(file, next);
345 return logSave({ file: rel, changed: true });
346 }
347 if (!handler.writeSource) {
348 throw new CopyEditError(`No markdown source for ${edit.id}`);
349 }
350 suppressReloadUntil = Date.now() + RELOAD_SUPPRESS_MS;
351 return logSave(await handler.writeSource(edit, context));
352 }),
353 );
354
355 const clientTag = `<script type="module" src="${config.base}@id/${CLIENT_ID}"></script>`;
356 server.middlewares.use((_req, res, next) => {
357 injectIntoHtml(res, clientTag);
358 next();
359 });
360
361 server.middlewares.use(SAVE_ENDPOINT, async (req, res) => {
362 const reply = (status: number, body: object) => {
363 res.statusCode = status;
364 res.setHeader("content-type", "application/json");
365 res.end(JSON.stringify(body));
366 };
367 if (req.method !== "POST") return reply(405, { error: "POST only" });
368 try {
369 const { id, before, after } = JSON.parse(await readBody(req)) as {
370 id: string;
371 before: string[];
372 after: string[];
373 };
374 const handler = handlerFor(id);
375 if (handler) {
376 if (!handler.edit)
377 throw new CopyEditError(`${id} can't be edited inline`);
378 suppressReloadUntil = Date.now() + RELOAD_SUPPRESS_MS;
379 return reply(
380 200,
381 logSave(await handler.edit({ id, before, after }, context)),
382 );
383 }
384 const hash = id.lastIndexOf("#");
385 const rel = id.slice(0, hash);
386 const index = Number(id.slice(hash + 1));
387 const file = path.resolve(config.root, rel);
388 if (
389 hash === -1 ||
390 !Number.isInteger(index) ||
391 !file.startsWith(path.resolve(config.root) + path.sep) ||
392 !matches(file)
393 ) {
394 throw new CopyEditError(`Invalid copy id: ${id}`);
395 }
396 const code = await fs.readFile(file, "utf8");
397 const next = applyEdit(code, { index, before, after });
398 if (next !== code) {
399 // The DOM already shows the edit, so a full-page reload would only
400 // cost the editor their place. Module-level HMR still applies.
401 suppressReloadUntil = Date.now() + RELOAD_SUPPRESS_MS;
402 await fs.writeFile(file, next);
403 config.logger.info(`copy-editor: saved ${rel}`, {
404 timestamp: true,
405 });
406 }
407 reply(200, { file: rel, changed: next !== code });
408 } catch (error) {
409 const status = error instanceof CopyEditError ? error.status : 500;
410 const message =
411 error instanceof Error ? error.message : String(error);
412 config.logger.warn(`copy-editor: ${message}`, { timestamp: true });
413 reply(status, { error: message });
414 }
415 });
416 },
417 };
418};
419
420const readBody = (req: IncomingMessage) =>
421 new Promise<string>((resolve, reject) => {
422 let body = "";
423 req.on("data", (chunk) => (body += chunk));
424 req.on("end", () => resolve(body));
425 req.on("error", reject);
426 });
427
428/**
429 * Appends `tag` to any HTML response, whichever middleware produces it
430 * (Vite's index.html handler or a framework's SSR handler).
431 */
432const injectIntoHtml = (res: ServerResponse, tag: string) => {
433 let html: boolean | undefined;
434 const isHtml = () =>
435 (html ??= String(res.getHeader("content-type") ?? "").includes(
436 "text/html",
437 ));
438
439 const writeHead = res.writeHead;
440 res.writeHead = function (this: ServerResponse, ...args: unknown[]) {
441 const headers = args.find(
442 (a): a is OutgoingHttpHeaders =>
443 typeof a === "object" && a !== null && !Array.isArray(a),
444 );
445 if (headers) {
446 for (const key of Object.keys(headers)) {
447 const lower = key.toLowerCase();
448 if (lower === "content-type")
449 html = String(headers[key]).includes("text/html");
450 }
451 if (isHtml()) {
452 for (const key of Object.keys(headers)) {
453 if (key.toLowerCase() === "content-length") delete headers[key];
454 }
455 }
456 }
457 if (isHtml()) res.removeHeader("content-length");
458 return (writeHead as (...a: unknown[]) => ServerResponse).apply(this, args);
459 } as typeof res.writeHead;
460
461 const end = res.end;
462 res.end = function (this: ServerResponse, ...args: unknown[]) {
463 if (!isHtml())
464 return (end as (...a: unknown[]) => ServerResponse).apply(this, args);
465 if (!res.headersSent) res.removeHeader("content-length");
466 const cb =
467 typeof args[args.length - 1] === "function"
468 ? (args.pop() as () => void)
469 : undefined;
470 const [chunk, encoding] = args as [unknown, BufferEncoding | undefined];
471 if (chunk != null) res.write(chunk, encoding as BufferEncoding);
472 res.write(tag);
473 return (end as (...a: unknown[]) => ServerResponse).call(this, cb);
474 } as typeof res.end;
475};
476
477/** Drops `full-reload` HMR messages while `active()` is true. */
478const suppressFullReloads = (server: ViteDevServer, active: () => boolean) => {
479 const channels = new Set([server.ws, server.environments.client.hot]);
480 for (const channel of channels) {
481 const send = channel.send.bind(channel) as (...args: unknown[]) => void;
482 channel.send = ((...args: unknown[]) => {
483 const payload = args[0] as { type?: string } | undefined;
484 if (active() && payload?.type === "full-reload") return;
485 send(...args);
486 }) as typeof channel.send;
487 }
488};