From c1228e50c315e8d1d0bf6ff4eb4354d09cb87f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 4 Nov 2022 19:53:24 -1000 Subject: [PATCH 1/3] Add a documentation generator --- .../LICENSE | 24 + .../README.md | 0 .../bin/api-documenter | 2 + .../package.json | 37 + .../src/cli/ApiDocumenterCommandLine.ts | 21 + .../src/cli/BaseAction.ts | 160 ++ .../src/cli/MarkdownAction.ts | 30 + .../src/documenters/DocumenterConfig.ts | 69 + .../src/documenters/IConfigFile.ts | 100 + .../src/documenters/MarkdownDocumenter.ts | 1708 +++++++++++++++++ .../src/index.ts | 20 + .../src/markdown/CustomMarkdownEmitter.ts | 242 +++ .../src/markdown/MarkdownEmitter.ts | 358 ++++ .../test/CustomMarkdownEmitter.test.ts | 180 ++ .../CustomMarkdownEmitter.test.ts.snap | 62 + .../src/nodes/CustomDocNodeKind.ts | 105 + .../src/nodes/DocContentBlock.ts | 94 + .../src/nodes/DocEmphasisSpan.ts | 33 + .../src/nodes/DocForceSoftBreak.ts | 45 + .../src/nodes/DocHeading.ts | 40 + .../src/nodes/DocMaybe.ts | 39 + .../src/nodes/DocNoteBox.ts | 32 + .../src/nodes/DocTable.ts | 79 + .../src/nodes/DocTableCell.ts | 28 + .../src/nodes/DocTableRow.ts | 64 + .../plugin/IApiDocumenterPluginManifest.ts | 79 + .../src/plugin/MarkdownDocumenterAccessor.ts | 42 + .../src/plugin/MarkdownDocumenterFeature.ts | 118 ++ .../src/plugin/PluginFeature.ts | 72 + .../src/plugin/PluginLoader.ts | 157 ++ .../src/schemas/api-documenter-template.json | 99 + .../src/schemas/api-documenter.schema.json | 47 + .../src/start.ts | 6 + .../src/utils/DocContentBlockBuilder.ts | 135 ++ .../src/utils/IndentedWriter.ts | 297 +++ .../src/utils/TypeResolver.ts | 184 ++ .../src/utils/Utilities.ts | 230 +++ .../src/utils/test/IndentedWriter.test.ts | 78 + .../__snapshots__/IndentedWriter.test.ts.snap | 46 + .../tsconfig.json | 10 + 40 files changed, 5172 insertions(+) create mode 100644 packages/typescript-reference-doc-generator/LICENSE create mode 100644 packages/typescript-reference-doc-generator/README.md create mode 100755 packages/typescript-reference-doc-generator/bin/api-documenter create mode 100644 packages/typescript-reference-doc-generator/package.json create mode 100644 packages/typescript-reference-doc-generator/src/cli/ApiDocumenterCommandLine.ts create mode 100644 packages/typescript-reference-doc-generator/src/cli/BaseAction.ts create mode 100644 packages/typescript-reference-doc-generator/src/cli/MarkdownAction.ts create mode 100644 packages/typescript-reference-doc-generator/src/documenters/DocumenterConfig.ts create mode 100644 packages/typescript-reference-doc-generator/src/documenters/IConfigFile.ts create mode 100644 packages/typescript-reference-doc-generator/src/documenters/MarkdownDocumenter.ts create mode 100644 packages/typescript-reference-doc-generator/src/index.ts create mode 100644 packages/typescript-reference-doc-generator/src/markdown/CustomMarkdownEmitter.ts create mode 100644 packages/typescript-reference-doc-generator/src/markdown/MarkdownEmitter.ts create mode 100644 packages/typescript-reference-doc-generator/src/markdown/test/CustomMarkdownEmitter.test.ts create mode 100644 packages/typescript-reference-doc-generator/src/markdown/test/__snapshots__/CustomMarkdownEmitter.test.ts.snap create mode 100644 packages/typescript-reference-doc-generator/src/nodes/CustomDocNodeKind.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocContentBlock.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocEmphasisSpan.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocForceSoftBreak.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocHeading.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocMaybe.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocNoteBox.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocTable.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocTableCell.ts create mode 100644 packages/typescript-reference-doc-generator/src/nodes/DocTableRow.ts create mode 100644 packages/typescript-reference-doc-generator/src/plugin/IApiDocumenterPluginManifest.ts create mode 100644 packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterAccessor.ts create mode 100644 packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterFeature.ts create mode 100644 packages/typescript-reference-doc-generator/src/plugin/PluginFeature.ts create mode 100644 packages/typescript-reference-doc-generator/src/plugin/PluginLoader.ts create mode 100644 packages/typescript-reference-doc-generator/src/schemas/api-documenter-template.json create mode 100644 packages/typescript-reference-doc-generator/src/schemas/api-documenter.schema.json create mode 100644 packages/typescript-reference-doc-generator/src/start.ts create mode 100644 packages/typescript-reference-doc-generator/src/utils/DocContentBlockBuilder.ts create mode 100644 packages/typescript-reference-doc-generator/src/utils/IndentedWriter.ts create mode 100644 packages/typescript-reference-doc-generator/src/utils/TypeResolver.ts create mode 100644 packages/typescript-reference-doc-generator/src/utils/Utilities.ts create mode 100644 packages/typescript-reference-doc-generator/src/utils/test/IndentedWriter.test.ts create mode 100644 packages/typescript-reference-doc-generator/src/utils/test/__snapshots__/IndentedWriter.test.ts.snap create mode 100644 packages/typescript-reference-doc-generator/tsconfig.json diff --git a/packages/typescript-reference-doc-generator/LICENSE b/packages/typescript-reference-doc-generator/LICENSE new file mode 100644 index 0000000000..e7ab1e482f --- /dev/null +++ b/packages/typescript-reference-doc-generator/LICENSE @@ -0,0 +1,24 @@ +@microsoft/api-documenter + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/typescript-reference-doc-generator/README.md b/packages/typescript-reference-doc-generator/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/typescript-reference-doc-generator/bin/api-documenter b/packages/typescript-reference-doc-generator/bin/api-documenter new file mode 100755 index 0000000000..783bb806fc --- /dev/null +++ b/packages/typescript-reference-doc-generator/bin/api-documenter @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../lib/start.js') diff --git a/packages/typescript-reference-doc-generator/package.json b/packages/typescript-reference-doc-generator/package.json new file mode 100644 index 0000000000..31ac84baf9 --- /dev/null +++ b/packages/typescript-reference-doc-generator/package.json @@ -0,0 +1,37 @@ +{ + "name": "@microsoft/api-documenter", + "version": "7.19.23", + "description": "Read JSON files from api-extractor, generate documentation pages", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/rushstack.git", + "directory": "apps/api-documenter" + }, + "homepage": "https://api-extractor.com/", + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft build --clean", + "_phase:test": "heft test --no-build" + }, + "bin": { + "api-documenter": "./bin/api-documenter" + }, + "main": "lib/index.js", + "typings": "dist/rollup.d.ts", + "dependencies": { + "@microsoft/api-extractor-model": "7.25.2", + "@microsoft/tsdoc": "0.14.2", + "@rushstack/node-core-library": "3.53.2", + "@rushstack/ts-command-line": "4.13.0", + "colors": "~1.2.1", + "js-yaml": "~3.13.1", + "resolve": "~1.17.0", + "typescript": "4.8.4" + }, + "devDependencies": { + "@rushstack/eslint-config": "3.1.1", + "@types/node": "12.20.24", + "@types/resolve": "1.17.1" + } +} diff --git a/packages/typescript-reference-doc-generator/src/cli/ApiDocumenterCommandLine.ts b/packages/typescript-reference-doc-generator/src/cli/ApiDocumenterCommandLine.ts new file mode 100644 index 0000000000..ca392e8986 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/cli/ApiDocumenterCommandLine.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { CommandLineParser } from '@rushstack/ts-command-line'; +import { MarkdownAction } from './MarkdownAction'; + +export class ApiDocumenterCommandLine extends CommandLineParser { + public constructor() { + super({ + toolFilename: 'api-documenter', + toolDescription: + 'Reads *.api.json files produced by api-extractor, ' + + ' and generates API documentation in various output formats.' + }); + this._populateActions(); + } + + private _populateActions(): void { + this.addAction(new MarkdownAction(this)); + } +} diff --git a/packages/typescript-reference-doc-generator/src/cli/BaseAction.ts b/packages/typescript-reference-doc-generator/src/cli/BaseAction.ts new file mode 100644 index 0000000000..ef7273dc69 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/cli/BaseAction.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import * as tsdoc from '@microsoft/tsdoc'; +import colors from 'colors/safe'; + +import * as fs from 'fs'; + +import { + CommandLineAction, + CommandLineStringParameter, + type ICommandLineActionOptions, +} from '@rushstack/ts-command-line'; +import { + ApiModel, + ApiItem, + ApiItemContainerMixin, + ApiDocumentedItem, + IResolveDeclarationReferenceResult, +} from '@microsoft/api-extractor-model'; + +export interface IBuildApiModelResult { + apiModel: ApiModel; + inputFolder: string; + outputFolder: string; +} + +export abstract class BaseAction extends CommandLineAction { + private readonly _inputFolderParameter: CommandLineStringParameter; + private readonly _outputFolderParameter: CommandLineStringParameter; + + protected constructor(options: ICommandLineActionOptions) { + super(options); + + // override + this._inputFolderParameter = this.defineStringParameter({ + parameterLongName: '--input-folder', + parameterShortName: '-i', + argumentName: 'FOLDER1', + description: + `Specifies the input folder containing the *.api.json files to be processed.` + + ` If omitted, the default is "./input"`, + }); + + this._outputFolderParameter = this.defineStringParameter({ + parameterLongName: '--output-folder', + parameterShortName: '-o', + argumentName: 'FOLDER2', + description: + `Specifies the output folder where the documentation will be written.` + + ` ANY EXISTING CONTENTS WILL BE DELETED!` + + ` If omitted, the default is "./${this.actionName}"`, + }); + } + + protected buildApiModel(): IBuildApiModelResult { + const apiModel: ApiModel = new ApiModel(); + + const inputFolder: string = + this._inputFolderParameter.value || './input'; + if (!fs.existsSync(inputFolder)) { + throw new Error('The input folder does not exist: ' + inputFolder); + } + + const outputFolder: string = + this._outputFolderParameter.value || `./${this.actionName}`; + try { + fs.mkdirSync(outputFolder, { recursive: true }); + } catch (e) { + if (!fs.existsSync(outputFolder)) { + throw e; + } + } + + for (const filename of fs.readdirSync(inputFolder)) { + if (filename.match(/\.api\.json$/i)) { + console.log(`Reading ${filename}`); + const filenamePath: string = path.join(inputFolder, filename); + apiModel.loadPackage(filenamePath); + } + } + + this._applyInheritDoc(apiModel, apiModel); + + return { apiModel, inputFolder, outputFolder }; + } + + // TODO: This is a temporary workaround. The long term plan is for API Extractor's DocCommentEnhancer + // to apply all @inheritDoc tags before the .api.json file is written. + // See DocCommentEnhancer._applyInheritDoc() for more info. + private _applyInheritDoc(apiItem: ApiItem, apiModel: ApiModel): void { + if (apiItem instanceof ApiDocumentedItem) { + if (apiItem.tsdocComment) { + const inheritDocTag: tsdoc.DocInheritDocTag | undefined = + apiItem.tsdocComment.inheritDocTag; + + if (inheritDocTag && inheritDocTag.declarationReference) { + // Attempt to resolve the declaration reference + const result: IResolveDeclarationReferenceResult = + apiModel.resolveDeclarationReference( + inheritDocTag.declarationReference, + apiItem + ); + + if (result.errorMessage) { + console.log( + colors.yellow( + `Warning: Unresolved @inheritDoc tag for ${apiItem.displayName}: ` + + result.errorMessage + ) + ); + } else if ( + result.resolvedApiItem instanceof ApiDocumentedItem && + result.resolvedApiItem.tsdocComment && + result.resolvedApiItem !== apiItem + ) { + this._copyInheritedDocs( + apiItem.tsdocComment, + result.resolvedApiItem.tsdocComment + ); + } + } + } + } + + // Recurse members + if (ApiItemContainerMixin.isBaseClassOf(apiItem)) { + for (const member of apiItem.members) { + this._applyInheritDoc(member, apiModel); + } + } + } + + /** + * Copy the content from `sourceDocComment` to `targetDocComment`. + * This code is borrowed from DocCommentEnhancer as a temporary workaround. + * + * @param targetDocComment + * @param sourceDocComment + */ + private _copyInheritedDocs( + targetDocComment: tsdoc.DocComment, + sourceDocComment: tsdoc.DocComment + ): void { + targetDocComment.summarySection = sourceDocComment.summarySection; + targetDocComment.remarksBlock = sourceDocComment.remarksBlock; + + targetDocComment.params.clear(); + for (const param of sourceDocComment.params) { + targetDocComment.params.add(param); + } + for (const typeParam of sourceDocComment.typeParams) { + targetDocComment.typeParams.add(typeParam); + } + targetDocComment.returnsBlock = sourceDocComment.returnsBlock; + + targetDocComment.inheritDocTag = undefined; + } +} diff --git a/packages/typescript-reference-doc-generator/src/cli/MarkdownAction.ts b/packages/typescript-reference-doc-generator/src/cli/MarkdownAction.ts new file mode 100644 index 0000000000..3904b16fbd --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/cli/MarkdownAction.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ApiDocumenterCommandLine } from './ApiDocumenterCommandLine'; +import { BaseAction } from './BaseAction'; +import { MarkdownDocumenter } from '../documenters/MarkdownDocumenter'; + +export class MarkdownAction extends BaseAction { + public constructor(parser: ApiDocumenterCommandLine) { + super({ + actionName: 'markdown', + summary: 'Generate documentation as Markdown files (*.md)', + documentation: + 'Generates API documentation as a collection of files in' + + ' Markdown format, suitable for example for publishing on a GitHub site.', + }); + } + + protected async onExecute(): Promise { + // override + const { apiModel, outputFolder } = this.buildApiModel(); + + const markdownDocumenter: MarkdownDocumenter = new MarkdownDocumenter({ + apiModel, + documenterConfig: undefined, + outputFolder, + }); + markdownDocumenter.generateFiles(); + } +} diff --git a/packages/typescript-reference-doc-generator/src/documenters/DocumenterConfig.ts b/packages/typescript-reference-doc-generator/src/documenters/DocumenterConfig.ts new file mode 100644 index 0000000000..55b4aef773 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/documenters/DocumenterConfig.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import { + JsonSchema, + JsonFile, + NewlineKind, +} from '@rushstack/node-core-library'; +import { IConfigFile } from './IConfigFile'; + +/** + * Helper for loading the api-documenter.json file format. Later when the schema is more mature, + * this class will be used to represent the validated and normalized configuration, whereas `IConfigFile` + * represents the raw JSON file structure. + */ +export class DocumenterConfig { + public readonly configFilePath: string; + public readonly configFile: IConfigFile; + + /** + * Specifies what type of newlines API Documenter should use when writing output files. By default, the output files + * will be written with Windows-style newlines. + */ + public readonly newlineKind: NewlineKind; + + /** + * The JSON Schema for API Documenter config file (api-documenter.schema.json). + */ + public static readonly jsonSchema: JsonSchema = JsonSchema.fromFile( + path.join(__dirname, '..', 'schemas', 'api-documenter.schema.json') + ); + + /** + * The config file name "api-documenter.json". + */ + public static readonly FILENAME: string = 'api-documenter.json'; + + private constructor(filePath: string, configFile: IConfigFile) { + this.configFilePath = filePath; + this.configFile = configFile; + + switch (configFile.newlineKind) { + case 'lf': + this.newlineKind = NewlineKind.Lf; + break; + case 'os': + this.newlineKind = NewlineKind.OsDefault; + break; + default: + this.newlineKind = NewlineKind.CrLf; + break; + } + } + + /** + * Load and validate an api-documenter.json file. + * + * @param configFilePath + */ + public static loadFile(configFilePath: string): DocumenterConfig { + const configFile: IConfigFile = JsonFile.loadAndValidate( + configFilePath, + DocumenterConfig.jsonSchema + ); + + return new DocumenterConfig(path.resolve(configFilePath), configFile); + } +} diff --git a/packages/typescript-reference-doc-generator/src/documenters/IConfigFile.ts b/packages/typescript-reference-doc-generator/src/documenters/IConfigFile.ts new file mode 100644 index 0000000000..81dc7a05e5 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/documenters/IConfigFile.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Typescript interface describing the config schema for toc.yml file format. + */ +export interface IConfigTableOfContents { + /** + * Optional category name that is recommended to be included along with + * one of the configs: {@link IConfigTableOfContents.categorizeByName} or + * {@link IConfigTableOfContents.categoryInlineTag}. + * Any items that are not matched according to the mentioned configuration options will be placed under this + * catchAll category. If none provided the items will not be included in the final toc.yml file. + */ + catchAllCategory?: string; + + /** + * Toggle either categorization of the API items should be made based on category name presence + * in the API item's name. Useful when there are API items without an inline tag to categorize them, + * but still need to place the items under categories. Note: this type of categorization might place some items + * under wrong categories if the names are similar but belong to different categories. + * In case that {@link IConfigTableOfContents.categoryInlineTag} is provided it will try categorize by + * using it and only if it didn't, it will attempt to categorize by name. + */ + categorizeByName?: boolean; + + /** + * Inline tag that will be used to categorize the API items. Will take precedence over the + * {@link IConfigTableOfContents.categorizeByName} flag in trying to place the API item according to the + * custom inline tag present in documentation of the source code. + */ + categoryInlineTag?: string; + + /** + * Array of node names that might have already items injected at the time of creating the + * {@link IConfigTableOfContents.tocConfig} tree structure but are still needed to be included as category + * nodes where API items will be pushed during the categorization algorithm. + */ + nonEmptyCategoryNodeNames?: string[]; +} + +/** + * Describes plugin packages to be loaded, and which features to enable. + */ +export interface IConfigPlugin { + /** + * Specifies the name of an API Documenter plugin package to be loaded. By convention, the NPM package name + * should have the prefix `doc-plugin-`. Its main entry point should export an object named + * `apiDocumenterPluginManifest` which implements the {@link IApiDocumenterPluginManifest} interface. + */ + packageName: string; + + /** + * A list of features to be enabled. The features are defined in {@link IApiDocumenterPluginManifest.features}. + * The `enabledFeatureNames` strings are matched with {@link IFeatureDefinition.featureName}. + */ + enabledFeatureNames: string[]; +} + +/** + * This interface represents the api-documenter.json file format. + */ +export interface IConfigFile { + /** + * Specifies the output target. + */ + outputTarget: 'docfx' | 'markdown'; + + /** + * Specifies what type of newlines API Documenter should use when writing output files. + * + * @remarks + * By default, the output files will be written with Windows-style newlines. + * To use POSIX-style newlines, specify "lf" instead. + * To use the OS's default newline kind, specify "os". + */ + newlineKind?: 'crlf' | 'lf' | 'os'; + + /** + * This enables an experimental feature that will be officially released with the next major version + * of API Documenter. It requires DocFX 2.46 or newer. It enables documentation for namespaces and + * adds them to the table of contents. This will also affect file layout as namespaced items will be nested + * under a directory for the namespace instead of just within the package. + * + * This setting currently only affects the 'docfx' output target. It is equivalent to the `--new-docfx-namespaces` + * command-line parameter. + */ + newDocfxNamespaces?: boolean; + + /** {@inheritDoc IConfigPlugin} */ + plugins?: IConfigPlugin[]; + + /** {@inheritDoc IConfigTableOfContents} */ + tableOfContents?: IConfigTableOfContents; + + /** + * Specifies whether inherited members should also be shown on an API item's page. + */ + showInheritedMembers?: boolean; +} diff --git a/packages/typescript-reference-doc-generator/src/documenters/MarkdownDocumenter.ts b/packages/typescript-reference-doc-generator/src/documenters/MarkdownDocumenter.ts new file mode 100644 index 0000000000..56a903601c --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/documenters/MarkdownDocumenter.ts @@ -0,0 +1,1708 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import fs from 'fs'; +import { + DocSection, + DocPlainText, + DocLinkTag, + TSDocConfiguration, + StringBuilder, + DocParagraph, + DocCodeSpan, + DocFencedCode, + StandardTags, + DocBlock, + DocComment, + DocNode, + DocExcerpt, + DocErrorText, +} from '@microsoft/tsdoc'; +import { + ApiModel, + ApiItem, + ApiEnum, + ApiPackage, + ApiItemKind, + ApiReleaseTagMixin, + ApiDocumentedItem, + ApiClass, + ReleaseTag, + ApiStaticMixin, + ApiPropertyItem, + ApiInterface, + Excerpt, + ApiParameterListMixin, + ApiReturnTypeMixin, + ApiDeclaredItem, + ApiNamespace, + ExcerptTokenKind, + ApiTypeAlias, + ExcerptToken, + ApiOptionalMixin, + ApiInitializerMixin, + ApiProtectedMixin, + ApiReadonlyMixin, + IFindApiItemsResult, + Parameter, + ApiProperty, + HeritageType, + ApiTypeParameterListMixin, +} from '@microsoft/api-extractor-model'; + +import { CustomDocNodes } from '../nodes/CustomDocNodeKind'; +import { DocTable } from '../nodes/DocTable'; +import { + DocEmphasisSpan, + IDocEmphasisSpanParameters, +} from '../nodes/DocEmphasisSpan'; +import { DocTableRow } from '../nodes/DocTableRow'; +import { DocTableCell } from '../nodes/DocTableCell'; +import { DocNoteBox } from '../nodes/DocNoteBox'; +import { ClassifiedToken, Utilities } from '../utils/Utilities'; +import { CustomMarkdownEmitter } from '../markdown/CustomMarkdownEmitter'; +import { PluginLoader } from '../plugin/PluginLoader'; +import { + IMarkdownDocumenterFeatureOnBeforeWritePageArgs, + MarkdownDocumenterFeatureContext, +} from '../plugin/MarkdownDocumenterFeature'; +import { DocumenterConfig } from './DocumenterConfig'; +import { MarkdownDocumenterAccessor } from '../plugin/MarkdownDocumenterAccessor'; +import { ContentBlockType, DocContentBlock } from '../nodes/DocContentBlock'; +import { + BuilderArg, + DocContentBlockBuilder, +} from '../utils/DocContentBlockBuilder'; +import TypeResolver from '../utils/TypeResolver'; +import { DocMaybe } from '../nodes/DocMaybe'; + +export interface IMarkdownDocumenterOptions { + apiModel: ApiModel; + documenterConfig: DocumenterConfig | undefined; + outputFolder: string; +} + +(DocParagraph.prototype as any).toBlock = function ( + type: ContentBlockType = 'inline' +): DocContentBlock { + return new DocContentBlock( + { configuration: this.configuration, type }, + this.getChildNodes() + ); +}; + +/** + * Renders API documentation in the Markdown file format. + * For more info: https://en.wikipedia.org/wiki/Markdown + */ +export class MarkdownDocumenter { + private readonly _apiModel: ApiModel; + private readonly _documenterConfig: DocumenterConfig | undefined; + private readonly _tsdocConfiguration: TSDocConfiguration; + private readonly _markdownEmitter: CustomMarkdownEmitter; + private readonly _outputFolder: string; + private readonly _pluginLoader: PluginLoader; + private readonly _typeResolver: TypeResolver; + + public constructor(options: IMarkdownDocumenterOptions) { + this._apiModel = options.apiModel; + this._documenterConfig = options.documenterConfig; + this._outputFolder = options.outputFolder; + this._tsdocConfiguration = CustomDocNodes.configuration; + this._markdownEmitter = new CustomMarkdownEmitter(this._apiModel); + this._typeResolver = new TypeResolver( + this._apiModel, + (apiItem: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItem); + } + ); + this._pluginLoader = new PluginLoader(); + } + + public generateFiles(): void { + if (this._documenterConfig) { + this._pluginLoader.load(this._documenterConfig, () => { + return new MarkdownDocumenterFeatureContext({ + apiModel: this._apiModel, + outputFolder: this._outputFolder, + documenter: new MarkdownDocumenterAccessor({ + getLinkForApiItem: (apiItem: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItem); + }, + }), + }); + }); + } + + this._deleteOldOutputFiles(); + + try { + this._writeApiItemPage(this._apiModel); + } catch (e) { + console.error(e); + throw e; + } + + if (this._pluginLoader.markdownDocumenterFeature) { + this._pluginLoader.markdownDocumenterFeature.onFinished({}); + } + } + + private _writeApiItemPage(apiItem: ApiItem): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + const output: DocSection = this.section(); + + this._writeBreadcrumb(output, apiItem); + + const scopedName: string = apiItem.getScopedNameWithinPackage(); + + switch (apiItem.kind) { + case ApiItemKind.Class: + output.appendNode(this.heading(`${scopedName} class`)); + break; + case ApiItemKind.Enum: + output.appendNode(this.heading(`${scopedName} enum`)); + break; + case ApiItemKind.Interface: + output.appendNode(this.heading(`${scopedName} interface`)); + break; + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + output.appendNode(this.heading(scopedName)); + break; + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + output.appendNode(this.heading(`${scopedName} method`)); + break; + case ApiItemKind.Function: + output.appendNode(this.heading(`${scopedName} function`)); + break; + case ApiItemKind.Model: + output.appendNode(this.heading(`API Reference`)); + break; + case ApiItemKind.Namespace: + output.appendNode(this.heading(`${scopedName} namespace`)); + break; + case ApiItemKind.Package: + console.log(`Writing ${apiItem.displayName} package`); + const unscopedPackageName: string = + Utilities.getUnscopedPackageName(apiItem.displayName); + output.appendNode( + this.heading(`${unscopedPackageName} package`) + ); + break; + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + output.appendNode(this.heading(`${scopedName} property`)); + break; + case ApiItemKind.TypeAlias: + output.appendNode(this.heading(`${scopedName} type`)); + break; + case ApiItemKind.Variable: + output.appendNode(this.heading(`${scopedName} variable`)); + break; + default: + throw new Error('Unsupported API item kind: ' + apiItem.kind); + } + + if (ApiReleaseTagMixin.isBaseClassOf(apiItem)) { + if (apiItem.releaseTag === ReleaseTag.Beta) { + const betaWarning: string = + 'This API is provided as a preview for developers and may change' + + ' based on feedback that we receive. Do not use this API in a production environment.'; + output.appendNode( + new DocNoteBox({ configuration }, [ + this.paragraph([this.text(betaWarning)]), + ]) + ); + } + } + + const decoratorBlocks: DocBlock[] = []; + + if (apiItem instanceof ApiDeclaredItem) { + if ( + apiItem.kind === ApiItemKind.Function && + ApiParameterListMixin.isBaseClassOf(apiItem) + ) { + output.appendNode( + this._createSignatureWithTypesLinkedToDefinitions( + apiItem, + undefined, + true + ) + ); + } else if (apiItem.excerpt.text.length > 0) { + output.appendNode( + this.paragraph([this.text('Signature:', { bold: true })]) + ); + + output.appendNode( + new DocFencedCode({ + configuration, + code: Utilities.getSignatureExcerpt(apiItem), + language: 'typescript', + }) + ); + } + + this._writeHeritageTypes(output, apiItem); + } + + this._writeRemarksSection(output, apiItem); + + let writtenDescription = false; + switch (apiItem.kind) { + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + case ApiItemKind.Function: + break; + default: + this._writeDescriptionSection(output, apiItem); + writtenDescription = true; + } + + switch (apiItem.kind) { + case ApiItemKind.Class: + this._writeClassMembers(output, apiItem as ApiClass); + break; + case ApiItemKind.Enum: + this._writeEnumTables(output, apiItem as ApiEnum); + break; + case ApiItemKind.Interface: + this._writeInterfaceMembers(output, apiItem as ApiInterface); + break; + case ApiItemKind.Constructor: + case ApiItemKind.ConstructSignature: + case ApiItemKind.Method: + case ApiItemKind.MethodSignature: + case ApiItemKind.Function: + output.appendNode( + this._createParametersList(apiItem as ApiParameterListMixin) + ); + output.appendNode(this._createThrowsSection(apiItem)); + break; + case ApiItemKind.Namespace: + this._writePackageOrNamespaceTables( + output, + apiItem as ApiNamespace + ); + break; + case ApiItemKind.Model: + this._writeModelTable(output, apiItem as ApiModel); + break; + case ApiItemKind.Package: + this._writePackageOrNamespaceTables( + output, + apiItem as ApiPackage + ); + break; + case ApiItemKind.Property: + case ApiItemKind.PropertySignature: + break; + case ApiItemKind.TypeAlias: + break; + case ApiItemKind.Variable: + break; + default: + throw new Error('Unsupported API item kind: ' + apiItem.kind); + } + + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + decoratorBlocks.push( + ...tsdocComment.customBlocks.filter( + (block) => + block.blockTag.tagNameWithUpperCase === + StandardTags.decorator.tagNameWithUpperCase + ) + ); + } + } + + if (!writtenDescription) { + this._writeDescriptionSection(output, apiItem); + } + + if (decoratorBlocks.length > 0) { + output.appendNode( + this.paragraph([this.text('Decorators:', { bold: true })]) + ); + for (const decoratorBlock of decoratorBlocks) { + output.appendNodes(decoratorBlock.content.nodes); + } + } + + this._writeExamplesSection(output, apiItem); + + const filename: string = path.join( + this._outputFolder, + this._getFilenameForApiItem(apiItem) + ); + const stringBuilder: StringBuilder = new StringBuilder(); + + stringBuilder.append( + '\n\n' + ); + + this._markdownEmitter.emit(stringBuilder, output, { + contextApiItem: apiItem, + onGetFilenameForApiItem: (apiItemForFilename: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItemForFilename); + }, + }); + + let pageContent: string = stringBuilder.toString(); + + if (this._pluginLoader.markdownDocumenterFeature) { + // Allow the plugin to customize the pageContent + const eventArgs: IMarkdownDocumenterFeatureOnBeforeWritePageArgs = { + apiItem: apiItem, + outputFilename: filename, + pageContent: pageContent, + }; + this._pluginLoader.markdownDocumenterFeature.onBeforeWritePage( + eventArgs + ); + pageContent = eventArgs.pageContent; + } + + fs.writeFileSync(filename, pageContent); + } + + private _writeDescriptionSection( + output: DocSection, + apiItem: ApiItem + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + if (tsdocComment.deprecatedBlock) { + output.appendNode( + new DocNoteBox({ configuration }, [ + this.paragraph([ + this.text( + 'Warning: This API is now obsolete. ' + ), + ]), + ...tsdocComment.deprecatedBlock.content.nodes, + ]) + ); + } + + output.appendNodes(tsdocComment.summarySection.nodes); + } + } + } + + private _writeHeritageTypes( + output: DocSection, + apiItem: ApiDeclaredItem + ): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiClass) { + if (apiItem.extendsType) { + output.appendNode( + this.build([ + this.text('Extends: ', { bold: true }), + this.formatTypeExpression( + apiItem, + apiItem.extendsType.excerpt + ), + ]).toContentBlock('block') + ); + } + if (apiItem.implementsTypes.length > 0) { + output.appendNode( + this.build([ + this.text('Implements: ', { bold: true }), + this.build( + apiItem.implementsTypes.map((implementsType) => + this.formatTypeExpression( + apiItem, + implementsType.excerpt + ) + ) + ) + .join(this.text(', ')) + .toContentBlock('inline'), + ]).toContentBlock('block') + ); + } + } + + if (apiItem instanceof ApiInterface) { + if (apiItem.extendsTypes.length > 0) { + output.appendNode( + this.build([ + this.text('Extends: ', { bold: true }), + this.build( + apiItem.extendsTypes.map((extendsType) => + this.formatTypeExpression( + apiItem, + extendsType.excerpt + ) + ) + ) + .join(this.text(', ')) + .toContentBlock('inline'), + ]).toContentBlock('block') + ); + } + } + + if (apiItem instanceof ApiTypeAlias) { + const refs: ExcerptToken[] = apiItem.excerptTokens.filter( + (token) => + token.kind === ExcerptTokenKind.Reference && + token.canonicalReference && + this._apiModel.resolveDeclarationReference( + token.canonicalReference, + undefined + ).resolvedApiItem + ); + if (refs.length > 0) { + const referencesParagraph: DocParagraph = this.paragraph([ + this.text('References: ', { bold: true }), + ]); + let needsComma: boolean = false; + const visited: Set = new Set(); + for (const ref of refs) { + if (visited.has(ref.text)) { + continue; + } + visited.add(ref.text); + + if (needsComma) { + referencesParagraph.appendNode(this.text(', ')); + } + + referencesParagraph.appendNode( + this.formatTypeIdentifier(ref) + ); + needsComma = true; + } + output.appendNode(referencesParagraph); + } + } + } + + private _writeRemarksSection(output: DocSection, apiItem: ApiItem): void { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + // Write the @remarks block + if (tsdocComment.remarksBlock) { + output.appendNode(this.heading('Remarks')); + output.appendNodes(tsdocComment.remarksBlock.content.nodes); + } + } + } + } + + private _writeExamplesSection(output: DocSection, apiItem: ApiItem): void { + if (apiItem instanceof ApiDocumentedItem) { + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + + if (tsdocComment) { + // Write the @example blocks + const exampleBlocks: DocBlock[] = + tsdocComment.customBlocks.filter( + (x) => + x.blockTag.tagNameWithUpperCase === + StandardTags.example.tagNameWithUpperCase + ); + + let exampleNumber: number = 1; + for (const exampleBlock of exampleBlocks) { + const heading: string = + exampleBlocks.length > 1 + ? `Example ${exampleNumber}` + : 'Example'; + + output.appendNode(this.heading(heading)); + output.appendNodes(exampleBlock.content.nodes); + ++exampleNumber; + } + } + } + } + + private _createThrowsSection(apiItem: ApiItem): DocNode { + if (!(apiItem instanceof ApiDocumentedItem)) { + return this.block(); + } + const tsdocComment: DocComment | undefined = apiItem.tsdocComment; + if (!tsdocComment) { + return this.block(); + } + + const throwsListItems = tsdocComment.customBlocks + .filter( + (block) => + block.blockTag.tagNameWithUpperCase === + StandardTags.throws.tagNameWithUpperCase + ) + .map((throwsBlock) => { + const errorClassName = ( + ( + throwsBlock.content + .getChildNodes()[0] + ?.getChildNodes() + .find( + (node) => node instanceof DocLinkTag + ) as DocLinkTag + )?.codeDestination?.memberReferences[0]?.memberIdentifier?.getChildNodes()[0] as any as DocExcerpt + )?.content?.toString(); + + this.build([ + errorClassName && this.formatTypeIdentifier(errorClassName), + throwsBlock.content, + ]) + .join(this.text(' – ')) + .toContentBlock('inline'); + }); + + if (throwsListItems.length === 0) { + return this.block(); + } + + return this.build() + .concat(this.paragraph([this.text('Exceptions:')])) + .concat(this.block(throwsListItems, 'ul')) + .toContentBlock(); + } + + /** + * GENERATE PAGE: MODEL + */ + private _writeModelTable(output: DocSection, apiModel: ApiModel): void { + const packagesTable: DocTable = this.table(['Package', 'Description']); + + for (const apiMember of apiModel.members) { + const row: DocTableRow = this.tableRow([ + this._createTitleCell(apiMember), + this._createDescriptionCell(apiMember), + ]); + + switch (apiMember.kind) { + case ApiItemKind.Package: + packagesTable.addRow(row); + this._writeApiItemPage(apiMember); + break; + } + } + + if (packagesTable.rows.length > 0) { + output.appendNode(this.heading('Packages')); + output.appendNode(packagesTable); + } + } + + /** + * GENERATE PAGE: PACKAGE or NAMESPACE + */ + private _writePackageOrNamespaceTables( + output: DocSection, + apiContainer: ApiPackage | ApiNamespace + ): void { + const classesList: DocContentBlock = this.block([], 'ul'); + const enumerationsList: DocContentBlock = this.block([], 'ul'); + const functionsList: DocContentBlock = this.block([], 'ul'); + const interfacesList: DocContentBlock = this.block([], 'ul'); + const namespacesList: DocContentBlock = this.block([], 'ul'); + const variablesList: DocContentBlock = this.block([], 'ul'); + const typeAliasesList: DocContentBlock = this.block([], 'ul'); + + const apiMembers: ReadonlyArray = + apiContainer.kind === ApiItemKind.Package + ? (apiContainer as ApiPackage).entryPoints[0].members + : (apiContainer as ApiNamespace).members; + + for (const apiMember of apiMembers) { + const item = this._createTitleListItem(apiMember); + + switch (apiMember.kind) { + case ApiItemKind.Class: + classesList.addItem(item); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Enum: + enumerationsList.addItem(item); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Interface: + interfacesList.addItem(item); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Namespace: + namespacesList.addItem(item); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Function: + functionsList.addItem(item); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.TypeAlias: + typeAliasesList.addItem(item); + this._writeApiItemPage(apiMember); + break; + + case ApiItemKind.Variable: + variablesList.addItem(item); + this._writeApiItemPage(apiMember); + break; + } + } + + if (classesList.items.length > 0) { + output.appendNode(this.heading('Classes')); + output.appendNode(classesList); + } + + if (enumerationsList.items.length > 0) { + output.appendNode(this.heading('Enumerations')); + output.appendNode(enumerationsList); + } + + if (functionsList.items.length > 0) { + output.appendNode(this.heading('Functions')); + output.appendNode(functionsList); + } + + if (interfacesList.items.length > 0) { + output.appendNode(this.heading('Interfaces')); + output.appendNode(interfacesList); + } + + if (namespacesList.items.length > 0) { + output.appendNode(this.heading('Namespaces')); + output.appendNode(namespacesList); + } + + if (variablesList.items.length > 0) { + output.appendNode(this.heading('Variables')); + output.appendNode(variablesList); + } + + if (typeAliasesList.items.length > 0) { + output.appendNode(this.heading('Type Aliases')); + output.appendNode(typeAliasesList); + } + } + + /** + * GENERATE PAGE: CLASS + */ + private _writeClassMembers(output: DocSection, apiClass: ApiClass): void { + const eventsTable: DocTable = this.table([ + 'Property', + 'Modifiers', + 'Type', + 'Description', + ]); + + const constructorsList: DocContentBlock = this.block([], 'block'); + const propertiesList: DocContentBlock = this.block([], 'ul'); + const methodsList: DocContentBlock = this.block([], 'block'); + + const apiMembers: readonly ApiItem[] = + this._getMembersAndWriteIncompleteWarning(apiClass, output); + for (const apiMember of apiMembers) { + const isInherited: boolean = apiMember.parent !== apiClass; + switch (apiMember.kind) { + case ApiItemKind.Constructor: { + constructorsList.addItems([ + this.block( + [ + this._createSignatureWithTypesLinkedToDefinitions( + apiMember, + apiClass + ), + ], + 'h3' + ), + this._createParametersList( + apiMember as ApiParameterListMixin + ), + ...this._createModifiersCell( + apiMember + ).content.getChildNodes(), + this.buildDescriptionBlock( + apiMember, + isInherited + ).toContentBlock(), + ]); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.Method: { + methodsList.addItems([ + this.block( + [ + this._createSignatureWithTypesLinkedToDefinitions( + apiMember, + apiClass + ), + ], + 'h3' + ), + this._createParametersList( + apiMember as ApiParameterListMixin + ), + this._createThrowsSection(apiMember), + ...this._createModifiersCell( + apiMember + ).content.getChildNodes(), + this.buildDescriptionBlock( + apiMember, + isInherited + ).toContentBlock(), + ]); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.Property: { + if ((apiMember as ApiPropertyItem).isEventProperty) { + eventsTable.addRow( + this.tableRow([ + this._createTitleCell(apiMember), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell( + apiMember, + isInherited + ), + ]) + ); + } else { + propertiesList.addItem( + this._createPropertyItem( + apiMember as ApiProperty, + isInherited + ) + ); + } + + this._writeApiItemPage(apiMember); + break; + } + } + } + + if (eventsTable.rows.length > 0) { + output.appendNode(this.heading('Events')); + output.appendNode(eventsTable); + } + + if (constructorsList.items.length > 0) { + output.appendNode(this.heading('Constructors')); + output.appendNode(constructorsList); + } + + if (propertiesList.items.length > 0) { + output.appendNode(this.heading('Properties')); + output.appendNode(propertiesList); + } + + if (methodsList.items.length > 0) { + output.appendNode(this.heading('Methods')); + output.appendNode(this.section([methodsList])); + } + } + + /** + * GENERATE PAGE: ENUM + */ + private _writeEnumTables(output: DocSection, apiEnum: ApiEnum): void { + const enumMembersTable: DocTable = this.table([ + 'Member', + 'Value', + 'Description', + ]); + + for (const apiEnumMember of apiEnum.members) { + enumMembersTable.addRow( + this.tableRow([ + this.tableCell([ + this.paragraph([ + this.text( + Utilities.getConciseSignature(apiEnumMember) + ), + ]), + ]), + this._createInitializerCell(apiEnumMember), + this._createDescriptionCell(apiEnumMember), + ]) + ); + } + + if (enumMembersTable.rows.length > 0) { + output.appendNode(this.heading('Enumeration Members')); + output.appendNode(enumMembersTable); + } + } + + /** + * GENERATE PAGE: INTERFACE + */ + private _writeInterfaceMembers( + output: DocSection, + apiInterface: ApiInterface + ): void { + const eventsTable: DocTable = this.table([ + 'Property', + 'Modifiers', + 'Type', + 'Description', + ]); + const propertiesList: DocContentBlock = this.block([], 'ul'); + const methodsList: DocContentBlock = this.block([], 'ul'); + + const apiMembers: readonly ApiItem[] = + this._getMembersAndWriteIncompleteWarning(apiInterface, output); + for (const apiMember of apiMembers) { + const isInherited: boolean = apiMember.parent !== apiInterface; + switch (apiMember.kind) { + case ApiItemKind.ConstructSignature: + case ApiItemKind.MethodSignature: { + methodsList.addItem( + this.build([ + ...this._createTitleCell( + apiMember + ).content.getChildNodes(), + this.buildDescriptionBlock( + apiMember, + isInherited + ).toContentBlock(), + ]).toContentBlock() + ); + + this._writeApiItemPage(apiMember); + break; + } + case ApiItemKind.PropertySignature: { + if ((apiMember as ApiPropertyItem).isEventProperty) { + eventsTable.addRow( + this.tableRow([ + this._createTitleCell(apiMember), + this._createModifiersCell(apiMember), + this._createPropertyTypeCell(apiMember), + this._createDescriptionCell( + apiMember, + isInherited + ), + ]) + ); + } else { + propertiesList.addItem( + this._createPropertyItem( + apiMember as ApiProperty, + isInherited + ) + ); + } + + this._writeApiItemPage(apiMember); + break; + } + } + } + + if (eventsTable.rows.length > 0) { + output.appendNode(this.heading('Events')); + output.appendNode(eventsTable); + } + + if (propertiesList.items.length > 0) { + output.appendNode(this.heading('Properties')); + output.appendNode(propertiesList); + } + + if (methodsList.items.length > 0) { + output.appendNode(this.heading('Methods')); + output.appendNode(methodsList); + } + } + + /** + * GENERATE PAGE: FUNCTION-LIKE + */ + private _createParametersList( + apiParameterListMixin: ApiParameterListMixin, + { withTypes = false } = {} + ): DocContentBlock { + const parametersList: DocContentBlock = this.block([], 'ul'); + + if (apiParameterListMixin.parameters.length > 0) { + for (const apiParameter of apiParameterListMixin.parameters) { + const descriptionNode = this.build() + .concat(apiParameter.isOptional && this.text('Optional.')) + .concat( + this.formatApiParameterDescription( + apiParameterListMixin, + apiParameter + ) + ) + .join(this.space()) + // .flatten() + .toContentBlockMaybe(); + if (!descriptionNode) { + continue; + } + parametersList.addItem( + this.build() + .concat( + this.build([ + this.code(apiParameter.name), + withTypes && + this.formatTypeExpression( + apiParameterListMixin, + apiParameter.parameterTypeExcerpt + ), + ]) + .join(this.space()) + .toContentBlock('inline') + ) + .concat(descriptionNode) + .join(this.text('–')) + .join(this.space()) + .toContentBlock() + ); + } + } + + if (ApiReturnTypeMixin.isBaseClassOf(apiParameterListMixin)) { + this._assertReturnsBlockDoesNotIncludeType(apiParameterListMixin); + const returnType = + apiParameterListMixin instanceof ApiDocumentedItem && + apiParameterListMixin?.tsdocComment?.returnsBlock?.content; + if (returnType) { + parametersList.addItem( + this.build() + .concat(this.text('Returns:')) + .concat(this.space()) + .concat(returnType) + .toContentBlock() + ); + } + } + + return parametersList; + } + + private _assertReturnsBlockDoesNotIncludeType( + apiParameterListMixin: ApiParameterListMixin + ): void { + const returnBlockContent = + apiParameterListMixin instanceof ApiDocumentedItem && + apiParameterListMixin?.tsdocComment?.returnsBlock?.content; + + if (!returnBlockContent) { + return; + } + + const nodes = + returnBlockContent.getChildNodes()[0]?.getChildNodes() || []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node instanceof DocErrorText && i <= 2 && node.text === '{') { + const source = [ + apiParameterListMixin.parent?.displayName, + apiParameterListMixin.displayName, + ] + .filter((x) => x) + .join('.'); + throw new Error( + "Don't use { } in the @returns block. Instead, use the TypeScript syntax to define the return type in " + + source + + '.' + ); + } + } + } + + private _createPropertyItem( + apiMember: ApiProperty, + isInherited: boolean + ): DocContentBlock { + return this.build([ + this.code(Utilities.getConciseSignature(apiMember)), + this.space(), + ]) + .concat(this._createModifiersDescription(apiMember)) + .concat( + this.build([ + this.formatTypeExpression( + apiMember, + apiMember.propertyTypeExcerpt + ), + this.buildDescriptionBlock( + apiMember, + isInherited + ).toContentBlockMaybe('inline'), + ]) + .join(this.text('–')) + .join(this.space()) + ) + .join(this.space()) + .toContentBlock(); + } + + private _createModifiersDescription(apiItem: ApiItem): DocContentBlock { + return this.build( + this._getModifiers(apiItem).map((name) => this.text(name)) + ) + .join(this.block([this.text(', '), this.space()])) + .toContentBlock('inline'); + } + + private _getModifiers(apiItem: ApiItem): string[] { + const modifiers: string[] = []; + + if (ApiProtectedMixin.isBaseClassOf(apiItem)) { + if (apiItem.isProtected) { + modifiers.push('protected'); + } + } + + if (ApiReadonlyMixin.isBaseClassOf(apiItem)) { + if (apiItem.isReadonly) { + modifiers.push('readonly'); + } + } + + if (ApiStaticMixin.isBaseClassOf(apiItem)) { + if (apiItem.isStatic) { + modifiers.push('static'); + } + } + return modifiers; + } + + public formatTypeExpression( + apiItem: ApiItem, + typeExcerpt: Excerpt + ): DocNode { + // Turn a token sequence into a string for TS to parse + const typeExpression = typeExcerpt.spannedTokens + .map(({ text }) => text) + .join(''); + + // Build an index of canonical tokens + const canonicalTokenIndex: Record = {}; + for (const token of typeExcerpt.spannedTokens) { + const canonicalUrl = + this._typeResolver.resolveCanonicalReference(token); + if (canonicalUrl) { + canonicalTokenIndex[token.text] = canonicalUrl; + } + } + + // Take note of the type parameters to avoid linking them + const typeParameters = ApiTypeParameterListMixin.isBaseClassOf(apiItem) + ? apiItem.typeParameters.map((t) => t.name) + : []; + + const classifiedTokens = Utilities.classifyTypeExpression( + typeExpression, + new Set(typeParameters) + ); + + const linkedTokens = classifiedTokens + .map(({ text, isLinkable }: ClassifiedToken) => { + if (!isLinkable) { + return { text }; + } + + if (canonicalTokenIndex[text]) { + return { text, url: canonicalTokenIndex[text] }; + } + + const nativeResult = + this._typeResolver.resolveNativeToken(text); + if (nativeResult) { + return { + text: nativeResult.name, + url: nativeResult.docUrl, + }; + } + + if (!MarkdownDocumenter.seenTypeTokens.has(text)) { + if (MarkdownDocumenter.seenTypeTokens.size === 0) { + console.warn( + `No definition was found for some type tokens listed below with ❌. ` + + `This means they won't be linked to their definition using in the documentation. \n` + + `Please check if the type, the imports etc are correct. If this type comes from ` + + `a third-party library, please add it to the list of known types in the NativeTypes object.` + ); + } + const source = this.formatExcerptSource(apiItem); + console.warn(`❌ Couldn't resolve "${text}" in ${source}`); + MarkdownDocumenter.seenTypeTokens.add(text); + } + return { text }; + }) + .map(({ text, url }) => ({ + text: text.replace('\n', ' ').trim(), + url, + })); + + return this.block( + linkedTokens.map(({ text, url }) => { + if (url) { + return this.link(text, url); + } else { + return this.text(text); + } + }), + 'inline' + ); + } + + public formatTypeIdentifier(type: ExcerptToken | string) { + const resolved = this._typeResolver.resolveTypeDocumentation(type); + if (resolved) { + return this.link(resolved.name, resolved.docUrl); + } else if (type instanceof ExcerptToken) { + return this.text(type.text); + } else { + return this.text(type); + } + } + + private formatApiParameterDescription( + apiParameterListMixin: ApiParameterListMixin, + apiParameter: Parameter + ): BuilderArg { + const contentSection = apiParameter.tsdocParamBlock?.content; + if (contentSection) { + // Remove the leading whitespaces and slashes from the + // parameters descriptions + const str = this.nodeToMarkdown( + apiParameterListMixin, + apiParameter.tsdocParamBlock!.content + ); + return [this.literal(str.replace(/^(\s[\-–—])+/, ''))]; + } + return []; + } + + private static seenTypeTokens = new Set(); + + private formatExcerptSource(source: ApiItem | HeritageType): string { + if (source instanceof HeritageType) { + return source.excerpt.text; + } else if (source instanceof ApiItem) { + return source.canonicalReference.toString(); + } + console.warn('Unknown source', { source }); + throw new Error('Unknown source'); + } + + private _createTitleCell(apiItem: ApiItem): DocTableCell { + let linkText: string = Utilities.getConciseSignature(apiItem); + if (ApiOptionalMixin.isBaseClassOf(apiItem) && apiItem.isOptional) { + linkText += '?'; + } + + return this.tableCell([ + this.paragraph([ + this.link(linkText, this._getLinkFilenameForApiItem(apiItem)), + ]), + ]); + } + + private _createTitleListItem(apiItem: ApiItem): DocNode { + return this.paragraph([this._createApiItemLinkTag(apiItem)]); + } + + private _createApiItemLinkTag(apiItem: ApiItem): DocLinkTag { + // Remove the arguments: + let linkText: string = + Utilities.getConciseSignature(apiItem).split('(')[0]; + if (ApiOptionalMixin.isBaseClassOf(apiItem) && apiItem.isOptional) { + linkText += '?'; + } + + return this.link(linkText, this._getLinkFilenameForApiItem(apiItem)); + } + + /** + * This generates a DocTableCell for an ApiItem including the summary section and "(BETA)" annotation. + * + * @remarks + * We mostly assume that the input is an ApiDocumentedItem, but it's easier to perform this as a runtime + * check than to have each caller perform a type cast. + */ + private buildDescriptionBlock( + apiItem: ApiItem, + isInherited: boolean = false + ): DocContentBlockBuilder { + return this.build([ + ApiReleaseTagMixin.isBaseClassOf(apiItem) && + apiItem.releaseTag === ReleaseTag.Beta && + this.paragraph([ + this.text('(BETA)', { bold: true, italic: true }), + ]), + ApiOptionalMixin.isBaseClassOf(apiItem) && + apiItem.isOptional && + this.paragraph([this.text('(Optional)', { italic: true })]), + ]) + .concat( + apiItem instanceof ApiDocumentedItem && + apiItem.tsdocComment?.summarySection && + this.literal( + this.nodeToMarkdown( + apiItem, + apiItem.tsdocComment!.summarySection + ) + ) + ) + .concat([ + isInherited && + apiItem.parent && + this.paragraph([ + this.text('(Inherited from '), + this.link( + apiItem.parent.displayName, + this._getLinkFilenameForApiItem(apiItem.parent) + ), + this.text(')'), + ]), + ]) + .join(this.space()); + } + + private _createDescriptionCell( + apiItem: ApiItem, + isInherited: boolean = false + ): DocTableCell { + const desc = this.buildDescriptionBlock( + apiItem, + isInherited + ).toContentBlockMaybe('inline'); + return this.tableCell(desc ? [desc] : []); + } + + private _createModifiersCell(apiItem: ApiItem): DocTableCell { + return this.tableCell( + this._createModifiersDescription(apiItem).getChildNodes() + ); + } + + private _createPropertyTypeCell(apiItem: ApiItem): DocTableCell { + const section: DocSection = this.section(); + + if (apiItem instanceof ApiPropertyItem) { + section.appendNode( + this.formatTypeExpression(apiItem, apiItem.propertyTypeExcerpt) + ); + } + + return this.tableCell(section.nodes); + } + + private _createInitializerCell(apiItem: ApiItem): DocTableCell { + const section: DocSection = this.section(); + + if (ApiInitializerMixin.isBaseClassOf(apiItem)) { + if (apiItem.initializerExcerpt) { + section.appendNodeInParagraph( + this.code(apiItem.initializerExcerpt.text) + ); + } + } + + return this.tableCell(section.nodes); + } + + private _writeBreadcrumb(output: DocSection, apiItem: ApiItem): void { + output.appendNodeInParagraph( + this.link('Home', this._getLinkFilenameForApiItem(this._apiModel)) + ); + + for (const hierarchyItem of apiItem.getHierarchy()) { + switch (hierarchyItem.kind) { + case ApiItemKind.Model: + case ApiItemKind.EntryPoint: + // We don't show the model as part of the breadcrumb because it is the root-level container. + // We don't show the entry point because today API Extractor doesn't support multiple entry points; + // this may change in the future. + break; + default: + output.appendNodesInParagraph([ + this.text(' > '), + this.link( + hierarchyItem.displayName, + this._getLinkFilenameForApiItem(hierarchyItem) + ), + ]); + } + } + } + + private _getMembersAndWriteIncompleteWarning( + apiClassOrInterface: ApiClass | ApiInterface, + output: DocSection + ): readonly ApiItem[] { + const showInheritedMembers: boolean = + !!this._documenterConfig?.configFile.showInheritedMembers; + if (!showInheritedMembers) { + return apiClassOrInterface.members; + } + + const result: IFindApiItemsResult = + apiClassOrInterface.findMembersWithInheritance(); + + // If the result is potentially incomplete, write a short warning communicating this. + if (result.maybeIncompleteResult) { + output.appendNode( + this.paragraph([ + this.text( + '(Some inherited members may not be shown because they are not represented in the documentation.)', + { + italic: true, + } + ), + ]) + ); + } + + // Log the messages for diagnostic purposes. + for (const message of result.messages) { + console.log( + `Diagnostic message for findMembersWithInheritance: ${message.text}` + ); + } + + return result.items; + } + + private _getFilenameForApiItem(apiItem: ApiItem): string { + if (apiItem.kind === ApiItemKind.Model) { + return 'index.md'; + } + + let baseName: string = ''; + for (const hierarchyItem of apiItem.getHierarchy()) { + // For overloaded methods, add a suffix such as "MyClass.myMethod_2". + let qualifiedName: string = Utilities.getSafeFilenameForName( + hierarchyItem.displayName + ); + if (ApiParameterListMixin.isBaseClassOf(hierarchyItem)) { + if (hierarchyItem.overloadIndex > 1) { + // Subtract one for compatibility with earlier releases of API Documenter. + // (This will get revamped when we fix GitHub issue #1308) + qualifiedName += `_${hierarchyItem.overloadIndex - 1}`; + } + } + + switch (hierarchyItem.kind) { + case ApiItemKind.Model: + case ApiItemKind.EntryPoint: + case ApiItemKind.EnumMember: + break; + case ApiItemKind.Package: + baseName = Utilities.getSafeFilenameForName( + Utilities.getUnscopedPackageName( + hierarchyItem.displayName + ) + ); + break; + default: + baseName += '.' + qualifiedName; + } + } + return baseName + '.md'; + } + + private _getLinkFilenameForApiItem(apiItem: ApiItem): string { + return './' + this._getFilenameForApiItem(apiItem); + } + + private _deleteOldOutputFiles(): void { + console.log('Deleting old output from ' + this._outputFolder); + + fs.readdirSync(this._outputFolder).forEach((file) => { + fs.unlinkSync(path.join(this._outputFolder, file)); + }); + } + + private _createSignatureWithTypesLinkedToDefinitions( + apiItem: ApiItem, + apiClass?: ApiClass, + wrap: boolean = false + ): DocContentBlock { + const typeParameters = ApiTypeParameterListMixin.isBaseClassOf(apiItem) + ? apiItem.typeParameters + : []; + const apiParameters = ApiParameterListMixin.isBaseClassOf(apiItem) + ? apiItem.parameters + : []; + const returnTypeExcerpt = + ApiReturnTypeMixin.isBaseClassOf(apiItem) && + apiItem.returnTypeExcerpt; + + let shouldWrapTypeParameters = false; + const typeParametersNodes = typeParameters.length + ? typeParameters.flatMap( + (typeParameter, i) => + this.build([ + this.text(typeParameter.name), + typeParameter.constraintExcerpt?.text && [ + this.text('extends'), + this.formatTypeExpression( + apiItem, + typeParameter.constraintExcerpt + ), + ], + typeParameter.defaultTypeExcerpt?.text && [ + this.text('='), + this.formatTypeExpression( + apiItem, + typeParameter.defaultTypeExcerpt + ), + ], + ]) + .join(this.space()) + .prepend( + this.maybe( + [this.softnl(), this.indent()], + () => shouldWrapTypeParameters + ) + ) + .concat( + i !== typeParameters.length - 1 && [ + this.text(','), + this.space(), + ] + ).childNodes + ) + : []; + + let shouldWrapArguments = false; + const argumentsNodes = apiParameters.flatMap( + (param, i) => + this.build([ + this.text(`${param.name}${param.isOptional ? '?' : ''}`), + ]) + .concat( + !param.parameterTypeExcerpt.isEmpty && [ + this.text(':'), + this.space(), + this.formatTypeExpression( + apiItem, + param.parameterTypeExcerpt + ), + ] + ) + .prepend( + this.maybe( + [this.softnl(), this.indent()], + () => shouldWrapArguments + ) + ) + .concat( + i !== apiParameters.length - 1 && [ + this.text(','), + this.space(), + ] + ).childNodes + ); + + const signature = this.build([ + this.text(Utilities.getDisplayName(apiItem, apiClass)), + ]) + .concat( + typeParametersNodes.length && [ + this.text('<'), + ...typeParametersNodes, + this.maybe([this.softnl()], () => shouldWrapArguments), + shouldWrapTypeParameters && this.softnl(), + this.literal('>'), + ] + ) + .concat([ + this.text('('), + ...argumentsNodes, + this.maybe([this.softnl()], () => shouldWrapArguments), + this.text(')'), + ]) + .concat( + returnTypeExcerpt && [ + this.text(':'), + this.space(), + this.formatTypeExpression(apiItem, returnTypeExcerpt), + ] + ); + + if (wrap) { + const typeParamsLength = Utilities.nodeToText( + this.block(typeParametersNodes) + ).length; + const argsLength = Utilities.nodeToText( + this.block(argumentsNodes) + ).length; + const signatureLength = Utilities.nodeToText( + this.block(argumentsNodes) + ).length; + + if (typeParamsLength > 50) { + shouldWrapTypeParameters = true; + } + + if (argsLength > 50) { + shouldWrapArguments = true; + } + + if (signatureLength > 80) { + shouldWrapTypeParameters = true; + shouldWrapArguments = true; + } + } + + return signature.toContentBlock(); + } + + private heading(text: string, level = 2) { + return this.block([this.text(text)], ('h' + level) as any); + } + + private build(nodes?: BuilderArg) { + return DocContentBlockBuilder.create(this._tsdocConfiguration, nodes); + } + + private code(code: string): DocNode { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocCodeSpan({ configuration, code }); + } + + private literal(text: string): DocNode { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocContentBlock({ configuration, type: 'literal', text }); + } + + private table( + headerTitles: string[] = [], + nodes: readonly DocTableRow[] = [] + ) { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocTable({ configuration, headerTitles }, nodes); + } + + private tableRow(nodes: readonly DocTableCell[] = []) { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocTableRow({ configuration }, nodes); + } + + private tableCell(nodes: readonly DocNode[] = []) { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocTableCell({ configuration }, nodes); + } + + private text( + text: string, + emphasis?: Omit + ): DocNode { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + const textNode = new DocPlainText({ configuration, text }); + if (emphasis) { + return new DocEmphasisSpan({ configuration, bold: true }, [ + textNode, + ]); + } + return textNode; + } + + private maybe(nodes: DocNode[], predicate: () => boolean) { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocMaybe({ configuration, predicate }, nodes); + } + + private paragraph(nodes: readonly DocNode[]): DocParagraph { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocParagraph({ configuration }, nodes); + } + + private section(nodes: readonly DocNode[] = []): DocSection { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocSection({ configuration }, nodes); + } + + private inline(nodes: BuilderArg = []): DocContentBlock { + return this.build(nodes).toContentBlock('inline'); + } + + private block( + nodes: BuilderArg = [], + type: ContentBlockType = 'block' + ): DocContentBlock { + return this.build(nodes).toContentBlock(type); + } + + private link(text: string, url: string): DocLinkTag { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocLinkTag({ + configuration, + tagName: '@link', + linkText: text, + urlDestination: url, + }); + } + + private space(): DocNode { + return this._whitespace(' '); + } + + private softnl(): DocNode { + return this.literal('\\\n'); + } + + private nl(): DocNode { + return this._whitespace('\n'); + } + + private indent(): DocNode { + return this.literal('   '); + } + + private _whitespace(character: string) { + const configuration: TSDocConfiguration = this._tsdocConfiguration; + return new DocContentBlock({ + configuration, + type: 'whitespace', + character, + }); + } + + private nodeToMarkdown(apiItem: ApiItem, node: DocNode): string { + const stringBuilder: StringBuilder = new StringBuilder(); + return this._markdownEmitter + .emit(stringBuilder, node, { + contextApiItem: apiItem, + onGetFilenameForApiItem: (apiItemForFilename: ApiItem) => { + return this._getLinkFilenameForApiItem(apiItemForFilename); + }, + }) + .replace(/^(\s|\n)*/m, '') + .replace(/(\s|\n)*$/m, ''); + } +} diff --git a/packages/typescript-reference-doc-generator/src/index.ts b/packages/typescript-reference-doc-generator/src/index.ts new file mode 100644 index 0000000000..90eb399d13 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/index.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * API Documenter generates an API reference website from the .api.json files created by API Extractor. + * The `@microsoft/api-documenter` package provides the command-line tool. It also exposes a developer API that you + * can use to create plugins that customize how API Documenter generates documentation. + * + * @packageDocumentation + */ + +export { IFeatureDefinition, IApiDocumenterPluginManifest } from './plugin/IApiDocumenterPluginManifest'; +export { MarkdownDocumenterAccessor } from './plugin/MarkdownDocumenterAccessor'; +export { + MarkdownDocumenterFeatureContext, + IMarkdownDocumenterFeatureOnBeforeWritePageArgs, + IMarkdownDocumenterFeatureOnFinishedArgs, + MarkdownDocumenterFeature +} from './plugin/MarkdownDocumenterFeature'; +export { PluginFeature, PluginFeatureContext, PluginFeatureInitialization } from './plugin/PluginFeature'; diff --git a/packages/typescript-reference-doc-generator/src/markdown/CustomMarkdownEmitter.ts b/packages/typescript-reference-doc-generator/src/markdown/CustomMarkdownEmitter.ts new file mode 100644 index 0000000000..04140d981e --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/markdown/CustomMarkdownEmitter.ts @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import colors from 'colors'; + +import { DocNode, DocLinkTag, StringBuilder } from '@microsoft/tsdoc'; +import { ApiModel, IResolveDeclarationReferenceResult, ApiItem } from '@microsoft/api-extractor-model'; + +import { CustomDocNodeKind } from '../nodes/CustomDocNodeKind'; +import { DocHeading } from '../nodes/DocHeading'; +import { DocNoteBox } from '../nodes/DocNoteBox'; +import { DocTable } from '../nodes/DocTable'; +import { DocTableCell } from '../nodes/DocTableCell'; +import { DocEmphasisSpan } from '../nodes/DocEmphasisSpan'; +import { MarkdownEmitter, IMarkdownEmitterContext, IMarkdownEmitterOptions } from './MarkdownEmitter'; +import { IndentedWriter } from '../utils/IndentedWriter'; +import { DocContentBlock } from '../nodes/DocContentBlock'; +import { DocMaybe } from '../nodes/DocMaybe'; + +export interface ICustomMarkdownEmitterOptions extends IMarkdownEmitterOptions { + contextApiItem: ApiItem | undefined; + + onGetFilenameForApiItem: (apiItem: ApiItem) => string | undefined; +} + +export class CustomMarkdownEmitter extends MarkdownEmitter { + private _apiModel: ApiModel; + + public constructor(apiModel: ApiModel) { + super(); + + this._apiModel = apiModel; + } + + public emit(stringBuilder: StringBuilder, docNode: DocNode, options: ICustomMarkdownEmitterOptions): string { + return super.emit(stringBuilder, docNode, options); + } + + /** @override */ + protected writeNode(docNode: DocNode, context: IMarkdownEmitterContext, docNodeSiblings: boolean): void { + const writer: IndentedWriter = context.writer; + + switch (docNode.kind) { + case CustomDocNodeKind.Heading: { + const docHeading: DocHeading = docNode as DocHeading; + writer.ensureSkippedLine(); + + let prefix: string; + switch (docHeading.level) { + case 1: + prefix = '##'; + break; + case 2: + prefix = '###'; + break; + case 3: + prefix = '###'; + break; + default: + prefix = '####'; + } + + writer.writeLine(prefix + ' ' + this.getEscapedText(docHeading.title)); + writer.writeLine(); + break; + } + case CustomDocNodeKind.ForceSoftBreak: { + writer.write(' '); + break; + } + case CustomDocNodeKind.NoteBox: { + const docNoteBox: DocNoteBox = docNode as DocNoteBox; + writer.ensureNewLine(); + + writer.increaseIndent('> '); + + this.writeNode(docNoteBox.content, context, false); + writer.ensureNewLine(); + + writer.decreaseIndent(); + + writer.writeLine(); + break; + } + case CustomDocNodeKind.Table: { + const docTable: DocTable = docNode as DocTable; + // GitHub's markdown renderer chokes on tables that don't have a blank line above them, + // whereas VS Code's renderer is totally fine with it. + writer.ensureSkippedLine(); + + context.insideTable = true; + + // Markdown table rows can have inconsistent cell counts. Size the table based on the longest row. + let columnCount: number = 0; + if (docTable.header) { + columnCount = docTable.header.cells.length; + } + for (const row of docTable.rows) { + if (row.cells.length > columnCount) { + columnCount = row.cells.length; + } + } + + // write the table header (which is required by Markdown) + writer.write('| '); + for (let i: number = 0; i < columnCount; ++i) { + writer.write(' '); + if (docTable.header) { + const cell: DocTableCell | undefined = docTable.header.cells[i]; + if (cell) { + this.writeNode(cell.content, context, false); + } + } + writer.write(' |'); + } + writer.writeLine(); + + // write the divider + writer.write('| '); + for (let i: number = 0; i < columnCount; ++i) { + writer.write(' --- |'); + } + writer.writeLine(); + + for (const row of docTable.rows) { + writer.write('| '); + for (const cell of row.cells) { + writer.write(' '); + this.writeNode(cell.content, context, false); + writer.write(' |'); + } + writer.writeLine(); + } + writer.writeLine(); + + context.insideTable = false; + + break; + } + case CustomDocNodeKind.Maybe: { + const maybe: DocMaybe = docNode as DocMaybe; + if (maybe.isActive) { + this.writeNodes(maybe.getChildNodes(), context); + } + + break; + } + case CustomDocNodeKind.ContentBlock: { + const block: DocContentBlock = docNode as DocContentBlock; + + if (block.type === 'block') { + writer.ensureSkippedLine(); + for (const item of block.items) { + this.writeNode(item, context, false); + } + } else if (block.type === 'literal') { + writer.write(block.text); + } else { + ++context.inlineBlockNestingLevel; + if (block.isWhitespace) { + writer.write(' '); + } else if (block.isList) { + writer.ensureSkippedLine(); + for (let i = 0, max = block.items.length - 1; i <= max; i++) { + const item = block.items[i]; + if (block.type === 'ul') { + writer.write('* '); + } else if (block.type === 'ol') { + writer.write('1. '); + } + this.writeNode(item, context, false); + writer.writeLine(); + } + } else { + if (block.isHeading) { + writer.write('#'.repeat(block.headingLevel) + ' '); + } + for (const item of block.items) { + this.writeNode(item, context, false); + } + } + --context.inlineBlockNestingLevel; + } + if (context.inlineBlockNestingLevel === 0) { + writer.writeLine(); + } + break; + } + case CustomDocNodeKind.EmphasisSpan: { + const docEmphasisSpan: DocEmphasisSpan = docNode as DocEmphasisSpan; + const oldBold: boolean = context.boldRequested; + const oldItalic: boolean = context.italicRequested; + context.boldRequested = docEmphasisSpan.bold; + context.italicRequested = docEmphasisSpan.italic; + this.writeNodes(docEmphasisSpan.nodes, context); + context.boldRequested = oldBold; + context.italicRequested = oldItalic; + break; + } + default: + super.writeNode(docNode, context, docNodeSiblings); + } + } + + /** @override */ + protected writeLinkTagWithCodeDestination( + docLinkTag: DocLinkTag, + context: IMarkdownEmitterContext + ): void { + const options: ICustomMarkdownEmitterOptions = context.options; + + const result: IResolveDeclarationReferenceResult = this._apiModel.resolveDeclarationReference( + docLinkTag.codeDestination!, + options.contextApiItem + ); + + if (result.resolvedApiItem) { + const filename: string | undefined = options.onGetFilenameForApiItem(result.resolvedApiItem); + + if (filename) { + let linkText: string = docLinkTag.linkText || ''; + if (linkText.length === 0) { + // Generate a name such as Namespace1.Namespace2.MyClass.myMethod() + linkText = result.resolvedApiItem.getScopedNameWithinPackage(); + } + if (linkText.length > 0) { + const encodedLinkText: string = this.getEscapedText(linkText.replace(/\s+/g, ' ')); + + context.writer.write('['); + context.writer.write(encodedLinkText); + context.writer.write(`](${filename!})`); + } else { + console.log(colors.yellow('WARNING: Unable to determine link text')); + } + } + } else if (result.errorMessage) { + console.log( + colors.yellow(`WARNING: Unable to resolve reference "${docLinkTag.codeDestination!.emitAsTsdoc()}": ` + result.errorMessage) + ); + } + } +} diff --git a/packages/typescript-reference-doc-generator/src/markdown/MarkdownEmitter.ts b/packages/typescript-reference-doc-generator/src/markdown/MarkdownEmitter.ts new file mode 100644 index 0000000000..37ec9902b0 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/markdown/MarkdownEmitter.ts @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + DocNode, + DocNodeKind, + StringBuilder, + DocPlainText, + DocHtmlStartTag, + DocHtmlEndTag, + DocCodeSpan, + DocLinkTag, + DocParagraph, + DocFencedCode, + DocSection, + DocNodeTransforms, + DocEscapedText, + DocErrorText, + DocBlockTag, + DocSoftBreak, + DocExcerpt, +} from '@microsoft/tsdoc'; +import { InternalError } from '@rushstack/node-core-library'; + +import { IndentedWriter } from '../utils/IndentedWriter'; + +export interface IMarkdownEmitterOptions {} + +export interface IMarkdownEmitterContext { + writer: IndentedWriter; + insideTable: boolean; + inlineBlockNestingLevel: number; + + boldRequested: boolean; + italicRequested: boolean; + + writingBold: boolean; + writingItalic: boolean; + + options: TOptions; +} + +/** + * Renders MarkupElement content in the Markdown file format. + * For more info: https://en.wikipedia.org/wiki/Markdown + */ +export class MarkdownEmitter { + public emit( + stringBuilder: StringBuilder, + docNode: DocNode, + options: IMarkdownEmitterOptions + ): string { + const writer: IndentedWriter = new IndentedWriter(stringBuilder); + + const context: IMarkdownEmitterContext = { + writer, + insideTable: false, + inlineBlockNestingLevel: 0, + + boldRequested: false, + italicRequested: false, + + writingBold: false, + writingItalic: false, + + options, + }; + + this.writeNode(docNode, context, false); + + writer.ensureNewLine(); // finish the last line + + return writer.toString().replace(/(\n\s*){3,}/gm, '\n\n'); // remove excessive blank lines + } + + /** + * Escapes the HTML characters in plain text. + * Does not escape the markdown characters as plain text is + * allowed to use markdown. + * + * @param text - The text to be encoded + * @returns The encoded text + */ + protected getEscapedText(text: string): string { + const textWithBackslashes: string = text + .replace(/&/g, '&') + .replace(//g, '>') + // Preserve HTML comments + .replace(/<!--((.*\n?)*)-->/gm, ''); + return textWithBackslashes; + } + + protected getTableEscapedText(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\|/g, '|'); + } + + /** + * @param docNode + * @param context + * @param docNodeSiblings + * @abstract + */ + protected writeNode( + docNode: DocNode, + context: IMarkdownEmitterContext, + docNodeSiblings: boolean + ): void { + const writer: IndentedWriter = context.writer; + + switch (docNode.kind) { + case DocNodeKind.PlainText: { + const docPlainText: DocPlainText = docNode as DocPlainText; + this.writePlainText(docPlainText.text, context); + break; + } + case DocNodeKind.HtmlStartTag: + case DocNodeKind.HtmlEndTag: { + const docHtmlTag: DocHtmlStartTag | DocHtmlEndTag = docNode as + | DocHtmlStartTag + | DocHtmlEndTag; + // write the HTML element verbatim into the output + writer.write(docHtmlTag.emitAsHtml()); + break; + } + case DocNodeKind.CodeSpan: { + const docCodeSpan: DocCodeSpan = docNode as DocCodeSpan; + if (context.insideTable) { + writer.write(''); + } else { + writer.write('`'); + } + if (context.insideTable) { + const code: string = this.getTableEscapedText( + docCodeSpan.code + ); + const parts: string[] = code.split(/\r?\n/g); + writer.write(parts.join('
')); + } else { + writer.write(docCodeSpan.code); + } + if (context.insideTable) { + writer.write(''); + } else { + writer.write('`'); + } + break; + } + case DocNodeKind.LinkTag: { + const docLinkTag: DocLinkTag = docNode as DocLinkTag; + if (docLinkTag.codeDestination) { + this.writeLinkTagWithCodeDestination(docLinkTag, context); + } else if (docLinkTag.urlDestination) { + this.writeLinkTagWithUrlDestination(docLinkTag, context); + } else if (docLinkTag.linkText) { + this.writePlainText(docLinkTag.linkText, context); + } + break; + } + case DocNodeKind.Paragraph: { + const docParagraph: DocParagraph = docNode as DocParagraph; + const trimmedParagraph: DocParagraph = + DocNodeTransforms.trimSpacesInParagraph(docParagraph); + if (context.insideTable) { + if (docNodeSiblings) { + // This tentative write is necessary to avoid writing empty paragraph tags (i.e. `

