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
19export 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 */
34export 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
62export type DocBlockKind =
63 | "paragraph"
64 | "listItem"
65 | "heading"
66 | "proseHeading"
67 | "sectionTitle"
68 | "exampleTitle";
69
70export 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
79const FENCE = /^\s*```/;
80const PROSE_HEADING = /^(\s*###\s+)(.+?)\s+<!-- api-prose -->\s*$/;
81const SECTION = /^(\s*###\s+)(.+?)\s*$/;
82const EXAMPLE = /^(\s*\*\*Example:\*\*\s*)(.*?)\s*$/;
83const TAG = /^\s*@(\w+)/;
84const LIST_ITEM = /^(\s*(?:[-*+]|\d+[.)])\s+)/;
85const HEADING = /^(\s*#{1,6}\s+)/;
86/** Lines that aren't inline prose: tables, HTML, directives, quotes, rules. */
87const 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 */
94export 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
225export type DocRegionKind = "summary" | "sectionDescription" | "exampleBody";
226
227export 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
238type 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 */
246export 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
338const 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 */
352export 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. */
363export 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 */
373export 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
428export const JSDOC_COPY_SCHEME = "jsdoc";
429
430/** Matches what follows `\n` on a JSDoc continuation line (` * `). */
431export const JSDOC_LINE_PREFIX = "[ \\t]*\\*(?!/)[ \\t]?";
432
433/** Style preset the website applies to previews of edited JSDoc markdown. */
434export const JSDOC_COPY_STYLE = "docs";
435
436export const jsdocCopyId = (
437 file: string,
438 commentStart: number,
439 target: `${number}` | `r${number}`,
440) => `${JSDOC_COPY_SCHEME}:${file}@${commentStart}#${target}`;
441
442export 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
451const REGION_CLOSE = "</div><!--/copy-->";
452const 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. */
458export 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 */
466export 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
505export const LINK_TAG_RE = /\{@link\s+([^}]+)\}/g;
506
507/** Split a `{@link ...}` tag's inner text into target + optional label. */
508export 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 */
535export 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). */
541export function linkTagText(inner: string): string {
542 const { target, label } = parseLinkTag(inner.replace(/\s+/g, " "));
543 return label ?? normalizeLinkTarget(target);
544}