1import {
2 applyRuns,
3 CopyEditError,
4 markdownDialect,
5 markdownRuns,
6 type CopyEditHandler,
7 type MarkdownOptions,
8 type MarkdownStyle,
9} from "@alchemy.run/vite-plugin-copy-editor";
10import { spawn } from "node:child_process";
11import { promises as fs } from "node:fs";
12import path from "node:path";
13import { fileURLToPath } from "node:url";
14import {
15 docCommentLines,
16 docTargets,
17 JSDOC_LINE_PREFIX,
18 linkTagText,
19 parseJsdocCopyId,
20 regionSource,
21 replaceRegion,
22} from "../../scripts/jsdoc-blocks.ts";
23
24const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
25const generator = path.join(repoRoot, "scripts/generate-api-reference.ts");
26
27const markdown: MarkdownOptions = {
28 linePrefix: JSDOC_LINE_PREFIX,
29 atomics: [
30 { pattern: /\{@link\s+([^}]+)\}/, text: (m) => linkTagText(m[1]!) },
31 ],
32};
33const dialect = markdownDialect(markdown);
34
35/**
36 * Preview style for edited JSDoc markdown on the reference pages, matching
37 * Starlight's markdown until the regenerated page replaces the preview.
38 */
39export const jsdocMarkdownStyle: MarkdownStyle = {
40 elements: {
41 code: { dir: "auto" },
42 pre: { class: "copy-preview-code" },
43 },
44 // `{@link X}` / `{@link X label}` render as the label or `X` in code.
45 rewrite: [
46 ["\\{@link\\s+[^}\\s|]+\\s*[|\\s]\\s*([^}]+)\\}", "$1"],
47 ["\\{@link\\s+(?:import\\([^)]*\\)\\.)?([^}\\s]+)\\s*\\}", "`$1`"],
48 ],
49 css: `
50 [data-copy-style="docs"] pre.copy-preview-code {
51 margin: 1rem 0;
52 padding: 0.9rem 1.1rem;
53 overflow-x: auto;
54 border: 1px solid var(--sl-color-gray-5, #444);
55 border-radius: 6px;
56 background: var(--sl-color-gray-6, #1a1a1a);
57 font-size: var(--sl-text-code, 0.875rem);
58 line-height: 1.6;
59 }
60 [data-copy-style="docs"] pre.copy-preview-code code {
61 background: none;
62 padding: 0;
63 font-size: inherit;
64 }
65 `,
66};
67
68/** Resolves a copy id to its source file and parsed comment. */
69const locate = async (id: string) => {
70 const parsed = parseJsdocCopyId(id);
71 const file = parsed && path.resolve(repoRoot, parsed.file);
72 if (
73 !parsed ||
74 !file ||
75 !file.startsWith(path.join(repoRoot, "packages") + path.sep) ||
76 !/\.tsx?$/.test(file)
77 ) {
78 throw new CopyEditError(`Invalid JSDoc copy id: ${id}`);
79 }
80 const code = await fs.readFile(file, "utf8");
81 if (!code.startsWith("/**", parsed.commentStart)) {
82 throw new CopyEditError(
83 "The JSDoc moved since this page was generated. Reload and try again.",
84 409,
85 );
86 }
87 const lines = docCommentLines(code, parsed.commentStart);
88 return { parsed, file, code, lines, targets: docTargets(lines) };
89};
90
91const gone = () =>
92 new CopyEditError(
93 "This part of the JSDoc no longer exists. Reload and try again.",
94 409,
95 );
96
97/** A fingerprint of a comment's editable structure (ids depend on it). */
98const shape = (targets: ReturnType<typeof docTargets>) =>
99 JSON.stringify([
100 targets.regions.map((r) => [r.kind, r.editable]),
101 targets.inline.map((b) => b.kind),
102 ]);
103
104/**
105 * Saves copy edits made on generated API reference pages back into the JSDoc
106 * the page was generated from, then regenerates the reference pages.
107 *
108 * Pages carry copy markup when the generator runs with
109 * `API_REFERENCE_COPY_MARKERS=1` (see `dev:site`): prose regions are edited
110 * as markdown, titles inline.
111 */
112export const jsdocCopyHandler = (): CopyEditHandler => {
113 let running: Promise<void> | undefined;
114 let queued: Promise<void> | undefined;
115
116 const runGenerator = () =>
117 new Promise<void>((resolve, reject) => {
118 const child = spawn("bun", [generator], {
119 cwd: repoRoot,
120 env: { ...process.env, API_REFERENCE_COPY_MARKERS: "1" },
121 stdio: ["ignore", "ignore", "pipe"],
122 });
123 let stderr = "";
124 child.stderr.on("data", (chunk) => (stderr += chunk));
125 child.on("error", reject);
126 child.on("exit", (code) =>
127 code === 0
128 ? resolve()
129 : reject(new Error(`API reference generation failed:\n${stderr}`)),
130 );
131 });
132
133 // One run at a time; edits made during a run share a single follow-up run.
134 const regenerate = (): Promise<void> => {
135 if (queued) return queued;
136 if (!running) {
137 running = runGenerator().finally(() => (running = undefined));
138 return running;
139 }
140 queued = running
141 .catch(() => {})
142 .then(() => {
143 queued = undefined;
144 return regenerate();
145 });
146 return queued;
147 };
148
149 const save = async (
150 file: string,
151 display: string,
152 before: ReturnType<typeof docTargets>,
153 code: string,
154 commentStart: number,
155 context: Parameters<NonNullable<CopyEditHandler["edit"]>>[1],
156 ) => {
157 await fs.writeFile(file, code);
158 // The page already shows the edit; keep the regenerated page's reload
159 // from interrupting whatever the editor does next.
160 const work = regenerate();
161 work.catch((error: Error) => context.logger.error(error.message));
162 context.suppressReloads(work);
163 const after = docTargets(docCommentLines(code, commentStart));
164 return {
165 file: display,
166 changed: true,
167 reload: shape(after) !== shape(before),
168 };
169 };
170
171 return {
172 async edit({ id, before, after }, context) {
173 const { parsed, file, code, targets } = await locate(id);
174 const block =
175 "block" in parsed ? targets.inline[parsed.block!] : undefined;
176 if (!block) throw gone();
177 const runs = markdownRuns(code, block.start, block.end, markdown);
178 const next = applyRuns(code, runs, dialect, { before, after });
179 if (next === code) return { file: parsed.file, changed: false };
180 return save(
181 file,
182 parsed.file,
183 targets,
184 next,
185 parsed.commentStart,
186 context,
187 );
188 },
189
190 async readSource(id) {
191 const { parsed, lines, targets } = await locate(id);
192 const region =
193 "region" in parsed ? targets.regions[parsed.region!] : undefined;
194 if (!region?.editable) throw gone();
195 return regionSource(lines, region);
196 },
197
198 async writeSource({ id, source, base }, context) {
199 const { parsed, file, code, lines, targets } = await locate(id);
200 const region =
201 "region" in parsed ? targets.regions[parsed.region!] : undefined;
202 if (!region?.editable) throw gone();
203 if (regionSource(lines, region) !== base) {
204 throw new CopyEditError(
205 "This JSDoc changed since you started editing. Copy your text, reload, and try again.",
206 409,
207 );
208 }
209 let next: string;
210 try {
211 next = replaceRegion(code, lines, region, source);
212 } catch (error) {
213 throw new CopyEditError((error as Error).message);
214 }
215 if (next === code) return { file: parsed.file, changed: false };
216 return save(
217 file,
218 parsed.file,
219 targets,
220 next,
221 parsed.commentStart,
222 context,
223 );
224 },
225 };
226};