`). At the + // time this code runs, we do not know whether the `writeNodes` call below will actually write + // anything. Thus, we want to only write a `

` tag (as well as eventually a corresponding + // `

` tag) if something ends up being written within the tags. + writer.writeTentative('

', '

', () => { + this.writeNodes(trimmedParagraph.nodes, context); + }); + } else { + // Special case: If we are the only element inside this table cell, then we can omit the

container. + this.writeNodes(trimmedParagraph.nodes, context); + } + } else if (context.inlineBlockNestingLevel > 0) { + this.writeNodes(trimmedParagraph.nodes, context); + } else { + this.writeNodes(trimmedParagraph.nodes, context); + writer.ensureNewLine(); + writer.writeLine(); + } + break; + } + case DocNodeKind.FencedCode: { + const docFencedCode: DocFencedCode = docNode as DocFencedCode; + writer.ensureNewLine(); + writer.write('```'); + writer.write(docFencedCode.language); + writer.writeLine(); + writer.write(docFencedCode.code); + writer.ensureNewLine(); + writer.writeLine('```'); + break; + } + case DocNodeKind.Section: { + const docSection: DocSection = docNode as DocSection; + this.writeNodes(docSection.nodes, context); + break; + } + case DocNodeKind.SoftBreak: { + if (!/^\s?$/.test(writer.peekLastCharacter())) { + // Output the same soft break character as consumed + // from the original input. + const softBreakNode = docNode as DocSoftBreak; + const child = softBreakNode.getChildNodes()[0]; + let char; + if (child instanceof DocExcerpt) { + char = child.content.toString().substring(0, 1); + } + writer.write(char || ' '); + } + break; + } + case DocNodeKind.EscapedText: { + const docEscapedText: DocEscapedText = + docNode as DocEscapedText; + this.writePlainText(docEscapedText.decodedText, context); + break; + } + case DocNodeKind.ErrorText: { + const docErrorText: DocErrorText = docNode as DocErrorText; + this.writePlainText(docErrorText.text, context); + break; + } + case DocNodeKind.InlineTag: { + break; + } + case DocNodeKind.BlockTag: { + const tagNode: DocBlockTag = docNode as DocBlockTag; + console.warn('Unsupported block tag: ' + tagNode.tagName); + break; + } + default: + throw new InternalError( + 'Unsupported DocNodeKind kind: ' + docNode.kind + ); + } + } + + /** + * @param docLinkTag + * @param context + * @abstract + */ + protected writeLinkTagWithCodeDestination( + docLinkTag: DocLinkTag, + context: IMarkdownEmitterContext + ): void { + // The subclass needs to implement this to support code destinations + throw new InternalError('writeLinkTagWithCodeDestination()'); + } + + /** + * @param docLinkTag + * @param context + * @abstract + */ + protected writeLinkTagWithUrlDestination( + docLinkTag: DocLinkTag, + context: IMarkdownEmitterContext + ): void { + const linkText: string = + docLinkTag.linkText !== undefined + ? docLinkTag.linkText + : docLinkTag.urlDestination!; + + const encodedLinkText: string = this.getEscapedText( + linkText.replace(/\s+/g, ' ') + ); + + context.writer.write('['); + context.writer.write(encodedLinkText); + context.writer.write(`](${docLinkTag.urlDestination!})`); + } + + protected writePlainText( + text: string, + context: IMarkdownEmitterContext + ): void { + const writer: IndentedWriter = context.writer; + + // split out the [ leading whitespace, content, trailing whitespace ] + const parts: string[] = text.match(/^(\s*)(.*?)(\s*)$/) || []; + + writer.write(parts[1]); // write leading whitespace + + const middle: string = parts[2]; + + if (middle !== '') { + switch (writer.peekLastCharacter()) { + case '': + case '\n': + case ' ': + case '[': + case '>': + // okay to put a symbol + break; + default: + // This is no problem: "**one** *two* **three**" + // But this is trouble: "**one***two***three**" + // The most general solution: "**one***two***three**" + writer.write(''); + break; + } + + let processedMiddle: string = middle; + + // This could be a markdown quote, we don't want to escape that + const [quoteMaybe] = processedMiddle.match(/^((\s*)>)+/) || ['']; + if (quoteMaybe) { + // It is a quote, don't escape it + if ( + writer.peekLastCharacter() === '\n' || + writer.peekLastCharacter() === ' ' || + writer.peekSecondLastCharacter() === '\n' + ) { + writer.write(quoteMaybe); + processedMiddle = processedMiddle.substring( + quoteMaybe.length + ); + } + } + + if (context.boldRequested) { + writer.write(''); + } + if (context.italicRequested) { + writer.write(''); + } + + writer.write(this.getEscapedText(processedMiddle)); + + if (context.italicRequested) { + writer.write(''); + } + if (context.boldRequested) { + writer.write(''); + } + } + + writer.write(parts[3]); // write trailing whitespace + } + + protected writeNodes( + docNodes: ReadonlyArray, + context: IMarkdownEmitterContext + ): void { + for (const docNode of docNodes) { + this.writeNode(docNode, context, docNodes.length > 1); + } + } +} diff --git a/packages/typescript-reference-doc-generator/src/markdown/test/CustomMarkdownEmitter.test.ts b/packages/typescript-reference-doc-generator/src/markdown/test/CustomMarkdownEmitter.test.ts new file mode 100644 index 0000000000..7de497a79c --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/markdown/test/CustomMarkdownEmitter.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + DocSection, + TSDocConfiguration, + DocPlainText, + StringBuilder, + DocParagraph, + DocSoftBreak, + DocLinkTag, + DocHtmlStartTag, + DocHtmlEndTag, + DocBlockTag +} from '@microsoft/tsdoc'; + +import { CustomDocNodes } from '../../nodes/CustomDocNodeKind'; +import { DocHeading } from '../../nodes/DocHeading'; +import { DocEmphasisSpan } from '../../nodes/DocEmphasisSpan'; +import { DocTable } from '../../nodes/DocTable'; +import { DocTableRow } from '../../nodes/DocTableRow'; +import { DocTableCell } from '../../nodes/DocTableCell'; +import { CustomMarkdownEmitter } from '../CustomMarkdownEmitter'; +import { ApiModel, ApiItem } from '@microsoft/api-extractor-model'; + +test('render Markdown from TSDoc', () => { + const configuration: TSDocConfiguration = CustomDocNodes.configuration; + + const output: DocSection = new DocSection({ configuration }); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Simple bold test' }), + new DocParagraph({ configuration }, [ + new DocPlainText({ configuration, text: 'This is a ' }), + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'bold' })]), + new DocPlainText({ configuration, text: ' word.' }) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'All whitespace bold' }), + new DocParagraph({ configuration }, [ + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: ' ' })]) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Newline bold' }), + new DocParagraph({ configuration }, [ + new DocEmphasisSpan({ configuration, bold: true }, [ + new DocPlainText({ configuration, text: 'line 1' }), + new DocSoftBreak({ configuration }), + new DocPlainText({ configuration, text: 'line 2' }) + ]) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Newline bold with spaces' }), + new DocParagraph({ configuration }, [ + new DocEmphasisSpan({ configuration, bold: true }, [ + new DocPlainText({ configuration, text: ' line 1 ' }), + new DocSoftBreak({ configuration }), + new DocPlainText({ configuration, text: ' line 2 ' }), + new DocSoftBreak({ configuration }), + new DocPlainText({ configuration, text: ' line 3 ' }) + ]) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Adjacent bold regions' }), + new DocParagraph({ configuration }, [ + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'one' })]), + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'two' })]), + new DocEmphasisSpan({ configuration, bold: true }, [ + new DocPlainText({ configuration, text: ' three ' }) + ]), + new DocPlainText({ configuration, text: '' }), + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'four' })]), + new DocPlainText({ configuration, text: 'non-bold' }), + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'five' })]) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Adjacent to other characters' }), + new DocParagraph({ configuration }, [ + new DocLinkTag({ + configuration, + tagName: '@link', + linkText: 'a link', + urlDestination: './index.md' + }), + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'bold' })]), + new DocPlainText({ configuration, text: 'non-bold' }), + new DocPlainText({ configuration, text: 'more-non-bold' }) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Unknown block tag' }), + new DocParagraph({ configuration }, [ + new DocBlockTag({ + configuration, + tagName: '@unknown' + }), + new DocEmphasisSpan({ configuration, bold: true }, [new DocPlainText({ configuration, text: 'bold' })]), + new DocPlainText({ configuration, text: 'non-bold' }), + new DocPlainText({ configuration, text: 'more-non-bold' }) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Bad characters' }), + new DocParagraph({ configuration }, [ + new DocEmphasisSpan({ configuration, bold: true }, [ + new DocPlainText({ configuration, text: '*one*two*' }) + ]), + new DocEmphasisSpan({ configuration, bold: true }, [ + new DocPlainText({ configuration, text: 'three*four' }) + ]) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Characters that should be escaped' }), + new DocParagraph({ configuration }, [ + new DocPlainText({ configuration, text: 'Double-encoded JSON: "{ \\"A\\": 123}"' }) + ]), + new DocParagraph({ configuration }, [ + new DocPlainText({ configuration, text: 'HTML chars: ' }) + ]), + new DocParagraph({ configuration }, [new DocPlainText({ configuration, text: 'HTML escape: "' })]), + new DocParagraph({ configuration }, [ + new DocPlainText({ configuration, text: '3 or more hyphens: - -- --- ---- ----- ------' }) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'HTML tag' }), + new DocParagraph({ configuration }, [ + new DocHtmlStartTag({ configuration, name: 'b' }), + new DocPlainText({ configuration, text: 'bold' }), + new DocHtmlEndTag({ configuration, name: 'b' }) + ]) + ]); + + output.appendNodes([ + new DocHeading({ configuration, title: 'Table' }), + new DocTable( + { + configuration, + headerTitles: ['Header 1', 'Header 2'] + }, + [ + new DocTableRow({ configuration }, [ + new DocTableCell({ configuration }, [ + new DocParagraph({ configuration }, [new DocPlainText({ configuration, text: 'Cell 1' })]) + ]), + new DocTableCell({ configuration }, [ + new DocParagraph({ configuration }, [new DocPlainText({ configuration, text: 'Cell 2' })]) + ]) + ]) + ] + ) + ]); + + const stringBuilder: StringBuilder = new StringBuilder(); + const apiModel: ApiModel = new ApiModel(); + const markdownEmitter: CustomMarkdownEmitter = new CustomMarkdownEmitter(apiModel); + markdownEmitter.emit(stringBuilder, output, { + contextApiItem: undefined, + onGetFilenameForApiItem: (apiItem: ApiItem) => { + return '#'; + } + }); + + expect(stringBuilder).toMatchSnapshot(); +}); diff --git a/packages/typescript-reference-doc-generator/src/markdown/test/__snapshots__/CustomMarkdownEmitter.test.ts.snap b/packages/typescript-reference-doc-generator/src/markdown/test/__snapshots__/CustomMarkdownEmitter.test.ts.snap new file mode 100644 index 0000000000..ae459a5a8d --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/markdown/test/__snapshots__/CustomMarkdownEmitter.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render Markdown from TSDoc 1`] = ` +StringBuilder { + "_chunks": Array [ + " +## Simple bold test + +This is a bold word. + +## All whitespace bold + + + +## Newline bold + +line 1 line 2 + +## Newline bold with spaces + + line 1 line 2 line 3 + +## Adjacent bold regions + +onetwo three fournon-boldfive + +## Adjacent to other characters + +[a link](./index.md)boldnon-boldmore-non-bold + +## Unknown block tag + +boldnon-boldmore-non-bold + +## Bad characters + +\\\\*one\\\\*two\\\\*three\\\\*four + +## Characters that should be escaped + +Double-encoded JSON: \\"{ \\\\\\\\\\"A\\\\\\\\\\": 123}\\" + +HTML chars: <script>alert(\\"\\\\[You\\\\] are \\\\#1!\\");</script> + +HTML escape: &quot; + +3 or more hyphens: - -- \\\\-\\\\-\\\\- \\\\-\\\\-\\\\-- \\\\-\\\\-\\\\--- \\\\-\\\\-\\\\-\\\\-\\\\-\\\\- + +## HTML tag + +bold + +## Table + +| Header 1 | Header 2 | +| --- | --- | +| Cell 1 | Cell 2 | + +", + ], +} +`; diff --git a/packages/typescript-reference-doc-generator/src/nodes/CustomDocNodeKind.ts b/packages/typescript-reference-doc-generator/src/nodes/CustomDocNodeKind.ts new file mode 100644 index 0000000000..2e458d93ef --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/CustomDocNodeKind.ts @@ -0,0 +1,105 @@ +import { TSDocConfiguration, DocNodeKind } from '@microsoft/tsdoc'; +import { DocEmphasisSpan } from './DocEmphasisSpan'; +import { DocHeading } from './DocHeading'; +import { DocNoteBox } from './DocNoteBox'; +import { DocTable } from './DocTable'; +import { DocTableCell } from './DocTableCell'; +import { DocTableRow } from './DocTableRow'; +import { DocContentBlock } from './DocContentBlock'; +import { DocForceSoftBreak } from './DocForceSoftBreak'; +import { DocMaybe } from './DocMaybe'; + +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Identifies custom subclasses of {@link DocNode}. + */ +export const enum CustomDocNodeKind { + EmphasisSpan = 'EmphasisSpan', + Heading = 'Heading', + NoteBox = 'NoteBox', + Table = 'Table', + TableCell = 'TableCell', + TableRow = 'TableRow', + ContentBlock = 'ContentBlock', + Maybe = 'Maybe', + ForceSoftBreak = 'ForceSoftBreak' +} + +export class CustomDocNodes { + private static _configuration: TSDocConfiguration | undefined; + + public static get configuration(): TSDocConfiguration { + if (CustomDocNodes._configuration === undefined) { + const configuration: TSDocConfiguration = new TSDocConfiguration(); + + configuration.docNodeManager.registerDocNodes('@micrososft/api-documenter', [ + { + docNodeKind: CustomDocNodeKind.EmphasisSpan, + constructor: DocEmphasisSpan + }, + { + docNodeKind: CustomDocNodeKind.Heading, + constructor: DocHeading + }, + { + docNodeKind: CustomDocNodeKind.NoteBox, + constructor: DocNoteBox + }, + { + docNodeKind: CustomDocNodeKind.Table, + constructor: DocTable + }, + { + docNodeKind: CustomDocNodeKind.TableCell, + constructor: DocTableCell + }, + { + docNodeKind: CustomDocNodeKind.TableRow, + constructor: DocTableRow + }, + { + docNodeKind: CustomDocNodeKind.ContentBlock, + constructor: DocContentBlock + }, + { + docNodeKind: CustomDocNodeKind.Maybe, + constructor: DocMaybe + }, + { + docNodeKind: CustomDocNodeKind.ForceSoftBreak, + constructor: DocForceSoftBreak + } + ]); + + configuration.docNodeManager.registerAllowableChildren(CustomDocNodeKind.EmphasisSpan, [ + DocNodeKind.PlainText, + DocNodeKind.SoftBreak, + CustomDocNodeKind.ForceSoftBreak + ]); + + configuration.docNodeManager.registerAllowableChildren(DocNodeKind.Section, [ + CustomDocNodeKind.Heading, + CustomDocNodeKind.NoteBox, + CustomDocNodeKind.Table, + CustomDocNodeKind.ContentBlock, + CustomDocNodeKind.Maybe, + DocNodeKind.Section, + DocNodeKind.SoftBreak, + CustomDocNodeKind.ForceSoftBreak + ]); + + configuration.docNodeManager.registerAllowableChildren(DocNodeKind.Paragraph, [ + CustomDocNodeKind.EmphasisSpan, + DocNodeKind.SoftBreak, + CustomDocNodeKind.ForceSoftBreak, + CustomDocNodeKind.ContentBlock, + CustomDocNodeKind.Maybe + ]); + + CustomDocNodes._configuration = configuration; + } + return CustomDocNodes._configuration; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocContentBlock.ts b/packages/typescript-reference-doc-generator/src/nodes/DocContentBlock.ts new file mode 100644 index 0000000000..24a0b8e0dc --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocContentBlock.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; + +export type ContentBlockType = 'block' | 'inline' | 'ul' | 'ol' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'whitespace' | 'literal'; + +/** + * Constructor parameters for {@link DocContentBlock}. + */ +export interface IContentBlockParameters extends IDocNodeParameters { + type?: ContentBlockType; + character?: string; + text?: string; +} + +/** + * Represents List, similar to an HTML `` element. + */ +export class DocContentBlock extends DocNode { + public readonly type: ContentBlockType; + public readonly whitespaceCharacter?: string; + public readonly text: string = ''; + + private _items: DocNode[]; + + public constructor(parameters: IContentBlockParameters, items?: ReadonlyArray) { + super(parameters); + + this.type = parameters.type || 'block'; + if (this.type === 'whitespace') { + this.whitespaceCharacter = parameters.character?.slice(0, 1) || ' '; + } else if (this.type === 'literal') { + this.text = parameters.text || ''; + } + this._items = []; + + if (items) { + for (const item of items) { + this.addItem(item); + } + } + } + + public get hasItems(): boolean { + return this._items.length > 0; + } + + public get isList(): boolean { + return ['ul', 'ol'].includes(this.type); + } + + public get isHeading(): boolean { + return this.type.startsWith('h'); + } + + public get isWhitespace(): boolean { + return this.type === 'whitespace'; + } + + public get headingLevel(): number { + if (this.isHeading) { + return parseInt(this.type[1], 10); + } + return 0; + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.ContentBlock; + } + + public get items(): ReadonlyArray { + return this._items; + } + + public addItems(items: readonly DocNode[]): void { + this._items.push(...items); + } + + public prependItem(item: DocNode): void { + this._items.unshift(item); + } + + public addItem(item: DocNode): void { + this._items.push(item); + } + + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return this._items; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocEmphasisSpan.ts b/packages/typescript-reference-doc-generator/src/nodes/DocEmphasisSpan.ts new file mode 100644 index 0000000000..0f1f543b87 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocEmphasisSpan.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DocNode, DocNodeContainer, IDocNodeContainerParameters } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; + +/** + * Constructor parameters for {@link DocEmphasisSpan}. + */ +export interface IDocEmphasisSpanParameters extends IDocNodeContainerParameters { + bold?: boolean; + italic?: boolean; +} + +/** + * Represents a span of text that is styled with CommonMark emphasis (italics), strong emphasis (boldface), + * or both. + */ +export class DocEmphasisSpan extends DocNodeContainer { + public readonly bold: boolean; + public readonly italic: boolean; + + public constructor(parameters: IDocEmphasisSpanParameters, children?: DocNode[]) { + super(parameters, children); + this.bold = !!parameters.bold; + this.italic = !!parameters.italic; + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.EmphasisSpan; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocForceSoftBreak.ts b/packages/typescript-reference-doc-generator/src/nodes/DocForceSoftBreak.ts new file mode 100644 index 0000000000..e0287b3556 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocForceSoftBreak.ts @@ -0,0 +1,45 @@ +import { DocNodeKind, IDocNodeParameters, DocNode, IDocNodeParsedParameters, TokenSequence } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; +/** + * Constructor parameters for {@link DocSoftBreak}. + */ +export interface IDocSoftBreakParameters extends IDocNodeParameters { +} +/** + * Constructor parameters for {@link DocSoftBreak}. + */ +export interface IDocSoftBreakParsedParameters extends IDocNodeParsedParameters { + softBreakExcerpt: TokenSequence; +} +/** + * Instructs a renderer to insert an explicit newline in the output. + * (Normally the renderer uses a formatting rule to determine where + * lines should wrap.) + * + * @remarks + * In HTML, a soft break is represented as an ASCII newline character (which does not + * affect the web browser's view), whereas the hard break is the `
` element + * (which starts a new line in the web browser's view). + * + * TSDoc follows the same conventions, except the renderer avoids emitting + * two empty lines (because that could start a new CommonMark paragraph). + */ +export class DocForceSoftBreak extends DocNode { + /** + * Don't call this directly. Instead use {@link TSDocParser} + * @internal + */ + constructor(parameters: IDocSoftBreakParameters | IDocSoftBreakParsedParameters) { + super(parameters); + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.ForceSoftBreak; + } + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return []; + } +} +//# sourceMappingURL=DocSoftBreak.d.ts.map \ No newline at end of file diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocHeading.ts b/packages/typescript-reference-doc-generator/src/nodes/DocHeading.ts new file mode 100644 index 0000000000..d5d4822766 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocHeading.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; + +/** + * Constructor parameters for {@link DocHeading}. + */ +export interface IDocHeadingParameters extends IDocNodeParameters { + title: string; + level?: number; +} + +/** + * Represents a section header similar to an HTML `

` or `

` element. + */ +export class DocHeading extends DocNode { + public readonly title: string; + public readonly level: number; + + /** + * Don't call this directly. Instead use {@link TSDocParser} + * @internal + */ + public constructor(parameters: IDocHeadingParameters) { + super(parameters); + this.title = parameters.title; + this.level = parameters.level !== undefined ? parameters.level : 1; + + if (this.level < 1 || this.level > 5) { + throw new Error('IDocHeadingParameters.level must be a number between 1 and 5'); + } + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.Heading; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocMaybe.ts b/packages/typescript-reference-doc-generator/src/nodes/DocMaybe.ts new file mode 100644 index 0000000000..19a23174a2 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocMaybe.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; + +/** + * Constructor parameters for {@link DocContentBlock}. + */ +export interface IDocMaybeParameters extends IDocNodeParameters { + predicate: () => boolean; +} + +export class DocMaybe extends DocNode { + private readonly _predicate: () => boolean; + + private _items: DocNode[]; + + public constructor(parameters: IDocMaybeParameters, items: ReadonlyArray) { + super(parameters); + + this._predicate = parameters.predicate; + this._items = [...items]; + } + + public get isActive(): boolean { + return this._predicate(); + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.Maybe; + } + + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return this._items; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocNoteBox.ts b/packages/typescript-reference-doc-generator/src/nodes/DocNoteBox.ts new file mode 100644 index 0000000000..85372fb5a2 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocNoteBox.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode, DocSection } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; + +/** + * Constructor parameters for {@link DocNoteBox}. + */ +export interface IDocNoteBoxParameters extends IDocNodeParameters {} + +/** + * Represents a note box, which is typically displayed as a bordered box containing informational text. + */ +export class DocNoteBox extends DocNode { + public readonly content: DocSection; + + public constructor(parameters: IDocNoteBoxParameters, sectionChildNodes?: ReadonlyArray) { + super(parameters); + this.content = new DocSection({ configuration: this.configuration }, sectionChildNodes); + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.NoteBox; + } + + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return [this.content]; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocTable.ts b/packages/typescript-reference-doc-generator/src/nodes/DocTable.ts new file mode 100644 index 0000000000..095e39e3b1 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocTable.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; +import { DocTableRow } from './DocTableRow'; +import { DocTableCell } from './DocTableCell'; + +/** + * Constructor parameters for {@link DocTable}. + */ +export interface IDocTableParameters extends IDocNodeParameters { + headerCells?: ReadonlyArray; + headerTitles?: string[]; +} + +/** + * Represents table, similar to an HTML `` element. + */ +export class DocTable extends DocNode { + public readonly header: DocTableRow; + + private _rows: DocTableRow[]; + + public constructor(parameters: IDocTableParameters, rows?: ReadonlyArray) { + super(parameters); + + this.header = new DocTableRow({ configuration: this.configuration }); + this._rows = []; + + if (parameters) { + if (parameters.headerTitles) { + if (parameters.headerCells) { + throw new Error( + 'IDocTableParameters.headerCells and IDocTableParameters.headerTitles' + + ' cannot both be specified' + ); + } + for (const cellText of parameters.headerTitles) { + this.header.addPlainTextCell(cellText); + } + } else if (parameters.headerCells) { + for (const cell of parameters.headerCells) { + this.header.addCell(cell); + } + } + } + + if (rows) { + for (const row of rows) { + this.addRow(row); + } + } + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.Table; + } + + public get rows(): ReadonlyArray { + return this._rows; + } + + public addRow(row: DocTableRow): void { + this._rows.push(row); + } + + public createAndAddRow(): DocTableRow { + const row: DocTableRow = new DocTableRow({ configuration: this.configuration }); + this.addRow(row); + return row; + } + + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return [this.header, ...this._rows]; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocTableCell.ts b/packages/typescript-reference-doc-generator/src/nodes/DocTableCell.ts new file mode 100644 index 0000000000..8a56c13a83 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocTableCell.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode, DocSection } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; + +/** + * Constructor parameters for {@link DocTableCell}. + */ +export interface IDocTableCellParameters extends IDocNodeParameters {} + +/** + * Represents table cell, similar to an HTML `` element. + */ +export class DocTableRow extends DocNode { + private readonly _cells: DocTableCell[]; + + public constructor(parameters: IDocTableRowParameters, cells?: ReadonlyArray) { + super(parameters); + + this._cells = []; + if (cells) { + for (const cell of cells) { + this.addCell(cell); + } + } + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.TableRow; + } + + public get cells(): ReadonlyArray { + return this._cells; + } + + public addCell(cell: DocTableCell): void { + this._cells.push(cell); + } + + public createAndAddCell(): DocTableCell { + const newCell: DocTableCell = new DocTableCell({ configuration: this.configuration }); + this.addCell(newCell); + return newCell; + } + + public addPlainTextCell(cellContent: string): DocTableCell { + const cell: DocTableCell = this.createAndAddCell(); + cell.content.appendNodeInParagraph( + new DocPlainText({ + configuration: this.configuration, + text: cellContent + }) + ); + return cell; + } + + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return this._cells; + } +} diff --git a/packages/typescript-reference-doc-generator/src/plugin/IApiDocumenterPluginManifest.ts b/packages/typescript-reference-doc-generator/src/plugin/IApiDocumenterPluginManifest.ts new file mode 100644 index 0000000000..6c4881eb4d --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/plugin/IApiDocumenterPluginManifest.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { MarkdownDocumenterFeature } from './MarkdownDocumenterFeature'; +import { PluginFeatureInitialization } from './PluginFeature'; + +/** + * Defines a "feature" that is provided by an API Documenter plugin. A feature is a user-defined module + * that customizes the behavior of API Documenter. + * + * @public + */ +export interface IFeatureDefinition { + /** + * The name of this feature, as it will appear in the config file. + * + * The name should consist of one or more words separated by hyphens. Each word should consist of lower case + * letters and numbers. Example: `my-feature` + */ + featureName: string; + + /** + * Determines the kind of feature. The specified value is the name of the base class that `subclass` inherits from. + * + * @remarks + * For now, `MarkdownDocumenterFeature` is the only supported value. + */ + kind: 'MarkdownDocumenterFeature'; + + /** + * Your subclass that extends from the base class. + */ + subclass: { + new ( + initialization: PluginFeatureInitialization + ): MarkdownDocumenterFeature; + }; +} + +/** + * The manifest for an API Documenter plugin. + * + * @remarks + * An API documenter plugin is an NPM package. By convention, the NPM package name should have the prefix + * `doc-plugin-`. Its main entry point should export an object named `apiDocumenterPluginManifest` which implements + * the `IApiDocumenterPluginManifest` interface. + * + * For example: + * ```ts + * class MyMarkdownDocumenter extends MarkdownDocumenterFeature { + * public onInitialized(): void { + * console.log('MyMarkdownDocumenter: onInitialized()'); + * } + * } + * + * export const apiDocumenterPluginManifest: IApiDocumenterPluginManifest = { + * manifestVersion: 1000, + * features: [ + * { + * featureName: 'my-markdown-documenter', + * kind: 'MarkdownDocumenterFeature', + * subclass: MyMarkdownDocumenter + * } + * ] + * }; + * ``` + * @public + */ +export interface IApiDocumenterPluginManifest { + /** + * The manifest version number. For now, this must always be `1000`. + */ + manifestVersion: 1000; + + /** + * The list of features provided by this plugin. + */ + features: IFeatureDefinition[]; +} diff --git a/packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterAccessor.ts b/packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterAccessor.ts new file mode 100644 index 0000000000..97abdbe1ca --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterAccessor.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ApiItem } from '@microsoft/api-extractor-model'; + +/** @internal */ +export interface IMarkdownDocumenterAccessorImplementation { + getLinkForApiItem(apiItem: ApiItem): string | undefined; +} + +/** + * Provides access to the documenter that is generating the output. + * + * @privateRemarks + * This class is wrapper that provides access to the underlying MarkdownDocumenter, while hiding the implementation + * details to ensure that the plugin API contract is stable. + * + * @public + */ +export class MarkdownDocumenterAccessor { + private _implementation: IMarkdownDocumenterAccessorImplementation; + + /** + * @param implementation + * @internal + */ + public constructor( + implementation: IMarkdownDocumenterAccessorImplementation + ) { + this._implementation = implementation; + } + + /** + * For a given `ApiItem`, return its markdown hyperlink. + * + * @param apiItem + * @returns The hyperlink, or `undefined` if the `ApiItem` object does not have a hyperlink. + */ + public getLinkForApiItem(apiItem: ApiItem): string | undefined { + return this._implementation.getLinkForApiItem(apiItem); + } +} diff --git a/packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterFeature.ts b/packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterFeature.ts new file mode 100644 index 0000000000..5b88e91ed5 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/plugin/MarkdownDocumenterFeature.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ApiItem, ApiModel } from '@microsoft/api-extractor-model'; +import { TypeUuid } from '@rushstack/node-core-library'; +import { PluginFeature } from './PluginFeature'; +import { MarkdownDocumenterAccessor } from './MarkdownDocumenterAccessor'; + +/** + * Context object for {@link MarkdownDocumenterFeature}. + * Exposes various services that can be used by a plugin. + * + * @public + */ +export class MarkdownDocumenterFeatureContext { + /** + * Provides access to the `ApiModel` for the documentation being generated. + */ + public readonly apiModel: ApiModel; + + /** + * The full path to the output folder. + */ + public readonly outputFolder: string; + + /** + * Exposes functionality of the documenter. + */ + public readonly documenter: MarkdownDocumenterAccessor; + + /** + * @param options + * @internal + */ + public constructor(options: MarkdownDocumenterFeatureContext) { + this.apiModel = options.apiModel; + this.outputFolder = options.outputFolder; + this.documenter = options.documenter; + } +} + +/** + * Event arguments for MarkdownDocumenterFeature.onBeforeWritePage() + * + * @public + */ +export interface IMarkdownDocumenterFeatureOnBeforeWritePageArgs { + /** + * The API item corresponding to this page. + */ + readonly apiItem: ApiItem; + + /** + * The page content. The {@link MarkdownDocumenterFeature.onBeforeWritePage} handler can reassign this + * string to customize the page appearance. + */ + pageContent: string; + + /** + * The filename where the output will be written. + */ + readonly outputFilename: string; +} + +/** + * Event arguments for MarkdownDocumenterFeature.onFinished() + * + * @public + */ +export interface IMarkdownDocumenterFeatureOnFinishedArgs {} + +const uuidMarkdownDocumenterFeature: string = + '34196154-9eb3-4de0-a8c8-7e9539dfe216'; + +/** + * Inherit from this base class to implement an API Documenter plugin feature that customizes + * the generation of markdown output. + * + * @public + */ +export class MarkdownDocumenterFeature extends PluginFeature { + /** {@inheritdoc PluginFeature.context} */ + public context!: MarkdownDocumenterFeatureContext; + + /** + * This event occurs before each markdown file is written. It provides an opportunity to customize the + * content of the file. + * + * @param eventArgs + * @abstract + */ + public onBeforeWritePage( + eventArgs: IMarkdownDocumenterFeatureOnBeforeWritePageArgs + ): void { + // (implemented by child class) + } + + /** + * This event occurs after all output files have been written. + * + * @param eventArgs + * @abstract + */ + public onFinished( + eventArgs: IMarkdownDocumenterFeatureOnFinishedArgs + ): void { + // (implemented by child class) + } + + public static [Symbol.hasInstance](instance: object): boolean { + return TypeUuid.isInstanceOf(instance, uuidMarkdownDocumenterFeature); + } +} + +TypeUuid.registerClass( + MarkdownDocumenterFeature, + uuidMarkdownDocumenterFeature +); diff --git a/packages/typescript-reference-doc-generator/src/plugin/PluginFeature.ts b/packages/typescript-reference-doc-generator/src/plugin/PluginFeature.ts new file mode 100644 index 0000000000..b4e8afb68c --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/plugin/PluginFeature.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TypeUuid } from '@rushstack/node-core-library'; + +/** + * This is an internal part of the plugin infrastructure. + * + * @remarks + * This object is the constructor parameter for API Documenter plugin features. + * + * @public + */ +export class PluginFeatureInitialization { + /** @internal */ + public _context!: PluginFeatureContext; + + /** @internal */ + public constructor() { + // reserved for future use + } +} + +/** + * Context object for {@link PluginFeature}. + * Exposes various services that can be used by a plugin. + * + * @public + */ +export class PluginFeatureContext {} + +const uuidPluginFeature: string = '56876472-7134-4812-819e-533de0ee10e6'; + +/** + * The abstract base class for all API Documenter plugin features. + * + * @public + */ +export abstract class PluginFeature { + /** + * Exposes various services that can be used by a plugin. + */ + public context: PluginFeatureContext; + + /** + * The subclass should pass the `initialization` through to the base class. + * Do not put custom initialization code in the constructor. Instead perform your initialization in the + * `onInitialized()` event function. + * + * @param initialization + * @internal + */ + public constructor(initialization: PluginFeatureInitialization) { + // reserved for future expansion + this.context = initialization._context; + } + + /** + * This event function is called after the feature is initialized, but before any processing occurs. + * + * @abstract + */ + public onInitialized(): void { + // (implemented by child class) + } + + public static [Symbol.hasInstance](instance: object): boolean { + return TypeUuid.isInstanceOf(instance, uuidPluginFeature); + } +} + +TypeUuid.registerClass(PluginFeature, uuidPluginFeature); diff --git a/packages/typescript-reference-doc-generator/src/plugin/PluginLoader.ts b/packages/typescript-reference-doc-generator/src/plugin/PluginLoader.ts new file mode 100644 index 0000000000..fe4cf24cb6 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/plugin/PluginLoader.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'path'; +import * as resolve from 'resolve'; + +import { + IApiDocumenterPluginManifest, + IFeatureDefinition, +} from './IApiDocumenterPluginManifest'; +import { + MarkdownDocumenterFeature, + MarkdownDocumenterFeatureContext, +} from './MarkdownDocumenterFeature'; +import { PluginFeatureInitialization } from './PluginFeature'; +import { DocumenterConfig } from '../documenters/DocumenterConfig'; + +interface ILoadedPlugin { + packageName: string; + manifest: IApiDocumenterPluginManifest; +} + +export class PluginLoader { + public markdownDocumenterFeature: MarkdownDocumenterFeature | undefined; + + public load( + documenterConfig: DocumenterConfig, + createContext: () => MarkdownDocumenterFeatureContext + ): void { + const configFileFolder: string = path.dirname( + documenterConfig.configFilePath + ); + for (const configPlugin of documenterConfig.configFile.plugins || []) { + try { + // Look for the package name in the same place as the config file + const resolvedEntryPointPath: string = resolve.sync( + configPlugin.packageName, + { + basedir: configFileFolder, + } + ); + + // Load the package + const entryPoint: + | { + apiDocumenterPluginManifest?: IApiDocumenterPluginManifest; + } + | undefined = require(resolvedEntryPointPath); + + if (!entryPoint) { + throw new Error('Invalid entry point'); + } + + if (!entryPoint.apiDocumenterPluginManifest) { + throw new Error( + `The package is not an API documenter plugin;` + + ` the "apiDocumenterPluginManifest" export was not found` + ); + } + + const manifest: IApiDocumenterPluginManifest = + entryPoint.apiDocumenterPluginManifest; + + if (manifest.manifestVersion !== 1000) { + throw new Error( + `The plugin is not compatible with this version of API Documenter;` + + ` unsupported manifestVersion` + ); + } + + const loadedPlugin: ILoadedPlugin = { + packageName: configPlugin.packageName, + manifest, + }; + + const featureDefinitionsByName: Map< + string, + IFeatureDefinition + > = new Map(); + for (const featureDefinition of manifest.features) { + featureDefinitionsByName.set( + featureDefinition.featureName, + featureDefinition + ); + } + + for (const featureName of configPlugin.enabledFeatureNames) { + const featureDefinition: IFeatureDefinition | undefined = + featureDefinitionsByName.get(featureName); + if (!featureDefinition) { + throw new Error( + `The plugin ${loadedPlugin.packageName} does not have a feature with name "${featureName}"` + ); + } + + if ( + featureDefinition.kind === 'MarkdownDocumenterFeature' + ) { + if (this.markdownDocumenterFeature) { + throw new Error( + 'A MarkdownDocumenterFeature is already loaded' + ); + } + + const initialization: PluginFeatureInitialization = + new PluginFeatureInitialization(); + initialization._context = createContext(); + + let markdownDocumenterFeature: + | MarkdownDocumenterFeature + | undefined; + try { + markdownDocumenterFeature = + new featureDefinition.subclass(initialization); + } catch (e) { + throw new Error( + `Failed to construct feature subclass:\n` + + (e as Error).toString() + ); + } + if ( + !( + markdownDocumenterFeature instanceof + MarkdownDocumenterFeature + ) + ) { + throw new Error( + 'The constructed subclass was not an instance of MarkdownDocumenterFeature' + ); + } + + try { + markdownDocumenterFeature.onInitialized(); + } catch (e) { + throw new Error( + 'Error occurred during the onInitialized() event: ' + + (e as Error).toString() + ); + } + + this.markdownDocumenterFeature = + markdownDocumenterFeature; + } else { + throw new Error( + `Unknown feature definition kind: "${featureDefinition.kind}"` + ); + } + } + } catch (e) { + throw new Error( + `Error loading plugin ${configPlugin.packageName}: ` + + (e as Error).message + ); + } + } + } +} diff --git a/packages/typescript-reference-doc-generator/src/schemas/api-documenter-template.json b/packages/typescript-reference-doc-generator/src/schemas/api-documenter-template.json new file mode 100644 index 0000000000..cab40e42c7 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/schemas/api-documenter-template.json @@ -0,0 +1,99 @@ +/** + * Config file for API Documenter. For more info, please visit: https://api-extractor.com + */ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-documenter.schema.json", + + /** + * Specifies the output target. + * Supported values are "docfx" or "markdown" + */ + // "outputTarget": "markdown", + + /** + * Specifies what type of newlines API Documenter should use when writing output files. By default, the output files + * will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead. + * To use the OS's default newline kind, specify "os". + * + * DEFAULT VALUE: "crlf" + */ + // "newlineKind": "crlf", + + /** + * This enables an experimental feature that will be officially released with the next major version + * of API Documenter. It requires DocFX 2.46 or newer. It enables documentation for namespaces and + * adds them to the table of contents. This will also affect file layout as namespaced items will be nested + * under a directory for the namespace instead of just within the package. + * + * This setting currently only affects the 'docfx' output target. It is equivalent to the `--new-docfx-namespaces` + * command-line parameter. + */ + // "newDocfxNamespaces": false, + + /** + * Describes plugin packages to be loaded, and which features to enable. + */ + "plugins": [ + // { + // "packageName": "doc-plugin-example", + // "enabledFeatureNames": [ "example-feature" ] + // } + ], + + /** + * Configures how the table of contents is generated. + */ + "tableOfContents": { + /** + * Allows hand-coded items to be injected into the table of contents. + * + * DEFAULT VALUE: (none) + */ + // "items": [ + // { "name": "Example Node", "href": "~/homepage/homepage.md" }, + // { + // "name": "API Reference", + // "items": [ + // { "name": "References" } + // ] + // } + // ], + /** + * Optional category name that is recommended to include in the `tocConfig`, + * along with one of the filters: `filterByApiItemName` or `filterByInlineTag`. + * Any items that are not matched to the mentioned filters will be placed under this + * catchAll category. If none provided the items will not be included in the final toc.yml file. + * + * DEFAULT VALUE: (none) + */ + // "catchAllCategory": "References", + /** + * When loading more than one api.json files that might include the same API items, + * toggle either to show duplicates or not. + * + * DEFAULT VALUE: false + */ + // "noDuplicateEntries": true, + /** + * Toggle either sorting of the API items should be made based on category name presence + * in the API item's name. + * + * DEFAULT VALUE: false + */ + // "filterByApiItemName": false, + /** + * Filter that can be used to sort the API items according to an inline custom tag + * that is present on them. + * + * DEFAULT VALUE: (none) + */ + // "filterByInlineTag": "@docCategory" + } + + /** + * Specifies whether inherited members should also be shown on an API item's page. + * + * DEFAULT VALUE: false + */ + // "showInheritedMembers": false +} diff --git a/packages/typescript-reference-doc-generator/src/schemas/api-documenter.schema.json b/packages/typescript-reference-doc-generator/src/schemas/api-documenter.schema.json new file mode 100644 index 0000000000..a61c9894a8 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/schemas/api-documenter.schema.json @@ -0,0 +1,47 @@ +{ + "title": "API Documenter Configuration", + "description": "Describes how the API Documenter tool will process a project.", + "type": "object", + "properties": { + "$schema": { + "description": "Part of the JSON Schema standard, this optional keyword declares the URL of the schema that the file conforms to. Editors may download the schema and use it to perform syntax highlighting.", + "type": "string" + }, + + "outputTarget": { + "description": "Specifies what type of documentation will be generated", + "type": "string", + "enum": ["docfx", "markdown"] + }, + + "newlineKind": { + "description": "Specifies what type of newlines API Documenter should use when writing output files. By default, the output files will be written with Windows-style newlines. To use POSIX-style newlines, specify \"lf\" instead. To use the OS's default newline kind, specify \"os\".", + "type": "string", + "enum": ["crlf", "lf", "os"], + "default": "crlf" + }, + + "newDocfxNamespaces": { + "description": "This enables an experimental feature that will be officially released with the next major version of API Documenter. It requires DocFX 2.46 or newer. It enables documentation for namespaces and adds them to the table of contents. This will also affect file layout as namespaced items will be nested under a directory for the namespace instead of just within the package.", + "type": "boolean" + }, + + "plugins": { + "description": "Specifies plugin packages to be loaded", + "type": "array" + }, + + "tableOfContents": { + "description": "Configures how the table of contents is generated.", + "type": "object", + "additionalProperties": true + }, + + "showInheritedMembers": { + "description": "Specifies whether inherited members should also be shown on an API item's page.", + "type": "boolean" + } + }, + + "additionalProperties": false +} diff --git a/packages/typescript-reference-doc-generator/src/start.ts b/packages/typescript-reference-doc-generator/src/start.ts new file mode 100644 index 0000000000..408030f9ae --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/start.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +import { ApiDocumenterCommandLine } from './cli/ApiDocumenterCommandLine'; + +const parser: ApiDocumenterCommandLine = new ApiDocumenterCommandLine(); + +parser.execute().catch(console.error); diff --git a/packages/typescript-reference-doc-generator/src/utils/DocContentBlockBuilder.ts b/packages/typescript-reference-doc-generator/src/utils/DocContentBlockBuilder.ts new file mode 100644 index 0000000000..362be53287 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/utils/DocContentBlockBuilder.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DocNode, TSDocConfiguration } from '@microsoft/tsdoc'; +import { ContentBlockType, DocContentBlock, IContentBlockParameters } from '../nodes/DocContentBlock'; +import { DocForceSoftBreak } from '../nodes/DocForceSoftBreak'; + +type BuilderArgItem = DocNode | unknown; +export type BuilderArg = readonly BuilderArgItem[] | BuilderArgItem[] | DocContentBlockBuilder | unknown; + +/** + * Represents List, similar to an HTML `` element. + */ +export class DocContentBlockBuilder { + private _nodes: DocNode[]; + private configuration: TSDocConfiguration; + private preferredBlockType: ContentBlockType = 'inline'; + + public get nodes(): readonly DocNode[] { + return this._nodes; + } + + public static create(configuration: TSDocConfiguration, nodes?: BuilderArg) { + return new DocContentBlockBuilder(configuration, nodes); + } + + public constructor(configuration: TSDocConfiguration, nodes?: BuilderArg) { + this.configuration = configuration; + this._nodes = DocContentBlockBuilder.toNodesList(nodes); + } + + public setPreferredBlockType(type: ContentBlockType) { + this.preferredBlockType = type; + return this; + } + + public clone() { + return new DocContentBlockBuilder(this.configuration, this.nodes); + } + + public fork(nodes?: BuilderArg) { + return new DocContentBlockBuilder(this.configuration, nodes); + } + + private static toNodesList(nodes: BuilderArg): DocNode[] { + let result: Array = []; + if (Array.isArray(nodes)) { + result.push(...nodes); + } else if (nodes instanceof DocNode) { + result.push(nodes); + } else if (nodes instanceof DocContentBlockBuilder) { + result.push(nodes.toContentBlockMaybe()); + } + return result.filter((node) => node) as DocNode[]; + } + + public prepend(nodes: BuilderArg) { + this._nodes.unshift(...DocContentBlockBuilder.toNodesList(nodes)); + return this; + } + + public concat(nodes: BuilderArg) { + this._nodes.push(...DocContentBlockBuilder.toNodesList(nodes)); + return this; + } + + public toContentBlockMaybe(type?: ContentBlockType): DocContentBlock | null { + if (this.nodes.length === 0) { + return null; + } + return this.toContentBlock(type); + } + + public get childNodes(): readonly DocNode[] { + return this.toContentBlock().getChildNodes(); + } + + public toContentBlock(type: ContentBlockType = this.preferredBlockType): DocContentBlock { + return new DocContentBlock( + { + configuration: this.configuration, + type + }, + this.nodes + ); + } + + public join(separator: DocNode) { + const newNodes = []; + for (let i = 0; i < this.nodes.length; i++) { + if (i > 0) { + newNodes.push(separator); + } + newNodes.push(this.nodes[i]); + } + this._nodes = newNodes; + return this; + } + + public flatten() { + this._nodes = DocContentBlockBuilder.flattenBlocks(this.nodes); + return this; + } + + /** + * Removes empty DocContentBlock wrappers, e.g. turns the following + * hierarchy: + * + * DocContentBlock + * DocContentBlock + * DocContentBlock + * DocContentBlock + * DocParagraph + * DocPlainText + * + * Into this: + * + * DocContentBlock + * DocParagraph + * DocPlainText + * + * @returns + */ + private static flattenBlocks(nodes: readonly DocNode[]): DocNode[] { + return nodes.flatMap((node) => { + if (!(node instanceof DocContentBlock)) { + return [node]; + } + if (!node.hasItems) { + return []; + } + return DocContentBlockBuilder.flattenBlocks(node.getChildNodes()); + }); + } +} diff --git a/packages/typescript-reference-doc-generator/src/utils/IndentedWriter.ts b/packages/typescript-reference-doc-generator/src/utils/IndentedWriter.ts new file mode 100644 index 0000000000..1bd3ccc8d3 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/utils/IndentedWriter.ts @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { StringBuilder, IStringBuilder } from '@rushstack/node-core-library'; + +/** + * A utility for writing indented text. + * + * @remarks + * + * Note that the indentation is inserted at the last possible opportunity. + * For example, this code... + * + * ```ts + * writer.write('begin\n'); + * writer.increaseIndent(); + * writer.write('one\ntwo\n'); + * writer.decreaseIndent(); + * writer.increaseIndent(); + * writer.decreaseIndent(); + * writer.write('end'); + * ``` + * + * ...would produce this output: + * + * ``` + * begin + * one + * two + * end + * ``` + */ +export class IndentedWriter { + /** + * The text characters used to create one level of indentation. + * Two spaces by default. + */ + public defaultIndentPrefix: string = ' '; + + private readonly _builder: IStringBuilder; + + private _latestChunk: string | undefined; + private _previousChunk: string | undefined; + private _atStartOfLine: boolean; + + private readonly _indentStack: string[]; + private _indentText: string; + + private _beforeStack: string[]; + private _isWritingBeforeStack: boolean; + + public constructor(builder?: IStringBuilder) { + this._builder = builder === undefined ? new StringBuilder() : builder; + + this._latestChunk = undefined; + this._previousChunk = undefined; + this._atStartOfLine = true; + + this._indentStack = []; + this._indentText = ''; + + this._beforeStack = []; + this._isWritingBeforeStack = false; + } + + /** + * Retrieves the output that was built so far. + */ + public getText(): string { + return this._builder.toString(); + } + + public toString(): string { + return this.getText(); + } + + /** + * Increases the indentation. Normally the indentation is two spaces, + * however an arbitrary prefix can optional be specified. (For example, + * the prefix could be "// " to indent and comment simultaneously.) + * Each call to IndentedWriter.increaseIndent() must be followed by a + * corresponding call to IndentedWriter.decreaseIndent(). + * + * @param indentPrefix + */ + public increaseIndent(indentPrefix?: string): void { + this._indentStack.push( + indentPrefix !== undefined ? indentPrefix : this.defaultIndentPrefix + ); + this._updateIndentText(); + } + + /** + * Decreases the indentation, reverting the effect of the corresponding call + * to IndentedWriter.increaseIndent(). + */ + public decreaseIndent(): void { + this._indentStack.pop(); + this._updateIndentText(); + } + + /** + * A shorthand for ensuring that increaseIndent()/decreaseIndent() occur + * in pairs. + * + * @param scope + * @param indentPrefix + */ + public indentScope(scope: () => void, indentPrefix?: string): void { + this.increaseIndent(indentPrefix); + scope(); + this.decreaseIndent(); + } + + /** + * Adds a newline if the file pointer is not already at the start of the line (or start of the stream). + */ + public ensureNewLine(): void { + const lastCharacter: string = this.peekLastCharacter(); + if (lastCharacter !== '\n' && lastCharacter !== '') { + this._writeNewLine(); + } + } + + /** + * Adds up to two newlines to ensure that there is a blank line above the current line. + */ + public ensureSkippedLine(): void { + if (this.peekLastCharacter() !== '\n') { + this._writeNewLine(); + } + + const secondLastCharacter: string = this.peekSecondLastCharacter(); + if (secondLastCharacter !== '\n' && secondLastCharacter !== '') { + this._writeNewLine(); + } + } + + /** + * Returns the last character that was written, or an empty string if no characters have been written yet. + */ + public peekLastCharacter(): string { + if (this._latestChunk !== undefined) { + return this._latestChunk.substr(-1, 1); + } + return ''; + } + + /** + * Returns the second to last character that was written, or an empty string if less than one characters + * have been written yet. + */ + public peekSecondLastCharacter(): string { + if (this._latestChunk !== undefined) { + if (this._latestChunk.length > 1) { + return this._latestChunk.substr(-2, 1); + } + if (this._previousChunk !== undefined) { + return this._previousChunk.substr(-1, 1); + } + } + return ''; + } + + /** + * Writes `before` and `after` messages if and only if `mayWrite` writes anything. + * + * If `mayWrite` writes "CONTENT", this method will write "CONTENT". + * If `mayWrite` writes nothing, this method will write nothing. + * + * @param before + * @param after + * @param mayWrite + */ + public writeTentative( + before: string, + after: string, + mayWrite: () => void + ): void { + this._beforeStack.push(before); + + // If this function writes anything, then _all_ messages in the "before stack" will also be + // written. This means that the stack will be empty (as when we write a message from the stack, + // we remove it from the stack). + mayWrite(); + + // If the stack is not empty, it means that `mayWrite` didn't write anything. Pop the last- + // added message from the stack, we'll never write it. Otherwise, if the stack is empty, then + // write the "after" message. + if (this._beforeStack.length > 0) { + this._beforeStack.pop(); + } else { + this.write(after); + } + } + + /** + * Writes some text to the internal string buffer, applying indentation according + * to the current indentation level. If the string contains multiple newlines, + * each line will be indented separately. + * + * @param message + */ + public write(message: string): void { + if (message.length === 0) { + return; + } + + if (!this._isWritingBeforeStack) { + this._writeBeforeStack(); + } + + // If there are no newline characters, then append the string verbatim + if (!/[\r\n]/.test(message)) { + this._writeLinePart(message); + return; + } + + // Otherwise split the lines and write each one individually + let first: boolean = true; + for (const linePart of message.split('\n')) { + if (!first) { + this._writeNewLine(); + } else { + first = false; + } + if (linePart) { + this._writeLinePart(linePart.replace(/[\r]/g, '')); + } + } + } + + /** + * A shorthand for writing an optional message, followed by a newline. + * Indentation is applied following the semantics of IndentedWriter.write(). + * + * @param message + */ + public writeLine(message: string = ''): void { + if (message.length > 0) { + this.write(message); + } else if (!this._isWritingBeforeStack) { + this._writeBeforeStack(); + } + + this._writeNewLine(); + } + + /** + * Writes a string that does not contain any newline characters. + * + * @param message + */ + private _writeLinePart(message: string): void { + if (message.length > 0) { + if (this._atStartOfLine && this._indentText.length > 0) { + this._write(this._indentText); + } + this._write(message); + this._atStartOfLine = false; + } + } + + private _writeNewLine(): void { + if (this._atStartOfLine && this._indentText.length > 0) { + this._write(this._indentText); + } + + this._write('\n'); + this._atStartOfLine = true; + } + + private _write(s: string): void { + this._previousChunk = this._latestChunk; + this._latestChunk = s; + this._builder.append(s); + } + + /** + * Writes all messages in our before stack, processing them in FIFO order. This stack is + * populated by the `writeTentative` method. + */ + private _writeBeforeStack(): void { + this._isWritingBeforeStack = true; + + for (const message of this._beforeStack) { + this.write(message); + } + + this._isWritingBeforeStack = false; + this._beforeStack = []; + } + + private _updateIndentText(): void { + this._indentText = this._indentStack.join(''); + } +} diff --git a/packages/typescript-reference-doc-generator/src/utils/TypeResolver.ts b/packages/typescript-reference-doc-generator/src/utils/TypeResolver.ts new file mode 100644 index 0000000000..7a13eb9ad3 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/utils/TypeResolver.ts @@ -0,0 +1,184 @@ +import { ApiItem, ApiModel, ExcerptToken, ExcerptTokenKind, IResolveDeclarationReferenceResult } from '@microsoft/api-extractor-model'; + +export interface DocumentedType { + name: string; + docUrl: string; +} + +export default class TypeResolver { + private readonly _apiModel: ApiModel; + private readonly _getLinkForApiItem: (item: ApiItem) => string; + + constructor(apiModel: ApiModel, getLinkForApiItem: (item: ApiItem) => string) { + this._apiModel = apiModel; + this._getLinkForApiItem = getLinkForApiItem; + } + + public resolveTypeDocumentation(type: ExcerptToken | string): DocumentedType | null { + if (type instanceof ExcerptToken) { + const canonicalUrl = this.resolveCanonicalReference(type); + if (canonicalUrl) { + return { name: type.text, docUrl: canonicalUrl }; + } + + return this.resolveNativeToken(type.text); + } + + return this.resolveNativeToken(type); + } + + public resolveCanonicalReference(token: ExcerptToken) { + if (token.kind === ExcerptTokenKind.Reference && token.canonicalReference) { + const apiItemResult: IResolveDeclarationReferenceResult = this._apiModel.resolveDeclarationReference( + token.canonicalReference, + undefined + ); + if (apiItemResult.resolvedApiItem) { + return this._getLinkForApiItem(apiItemResult.resolvedApiItem); + } + } + } + + public resolveNativeToken(token: string): DocumentedType | null { + // Try finding an exact match first. If that fails, try a + // case-insensitive match. + const toComparables = [(token: string) => token, (token: string) => token.toLowerCase()]; + + const tokenText = token.split(/[^a-zA-Z0-9\_]/g)[0]; + for (const toComparable of toComparables) { + const comparableTokenText = toComparable(tokenText); + for (const [nativeToken, docUrl] of Object.entries(NativeTypes)) { + if (toComparable(nativeToken) === comparableTokenText) { + return { name: nativeToken, docUrl }; + } + } + } + + return null; + } +} + +/** + * List of types and their documentation pages. + * This object can be easily updated by going to any documentation page + * that lists the types, selecting the
` element. + */ +export class DocTableCell extends DocNode { + public readonly content: DocSection; + + public constructor(parameters: IDocTableCellParameters, sectionChildNodes?: ReadonlyArray) { + super(parameters); + + this.content = new DocSection({ configuration: this.configuration }, sectionChildNodes); + } + + /** @override */ + public get kind(): string { + return CustomDocNodeKind.TableCell; + } +} diff --git a/packages/typescript-reference-doc-generator/src/nodes/DocTableRow.ts b/packages/typescript-reference-doc-generator/src/nodes/DocTableRow.ts new file mode 100644 index 0000000000..d0d949a7c9 --- /dev/null +++ b/packages/typescript-reference-doc-generator/src/nodes/DocTableRow.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { IDocNodeParameters, DocNode, DocPlainText } from '@microsoft/tsdoc'; +import { CustomDocNodeKind } from './CustomDocNodeKind'; +import { DocTableCell } from './DocTableCell'; + +/** + * Constructor parameters for {@link DocTableRow}. + */ +export interface IDocTableRowParameters extends IDocNodeParameters {} + +/** + * Represents table row, similar to an HTML `