diff --git a/.eslintrc.js b/.eslintrc.js index 94dafdd..a669572 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,4 +3,4 @@ module.exports = { rules: { 'func-names': 0 } -}; \ No newline at end of file +}; diff --git a/gulp-tasks.js b/gulp-tasks.js index 1d3ea22..0f10418 100644 --- a/gulp-tasks.js +++ b/gulp-tasks.js @@ -73,7 +73,7 @@ function createTasks(packageName, options = {}) { const typingFiles = components .pipe(clone()) - .pipe(componentTypings(packageName)); + .pipe(componentTypings()); return es .merge(packages, typingFiles) diff --git a/gulp/component-typings.js b/gulp/component-typings.js index 3ea884d..91d6ff9 100644 --- a/gulp/component-typings.js +++ b/gulp/component-typings.js @@ -9,14 +9,14 @@ const getReactComponentDefinitionsContent = require('../typings/index'); * @param {String} libraryName Library name, will be used in typescript declarations. * @returns {Function} */ -function componentTypings(libraryName) { +function componentTypings() { function transform(file, encoding, callback) { if (file.isStream()) { callback(); return; } const componentName = path.parse(file.path).name; - getReactComponentDefinitionsContent(file.path, libraryName).then((definitionsContent) => { + getReactComponentDefinitionsContent(file.path).then((definitionsContent) => { if (!definitionsContent) { console.warn(`Unable to create typings for ${file.path}`); return callback(null); diff --git a/package.json b/package.json index dc0afd4..6f52251 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "gulp-typescript": "^3.1.5", "react-component-info": "^1.0.0", "react-docgen": "^2.15.0", + "react-docgen-displayname-handler": "^1.0.0", "recast": "^0.12.5", "resolve": "^1.3.3", "through2": "^2.0.3", diff --git a/typings/create-resolver.js b/react-doc/create-resolver.js similarity index 64% rename from typings/create-resolver.js rename to react-doc/create-resolver.js index 32d672b..0875706 100644 --- a/typings/create-resolver.js +++ b/react-doc/create-resolver.js @@ -1,20 +1,20 @@ +const path = require('path'); const babylon = require('react-docgen/dist/babylon').default; +const resolve = require('resolve').sync; const isExportsOrModuleAssignment = require('react-docgen/dist/utils/isExportsOrModuleAssignment').default; const isReactComponentClass = require('react-docgen/dist/utils/isReactComponentClass').default; const isReactCreateClassCall = require('react-docgen/dist/utils/isReactCreateClassCall').default; const isStatelessComponent = require('react-docgen/dist/utils/isStatelessComponent').default; const normalizeClassDefinition = require('react-docgen/dist/utils/normalizeClassDefinition').default; -const resolveExportDeclaration = require('./resolve-export-declaration'); const resolveToValue = require('react-docgen/dist/utils/resolveToValue').default; const resolveHOC = require('react-docgen/dist/utils/resolveHOC').default; +const resolveToModule = require('react-docgen/dist/utils/resolveToModule').default; const getSourceFileContent = require('./get-source-file-content'); +const resolveExportDeclaration = require('./docgen/resolve-export-declaration'); +const isDecoratedBy = require('./docgen/is-decorated-by'); const ERROR_MULTIPLE_DEFINITIONS = 'Multiple exported component definitions found.'; -function ignore() { - return false; -} - function isComponentDefinition(path) { return isReactCreateClassCall(path) || isReactComponentClass(path) || isStatelessComponent(path); } @@ -26,7 +26,7 @@ function resolveDefinition(definition, types) { if (types.ObjectExpression.check(resolvedPath.node)) { return resolvedPath; } - } else if (isReactComponentClass(definition)) { + } else if (isReactComponentClass(definition) || isDecoratedBy(definition, 'cn')) { normalizeClassDefinition(definition); return definition; } else if (isStatelessComponent(definition)) { @@ -35,12 +35,9 @@ function resolveDefinition(definition, types) { return null; } -function findExportedComponentDefinition( - ast, - recast, - filePath -) { +function findExportedComponentDefinition(ast, recast, filePath) { const types = recast.types.namedTypes; + const importedModules = {}; let definition; function exportDeclaration(nodePath) { @@ -49,16 +46,26 @@ function findExportedComponentDefinition( .reduce((acc, def) => { if (isComponentDefinition(def)) { acc.push(def); - } else { - const resolved = resolveToValue(resolveHOC(def)); - if (isComponentDefinition(resolved)) { - acc.push(resolved); - return acc; - } + return acc; + } + + const resolved = resolveToValue(resolveHOC(def)); + if (isComponentDefinition(resolved)) { + acc.push(resolved); + return acc; + } + + if (isDecoratedBy(def, 'cn') && def.get('superClass')) { + const superClass = def.get('superClass'); + const src = getSourceFileContent(importedModules[superClass.value.name], filePath); + filePath = src.filePath; // update file path, so we can correctly resolve imports + linkedFile = recast.parse(src.content, { esprima: babylon }); + return acc; } if (def.get('value') && def.get('value').value) { // if we found reexported file - parse it with recast and return const src = getSourceFileContent(def.get('value').value, filePath); + filePath = src.filePath; // update file path, so we can correctly resolve imports linkedFile = recast.parse(src.content, { esprima: babylon }); } return acc; @@ -80,23 +87,27 @@ function findExportedComponentDefinition( } recast.visit(ast, { - visitFunctionDeclaration: ignore, - visitFunctionExpression: ignore, - visitClassDeclaration: ignore, - visitClassExpression: ignore, - visitIfStatement: ignore, - visitWithStatement: ignore, - visitSwitchStatement: ignore, - visitCatchCause: ignore, - visitWhileStatement: ignore, - visitDoWhileStatement: ignore, - visitForStatement: ignore, - visitForInStatement: ignore, - visitExportDeclaration: exportDeclaration, visitExportNamedDeclaration: exportDeclaration, visitExportDefaultDeclaration: exportDeclaration, + visitImportDeclaration(node) { + const specifiers = node.value.specifiers; + const moduleName = resolveToModule(node); + + if (moduleName !== 'react' && moduleName !== 'prop-types') { + // resolve path to file here, because this is the only place where we've got actual source path + // but skip `react` and `prop-types` modules, because dockgen winn not be able to detect types otherwise + node.value.source.value = resolve( + node.value.source.value, + { basedir: path.dirname(filePath), extensions: ['.js', '.jsx'] } + ); + } + if (specifiers && specifiers.length > 0) { + importedModules[specifiers[0].local.name] = node.value.source.value; + } + return false; + }, visitAssignmentExpression(path) { // Ignore anything that is not `exports.X = ...;` or // `module.exports = ...;` diff --git a/react-doc/docgen/is-decorated-by.js b/react-doc/docgen/is-decorated-by.js new file mode 100644 index 0000000..001f6cc --- /dev/null +++ b/react-doc/docgen/is-decorated-by.js @@ -0,0 +1,10 @@ +function isDecoratedBy(path, decoratorName = 'cn') { + const decorators = path.get('decorators'); + if (decorators && decorators.value) { + return decorators.value + .some(decorator => decorator.expression.callee && decorator.expression.callee.name === decoratorName); + } + return false; +} + +module.exports = isDecoratedBy; diff --git a/typings/resolve-export-declaration.js b/react-doc/docgen/resolve-export-declaration.js similarity index 100% rename from typings/resolve-export-declaration.js rename to react-doc/docgen/resolve-export-declaration.js diff --git a/typings/get-source-file-content.js b/react-doc/get-source-file-content.js similarity index 100% rename from typings/get-source-file-content.js rename to react-doc/get-source-file-content.js diff --git a/react-doc/index.js b/react-doc/index.js new file mode 100644 index 0000000..910dfcd --- /dev/null +++ b/react-doc/index.js @@ -0,0 +1,48 @@ +const path = require('path'); +const reactDocGen = require('react-docgen'); +const getSourceFileContent = require('./get-source-file-content'); +const createResolver = require('./create-resolver'); +const createDisplayNameHandler = require('react-docgen-displayname-handler').createDisplayNameHandler; + +const documentation = {}; +const defaultHandlers = [ + reactDocGen.handlers.propTypeHandler, + reactDocGen.handlers.propTypeCompositionHandler, + reactDocGen.handlers.propDocBlockHandler, + reactDocGen.handlers.flowTypeHandler, + reactDocGen.handlers.flowTypeDocBlockHandler, + reactDocGen.handlers.defaultPropsHandler, + reactDocGen.handlers.componentDocblockHandler, + reactDocGen.handlers.displayNameHandler, + reactDocGen.handlers.componentMethodsHandler, + reactDocGen.handlers.componentMethodsJsDocHandler +]; + +function getReactComponentInfo(filePath, parentPath) { + if (documentation[filePath]) { + return documentation[filePath]; + } + + const src = getSourceFileContent(filePath, parentPath); + const content = src.content; + filePath = src.filePath; + const info = reactDocGen.parse( + content, + createResolver(filePath), + defaultHandlers.concat(createDisplayNameHandler(filePath)) + ); + info.filePath = filePath; + if (info.composes) { + info.composes = info.composes + .map(relativePath => getReactComponentInfo(relativePath, path.dirname(filePath))); + } else { + info.composes = []; + } + // extends props for composed components + const composeProps = info.composes.reduce((prev, item) => Object.assign({}, prev, item.props), {}); + info.props = Object.assign(composeProps || {}, info.props || {}); // own props should have higher priority + documentation[filePath] = info; + return info; +} + +module.exports = getReactComponentInfo; diff --git a/typings/get-react-component-info.js b/typings/get-react-component-info.js deleted file mode 100644 index 2ca9434..0000000 --- a/typings/get-react-component-info.js +++ /dev/null @@ -1,32 +0,0 @@ -const path = require('path'); -const reactDocGen = require('react-docgen'); -const getSourceFileContent = require('./get-source-file-content'); -const createResolver = require('./create-resolver'); - -const documentation = {}; - -function getReactComponentInfo(filePath, parentPath) { - if (documentation[filePath]) { - return documentation[filePath]; - } - - const src = getSourceFileContent(filePath, parentPath); - const content = src.content; - filePath = src.filePath; - const info = reactDocGen.parse(content, createResolver(filePath), undefined); - info.filePath = filePath; - if (info.composes) { - info.composes = info.composes - .map(relativePath => getReactComponentInfo(relativePath, path.dirname(filePath))); - } else { - info.composes = []; - } - // filter public methods - info.methods = info.methods.filter(({ docblock }) => docblock && docblock.indexOf('@public') !== -1); - // extends props for composed components - info.props = info.composes.reduce((prev, item) => Object.assign({}, prev, item.props), info.props || {}); - documentation[filePath] = info; - return info; -} - -module.exports = getReactComponentInfo; diff --git a/typings/index.js b/typings/index.js index 89aeecb..10fb183 100644 --- a/typings/index.js +++ b/typings/index.js @@ -1,15 +1,16 @@ -const path = require('path'); -const getReactComponentInfo = require('./get-react-component-info'); +const getReactComponentInfo = require('../react-doc'); const getReactComponentDefinitionsContent = require('./stringify-component-definition'); const formatTs = require('./format-ts'); -function getFormattedReactComponentDefinitionsContent(filePath, projectName) { +function getFormattedReactComponentDefinitionsContent(filePath) { return new Promise((resolve) => { - const componentName = path.parse(filePath).name; try { const componentInfo = getReactComponentInfo(filePath); - const definitionsContent = getReactComponentDefinitionsContent(componentInfo, componentName, projectName); + // filter public methods + componentInfo.methods = componentInfo.methods + .filter(({ docblock }) => docblock && docblock.indexOf('@public') !== -1); + const definitionsContent = getReactComponentDefinitionsContent(componentInfo); formatTs(definitionsContent) .then(resolve); } catch (e) { diff --git a/typings/stringify-component-definition.js b/typings/stringify-component-definition.js index c11cac1..f4c0d07 100644 --- a/typings/stringify-component-definition.js +++ b/typings/stringify-component-definition.js @@ -1,7 +1,8 @@ /* eslint no-use-before-define: ["error", "nofunc"] */ const upperCamelCase = require('uppercamelcase'); -function stringifyType(type) { +function stringifyType(type, componentName, propName, typeRefs) { + const typeName = `${componentName}${upperCamelCase(propName)}FieldType`; switch (type.name) { case 'string': return 'string'; @@ -14,17 +15,21 @@ function stringifyType(type) { case 'node': return 'ReactNode'; case 'union': - return stringifyUnion(type); + typeRefs.push(`export type ${typeName} = ${stringifyUnion(type, componentName, propName, typeRefs)};`); + return typeName; case 'func': return 'Function'; case 'enum': - return stringifyEnum(type); + typeRefs.push(`export type ${typeName} = ${stringifyEnum(type)};`); + return typeName; case 'arrayOf': - return stringifyArray(type); + return stringifyArray(type, componentName, propName, typeRefs); case 'shape': - return stringifyShape(type); + typeRefs.push(`export type ${typeName} = ${stringifyShape(type, componentName, propName, typeRefs)};`); + return typeName; case 'objectOf': - return stringifyObjectOf(type); + typeRefs.push(`export type ${typeName} = ${stringifyObjectOf(type, componentName, propName, typeRefs)};`); + return typeName; case 'object': return 'object'; case 'any': @@ -37,68 +42,81 @@ function stringifyType(type) { } } -function stringifyArray(type) { - return `Array<${stringifyType(type.value)}>`; +function stringifyArray(type, componentName, propName, typeRefs) { + return `Array<${stringifyType(type.value, componentName, propName, typeRefs)}>`; } function stringifyEnum(type) { return `${type.value.map(({ value }) => value).join(' | ')}`; } -function stringifyUnion(type) { - return `${type.value.map(type => stringifyType(type)).join(' | ')}`; +function stringifyUnion(type, componentName, propName, typeRefs) { + return `${type.value.map(type => stringifyType(type, componentName, propName, typeRefs)).join(' | ')}`; } function stringifyDescription(description, docblock) { return !description ? '' : ` /** * ${description || docblock} - */`; + */\n`; } -function stringifyField(name, type) { +function stringifyField(fieldName, type, componentName, propName, typeRefs) { return ( - `${stringifyDescription(type.description, type.docblock)} - ${name}${type.required ? '' : '?'}: ${stringifyType(type)}` + stringifyDescription(type.description, type.docblock) + // eslint-disable-line prefer-template + `${fieldName}${type.required ? '' : '?'}: ${stringifyType(type, componentName, propName, typeRefs)}` ); } -function stringifyShape(type) { +function stringifyShape(type, componentName, propName, typeRefs) { const fields = type.value; return `{ - ${Object.keys(fields).map(fieldName => stringifyField(fieldName, fields[fieldName])).join(';\n')} + ${Object + .keys(fields) + .map(fieldName => stringifyField(fieldName, fields[fieldName], componentName, propName, typeRefs)) + .join(';\n')} }`; } -function stringifyObjectOf(type) { +function stringifyObjectOf(type, componentName, propName, typeRefs) { const fieldType = type.value; return `{ - [key: string]: ${stringifyType(fieldType)}; + [key: string]: ${stringifyType(fieldType, componentName, propName, typeRefs)}; }`; } -function stringifyComponentDefinition(info, componentModuleName) { - const componentName = upperCamelCase(componentModuleName); - return ( +function stringifyMethod({ name, docblock, params, description }) { + return stringifyDescription(description, docblock) + // eslint-disable-line prefer-template + `${name}(${params.map(({ name }) => `${name}: any`).join(',')}): any;`; +} + +function stringifyComponentDefinition(info) { + const typeRefs = []; // PropType fields typedefs + const propsInterfaceName = `${info.displayName}Props`; + const propsDef = ( ` - import { Component, ReactNode } from 'react'; - - export interface ${componentName}Props { + export interface ${propsInterfaceName} { ${Object.keys(info.props).map((propName) => { const { required, type, description, docblock } = info.props[propName]; + const typeDef = stringifyType(type, info.displayName, propName, typeRefs); return ( - `${stringifyDescription(description, docblock)} - ${propName}${required ? '' : '?'}: ${stringifyType(type)};` + `${stringifyDescription(description, docblock)}${propName}${required ? '' : '?'}: ${typeDef};` ); }).join('')} } + ` + ); + return ( + ` + import { Component, ReactNode } from 'react'; + + ${typeRefs.join('\n')} + + ${propsDef} ${stringifyDescription(info.description, info.docblock)} - export default class ${componentName} extends Component<${componentName}Props, any> { - ${info.methods.map(({ name, docblock, params, description }) => ( - `${stringifyDescription(description, docblock)} - ${name}(${params.map(({ name }) => `${name}: any`).join(',')}): any;` - )).join('')} + export default class ${info.displayName} extends Component<${propsInterfaceName}, any> { + ${info.methods.map(stringifyMethod).join('')} } ` ); diff --git a/yarn.lock b/yarn.lock index 9693129..bcfd700 100644 --- a/yarn.lock +++ b/yarn.lock @@ -264,6 +264,10 @@ ast-types-flow@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" +ast-types@0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.0.tgz#c8721c8747ae4d5b29b929e99c5317b4e8745623" + ast-types@0.9.11: version "0.9.11" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.11.tgz#371177bb59232ff5ceaa1d09ee5cad705b1a5aa9" @@ -1193,9 +1197,9 @@ browserslist@^1.1.1, browserslist@^1.1.3, browserslist@^1.3.6, browserslist@^1.5 caniuse-db "^1.0.30000639" electron-to-chromium "^1.2.7" -bser@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" +bser@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bser/-/bser-1.0.2.tgz#381116970b2a6deea5646dd15dd7278444b56169" dependencies: node-int64 "^0.4.0" @@ -2283,7 +2287,7 @@ espree@^3.4.0: acorn "^5.0.1" acorn-jsx "^3.0.0" -esprima@^2.6.0: +esprima@^2.6.0, esprima@~2.7.1: version "2.7.3" resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" @@ -2479,11 +2483,11 @@ faye-websocket@~0.11.0: dependencies: websocket-driver ">=0.5.1" -fb-watchman@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" +fb-watchman@^1.8.0: + version "1.9.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-1.9.2.tgz#a24cf47827f82d38fb59a69ad70b76e3b6ae7383" dependencies: - bser "^2.0.0" + bser "1.0.2" figures@^1.3.5: version "1.7.0" @@ -5257,6 +5261,12 @@ react-component-info@^1.0.0: react-docgen "^2.13.0" uppercamelcase "^1.1.0" +react-docgen-displayname-handler@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-docgen-displayname-handler/-/react-docgen-displayname-handler-1.0.0.tgz#9fc1b071cfaad01e2546b258382dec33c59e107b" + dependencies: + recast "0.11.12" + react-docgen@^2.13.0, react-docgen@^2.15.0: version "2.15.0" resolved "https://registry.yarnpkg.com/react-docgen/-/react-docgen-2.15.0.tgz#11aced462256e4862b14e6af6e46bdcefacb28bb" @@ -5370,6 +5380,15 @@ readline2@^1.0.1: is-fullwidth-code-point "^1.0.0" mute-stream "0.0.5" +recast@0.11.12: + version "0.11.12" + resolved "https://registry.yarnpkg.com/recast/-/recast-0.11.12.tgz#a79e4d3f82d5d72a82ee177aeaa791e793bbe5d6" + dependencies: + ast-types "0.9.0" + esprima "~2.7.1" + private "~0.1.5" + source-map "~0.5.0" + recast@^0.11.5: version "0.11.22" resolved "https://registry.yarnpkg.com/recast/-/recast-0.11.22.tgz#dedeb18fb001a2bbc6ac34475fda53dfe3d47dfa" @@ -5568,13 +5587,7 @@ resolve-url@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.2.tgz#1f0442c9e0cbb8136e87b9305f932f46c7f28235" - dependencies: - path-parse "^1.0.5" - -resolve@^1.3.3: +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2, resolve@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.3.tgz#655907c3469a8680dc2de3a275a8fdd69691f0e5" dependencies: @@ -5632,12 +5645,12 @@ safe-buffer@^5.0.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" sane@^1.3.3: - version "1.7.0" - resolved "https://registry.yarnpkg.com/sane/-/sane-1.7.0.tgz#b3579bccb45c94cf20355cc81124990dfd346e30" + version "1.6.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-1.6.0.tgz#9610c452307a135d29c1fdfe2547034180c46775" dependencies: anymatch "^1.3.0" exec-sh "^0.2.0" - fb-watchman "^2.0.0" + fb-watchman "^1.8.0" minimatch "^3.0.2" minimist "^1.1.1" walker "~1.0.5"