a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 8.5 kB view raw
1import { echo } from "$console/echo.js"; 2import { getDocsPath, getLibSrcPath } from "$utils/paths.js"; 3import { trackVersion } from "$versioning/tracker.js"; 4import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; 5import path from "node:path"; 6import ts from "typescript"; 7 8type Member = { name: string; type: string; docs?: string }; 9 10type EntryKind = "function" | "interface" | "type" | "class"; 11 12type DocumentEntry = { 13 name: string; 14 kind: EntryKind; 15 description: string; 16 examples: string[]; 17 signature?: string; 18 members?: Array<Member>; 19}; 20 21type JSDocumentParsed = { description: string; examples: string[] }; 22 23/** 24 * Extract and parse JSDoc comment text 25 */ 26function extractJSDoc(node: ts.Node, sourceFile: ts.SourceFile): JSDocumentParsed { 27 const fullText = sourceFile.getFullText(); 28 const ranges = ts.getLeadingCommentRanges(fullText, node.getFullStart()); 29 30 if (!ranges || ranges.length === 0) { 31 return { description: "", examples: [] }; 32 } 33 34 const comments = ranges.map((range) => fullText.slice(range.pos, range.end)); 35 const jsdocComments = comments.filter((c) => c.trim().startsWith("/**")); 36 37 if (jsdocComments.length === 0) { 38 return { description: "", examples: [] }; 39 } 40 41 const comment = jsdocComments.at(-1); 42 const lines = comment!.split("\n").map((line) => line.trim()).map((line) => line.replace(/^\/\*\*\s?/, "")).map(( 43 line, 44 ) => line.replace(/^\*\s?/, "")).map((line) => line.replace(/\*\/\s*$/, "")).filter((line) => 45 line !== "/" && line !== "*" 46 ); 47 48 const description: string[] = []; 49 const examples: string[] = []; 50 let currentExample: string[] = []; 51 let inExample = false; 52 53 for (const line of lines) { 54 if (line.startsWith("@example")) { 55 inExample = true; 56 continue; 57 } 58 59 if (line.startsWith("@") && !line.startsWith("@example")) { 60 if (inExample && currentExample.length > 0) { 61 examples.push(currentExample.join("\n").trim()); 62 currentExample = []; 63 } 64 inExample = false; 65 continue; 66 } 67 68 if (inExample) { 69 currentExample.push(line); 70 } else { 71 description.push(line); 72 } 73 } 74 75 if (currentExample.length > 0) { 76 examples.push(currentExample.join("\n").trim()); 77 } 78 79 return { description: description.join("\n").trim(), examples }; 80} 81 82/** 83 * Extract function signature 84 */ 85function extractFnSig(node: ts.FunctionDeclaration, sourceFile: ts.SourceFile): string { 86 const start = node.getStart(sourceFile); 87 const end = node.body ? node.body.getStart(sourceFile) : node.getEnd(); 88 return sourceFile.text.slice(start, end).trim().replaceAll(/\s+/g, " "); 89} 90 91/** 92 * Extract interface members 93 */ 94function extractIMembers(node: ts.InterfaceDeclaration, sourceFile: ts.SourceFile): Array<Member> { 95 const members: Array<Member> = []; 96 97 for (const member of node.members) { 98 if (ts.isPropertySignature(member) && member.name) { 99 const name = member.name.getText(sourceFile); 100 const type = member.type ? member.type.getText(sourceFile) : "unknown"; 101 const { description } = extractJSDoc(member, sourceFile); 102 103 members.push({ name, type, docs: description || undefined }); 104 } 105 } 106 107 return members; 108} 109 110/** 111 * Generate markdown for a documentation entry 112 */ 113function generateMD(entries: DocumentEntry[], moduleName: string, moduleDocs: string): string { 114 const lines: string[] = []; 115 116 lines.push(`# ${moduleName}`, ""); 117 118 if (moduleDocs) { 119 lines.push(moduleDocs, ""); 120 } 121 122 for (const entry of entries) { 123 lines.push(`## ${entry.name}`, ""); 124 125 if (entry.description) { 126 lines.push(entry.description, ""); 127 } 128 129 if (entry.signature) { 130 lines.push("```typescript", entry.signature, "```", ""); 131 } 132 133 if (entry.examples && entry.examples.length > 0) { 134 for (const example of entry.examples) { 135 lines.push("**Example:**", "", "```typescript", example, "```", ""); 136 } 137 } 138 139 if (entry.members && entry.members.length > 0) { 140 lines.push("### Members", ""); 141 142 for (const member of entry.members) { 143 lines.push(`- **${member.name}**: \`${member.type}\``); 144 if (member.docs) { 145 lines.push(` ${member.docs}`); 146 } 147 } 148 149 lines.push(""); 150 } 151 } 152 153 return lines.join("\n"); 154} 155 156/** 157 * Extract module-level documentation 158 */ 159function extractModDocs(content: string): string { 160 const lines = content.split("\n"); 161 const docLines: string[] = []; 162 let inDoc = false; 163 164 for (const line of lines) { 165 const trimmed = line.trim(); 166 167 if (trimmed === "/**") { 168 inDoc = true; 169 continue; 170 } 171 172 if (inDoc) { 173 if (trimmed === "*/") { 174 break; 175 } 176 177 const cleaned = trimmed.replace(/^\*\s?/, ""); 178 if (!cleaned.startsWith("@packageDocumentation")) { 179 docLines.push(cleaned); 180 } 181 } 182 } 183 184 return docLines.join("\n").trim(); 185} 186 187/** 188 * Parse a TypeScript file and extract documentation 189 */ 190function parseFile(filePath: string, content: string): DocumentEntry[] { 191 const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); 192 const entries: DocumentEntry[] = []; 193 194 function visit(node: ts.Node) { 195 if (ts.isFunctionDeclaration(node) && node.name) { 196 const modifiers = node.modifiers; 197 const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); 198 199 if (isExported) { 200 const name = node.name.text; 201 const { description, examples } = extractJSDoc(node, sourceFile); 202 const signature = extractFnSig(node, sourceFile); 203 204 entries.push({ name, kind: "function", description, examples, signature }); 205 } 206 } 207 208 if (ts.isInterfaceDeclaration(node)) { 209 const modifiers = node.modifiers; 210 const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); 211 212 if (isExported) { 213 const name = node.name.text; 214 const { description, examples } = extractJSDoc(node, sourceFile); 215 const members = extractIMembers(node, sourceFile); 216 217 entries.push({ name, kind: "interface", description, examples, members }); 218 } 219 } 220 221 if (ts.isTypeAliasDeclaration(node)) { 222 const modifiers = node.modifiers; 223 const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); 224 225 if (isExported) { 226 const name = node.name.text; 227 const { description, examples } = extractJSDoc(node, sourceFile); 228 const signature = node.type.getText(sourceFile); 229 230 entries.push({ name, kind: "type", description, examples, signature }); 231 } 232 } 233 234 ts.forEachChild(node, visit); 235 } 236 237 visit(sourceFile); 238 return entries; 239} 240 241/** 242 * Process a TypeScript file and generate documentation 243 */ 244async function processFile(filePath: string, baseDir: string, outputDir: string): Promise<void> { 245 const content = await readFile(filePath, "utf8"); 246 const entries = parseFile(filePath, content); 247 248 if (entries.length === 0) { 249 return; 250 } 251 252 const moduleDocs = extractModDocs(content); 253 const relativePath = path.relative(baseDir, filePath); 254 const moduleName = path.basename(relativePath, ".ts"); 255 const markdown = generateMD(entries, moduleName, moduleDocs); 256 257 const outputPath = path.join(outputDir, `${moduleName}.md`); 258 const versionedContent = await trackVersion(outputPath, markdown); 259 await writeFile(outputPath, versionedContent, "utf8"); 260 261 echo.ok(` Generated: ${relativePath} -> api/${moduleName}.md`); 262} 263 264/** 265 * Recursively find all TypeScript files 266 */ 267async function findTsFiles(dir: string, files: string[] = []): Promise<string[]> { 268 const entries = await readdir(dir, { withFileTypes: true }); 269 270 for (const entry of entries) { 271 const fullPath = path.join(dir, entry.name); 272 273 if (entry.isDirectory()) { 274 await findTsFiles(fullPath, files); 275 } else if (entry.isFile() && entry.name.endsWith(".ts")) { 276 files.push(fullPath); 277 } 278 } 279 280 return files; 281} 282 283/** 284 * Docs command implementation 285 */ 286export async function docsCommand(): Promise<void> { 287 const srcDir = await getLibSrcPath(); 288 const docsPath = await getDocsPath(); 289 const docsDir = path.join(docsPath, "api"); 290 291 echo.title("\nGenerating API Documentation\n"); 292 293 await mkdir(docsDir, { recursive: true }); 294 295 const files = await findTsFiles(srcDir); 296 297 echo.info(`Found ${files.length} TypeScript files\n`); 298 299 for (const file of files) { 300 await processFile(file, srcDir, docsDir); 301 } 302 303 echo.success(`\nAPI documentation generated in docs/api/\n`); 304}