diff --git a/src/index.js b/src/index.js index 6f35b9b..95f963e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,51 +1,68 @@ import * as _ from 'lodash'; import * as reactDocs from 'react-docgen'; import isReactComponentClass from './isReactComponentClass'; +import isStatelessComponent from './isStatelessComponent'; import * as p from 'path'; -export default function({types: t}) { +export default function ({types: t}) { return { visitor: { Class(path, state) { - const { - node, - scope, - } = path; - - if(isReactComponentClass(path)){ - injectReactDocgenInfo(path, this.file.code, t); - injectDocgenGlobal(path, state, t); + if(isReactComponentClass(path)) { + const className = path.node.id.name; + injectReactDocgenInfo(className, path, state, this.file.code, t); } }, - 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path) { + 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'(path, state) { + if(isStatelessComponent(path)) { + const className = path.parentPath.node.id.name; + injectReactDocgenInfo(className, path, state, this.file.code, t); + } }, } }; } +function alreadyVisited(program, t) { + return program.node.body.some(node => { + if(t.isExpressionStatement(node) && + t.isAssignmentExpression(node.expression) && + t.isMemberExpression(node.expression.left) + ) { + return node.expression.left.property.name === '__docgenInfo'; + } + return false; + }); +} + -function injectReactDocgenInfo(path, code, t) { - if(!t.isProgram(path.parentPath.node)) { +function injectReactDocgenInfo(className, path, state, code, t) { + const program = path.scope.getProgramParent().path; + + if(alreadyVisited(program, t)) { return; } + const docObj = reactDocs.parse(code); const docNode = buildObjectExpression(docObj, t); const docgenInfo = t.expressionStatement( t.assignmentExpression( "=", - t.memberExpression(t.identifier(path.node.id.name), t.identifier('__docgenInfo')), + t.memberExpression(t.identifier(className), t.identifier('__docgenInfo')), docNode )); - path.parentPath.pushContainer('body', docgenInfo); - + program.pushContainer('body', docgenInfo); + injectDocgenGlobal(className, path, state, t); } -function injectDocgenGlobal(path, state, t) { - if(!state.opts.DOC_GEN_GLOBAL || !t.isProgram(path.parentPath.node)) { +function injectDocgenGlobal(className, path, state, t) { + const program = path.scope.getProgramParent().path; + + if(!state.opts.DOC_GEN_GLOBAL) { return; } + const globalName = state.opts.DOC_GEN_GLOBAL; - const className = path.node.id.name; const filePath = p.relative('./', p.resolve('./', path.hub.file.opts.filename)); const globalNode = t.ifStatement( t.binaryExpression( @@ -86,7 +103,7 @@ function injectDocgenGlobal(path, state, t) { ) ]) ); - path.parentPath.pushContainer('body', globalNode); + program.pushContainer('body', globalNode); } function buildObjectExpression(obj, t){ @@ -109,7 +126,7 @@ function buildObjectExpression(obj, t){ return t.numericLiteral(obj); } else if (_.isArray(obj)) { const children = []; - obj.forEach(function(val) { + obj.forEach(function (val) { children.push(buildObjectExpression(val, t)); }); return t.ArrayExpression(children); diff --git a/src/isStatelessComponent.js b/src/isStatelessComponent.js new file mode 100644 index 0000000..5d950ba --- /dev/null +++ b/src/isStatelessComponent.js @@ -0,0 +1,91 @@ +function isJSXElementOrReactCreateElement(node) { + const { + type, + callee, + } = node; + + if (type === 'JSXElement') { + return true; + } + + if (callee && callee.object && callee.object.name === 'React' && + callee.property.name === 'createElement') { + return true; + } + + return false; +} + +function isReturningJSXElement(path) { + /** + * Early exit for ArrowFunctionExpressions, there is no ReturnStatement node. + */ + if (path.node.init && path.node.init.body && isJSXElementOrReactCreateElement(path.node.init.body)) { + return true; + } + + let visited = false; + + path.traverse({ + ReturnStatement(path2) { + // We have already found what we are looking for. + if (visited) { + return; + } + + const argument = path2.get('argument'); + + // Nothing is returned + if (!argument.node) { + return; + } + + if (isJSXElementOrReactCreateElement(argument.node)) { + visited = true; + return; + } + + if (argument.node.type === 'CallExpression') { + const name = argument.get('callee').node.name; + const binding = path.scope.getBinding(name); + + if (!binding) { + return; + } + + if (isReturningJSXElement(binding.path)) { + visited = true; + } + } + }, + }); + + return visited; +} + +const validPossibleStatelessComponentTypes = [ + 'Property', + 'VariableDeclarator', + 'FunctionDeclaration', + 'ArrowFunctionExpression', +]; + +/** + * Returns `true` if the path represents a function which returns a JSXElement + */ +export default function isStatelessComponent(path) { + const node = path.node; + + if (validPossibleStatelessComponentTypes.indexOf(node.type) === -1) { + return false; + } + + if(path.get('body').get('type').node == 'JSXElement') { + return true; + } + if (isReturningJSXElement(path)) { + return true; + } + + return false; +} diff --git a/test/fixtures/case3/actual.js b/test/fixtures/case3/actual.js new file mode 100644 index 0000000..92f7170 --- /dev/null +++ b/test/fixtures/case3/actual.js @@ -0,0 +1,27 @@ +import React from 'react'; + +const Button = ({ children, onClick, style = {} }) => ( + +); + +Button.propTypes = { + children: React.PropTypes.string.isRequired, + onClick: React.PropTypes.func, + style: React.PropTypes.object, +}; + +export default Button; + +let A; +A = [1,2,2,2]; + +function abc() { + let c = function cef() { + A = 'str'; + }; +} diff --git a/test/fixtures/case3/expected.js b/test/fixtures/case3/expected.js new file mode 100644 index 0000000..432e988 --- /dev/null +++ b/test/fixtures/case3/expected.js @@ -0,0 +1,79 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _react = require('react'); + +var _react2 = _interopRequireDefault(_react); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var Button = function Button(_ref) { + var children = _ref.children; + var onClick = _ref.onClick; + var _ref$style = _ref.style; + var style = _ref$style === undefined ? {} : _ref$style; + return _react2.default.createElement( + 'button', + { + style: {}, + onClick: onClick + }, + children + ); +}; + +Button.propTypes = { + children: _react2.default.PropTypes.string.isRequired, + onClick: _react2.default.PropTypes.func, + style: _react2.default.PropTypes.object +}; + +exports.default = Button; + + +var A = void 0; +A = [1, 2, 2, 2]; + +function abc() { + var c = function cef() { + A = 'str'; + }; +} +Button.__docgenInfo = { + description: '', + methods: [], + props: { + children: { + type: { + name: 'string' + }, + required: true, + description: '' + }, + onClick: { + type: { + name: 'func' + }, + required: false, + description: '' + }, + style: { + type: { + name: 'object' + }, + required: false, + description: '' + } + } +}; + +if (typeof STORYBOOK_REACT_CLASSES !== 'undefined') { + STORYBOOK_REACT_CLASSES['test/fixtures/case3/actual.js'] = { + name: 'Button', + docgenInfo: Button.__docgenInfo, + path: 'test/fixtures/case3/actual.js' + }; +} diff --git a/test/fixtures/case4/actual.js b/test/fixtures/case4/actual.js new file mode 100644 index 0000000..25c7430 --- /dev/null +++ b/test/fixtures/case4/actual.js @@ -0,0 +1,29 @@ +import React from 'react'; + +const Button = ({ children, onClick, style = {} }) => { + return ( + + ); +}; + +Button.propTypes = { + children: React.PropTypes.string.isRequired, + onClick: React.PropTypes.func, + style: React.PropTypes.object, +}; + +export default Button; + +let A; +A = [1,2,2,2]; + +function abc() { + let c = function cef() { + A = 'str'; + }; +} diff --git a/test/fixtures/case4/expected.js b/test/fixtures/case4/expected.js new file mode 100644 index 0000000..615d77e --- /dev/null +++ b/test/fixtures/case4/expected.js @@ -0,0 +1,80 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); + +var _react = require('react'); + +var _react2 = _interopRequireDefault(_react); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +var Button = function Button(_ref) { + var children = _ref.children; + var onClick = _ref.onClick; + var _ref$style = _ref.style; + var style = _ref$style === undefined ? {} : _ref$style; + + return _react2.default.createElement( + 'button', + { + style: {}, + onClick: onClick + }, + children + ); +}; + +Button.propTypes = { + children: _react2.default.PropTypes.string.isRequired, + onClick: _react2.default.PropTypes.func, + style: _react2.default.PropTypes.object +}; + +exports.default = Button; + + +var A = void 0; +A = [1, 2, 2, 2]; + +function abc() { + var c = function cef() { + A = 'str'; + }; +} +Button.__docgenInfo = { + description: '', + methods: [], + props: { + children: { + type: { + name: 'string' + }, + required: true, + description: '' + }, + onClick: { + type: { + name: 'func' + }, + required: false, + description: '' + }, + style: { + type: { + name: 'object' + }, + required: false, + description: '' + } + } +}; + +if (typeof STORYBOOK_REACT_CLASSES !== 'undefined') { + STORYBOOK_REACT_CLASSES['test/fixtures/case4/actual.js'] = { + name: 'Button', + docgenInfo: Button.__docgenInfo, + path: 'test/fixtures/case4/actual.js' + }; +} diff --git a/test/fixtures/example/.babelrc b/test/fixtures/example/.babelrc deleted file mode 100644 index e624888..0000000 --- a/test/fixtures/example/.babelrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "plugins": [ - ["../../../src"] - ] -} diff --git a/test/index.js b/test/index.js index 7bc1a6e..a476edf 100644 --- a/test/index.js +++ b/test/index.js @@ -26,12 +26,12 @@ describe('Add propType doc to react classes', () => { ], babelrc: false }; + const actual = transformFileSync(actualPath, options).code; //fs.writeFileSync(path.join(fixtureDir, 'actual-output.js'), actual); const expected = fs.readFileSync( path.join(fixtureDir, 'expected.js') ).toString(); - assert.equal(trim(actual), trim(expected)); }); });