UNPKG

docusaurus-plugin-typedoc-api

Version:

Docusaurus plugin that provides source code API documentation powered by TypeDoc.

494 lines (421 loc) 14.8 kB
import fs from 'fs'; import path from 'path'; import * as TypeDoc from 'typedoc'; import { type InlineTagDisplayPart, type JSONOutput, ReflectionKind } from 'typedoc' import ts from 'typescript'; import { normalizeUrl } from '@docusaurus/utils'; import type { DocusaurusPluginTypeDocApiOptions, PackageEntryConfig, PackageReflectionGroup, ResolvedPackageConfig, TSDDeclarationReflection, TSDDeclarationReflectionMap, } from '../types'; import { migrateToVersion0230 } from './structure/0.23'; import { getKindSlug, getPackageSlug, joinUrl } from './url'; function shouldEmit(projectRoot: string, tsconfigPath: string) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { config, error } = ts.readConfigFile(tsconfigPath, (name) => fs.readFileSync(name, 'utf8'), ); if (error) { throw new Error(`Failed to load ${tsconfigPath}`); } const result = ts.parseJsonConfigFileContent(config, ts.sys, projectRoot, {}, tsconfigPath); if (result.errors.length > 0) { throw new Error(`Failed to parse ${tsconfigPath}`); } return result.projectReferences && result.projectReferences.length > 0 ? 'docs' : 'none'; } // Persist build state as a global, since the plugin is re-evaluated every hot reload. // Because of this, we can't use state in the plugin or module scope. if (!global.typedocBuild) { global.typedocBuild = { count: 0 }; } export async function generateJson( projectRoot: string, entryPoints: string[], outFile: string, options: DocusaurusPluginTypeDocApiOptions, ): Promise<boolean> { /* eslint-disable sort-keys */ // Running the TypeDoc compiler is pretty slow... // We should only load on the 1st build, and use cache for subsequent reloads. if (global.typedocBuild.count > 0 && fs.existsSync(outFile)) { return true; } const tsconfig = path.join(projectRoot, options.tsconfigName ?? 'tsconfig.json'); const app = await TypeDoc.Application.bootstrapWithPlugins( { gitRevision: options.gitRefName, includeVersion: true, skipErrorChecking: true, // stripYamlFrontmatter: true, // Only emit when using project references emit: shouldEmit(projectRoot, tsconfig), // Only document the public API by default excludeExternals: true, excludeInternal: true, excludePrivate: true, excludeProtected: true, // Enable verbose logging when debugging logLevel: options.debug ? 'Verbose' : 'Info', inlineTags: [ '@link', '@inheritDoc', '@label', '@linkcode', '@linkplain', '@apilink', '@doclink', ] as `@${string}`[], ...options.typedocOptions, // Control how config and packages are detected tsconfig, entryPoints: entryPoints.map((ep) => path.join(projectRoot, ep)), entryPointStrategy: 'expand', exclude: options.exclude, // We use a fake category title so that we can fallback to the parent group defaultCategory: '__CATEGORY__', }, [new TypeDoc.TSConfigReader(), new TypeDoc.TypeDocReader()], ); const project = await app.convert(); if (project) { await app.generateJson(project, outFile); global.typedocBuild.count += 1; return true; } return false; } export function createReflectionMap( items: TSDDeclarationReflection[] = [], ): TSDDeclarationReflectionMap { const map: TSDDeclarationReflectionMap = {}; // eslint-disable-next-line complexity items.forEach((item) => { // Add @reference categories to reflection. const referenceCategories: Record<string, { title: string; children: number[] }> = {}; for (const tag of item.comment?.blockTags ?? []) { if (tag.tag === '@reference' && tag.content.length >= 2 && tag.content[0].kind === 'text') { const categoryName = tag.content[0].text.trim(); const ref = (tag.content as InlineTagDisplayPart[]).find((t) => t.tag === '@link'); if (ref && typeof ref.target === 'number') { if (!(categoryName in referenceCategories)) { referenceCategories[categoryName] = { title: categoryName, children: [] }; } if (!referenceCategories[categoryName].children.includes(ref.target)) { referenceCategories[categoryName].children.push(ref.target); } } } } // Update categories with reference categories. if (!item.categories) { // eslint-disable-next-line no-param-reassign item.categories = []; } for (const category of Object.values(referenceCategories)) { if (category.children.length > 0) { const index = item.categories.findIndex((c) => c.title === category.title); if (index === -1) { item.categories.push(category); } } } // Add item. map[item.id] = item; }); return map; } export function loadPackageJsonAndDocs( initialDir: string, pkgFileName: string = 'package.json', readmeFileName: string = 'README.md', changelogFileName: string = 'CHANGELOG.md', ) { let currentDir = initialDir; while (!fs.existsSync(path.join(currentDir, pkgFileName))) { currentDir = path.dirname(currentDir); } const readmePath = path.join(currentDir, readmeFileName); const changelogPath = path.join(currentDir, changelogFileName); return { packageJson: JSON.parse(fs.readFileSync(path.join(currentDir, pkgFileName), 'utf8')) as { name: string; version: string; }, readmePath: fs.existsSync(readmePath) ? readmePath : '', changelogPath: fs.existsSync(changelogPath) ? changelogPath : '', }; } export function addMetadataToReflections( project: JSONOutput.DeclarationReflection, packageSlug: string, urlPrefix: string, ): TSDDeclarationReflection { const permalink = `/${joinUrl(urlPrefix, packageSlug)}`; if (project.children) { // eslint-disable-next-line no-param-reassign project.children = project.children.map((child) => { migrateToVersion0230(child); const kindSlugPart = getKindSlug(child); const childSlug = kindSlugPart ? `/${kindSlugPart}/${child.name}` : `#${child.name}`; const childPermalink = permalink + childSlug; // We need to go another level deeper and only use fragments if (child.kind === ReflectionKind.Namespace && child.children) { // eslint-disable-next-line no-param-reassign child.children = child.children.map((grandChild) => ({ ...grandChild, permalink: normalizeUrl([`${childPermalink}#${grandChild.name}`]), })); } return { ...child, permalink: normalizeUrl([childPermalink]), }; }); } // @ts-expect-error Not sure why this fails return { ...project, permalink: normalizeUrl([permalink]), }; } function mergeReflections(base: TSDDeclarationReflection, next: TSDDeclarationReflection) { if (Array.isArray(base.children) && Array.isArray(next.children)) { base.children.push(...next.children); } if (Array.isArray(base.groups) && Array.isArray(next.groups)) { next.groups.forEach((group) => { const baseGroup = base.groups?.find((g) => g.title === group.title); if (baseGroup) { baseGroup.children?.push(...(group.children ?? [])); } else { base.groups?.push(group); } }); // We can remove refs since were merging all reflections into one // eslint-disable-next-line no-param-reassign base.groups = base.groups.filter((group) => group.title !== 'References'); } } function sortReflectionGroups(reflections: TSDDeclarationReflection[]) { reflections.forEach((reflection) => { const map = createReflectionMap(reflection.children); const sort = (a: number, b: number) => (map[a].name < map[b].name ? -1 : 1); reflection.categories?.forEach((category) => { category.children?.sort(sort); }); reflection.groups?.forEach((group) => { group.children?.sort(sort); group.categories?.forEach((category) => { category.children?.sort(sort); }); }); }); } function sourceFileMatchesEntryPoint( sourceFile: string, entryPoint: string, { deep, single }: { deep: boolean; single: boolean }, ): boolean { // Single package if (single) { return ( // src/index.ts === src/index.ts (!deep && sourceFile === entryPoint) || // index.ts === src/index.ts (!deep && sourceFile === path.basename(entryPoint)) || // some/deep/file.ts === ... deep ); } // Multiple packages return ( // packages/foo/src/index.ts === packages/foo/src/index.ts // foo/src/index.ts ~ packages/foo/src/index.ts (!deep && (sourceFile === entryPoint || entryPoint.endsWith(sourceFile))) || // packages/foo/src/some/deep/file.ts === packages/foo/src/ (deep && sourceFile.startsWith(entryPoint)) ); } function modContainsEntryPoint( mod: JSONOutput.DeclarationReflection, entry: PackageEntryConfig, meta: { allSourceFiles: Record<string, boolean>; packagePath: string; packageRoot: string; isSinglePackage: boolean; isUsingDeepImports: boolean; }, ) { const relModSources = mod.sources ?? []; const relModSourceFile = relModSources.find((sf) => !!sf.fileName)?.fileName ?? ''; const relEntryPoint = joinUrl(meta.packagePath, entry.path); // Monorepos of 1 package don't have sources, so use the child sources. // They also don't use full paths like "package/src/index.ts" and simply use "index.ts", // so account for those entry points also. if (!relModSourceFile) { const absEntryPoint = path.normalize(path.join(meta.packageRoot, entry.path)); const relEntryPointName = path.basename(relEntryPoint); const entryPointInSourceFiles = !!meta.allSourceFiles[absEntryPoint] || !!meta.allSourceFiles[relEntryPoint] || (relEntryPointName.startsWith('index.') && !!meta.allSourceFiles[relEntryPointName]); if (entryPointInSourceFiles) { return sourceFileMatchesEntryPoint(relEntryPoint, relEntryPoint, { deep: meta.isUsingDeepImports, single: meta.isSinglePackage, }); } } return sourceFileMatchesEntryPoint(relModSourceFile, relEntryPoint, { deep: meta.isUsingDeepImports, single: meta.isSinglePackage, }); } function extractReflectionModules( project: JSONOutput.ProjectReflection, isSinglePackage: boolean, ): JSONOutput.DeclarationReflection[] { const modules: JSONOutput.DeclarationReflection[] = []; const inheritChildren = () => { project.children?.forEach((child) => { if (child.kind === ReflectionKind.Module) { modules.push(child); } }); }; // Single packages are extremely difficult, as the TypeDoc structure is // different for every kind of package entry point pattern if (isSinglePackage) { const hasNoModules = project.children?.every((child) => child.kind !== ReflectionKind.Module); if (hasNoModules) { // No "module" children: // - Polyrepos // - Monorepos with 1 package modules.push(project as unknown as JSONOutput.DeclarationReflection); } else { // Has "module" children: // - Polyrepos with deep imports // - Polyrepos with multi-imports // - Monorepos inheritChildren(); } // Multiple packages are extremely simple, as every package is a module reflection // as a child on the top-level project reflection } else { inheritChildren(); } return modules; } function buildSourceFileNameMap( project: JSONOutput.ProjectReflection, modChildren: JSONOutput.DeclarationReflection[], ) { const map: Record<string, boolean> = {}; const cwd = process.cwd(); Object.values(project.symbolIdMap).forEach((symbol) => { // absolute map[path.normalize(path.join(cwd, symbol.sourceFileName))] = true; }); modChildren.forEach((child) => { child.sources?.forEach((sf) => { // relative map[sf.fileName] = true; }); }); return map; } export function flattenAndGroupPackages( packageConfigs: ResolvedPackageConfig[], project: JSONOutput.ProjectReflection, urlPrefix: string, options: DocusaurusPluginTypeDocApiOptions, versioned: boolean = false, ): PackageReflectionGroup[] { const isSinglePackage = packageConfigs.length === 1; const modules = extractReflectionModules(project, isSinglePackage); // Loop through every TypeDoc module and group based on package and entry point const packages: Record<string, PackageReflectionGroup> = {}; const packagesWithDeepImports: TSDDeclarationReflection[] = []; modules.forEach((mod) => { const allSourceFiles = buildSourceFileNameMap(project, mod.children ?? []); packageConfigs.some((cfg) => Object.entries(cfg.entryPoints).some(([importPath, entry]) => { const isUsingDeepImports = !entry.path.match(/\.tsx?$/); if ( !modContainsEntryPoint(mod, entry, { allSourceFiles, isSinglePackage, isUsingDeepImports, packagePath: cfg.packagePath, packageRoot: cfg.packageRoot, }) ) { return false; } // We have a matching entry point, so store the record if (!packages[cfg.packagePath]) { const { packageJson, readmePath, changelogPath } = loadPackageJsonAndDocs( path.join(options.projectRoot, cfg.packagePath), options.packageJsonName, options.readmeName, options.changelogName, ); packages[cfg.packagePath] = { entryPoints: [], packageName: (versioned && cfg.packageName) || packageJson.name, packageVersion: (versioned && cfg.packageVersion) || packageJson.version, readmePath, changelogPath, }; // eslint-disable-next-line no-param-reassign cfg.packageName = packages[cfg.packagePath].packageName; // eslint-disable-next-line no-param-reassign cfg.packageVersion = packages[cfg.packagePath].packageVersion; } // Add metadata to package and children reflections const urlSlug = getPackageSlug(cfg, importPath, isSinglePackage); const reflection = addMetadataToReflections(mod, urlSlug, urlPrefix); const existingEntry = packages[cfg.packagePath].entryPoints.find( (ep) => ep.urlSlug === urlSlug, ); if (existingEntry) { if (isUsingDeepImports) { mergeReflections(existingEntry.reflection, reflection); } else { // eslint-disable-next-line no-console console.error(`Entry point ${urlSlug} already defined. How did you get here?`); } } else { packages[cfg.packagePath].entryPoints.push({ index: importPath === 'index', label: entry.label, reflection, urlSlug, }); if (isUsingDeepImports) { packagesWithDeepImports.push(reflection); } } // Update the reflection name since its useless reflection.name = importPath === 'index' ? packages[cfg.packagePath].packageName : joinUrl(packages[cfg.packagePath].packageName, importPath); return true; }), ); }); // Since we merged multiple reflections together, we'll need to sort groups manually sortReflectionGroups(packagesWithDeepImports); // Sort packages by name return Object.values(packages).sort((a, b) => a.packageName.localeCompare(b.packageName)); } export function formatPackagesWithoutHostInfo(packages: PackageReflectionGroup[]) { return packages.map(({ changelogPath, readmePath, ...pkg }) => pkg); }