diff --git a/packages/docgen/src/docgen/ApiDocPrinter.ts b/packages/docgen/src/docgen/ApiDocsPrinter.ts similarity index 64% rename from packages/docgen/src/docgen/ApiDocPrinter.ts rename to packages/docgen/src/docgen/ApiDocsPrinter.ts index cd401e41b4..d6c412d498 100644 --- a/packages/docgen/src/docgen/ApiDocPrinter.ts +++ b/packages/docgen/src/docgen/ApiDocsPrinter.ts @@ -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(', ')}.`); diff --git a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts new file mode 100644 index 0000000000..391f50a01d --- /dev/null +++ b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts @@ -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('
');
+    this.line(this.formatWithLinks({ text, links }));
+    this.line('
'); + 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 `${this.escapeText(part.text)}`; + } + return this.escapeText(part.text); + }) + .join(''); + } + + toBuffer(): Buffer { + return Buffer.from(this.str, 'utf8'); + } +} diff --git a/packages/docgen/src/docgen/MarkdownPrinter.ts b/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts similarity index 74% rename from packages/docgen/src/docgen/MarkdownPrinter.ts rename to packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts index fb64863da9..3609e0526d 100644 --- a/packages/docgen/src/docgen/MarkdownPrinter.ts +++ b/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts @@ -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('
'); this.line( '
', @@ -123,7 +143,8 @@ export default class MarkdownPrinter { return parts .map(part => { if (part.path) { - return `${this.escapeText(part.text)}`; + const link = this.headerLink(part.text, part.path); + return `${this.escapeText(part.text)}`; } return this.highlighter.highlight(this.escapeText(part.text)); }) diff --git a/packages/docgen/src/docgen/types.ts b/packages/docgen/src/docgen/types.ts index 7260b8697f..d091e9673e 100644 --- a/packages/docgen/src/docgen/types.ts +++ b/packages/docgen/src/docgen/types.ts @@ -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; +}; diff --git a/packages/docgen/src/generate.ts b/packages/docgen/src/generate.ts index 3d9c1ba282..3587e2776b 100644 --- a/packages/docgen/src/generate.ts +++ b/packages/docgen/src/generate.ts @@ -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', - ); } diff --git a/packages/docgen/src/index.ts b/packages/docgen/src/index.ts index 69f4ec31ad..866299151a 100644 --- a/packages/docgen/src/index.ts +++ b/packages/docgen/src/index.ts @@ -30,8 +30,12 @@ const main = (argv: string[]) => { 'Generate documentation for the declarations in the core-api package', ) .option('--output ', 'Output directory [./dist]') + .option( + '--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:*', () => {