| 1 | /** |
| 2 | * Copy editing for hand-written markdown files (`.md`, `.mdx`). |
| 3 | * |
| 4 | * {@link markdownBlocks} is a Sätteri mdast plugin (Astro 7's markdown |
| 5 | * processor) that tags each markdown block with its source range: |
| 6 | * `data-copy="md:<file>@<start>-<end>~<version>"`, where `version` hashes the |
| 7 | * text the page was rendered from. {@link markdownFiles} reads and writes |
| 8 | * those ranges, following offsets from older versions through the edits it |
| 9 | * has made since, so a page stays editable without reloading after each save. |
| 10 | */ |
| 11 | import { createHash } from "node:crypto"; |
| 12 | import { promises as fs } from "node:fs"; |
| 13 | import path from "node:path"; |
| 14 | import { fileURLToPath } from "node:url"; |
| 15 | import type { CopyEditHandler } from "./index.ts"; |
| 16 | import { CopyEditError } from "./text.ts"; |
| 17 | |
| 18 | export const MARKDOWN_FILE_SCHEME = "md"; |
| 19 | |
| 20 | /** Blocks edited as markdown, as a whole. */ |
| 21 | const BLOCKS = new Set([ |
| 22 | "paragraph", |
| 23 | "heading", |
| 24 | "list", |
| 25 | "blockquote", |
| 26 | "table", |
| 27 | "code", |
| 28 | ]); |
| 29 | /** Containers whose children are markdown blocks (asides, JSX components). */ |
| 30 | const CONTAINERS = new Set(["containerDirective", "mdxJsxFlowElement"]); |
| 31 | |
| 32 | export interface MarkdownFilesOptions { |
| 33 | /** Directory ids are relative to (usually the Vite root). */ |
| 34 | root: string; |
| 35 | /** Files to mark. @default /\.mdx?$/ */ |
| 36 | include?: RegExp; |
| 37 | /** Files to skip, e.g. generated pages. */ |
| 38 | exclude?: RegExp; |
| 39 | /** `data-copy-style` preset for the blocks. */ |
| 40 | style?: string; |
| 41 | } |
| 42 | |
| 43 | interface Point { |
| 44 | offset?: number; |
| 45 | } |
| 46 | interface MdNode { |
| 47 | type: string; |
| 48 | children?: MdNode[]; |
| 49 | data?: Record<string, unknown>; |
| 50 | position?: { start: Point; end: Point }; |
| 51 | } |
| 52 | interface MdContext { |
| 53 | setProperty(node: MdNode, key: "data", value: Record<string, unknown>): void; |
| 54 | wrapNode(node: MdNode, parent: MdNode): void; |
| 55 | } |
| 56 | |
| 57 | const toPosix = (p: string) => p.split(path.sep).join("/"); |
| 58 | |
| 59 | /** |
| 60 | * Hash of a document's text after its frontmatter. Renderers see frontmatter |
| 61 | * blanked to whitespace (so offsets stay file offsets); hashing only what |
| 62 | * follows it gives both sides the same version. |
| 63 | */ |
| 64 | const version = (text: string) => |
| 65 | createHash("sha1") |
| 66 | .update(text.replace(/^\s*---\r?\n[\s\S]*?\r?\n---/, "").trim()) |
| 67 | .digest("hex") |
| 68 | .slice(0, 12); |
| 69 | |
| 70 | const matches = (file: string, options: MarkdownFilesOptions) => |
| 71 | (options.include ?? /\.mdx?$/).test(file) && !options.exclude?.test(file); |
| 72 | |
| 73 | /** |
| 74 | * A Sätteri mdast plugin entry that marks markdown blocks for editing. Put it |
| 75 | * first in `markdown.processor.options.mdastPlugins`, in dev only, so it sees |
| 76 | * blocks before other plugins (asides, etc.) rewrite them. |
| 77 | */ |
| 78 | export const markdownBlocks = |
| 79 | (options: MarkdownFilesOptions) => |
| 80 | ({ fileURL, source }: { fileURL: URL | undefined; source: string }) => { |
| 81 | if (!fileURL || fileURL.protocol !== "file:") return undefined; |
| 82 | const file = fileURLToPath(fileURL); |
| 83 | const rel = toPosix(path.relative(options.root, file)); |
| 84 | if (rel.startsWith("..") || !matches(file, options)) return undefined; |
| 85 | const v = version(source); |
| 86 | return { |
| 87 | name: "copy-editor-markdown-blocks", |
| 88 | options: { position: true }, |
| 89 | before(root: MdNode, ctx: MdContext) { |
| 90 | const walk = (parent: MdNode) => { |
| 91 | for (const node of parent.children ?? []) { |
| 92 | if (CONTAINERS.has(node.type)) { |
| 93 | walk(node); |
| 94 | continue; |
| 95 | } |
| 96 | const start = node.position?.start.offset; |
| 97 | const end = node.position?.end.offset; |
| 98 | if ( |
| 99 | !BLOCKS.has(node.type) || |
| 100 | start === undefined || |
| 101 | end === undefined |
| 102 | ) { |
| 103 | continue; |
| 104 | } |
| 105 | const attributes = { |
| 106 | "data-copy": `${MARKDOWN_FILE_SCHEME}:${rel}@${start}-${end}~${v}`, |
| 107 | "data-copy-format": "markdown", |
| 108 | ...(options.style ? { "data-copy-style": options.style } : {}), |
| 109 | }; |
| 110 | if (node.type === "code") { |
| 111 | // Code highlighters replace the <pre>, so mark a wrapper. |
| 112 | ctx.wrapNode(node, { |
| 113 | type: "copyEditorBlock", |
| 114 | data: { hName: "div", hProperties: attributes }, |
| 115 | children: [], |
| 116 | }); |
| 117 | continue; |
| 118 | } |
| 119 | const data = node.data ?? {}; |
| 120 | ctx.setProperty(node, "data", { |
| 121 | ...data, |
| 122 | hProperties: { |
| 123 | ...(data.hProperties as Record<string, unknown> | undefined), |
| 124 | ...attributes, |
| 125 | }, |
| 126 | }); |
| 127 | } |
| 128 | }; |
| 129 | walk(root); |
| 130 | }, |
| 131 | }; |
| 132 | }; |
| 133 | |
| 134 | const parseId = (id: string) => { |
| 135 | const m = /^md:(.+)@(\d+)-(\d+)~([0-9a-f]+)$/.exec(id); |
| 136 | return m |
| 137 | ? { file: m[1]!, start: Number(m[2]), end: Number(m[3]), version: m[4]! } |
| 138 | : undefined; |
| 139 | }; |
| 140 | |
| 141 | /** One saved edit: the `before` characters at `at` became `after` characters. */ |
| 142 | interface Step { |
| 143 | at: number; |
| 144 | before: number; |
| 145 | after: number; |
| 146 | /** Version of the file after the edit. */ |
| 147 | next: string; |
| 148 | } |
| 149 | |
| 150 | const outdated = () => |
| 151 | new CopyEditError( |
| 152 | "This page is out of date with its file (it changed elsewhere). Reload and try again.", |
| 153 | 409, |
| 154 | ); |
| 155 | |
| 156 | /** Reads and writes the markdown behind blocks marked by {@link markdownBlocks}. */ |
| 157 | export const markdownFiles = ( |
| 158 | options: MarkdownFilesOptions, |
| 159 | ): CopyEditHandler => { |
| 160 | /** Per file, the edit saved from each version, to follow old offsets. */ |
| 161 | const history = new Map<string, Map<string, Step>>(); |
| 162 | |
| 163 | const locate = async (id: string) => { |
| 164 | const parsed = parseId(id); |
| 165 | const file = parsed && path.resolve(options.root, parsed.file); |
| 166 | if ( |
| 167 | !parsed || |
| 168 | !file || |
| 169 | !file.startsWith(path.resolve(options.root) + path.sep) || |
| 170 | !matches(file, options) |
| 171 | ) { |
| 172 | throw new CopyEditError(`Invalid markdown copy id: ${id}`); |
| 173 | } |
| 174 | const code = await fs.readFile(file, "utf8"); |
| 175 | |
| 176 | let { start, end } = parsed; |
| 177 | const current = version(code); |
| 178 | const steps = history.get(file); |
| 179 | for (let v = parsed.version; v !== current;) { |
| 180 | const step = steps?.get(v); |
| 181 | if (!step) throw outdated(); |
| 182 | if (start === step.at && end === step.at + step.before) { |
| 183 | end = step.at + step.after; |
| 184 | } else if (start >= step.at + step.before) { |
| 185 | start += step.after - step.before; |
| 186 | end += step.after - step.before; |
| 187 | } else if (end > step.at) { |
| 188 | throw outdated(); |
| 189 | } |
| 190 | v = step.next; |
| 191 | } |
| 192 | if (end > code.length) throw outdated(); |
| 193 | return { rel: parsed.file, file, code, start, end, current }; |
| 194 | }; |
| 195 | |
| 196 | return { |
| 197 | async readSource(id) { |
| 198 | const { code, start, end } = await locate(id); |
| 199 | return code.slice(start, end); |
| 200 | }, |
| 201 | |
| 202 | async writeSource({ id, source, base }, context) { |
| 203 | const { rel, file, code, start, end, current } = await locate(id); |
| 204 | if (code.slice(start, end) !== base) { |
| 205 | throw new CopyEditError( |
| 206 | "This text changed since you started editing. Copy your text, reload, and try again.", |
| 207 | 409, |
| 208 | ); |
| 209 | } |
| 210 | const next = code.slice(0, start) + source + code.slice(end); |
| 211 | if (next === code) return { file: rel, changed: false }; |
| 212 | await fs.writeFile(file, next); |
| 213 | const steps = history.get(file) ?? new Map<string, Step>(); |
| 214 | steps.set(current, { |
| 215 | at: start, |
| 216 | before: end - start, |
| 217 | after: source.length, |
| 218 | next: version(next), |
| 219 | }); |
| 220 | history.set(file, steps); |
| 221 | // Lets the page's own rendering replace the preview once it's rebuilt. |
| 222 | context.suppressReloads(Promise.resolve()); |
| 223 | return { file: rel, changed: true }; |
| 224 | }, |
| 225 | }; |
| 226 | }; |