docgen: add option to output github markdown
This commit is contained in:
+16
-68
@@ -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 } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
+32
-11
@@ -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));
|
||||
})
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:*', () => {
|
||||
|
||||
Reference in New Issue
Block a user