| 1 | import { |
| 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"; |
| 10 | import { spawn } from "node:child_process"; |
| 11 | import { promises as fs } from "node:fs"; |
| 12 | import path from "node:path"; |
| 13 | import { fileURLToPath } from "node:url"; |
| 14 | import { |
| 15 | docCommentLines, |
| 16 | docTargets, |
| 17 | JSDOC_LINE_PREFIX, |
| 18 | linkTagText, |
| 19 | parseJsdocCopyId, |
| 20 | regionSource, |
| 21 | replaceRegion, |
| 22 | } from "../../scripts/jsdoc-blocks.ts"; |
| 23 | |
| 24 | const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); |
| 25 | const generator = path.join(repoRoot, "scripts/generate-api-reference.ts"); |
| 26 | |
| 27 | const markdown: MarkdownOptions = { |
| 28 | linePrefix: JSDOC_LINE_PREFIX, |
| 29 | atomics: [ |
| 30 | { pattern: /\{@link\s+([^}]+)\}/, text: (m) => linkTagText(m[1]!) }, |
| 31 | ], |
| 32 | }; |
| 33 | const 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 | */ |
| 39 | export 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. */ |
| 69 | const 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 | |
| 91 | const 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). */ |
| 98 | const 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 | */ |
| 112 | export 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 | }; |