1import * as fs from "node:fs/promises";
2import * as path from "node:path";
3
4const roots = [
5 path.join(import.meta.dir, "../packages/alchemy/src"),
6 path.join(import.meta.dir, "../packages/better-auth/src"),
7];
8
9const pageTag = /^\s*\*\s+@(resource|binding|layer)\b/;
10const generatorTag =
11 /^\s*\*\s+@(resource|binding|layer|product|category|group|label|provides|peer)\b/;
12const sectionTag = /^(\s*\*\s+)@section(?:\s+(.*))?$/;
13const exampleTag = /^(\s*\*\s+)@example(?:\s+(.*))?$/;
14
15async function sourceFiles(root: string): Promise<string[]> {
16 const entries = (await fs.readdir(root, { recursive: true })) as string[];
17 return entries
18 .filter((entry) => /\.[cm]?tsx?$/.test(entry) && !entry.endsWith(".d.ts"))
19 .map((entry) => path.join(root, entry));
20}
21
22function fixBlock(block: string): string {
23 const lines = block.split("\n");
24 const isPage = lines.some((line) => pageTag.test(line));
25 const hasSections = lines.some((line) => sectionTag.test(line));
26 if (!isPage && !hasSections) return block;
27
28 const metadata: string[] = [];
29 const body: string[] = [];
30 let insideFence = false;
31
32 for (const line of lines) {
33 if (
34 line
35 .trim()
36 .replace(/^\*\s?/, "")
37 .startsWith("```")
38 ) {
39 insideFence = !insideFence;
40 }
41
42 if (isPage && !insideFence && generatorTag.test(line)) {
43 metadata.push(line);
44 continue;
45 }
46
47 if (!insideFence) {
48 const section = line.match(sectionTag);
49 if (section) {
50 body.push(`${section[1]}### ${section[2]?.trim() || "Examples"}`);
51 continue;
52 }
53
54 const example = line.match(exampleTag);
55 if (example) {
56 body.push(
57 `${example[1]}**Example:** ${example[2]?.trim() || "Example"}`,
58 );
59 continue;
60 }
61 }
62
63 body.push(line);
64 }
65
66 const close = body.pop();
67 if (close === undefined || !close.trim().endsWith("*/")) return block;
68
69 while (body.at(-1)?.trim() === "*") body.pop();
70 const commentPrefix = metadata[0]?.match(/^(\s*\*)/)?.[1];
71 if (body.length > 1 && commentPrefix) body.push(commentPrefix);
72 body.push(...metadata, close);
73 return body.join("\n");
74}
75
76const check = process.argv.includes("--check");
77const changedFiles: string[] = [];
78
79for (const root of roots) {
80 for (const file of await sourceFiles(root)) {
81 const source = await fs.readFile(file, "utf8");
82 const fixed = source.replace(/\/\*\*[\s\S]*?\*\//g, fixBlock);
83 if (fixed === source) continue;
84 changedFiles.push(path.relative(path.join(import.meta.dir, ".."), file));
85 if (!check) await fs.writeFile(file, fixed);
86 }
87}
88
89if (check && changedFiles.length > 0) {
90 console.error(
91 `${changedFiles.length} files contain API JSDocs that need fixing:\n${changedFiles.map((file) => `- ${file}`).join("\n")}\n\nRun \`pnpm docs:fix-jsdoc\` to fix them.`,
92 );
93 process.exitCode = 1;
94} else {
95 console.log(
96 check
97 ? "All API JSDocs are normalized."
98 : `Normalized API JSDocs in ${changedFiles.length} files.`,
99 );
100}