shelving
Version:
Toolkit for using data in JavaScript.
409 lines (408 loc) • 16.9 kB
JavaScript
import ts from "typescript";
import { requireSlug } from "../util/string.js";
import { FileExtractor } from "./FileExtractor.js";
import { extractMarkdownProps } from "./MarkupExtractor.js";
/**
* File extractor that parses a TypeScript source file into a tree element.
* - Uses the TypeScript compiler API to parse the AST.
* - Extracts exported, public, non-`_`-prefixed declarations as `tree-documentation` children.
* - Overloaded declarations sharing a name are merged into a single `tree-documentation` with multiple `signatures`.
* - Top-of-file JSDoc comment becomes the file's `content`.
* - Sets `description` (a plain-text summary from the first JSDoc paragraph) on the file and every `tree-documentation` child.
* - Sets `title` on every `tree-documentation` child — `name()` for functions and methods, `name` for other kinds.
* - The file element itself has no `title` — a TS source file has no confident title source; renderers fall back to `name`.
*/
export class TypescriptExtractor extends FileExtractor {
extractProps(name, text) {
const source = ts.createSourceFile(name, text, ts.ScriptTarget.Latest, true);
const content = _getFileDocComment(source);
// Collect elements by key, merging overloads (same name) by appending signatures.
const byKey = new Map();
for (const statement of source.statements) {
const element = _extractStatement(statement, source);
if (!element)
continue;
const existing = byKey.get(element.key);
byKey.set(element.key, existing ? _mergeOverloads(existing, element) : element);
}
// The file element itself gets no `title` — a TS source file has no confident title source (the filename isn't one),
// so renderers fall back to `name`. The `tree-documentation` children each carry their own `title`.
return { name, description: extractMarkdownProps(content ?? "").description, content, children: Array.from(byKey.values()) };
}
}
/** Merge a newly-extracted overload into the existing documentation element with the same key. */
function _mergeOverloads(existing, next) {
const a = existing.props;
const b = next.props;
const merged = {
...a,
// Keep first content encountered; fill in if `existing` had none.
content: a.content ?? b.content,
// Append signatures.
signatures: _concat(a.signatures, b.signatures),
// Append params, returns, throws, examples — never dedupe (per spec).
params: _concat(a.params, b.params),
returns: _concat(a.returns, b.returns),
throws: _concat(a.throws, b.throws),
examples: _concat(a.examples, b.examples),
};
return { ...existing, props: merged };
}
function _concat(a, b) {
if (!a)
return b;
if (!b)
return a;
return [...a, ...b];
}
/** Get the leading JSDoc comment of the file (before the first statement). */
function _getFileDocComment(source) {
const { statements } = source;
if (!statements.length)
return;
const first = statements[0];
if (!first)
return;
const ranges = ts.getLeadingCommentRanges(source.text, first.pos);
if (!ranges?.length)
return;
const range = ranges[0];
if (!range || range.kind !== ts.SyntaxKind.MultiLineCommentTrivia)
return;
const text = source.text.slice(range.pos, range.end);
return _parseJSDocComment(text);
}
/** Extract an element from a top-level statement, or return undefined if it should be skipped. */
function _extractStatement(statement, source) {
// Skip non-exported statements.
if (!_isExported(statement))
return;
// Skip statements without a name.
const name = _getStatementName(statement);
if (!name)
return;
// Skip private/internal names.
if (name.startsWith("_"))
return;
const jsDoc = _getJSDoc(statement, source);
const kind = _getKind(statement);
if (!kind)
return;
const signature = _getSignature(statement, source);
const params = _getParams(statement, source, jsDoc?.params);
const returns = _getReturns(statement, source, jsDoc?.returns);
const throws = jsDoc?.throws;
const examples = jsDoc?.examples;
const children = _getClassMembers(statement, source);
return {
type: "tree-documentation",
key: requireSlug(name),
props: {
name,
// Functions read as callable with `()`; other kinds use the bare name.
title: kind === "function" ? `${name}()` : name,
kind,
description: extractMarkdownProps(jsDoc?.description ?? "").description,
content: _buildJSDocContent(jsDoc?.description, jsDoc?.unhandled),
signatures: signature ? [signature] : undefined,
params,
returns,
throws,
examples,
children,
},
};
}
/**
* Combine the JSDoc leading-description text and any unhandled `@rule` blocks into a single markup content string.
* - Unhandled rules (anything not `@param`/`@returns`/`@throws`/`@example`) are appended after the description, separated by blank lines, with their `@name` preserved.
* - Returns `undefined` if both are empty.
*/
function _buildJSDocContent(description, unhandled) {
if (!description)
return unhandled;
if (!unhandled)
return description;
return `${description}\n\n${unhandled}`;
}
/** Check if a statement has an `export` modifier. */
function _isExported(statement) {
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
return !!modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
}
/** Get the declared name of a statement. */
function _getStatementName(statement) {
if (ts.isFunctionDeclaration(statement) ||
ts.isClassDeclaration(statement) ||
ts.isInterfaceDeclaration(statement) ||
ts.isTypeAliasDeclaration(statement) ||
ts.isEnumDeclaration(statement)) {
return statement.name?.text;
}
if (ts.isVariableStatement(statement)) {
const declaration = statement.declarationList.declarations[0];
if (declaration && ts.isIdentifier(declaration.name))
return declaration.name.text;
}
}
/** Map a statement to its documentation kind. */
function _getKind(statement) {
if (ts.isFunctionDeclaration(statement))
return "function";
if (ts.isClassDeclaration(statement))
return "class";
if (ts.isInterfaceDeclaration(statement))
return "interface";
if (ts.isTypeAliasDeclaration(statement))
return "type";
if (ts.isVariableStatement(statement))
return "constant";
}
/** Get the text signature of a statement. */
function _getSignature(statement, source) {
if (ts.isFunctionDeclaration(statement)) {
const params = statement.parameters.map(p => p.getText(source)).join(", ");
const ret = statement.type ? statement.type.getText(source) : "void";
return `(${params}) => ${ret}`;
}
if (ts.isTypeAliasDeclaration(statement)) {
return statement.type.getText(source);
}
if (ts.isVariableStatement(statement)) {
const declaration = statement.declarationList.declarations[0];
if (declaration?.type)
return declaration.type.getText(source);
}
}
/** Extract parameters from a function or method declaration, enriched with JSDoc `@param` descriptions. */
function _getParams(statement, source, jsDocParams) {
if (!ts.isFunctionDeclaration(statement))
return;
const params = statement.parameters.map(p => {
const name = p.name.getText(source);
const type = p.type?.getText(source);
const optional = !!p.questionToken || !!p.initializer;
const description = jsDocParams?.find(d => d.name === name)?.description;
return { name, type, description, optional };
});
return params.length ? params : undefined;
}
/** Extract return entries — combines the signature return type with any `@returns` descriptions. */
function _getReturns(statement, source, jsDocReturns) {
if (!ts.isFunctionDeclaration(statement))
return jsDocReturns;
const type = statement.type?.getText(source);
if (jsDocReturns?.length) {
// Merge: first entry gets the inferred type if it doesn't already have one.
const [first, ...rest] = jsDocReturns;
if (!first)
return jsDocReturns;
return [{ type: first.type ?? type, description: first.description }, ...rest];
}
if (type && type !== "void")
return [{ type }];
}
/** Extract class or interface members as child elements. */
function _getClassMembers(statement, source) {
if (!ts.isClassDeclaration(statement) && !ts.isInterfaceDeclaration(statement))
return;
const members = [];
for (const member of statement.members) {
// Skip private, protected, and _-prefixed members.
const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined;
if (!name || name.startsWith("_"))
continue;
if (ts.canHaveModifiers(member)) {
const modifiers = ts.getModifiers(member);
if (modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword))
continue;
}
const memberJSDoc = _getJSDoc(member, source);
const content = _buildJSDocContent(memberJSDoc?.description, memberJSDoc?.unhandled);
if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) {
const params = member.parameters.map(p => p.getText(source)).join(", ");
const ret = member.type ? member.type.getText(source) : "void";
const signature = `(${params}) => ${ret}`;
const key = requireSlug(name);
const existingIndex = members.findIndex(m => m.key === key);
const existing = members[existingIndex];
if (existing) {
members[existingIndex] = {
...existing,
props: { ...existing.props, signatures: _concat(existing.props.signatures, [signature]) },
};
}
else {
members.push({
type: "tree-documentation",
key,
props: {
name,
title: `${name}()`,
description: extractMarkdownProps(memberJSDoc?.description ?? "").description,
content,
kind: "method",
signatures: [signature],
},
});
}
}
else if (ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) {
const type = member.type?.getText(source);
members.push({
type: "tree-documentation",
key: requireSlug(name),
props: {
name,
title: name,
description: extractMarkdownProps(memberJSDoc?.description ?? "").description,
content,
kind: "property",
signatures: type ? [type] : undefined,
},
});
}
}
return members.length ? members : undefined;
}
/** `@rule` names handled by dedicated parsers — everything else is appended to `unhandled` as raw markup. */
const _HANDLED_RULES = new Set(["param", "params", "return", "returns", "throw", "throws", "example", "examples"]);
/** Extract JSDoc from a node. */
function _getJSDoc(node, source) {
const ranges = ts.getLeadingCommentRanges(source.text, node.pos);
if (!ranges?.length)
return;
// Find the last JSDoc-style comment (/** ... */).
for (let i = ranges.length - 1; i >= 0; i--) {
const range = ranges[i];
if (!range || range.kind !== ts.SyntaxKind.MultiLineCommentTrivia)
continue;
const text = source.text.slice(range.pos, range.end);
if (!text.startsWith("/**"))
continue;
const description = _parseJSDocComment(text);
const params = _parseJSDocParams(text);
const returns = _parseJSDocReturns(text);
const throws = _parseJSDocThrows(text);
const examples = _parseJSDocExamples(text);
const unhandled = _parseJSDocUnhandled(text);
return {
description: description || undefined,
params: params.length ? params : undefined,
returns: returns.length ? returns : undefined,
throws: throws.length ? throws : undefined,
examples: examples.length ? examples : undefined,
unhandled,
};
}
}
/**
* Walk the JSDoc body for `@rule` blocks not handled by dedicated parsers (param/returns/throws/example).
* - Each rule block extends from its `@name` line up to the next `@rule` or the end of the docblock.
* - Returns the unhandled blocks joined by blank lines as `@name body`, preserved verbatim for downstream markup rendering.
* - Returns `undefined` if every rule is handled or there are none.
*/
function _parseJSDocUnhandled(text) {
const body = text
.replace(/^\/\*\*\s*/, "")
.replace(/\s*\*\/$/, "")
.split("\n")
.map(l => l.replace(/^\s*\*\s?/, ""))
.join("\n");
const sections = [];
let currentName;
let currentLines = [];
const flush = () => {
if (currentName && !_HANDLED_RULES.has(currentName)) {
sections.push(`@${currentName} ${currentLines.join("\n")}`.trimEnd());
}
currentName = undefined;
currentLines = [];
};
for (const line of body.split("\n")) {
const match = line.match(/^@(\w+)\s*(.*)$/);
if (match) {
flush();
currentName = match[1];
currentLines = match[2] ? [match[2]] : [];
}
else if (currentName) {
currentLines.push(line);
}
}
flush();
return sections.length ? sections.join("\n\n") : undefined;
}
/** Parse a JSDoc comment block into its description text. */
function _parseJSDocComment(text) {
const lines = text
.replace(/^\/\*\*\s*/, "")
.replace(/\s*\*\/$/, "")
.split("\n")
.map(l => l.replace(/^\s*\*\s?/, ""));
// Collect lines until we hit a @tag.
const description = [];
for (const line of lines) {
if (line.startsWith("@"))
break;
description.push(line);
}
const result = description.join("\n").trim();
return result || undefined;
}
/** Parse `@param` tags from a JSDoc comment. Duplicates are kept (overloads). */
function _parseJSDocParams(text) {
const results = [];
// `@param {Type} name description` — type is optional.
const regexp = /@param\s+(?:\{([^}]*)\}\s+)?(\w+)\s+(.*)/g;
let match;
while ((match = regexp.exec(text))) {
const type = match[1]?.trim();
const name = match[2];
const description = match[3]?.trim();
if (name)
results.push({ name, type: type || undefined, description: description || undefined });
}
return results;
}
/** Parse `@returns` / `@return` tags from a JSDoc comment. */
function _parseJSDocReturns(text) {
const results = [];
// `@returns {Type} description` or `@return {Type} description`.
const regexp = /@returns?\s+(?:\{([^}]*)\}\s*)?(.*)/g;
let match;
while ((match = regexp.exec(text))) {
const type = match[1]?.trim();
const description = match[2]?.trim();
if (type || description)
results.push({ type: type || undefined, description: description || undefined });
}
return results;
}
/** Parse `@throws` / `@throw` tags from a JSDoc comment. */
function _parseJSDocThrows(text) {
const results = [];
// `@throws {Type} description` or `@throw {Type} description`.
const regexp = /@throws?\s+(?:\{([^}]*)\}\s*)?(.*)/g;
let match;
while ((match = regexp.exec(text))) {
const type = match[1]?.trim();
const description = match[2]?.trim();
if (type || description)
results.push({ type: type || undefined, description: description || undefined });
}
return results;
}
/** Parse `@example` tags from a JSDoc comment. */
function _parseJSDocExamples(text) {
const results = [];
// `@example` followed by the rest of the line.
const regexp = /@examples?\s+(.+)/g;
let match;
while ((match = regexp.exec(text))) {
const description = match[1]?.trim();
if (description)
results.push({ description });
}
return results;
}