builddocs
Version:
Generate HTML documentation from TypeScript types and doc comments
452 lines (451 loc) • 19.8 kB
JavaScript
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(/\{\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 };
});
}