docgen: add option to output github markdown

This commit is contained in:
Patrik Oldsberg
2020-07-17 00:22:16 +02:00
parent 38b356f826
commit 9bbd1fed18
6 changed files with 266 additions and 108 deletions
@@ -14,16 +14,8 @@
* limitations under the License.
*/
import { execSync } from 'child_process';
import MarkdownPrinter from './MarkdownPrinter';
import sortSelector from './sortSelector';
import { ApiDoc, InterfaceInfo } from './types';
// TODO(Rugvip): provide through options?
const GH_BASE_URL = 'https://github.com/spotify/backstage';
const COMMIT_SHA =
process.env.COMMIT_SHA || execSync('git rev-parse HEAD').toString('utf8');
import { ApiDoc, InterfaceInfo, MarkdownPrinter } from './types';
/**
* The ApiDocPrinter takes a ApiDoc data structure, typically generated by an ApiDocGenerator,
@@ -36,52 +28,6 @@ export default class ApiDocPrinter {
this.printerFactory = printerFactory;
}
mkLink(
{ file, lineInFile }: { file: string; lineInFile: number },
text?: string,
) {
const linkText = text ?? `${file}:${lineInFile}`;
const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`;
return `[${linkText}](${href}){:target="_blank"}`;
}
print(apiDoc: ApiDoc): Buffer {
const printer = this.printerFactory();
// Remove line numbers from codeblocks
printer.style('.linenodiv{ display: none }');
printer.header(1, apiDoc.id);
const ifInfo = apiDoc.interfaceInfos[0];
if (ifInfo.docs.length) {
for (const doc of ifInfo.docs) {
printer.text(doc);
}
} else {
printer.paragraph(apiDoc.description);
}
printer.header(2, 'API Interface');
printer.paragraph(
`The API interface type is defined at ${this.mkLink(ifInfo)} as ${
ifInfo.name
}.`,
);
printer.paragraph('All members of the interface are listed below.');
this.addInterfaceMembers(printer, ifInfo);
if (ifInfo.dependentTypes.length) {
printer.header(2, 'Types');
this.addInterfaceTypes(printer, ifInfo);
}
return printer.toBuffer();
}
printApiIndex(apiDocs: ApiDoc[]): Buffer {
const printer = this.printerFactory();
@@ -89,8 +35,8 @@ export default class ApiDocPrinter {
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 need to be provided by the app',
'They can be accessed using the `useApi` hook, also provided 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/spotify/backstage/blob/master/docs/api/utility-apis.md.',
);
@@ -99,14 +45,14 @@ export default class ApiDocPrinter {
printer.paragraph(api.description);
const typeLinks = api.interfaceInfos.map(i => `[${i.name}](${i.name})`);
const typeLinks = api.interfaceInfos.map(i => `[${i.name}](./${i.name})`);
printer.paragraph(
`Implemented type${typeLinks.length > 1 ? 's' : ''}: ${typeLinks.join(
', ',
)}`,
);
printer.paragraph(`ApiRef: ${this.mkLink(api, api.name)}`);
printer.paragraph(`ApiRef: ${printer.srcLink(api, api.name)}`);
}
return printer.toBuffer();
@@ -115,18 +61,18 @@ export default class ApiDocPrinter {
printInterface(apiType: InterfaceInfo, apiDocs: ApiDoc[]): Buffer {
const printer = this.printerFactory();
// Remove line numbers from codeblocks
printer.style('.linenodiv{ display: none }');
printer.header(1, apiType.name);
printer.paragraph(
`The ${apiType.name} type is defined at ${this.mkLink(apiType)}.`,
`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 => `[${ad.name}](../#${ad.id})`);
.map(ad => {
const link = printer.indexLink() + printer.headerLink(ad.name, ad.id);
return `[${ad.name}](${link})`;
});
if (apiLinks.length === 1) {
printer.paragraph(
@@ -170,7 +116,7 @@ export default class ApiDocPrinter {
printer.text(doc);
}
printer.codeWithLinks(member);
printer.code(member);
}
}
@@ -183,15 +129,17 @@ export default class ApiDocPrinter {
for (const doc of type.docs) {
printer.text(doc);
}
printer.codeWithLinks(type);
printer.code(type);
printer.paragraph(`Defined at ${this.mkLink(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}](#${path})`);
.map(
({ name, path }) => `[${name}](${printer.headerLink(name, path)})`,
);
if (usageLinks.length) {
printer.paragraph(`Referenced by: ${usageLinks.join(', ')}.`);
@@ -0,0 +1,140 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { MarkdownPrinter, TypeLink } from './types';
import { execSync } from 'child_process';
// TODO(Rugvip): provide through options?
const GH_BASE_URL = 'https://github.com/spotify/backstage';
const COMMIT_SHA =
process.env.COMMIT_SHA ||
execSync('git rev-parse HEAD').toString('utf8').trim();
/**
* The MarkdownPrinter is a helper class for building 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 = heading.replace(/[^a-z0-9]/gi, '-').toLowerCase();
return `#${slug}`;
}
indexLink() {
return './README.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');
}
}
@@ -15,17 +15,27 @@
*/
import sortSelector from './sortSelector';
import { Highlighter, TypeLink } from './types';
import { Highlighter, MarkdownPrinter, TypeLink } from './types';
import { execSync } from 'child_process';
// TODO(Rugvip): provide through options?
const GH_BASE_URL = 'https://github.com/spotify/backstage';
const COMMIT_SHA =
process.env.COMMIT_SHA || execSync('git rev-parse HEAD').toString('utf8');
/**
* The MarkdownPrinter is a helper class for building markdown documents.
*/
export default class MarkdownPrinter {
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 = '') {
@@ -48,6 +58,23 @@ export default class MarkdownPrinter {
this.line();
}
headerLink(heading: string, id?: string): string {
return `#${id ?? heading}`;
}
indexLink() {
return '../';
}
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
@@ -60,14 +87,7 @@ export default class MarkdownPrinter {
this.line();
}
code(syntax: string = '', block: string) {
this.line(`\`\`\`${syntax}`);
this.line(block);
this.line('```');
this.line();
}
codeWithLinks({ text, links = [] }: { text: string; links?: TypeLink[] }) {
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">',
@@ -123,7 +143,8 @@ export default class MarkdownPrinter {
return parts
.map(part => {
if (part.path) {
return `<a href="#${part.path}">${this.escapeText(part.text)}</a>`;
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));
})
+16
View File
@@ -95,3 +95,19 @@ export type ExportedInstance = {
export type Highlighter = {
highlight(test: string): string;
};
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;
indexLink(): string;
srcLink(
{ file, lineInFile }: { file: string; lineInFile: number },
text?: string,
): string;
toBuffer(): Buffer;
};
+57 -28
View File
@@ -20,15 +20,26 @@ 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/ApiDocPrinter';
import ApiDocPrinter from './docgen/ApiDocsPrinter';
import TypescriptHighlighter from './docgen/TypescriptHighlighter';
import MarkdownPrinter from './docgen/MarkdownPrinter';
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(', ')}`,
);
}
export async function generate(targetPath: string) {
const rootDir = resolvePath(__dirname, '../../..');
const srcDir = resolvePath(rootDir, 'packages', 'core-api', 'src');
const targetDir = resolvePath(targetPath);
const docsDir = resolvePath(targetDir, 'docs');
const options = await fs.readJson(resolvePath('../cli/config/tsconfig.json'));
@@ -65,33 +76,51 @@ export async function generate(targetPath: string) {
),
).sort(sortSelector(i => i.name));
const apiDocPrinter = new ApiDocPrinter(
() => new MarkdownPrinter(new TypescriptHighlighter()),
);
if (format === 'techdocs') {
const docsDir = resolvePath(targetDir, 'docs');
await fs.ensureDir(docsDir);
fs.ensureDirSync(docsDir);
const apiDocPrinter = new ApiDocPrinter(
() => new TechdocsMarkdownPrinter(new TypescriptHighlighter()),
);
await fs.writeFile(
joinPath(docsDir, 'README.md'),
apiDocPrinter.printApiIndex(apiDocs),
);
await fs.writeFile(
joinPath(docsDir, 'README.md'),
apiDocPrinter.printApiIndex(apiDocs),
);
for (const apiType of Object.values(apiTypes)) {
const data = apiDocPrinter.printInterface(apiType, 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(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);
}
}
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',
);
}
+5 -1
View File
@@ -30,8 +30,12 @@ const main = (argv: string[]) => {
'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');
await generate(cmd.output ?? './dist', cmd.format ?? 'techdocs');
});
program.on('command:*', () => {