| 1 | import { promises as fs } from "node:fs"; |
| 2 | import type { |
| 3 | IncomingMessage, |
| 4 | OutgoingHttpHeaders, |
| 5 | ServerResponse, |
| 6 | } from "node:http"; |
| 7 | import path from "node:path"; |
| 8 | import { fileURLToPath } from "node:url"; |
| 9 | import type { Plugin, ResolvedConfig, ViteDevServer } from "vite"; |
| 10 | import { |
| 11 | annotate, |
| 12 | applyEdit, |
| 13 | ATTR, |
| 14 | inlineSource, |
| 15 | strip, |
| 16 | writeInline, |
| 17 | } from "./source.ts"; |
| 18 | import { CopyEditError, type TextEdit } from "./text.ts"; |
| 19 | |
| 20 | export { CopyEditError, applyRuns, htmlDialect, normalize } from "./text.ts"; |
| 21 | export type { |
| 22 | Dialect, |
| 23 | EscapeContext, |
| 24 | Run, |
| 25 | RunKind, |
| 26 | TextEdit, |
| 27 | TextToken, |
| 28 | } from "./text.ts"; |
| 29 | export { markdownDialect, markdownRuns } from "./markdown.ts"; |
| 30 | export type { Atomic, MarkdownOptions } from "./markdown.ts"; |
| 31 | export { |
| 32 | MARKDOWN_FILE_SCHEME, |
| 33 | markdownBlocks, |
| 34 | markdownFiles, |
| 35 | } from "./markdown-files.ts"; |
| 36 | export type { MarkdownFilesOptions } from "./markdown-files.ts"; |
| 37 | |
| 38 | const CLIENT_ID = "virtual:copy-editor/client"; |
| 39 | const CONFIG_ID = "virtual:copy-editor/config"; |
| 40 | const RESOLVED_CONFIG_ID = `\0${CONFIG_ID}`; |
| 41 | const SAVE_ENDPOINT = "/__copy-editor/save"; |
| 42 | const SOURCE_ENDPOINT = "/__copy-editor/source"; |
| 43 | /** Sent once a handler's follow-up work settles, so previews can refresh. */ |
| 44 | const REFRESH_EVENT = "copy-editor:refresh"; |
| 45 | /** Time for watchers (e.g. a content layer) to pick up regenerated files. */ |
| 46 | const REFRESH_DELAY_MS = 1500; |
| 47 | // Resolves to the TypeScript source under bun and the compiled file otherwise. |
| 48 | const 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. */ |
| 56 | const RELOAD_SUPPRESS_MS = 1500; |
| 57 | |
| 58 | export 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 | |
| 70 | export 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 | */ |
| 85 | export 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. */ |
| 104 | export 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 | */ |
| 135 | export 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 | |
| 147 | export 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 | */ |
| 183 | export 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 | |
| 420 | const 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 | */ |
| 432 | const 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. */ |
| 478 | const 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 | }; |