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 */
11import { createHash } from "node:crypto";
12import { promises as fs } from "node:fs";
13import path from "node:path";
14import { fileURLToPath } from "node:url";
15import type { CopyEditHandler } from "./index.ts";
16import { CopyEditError } from "./text.ts";
17
18export const MARKDOWN_FILE_SCHEME = "md";
19
20/** Blocks edited as markdown, as a whole. */
21const 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). */
30const CONTAINERS = new Set(["containerDirective", "mdxJsxFlowElement"]);
31
32export 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
43interface Point {
44 offset?: number;
45}
46interface MdNode {
47 type: string;
48 children?: MdNode[];
49 data?: Record<string, unknown>;
50 position?: { start: Point; end: Point };
51}
52interface MdContext {
53 setProperty(node: MdNode, key: "data", value: Record<string, unknown>): void;
54 wrapNode(node: MdNode, parent: MdNode): void;
55}
56
57const 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 */
64const 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
70const 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 */
78export 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
134const 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. */
142interface Step {
143 at: number;
144 before: number;
145 after: number;
146 /** Version of the file after the edit. */
147 next: string;
148}
149
150const 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}. */
157export 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};