Merge pull request #7163 from backstage/rugvip/drop-docgen

remove docgen package
This commit is contained in:
Ben Lambert
2021-09-13 19:59:09 +02:00
committed by GitHub
23 changed files with 0 additions and 1960 deletions
-1
View File
@@ -68,7 +68,6 @@ devs
discoverability
Discoverability
dls
docgen
Dockerfile
dockerfiles
Dockerize
@@ -147,11 +147,6 @@ are separated out into their own folder, see further down.
Helps you setup a plugin for isolated development so that it can be served
separately.
- [`docgen/`](https://github.com/backstage/backstage/tree/master/packages/docgen) -
Uses the
[TypeScript Compiler API](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API)
to read out definitions and generate documentation for it.
- [`e2e-test/`](https://github.com/backstage/backstage/tree/master/packages/e2e-test) -
Another CLI that can be run to try out what would happen if you build all the
packages, publish them, create a new app, and then run them. CI uses this for
-6
View File
@@ -145,12 +145,6 @@ Provides utilities for developing plugins in isolation.
Stability: `0`. This package is largely broken and needs updates.
### `docgen` [GitHub](https://github.com/backstage/backstage/tree/master/packages/docgen/)
Internal CLI utility for generating API Documentation.
Stability: `N/A`
### `e2e-test` [GitHub](https://github.com/backstage/backstage/tree/master/packages/e2e-test/)
Internal CLI utility for running e2e tests.
-1
View File
@@ -22,7 +22,6 @@
"lint:docs": "node ./scripts/check-docs-quality",
"lint:all": "lerna run lint --",
"lint:type-deps": "node scripts/check-type-dependencies.js",
"docgen": "lerna run docgen",
"docker-build": "yarn tsc && yarn workspace example-backend build --build-dependencies && yarn workspace example-backend build-image",
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
"remove-plugin": "backstage-cli remove-plugin",
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
rules: {
'no-console': 0,
},
};
-21
View File
@@ -1,21 +0,0 @@
# DocGen - API Reference Documentation Generator
The docgen package provides a CLI to generate markdown documentation for all exported ApiRefs in `@backstage/core`. The documentation is generated based on exported `ApiRef` instances and their type parameters.
The CLI supports generating both TechDocs and GitHub Markdown, where the TechDocs one provides some better linking and syntax highlighting.
## Usage
To generate markdown documentation in the top-level `docs/` directory, run the following:
```bash
yarn docgen
```
## TODO
This package was lifted out from the Spotify internal Backstage project and could use some further work:
- Use a higher-level TypeScript compiler library, e.g. `ts-morph`.
- Support for generating docs for any package or multiple packages.
- Better handling of self-referencing types in APIs, e.g. ConfigApi.
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env node
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-restricted-syntax */
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src'));
if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) {
require('..');
} else {
require('ts-node').register({
transpileOnly: true,
project: path.resolve(__dirname, '../../../tsconfig.json'),
compilerOptions: {
module: 'CommonJS',
},
});
require('../src');
}
-51
View File
@@ -1,51 +0,0 @@
{
"name": "docgen",
"description": "Tool for generating API Documentation for itself",
"version": "0.1.1",
"private": true,
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/docgen"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "src/index.ts",
"scripts": {
"build": "backstage-cli build --outputs cjs",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"docgen": "backstage-docgen generate --output ../../docs/reference/utility-apis --format github && prettier --write ../../docs/reference/utility-apis",
"clean": "backstage-cli clean",
"start": "nodemon --"
},
"bin": {
"backstage-docgen": "bin/backstage-docgen"
},
"dependencies": {
"chalk": "^4.0.0",
"commander": "^6.1.0",
"fs-extra": "9.1.0",
"github-slugger": "^1.3.0",
"ts-node": "^10.0.0",
"typescript": "^4.0.3"
},
"devDependencies": {
"@types/fs-extra": "^9.0.1",
"@types/github-slugger": "^1.3.0",
"@types/node": "^14.14.32",
"nodemon": "^2.0.2"
},
"files": [
"bin",
"dist"
],
"nodemonConfig": {
"watch": "./src",
"exec": "bin/backstage-docgen",
"ext": "ts"
}
}
@@ -1,266 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from 'typescript';
import TypeLocator from './TypeLocator';
import { createMemProgram } from './testUtils';
import ApiDocGenerator from './ApiDocGenerator';
describe('ApiDocGenerator', () => {
it('should generate empty API doc', () => {
const program = createMemProgram(
`
import MyApi from './type';
type MyApiType = {};
export const myApi = new MyApi<MyApiType>({
id: 'my-id',
description: 'my-description',
});
`,
{
'/mem/type.ts': `export default class MyApi<T> {
constructor(private readonly info: { id: string, description: string }) {}
}`,
},
);
const typeLocator = TypeLocator.fromProgram(program, '/');
const { apiInstances } = typeLocator.findExportedInstances({
apiInstances: typeLocator.getExportedType('/mem/type.ts'),
});
expect(apiInstances.length).toBe(1);
const [apiInstance] = apiInstances;
const docGenerator = ApiDocGenerator.fromProgram(program, '/');
const doc = docGenerator.toDoc(apiInstance);
expect(doc.id).toBe('my-id');
expect(doc.description).toBe('my-description');
expect(doc.name).toBe('myApi');
expect(doc.file).toBe('mem/index.ts');
expect(doc.lineInFile).toBe(6);
expect(doc.interfaceInfos).toEqual([
{
dependentTypes: [],
docs: [],
file: 'mem/index.ts',
lineInFile: 4,
members: [],
name: 'MyApiType',
},
]);
});
it('should generate API docs', () => {
const program = createMemProgram(
`
import MyApi from './type';
/** MySubSubType Docs */
type MySubSubType = { n: number; };
/** MySubType Docs */
type MySubType = {
/** Field a docs */
a: boolean;
// Field b docs
b: MySubSubType;
}
/** MySecondSubType Docs */
/** With multiple comments */
export type MySecondSubType = { s: string };
// MyThirdSubType Docs that shouldn't show up
type MyThirdSubType = { b: boolean };
/** MyApiType Docs */
type MyApiType = {
/** Docs for x */
x: string;
// Line comments shouldn't show up
y: MySubType;
/** Multiple */
/** JsDoc */
/** Comments */
z(a: Promise<readonly [{k: MySecondSubType}[]]>): Array<MyThirdSubType>;
};
/** Should not show up */
export const myApi = new MyApi<MyApiType>({
id: 'my-id',
description: 'my-description',
});
`,
{
'/mem/type.ts': `export default class MyApi<T> {
constructor(private readonly info: { id: string, description: string }) {}
}`,
},
);
const source = program.getSourceFile('/mem/index.ts');
// Figure out type IDs so we can make sure they match later
const checker = program.getTypeChecker();
const symbols = checker.getSymbolsInScope(
source!.getChildren().slice(-1)[0],
ts.SymbolFlags.TypeAlias,
);
const Ids = [
'MySubType',
'MySubSubType',
'MySecondSubType',
'MyThirdSubType',
].reduce((ids, name) => {
const symbol = symbols.find(s => s.escapedName === name)!;
const type = checker.getTypeAtLocation(symbol.declarations[0]);
ids[name] = (type.aliasSymbol as any).id;
return ids;
}, {} as { [key in string]: number });
const typeLocator = TypeLocator.fromProgram(program, '/mem');
const { apiInstances } = typeLocator.findExportedInstances({
apiInstances: typeLocator.getExportedType('/mem/type.ts'),
});
expect(apiInstances.length).toBe(1);
const [apiInstance] = apiInstances;
const docGenerator = ApiDocGenerator.fromProgram(program, '/mem');
const doc = docGenerator.toDoc(apiInstance);
expect(doc.id).toBe('my-id');
expect(doc.description).toBe('my-description');
expect(doc.name).toBe('myApi');
expect(doc.file).toBe('index.ts');
expect(doc.interfaceInfos.length).toBe(1);
expect(doc.interfaceInfos[0].docs).toEqual(['MyApiType Docs']);
expect(doc.interfaceInfos[0].file).toBe('index.ts');
expect(doc.interfaceInfos[0].lineInFile).toBe(24);
expect(doc.interfaceInfos[0].name).toBe('MyApiType');
expect(doc.interfaceInfos[0].members).toEqual([
{
type: 'prop',
name: 'x',
path: 'MyApiType.x',
text: 'x: string',
docs: ['Docs for x'],
links: [],
},
{
type: 'prop',
name: 'y',
path: 'MyApiType.y',
text: 'y: MySubType',
docs: [],
links: [
{
id: Ids.MySubType,
name: 'MySubType',
path: 'index.ts/MySubType',
location: [3, 12],
},
],
},
{
type: 'method',
name: 'z',
path: 'MyApiType.z',
text: 'z(a: Promise<readonly [{k: MySecondSubType}[]]>): Array<MyThirdSubType>',
docs: ['Multiple', 'JsDoc', 'Comments'],
links: [
{
id: Ids.MySecondSubType,
name: 'MySecondSubType',
path: 'index.ts/MySecondSubType',
location: [27, 42],
},
{
id: Ids.MyThirdSubType,
name: 'MyThirdSubType',
path: 'index.ts/MyThirdSubType',
location: [56, 70],
},
],
},
]);
expect(doc.interfaceInfos[0].dependentTypes).toEqual([
{
id: Ids.MySubType,
name: 'MySubType',
path: 'index.ts/MySubType',
file: 'index.ts',
lineInFile: 8,
text: `type MySubType = {
/** Field a docs */
a: boolean;
// Field b docs
b: MySubSubType;
}`,
docs: ['MySubType Docs'],
links: [
{
id: Ids.MySubSubType,
name: 'MySubSubType',
path: 'index.ts/MySubSubType',
location: [102, 114],
},
],
children: [],
},
{
id: Ids.MySubSubType,
name: 'MySubSubType',
path: 'index.ts/MySubSubType',
file: 'index.ts',
lineInFile: 5,
text: 'type MySubSubType = { n: number; }',
docs: ['MySubSubType Docs'],
links: [],
children: [],
},
{
id: Ids.MySecondSubType,
name: 'MySecondSubType',
path: 'index.ts/MySecondSubType',
file: 'index.ts',
lineInFile: 18,
text: 'export type MySecondSubType = { s: string }',
docs: ['MySecondSubType Docs', 'With multiple comments'],
links: [],
children: [],
},
{
id: Ids.MyThirdSubType,
name: 'MyThirdSubType',
path: 'index.ts/MyThirdSubType',
file: 'index.ts',
lineInFile: 21,
text: 'type MyThirdSubType = { b: boolean }',
docs: [],
links: [],
children: [],
},
]);
});
});
@@ -1,343 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from 'typescript';
import {
relative as relativePath,
sep as pathSep,
posix as posixPath,
} from 'path';
import {
ExportedInstance,
ApiDoc,
InterfaceInfo,
FieldInfo,
TypeInfo,
TypeLink,
} from './types';
// Always use unix path separators for the relative file
// paths, since we'll be using those for HTTP URLs.
function relativeLink(basePath: string, filePath: string) {
return relativePath(basePath, filePath).split(pathSep).join(posixPath.sep);
}
/**
* The ApiDocGenerator uses the typescript compiler API to build the data structure that
* describes a Backstage API and all of it's related types.
*
* It receives an exported instance that of the form
* `export name = createApiRef<Interface>({id: ..., description: ...})`.
* It will then traverse all fields and methods on the interface type, and also
* types and declaration of all types that are used by those, both directly and indirectly.
* While traversing, it collects information such as names, location in source, docs, etc.
*/
export default class ApiDocGenerator {
static fromProgram(program: ts.Program, sourcePath: string) {
return new ApiDocGenerator(program.getTypeChecker(), sourcePath);
}
constructor(
private readonly checker: ts.TypeChecker,
private readonly basePath: string,
) {}
/**
* Generate documentation information for a given exported symbol.
*/
toDoc(apiInstance: ExportedInstance): ApiDoc {
const { name, source, args, typeArgs } = apiInstance;
const [info] = args;
if (!ts.isObjectLiteralExpression(info)) {
throw new Error('api info is not an object literal');
}
const id = this.getObjectPropertyLiteral(info, 'id');
const description = this.getObjectPropertyLiteral(info, 'description');
const file = relativeLink(this.basePath, source.fileName);
const { line } = source.getLineAndCharacterOfPosition(
apiInstance.node.getStart(),
);
const rootTypeNode = typeArgs[0];
const typeNodes = ts.isIntersectionTypeNode(rootTypeNode)
? rootTypeNode.types.slice()
: [rootTypeNode];
const interfaceInfos = typeNodes.map(typeNode =>
this.getInterfaceInfo(typeNode),
);
return {
id,
name,
file,
lineInFile: line + 1,
description,
interfaceInfos,
};
}
/**
* Grab jsDoc and regular comments for a node
*/
private getNodeDocs(node: ts.Node): string[] {
const docNodes = ((node && (node as any).jsDoc) || []) as ts.JSDoc[];
const docs = docNodes.map(docNode => docNode.comment || '').filter(Boolean);
return docs;
}
/**
* Collect information about a top-level type.
*/
private getInterfaceInfo(typeNode: ts.TypeNode): InterfaceInfo {
if (ts.isTypeQueryNode(typeNode)) {
throw new Error(
'APIs must have a proper type parameter, TypeQueries are now allowed',
);
}
if (!ts.isTypeReferenceNode(typeNode)) {
throw new Error('Interface is not a type node');
}
const type = this.checker.getTypeFromTypeNode(typeNode);
const interfaceMembers = type.symbol.members as Map<string, ts.Symbol>;
if (!interfaceMembers) {
throw new Error('Interface does not have any members');
}
const name = (type.aliasSymbol || type.symbol).name;
const [declaration] = (type.aliasSymbol || type.symbol).declarations;
const sourceFile = declaration.getSourceFile();
const file = relativeLink(this.basePath, sourceFile.fileName);
const { line } = sourceFile.getLineAndCharacterOfPosition(
declaration.getStart(),
);
const docs = this.getNodeDocs(declaration);
const membersAndTypes = Array.from(interfaceMembers.values()).flatMap(
fieldSymbol => this.getMemberInfo(name, fieldSymbol),
);
const members = membersAndTypes.map(t => t.member);
const dependentTypes = this.flattenTypes(
membersAndTypes.flatMap(t => t.dependentTypes),
);
return { name, docs, file, lineInFile: line + 1, members, dependentTypes };
}
/**
* Grab primitive values from an object literal expression.
*/
private getObjectPropertyLiteral(
objectLiteral: ts.ObjectLiteralExpression,
propertyName: string,
): string {
const matchingProp = objectLiteral.properties.filter(
prop =>
ts.isPropertyAssignment(prop) &&
ts.isIdentifier(prop.name) &&
prop.name.text === propertyName,
)[0] as ts.PropertyAssignment;
if (!matchingProp) {
throw new Error(`no identifier found for property ${propertyName}`);
}
const { initializer } = matchingProp;
if (!ts.isStringLiteral(initializer)) {
throw new Error(`no string literal for ${propertyName}`);
}
return initializer.text;
}
/**
* Get field definitions for a given symbol.
*/
private getMemberInfo(
parentName: string,
symbol: ts.Symbol,
): { member: FieldInfo; dependentTypes: TypeInfo[] } {
const declaration = symbol.valueDeclaration;
const { links, infos: dependentTypes } =
this.findAllTypeReferences(declaration);
let type: FieldInfo['type'] = 'prop';
if (
ts.isPropertySignature(declaration) &&
declaration.type &&
ts.isFunctionTypeNode(declaration.type)
) {
type = 'method';
} else if (ts.isMethodSignature(declaration)) {
type = 'method';
}
return {
member: {
type,
name: symbol.name,
path: `${parentName}.${symbol.name}`,
text: declaration.getText().replace(/;$/, ''),
docs: this.getNodeDocs(declaration),
links: this.recalculateLinkOffsets(declaration, links),
},
dependentTypes,
};
}
/**
* Recursively search for references to types that we care about and want to include in the documentation.
*/
private findAllTypeReferences = (
node: ts.Node | undefined,
visited: Set<ts.Node> = new Set(),
): { links: TypeLink[]; infos: TypeInfo[] } => {
if (!node || visited.has(node)) {
return { links: [], infos: [] };
}
// This makes sure we don't end up in a loop.
// It doesn't exclude repeated types, e.g. Array<Array<MyType>>, since we're exiting on duplicate nodes, not types.
visited.add(node);
const children = node
.getChildren()
.flatMap(child => this.findAllTypeReferences(child, visited));
if (!ts.isTypeReferenceNode(node)) {
return {
links: children.flatMap(child => child.links),
infos: children.flatMap(child => child.infos),
};
}
const type = this.checker.getTypeFromTypeNode(node);
const info = this.validateTypeDeclaration(type);
if (!info) {
return {
links: children.flatMap(child => child.links),
infos: children.flatMap(child => child.infos),
};
}
const { symbol, declaration } = info;
const typeRefs = this.findAllTypeReferences(declaration, visited);
const sourceFile = declaration.getSourceFile();
const { line } = sourceFile.getLineAndCharacterOfPosition(
declaration.getStart(),
);
const file = relativeLink(this.basePath, sourceFile.fileName);
const typeInfo = {
id: (symbol as any).id,
name: symbol.name,
text: declaration.getText().replace(/;$/, ''),
docs: this.getNodeDocs(declaration),
path: `${file}/${symbol.name}`,
file,
lineInFile: line + 1, // TS line is 0-based
links: this.recalculateLinkOffsets(declaration, typeRefs.links),
children: typeRefs.infos,
};
const link = {
id: typeInfo.id,
path: typeInfo.path,
name: typeInfo.name,
location: [node.typeName.getStart(), node.typeName.getEnd()] as const,
};
return {
links: [link, ...children.flatMap(child => child.links)],
infos: [typeInfo, ...children.flatMap(child => child.infos)],
};
};
/**
* Check if a given type declaration is one that we care about.
*/
private validateTypeDeclaration(
type: ts.Type | undefined,
): undefined | { symbol: ts.Symbol; declaration: ts.Declaration } {
if (!type) {
return undefined;
}
const symbol = type.aliasSymbol || type.symbol;
if (!symbol) {
return undefined;
}
const [declaration] = symbol.declarations;
// Don't generate standalone infos for type paramters
if (ts.isTypeParameterDeclaration(declaration)) {
return undefined;
}
if (declaration.getSourceFile().fileName.includes('node_modules')) {
return undefined;
}
return { symbol, declaration };
}
/**
* Flatten a tree of TypeInfos with children into a flat
* list of TypeInfos with duplicates removed.
*/
private flattenTypes(descs: TypeInfo[]): TypeInfo[] {
function getChildren(parent: TypeInfo): TypeInfo[] {
return [
{
...parent,
children: [],
},
...parent.children.flatMap(getChildren),
];
}
const flatDescs = descs.flatMap(getChildren);
const seenTypes = new Set<number>();
return flatDescs.filter(desc => {
if (seenTypes.has(desc.id)) {
return false;
}
seenTypes.add(desc.id);
return true;
});
}
/**
* Calculate link positions within a block of code, with 0 being
* the first character in the block.
*/
private recalculateLinkOffsets(
parent: ts.Node,
links: TypeLink[],
): TypeLink[] {
const parentStart = parent.getStart();
return links.map(link => {
const [start, end] = link.location;
return {
...link,
location: [start - parentStart, end - parentStart],
};
});
}
}
@@ -1,163 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import sortSelector from './sortSelector';
import { ApiDoc, InterfaceInfo, MarkdownPrinter } from './types';
/**
* The ApiDocPrinter takes a ApiDoc data structure, typically generated by an ApiDocGenerator,
* and prints it out as a markdown doc with custom code highlighting and links.
*/
export default class ApiDocPrinter {
printerFactory: () => MarkdownPrinter;
constructor(printerFactory: () => MarkdownPrinter) {
this.printerFactory = printerFactory;
}
/**
* Print an index file with all ApiRefs and what types they implement.
*/
printApiIndex(apiDocs: ApiDoc[]): Buffer {
const printer = this.printerFactory();
printer.header(1, 'Backstage Core Utility APIs');
printer.paragraph(
'The following is a list of all Utility APIs defined by `@backstage/core`.',
'They are available to use by plugins and components, and can be accessed ',
'using the `useApi` hook, also provided by `@backstage/core`.',
'For more information, see https://github.com/backstage/backstage/blob/master/docs/api/utility-apis.md.',
);
for (const api of apiDocs) {
printer.header(3, `${this.apiDisplayName(api)}`, api.id);
printer.paragraph(api.description);
const typeLinks = api.interfaceInfos.map(
i => `[${i.name}](${printer.pageLink(i.name)})`,
);
printer.paragraph(
`Implemented type${typeLinks.length > 1 ? 's' : ''}: ${typeLinks.join(
', ',
)}`,
);
printer.paragraph(`ApiRef: ${printer.srcLink(api, api.name)}`);
}
return printer.toBuffer();
}
/**
* Print documentation page for a type implemented by an ApiRef and
*/
printInterface(apiType: InterfaceInfo, apiDocs: ApiDoc[]): Buffer {
const printer = this.printerFactory();
printer.header(1, apiType.name);
printer.paragraph(
`The ${apiType.name} type is defined at ${printer.srcLink(apiType)}.`,
);
const apiLinks = apiDocs
.filter(ad => ad.interfaceInfos.some(i => i.name === apiType.name))
.map(ad => {
const link =
printer.indexLink() +
printer.headerLink(this.apiDisplayName(ad), ad.id);
return `[${ad.name}](${link})`;
});
if (apiLinks.length === 1) {
printer.paragraph(
`The following Utility API implements this type: ${apiLinks}`,
);
} else {
printer.paragraph(`The following Utility APIs implement this type:`);
for (const link of apiLinks) {
printer.text(` - ${link}`);
}
}
printer.header(2, 'Members');
this.addInterfaceMembers(printer, apiType);
if (apiType.dependentTypes.length) {
printer.header(2, 'Supporting types');
printer.paragraph(
'These types are part of the API declaration, but may not be unique to this API.',
);
this.addInterfaceTypes(printer, apiType);
}
return printer.toBuffer();
}
private addInterfaceMembers(
printer: MarkdownPrinter,
apiType: InterfaceInfo,
) {
for (const member of apiType.members) {
printer.header(
3,
`${member.name}${member.type === 'method' ? '()' : ''}`,
member.path,
);
for (const doc of member.docs) {
printer.text(doc);
}
printer.code(member);
}
}
private addInterfaceTypes(printer: MarkdownPrinter, apiType: InterfaceInfo) {
for (const type of apiType.dependentTypes
.slice()
.sort(sortSelector(x => x.name))) {
printer.header(3, `${type.name}`, type.path);
for (const doc of type.docs) {
printer.text(doc);
}
printer.code(type);
printer.paragraph(`Defined at ${printer.srcLink(type)}.`);
const usageLinks = [...apiType.members, ...apiType.dependentTypes]
.filter(member => {
return member.links.some(link => link.id === type.id);
})
.map(
({ name, path }) => `[${name}](${printer.headerLink(name, path)})`,
);
if (usageLinks.length) {
printer.paragraph(`Referenced by: ${usageLinks.join(', ')}.`);
}
}
}
private apiDisplayName(api: ApiDoc): string {
return api.name.replace(/ApiRef$/, '');
}
}
@@ -1,145 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import GithubSlugger from 'github-slugger';
import sortSelector from './sortSelector';
import { MarkdownPrinter, TypeLink } from './types';
import { execSync } from 'child_process';
// TODO(Rugvip): provide through options?
const GH_BASE_URL = 'https://github.com/backstage/backstage';
const COMMIT_SHA =
process.env.COMMIT_SHA ||
execSync('git rev-parse HEAD').toString('utf8').trim();
/**
* The GithubMarkdownPrinter is a MarkdownPrinter for printing GitHub Flavored Markdown documents.
*/
export default class GithubMarkdownPrinter implements MarkdownPrinter {
private str: string = '';
private line(line: string = '') {
this.str += `${line}\n`;
}
text(text: string) {
this.line(text);
this.line();
}
header(level: number, text: string) {
this.line(`${'#'.repeat(level)} ${text}`);
this.line();
}
headerLink(heading: string): string {
const slug = GithubSlugger.slug(heading);
return `#${slug}`;
}
indexLink() {
return './README.md';
}
pageLink(name: string) {
return `./${name}.md`;
}
srcLink(
{ file, lineInFile }: { file: string; lineInFile: number },
text?: string,
): string {
const linkText = text ?? `${file}:${lineInFile}`;
const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`;
return `[${linkText}](${href})`;
}
paragraph(...text: string[]) {
this.line(
text
.join('\n')
.trim()
.split('\n')
.map(line => line.trim())
.join('\n'),
);
this.line();
}
code({ text, links = [] }: { text: string; links?: TypeLink[] }) {
this.line('<pre>');
this.line(this.formatWithLinks({ text, links }));
this.line('</pre>');
this.line();
}
private escapeText: (text: string) => string = (() => {
const escapes: { [char in string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
};
return (text: string) => text.replace(/[&<>]/g, char => escapes[char]);
})();
private formatWithLinks({
text,
links = [],
}: {
text: string;
links?: TypeLink[];
}): string {
const sortedLinks = links.slice().sort(sortSelector(x => x.location[0]));
sortedLinks.reduce((lastEnd, link) => {
if (link.location[0] <= lastEnd) {
throw new Error(
`overlapping link detected for ${link.path}, ${link.location}`,
);
}
return link.location[1];
}, -1);
const parts: Array<{ text: string; link?: boolean }> = [];
const endLocation = sortedLinks.reduce((prev, link) => {
const [start, end] = link.location;
parts.push(
{ text: text.slice(prev, start) },
{ text: text.slice(start, end), link: true },
);
return end;
}, 0);
parts.push({ text: text.slice(endLocation) });
return parts
.map(part => {
if (part.link) {
const link = this.headerLink(part.text);
return `<a href="${link}">${this.escapeText(part.text)}</a>`;
}
return this.escapeText(part.text);
})
.join('');
}
toBuffer(): Buffer {
return Buffer.from(this.str, 'utf8');
}
}
@@ -1,161 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import sortSelector from './sortSelector';
import { Highlighter, MarkdownPrinter, TypeLink } from './types';
import { execSync } from 'child_process';
// TODO(Rugvip): provide through options?
const GH_BASE_URL = 'https://github.com/backstage/backstage';
const COMMIT_SHA =
process.env.COMMIT_SHA || execSync('git rev-parse HEAD').toString('utf8');
/**
* The TechdocsMarkdownPrinter is a MarkdownPrinter for building TechDocs markdown documents.
*/
export default class TechdocsMarkdownPrinter implements MarkdownPrinter {
private str: string = '';
private readonly highlighter: Highlighter;
constructor(highlighter: Highlighter) {
this.highlighter = highlighter;
// Remove line numbers from codeblocks
this.style('.linenodiv{ display: none }');
}
private line(line: string = '') {
this.str += `${line}\n`;
}
text(text: string) {
this.line(text);
this.line();
}
style(style: string) {
this.line('<style>');
this.line(style);
this.line('</style>');
}
header(level: number, text: string, id?: string) {
this.line(`${'#'.repeat(level)} ${text}${id ? ` {#${id}}` : ''}`);
this.line();
}
headerLink(heading: string, id?: string): string {
return `#${id ?? heading}`;
}
indexLink() {
return '../';
}
pageLink(name: string) {
return `./${name}/`;
}
srcLink(
{ file, lineInFile }: { file: string; lineInFile: number },
text?: string,
): string {
const linkText = text ?? `${file}:${lineInFile}`;
const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`;
return `[${linkText}](${href})`;
}
paragraph(...text: string[]) {
this.line(
text
.join('\n')
.trim()
.split('\n')
.map(line => line.trim())
.join('\n'),
);
this.line();
}
code({ text, links = [] }: { text: string; links?: TypeLink[] }) {
this.line('<div class="code">');
this.line(
'<div class="codehilite" style="background: #f0f0f0; padding: 0.225rem 0.6rem">',
);
this.line('<pre style="line-height: 125%">');
this.line(this.formatWithLinks({ text, links }));
this.line('</pre>');
this.line('</div>');
this.line('</div>');
}
private escapeText: (text: string) => string = (() => {
const escapes: { [char in string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
};
return (text: string) => text.replace(/[&<>]/g, char => escapes[char]);
})();
private formatWithLinks({
text,
links = [],
}: {
text: string;
links?: TypeLink[];
}): string {
const sortedLinks = links.slice().sort(sortSelector(x => x.location[0]));
sortedLinks.reduce((lastEnd, link) => {
if (link.location[0] <= lastEnd) {
throw new Error(
`overlapping link detected for ${link.path}, ${link.location}`,
);
}
return link.location[1];
}, -1);
const parts: Array<{ text: string; path?: string }> = [];
const endLocation = sortedLinks.reduce((prev, link) => {
const [start, end] = link.location;
parts.push(
{ text: text.slice(prev, start) },
{ text: text.slice(start, end), path: link.path },
);
return end;
}, 0);
parts.push({ text: text.slice(endLocation) });
return parts
.map(part => {
if (part.path) {
const link = this.headerLink(part.text, part.path);
return `<a href="${link}">${this.escapeText(part.text)}</a>`;
}
return this.highlighter.highlight(this.escapeText(part.text));
})
.join('');
}
toBuffer(): Buffer {
return Buffer.from(this.str, 'utf8');
}
}
@@ -1,68 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from 'typescript';
import TypeLocator from './TypeLocator';
import { createMemProgram } from './testUtils';
describe('TypeLocator', () => {
it('should find a default export', () => {
const program = createMemProgram('export default class MyApi<T> {}');
const typeLocator = TypeLocator.fromProgram(program, '/mem');
const apiType = typeLocator.getExportedType('/mem/index.ts');
expect(apiType.symbol.name).toBe('default');
const [declaration] = apiType.symbol.declarations;
expect(declaration.kind).toBe(ts.SyntaxKind.ClassDeclaration);
expect((declaration as ts.ClassDeclaration).name).toBeDefined();
expect((declaration as ts.ClassDeclaration).name!.text).toBe('MyApi');
}, 10000);
it('should find api instance export', () => {
const program = createMemProgram(
`
import MyApi from './type';
type MyApiType = {};
export const myApi = new MyApi<MyApiType>();
`,
{
'/mem/type.ts': 'export default class MyApi<T> {}',
},
);
const typeLocator = TypeLocator.fromProgram(program, '/mem');
const { apiInstances } = typeLocator.findExportedInstances({
apiInstances: typeLocator.getExportedType('/mem/type.ts'),
});
expect(apiInstances.length).toBe(1);
const [apiInstance] = apiInstances;
expect(apiInstance.name).toBe('myApi');
expect(apiInstance.source.fileName).toBe('/mem/index.ts');
expect(apiInstance.args).toEqual([]);
expect(apiInstance.typeArgs.length).toBe(1);
const [typeArg] = apiInstance.typeArgs;
expect(typeArg.kind).toBe(ts.SyntaxKind.TypeReference);
expect((typeArg as ts.TypeReferenceNode).typeName.getText()).toBe(
'MyApiType',
);
});
});
-143
View File
@@ -1,143 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from 'typescript';
import { ExportedInstance } from './types';
/**
* The TypeLocator is used to extract typescrint Type structures from a compiled program,
* as well as finding locations where types are used in according to a matching pattern.
*
* This is used to e.g. find exported APIs that we should generate documentation for.
*/
export default class TypeLocator {
static fromProgram(program: ts.Program, sourcePath: string) {
return new TypeLocator(program.getTypeChecker(), program, sourcePath);
}
constructor(
private readonly checker: ts.TypeChecker,
private readonly program: ts.Program,
private readonly sourcePath: string,
) {}
/**
* Get the type of a symbol by export path and name
*/
getExportedType(path: string, exportedName: string = 'default'): ts.Type {
const source = this.program.getSourceFile(path);
if (!source) {
throw new Error(`Source not found for path '${path}'`);
}
const exported = this.checker.getExportsOfModule(
this.checker.getSymbolAtLocation(source)!,
);
const [symbol] = exported.filter(e => e.name === exportedName);
if (!symbol) {
throw new Error(`No export '${exportedName}' found in ${path}`);
}
const type = this.checker.getTypeOfSymbolAtLocation(symbol, source);
return type;
}
/**
* Find exported instances and return values from calls using the types
* provided in the lookup table.
*/
findExportedInstances<T extends string>(typeLookupTable: {
[key in T]: ts.Type;
}): { [key in T]: ExportedInstance[] } {
const docMap = new Map<ts.Type, ExportedInstance[]>();
for (const type of Object.values<ts.Type>(typeLookupTable)) {
docMap.set(type, []);
}
this.program.getSourceFiles().forEach(source => {
const inRoot = source.fileName.startsWith(this.sourcePath);
if (!inRoot) {
return;
}
ts.forEachChild(source, node => {
const decl = this.getExportedConstructorDeclaration(node);
if (!decl || !docMap.has(decl.constructorType)) {
return;
}
docMap.get(decl.constructorType)!.push({ node, source, ...decl });
});
});
const result: { [key in T]?: ExportedInstance[] } = {};
for (const key in typeLookupTable) {
if (typeLookupTable.hasOwnProperty(key)) {
const type = typeLookupTable[key];
result[key] = docMap.get(type)!;
}
}
return result as { [key in T]: ExportedInstance[] };
}
private getExportedConstructorDeclaration(node: ts.Node):
| {
constructorType: ts.Type;
args: ts.Expression[];
typeArgs: ts.TypeNode[];
name: string;
}
| undefined {
if (!ts.isVariableStatement(node)) {
return undefined;
}
if (
!node.modifiers ||
!node.modifiers.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)
) {
return undefined;
}
const { declarations } = node.declarationList;
if (declarations.length !== 1) {
return undefined;
}
const [declaration] = declarations;
const { initializer, name } = declaration;
if (!initializer || !name) {
return undefined;
}
if (!ts.isCallOrNewExpression(initializer)) {
return undefined;
}
if (!ts.isIdentifier(name)) {
return undefined;
}
const constructorType = this.checker.getTypeAtLocation(
initializer.expression,
);
const args = Array.from(initializer.arguments ?? []);
const typeNode = declaration.type;
const typeArgs = Array.from(
(typeNode && ts.isTypeReferenceNode(typeNode)
? typeNode.typeArguments
: initializer.typeArguments) ?? [],
);
return { constructorType, args, typeArgs, name: name.text };
}
}
@@ -1,88 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Highlighter } from './types';
// Simple syntax highlighter that mimics hilite
export default class TypescriptHighlighter implements Highlighter {
private static readonly basicTypes = [
'boolean',
'number',
'string',
'Array',
'object',
'Record',
'Set',
'Map',
'true',
'false',
'null',
'undefined',
'void',
'Promise',
'any',
'[0-9\\.]+',
];
// List of highlightings to apply, each with a match regex and the style that should be applied
private static readonly highlighters = [
[/(\/\*\*?(?:.|\n)+?\*\/)/g, 'color: #60a0b0; font-style: italic'], // block comment
[/(\/\/.*)/gm, 'color: #60a0b0; font-style: italic'], // line comment
[/('[^']*?'|"[^"]*?")/g, 'color: #4070a0'], // string literals
[
new RegExp(
`\\b(${TypescriptHighlighter.basicTypes.join('|')})\\b(?!:|,)`,
'g',
),
'color: #902000',
], // basic types
[
/^((?:export\s)?(?:type\s)?(?:interface\s)?)/g,
'color: #007020; font-weight: bold',
], // keywords
] as readonly [RegExp, string][];
highlight(fullText: string): string {
// Each part is either plain text that can be highlighted or text that is already highlighted
type HighlightPart = { text: string; highlighted?: boolean };
const painter = (regex: RegExp, style: string) => (part: HighlightPart) => {
if (part.highlighted) {
return [part];
}
// Apply highlighting to all matches of the regex by splitting into parts
return part.text.split(regex).map((text, index) => {
// Odd parts are the ones that matched the regex and should be highlighted.
if (index % 2 === 1) {
return {
text: `<span style="${style}">${text}</span>`,
highlighted: true,
};
}
return { text };
});
};
// Order here is important, e.g. comments must be first to avoid string literals inside comments being highlighted
return TypescriptHighlighter.highlighters
.reduce(
(parts, highlighter) => parts.flatMap(painter(...highlighter)),
[{ text: fullText }],
)
.map(({ text }) => text)
.join('');
}
}
@@ -1,49 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import sortSelector from './sortSelector';
describe('sortSelector', () => {
it('should stable sort', () => {
const arr = [
[3, 1],
[1, 2],
[1, 1],
[1, 3],
[2, 1],
];
const sortedByFirst = arr.slice().sort(sortSelector(([first]) => first));
expect(sortedByFirst).toEqual([
[1, 2],
[1, 1],
[1, 3],
[2, 1],
[3, 1],
]);
const sortedBySecond = arr
.slice()
.sort(sortSelector(([, second]) => second));
expect(sortedBySecond).toEqual([
[3, 1],
[1, 1],
[2, 1],
[1, 2],
[1, 3],
]);
});
});
@@ -1,33 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The sortSelector is a utility that makes a sort function by selecting the value to sort on with a lambda.
*/
export default function sortSelector<T>(
selector: (x: T) => any,
): (a: T, b: T) => -1 | 1 | 0 {
return (a: T, b: T) => {
const aV = selector(a);
const bV = selector(b);
if (aV < bV) {
return -1;
} else if (aV > bV) {
return 1;
}
return 0;
};
}
-67
View File
@@ -1,67 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from 'typescript';
export function createMemProgram(
indexSource: string,
otherSourceFiles: { [fileName in string]: string } = {},
): ts.Program {
const rootDir = '/mem';
const options = { noEmit: true };
const baseHost = ts.createCompilerHost(options);
const files: { [fileName in string]: string } = {
[`${rootDir}/index.ts`]: indexSource,
...otherSourceFiles,
};
// Custom compiler hosts that reads from a map of in-memory files, but
// falls back to reading from disc for ts libs etc.
const compilerHost: ts.CompilerHost = {
...baseHost,
readFile(fileName): string | undefined {
if (fileName in files) {
return files[fileName];
}
return baseHost.readFile(fileName);
},
getCurrentDirectory(): string {
return rootDir;
},
directoryExists(dir) {
if (dir === rootDir) {
return true;
}
if (baseHost.directoryExists) {
return baseHost.directoryExists(dir);
}
return false;
},
fileExists(fileName: string): boolean {
return !!files[fileName] || baseHost.fileExists(fileName);
},
getSourceFile(fileName, ...rest): ts.SourceFile | undefined {
const file = files[fileName];
if (file) {
return ts.createSourceFile(fileName, file, ts.ScriptTarget.ES2017);
}
return baseHost.getSourceFile(fileName, ...rest);
},
};
return ts.createProgram(['/mem/index.ts'], options, compilerHost);
}
-119
View File
@@ -1,119 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ts from 'typescript';
/**
* A TypeLink is a link to a different type.
*/
export type TypeLink = {
// The ID of the linked type
id: number;
// The path to the type from the project root
path: string;
// The name of the type, to display
name: string;
// The location of the type name as it appears in it's parent text.
location: readonly [number, number];
};
/**
* TypeInfo describes a TypeScript Type.
*/
export type TypeInfo = {
id: number;
name: string;
path: string;
file: string;
lineInFile: number;
text: string;
docs: string[];
links: TypeLink[];
children: TypeInfo[];
};
/**
* FieldInfo describes a property or method in a documented API.
*/
export type FieldInfo = {
type: 'prop' | 'method';
name: string;
path: string;
text: string;
docs: string[];
links: TypeLink[];
};
/**
* InterfaceInfo describes the type of a documented API.
*/
export type InterfaceInfo = {
name: string;
docs: string[];
file: string;
lineInFile: number;
members: Array<FieldInfo>;
dependentTypes: TypeInfo[];
};
/**
* ApiDoc describes a documented API.
*/
export type ApiDoc = {
id: string;
name: string;
description: string;
file: string;
lineInFile: number;
interfaceInfos: InterfaceInfo[];
};
/**
* ExportedInstance describes an expression matching `export {name} = new {Contructor}<{typeArgs}...>({args}...)`
*/
export type ExportedInstance = {
node: ts.Node;
name: string;
source: ts.SourceFile;
args: Array<ts.Expression>;
typeArgs: Array<ts.TypeNode>;
};
export type Highlighter = {
highlight(test: string): string;
};
/**
* Markdown printer is an abstraction for printing markdown documents of different flavors.
*/
export type MarkdownPrinter = {
text(text: string): void;
header(level: number, text: string, id?: string): void;
paragraph(...text: string[]): void;
code(options: { text: string; links?: TypeLink[] }): void;
headerLink(header: string, id?: string): string;
/** Link from pages to index */
indexLink(): string;
/** Link from index to pages */
pageLink(name: string): string;
srcLink(
{ file, lineInFile }: { file: string; lineInFile: number },
text?: string,
): string;
toBuffer(): Buffer;
};
-127
View File
@@ -1,127 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as ts from 'typescript';
import fs from 'fs-extra';
import { resolve as resolvePath, join as joinPath } from 'path';
import ApiDocGenerator from './docgen/ApiDocGenerator';
import sortSelector from './docgen/sortSelector';
import TypeLocator from './docgen/TypeLocator';
import ApiDocPrinter from './docgen/ApiDocsPrinter';
import TypescriptHighlighter from './docgen/TypescriptHighlighter';
import GitHubMarkdownPrinter from './docgen/GitHubMarkdownPrinter';
import TechdocsMarkdownPrinter from './docgen/TechdocsMarkdownPrinter';
const FORMATS = ['github', 'techdocs'] as const;
export async function generate(
targetPath: string,
format: typeof FORMATS[number],
) {
if (!FORMATS.includes(format)) {
throw new TypeError(
`Invalid format, '${format}', must be one of ${FORMATS.join(', ')}`,
);
}
/* eslint-disable-next-line no-restricted-syntax */
const rootDir = resolvePath(__dirname, '../../..');
const srcDir = resolvePath(rootDir, 'packages', 'core-api', 'src');
const targetDir = resolvePath(targetPath);
const options = await fs.readJson(resolvePath('../cli/config/tsconfig.json'));
delete options.moduleResolution;
options.noEmit = true;
const program = ts.createProgram([resolvePath(srcDir, 'index.ts')], options);
const typeLocator = TypeLocator.fromProgram(program, srcDir);
const { apis } = typeLocator.findExportedInstances({
apis: typeLocator.getExportedType(
resolvePath(srcDir, 'index.ts'),
'createApiRef',
),
});
const apiDocGenerator = ApiDocGenerator.fromProgram(program, rootDir);
const apiDocs = apis
.map(api => {
try {
return apiDocGenerator.toDoc(api);
} catch (error) {
throw new Error(
`Doc generation failed for API in ${api.source.fileName}, ${error.stack}`,
);
}
})
.sort(sortSelector(x => x.name));
const apiTypes = Object.values(
Object.fromEntries(
apiDocs.flatMap(d => d.interfaceInfos).map(i => [i.name, i]),
),
).sort(sortSelector(i => i.name));
if (format === 'techdocs') {
const docsDir = resolvePath(targetDir, 'docs');
await fs.ensureDir(docsDir);
const apiDocPrinter = new ApiDocPrinter(
() => new TechdocsMarkdownPrinter(new TypescriptHighlighter()),
);
await fs.writeFile(
joinPath(docsDir, 'README.md'),
apiDocPrinter.printApiIndex(apiDocs),
);
for (const apiType of Object.values(apiTypes)) {
const data = apiDocPrinter.printInterface(apiType, apiDocs);
await fs.writeFile(joinPath(docsDir, `${apiType.name}.md`), data);
}
await fs.writeFile(
resolvePath(targetDir, 'mkdocs.yml'),
[
'site_name: Backstage Core Utility API References',
'nav:',
` - API Index: 'README.md'`,
...apiTypes.map(({ name }) => ` - ${name}: '${name}.md'`),
'plugins:',
' - techdocs-core',
].join('\n'),
'utf8',
);
} else {
await fs.ensureDir(targetDir);
const apiDocPrinter = new ApiDocPrinter(() => new GitHubMarkdownPrinter());
await fs.writeFile(
joinPath(targetDir, 'README.md'),
apiDocPrinter.printApiIndex(apiDocs),
);
for (const apiType of Object.values(apiTypes)) {
const data = apiDocPrinter.printInterface(apiType, apiDocs);
await fs.writeFile(joinPath(targetDir, `${apiType.name}.md`), data);
}
}
}
-60
View File
@@ -1,60 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import program from 'commander';
import { resolve as resolvePath } from 'path';
import chalk from 'chalk';
import fs from 'fs-extra';
import { generate } from './generate';
const main = (argv: string[]) => {
/* eslint-disable-next-line no-restricted-syntax */
const pkgJson = fs.readJsonSync(resolvePath(__dirname, '../package.json'));
program.name('docgen').version(pkgJson.version);
program
.command('generate')
.description(
'Generate documentation for the declarations in the core-api package',
)
.option('--output <output>', 'Output directory [./dist]')
.option(
'--format <format>',
'Output format, either techdocs or github [techdocs]',
)
.action(async cmd => {
await generate(cmd.output ?? './dist', cmd.format ?? 'techdocs');
});
program.on('command:*', () => {
console.log();
console.log(
chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`),
);
console.log(chalk.red('See --help for a list of available commands.'));
console.log();
process.exit(1);
});
program.parse(argv);
};
process.on('unhandledRejection', rejection => {
console.error(String(rejection));
process.exit(1);
});
main(process.argv);
-1
View File
@@ -95,7 +95,6 @@ const SKIPPED_PACKAGES = [
'packages/cli',
'packages/codemods',
'packages/create-app',
'packages/docgen',
'packages/e2e-test',
'packages/storybook',
'packages/techdocs-cli',