Files
2025-08-10 17:04:52 +10:00

284 lines
8.4 KiB
TypeScript

import oxc from "oxc-parser";
import * as ts from "typescript";
import fs from "fs";
import path from "path";
import generateDocsPage from "./docs";
import { searchForSymbol, useProgram } from "./utils";
async function recursivelyDiscoverRoutes(dir: string): Promise<string[]> {
const files = fs.readdirSync(dir);
const resultFiles: string[] = [];
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
resultFiles.push(...(await recursivelyDiscoverRoutes(filePath)));
continue;
}
if (!filePath.endsWith(".ts")) continue;
resultFiles.push(filePath);
}
return resultFiles;
}
export interface Route {
fsPath: string;
urlPath: string;
method: string;
}
async function transformRoutes(base: string, routes: string[]) {
const results: Route[] = [];
for (const route of routes) {
const rawUrl = path.relative(base, route);
const method = (rawUrl.split(".").slice(-2)[0] ?? "get").toUpperCase();
let url = rawUrl.split(".")[0]!;
if (url.endsWith("index")) url = url.slice(0, -"index".length);
if (url.endsWith("/")) url = url.slice(0, -1);
const finalUrl = url
.split("/")
// Convert from [id] to :id
.map((e) => {
if (e.startsWith("[") && e.endsWith("]")) return `:${e.slice(1, -1)}`;
return e;
})
.join("/");
results.push({
fsPath: route,
urlPath: `/api/${finalUrl}`,
method,
});
}
results.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
return results;
}
export interface RouteMetadata {
checker: ts.TypeChecker;
sourceFile: ts.SourceFile;
deprecated?: boolean;
body?: ts.Type;
bodyComment?: string;
query?: ts.Type;
response?: ts.Type;
responseTag?: string;
responseComment?: string;
routeDescription?: string;
routeParams?: { [key: string]: { type: string; comment: string } };
acls?: string[];
aclMode?: "user" | "system";
clientRoute: boolean;
}
async function generateDocs(
sourceDir: string,
baseDir: string,
docsDir: string,
routes: Route[]
) {
const program = useProgram(sourceDir);
const checker = program.getTypeChecker();
const allDiagnostics = ts.getPreEmitDiagnostics(program);
if (allDiagnostics.length > 0) throw allDiagnostics;
// Fetch aclManager symbol so we can find ACLs later
const aclManagerPath = path.join(sourceDir, "server/internal/acls/index.ts");
const aclManagerSource = program.getSourceFile(aclManagerPath);
if (!aclManagerSource) throw "No ACL manager - did the source change?";
const aclManagerExport = aclManagerSource
.getChildAt(0)!
.getChildren()
.find((v) => ts.isExportAssignment(v))!.expression;
const aclManagerSymbol = checker.getSymbolAtLocation(aclManagerExport);
if (!aclManagerSymbol) throw "Failed to fetch ACL manager symbol. What.";
for (const route of routes) {
const routeTs = program.getSourceFile(route.fsPath);
if (!routeTs) throw `No source file for ${route.fsPath}`;
// Extract the `defineClientHandler`/`defineHandler` from the exports
const syntaxList = routeTs.getChildAt(0);
const exportAssignment = syntaxList
.getChildren()
.find((e) => ts.isExportAssignment(e))!;
const eventHandler = exportAssignment.expression;
if (!ts.isCallExpression(eventHandler)) continue;
// Check if this is defineClientHandler, and mark as a client route if so
const eventHandlerName = eventHandler.expression.getText().toLowerCase();
const isClient = eventHandlerName.includes("client");
const metadata: RouteMetadata = {
checker,
clientRoute: isClient,
sourceFile: routeTs,
};
// Extract the body type from the generic args
// Complicated and messy
// Only run on client/normal handlers
if (!eventHandlerName.includes("websocket")) {
const resolvedSignature = checker.getResolvedSignature(eventHandler);
if (!resolvedSignature)
throw "No resolved signature? Somehow an invalid node?";
// Get handler (function) type
const handlerParam = resolvedSignature.getParameters().at(0)!;
let handlerType = checker.getTypeOfSymbolAtLocation(
handlerParam,
handlerParam.declarations!.at(0)!
);
if (handlerType.isUnion()) handlerType = handlerType.types.at(0)!;
const handlerSignature = handlerType.getCallSignatures().at(0)!;
const typeArguments = (handlerType as any)[
"resolvedTypeArguments"
] as ts.Type[];
if (typeArguments) {
const requestConfigurationType = typeArguments.at(isClient ? 1 : 0)!;
const properties = requestConfigurationType.getProperties();
for (const property of properties) {
const type = checker.getTypeOfSymbolAtLocation(
property,
property.declarations!.at(0)!
);
if (type.flags & ts.TypeFlags.Any) continue;
(metadata as any)[property.escapedName!] = type;
}
const responseType = checker.getReturnTypeOfSignature(handlerSignature);
const awaited = checker.getAwaitedType(responseType);
if (awaited && !(awaited.flags & ts.TypeFlags.Any)) {
metadata.response = awaited;
}
}
} else {
// If it's websocket, change the "method"
route.method = "WS";
}
// A kinda inefficient search through every node in the AST
// For the symbol "aclManager"
const foundAclManager = searchForSymbol(
checker,
eventHandler,
aclManagerSymbol
);
// Check the above node for the markers of an ACL check
// We can't be 100% sure because I'm not good at AST stuff
// Deeply nested but no other way to do the control flow
// Unless refactoring into another function
if (foundAclManager) {
const callExpression = foundAclManager.parent.parent;
if (ts.isCallExpression(callExpression)) {
const isSystem = callExpression.expression
.getText()
.toLowerCase()
.includes("system");
metadata.aclMode = isSystem ? "system" : "user";
const arrayLiteral = callExpression.arguments.find((e) =>
ts.isArrayLiteralExpression(e)
);
if (arrayLiteral) {
const strings = arrayLiteral.elements.filter((e) =>
ts.isStringLiteral(e)
);
const acls = strings.map((e) => e.text);
metadata.acls = acls;
}
}
}
// Extract and parse the JSDoc for additional metadata
const jsDoc = ts.getJSDocCommentsAndTags(exportAssignment).at(0);
if (jsDoc) {
const description = jsDoc.comment;
if (description) {
metadata.routeDescription = description.toString();
}
for (const tag of jsDoc.getChildren()) {
if (ts.isJSDocParameterTag(tag)) {
const type = tag.typeExpression?.getText().slice(1, -1) ?? "string";
const paramName = tag.name.getText();
const comment = tag.comment?.toString() ?? "";
metadata.routeParams ??= {};
metadata.routeParams[paramName] = { type, comment };
}
if (ts.isJSDocReturnTag(tag)) {
metadata.responseTag = tag.comment?.toString();
}
if (ts.isJSDocUnknownTag(tag)) {
if (tag.tagName.escapedText == "request") {
// Custom request text
metadata.bodyComment = tag.comment?.toString();
}
if (tag.tagName.escapedText == "response") {
metadata.responseComment = tag.comment?.toString();
}
}
if (ts.isJSDocDeprecatedTag(tag)) {
metadata.deprecated = true;
}
}
}
// Generate docs page and write to disk
const docs = await generateDocsPage(route, metadata);
const relativeOutput = path.relative(baseDir, route.fsPath);
const relativeOutputDir = path.join(docsDir, path.dirname(relativeOutput));
fs.mkdirSync(relativeOutputDir, { recursive: true });
const filename = path.parse(relativeOutput).name + ".md";
fs.writeFileSync(path.join(relativeOutputDir, filename), docs);
}
}
export default async function build() {
const sourceDir = path.resolve("./drop");
const baseDir = path.resolve("./drop/server/api");
const docsDir = path.resolve("./docs");
const rawRoutes = await recursivelyDiscoverRoutes(baseDir);
const routes = await transformRoutes(baseDir, rawRoutes);
await generateDocs(sourceDir, baseDir, docsDir, routes);
//console.log(routes.slice(0, 5));
}