UNPKG

typedoc-plugin-markdown

Version:

A TypeDoc plugin that generates TypeScript API documentation as Markdown.

319 lines (318 loc) • 14.2 kB
import { isQuoted, removeFirstScopedDirectory, toPascalCase, } from '../libs/utils/index.js'; import { getHierarchyRoots } from '../theme/lib/index.js'; import path from 'path'; import { BaseRouter, EntryPointStrategy, PageKind, Reflection, ReflectionKind, Slugger, } from 'typedoc'; import { getAnchorName } from './anchor-name.js'; export class MarkdownRouter extends BaseRouter { extension = this.application.options.getValue('fileExtension'); outputFileStrategy = this.application.options.getValue('outputFileStrategy'); entryModule = this.application.options.getValue('entryModule'); ignoreScopes = this.application.options.getValue('excludeScopesInPaths'); modulesFileName = path.parse(this.application.options.getValue('modulesFileName')).name; entryFileName = path.parse(this.application.options.getValue('entryFileName')) .name; isPackages = this.application.options.getValue('entryPointStrategy') === EntryPointStrategy.Packages; membersWithOwnFile = this.application.options.getValue('membersWithOwnFile'); mergeReadme = this.application.options.getValue('mergeReadme'); anchorPrefix = this.application.options.getValue('anchorPrefix') ?? ''; parametersFormat = this.application.options.getValue('parametersFormat'); directories = new Map([ [ReflectionKind.Class, 'classes'], [ReflectionKind.Interface, 'interfaces'], [ReflectionKind.Enum, 'enumerations'], [ReflectionKind.Namespace, 'namespaces'], [ReflectionKind.TypeAlias, 'type-aliases'], [ReflectionKind.Function, 'functions'], [ReflectionKind.Variable, 'variables'], [ReflectionKind.Document, 'documents'], ]); kindsToString = new Map([ [ReflectionKind.Module, 'Module'], [ReflectionKind.Namespace, 'Namespace'], [ReflectionKind.Document, 'Document'], [ReflectionKind.Class, 'Class'], [ReflectionKind.Interface, 'Interface'], [ReflectionKind.Enum, 'Enum'], [ReflectionKind.TypeAlias, 'TypeAlias'], [ReflectionKind.Function, 'Function'], [ReflectionKind.Variable, 'Variable'], ]); tableAnchorRules = [ { targetKind: ReflectionKind.Property, parentKind: ReflectionKind.TypeAlias, option: 'typeAliasPropertiesFormat', }, { targetKind: ReflectionKind.Property, parentKind: ReflectionKind.Interface, option: 'interfacePropertiesFormat', }, { targetKind: ReflectionKind.Property, parentKind: ReflectionKind.TypeLiteral, option: 'typeDeclarationFormat', }, { targetKind: ReflectionKind.Property, parentKind: ReflectionKind.Class, option: 'classPropertiesFormat', }, { targetKind: ReflectionKind.EnumMember, parentKind: ReflectionKind.Enum, option: 'enumMembersFormat', }, { targetKind: ReflectionKind.TypeParameter, parentKind: ReflectionKind.All, option: 'parametersFormat', }, ]; buildPages(project) { this.usedFileNames = new Set(); this.sluggers = new Map([ [project, new Slugger(this.sluggerConfiguration)], ]); const pages = []; // Note: The concept of "entryModule" is being deprecated in favour of custom router implementations. // The concept is hard to understand and makes the code unnecessarily complex. // Search the project's children, not only its first group: with project // documents the first group is "Documents" and the module was never found. const entryModule = project?.children?.find((child) => this.isEntryModule(child)); if (entryModule) { pages.push({ url: this.getFileName(this.entryFileName), kind: PageKind.Reflection, model: entryModule, }); if (project.readme?.length) { pages.push({ url: this.getFileName(this.entryFileName), kind: PageKind.Index, model: project, }); this.fullUrls.set(project, pages[0].url); } } else { if (project.readme?.length && !this.mergeReadme) { pages.push({ url: this.getFileName(this.entryFileName), kind: PageKind.Index, model: project, }); pages.push({ url: this.getFileName(this.getModulesFileName(project)), kind: PageKind.Reflection, model: project, }); } else { pages.push({ url: this.getFileName(this.entryFileName), kind: PageKind.Reflection, model: project, }); } this.fullUrls.set(project, pages[pages.length - 1].url); } if (this.application.options.isSet('includeHierarchySummary') && this.includeHierarchySummary && getHierarchyRoots(project)?.length) { pages.push({ url: this.getFileName('hierarchy'), kind: PageKind.Hierarchy, model: project, }); } this.parseChildPages(project, pages); return pages; } /** * This is essentially a copy of the BaseRouter implementation, but adjusted to * generate anchors in a way that is compatible with markdown links. */ buildAnchors(target, pageTarget) { if (!(target instanceof Reflection) || !(pageTarget instanceof Reflection)) { return; } if (!target.isDeclaration() && !target.isSignature() && !target.isTypeParameter() && !target.kindOf(ReflectionKind.TypeLiteral)) { return; } // We support linking to reflections for types directly contained within an export // but not any deeper. This is because TypeDoc may or may not render the type details // for a property depending on whether or not it is deemed useful, and defining a link // which might not be used may result in a link being generated which isn't valid. #2808. // This should be kept in sync with the renderingChildIsUseful function. if (target.kindOf(ReflectionKind.TypeLiteral) && (!target.parent?.kindOf(ReflectionKind.SomeExport) || target.parent.type?.type !== 'reflection')) { return; } // -------------------------------------------- // typedoc-plugin-markdown customization (start) // -------------------------------------------- // The method carries the anchor, so its call signatures get none, but the // signature's type parameters are still traversed below. const isMethodSignature = target.kindOf(ReflectionKind.CallSignature) && !!target.parent?.kindOf(ReflectionKind.Method); if (target.isTypeParameter() && this.parametersFormat === 'none') { // Type parameters are not rendered at all, so must not take an anchor. return; } // ------------------------------------------ // typedoc-plugin-markdown customization (end) // ------------------------------------------- if (!target.kindOf(ReflectionKind.TypeLiteral) && !isMethodSignature) { let refl = target; const parts = [getAnchorName(target)]; while (refl.parent && refl.parent !== pageTarget) { refl = refl.parent; // Avoid duplicate names for signatures and useless __type in anchors if (!refl.kindOf(ReflectionKind.TypeLiteral | ReflectionKind.FunctionOrMethod)) { parts.unshift(refl.name); } } // -------------------------------------------- // typedoc-plugin-markdown customization (start) // -------------------------------------------- let toSlug = parts.join('.'); if (this.isSluggedByName(target, pageTarget)) { toSlug = target.name; } if (this.tableAnchorRules.some((r) => this.isTableAnchor(target, r.targetKind, r.parentKind, r.option))) { toSlug = `${ReflectionKind.singularString(target.kind)}-${toSlug}`; } if (target.kindOf(ReflectionKind.Class) && target.flags?.isAbstract) { toSlug = `abstract-${toSlug}`; } // The prefix is stored with the anchor so that same-page anchors and // cross-page URLs (built from fullUrls) both carry it. const anchor = `${this.anchorPrefix}${this.getSlugger(pageTarget).slug(toSlug)}`; // ------------------------------------------ // typedoc-plugin-markdown customization (end) // ------------------------------------------- this.fullUrls.set(target, this.fullUrls.get(pageTarget) + '#' + anchor); this.anchors.set(target, anchor); } target.traverse((child) => { this.buildAnchors(child, pageTarget); return true; }); } /** * Whether a type parameter is slugged from its bare name rather than its * qualified path. In list format its anchor is its heading, which renderers * slug from the bare name, so the router must match it; table anchors follow * the same rule so both formats name a type parameter alike. * * The module and member routers anchor a member twice: first relative to the * page, then relative to its parent, and only the second anchor is kept. The * bare name is therefore used only when the owner of the type parameter (past * its signature and method) is `pageTarget`; a bare slug in the first pass * would push the kept anchor to `-1`. */ isSluggedByName(target, pageTarget) { if (!target.isTypeParameter()) { return false; } let owner = target.parent; while (owner && owner !== pageTarget && owner.kindOf(ReflectionKind.SomeSignature | ReflectionKind.Method)) { owner = owner.parent; } return owner === pageTarget; } isTableAnchor(target, targetKind, parentKind, option) { return (!!target.kindOf(targetKind) && !!target.parent?.kindOf(parentKind) && this.application.options.getValue(option) .toLowerCase() .endsWith('table')); } parseChildPages(project, pages) { for (const child of project.childrenIncludingDocuments || []) { this.buildChildPages(child, pages); } } getIdealBaseNameFlattened(reflection) { const fullName = reflection.getFullName(); const fullNameParts = fullName.replace(/\//g, '.').split('.'); if (reflection.kind !== ReflectionKind.Module) { if (reflection.kind === ReflectionKind.Document && reflection?.parent?.kind === ReflectionKind.Project) { fullNameParts.splice(0, 0, toPascalCase(ReflectionKind.singularString(reflection.kind))); } else { fullNameParts.splice(fullNameParts.length - 1, 0, toPascalCase(ReflectionKind.singularString(reflection.kind))); } } const finalName = removeUnsafeFileNameChars(`${fullNameParts.join('.')}`) .replace(/"/g, '') .replace(/ /g, '-') .replace(/^\./g, ''); if (this.ignoreScopes) { return removeFirstScopedDirectory(finalName, '.'); } return finalName; } getReflectionAlias(reflection) { let name = reflection.name; if (isQuoted(reflection.name)) { name = name.replace(/\//g, '_'); } const safeName = removeTraversalSegments(removeUnsafeFileNameChars(name).replace(/"/g, '')); const alias = safeName.replace(/^_+|_+$/g, '').replace(/[<>]/g, '-'); // A name made only of underscores (e.g. `_`) would otherwise be empty, as // would one made only of traversal segments (e.g. `..`). return alias || safeName || '_'; } /** * Whether the reflection is the module named by the (deprecated) * "entryModule" option. Only modules match, so a function or class sharing * the name keeps its own page. */ isEntryModule(reflection) { return (Boolean(this.entryModule) && reflection.kindOf(ReflectionKind.Module) && reflection.name === this.entryModule); } getModulesFileName(reflection) { if (this.modulesFileName) { return this.modulesFileName; } if (this.isPackages && reflection.kind === ReflectionKind.Project) { return 'packages'; } const isModules = reflection.children?.every((child) => child.kind === ReflectionKind.Module); return isModules ? 'modules' : 'globals'; } } /** * Removes characters that break a relative link to the file on every * platform: `#` starts a fragment, `?` starts a query and `\` is a path * separator on Windows and an escape in markdown. */ function removeUnsafeFileNameChars(name) { return name.replace(/[#?\\]/g, ''); } /** * Drops `.` and `..` path segments. A reflection name is used as a path, so a * module named `../../x` (via an `@module` tag) or a document whose title is * `../x` would otherwise be written outside the output directory. TypeDoc's * own routers replace the separators instead, but `/` is kept here so a * module name can still map to a directory. */ function removeTraversalSegments(name) { return name .split('/') .filter((segment) => segment !== '.' && segment !== '..') .join('/'); }