| 1 | /** |
| 2 | * Source-mapped JSDoc prose, shared by the API reference generator and the |
| 3 | * website's dev-time copy editor. |
| 4 | * |
| 5 | * The generator renders a resource's JSDoc as a markdown page. With copy |
| 6 | * markers on: |
| 7 | * |
| 8 | * - each prose region (the summary, a section's description, an example's |
| 9 | * body) is wrapped in a `<div data-copy="jsdoc:<file>@<comment>#r<n>" |
| 10 | * data-copy-format="markdown">` the copy editor edits as markdown source; |
| 11 | * - each remaining one-line block (section and example titles) ends with a |
| 12 | * `<!--copy:jsdoc:<file>@<comment>#<n>-->` marker for inline text edits. |
| 13 | * |
| 14 | * Saving re-derives the same regions and blocks from the source file to find |
| 15 | * what to rewrite, so both sides MUST segment comments with |
| 16 | * {@link docRegions} and {@link docBlocks}. |
| 17 | */ |
| 18 | |
| 19 | export interface DocLine { |
| 20 | /** The line with its ` * ` prefix removed (what the generator parses). */ |
| 21 | text: string; |
| 22 | /** Source offset where the raw line (including its ` * ` prefix) starts. */ |
| 23 | lineStart: number; |
| 24 | /** Source offset where `text` starts. */ |
| 25 | start: number; |
| 26 | /** Source offset where `text` ends. */ |
| 27 | end: number; |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Splits the `/** ... *\/` comment at `commentStart` into cleaned lines with |
| 32 | * source offsets. Cleaning matches the generator's historical behavior. |
| 33 | */ |
| 34 | export function docCommentLines( |
| 35 | fileText: string, |
| 36 | commentStart: number, |
| 37 | ): DocLine[] { |
| 38 | if (!fileText.startsWith("/*", commentStart)) { |
| 39 | throw new Error(`No doc comment at offset ${commentStart}`); |
| 40 | } |
| 41 | const close = fileText.indexOf("*/", commentStart + 2); |
| 42 | if (close === -1) |
| 43 | throw new Error(`Unterminated doc comment at ${commentStart}`); |
| 44 | const raw = fileText.slice(commentStart, close + 2); |
| 45 | const open = /^\/\*\*?/.exec(raw)![0].length; |
| 46 | const body = raw.slice(open, raw.length - 2); |
| 47 | const lines: DocLine[] = []; |
| 48 | let offset = commentStart + open; |
| 49 | for (const line of body.split("\n")) { |
| 50 | const prefix = /^\s*\*\s?/.exec(line)?.[0].length ?? 0; |
| 51 | lines.push({ |
| 52 | text: line.slice(prefix), |
| 53 | lineStart: offset, |
| 54 | start: offset + prefix, |
| 55 | end: offset + line.length, |
| 56 | }); |
| 57 | offset += line.length + 1; |
| 58 | } |
| 59 | return lines; |
| 60 | } |
| 61 | |
| 62 | export type DocBlockKind = |
| 63 | | "paragraph" |
| 64 | | "listItem" |
| 65 | | "heading" |
| 66 | | "proseHeading" |
| 67 | | "sectionTitle" |
| 68 | | "exampleTitle"; |
| 69 | |
| 70 | export interface DocBlock { |
| 71 | kind: DocBlockKind; |
| 72 | firstLine: number; |
| 73 | lastLine: number; |
| 74 | /** Source range of the block's editable inline markdown. */ |
| 75 | start: number; |
| 76 | end: number; |
| 77 | } |
| 78 | |
| 79 | const FENCE = /^\s*```/; |
| 80 | const PROSE_HEADING = /^(\s*###\s+)(.+?)\s+<!-- api-prose -->\s*$/; |
| 81 | const SECTION = /^(\s*###\s+)(.+?)\s*$/; |
| 82 | const EXAMPLE = /^(\s*\*\*Example:\*\*\s*)(.*?)\s*$/; |
| 83 | const TAG = /^\s*@(\w+)/; |
| 84 | const LIST_ITEM = /^(\s*(?:[-*+]|\d+[.)])\s+)/; |
| 85 | const HEADING = /^(\s*#{1,6}\s+)/; |
| 86 | /** Lines that aren't inline prose: tables, HTML, directives, quotes, rules. */ |
| 87 | const NON_PROSE = /^\s*(?:\||<|:::|>|---+\s*$|\*\*\*+\s*$)/; |
| 88 | |
| 89 | /** |
| 90 | * Finds the rendered prose blocks in a comment's lines, mirroring how the |
| 91 | * generator's `parseJSDoc` routes each line (summary, section description, |
| 92 | * example body, tag) so only lines that reach the page become blocks. |
| 93 | */ |
| 94 | export function docBlocks(lines: DocLine[]): DocBlock[] { |
| 95 | const blocks: DocBlock[] = []; |
| 96 | let sawTag = false; |
| 97 | let inExample = false; |
| 98 | let inSectionDesc = false; |
| 99 | let insideFence = false; |
| 100 | let open: DocBlock | undefined; |
| 101 | |
| 102 | const close = () => { |
| 103 | if (open) blocks.push(open); |
| 104 | open = undefined; |
| 105 | }; |
| 106 | const single = (kind: DocBlockKind, i: number, from: number, to: number) => { |
| 107 | close(); |
| 108 | const line = lines[i]; |
| 109 | if (to > from) { |
| 110 | blocks.push({ |
| 111 | kind, |
| 112 | firstLine: i, |
| 113 | lastLine: i, |
| 114 | start: line.start + from, |
| 115 | end: line.start + to, |
| 116 | }); |
| 117 | } |
| 118 | }; |
| 119 | |
| 120 | lines.forEach((line, i) => { |
| 121 | const text = line.text; |
| 122 | if (FENCE.test(text)) { |
| 123 | insideFence = !insideFence; |
| 124 | close(); |
| 125 | return; |
| 126 | } |
| 127 | if (insideFence) return; |
| 128 | |
| 129 | const prose = PROSE_HEADING.exec(text); |
| 130 | if (prose) { |
| 131 | if (!sawTag) |
| 132 | single( |
| 133 | "proseHeading", |
| 134 | i, |
| 135 | prose[1].length, |
| 136 | prose[1].length + prose[2].length, |
| 137 | ); |
| 138 | else close(); |
| 139 | return; |
| 140 | } |
| 141 | const section = SECTION.exec(text); |
| 142 | if (section) { |
| 143 | sawTag = true; |
| 144 | inExample = false; |
| 145 | inSectionDesc = true; |
| 146 | single( |
| 147 | "sectionTitle", |
| 148 | i, |
| 149 | section[1].length, |
| 150 | section[1].length + section[2].length, |
| 151 | ); |
| 152 | return; |
| 153 | } |
| 154 | const example = EXAMPLE.exec(text); |
| 155 | if (example) { |
| 156 | sawTag = true; |
| 157 | inSectionDesc = false; |
| 158 | inExample = true; |
| 159 | single( |
| 160 | "exampleTitle", |
| 161 | i, |
| 162 | example[1].length, |
| 163 | example[1].length + example[2].length, |
| 164 | ); |
| 165 | return; |
| 166 | } |
| 167 | const tag = TAG.exec(text); |
| 168 | if (tag) { |
| 169 | sawTag = true; |
| 170 | if (tag[1] === "section") { |
| 171 | inExample = false; |
| 172 | inSectionDesc = true; |
| 173 | } else if (tag[1] === "example") { |
| 174 | inSectionDesc = false; |
| 175 | inExample = true; |
| 176 | } |
| 177 | close(); |
| 178 | return; |
| 179 | } |
| 180 | |
| 181 | const rendered = !sawTag || (!inExample && inSectionDesc); |
| 182 | if (!rendered || text.trim() === "" || NON_PROSE.test(text)) { |
| 183 | close(); |
| 184 | return; |
| 185 | } |
| 186 | |
| 187 | const trimmedEnd = text.trimEnd().length; |
| 188 | const heading = HEADING.exec(text); |
| 189 | if (heading) { |
| 190 | single("heading", i, heading[1].length, trimmedEnd); |
| 191 | return; |
| 192 | } |
| 193 | const item = LIST_ITEM.exec(text); |
| 194 | if (item) { |
| 195 | close(); |
| 196 | open = { |
| 197 | kind: "listItem", |
| 198 | firstLine: i, |
| 199 | lastLine: i, |
| 200 | start: line.start + item[1].length, |
| 201 | end: line.start + trimmedEnd, |
| 202 | }; |
| 203 | return; |
| 204 | } |
| 205 | if (open) { |
| 206 | open.lastLine = i; |
| 207 | open.end = line.start + trimmedEnd; |
| 208 | return; |
| 209 | } |
| 210 | const indent = text.length - text.trimStart().length; |
| 211 | open = { |
| 212 | kind: "paragraph", |
| 213 | firstLine: i, |
| 214 | lastLine: i, |
| 215 | start: line.start + indent, |
| 216 | end: line.start + trimmedEnd, |
| 217 | }; |
| 218 | }); |
| 219 | close(); |
| 220 | return blocks; |
| 221 | } |
| 222 | |
| 223 | // ── prose regions ───────────────────────────────────────────────────────── |
| 224 | |
| 225 | export type DocRegionKind = "summary" | "sectionDescription" | "exampleBody"; |
| 226 | |
| 227 | export interface DocRegion { |
| 228 | kind: DocRegionKind; |
| 229 | /** First and last content line; `lastLine < firstLine` for an empty region. */ |
| 230 | firstLine: number; |
| 231 | lastLine: number; |
| 232 | /** For an empty region, the line new content is inserted after. */ |
| 233 | anchorLine: number; |
| 234 | /** False when the region shares a line with the comment's delimiters. */ |
| 235 | editable: boolean; |
| 236 | } |
| 237 | |
| 238 | type Route = "summary" | "sectionDescription" | "exampleBody" | "structural"; |
| 239 | |
| 240 | /** |
| 241 | * Finds the prose regions in a comment's lines: runs of lines the generator |
| 242 | * routes into one rendered chunk (summary, section description, example |
| 243 | * body), trimmed of blank edge lines. A section title with no description |
| 244 | * yields an empty region so a description can be added. |
| 245 | */ |
| 246 | export function docRegions(lines: DocLine[]): DocRegion[] { |
| 247 | const routes: Route[] = []; |
| 248 | let sawTag = false; |
| 249 | let inExample = false; |
| 250 | let inSectionDesc = false; |
| 251 | let insideFence = false; |
| 252 | const sectionTitles = new Set<number>(); |
| 253 | |
| 254 | lines.forEach((line, i) => { |
| 255 | const text = line.text; |
| 256 | if (FENCE.test(text)) insideFence = !insideFence; |
| 257 | if (!insideFence && !FENCE.test(text)) { |
| 258 | if (PROSE_HEADING.test(text)) { |
| 259 | routes.push(sawTag ? "structural" : "summary"); |
| 260 | return; |
| 261 | } |
| 262 | if (SECTION.test(text)) { |
| 263 | sawTag = true; |
| 264 | inExample = false; |
| 265 | inSectionDesc = true; |
| 266 | sectionTitles.add(i); |
| 267 | routes.push("structural"); |
| 268 | return; |
| 269 | } |
| 270 | if (EXAMPLE.test(text)) { |
| 271 | sawTag = true; |
| 272 | inSectionDesc = false; |
| 273 | inExample = true; |
| 274 | routes.push("structural"); |
| 275 | return; |
| 276 | } |
| 277 | const tag = TAG.exec(text); |
| 278 | if (tag) { |
| 279 | sawTag = true; |
| 280 | if (tag[1] === "section") { |
| 281 | inExample = false; |
| 282 | inSectionDesc = true; |
| 283 | } else if (tag[1] === "example") { |
| 284 | inSectionDesc = false; |
| 285 | inExample = true; |
| 286 | } |
| 287 | routes.push("structural"); |
| 288 | return; |
| 289 | } |
| 290 | } |
| 291 | routes.push( |
| 292 | !sawTag |
| 293 | ? "summary" |
| 294 | : inExample |
| 295 | ? "exampleBody" |
| 296 | : inSectionDesc |
| 297 | ? "sectionDescription" |
| 298 | : "structural", |
| 299 | ); |
| 300 | }); |
| 301 | |
| 302 | const regions: DocRegion[] = []; |
| 303 | const last = lines.length - 1; |
| 304 | let i = 0; |
| 305 | while (i < lines.length) { |
| 306 | const route = routes[i]!; |
| 307 | let j = i; |
| 308 | while (j + 1 < lines.length && routes[j + 1] === route) j++; |
| 309 | if (route !== "structural") { |
| 310 | let first = i; |
| 311 | let end = j; |
| 312 | while (first <= end && lines[first]!.text.trim() === "") first++; |
| 313 | while (end >= first && lines[end]!.text.trim() === "") end--; |
| 314 | if (first <= end) { |
| 315 | regions.push({ |
| 316 | kind: route, |
| 317 | firstLine: first, |
| 318 | lastLine: end, |
| 319 | anchorLine: first - 1, |
| 320 | editable: first > 0 && end < last, |
| 321 | }); |
| 322 | } else if (route === "sectionDescription" && sectionTitles.has(i - 1)) { |
| 323 | regions.push(emptyAfter(i - 1, last)); |
| 324 | } |
| 325 | } else { |
| 326 | // A section title followed directly by an example, tag, or section. |
| 327 | for (let k = i; k <= j; k++) { |
| 328 | if (sectionTitles.has(k) && routes[k + 1] !== "sectionDescription") { |
| 329 | regions.push(emptyAfter(k, last)); |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | i = j + 1; |
| 334 | } |
| 335 | return regions; |
| 336 | } |
| 337 | |
| 338 | const emptyAfter = (anchorLine: number, last: number): DocRegion => ({ |
| 339 | kind: "sectionDescription", |
| 340 | firstLine: anchorLine + 1, |
| 341 | lastLine: anchorLine, |
| 342 | anchorLine, |
| 343 | editable: anchorLine < last, |
| 344 | }); |
| 345 | |
| 346 | /** |
| 347 | * The copy-editable targets in a comment: prose regions (edited as markdown) |
| 348 | * and the inline blocks outside editable regions (titles, edited as text). |
| 349 | * Inline blocks are indexed among themselves so editing a region's markdown |
| 350 | * never shifts their ids. |
| 351 | */ |
| 352 | export function docTargets(lines: DocLine[]) { |
| 353 | const regions = docRegions(lines); |
| 354 | const inRegion = (line: number) => |
| 355 | regions.some( |
| 356 | (r) => r.editable && line >= r.firstLine && line <= r.lastLine, |
| 357 | ); |
| 358 | const inline = docBlocks(lines).filter((b) => !inRegion(b.firstLine)); |
| 359 | return { regions, inline }; |
| 360 | } |
| 361 | |
| 362 | /** The region's markdown, as shown for editing. */ |
| 363 | export const regionSource = (lines: DocLine[], region: DocRegion) => |
| 364 | lines |
| 365 | .slice(region.firstLine, region.lastLine + 1) |
| 366 | .map((l) => l.text) |
| 367 | .join("\n"); |
| 368 | |
| 369 | /** |
| 370 | * Returns `fileText` with `region` replaced by `markdown`, written as JSDoc |
| 371 | * lines in the comment's own indentation. |
| 372 | */ |
| 373 | export function replaceRegion( |
| 374 | fileText: string, |
| 375 | lines: DocLine[], |
| 376 | region: DocRegion, |
| 377 | markdown: string, |
| 378 | ): string { |
| 379 | const reference = |
| 380 | lines[ |
| 381 | region.lastLine >= region.firstLine ? region.firstLine : region.anchorLine |
| 382 | ]!; |
| 383 | const prefix = fileText |
| 384 | .slice(reference.lineStart, reference.start) |
| 385 | .replace(/\*\s*$/, "* "); |
| 386 | const bare = prefix.trimEnd(); |
| 387 | |
| 388 | let inCode = false; |
| 389 | let insideFence = false; |
| 390 | const body = markdown |
| 391 | .replace(/\r\n?/g, "\n") |
| 392 | .replace(/^\n+|\s+$/g, "") |
| 393 | .split("\n") |
| 394 | // Outside code, one blank line separates blocks as well as several. |
| 395 | .filter((line, i, all) => { |
| 396 | if (FENCE.test(line)) inCode = !inCode; |
| 397 | return inCode || line.trim() !== "" || all[i - 1]?.trim() !== ""; |
| 398 | }) |
| 399 | .map((line) => { |
| 400 | if (FENCE.test(line)) insideFence = !insideFence; |
| 401 | // Outside code, a leading `@` would start a JSDoc tag. |
| 402 | const safe = |
| 403 | !insideFence && /^\s*@/.test(line) ? line.replace("@", "\\@") : line; |
| 404 | return safe.trim() === "" ? bare : `${prefix}${safe}`; |
| 405 | }); |
| 406 | if (body.join("\n").includes("*/")) { |
| 407 | throw new Error("Markdown in a JSDoc comment can't contain */."); |
| 408 | } |
| 409 | |
| 410 | const empty = body.length === 1 && body[0] === bare; |
| 411 | if (region.lastLine < region.firstLine) { |
| 412 | if (empty) return fileText; |
| 413 | const at = lines[region.anchorLine]!.end; |
| 414 | return `${fileText.slice(0, at)}\n${body.join("\n")}${fileText.slice(at)}`; |
| 415 | } |
| 416 | const from = lines[region.firstLine]!.lineStart; |
| 417 | if (empty) { |
| 418 | // Drop the region's lines entirely, including their line breaks. |
| 419 | const to = lines[region.lastLine + 1]!.lineStart; |
| 420 | return fileText.slice(0, from) + fileText.slice(to); |
| 421 | } |
| 422 | const to = lines[region.lastLine]!.end; |
| 423 | return fileText.slice(0, from) + body.join("\n") + fileText.slice(to); |
| 424 | } |
| 425 | |
| 426 | // ── copy markers ────────────────────────────────────────────────────────── |
| 427 | |
| 428 | export const JSDOC_COPY_SCHEME = "jsdoc"; |
| 429 | |
| 430 | /** Matches what follows `\n` on a JSDoc continuation line (` * `). */ |
| 431 | export const JSDOC_LINE_PREFIX = "[ \\t]*\\*(?!/)[ \\t]?"; |
| 432 | |
| 433 | /** Style preset the website applies to previews of edited JSDoc markdown. */ |
| 434 | export const JSDOC_COPY_STYLE = "docs"; |
| 435 | |
| 436 | export const jsdocCopyId = ( |
| 437 | file: string, |
| 438 | commentStart: number, |
| 439 | target: `${number}` | `r${number}`, |
| 440 | ) => `${JSDOC_COPY_SCHEME}:${file}@${commentStart}#${target}`; |
| 441 | |
| 442 | export const parseJsdocCopyId = (id: string) => { |
| 443 | const m = /^jsdoc:(.+)@(\d+)#(r?)(\d+)$/.exec(id); |
| 444 | if (!m) return undefined; |
| 445 | const base = { file: m[1]!, commentStart: Number(m[2]) }; |
| 446 | return m[3] |
| 447 | ? { ...base, region: Number(m[4]) } |
| 448 | : { ...base, block: Number(m[4]) }; |
| 449 | }; |
| 450 | |
| 451 | const REGION_CLOSE = "</div><!--/copy-->"; |
| 452 | const COPY_MARKUP_RE = new RegExp( |
| 453 | `<!--copy:[^>]*?-->|^<div data-copy=[^\\n]*>$|^${REGION_CLOSE.replace(/[/]/g, "\\/")}$`, |
| 454 | "gm", |
| 455 | ); |
| 456 | |
| 457 | /** Removes copy markers and region wrappers from generated markdown. */ |
| 458 | export const stripCopyMarkers = (text: string) => |
| 459 | text.replace(COPY_MARKUP_RE, ""); |
| 460 | |
| 461 | /** |
| 462 | * Returns the comment's cleaned line texts with copy markup added: region |
| 463 | * wrappers around each editable prose region, and a trailing marker on each |
| 464 | * title block outside them. |
| 465 | */ |
| 466 | export function markDocLines( |
| 467 | lines: DocLine[], |
| 468 | idFor: (target: `${number}` | `r${number}`) => string, |
| 469 | ): string[] { |
| 470 | const texts = lines.map((l) => l.text); |
| 471 | const { regions, inline } = docTargets(lines); |
| 472 | |
| 473 | inline.forEach((block, index) => { |
| 474 | const marker = `<!--copy:${idFor(`${index}`)}-->`; |
| 475 | const text = texts[block.lastLine]!; |
| 476 | const prose = /\s+<!-- api-prose -->\s*$/.exec(text); |
| 477 | texts[block.lastLine] = prose |
| 478 | ? `${text.slice(0, prose.index).trimEnd()}${marker}${prose[0]}` |
| 479 | : `${text.trimEnd()}${marker}`; |
| 480 | }); |
| 481 | |
| 482 | const before = new Map<number, string[]>(); |
| 483 | const after = new Map<number, string[]>(); |
| 484 | const add = (map: Map<number, string[]>, line: number, add: string[]) => |
| 485 | map.set(line, [...(map.get(line) ?? []), ...add]); |
| 486 | regions.forEach((region, index) => { |
| 487 | if (!region.editable) return; |
| 488 | const open = `<div data-copy="${idFor(`r${index}`)}" data-copy-format="markdown" data-copy-style="${JSDOC_COPY_STYLE}">`; |
| 489 | if (region.lastLine < region.firstLine) { |
| 490 | add(after, region.anchorLine, ["", open, REGION_CLOSE]); |
| 491 | } else { |
| 492 | add(before, region.firstLine, [open, ""]); |
| 493 | add(after, region.lastLine, ["", REGION_CLOSE]); |
| 494 | } |
| 495 | }); |
| 496 | return texts.flatMap((text, i) => [ |
| 497 | ...(before.get(i) ?? []), |
| 498 | text, |
| 499 | ...(after.get(i) ?? []), |
| 500 | ]); |
| 501 | } |
| 502 | |
| 503 | // ── {@link} tags ────────────────────────────────────────────────────────── |
| 504 | |
| 505 | export const LINK_TAG_RE = /\{@link\s+([^}]+)\}/g; |
| 506 | |
| 507 | /** Split a `{@link ...}` tag's inner text into target + optional label. */ |
| 508 | export function parseLinkTag(inner: string): { |
| 509 | target: string; |
| 510 | label?: string; |
| 511 | } { |
| 512 | const trimmed = inner.trim(); |
| 513 | const pipe = trimmed.indexOf("|"); |
| 514 | if (pipe !== -1) { |
| 515 | return { |
| 516 | target: trimmed.slice(0, pipe).trim(), |
| 517 | label: trimmed.slice(pipe + 1).trim() || undefined, |
| 518 | }; |
| 519 | } |
| 520 | const space = trimmed.search(/\s/); |
| 521 | if (space !== -1) { |
| 522 | return { |
| 523 | target: trimmed.slice(0, space).trim(), |
| 524 | label: trimmed.slice(space + 1).trim() || undefined, |
| 525 | }; |
| 526 | } |
| 527 | return { target: trimmed }; |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * Reduce a typedoc-style `import("./Secrets.ts").Secrets` target to the bare |
| 532 | * symbol name — resolution (same-directory first) and display both want the |
| 533 | * name, not the module expression. |
| 534 | */ |
| 535 | export function normalizeLinkTarget(target: string): string { |
| 536 | const match = target.match(/^import\((["'])[^"']+\1\)\.(.+)$/); |
| 537 | return match ? match[2] : target; |
| 538 | } |
| 539 | |
| 540 | /** The text a `{@link}` tag renders as (its label, or the bare target). */ |
| 541 | export function linkTagText(inner: string): string { |
| 542 | const { target, label } = parseLinkTag(inner.replace(/\s+/g, " ")); |
| 543 | return label ?? normalizeLinkTarget(target); |
| 544 | } |