@mintlify/scraping
Version:
Scrape documentation frameworks to Mintlify docs
424 lines (387 loc) • 14.4 kB
text/typescript
import fse from 'fs-extra';
import path from 'node:path';
import { markdownToMdx } from '../convert.js';
import type { SdkNavGroup, SdkPage, SdkReference } from '../types.js';
const KIND = {
project: 1,
module: 2,
namespace: 4,
enum: 8,
enumMember: 16,
variable: 32,
function: 64,
class: 128,
interface: 256,
constructor: 512,
property: 1024,
method: 2048,
callSignature: 4096,
indexSignature: 8192,
constructorSignature: 16384,
parameter: 32768,
typeLiteral: 65536,
accessor: 262144,
getSignature: 524288,
setSignature: 1048576,
typeAlias: 2097152,
} as const;
type CommentPart = { kind: string; text: string; target?: number | string };
type Comment = { summary?: CommentPart[]; blockTags?: { tag: string; content: CommentPart[] }[] };
type TypedocType = {
type?: string;
name?: string;
value?: unknown;
types?: TypedocType[];
elementType?: TypedocType;
typeArguments?: TypedocType[];
target?: number | TypedocType;
declaration?: TypedocNode;
elements?: TypedocType[];
checkType?: TypedocType;
extendsType?: TypedocType;
trueType?: TypedocType;
falseType?: TypedocType;
operator?: string;
indexType?: TypedocType;
objectType?: TypedocType;
qualifiedName?: string;
head?: string;
tail?: [TypedocType, string][];
};
type TypedocNode = {
id: number;
name: string;
kind: number;
comment?: Comment;
children?: TypedocNode[];
signatures?: TypedocNode[];
parameters?: TypedocNode[];
type?: TypedocType;
flags?: { isOptional?: boolean; isStatic?: boolean; isReadonly?: boolean; isPrivate?: boolean };
defaultValue?: string;
getSignature?: TypedocNode;
setSignature?: TypedocNode;
extendedTypes?: TypedocType[];
implementedTypes?: TypedocType[];
inheritedFrom?: { name: string };
};
const SECTIONS: { kind: number; group: string; dir: string; tag: string }[] = [
{ kind: KIND.class, group: 'Classes', dir: 'classes', tag: 'CLASS' },
{ kind: KIND.interface, group: 'Interfaces', dir: 'interfaces', tag: 'INTERFACE' },
{ kind: KIND.enum, group: 'Enumerations', dir: 'enums', tag: 'ENUM' },
{ kind: KIND.function, group: 'Functions', dir: 'functions', tag: 'FUNCTION' },
{ kind: KIND.typeAlias, group: 'Type Aliases', dir: 'types', tag: 'TYPE' },
{ kind: KIND.variable, group: 'Variables', dir: 'variables', tag: 'VARIABLE' },
];
export async function convertTypedoc(sourcePath: string): Promise<SdkReference> {
const project = (await fse.readJson(sourcePath)) as TypedocNode;
const modules =
project.children?.some((child) => child.kind === KIND.module) === true
? (project.children?.filter((child) => child.kind === KIND.module) ?? [])
: [project];
const multiModule = modules.length > 1;
const slugById = new Map<number, string>();
for (const module of modules) {
const prefix = multiModule ? slugify(module.name) : '';
for (const { node, namespacePath } of collectDeclarations(module)) {
const section = SECTIONS.find((entry) => entry.kind === node.kind);
if (!section) continue;
slugById.set(
node.id,
path.posix.join(prefix, ...namespacePath.map(slugify), section.dir, slugify(node.name))
);
}
}
const renderer = new TypedocRenderer(slugById);
const pages: SdkPage[] = [];
const groups: SdkNavGroup[] = [];
for (const module of modules) {
const prefix = multiModule ? `${module.name} ` : '';
const declarations = collectDeclarations(module);
for (const section of SECTIONS) {
const entries = declarations
.filter(({ node }) => node.kind === section.kind && !node.flags?.isPrivate)
.sort((left, right) => qualifiedName(left).localeCompare(qualifiedName(right)));
if (entries.length === 0) continue;
const sectionPages = entries.map((entry) =>
renderer.renderPage(entry.node, section, qualifiedName(entry))
);
pages.push(...sectionPages);
groups.push({ group: `${prefix}${section.group}`, pages: sectionPages.map((p) => p.slug) });
}
}
return { pages, groups };
}
type Declaration = { node: TypedocNode; namespacePath: string[] };
function qualifiedName({ node, namespacePath }: Declaration): string {
return [...namespacePath, node.name].join('.');
}
function collectDeclarations(container: TypedocNode, namespacePath: string[] = []): Declaration[] {
const declarations: Declaration[] = [];
for (const child of container.children ?? []) {
if (child.kind === KIND.namespace) {
declarations.push(...collectDeclarations(child, [...namespacePath, child.name]));
} else {
declarations.push({ node: child, namespacePath });
}
}
return declarations;
}
function slugify(name: string): string {
return name.replace(/[^a-zA-Z0-9-_.]+/g, '-').replace(/^-+|-+$/g, '') || 'item';
}
class TypedocRenderer {
constructor(private slugById: Map<number, string>) {}
renderPage(node: TypedocNode, section: { dir: string; tag: string }, title?: string): SdkPage {
const slug = this.slugById.get(node.id) ?? path.posix.join(section.dir, slugify(node.name));
const lines: string[] = [];
const summary = this.comment(node.comment);
if (summary) lines.push(summary);
switch (node.kind) {
case KIND.class:
case KIND.interface:
lines.push(...this.renderClassLike(node));
break;
case KIND.enum:
lines.push(...this.renderEnum(node));
break;
case KIND.function:
lines.push(...this.renderSignatures(node, 2));
break;
case KIND.typeAlias:
case KIND.variable:
lines.push(
this.codeBlock(
`${node.kind === KIND.variable ? 'const' : 'type'} ${node.name}${node.kind === KIND.typeAlias ? ' =' : ':'} ${this.type(node.type)}`
)
);
break;
}
return {
slug,
title: title ?? node.name,
description: this.firstSentence(node.comment),
tag: section.tag,
content: lines.filter(Boolean).join('\n\n'),
};
}
private renderClassLike(node: TypedocNode): string[] {
const lines: string[] = [];
const heritage = [
...(node.extendedTypes?.length
? [`extends ${node.extendedTypes.map((t) => this.type(t)).join(', ')}`]
: []),
...(node.implementedTypes?.length
? [`implements ${node.implementedTypes.map((t) => this.type(t)).join(', ')}`]
: []),
];
if (heritage.length) {
lines.push(
this.codeBlock(
`${node.kind === KIND.class ? 'class' : 'interface'} ${node.name} ${heritage.join(' ')}`
)
);
}
const members = (node.children ?? []).filter((child) => !child.flags?.isPrivate);
const constructors = members.filter((child) => child.kind === KIND.constructor);
const properties = members
.filter((child) => child.kind === KIND.property || child.kind === KIND.accessor)
.sort((left, right) => left.name.localeCompare(right.name));
const methods = members
.filter((child) => child.kind === KIND.method)
.sort((left, right) => left.name.localeCompare(right.name));
for (const ctor of constructors) {
lines.push('## Constructor');
lines.push(...this.renderSignatures(ctor, 3));
}
if (properties.length) {
lines.push('## Properties');
for (const property of properties) {
const type = property.kind === KIND.accessor ? property.getSignature?.type : property.type;
const optional = property.flags?.isOptional === true;
lines.push(
this.responseField(
property.name,
this.type(type),
optional,
this.comment(property.comment ?? property.getSignature?.comment)
)
);
}
}
if (methods.length) {
lines.push('## Methods');
for (const method of methods) {
lines.push(`### ${method.name}()`);
lines.push(...this.renderSignatures(method, 4));
}
}
return lines;
}
private renderEnum(node: TypedocNode): string[] {
const lines: string[] = ['## Members'];
for (const member of node.children ?? []) {
lines.push(
this.responseField(
member.name,
member.type?.type === 'literal' ? JSON.stringify(member.type.value) : '',
false,
this.comment(member.comment)
)
);
}
return lines;
}
private renderSignatures(node: TypedocNode, depth: number): string[] {
const lines: string[] = [];
for (const signature of node.signatures ?? []) {
const params = (signature.parameters ?? [])
.map(
(param) =>
`${param.name}${param.flags?.isOptional || param.defaultValue !== undefined ? '?' : ''}: ${this.type(param.type)}`
)
.join(', ');
lines.push(this.codeBlock(`${signature.name}(${params}): ${this.type(signature.type)}`));
const comment = this.comment(signature.comment);
if (comment) lines.push(comment);
if (signature.parameters?.length) {
lines.push(`${'#'.repeat(depth)} Parameters`);
for (const param of signature.parameters) {
lines.push(
this.responseField(
param.name,
this.type(param.type),
param.flags?.isOptional === true || param.defaultValue !== undefined,
this.comment(param.comment)
)
);
}
}
const returns = signature.comment?.blockTags?.find((tag) => tag.tag === '@returns');
const returnType = this.type(signature.type);
if (returnType && returnType !== 'void') {
lines.push(`${'#'.repeat(depth)} Returns`);
lines.push(
`\`${escapeInlineCode(returnType)}\`${returns ? `\n\n${this.parts(returns.content)}` : ''}`
);
}
}
return lines;
}
private responseField(name: string, type: string, optional: boolean, body?: string): string {
const typeAttr = type ? ` type=${this.jsxString(truncate(type, 80))}` : '';
const requiredAttr = optional ? '' : ' required';
return `<ResponseField name=${this.jsxString(name)}${typeAttr}${requiredAttr}>\n${indent(body || '')}\n</ResponseField>`;
}
private jsxString(value: string): string {
return `{${JSON.stringify(value)}}`;
}
private codeBlock(code: string): string {
return `\`\`\`typescript\n${code}\n\`\`\``;
}
private comment(comment?: Comment): string {
if (!comment?.summary?.length) return '';
const markdown = this.parts(comment.summary);
const deprecated = comment.blockTags?.find((tag) => tag.tag === '@deprecated');
const example = comment.blockTags?.find((tag) => tag.tag === '@example');
const chunks = [markdown];
if (deprecated)
chunks.push(
`<Warning>Deprecated${deprecated.content.length ? `: ${this.parts(deprecated.content)}` : ''}</Warning>`
);
if (example) chunks.push(this.parts(example.content));
return chunks.filter(Boolean).join('\n\n');
}
private parts(parts: CommentPart[]): string {
const markdown = parts
.map((part) => {
if (part.kind === 'inline-tag') {
if (typeof part.target === 'number' && this.slugById.has(part.target)) {
return `[${part.text}](/${this.slugById.get(part.target)})`;
}
if (typeof part.target === 'string') return `[${part.text}](${part.target})`;
return `\`${part.text}\``;
}
return part.text;
})
.join('');
return markdownToMdx(markdown);
}
private firstSentence(comment?: Comment): string | undefined {
if (!comment?.summary?.length) return undefined;
const text = comment.summary
.filter((part) => part.kind === 'text')
.map((part) => part.text)
.join('')
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[`*_]/g, '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return undefined;
const sentence = text.split(/(?<=\.)\s/)[0] ?? text;
return truncate(sentence, 160);
}
type(type?: TypedocType): string {
if (!type) return 'void';
switch (type.type) {
case 'intrinsic':
return type.name ?? 'unknown';
case 'reference': {
const args = type.typeArguments?.length
? `<${type.typeArguments.map((arg) => this.type(arg)).join(', ')}>`
: '';
return `${type.name ?? 'unknown'}${args}`;
}
case 'union':
return (type.types ?? []).map((entry) => this.type(entry)).join(' | ');
case 'intersection':
return (type.types ?? []).map((entry) => this.type(entry)).join(' & ');
case 'array':
return `${this.type(type.elementType)}[]`;
case 'literal':
return JSON.stringify(type.value);
case 'tuple':
return `[${(type.elements ?? []).map((entry) => this.type(entry)).join(', ')}]`;
case 'reflection':
return this.reflection(type.declaration);
case 'typeOperator':
return `${type.operator} ${this.type(type.target as TypedocType)}`;
case 'indexedAccess':
return `${this.type(type.objectType)}[${this.type(type.indexType)}]`;
case 'conditional':
return `${this.type(type.checkType)} extends ${this.type(type.extendsType)} ? ${this.type(type.trueType)} : ${this.type(type.falseType)}`;
case 'query':
return `typeof ${this.type(type.target as TypedocType)}`;
case 'templateLiteral':
return 'string';
default:
return type.name ?? 'unknown';
}
}
private reflection(declaration?: TypedocNode): string {
if (!declaration) return 'object';
if (declaration.signatures?.length) {
const signature = declaration.signatures[0]!;
const params = (signature.parameters ?? [])
.map((param) => `${param.name}: ${this.type(param.type)}`)
.join(', ');
return `(${params}) => ${this.type(signature.type)}`;
}
if (declaration.children?.length) {
return `{ ${declaration.children.map((child) => `${child.name}: ${this.type(child.type)}`).join('; ')} }`;
}
return 'object';
}
}
function indent(text: string): string {
return text
.split('\n')
.map((line) => (line ? ` ${line}` : line))
.join('\n');
}
function truncate(text: string, max: number): string {
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
}
function escapeInlineCode(text: string): string {
return text.replace(/`/g, '');
}