@alifd/api-extractor
Version:
A tool to help extracting api from React components.
395 lines (337 loc) • 11.5 kB
JavaScript
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const reactDocs = require('react-docgen');
const doctrine = require('doctrine');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const parseJsDoc = require('./parseJsDoc');
function getApiInfos(dir, options, parentMap, staticMethodsWrapper, needDeep) {
const relativePaths = glob.sync('**/*.@(jsx|tsx)', {
cwd: dir,
ignore: 'node_modules/**'
});
return relativePaths.reduce((ret, relativePath) => {
const fullPath = path.join(dir, relativePath);
const apiInfo = extractFile(fullPath, options);
if (apiInfo) {
const parent = parentMap && parentMap[apiInfo.name];
if (parent && needDeep) {
const parentName = parent.name;
const parentPath = parent.path;
if (needDeep && dir === parentPath) {
needDeep = false;
}
const parentApiInfos = getApiInfos(parentPath, {}, parentMap, { methods: [] }, needDeep);
const parentItem = parentApiInfos.find(item => item.name === parentName);
apiInfo.props = Object.assign({}, parentItem.props, apiInfo.props);
}
ret.push(apiInfo);
}
staticMethodsWrapper.methods = staticMethodsWrapper.methods.concat(getExportMethod(fullPath));
return ret;
}, []);
}
/**
*
* @param {String} dir dir path
* @param {Object} options
* @param {Object} parentMap { ${childProps}: { name: ${parentProps}, path: ''}} e.g.
* {
* 'Nav': {
name: 'Menu',
path: 'my-path/src/menu'
},
'Nav.Item': {
name: 'Menu.Item',
path: 'my-path/src/menu'
}
}
*/
function extract(dir, options = {}, parentMap = {}) {
let methodsWrapper = {};
methodsWrapper.methods = [];
const apiInfos = getApiInfos(dir, options, parentMap, methodsWrapper, true);
const staticMethods = methodsWrapper.methods;
if (apiInfos.length) {
let mainAPIInfo;
let mainIndex = apiInfos.findIndex(apiInfo => apiInfo.name.indexOf('.') === -1);
if (mainIndex === -1) {
mainAPIInfo = {
name: apiInfos[0].name.split('.')[0]
};
} else {
mainAPIInfo = apiInfos[mainIndex];
apiInfos.splice(mainIndex, 1);
}
apiInfos.forEach(apiInfo => {
apiInfo.name = apiInfo.name.split('.')[1];
});
const apiInfosWithOrder = apiInfos
.filter(apiInfo => ('order' in apiInfo))
.sort((prev, next) => prev.order - next.order);
const apiInfosWithoutOrder = apiInfos
.filter(apiInfo => !('order' in apiInfo));
mainAPIInfo.subComponents = apiInfosWithOrder.concat(apiInfosWithoutOrder);
mainAPIInfo.methods = (mainAPIInfo.methods || []).concat(staticMethods);
return mainAPIInfo;
}
return null;
}
function extractFile(filePath, options = {}) {
const source = fs.readFileSync(filePath, 'utf8');
let componentInfo;
try {
componentInfo = reactDocs.parse(source, reactDocs.resolver.findExportedComponentDefinition, reactDocs.defaultHandlers);
const composes = componentInfo.composes;
if (composes && composes.length > 0) {
for (var i = 0; i < composes.length; i++) {
let name = composes[i];
if (name.indexOf('.jsx') === -1) {
name = name + '.jsx';
}
const composesPath = path.resolve(path.dirname(filePath), name);
if (fs.existsSync(composesPath)) {
const composesInfo = reactDocs.parse(fs.readFileSync(composesPath, 'utf8'), reactDocs.resolver.findExportedComponentDefinition, reactDocs.defaultHandlers);
componentInfo.props = Object.assign({}, composesInfo.props, componentInfo.props);
}
}
}
} catch (e) {
if (options.fileErrorHandler) {
options.fileErrorHandler(e);
} else {
if (e.message !== 'No suitable component definition found.') {
e.message = `${e.message}\n[${filePath}]`;
throw e;
}
}
}
if (componentInfo && componentInfo.description) {
return filter(componentInfo, options);
}
return null;
}
function getExportMethod(filePath) {
const source = fs.readFileSync(filePath, 'utf8');
const ast = parser.parse(source, {
sourceType: 'module',
plugins: [
'estree',
'jsx',
'objectRestSpread',
'decorators-legacy',
'typescript',
'decorators2',
'classProperties',
'functionBind',
]
});
const methods = [];
traverse(ast, {
enter(path) {
if (path.node.leadingComments &&
path.node.leadingComments[0] &&
path.node.leadingComments[0].type === 'CommentBlock') {
const docblock = path.node.leadingComments[0].value.replace(/\*/g, '');
const docAST = doctrine.parse(docblock);
const exportNameTag = docAST.tags.find(tag => tag.title === 'exportName');
if (exportNameTag && exportNameTag.description) {
const params = docAST.tags.filter(tag => tag.title === 'param' || tag.title === 'params').map(tag => ({
name: tag.name,
description: tag.description,
type: tag.type
}));
const returnsTag = docAST.tags.find(tag => tag.title === 'return' || tag.title === 'returns');
const returns = returnsTag ? {
description: returnsTag.description,
type: returnsTag.type
} : null;
methods.push({
name: exportNameTag.description,
docblock,
description: docAST.description,
modifiers: ['static'],
params,
returns
});
}
}
}
});
return methods;
}
function filter(componentInfo, options) {
const docblock = componentInfo.description;
const ast = doctrine.parse(docblock);
const name = ast.description;
const descriptionTag = ast.tags.find(tag => tag.title === 'description');
const extendsTag = ast.tags.find(tag => tag.title === 'propsExtends');
const orderTag = ast.tags.find(tag => tag.title === 'order');
const order = orderTag ? parseInt(orderTag.description) : null;
let props = [];
if (componentInfo.props) {
props = Object.keys(componentInfo.props).reduce((ret, propName) => {
const prop = componentInfo.props[propName];
if (prop.description) {
const docblock = prop.description;
const ast = parseJsDoc(docblock, prop.type) || {};
ret[propName] = Object.assign(prop, ast);
}
return ret;
}, {});
}
let methods = [];
if (componentInfo.methods) {
methods = componentInfo.methods.reduce((ret, method) => {
if (method.description) {
if (options.md) {
method.md = generateMethodMD(method);
}
ret.push(method);
}
return ret;
}, []);
}
const apiInfo = {
name,
props,
methods
};
if (descriptionTag) {
apiInfo.description = descriptionTag.description;
}
if (extendsTag) {
if (extendsTag.description === 'false') {
apiInfo.propsExtends = false;
} else {
apiInfo.propsExtends = extendsTag.description;
}
}
if (typeof order === 'number') {
apiInfo.order = order;
}
if (options.md) {
apiInfo.propsMD = generatePropsMD(props);
}
return apiInfo;
}
function generatePropsMD(props) {
const hasVersion = Object.keys(props).some(propName => props[propName].version);
if (props && Object.keys(props).length) {
if (hasVersion) return `
参数 | 说明 | 类型 | 默认值 | 版本支持
-----|-----|-----|-----| -----
${generatePropsContentMD(props, true)}`;
return `
参数 | 说明 | 类型 | 默认值
-----|-----|-----|-----
${generatePropsContentMD(props, false)}`;
}
return '';
}
function generatePropsContentMD(props, hasVersion = false) {
return Object.keys(props).reduce((ret, name) => {
return ret + generatePropMD(name, props[name], hasVersion);
}, '');
}
function generatePropMD(name, prop, hasVersion = false) {
if (!prop.description) {
throw new Error(`Can not get description of '${name}' prop`);
}
const description = prop.description.replace(/\n/g, '<br/>');
let extra = '';
if (prop.type.name === 'func') {
extra = `<br/><br/>${generateMethodMD(prop)}`;
} else if (['object', 'objectOf', 'shape'].indexOf(prop.type.name) > -1) {
if (prop.properties.length) {
extra += '<br/><br/>**属性**:<br/>';
extra += prop.properties.map(tag => {
return `*${tag.name}*: {${getType(tag.type)}} ${tag.description}`;
}).join('<br/>');
}
} else if (prop.type.name === 'enum') {
if (Array.isArray(prop.type.value)) {
const hasDesc = prop.type.value.some(item => !!item.description);
extra += `<br/><br/>**可选值**:<br/>${prop.type.value.map(item => {
let desc = '';
if (item.description) {
desc = `(${item.description})`;
}
return `${item.value}${desc}`;
}).join(hasDesc ? '<br/>' : ', ')}`;
}
}
const displayType = getDisplayType(prop.type);
const defaultValue = prop.defaultValue ?
prop.defaultValue.value
.replace(/\n/g, ' ')
.replace(/</g, '<')
.replace(/>/g, '>') :
'-';
const verisonValue = prop.version && !prop.version.computed ? prop.version.value : '';
return `${name} | ${description}${extra} | ${displayType} | ${defaultValue} ${hasVersion ? `| ${verisonValue}` : ''}\n`;
}
function generateMethodMD(method) {
const paramsSign = method.params.reduce((ret, tag) => {
if (tag.name.indexOf('.') === -1) {
ret.push(`${tag.name}: ${getType(tag.type)}`);
}
return ret;
}, []).join(', ');
const returnsSign = method.returns ? getType(method.returns.type) : 'void';
const funcSign = `**签名**:<br/>Function(${paramsSign}) => ${returnsSign}`;
let paramsHTML = '';
if (method.params.length) {
paramsHTML += '<br/>**参数**:<br/>';
paramsHTML += method.params.map(tag => {
return `*${tag.name}*: {${getType(tag.type)}} ${tag.description}`;
}).join('<br/>');
}
let returnsHTML = '';
if (method.returns) {
returnsHTML += '<br/>**返回值**:<br/>';
returnsHTML += `{${getType(method.returns.type)}} ${method.returns.description}<br/>`;
}
return `${funcSign}${paramsHTML}${returnsHTML}`;
}
function getType(type) {
if (!type) {
return 'unknown';
}
if (type.name === 'union') {
return type.value.join('/');
}
return type.name;
}
function getDisplayType(type) {
if (type.name === 'union') {
return type.value.map(getDisplayType).join('/');
}
if (type.name === 'instanceOf') {
return type.value;
}
if (type.name === 'arrayOf') {
return `Array<${getDisplayType(type.value)}>`;
}
return {
any: 'any',
array: 'Array',
bool: 'Boolean',
custom: 'custom',
element: 'ReactElement',
enum: 'Enum',
func: 'Function',
node: 'ReactNode',
number: 'Number',
object: 'Object',
string: 'String',
symbol: 'Symbol',
objectOf: 'Object',
shape: 'Object'
}[type.name] || 'unknown';
}
exports.extract = extract;
exports.extractFile = extractFile;
exports.generatePropsMD = generatePropsMD;
exports.generateMethodMD = generateMethodMD;