a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}