diff --git a/README.md b/README.md index 64c1634..19e6b4c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ yarn global add electron-docs-parser cd ~/projects/path/to/electron/repo electron-docs-parser --dir ./ -# You now have ./electron-api.json with the entire Electron API +# You now have ./api.json with the entire Electron API ``` ## How it Works diff --git a/src/DocsParser.ts b/src/DocsParser.ts index fd137ff..f855471 100644 --- a/src/DocsParser.ts +++ b/src/DocsParser.ts @@ -23,9 +23,7 @@ import { headingsAndContent, findConstructorHeader, consumeTypedKeysList, - findProcess, } from './markdown-helpers'; -import { WEBSITE_BASE_DOCS_URL, REPO_BASE_DOCS_URL } from './constants'; import { extendError } from './helpers'; import { parseMethodBlocks, @@ -33,15 +31,35 @@ import { parsePropertyBlocks, parseEventBlocks, } from './block-parsers'; +import { DocsParserPlugin } from './DocsParserPlugin'; export class DocsParser { constructor( - private baseElectronDir: string, + private baseDir: string, private moduleVersion: string, private apiFiles: string[], private structureFiles: string[], + private plugins: DocsParserPlugin[] = [], ) {} + private getRelatveDocsPath = (filePath: string) => + path.relative(this.baseDir, filePath).split('.')[0]; + + private extendAPI = < + T extends + | ModuleDocumentationContainer + | ClassDocumentationContainer + | ElementDocumentationContainer + >( + api: T, + tokens: Token[], + ): T => { + for (const plugin of this.plugins) { + if (plugin.extendAPI) Object.assign(api, plugin.extendAPI(api, tokens) || {}); + } + return api; + }; + private async parseBaseContainers( filePath: string, fileContents: string, @@ -53,7 +71,7 @@ export class DocsParser { isClass: boolean; }[] > { - const relativeDocsPath = path.relative(this.baseElectronDir, filePath).split('.')[0]; + const relativeDocsPath = this.getRelatveDocsPath(filePath); const isStructure = relativeDocsPath.includes('structures'); const headings = headingsAndContent(tokens); expect(headings).to.not.have.lengthOf( @@ -105,11 +123,19 @@ export class DocsParser { extends: extendsMatch ? extendsMatch[1] : undefined, description, slug: path.basename(filePath, '.md'), - websiteUrl: `${WEBSITE_BASE_DOCS_URL}/${relativeDocsPath}`, - repoUrl: `${REPO_BASE_DOCS_URL(this.moduleVersion)}/${relativeDocsPath}.md`, version: this.moduleVersion, }, }); + const added = parsedContainers[parsedContainers.length - 1]; + for (const plugin of this.plugins) + Object.assign( + added.container, + plugin.extendContainer + ? plugin.extendContainer(added.container, { + relativeDocsPath, + }) + : {}, + ); } } @@ -147,7 +173,6 @@ export class DocsParser { 'HTMLElement documentation should not be considered a class', ); } - const electronProcess = findProcess(tokens); if (isClass) { // Instance name will be taken either from an example in a method declaration or the camel // case version of the class name @@ -161,60 +186,78 @@ export class DocsParser { const constructorMethod = _headingToMethodBlock(findConstructorHeader(tokens)); // This is a class - parsed.push({ - ...container, - type: 'Class', - process: electronProcess, - constructorMethod: constructorMethod - ? { - signature: constructorMethod.signature, - parameters: constructorMethod.parameters, - } - : null, - // ### Static Methods - staticMethods: parseMethodBlocks(findContentInsideHeader(tokens, 'Static Methods', 3)), - // ### Static Properties - staticProperties: parsePropertyBlocks( - findContentInsideHeader(tokens, 'Static Properties', 3), - ), - // ### Instance Methods - instanceMethods: parseMethodBlocks( - findContentInsideHeader(tokens, 'Instance Methods', 3), + parsed.push( + this.extendAPI( + { + ...container, + type: 'Class', + constructorMethod: constructorMethod + ? { + signature: constructorMethod.signature, + parameters: constructorMethod.parameters, + } + : null, + // ### Static Methods + staticMethods: parseMethodBlocks( + findContentInsideHeader(tokens, 'Static Methods', 3), + ), + // ### Static Properties + staticProperties: parsePropertyBlocks( + findContentInsideHeader(tokens, 'Static Properties', 3), + ), + // ### Instance Methods + instanceMethods: parseMethodBlocks( + findContentInsideHeader(tokens, 'Instance Methods', 3), + ), + // ### Instance Properties + instanceProperties: parsePropertyBlocks( + findContentInsideHeader(tokens, 'Instance Properties', 3), + ), + // ### Instance Events + instanceEvents: parseEventBlocks( + findContentInsideHeader(tokens, 'Instance Events', 3), + ), + instanceName, + }, + tokens, ), - // ### Instance Properties - instanceProperties: parsePropertyBlocks( - findContentInsideHeader(tokens, 'Instance Properties', 3), - ), - // ### Instance Events - instanceEvents: parseEventBlocks(findContentInsideHeader(tokens, 'Instance Events', 3)), - instanceName, - }); + ); } else { // This is a module if (isElement) { - parsed.push({ - ...container, - type: 'Element', - process: electronProcess, - // ## Methods - methods: parseMethodBlocks(findContentInsideHeader(tokens, 'Methods', 2)), - // ## Properties - properties: parsePropertyBlocks(findContentInsideHeader(tokens, 'Tag Attributes', 2)), - // ## Events - events: parseEventBlocks(findContentInsideHeader(tokens, 'DOM Events', 2)), - }); + parsed.push( + this.extendAPI( + { + ...container, + type: 'Element', + // ## Methods + methods: parseMethodBlocks(findContentInsideHeader(tokens, 'Methods', 2)), + // ## Properties + properties: parsePropertyBlocks( + findContentInsideHeader(tokens, 'Tag Attributes', 2), + ), + // ## Events + events: parseEventBlocks(findContentInsideHeader(tokens, 'DOM Events', 2)), + }, + tokens, + ), + ); } else { - parsed.push({ - ...container, - type: 'Module', - process: electronProcess, - // ## Methods - methods: parseMethodBlocks(findContentInsideHeader(tokens, 'Methods', 2)), - // ## Properties - properties: parsePropertyBlocks(findContentInsideHeader(tokens, 'Properties', 2)), - // ## Events - events: parseEventBlocks(findContentInsideHeader(tokens, 'Events', 2)), - }); + parsed.push( + this.extendAPI( + { + ...container, + type: 'Module', + // ## Methods + methods: parseMethodBlocks(findContentInsideHeader(tokens, 'Methods', 2)), + // ## Properties + properties: parsePropertyBlocks(findContentInsideHeader(tokens, 'Properties', 2)), + // ## Events + events: parseEventBlocks(findContentInsideHeader(tokens, 'Events', 2)), + }, + tokens, + ), + ); } } } diff --git a/src/DocsParserPlugin.ts b/src/DocsParserPlugin.ts new file mode 100644 index 0000000..da98c25 --- /dev/null +++ b/src/DocsParserPlugin.ts @@ -0,0 +1,26 @@ +import { + StructureDocumentationContainer, + BaseDocumentationContainer, + ClassDocumentationContainer, + ElementDocumentationContainer, + ModuleDocumentationContainer, +} from './ParsedDocumentation'; +import Token = require('markdown-it/lib/token'); + +export interface ExtendOptions { + relativeDocsPath: string; +} + +export abstract class DocsParserPlugin { + constructor(protected readonly options: Options) {} + + abstract extendContainer?( + container: BaseDocumentationContainer, + opts: ExtendOptions, + ): object | void; + + abstract extendAPI?( + api: ClassDocumentationContainer | ElementDocumentationContainer | ModuleDocumentationContainer, + tokens: Token[], + ): object | void; +} diff --git a/src/ParsedDocumentation.ts b/src/ParsedDocumentation.ts index f3ad139..cfb3de2 100644 --- a/src/ParsedDocumentation.ts +++ b/src/ParsedDocumentation.ts @@ -72,16 +72,9 @@ export declare type BaseDocumentationContainer = { description: string; version: string; slug: string; - websiteUrl: string; - repoUrl: string; -}; -export declare type ProcessBlock = { - main: boolean; - renderer: boolean; }; export declare type ModuleDocumentationContainer = { type: 'Module'; - process: ProcessBlock; methods: MethodDocumentationBlock[]; events: EventDocumentationBlock[]; properties: PropertyDocumentationBlock[]; @@ -107,7 +100,6 @@ export declare type StructureDocumentationContainer = { } & BaseDocumentationContainer; export declare type ClassDocumentationContainer = { type: 'Class'; - process: ProcessBlock; constructorMethod: Pick | null; instanceName: string; staticMethods: MethodDocumentationBlock[]; @@ -121,7 +113,6 @@ export declare type ClassDocumentationContainer = { } & BaseDocumentationContainer; export declare type ElementDocumentationContainer = { type: 'Element'; - process: ProcessBlock; constructorMethod?: undefined; methods: MethodDocumentationBlock[]; events: EventDocumentationBlock[]; diff --git a/src/__tests__/markdown-helpers.spec.ts b/src/__tests__/markdown-helpers.spec.ts index 0cfc15b..d08a19d 100644 --- a/src/__tests__/markdown-helpers.spec.ts +++ b/src/__tests__/markdown-helpers.spec.ts @@ -11,7 +11,6 @@ import { getTopLevelGenericType, findFirstHeading, consumeTypedKeysList, - findProcess, } from '../markdown-helpers'; import { DocumentationTag } from '../ParsedDocumentation'; @@ -388,50 +387,4 @@ foo`), ); }); }); - - describe('findProcess()', () => { - it('should be available in main processe only', () => { - var proc = findProcess(getTokens('Process: [Main](../glossary.md#main-process)')); - expect(proc.main).toEqual(true); - expect(proc.renderer).toEqual(false); - }); - - it('should be available in renderer processe only', () => { - var proc = findProcess(getTokens('Process: [Renderer](../glossary.md#renderer-process)')); - expect(proc.main).toEqual(false); - expect(proc.renderer).toEqual(true); - }); - - it('should be available in both processes', () => { - var proc = findProcess( - getTokens( - 'Process: [Main](../glossary.md#main-process), [Renderer](../glossary.md#renderer-process)', - ), - ); - expect(proc.main).toEqual(true); - expect(proc.renderer).toEqual(true); - }); - - it('should be available in both processes', () => { - var proc = findProcess( - getTokens( - 'Process: [Renderer](../glossary.md#renderer-process), [Main](../glossary.md#main-process)', - ), - ); - expect(proc.main).toEqual(true); - expect(proc.renderer).toEqual(true); - }); - - it('should be available in both processes', () => { - var proc = findProcess(getTokens('')); - expect(proc.main).toEqual(true); - expect(proc.renderer).toEqual(true); - }); - - it('should be available in both processes', () => { - var proc = findProcess([]); - expect(proc.main).toEqual(true); - expect(proc.renderer).toEqual(true); - }); - }); }); diff --git a/src/bin.ts b/src/bin.ts index 58ec939..785aad3 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -8,14 +8,20 @@ import pretty from 'pretty-ms'; import { parseDocs } from '.'; import chalk from 'chalk'; +import { DocsParserPlugin } from './DocsParserPlugin'; -const args = minimist(process.argv); +const args = minimist(process.argv, { + string: ['dir', 'outDir', 'outFile'], + boolean: ['help'], +}); const { dir, outDir, help } = args; if (help) { console.info( - chalk.cyan('Usage: electron-docs-parser --dir ../electron [--out-dir ../electron-out]'), + chalk.cyan( + 'Usage: electron-docs-parser --dir ../electron [--out-dir ../electron-out] [--out-file api.json]', + ), ); process.exit(0); } @@ -54,26 +60,47 @@ runner.text = chalk.cyan(`Generating API in directory: ${chalk.yellow(`"${resolv const start = Date.now(); -fs.mkdirp(resolvedOutDir).then(() => - parseDocs({ - baseDirectory: resolvedDir, - moduleVersion: pj.version, - }) - .then(data => - fs.writeJson(path.resolve(resolvedOutDir, './electron-api.json'), data, { - spaces: 2, - }), - ) - .then(() => - runner.succeed( - `${chalk.green('Electron API generated in')} ${chalk.yellow( - `"${resolvedOutDir}"`, - )} took ${chalk.cyan(pretty(Date.now() - start))}`, +const loadPlugins = () => { + if (!args.plugin) return []; + + const plugins: DocsParserPlugin[] = []; + const pluginArgs = (Array.isArray(args.plugin) ? args.plugin : [args.plugin]).map(String); + for (const pluginArg of pluginArgs) { + // Prevent path traversal + if (pluginArg.includes('..')) continue; + + const plugin = require.resolve(path.resolve(__dirname, 'plugins', pluginArg)); + const Class = require(plugin).default; + plugins.push(new Class(args[pluginArg] || {})); + // TODO: Resolve a non-built-in plugin + } + // console.log(args); + // process.exit(0); + return plugins; +}; + +fs.mkdirp(resolvedOutDir) + .then(() => + parseDocs({ + baseDirectory: resolvedDir, + moduleVersion: pj.version, + plugins: loadPlugins(), + }) + .then(data => + fs.writeJson(path.resolve(resolvedOutDir, `./${args.outFile || 'api'}.json`), data, { + spaces: 2, + }), + ) + .then(() => + runner.succeed( + `${chalk.green( + `${pj.productName || pj.name || 'Project'} API generated in`, + )} ${chalk.yellow(`"${resolvedOutDir}"`)} took ${chalk.cyan(pretty(Date.now() - start))}`, + ), ), - ) - .catch(err => { - runner.fail(); - console.error(err); - process.exit(1); - }), -); + ) + .catch(err => { + runner.fail(); + console.error(err); + process.exit(1); + }); diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index b04eea4..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const WEBSITE_BASE_DOCS_URL = 'http://electronjs.org'; -export const REPO_BASE_DOCS_URL = (version: string) => - `https://github.com/electron/electron/blob/${version}/docs/api`; diff --git a/src/index.ts b/src/index.ts index 41a4c86..14ae767 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import { DocsParser } from './DocsParser'; +import { DocsParserPlugin } from './DocsParserPlugin'; type ParseOptions = { baseDirectory: string; moduleVersion: string; + plugins?: DocsParserPlugin[]; }; export async function parseDocs(options: ParseOptions) { @@ -15,6 +17,7 @@ export async function parseDocs(options: ParseOptions) { options.moduleVersion, await getAllMarkdownFiles(electronDocsPath), await getAllMarkdownFiles(path.resolve(electronDocsPath, 'structures')), + options.plugins || [], ); return await parser.parse(); diff --git a/src/markdown-helpers.ts b/src/markdown-helpers.ts index 82134e9..c75bbb4 100644 --- a/src/markdown-helpers.ts +++ b/src/markdown-helpers.ts @@ -6,7 +6,6 @@ import { MethodParameterDocumentation, PossibleStringValue, DocumentationTag, - ProcessBlock, } from './ParsedDocumentation'; const tagMap = { @@ -717,19 +716,3 @@ export const convertListToTypedKeys = (listTokens: Token[]): TypedKeyList => { return unconsumedTypedKeyList(convertNestedListToTypedKeys(list)); }; - -export const findProcess = (tokens: Token[]): ProcessBlock => { - for (const tk of tokens) { - if (tk.type === 'inline' && tk.content.indexOf('Process') === 0) { - const ptks = tk.children.slice(2, tk.children.length - 1); - const procs: ProcessBlock = { main: false, renderer: false }; - for (const ptk of ptks) { - if (ptk.type !== 'text') continue; - if (ptk.content === 'Main') procs.main = true; - if (ptk.content === 'Renderer') procs.renderer = true; - } - return procs; - } - } - return { main: true, renderer: true }; -}; diff --git a/src/plugins/__tests__/electron-process.spec.ts b/src/plugins/__tests__/electron-process.spec.ts new file mode 100644 index 0000000..37e5d87 --- /dev/null +++ b/src/plugins/__tests__/electron-process.spec.ts @@ -0,0 +1,54 @@ +import MarkdownIt from 'markdown-it'; + +import { findProcess } from '../electron-process'; + +const getTokens = (md: string) => { + const markdown = new MarkdownIt(); + return markdown.parse(md, {}); +}; + +describe('findProcess()', () => { + it('should be available in main processe only', () => { + var proc = findProcess(getTokens('Process: [Main](../glossary.md#main-process)')); + expect(proc.main).toEqual(true); + expect(proc.renderer).toEqual(false); + }); + + it('should be available in renderer processe only', () => { + var proc = findProcess(getTokens('Process: [Renderer](../glossary.md#renderer-process)')); + expect(proc.main).toEqual(false); + expect(proc.renderer).toEqual(true); + }); + + it('should be available in both processes', () => { + var proc = findProcess( + getTokens( + 'Process: [Main](../glossary.md#main-process), [Renderer](../glossary.md#renderer-process)', + ), + ); + expect(proc.main).toEqual(true); + expect(proc.renderer).toEqual(true); + }); + + it('should be available in both processes', () => { + var proc = findProcess( + getTokens( + 'Process: [Renderer](../glossary.md#renderer-process), [Main](../glossary.md#main-process)', + ), + ); + expect(proc.main).toEqual(true); + expect(proc.renderer).toEqual(true); + }); + + it('should be available in both processes', () => { + var proc = findProcess(getTokens('')); + expect(proc.main).toEqual(true); + expect(proc.renderer).toEqual(true); + }); + + it('should be available in both processes', () => { + var proc = findProcess([]); + expect(proc.main).toEqual(true); + expect(proc.renderer).toEqual(true); + }); +}); diff --git a/src/plugins/electron-process.ts b/src/plugins/electron-process.ts new file mode 100644 index 0000000..cb40118 --- /dev/null +++ b/src/plugins/electron-process.ts @@ -0,0 +1,52 @@ +import { expect } from 'chai'; + +import { DocsParserPlugin, ExtendOptions } from '../DocsParserPlugin'; +import { + ClassDocumentationContainer, + ElementDocumentationContainer, + ModuleDocumentationContainer, +} from '../ParsedDocumentation'; +import Token = require('markdown-it/lib/token'); + +/** + * This plugin adds a "process" object to all parsed API files that indicate which process + * the API is available in in Electron. + */ + +export interface ElectronProcessPluginOptions {} + +export interface ElectronProcessPluginAPIExtension { + process: { + main: boolean; + renderer: boolean; + }; +} + +export const findProcess = (tokens: Token[]): ElectronProcessPluginAPIExtension['process'] => { + for (const tk of tokens) { + if (tk.type === 'inline' && tk.content.indexOf('Process') === 0) { + const ptks = tk.children.slice(2, tk.children.length - 1); + const procs = { main: false, renderer: false }; + for (const ptk of ptks) { + if (ptk.type !== 'text') continue; + if (ptk.content === 'Main') procs.main = true; + if (ptk.content === 'Renderer') procs.renderer = true; + } + return procs; + } + } + return { main: true, renderer: true }; +}; + +export default class ElectronProcessPlugin extends DocsParserPlugin { + extendContainer: undefined; + + extendAPI( + api: ClassDocumentationContainer | ElementDocumentationContainer | ModuleDocumentationContainer, + tokens: Token[], + ): ElectronProcessPluginAPIExtension { + return { + process: findProcess(tokens), + }; + } +} diff --git a/src/plugins/url-provider.ts b/src/plugins/url-provider.ts new file mode 100644 index 0000000..2debb32 --- /dev/null +++ b/src/plugins/url-provider.ts @@ -0,0 +1,51 @@ +import { expect } from 'chai'; + +import { DocsParserPlugin, ExtendOptions } from '../DocsParserPlugin'; +import { BaseDocumentationContainer } from '../ParsedDocumentation'; + +/** + * This plugin adds "repoUrl" and "websiteURL" to each documention block so that users + * can view the documentation that generated the block in either it's source form or on + * your website + */ + +export interface URLProviderOptions { + // E.g. + websiteDocsBaseURL: string; + websiteDocsURLIncludesVersion?: boolean; + // E.g. https://github.com/electron/electron/blob + repoDocsBaseURL: string; +} + +export interface URLProviderContainerExtension { + websiteUrl: string; + repoUrl: string; +} + +export default class URLProviderPlugin extends DocsParserPlugin { + private getURLInfo = ( + version: string, + relativeDocsPath: string, + ): URLProviderContainerExtension => { + expect( + this.options, + 'should provide all required config to URLProviderPlugin', + ).to.have.property('websiteDocsBaseURL'); + expect( + this.options, + 'should provide all required config to URLProviderPlugin', + ).to.have.property('repoDocsBaseURL'); + return { + websiteUrl: `${this.options.websiteDocsBaseURL}/${ + this.options.websiteDocsURLIncludesVersion ? `${version}/` : '' + }${relativeDocsPath}`, + repoUrl: `${this.options.repoDocsBaseURL}/v${version}/${relativeDocsPath}.md`, + }; + }; + + extendContainer(container: BaseDocumentationContainer, { relativeDocsPath }: ExtendOptions) { + return this.getURLInfo(container.version, relativeDocsPath); + } + + extendAPI: undefined; +}