clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/141-effect-library
lofi / tools / api_docs.ts
api_docs.ts 19.8K
1/**2 * Generates the Docusaurus API reference from `deno doc --json` (schema v2)3 * into `website/api-gen/`. One page per entry in {@link entrypoints}, with the4 * six CLI command wrappers sharing a single page. Run via `deno task site:api`.5 */6import { type Entrypoint, entrypoints } from "./entrypoints.ts";78// `--node` renders the lofi-node API instead: same renderer, sourced from the9// pinned checkout of FelineStateMachine/lofi-node (LOFI_NODE_DIR, default10// ../lofi-node) so pages match the ref the /node docs render from; the11// package itself is published as jsr:@nzip/lofi-node.12const NODE_MODE = Deno.args.includes("--node");13const NODE_DIR = NODE_MODE ? (Deno.env.get("LOFI_NODE_DIR") ?? "../lofi-node") : "";1415const nodeEntrypoints: readonly Entrypoint[] = [16 {17 jsrName: ".",18 file: `${NODE_DIR}/mod.ts`,19 page: "node",20 title: "Sync node",21 },22 {23 jsrName: "./testing",24 file: `${NODE_DIR}/testing/mod.ts`,25 page: "testing",26 title: "Testing",27 },28];2930const activeEntrypoints = NODE_MODE ? nodeEntrypoints : entrypoints;31const packageName = NODE_MODE ? "@nzip/lofi-node" : "@nzip/lofi";3233const OUT_DIR = NODE_MODE ? "website/node-api-gen" : "website/api-gen";3435type JsDocTag = {36 kind: string;37 name?: string;38 doc?: string;39 type?: string;40};41type JsDoc = { doc?: string; tags?: JsDocTag[] };42type TsType = {43 repr?: string;44 kind?: string;45 // deno-lint-ignore no-explicit-any46 value?: any;47 // deno-lint-ignore no-explicit-any48 [key: string]: any;49};50type Param = {51 kind: string;52 name?: string;53 optional?: boolean;54 tsType?: TsType;55 // deno-lint-ignore no-explicit-any56 [k: string]: any;57};58type TypeParam = { name: string; constraint?: TsType; default?: TsType };59type Declaration = {60 kind: string;61 declarationKind: string;62 jsDoc?: JsDoc;63 // deno-lint-ignore no-explicit-any64 def?: any;65};66type SymbolNode = { name: string; declarations: Declaration[] };67type ModuleNodes = { module_doc?: JsDoc; symbols?: SymbolNode[] };68type DocOutput = { version: number; nodes: Record<string, ModuleNodes> };6970function printType(t: TsType | undefined | null): string {71 if (!t) return "unknown";72 switch (t.kind) {73 case "keyword":74 return String(t.value ?? t.repr ?? "unknown");75 case "literal": {76 const v = t.value ?? {};77 if (v.kind === "string") return JSON.stringify(v.string);78 if (v.kind === "number") return String(v.number);79 if (v.kind === "boolean") return String(v.boolean);80 if (v.kind === "bigInt") return `${v.string}n`;81 if (v.kind === "template") {82 return t.repr ? `\`${t.repr}\`` : "`...`";83 }84 return t.repr ?? "unknown";85 }86 case "typeRef": {87 const v = t.value ?? {};88 const args: TsType[] = v.typeParams ?? [];89 const name = v.typeName ?? t.repr ?? "unknown";90 return args.length > 0 ? `${name}<${args.map(printType).join(", ")}>` : name;91 }92 case "union":93 return (t.value as TsType[]).map(printType).join(" | ");94 case "intersection":95 return (t.value as TsType[]).map(printType).join(" & ");96 case "array": {97 const inner = printType(t.value);98 return /[|&]/.test(inner) ? `(${inner})[]` : `${inner}[]`;99 }100 case "tuple":101 return `[${(t.value as TsType[]).map(printType).join(", ")}]`;102 case "typeOperator": {103 const v = t.value ?? {};104 return `${v.operator} ${printType(v.tsType)}`;105 }106 case "optional":107 return printType(t.value);108 case "parenthesized":109 return `(${printType(t.value)})`;110 case "rest":111 return `...${printType(t.value)}`;112 case "fnOrConstructor": {113 const v = t.value ?? {};114 const params = (v.params ?? []).map(printParam).join(", ");115 const tp = printTypeParams(v.typeParams ?? []);116 const prefix = v.constructor ? "new " : "";117 return `${prefix}${tp}(${params}) => ${printType(v.tsType)}`;118 }119 case "typeLiteral": {120 const v = t.value ?? {};121 const parts: string[] = [];122 for (const sig of v.indexSignatures ?? []) {123 parts.push(124 `[${(sig.params ?? []).map(printParam).join(", ")}]: ${printType(sig.tsType)}`,125 );126 }127 for (const p of v.properties ?? []) {128 parts.push(129 `${p.readonly ? "readonly " : ""}${p.name}${p.optional ? "?" : ""}: ${130 printType(p.tsType)131 }`,132 );133 }134 for (const m of v.methods ?? []) {135 const fd = m.functionDef ?? m;136 parts.push(137 `${m.name}${m.optional ? "?" : ""}${printTypeParams(fd.typeParams ?? [])}(${138 (fd.params ?? m.params ?? []).map(printParam).join(", ")139 }): ${printType(fd.returnType ?? m.returnType)}`,140 );141 }142 if (parts.length === 0) return "Record<never, never>";143 return `{ ${parts.join("; ")} }`;144 }145 case "conditional": {146 const v = t.value ?? {};147 return `${printType(v.checkType)} extends ${printType(v.extendsType)} ? ${148 printType(v.trueType)149 } : ${printType(v.falseType)}`;150 }151 case "indexedAccess": {152 const v = t.value ?? {};153 return `${printType(v.objType)}[${printType(v.indexType)}]`;154 }155 case "typeQuery":156 return `typeof ${t.value}`;157 case "typePredicate": {158 const v = t.value ?? {};159 const name = v.param?.name ?? "value";160 const asserts = v.asserts ? "asserts " : "";161 return v.type ? `${asserts}${name} is ${printType(v.type)}` : `${asserts}${name}`;162 }163 case "importType": {164 const v = t.value ?? {};165 const qualifier = v.qualifier ? `.${v.qualifier}` : "";166 return `import(${JSON.stringify(v.specifier ?? "?")})${qualifier}`;167 }168 case "infer":169 return `infer ${t.value?.typeParam?.name ?? "T"}`;170 case "this":171 return "this";172 case "mapped":173 return t.repr && t.repr.length > 0 ? t.repr : "{ [mapped type] }";174 default:175 return t.repr && t.repr.length > 0 ? t.repr : "unknown";176 }177}178179function printParam(p: Param): string {180 switch (p.kind) {181 case "identifier":182 return `${p.name}${p.optional ? "?" : ""}${p.tsType ? `: ${printType(p.tsType)}` : ""}`;183 case "rest":184 return `...${printParam(p.arg)}${p.tsType ? `: ${printType(p.tsType)}` : ""}`;185 case "assign": {186 const left = printParam(p.left);187 return p.left?.tsType || !p.tsType ? left : `${left}: ${printType(p.tsType)}`;188 }189 case "object":190 return `{ ${(p.props ?? []).map((prop: Param) => prop.name ?? "...").join(", ")} }${191 p.tsType ? `: ${printType(p.tsType)}` : ""192 }`;193 case "array":194 return `[...]${p.tsType ? `: ${printType(p.tsType)}` : ""}`;195 default:196 return p.name ?? "arg";197 }198}199200function printTypeParams(typeParams: TypeParam[]): string {201 if (!typeParams || typeParams.length === 0) return "";202 const parts = typeParams.map((tp) => {203 let out = tp.name;204 if (tp.constraint) out += ` extends ${printType(tp.constraint)}`;205 if (tp.default) out += ` = ${printType(tp.default)}`;206 return out;207 });208 return `<${parts.join(", ")}>`;209}210211// deno-lint-ignore no-explicit-any212function functionSignature(name: string, def: any, isAsync = false): string {213 const asyncPrefix = def?.isAsync || isAsync ? "async " : "";214 const params = (def?.params ?? []).map(printParam).join(", ");215 const ret = def?.returnType ? `: ${printType(def.returnType)}` : "";216 return `${asyncPrefix}function ${name}${printTypeParams(def?.typeParams ?? [])}(${params})${ret}`;217}218219// deno-lint-ignore no-explicit-any220function classSignature(name: string, def: any): string {221 const lines: string[] = [];222 let head = `class ${name}${printTypeParams(def?.typeParams ?? [])}`;223 if (def?.extends) head += ` extends ${def.extends}`;224 if (def?.implements?.length) {225 head += ` implements ${def.implements.map(printType).join(", ")}`;226 }227 lines.push(`${head} {`);228 for (const ctor of def?.constructors ?? []) {229 lines.push(230 ` constructor(${(ctor.params ?? []).map(printParam).join(", ")});`,231 );232 }233 for (const prop of def?.properties ?? []) {234 const mods = [235 prop.isStatic ? "static " : "",236 prop.readonly ? "readonly " : "",237 ].join("");238 lines.push(239 ` ${mods}${prop.name}${prop.optional ? "?" : ""}${240 prop.tsType ? `: ${printType(prop.tsType)}` : ""241 };`,242 );243 }244 for (const method of def?.methods ?? []) {245 const fd = method.functionDef ?? {};246 const mods = method.isStatic ? "static " : "";247 const accessor = method.kind === "getter" ? "get " : method.kind === "setter" ? "set " : "";248 const params = (fd.params ?? []).map(printParam).join(", ");249 const ret = fd.returnType ? `: ${printType(fd.returnType)}` : "";250 lines.push(251 ` ${mods}${accessor}${method.name}${printTypeParams(fd.typeParams ?? [])}(${params})${ret};`,252 );253 }254 lines.push("}");255 return lines.join("\n");256}257258// deno-lint-ignore no-explicit-any259function interfaceSignature(name: string, def: any): string {260 const lines: string[] = [];261 let head = `interface ${name}${printTypeParams(def?.typeParams ?? [])}`;262 if (def?.extends?.length) {263 head += ` extends ${def.extends.map(printType).join(", ")}`;264 }265 lines.push(`${head} {`);266 for (const prop of def?.properties ?? []) {267 lines.push(268 ` ${prop.readonly ? "readonly " : ""}${prop.name}${prop.optional ? "?" : ""}${269 prop.tsType ? `: ${printType(prop.tsType)}` : ""270 };`,271 );272 }273 for (const method of def?.methods ?? []) {274 const params = (method.params ?? []).map(printParam).join(", ");275 const ret = method.returnType ? `: ${printType(method.returnType)}` : "";276 lines.push(277 ` ${method.name}${method.optional ? "?" : ""}${278 printTypeParams(method.typeParams ?? [])279 }(${params})${ret};`,280 );281 }282 lines.push("}");283 return lines.join("\n");284}285286// deno-lint-ignore no-explicit-any287function typeAliasSignature(name: string, def: any): string {288 const printed = printTypeAliasBody(def?.tsType);289 return `type ${name}${printTypeParams(def?.typeParams ?? [])} = ${printed};`;290}291292function printTypeAliasBody(t: TsType | undefined): string {293 if (t?.kind === "typeLiteral") {294 const v = t.value ?? {};295 const parts: string[] = [];296 for (const sig of v.indexSignatures ?? []) {297 parts.push(298 ` [${(sig.params ?? []).map(printParam).join(", ")}]: ${printType(sig.tsType)};`,299 );300 }301 for (const p of v.properties ?? []) {302 parts.push(303 ` ${p.readonly ? "readonly " : ""}${p.name}${p.optional ? "?" : ""}: ${304 printType(p.tsType)305 };`,306 );307 }308 for (const m of v.methods ?? []) {309 const fd = m.functionDef ?? m;310 parts.push(311 ` ${m.name}${m.optional ? "?" : ""}${printTypeParams(fd.typeParams ?? [])}(${312 (fd.params ?? m.params ?? []).map(printParam).join(", ")313 }): ${printType(fd.returnType ?? m.returnType)};`,314 );315 }316 if (parts.length > 0) return `{\n${parts.join("\n")}\n}`;317 }318 if (t?.kind === "union" && (t.value as TsType[]).length > 3) {319 return `\n | ${(t.value as TsType[]).map(printType).join("\n | ")}`;320 }321 return printType(t);322}323324// deno-lint-ignore no-explicit-any325function variableSignature(name: string, def: any): string {326 const keyword = def?.kind ?? "const";327 return `${keyword} ${name}${def?.tsType ? `: ${printType(def.tsType)}` : ""};`;328}329330function declarationSignature(name: string, dec: Declaration): string | null {331 switch (dec.kind) {332 case "function":333 return functionSignature(name, dec.def);334 case "class":335 return classSignature(name, dec.def);336 case "interface":337 return interfaceSignature(name, dec.def);338 case "typeAlias":339 return typeAliasSignature(name, dec.def);340 case "variable":341 return variableSignature(name, dec.def);342 case "enum":343 // deno-lint-ignore no-explicit-any344 return `enum ${name} { ${(dec.def?.members ?? []).map((m: any) => m.name).join(", ")} }`;345 case "namespace":346 return `namespace ${name}`;347 default:348 return null;349 }350}351352/** Converts `{@link X}` / `{@linkcode X}` JSDoc tags to plain Markdown. */353function renderJsDocText(text: string): string {354 return text.replace(355 /\{@link(?:code|plain)?\s+([^}|\s]+)(?:\s*\|\s*([^}]+))?\}/g,356 (_match, target: string, label?: string) => {357 const display = (label ?? target).trim();358 if (/^https?:\/\//.test(target)) return `[${display}](${target})`;359 return `\`${display}\``;360 },361 );362}363364function renderJsDoc(jsDoc: JsDoc | undefined): string {365 if (!jsDoc) return "";366 const out: string[] = [];367 if (jsDoc.doc) out.push(renderJsDocText(jsDoc.doc.trim()));368 const tags = jsDoc.tags ?? [];369 const params = tags.filter((tag) => tag.kind === "param" && tag.doc);370 if (params.length > 0) {371 const rows = [372 "| Parameter | Description |",373 "| --- | --- |",374 ...params.map((tag) =>375 `| \`${tag.name}\` | ${renderJsDocText(tag.doc ?? "").replaceAll("\n", " ")} |`376 ),377 ];378 out.push(rows.join("\n"));379 }380 for (const tag of tags) {381 if (tag.kind === "return" && tag.doc) {382 out.push(`**Returns:** ${renderJsDocText(tag.doc)}`);383 }384 if (tag.kind === "deprecated") {385 out.push(`**Deprecated.** ${renderJsDocText(tag.doc ?? "")}`.trim());386 }387 if (tag.kind === "default") {388 out.push(`**Default:** \`${tag.doc ?? tag.type ?? ""}\``);389 }390 }391 for (const tag of tags) {392 if (tag.kind === "example" && tag.doc) {393 const body = tag.doc.trim();394 const rendered = body.includes("```") ? body : `\`\`\`ts\n${body}\n\`\`\``;395 out.push(`**Example**\n\n${renderJsDocText(rendered)}`);396 }397 }398 return out.join("\n\n");399}400401async function docModule(file: string): Promise<ModuleNodes> {402 const command = new Deno.Command(Deno.execPath(), {403 args: ["doc", "--json", file],404 stdout: "piped",405 stderr: "piped",406 });407 const result = await command.output();408 if (!result.success) {409 throw new Error(410 `deno doc --json ${file} failed:\n${new TextDecoder().decode(result.stderr)}`,411 );412 }413 const parsed = JSON.parse(414 new TextDecoder().decode(result.stdout),415 ) as DocOutput;416 if (parsed.version !== 2) {417 throw new Error(418 `deno doc --json for ${file} returned schema v${parsed.version}; expected v2.`,419 );420 }421 const modules = Object.values(parsed.nodes);422 if (modules.length === 0) {423 throw new Error(`deno doc --json ${file} returned no modules.`);424 }425 return modules[0];426}427428const KIND_LABEL: Record<string, string> = {429 function: "function",430 class: "class",431 interface: "interface",432 typeAlias: "type",433 variable: "const",434 enum: "enum",435 namespace: "namespace",436};437438function renderSymbol(symbol: SymbolNode): string {439 const out: string[] = [];440 const kinds = [441 ...new Set(442 symbol.declarations.map((dec) => KIND_LABEL[dec.kind] ?? dec.kind),443 ),444 ];445 // h2 keeps the heading hierarchy sequential under the page h1446 out.push(`## ${symbol.name}`);447 out.push(`<sup>${kinds.join(", ")}</sup>`);448 const signatures = symbol.declarations449 .map((dec) => declarationSignature(symbol.name, dec))450 .filter((sig): sig is string => sig !== null);451 if (signatures.length > 0) {452 out.push(`\`\`\`ts\n${signatures.join("\n\n")}\n\`\`\``);453 }454 const jsDoc = symbol.declarations.find((dec) => dec.jsDoc)?.jsDoc;455 const rendered = renderJsDoc(jsDoc);456 if (rendered) out.push(rendered);457 return out.join("\n\n");458}459460function symbolSortKey(symbol: SymbolNode): [number, string] {461 const kindRank =462 symbol.declarations.some((dec) =>463 dec.kind === "function" || dec.kind === "class" ||464 dec.kind === "variable"465 )466 ? 0467 : 1;468 return [kindRank, symbol.name.toLowerCase()];469}470471function moduleSummary(moduleDoc: JsDoc | undefined): string {472 const doc = moduleDoc?.doc ?? "";473 const firstSentence = doc.split(/\n\s*\n/)[0]?.replaceAll("\n", " ").trim() ??474 "";475 return renderJsDocText(firstSentence);476}477478type PageGroup = {479 page: string;480 position: number;481 entries: typeof entrypoints[number][];482};483484const pageOrder = NODE_MODE485 ? ["node", "testing"]486 : ["runtime", "schema", "schema-store", "astro", "access", "preact", "testing", "cli"];487const groups = new Map<string, PageGroup>();488for (const entry of activeEntrypoints) {489 const group = groups.get(entry.page) ?? {490 page: entry.page,491 position: entry.page.startsWith("recipes/")492 ? 1 +493 [...activeEntrypoints.filter((e) => e.page.startsWith("recipes/"))]494 .findIndex((e) => e.page === entry.page)495 : 1 + pageOrder.indexOf(entry.page),496 entries: [],497 };498 group.entries.push(entry);499 groups.set(entry.page, group);500}501502const denoJson = JSON.parse(503 await Deno.readTextFile(NODE_MODE ? `${NODE_DIR}/deno.json` : "deno.json"),504) as {505 version: string;506 description: string;507};508509await Deno.mkdir(NODE_MODE ? OUT_DIR : `${OUT_DIR}/recipes`, {510 recursive: true,511});512513const indexRows: string[] = [];514let totalSymbols = 0;515516for (const group of groups.values()) {517 const sections: string[] = [];518 let pageTitle: string;519 if (group.page === "cli") {520 pageTitle = "CLI commands";521 sections.push(522 "Executable entrypoints run with `deno run` (the generated project wires them up as `deno task` commands). They expose behavior, not exported symbols.",523 );524 } else {525 pageTitle = group.entries[0].title;526 }527528 for (const entry of group.entries) {529 const module = await docModule(entry.file);530 const symbols = (module.symbols ?? []).filter((symbol) =>531 symbol.declarations.some((dec) => dec.declarationKind === "export")532 );533 symbols.sort((a, b) => {534 const [rankA, nameA] = symbolSortKey(a);535 const [rankB, nameB] = symbolSortKey(b);536 return rankA - rankB || nameA.localeCompare(nameB);537 });538 const isCommand = group.page === "cli";539 if (!isCommand && symbols.length === 0) {540 throw new Error(541 `No exported symbols found for ${entry.file}; refusing to emit empty page.`,542 );543 }544 if (!module.module_doc?.doc && symbols.length === 0) {545 throw new Error(`No module doc or symbols for ${entry.file}.`);546 }547 totalSymbols += symbols.length;548549 const jsrSpecifier = entry.jsrName === "."550 ? packageName551 : `${packageName}${entry.jsrName.slice(1)}`;552 if (isCommand) {553 sections.push(`## ${entry.title}`);554 sections.push(`\`\`\`sh\ndeno run -A jsr:${jsrSpecifier}\n\`\`\``);555 if (module.module_doc?.doc) {556 sections.push(renderJsDocText(module.module_doc.doc.trim()));557 }558 } else {559 sections.push(`\`\`\`ts\nimport * as mod from "jsr:${jsrSpecifier}";\n\`\`\``);560 if (module.module_doc?.doc) {561 sections.push(renderJsDocText(module.module_doc.doc.trim()));562 }563 sections.push(...symbols.map(renderSymbol));564 }565 indexRows.push(566 `| [\`${jsrSpecifier}\`](./${group.page}.md${isCommand ? `#${entry.title}` : ""}) | ${567 moduleSummary(module.module_doc)568 } |`,569 );570 }571572 const frontmatter = [573 "---",574 `title: ${JSON.stringify(pageTitle)}`,575 `sidebar_position: ${group.position}`,576 "---",577 ].join("\n");578 const body = `${frontmatter}\n\n# ${pageTitle}\n\n${sections.join("\n\n")}\n`;579 await Deno.writeTextFile(`${OUT_DIR}/${group.page}.md`, body);580}581582const indexIntro = NODE_MODE583 ? `Generated from the JSDoc of [\`@nzip/lofi-node\`](https://jsr.io/@nzip/lofi-node) v${denoJson.version}.584${denoJson.description}`585 : `Generated from the JSDoc of [\`@nzip/lofi\`](https://jsr.io/@nzip/lofi) v${denoJson.version}.586${denoJson.description}`;587588const index = `---589title: "API reference"590sidebar_position: 0591slug: /592---593594# ${NODE_MODE ? "lofi-node API reference" : "API reference"}595596${indexIntro}597598| Export | Summary |599| --- | --- |600${indexRows.join("\n")}601`;602await Deno.writeTextFile(`${OUT_DIR}/index.md`, index);603604if (!NODE_MODE) {605 await Deno.writeTextFile(606 `${OUT_DIR}/recipes/_category_.json`,607 `${608 JSON.stringify(609 { label: "Recipes", position: 8, collapsed: true },610 null,611 2,612 )613 }\n`,614 );615}616617console.log(618 `Generated ${groups.size + 1} API pages (${totalSymbols} exported symbols) into ${OUT_DIR}/.`,619);