UNPKG

builddocs

Version:

Generate HTML documentation from TypeScript types and doc comments

452 lines (451 loc) 19.8 kB
import * as fs from "node:fs"; import { getDocs } from "./getdocs.js"; import { typeLinks, simpleTypeLink } from "./typelinks.js"; import { seq, seqA, sep, space, nbsp, text, maybeBreak, delim, ref, format } from "./format.js"; function maybeRef(i, shape) { return i.description ? seq(shape, ref(i)) : shape; } export class BuildError extends Error { } function fail(reason) { throw new BuildError(reason); } function kw(name) { return text(name, { class: "keyword" }); } function findIn(mod, path) { let items = mod.items.filter(i => i.name == path[0].name); for (let i = 1; i < path.length && items.length; i++) { let newItems = []; let add = (obj) => { if (obj.name == path[i].name) newItems.push(obj); }; for (let j = 0; j < items.length; j++) { let item = items[j]; if ("items" in item) for (let i of item.items) add(i); if ("members" in item) for (let i of item.members) add(i); if ("typeParams" in item && item.typeParams) for (let p of item.typeParams) add(p); if ("signatures" in item && item.signatures) for (let sig of item.signatures) { for (let p of sig.params) add(p); if (sig.typeParams) for (let p of sig.typeParams) add(p); } } items = newItems; if (path[i].sep == "#") { let stat = items.filter(item => (item.kind != "property" && item.kind != "method" || item.static)); if (stat.length) items = stat; } } items.sort((a, b) => itemScore(b) - itemScore(a)); return items.length ? items[0] : null; } /// Try to resolve a `@link`-style path into the given project. export function resolveLink(project, path, homemod) { let pieces = []; while (path) { let next = /^([.#])?([^.#]+)/.exec(path); if (!next) break; pieces.push({ name: next[2], sep: next[1] || "." }); path = path.slice(next[0].length); } let top = pieces.length > 1 && project.find(m => m.modname == pieces[0].name); let home = homemod && project.find(m => m.modname == homemod) || null; let found = top && findIn(top, pieces.slice(1)) || home && findIn(home, pieces); if (!found) for (let mod of project) { found = findIn(mod, pieces); if (found) break; } return found && { id: found.id, name: found.qualifiedName || found.name }; } /// Find and resolve all `@link` references in the given Markdown /// string. Will call `warn` when running into an unresolved link. export function resolveLinks(project, text, config = {}) { let prefix = config.prefix || ""; return text.replace(/\{@link\s+([^\s}]+)\s*(?:(?:\|\s*)?([^}]*))\}/g, (_, target, text) => { let found = resolveLink(project, target, config.homemod); if (!found) { if (config.warn) config.warn(target); return text || `\`${target}\``; } return `[${text || `\`${found.name}\``}](${prefix}#${found.id})`; }); } function itemScore(i) { return i.kind == "namespace" ? 0 : i.kind == "typeparam" ? 1 : i.kind == "param" ? 2 : (i.kind == "typealias" || i.kind == "interface" || i.kind == "class") ? 4 : 3; } class Builder { html = ""; mod; conf; renderMarkdown; maxCol; project; constructor(project, mod, conf) { this.project = project; this.conf = conf; this.mod = mod; this.renderMarkdown = conf.renderMarkdown; this.maxCol = conf.maxCol ?? 55; } prepareDef(b, useQualified = true, id = true) { let props = { href: "#" + b.id, class: "def" }; if (id) props.id = b.id; return text(typeof useQualified == "string" ? useQualified : (useQualified && b.qualifiedName) || b.name, props); } prepareHeadDef(b) { return text(b.qualifiedName || b.name, { href: "#" + b.id, class: "def heading", id: b.id }); } renderComment(comment, indent) { this.html += `<div class="doc${indent ? ` indent${indent}` : ""}">\n${this.renderMarkdown(resolveLinks(this.project, comment, { homemod: this.mod.modname, warn: target => { console.warn(`Failed to resolve link to ${target} in ${this.mod.modname}`); } }))}</div>\n`; } emitItem(item) { if (item.kind == "enum") { let inner = [], hasComment = false; if (item.items) for (let child of item.items) { inner.push(maybeRef(child, this.prepareDef(child, false))); if (child.description) hasComment = true; } this.html += `<section class=enum>\n`; this.format(delim(seq(kw("enum"), nbsp, this.prepareDef(item), nbsp, maybeRef(item, text("{"))), sep(inner), text("}"), hasComment)); this.html += `</section>\n`; } else if (item.kind == "class") { this.html += `<section class=class>\n`; let content = []; if (item.abstract) content.push(kw("abstract"), nbsp); content.push(kw("class"), nbsp, this.prepareHeadDef(item)); if (item.typeParams) content.push(this.prepareTypeParams(item.typeParams)); if (item.extends) content.push(space, kw("extends"), nbsp, this.prepareType(item.extends)); if (item.implements) content.push(space, kw("implements"), nbsp, seqA(sep(item.implements.map(t => this.prepareType(t))))); content.push(nbsp, this.prepareObjectType(item, true, item)); this.format(seqA(content)); this.html += `</section>\n`; } else if (item.kind == "typealias") { let needsSection = item.type == "object" && item.members.some(m => m.description); if (needsSection) this.html += `<section class=typealias>\n`; let parts = [kw("type"), nbsp, needsSection ? this.prepareHeadDef(item) : this.prepareDef(item)]; if (item.typeParams) parts.push(this.prepareTypeParams(item.typeParams)); parts.push(nbsp, text("="), space, needsSection ? this.prepareObjectType(item, true, item) : maybeRef(item, this.prepareType(item))); this.format(seqA(parts)); if (needsSection) this.html += `</section>\n`; } else if (item.kind == "function") { for (let i = 0; i < item.signatures.length; i++) { let sig = item.signatures[i]; let content = [this.prepareDef(item, true, i == 0)]; if (sig.typeParams) content.push(this.prepareTypeParams(sig.typeParams)); content.push(this.prepareParams(sig.params)); content.push(text(":"), space); content.push(sig.returns ? this.prepareType(sig.returns) : kw("void")); if (i == item.signatures.length - 1 && item.description) content.push(ref(item)); this.format(seqA(content)); } } else if (item.kind == "interface") { this.html += `<section class=interface>\n`; let parts = [kw("interface"), nbsp, this.prepareHeadDef(item)]; if (item.implements) parts.push(space, kw("extends"), nbsp, seqA(sep(item.implements.map(n => this.prepareType(n))))); parts.push(nbsp, this.prepareObjectType(item, true, item)); this.format(seqA(parts)); this.html += `</section>\n`; } else if (item.kind == "variable") { this.format(seq(this.prepareDef(item), text(":"), space, item.type == "object" ? this.prepareObjectType(item, false, item) : maybeRef(item, this.prepareType(item)))); } else if (item.kind == "namespace") { if (item.description) this.renderComment(item.description); for (let i of item.items) this.emitItem(i); } else if (item.kind == "reexport") { let parts = [kw("export"), nbsp, text("{"), this.prepareReference(item)]; if (item.typeName != item.name) parts.push(nbsp, kw("as"), nbsp, text(item.name)); parts.push(text("}")); this.format(seqA(parts)); } else { fail("Unhandled export kind: " + item.kind); } } prepareType(type, contextPrec = 0) { let inner = this.prepareTypeInner(type); return contextPrec && typePrec(type.type) <= contextPrec ? seq(text("("), inner, text(")")) : inner; } prepareReference(type) { let base; if (type.local) { base = text(type.typeName, { href: "#" + type.local, class: "ref" }); } else { let link; if (this.conf.resolveType) for (let res of this.conf.resolveType) { if (typeof res == "function" ? (link = res(type)) : (link = res[type.typeName])) break; } if (!link) link = typeLinks[type.typeName]; if (!link) console.warn(`Unresolved type ${type.typeName}`); base = link ? text(type.typeName, { class: "ref", href: link }) : text(type.typeName, { class: "ref" }); } return !type.typeArgs ? base : seq(base, delim(text("<"), sep(type.typeArgs.map(a => this.prepareType(a))), text(">"))); } prepareTypeInner(type) { if (type.type == "reference") { if (type.typeName == "Array" && type.typeArgs) return seq(this.prepareType(type.typeArgs[0], 4), text("[]")); if (type.typeName == "ReadonlyArray" && type.typeArgs) return seq(kw("readonly"), nbsp, this.prepareType(type.typeArgs[0], 4), text("[]")); return this.prepareReference(type); } else if (type.type == "simple") { return text(type.typeName, { class: "prim", href: simpleTypeLink[type.typeName] }); } else if (type.type == "literal") { if (typeof type.value == "boolean") return text(String(type.value), { class: "literal", href: typeLinks[String(type.value)] }); return text(JSON.stringify(type.value), { class: "literal" }); } else if (type.type == "union" || type.type == "intersection") { let inner = type.type == "union" ? sep(type.typeArgs.map(t => this.prepareType(t, 1)), "|") : sep(type.typeArgs.map(t => this.prepareType(t, 2)), "&"); // If all the types are simple, allow breaking anywhere. // Otherwise, put each element on a separate line if the type // needs to be broken. let simple = type.typeArgs.every(t => t.type == "simple" || t.type == "reference" || t.type == "literal"); return simple ? seqA(inner) : maybeBreak(inner); } else if (type.type == "tuple") { return delim(text("["), sep(type.typeArgs.map(t => this.prepareType(t))), text("]")); } else if (type.type == "conditional") { return seq(this.prepareType(type.inner, 3), type.extends ? seq(space, kw("extends"), nbsp, this.prepareType(type.extends, 1)) : space, nbsp, text("?"), space, this.prepareType(type.true), nbsp, text(":"), space, this.prepareType(type.false)); } else if (type.type == "indexed") { return seq(this.prepareType(type.inner), text("["), this.prepareType(type.key), text("]")); } else if (type.type == "object" || type.type == "interface" || type.type == "class") { return this.prepareObjectType(type, false); } else if (type.type == "predicate") { return seq(type.target == "this" ? kw("this") : text(type.target), nbsp, kw("is"), nbsp, this.prepareType(type.inner)); } else if (type.type == "mapped") { let parts = [text("{"), text("["), text(type.key.name)]; if (type.key.extends) parts.push(nbsp, kw("in"), space, this.prepareType(type.key.extends)); parts.push(text("]:"), space, this.prepareType(type.inner), text("}")); return seqA(parts); } else if (type.type == "infer" || type.type == "typeof" || type.type == "keyof") { return seq(kw(type.type), nbsp, this.prepareType(type.inner, 3)); } else if (type.type == "function") { let sigs = type.signatures.map(sig => { let parts = []; if (sig.typeParams) parts.push(this.prepareTypeParams(sig.typeParams)); parts.push(this.prepareParams(sig.params), nbsp, text("=>"), space, sig.returns ? this.prepareType(sig.returns) : kw("void")); return seqA(parts); }); return sigs.length == 1 ? sigs[0] : delim(text("{"), sigs, text("}"), true); } else { fail("Unhandled type: " + type.type); } } prepareParam(param) { let parts = []; if (param.rest) parts.push(text("...")); parts.push(this.prepareDef(param)); if (param.optional && !param.defaultValue) parts.push(text("?")); parts.push(text(":"), nbsp, this.prepareType(param)); if (param.defaultValue) parts.push(nbsp, text("="), space, text(param.defaultValue)); return seqA(parts); } prepareParams(params) { return delim(text("("), sep(params.map((p, i) => maybeRef(p, this.prepareParam(p)))), text(")"), params.some(p => p.description)); } prepareTypeParam(param) { let result = this.prepareDef(param); if (param.extends) result = seq(result, nbsp, kw("extends"), nbsp, this.prepareType(param.extends)); if (param.default) result = seq(result, nbsp, text("="), space, this.prepareType(param.default)); return result; } prepareTypeParams(params) { return delim(text("<"), sep(params.map(p => maybeRef(p, this.prepareTypeParam(p)))), text(">")); } prepareObjectType(type, mustBreak = false, addRef) { let fProps = []; if (addRef || type.type != "object") mustBreak = true; if (type.type != "class" && type.signatures) { for (let sig of type.signatures) { let parts = []; if (sig.typeParams) parts.push(this.prepareTypeParams(sig.typeParams)); parts.push(this.prepareParams(sig.params)); parts.push(nbsp, text("=>"), nbsp, sig.returns ? this.prepareType(sig.returns) : kw("void")); fProps.push(seqA(parts)); } } for (let member of type.members) { if (member.kind == "property") { let parts = []; if (member.abstract) parts.push(kw("abstract"), nbsp); if (member.protected) parts.push(kw("protected"), nbsp); if (member.static) parts.push(kw("static"), nbsp); parts.push(this.prepareDef(member, false)); if (member.optional) parts.push(text("?")); parts.push(text(":"), nbsp, this.prepareType(member)); if (member.description) { mustBreak = true; parts.push(ref(member)); } fProps.push(seqA(parts)); } else { for (let i = 0; i < member.signatures.length; i++) { let parts = [], sig = member.signatures[i]; if (member.kind == "method" && member.abstract) parts.push(kw("abstract"), nbsp); if (member.protected) parts.push(kw("protected"), nbsp); if (member.kind == "method" && member.static) parts.push(kw("static"), nbsp); if (member.kind == "method") { parts.push(this.prepareDef(member, false, i == 0)); } else if (addRef) { parts.push(kw("new"), nbsp, this.prepareDef(member, addRef.name, i == 0)); } else { parts.push(kw("new")); } if (sig.typeParams) parts.push(this.prepareTypeParams(sig.typeParams)); parts.push(this.prepareParams(sig.params)); if (sig.returns) parts.push(text(":"), nbsp, this.prepareType(sig.returns)); if (i == member.signatures.length - 1 && member.description) { mustBreak = true; parts.push(ref(member)); } fProps.push(seqA(parts)); } } } return delim(addRef ? maybeRef(addRef, text("{")) : text("{"), mustBreak ? fProps : sep(fProps), text("}"), mustBreak); } format(content) { let { layout, refs } = format(content, this.maxCol, 2), open = true, refI = 0; this.html += `<pre class="refcode">`; for (let i = 0; i < layout.length; i++) { if (!open) { this.html += `<pre class="refcode">`; open = true; } let line = layout[i]; this.html += line.render() + "\n"; while (refI < refs.length && refs[refI].line == i) { let decl = refs[refI++].ref; if (decl.description) { if (open) { this.html += `</pre>\n`; open = false; } this.renderComment(decl.description, (line.indent - line.extraIndent) / 2 + 1); } } if (line.space && open) { open = false; this.html += `</pre>\n`; } } if (open) this.html += `</pre>\n`; } buildModule(template) { for (let elt of template) { if (typeof elt == "string") { this.renderComment(elt); } else { for (let item of this.mod.items) if (item.name == elt.export) this.emitItem(item); } } } } const typePrecs = { function: 1, conditional: 1, union: 2, intersection: 3, infer: 4, keyof: 4 }; function typePrec(type) { return typePrecs[type] ?? 5; } /// Build the documentation for the given set of modules. export function buildDocs(conf) { let mods = conf.modules.every(m => "items" in m) ? conf.modules : getDocs({ modules: conf.modules }); return mods.map((mod) => { let builder = new Builder(mods, mod, conf); let code = fs.readFileSync(mod.filename, "utf8"); builder.buildModule(mod.template); return { modname: mod.modname, module: mod, filename: mod.filename, text: builder.html, mainFile: code }; }); }