From ba7021f6f823ae5f6323cdfd686471bc45a17812 Mon Sep 17 00:00:00 2001 From: Felix Kling Date: Thu, 12 Feb 2015 11:51:52 -0800 Subject: [PATCH] Adding react-docgen for documentation generation Usage: cd website/react-docgen && npm install or: cd website/react-docgen && npm install -g ./ --- website/react-docgen/LICENSE | 30 ++ website/react-docgen/PATENTS | 23 + website/react-docgen/README.md | 175 +++++++ website/react-docgen/bin/react-docgen.js | 168 +++++++ website/react-docgen/flow/recast.js | 30 ++ website/react-docgen/lib/Documentation.js | 72 +++ .../lib/ReactDocumentationParser.js | 213 ++++++++ .../ReactDocumentationParser-test.js | 118 +++++ .../componentDocblockHandler-test.js | 96 ++++ .../__tests__/defaultValueHandler-test.js | 81 ++++ .../__tests__/propDocblockHandler-test.js | 189 ++++++++ .../__tests__/propTypeHandler-test.js | 456 ++++++++++++++++++ .../lib/handlers/componentDocblockHandler.js | 39 ++ .../lib/handlers/defaultValueHandler.js | 77 +++ .../lib/handlers/propDocBlockHandler.js | 38 ++ .../lib/handlers/propTypeHandler.js | 255 ++++++++++ website/react-docgen/lib/main.js | 39 ++ .../lib/utils/__tests__/docblock-test.js | 40 ++ website/react-docgen/lib/utils/docblock.js | 60 +++ .../react-docgen/lib/utils/expressionTo.js | 80 +++ .../react-docgen/lib/utils/getNameOrValue.js | 34 ++ .../react-docgen/lib/utils/getPropertyName.js | 32 ++ .../lib/utils/isReactModuleName.js | 28 ++ website/react-docgen/lib/utils/match.js | 40 ++ .../react-docgen/lib/utils/resolveToModule.js | 54 +++ .../react-docgen/lib/utils/resolveToValue.js | 46 ++ website/react-docgen/package.json | 35 ++ website/react-docgen/preprocessor.js | 9 + 28 files changed, 2557 insertions(+) create mode 100644 website/react-docgen/LICENSE create mode 100644 website/react-docgen/PATENTS create mode 100644 website/react-docgen/README.md create mode 100755 website/react-docgen/bin/react-docgen.js create mode 100644 website/react-docgen/flow/recast.js create mode 100644 website/react-docgen/lib/Documentation.js create mode 100644 website/react-docgen/lib/ReactDocumentationParser.js create mode 100644 website/react-docgen/lib/__tests__/ReactDocumentationParser-test.js create mode 100644 website/react-docgen/lib/handlers/__tests__/componentDocblockHandler-test.js create mode 100644 website/react-docgen/lib/handlers/__tests__/defaultValueHandler-test.js create mode 100644 website/react-docgen/lib/handlers/__tests__/propDocblockHandler-test.js create mode 100644 website/react-docgen/lib/handlers/__tests__/propTypeHandler-test.js create mode 100644 website/react-docgen/lib/handlers/componentDocblockHandler.js create mode 100644 website/react-docgen/lib/handlers/defaultValueHandler.js create mode 100644 website/react-docgen/lib/handlers/propDocBlockHandler.js create mode 100644 website/react-docgen/lib/handlers/propTypeHandler.js create mode 100644 website/react-docgen/lib/main.js create mode 100644 website/react-docgen/lib/utils/__tests__/docblock-test.js create mode 100644 website/react-docgen/lib/utils/docblock.js create mode 100644 website/react-docgen/lib/utils/expressionTo.js create mode 100644 website/react-docgen/lib/utils/getNameOrValue.js create mode 100644 website/react-docgen/lib/utils/getPropertyName.js create mode 100644 website/react-docgen/lib/utils/isReactModuleName.js create mode 100644 website/react-docgen/lib/utils/match.js create mode 100644 website/react-docgen/lib/utils/resolveToModule.js create mode 100644 website/react-docgen/lib/utils/resolveToValue.js create mode 100644 website/react-docgen/package.json create mode 100644 website/react-docgen/preprocessor.js diff --git a/website/react-docgen/LICENSE b/website/react-docgen/LICENSE new file mode 100644 index 000000000..17e428880 --- /dev/null +++ b/website/react-docgen/LICENSE @@ -0,0 +1,30 @@ +BSD License + +For React docs generator software + +Copyright (c) 2015, Facebook, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook nor the names of its contributors may be used to + endorse or promote products derived from this software without specific + prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/website/react-docgen/PATENTS b/website/react-docgen/PATENTS new file mode 100644 index 000000000..f8ef30d1d --- /dev/null +++ b/website/react-docgen/PATENTS @@ -0,0 +1,23 @@ +Additional Grant of Patent Rights + +"Software" means the React docs generator software distributed by Facebook, Inc. + +Facebook hereby grants you a perpetual, worldwide, royalty-free, non-exclusive, +irrevocable (subject to the termination provision below) license under any +rights in any patent claims owned by Facebook, to make, have made, use, sell, +offer to sell, import, and otherwise transfer the Software. For avoidance of +doubt, no license is granted under Facebook’s rights in any patent claims that +are infringed by (i) modifications to the Software made by you or a third party, +or (ii) the Software in combination with any software or other technology +provided by you or a third party. + +The license granted hereunder will terminate, automatically and without notice, +for anyone that makes any claim (including by filing any lawsuit, assertion or +other action) alleging (a) direct, indirect, or contributory infringement or +inducement to infringe any patent: (i) by Facebook or any of its subsidiaries or +affiliates, whether or not such claim is related to the Software, (ii) by any +party if such claim arises in whole or in part from any software, product or +service of Facebook or any of its subsidiaries or affiliates, whether or not +such claim is related to the Software, or (iii) by any party relating to the +Software; or (b) that any right in any patent claim of Facebook is invalid or +unenforceable. diff --git a/website/react-docgen/README.md b/website/react-docgen/README.md new file mode 100644 index 000000000..b9c7b10f0 --- /dev/null +++ b/website/react-docgen/README.md @@ -0,0 +1,175 @@ + # react-docs-generator + +`react-docs-generator` extracts information from React components with which +you can generate documentation for those components. + +It uses esprima-fb to parse the provided files into an AST, looks for React +component definitions, and inspects the `propTypes` and `getDefaultProps` +declarations. The output is a JSON blob with the extracted information. + +Note that component definitions must follow certain guidelines in order to be +analyzable by this tool. We will work towards less strict guidelines, but there +is a limit to what is statically analyzable. + +## Install + +Install the module directly from npm: + +``` +npm install -g react-docs-generator +``` + +## CLI + +Installing the module adds a `react-docs` executable which allows you do convert +a single file, multiple files or an input stream. We are trying to make the +executable as versatile as possible so that it can be integrated into many +workflows. + +``` +Usage: react-docs [path]... [options] + +path A component file or directory. If no path is provided it reads from stdin. + +Options: + -o FILE, --out FILE store extracted information in FILE + --pretty pretty print JSON + -x, --extension File extensions to consider. Repeat to define multiple extensions. Default: [js,jsx] + -i, --ignore Folders to ignore. Default: [node_modules,__tests__] + +Extract meta information from React components. +If a directory is passed, it is recursively traversed. +``` + +## API + +The tool can also be used programmatically to extract component information: + +```js +var reactDocs = require('react-docs-generator'); +var componentInfo reactDocs.parseSource(src); +``` + +## Guidelines + +- Modules have to export a single component, and only that component is + analyzed. +- `propTypes` must be an object literal or resolve to an object literal in the + same file. +- The `return` statement in `getDefaultProps` must consist of an object literal. + +## Example + +For the following component + +```js +var React = require('react'); + +/** + * General component description. + */ +var Component = React.createClass({ + propTypes: { + /** + * Description of prop "foo". + */ + foo: React.PropTypes.number, + /** + * Description of prop "bar" (a custom validation function). + */ + bar: function(props, propName, componentName) { + // ... + }, + baz: React.PropTypes.oneOfType([ + React.PropTypes.number, + React.PropTypes.string + ]), + }, + + getDefaultProps: function() { + return { + foo: 42, + bar: 21 + }; + }, + + render: function() { + // ... + } +}); + +module.exports = Component; +``` + +we are getting this output: + +``` +{ + "props": { + "foo": { + "type": { + "name": "number" + }, + "required": false, + "description": "Description of prop \"foo\".", + "defaultValue": { + "value": "42", + "computed": false + } + }, + "bar": { + "type": { + "name": "custom" + }, + "required": false, + "description": "Description of prop \"bar\" (a custom validation function).", + "defaultValue": { + "value": "21", + "computed": false + } + }, + "baz": { + "type": { + "name": "union", + "value": [ + { + "name": "number" + }, + { + "name": "string" + } + ] + }, + "required": false, + "description": "" + } + }, + "description": "General component description." +} +``` + +## Result data structure + +The structure of the JSON blob / JavaScript object is as follows: + +``` +{ + "description": string + "props": { + "": { + "type": { + "name": "", + ["value": ] + }, + "required": boolean, + "description": string, + ["defaultValue": { + "value": number | string, + "computed": boolean + }] + }, + ... + }, + ["composes": ] +} +``` diff --git a/website/react-docgen/bin/react-docgen.js b/website/react-docgen/bin/react-docgen.js new file mode 100755 index 000000000..8fb924085 --- /dev/null +++ b/website/react-docgen/bin/react-docgen.js @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +var argv = require('nomnom') + .script('react-docs') + .help( + 'Extract meta information from React components.\n' + + 'If a directory is passed, it is recursively traversed.' + ) + .options({ + path: { + position: 0, + help: 'A component file or directory. If no path is provided it reads from stdin.', + metavar: 'PATH', + list: true + }, + out: { + abbr: 'o', + help: 'store extracted information in FILE', + metavar: 'FILE' + }, + pretty: { + help: 'pretty print JSON', + flag: true + }, + extension: { + abbr: 'x', + help: 'File extensions to consider. Repeat to define multiple extensions. Default:', + list: true, + default: ['js', 'jsx'] + }, + ignoreDir: { + abbr: 'i', + full: 'ignore', + help: 'Folders to ignore. Default:', + list: true, + default: ['node_modules', '__tests__'] + } + }) + .parse(); + +var async = require('async'); +var dir = require('node-dir'); +var fs = require('fs'); +var parser = require('../dist/main.js'); + +var output = argv.o; +var paths = argv.path; +var extensions = new RegExp('\\.(?:' + argv.extension.join('|') + ')$'); +var ignoreDir = argv.ignoreDir; + +function writeError(msg, path) { + if (path) { + process.stderr.write('Error with path "' + path + '": '); + } + process.stderr.write(msg + '\n'); +} + +function exitWithError(error) { + writeError(error); + process.exit(1); +} + +function exitWithResult(result) { + result = argv.pretty ? + JSON.stringify(result, null, 2) : + JSON.stringify(result); + if (argv.o) { + fs.writeFileSync(argv.o, result); + } else { + process.stdout.write(result + '\n'); + } + process.exit(0); +} + +/** + * 1. No files passed, consume input stream + */ +if (paths.length === 0) { + var source = ''; + process.stdin.setEncoding('utf8'); + process.stdin.resume(); + var timer = setTimeout(function() { + process.stderr.write('Still waiting for std input...'); + }, 5000); + process.stdin.on('data', function (chunk) { + clearTimeout(timer); + source += chunk; + }); + process.stdin.on('end', function () { + exitWithResult(parser.parseSource(source)); + }); +} + +function traverseDir(path, result, done) { + dir.readFiles( + path, + { + match: extensions, + excludeDir: ignoreDir + }, + function(error, content, filename, next) { + if (error) { + exitWithError(error); + } + try { + result[filename] = parser.parseSource(content); + } catch(error) { + writeError(error, path); + } + next(); + }, + function(error) { + if (error) { + writeError(error); + } + done(); + } + ); +} + +/** + * 2. Paths are passed. + */ +var result = Object.create(null); +async.eachSeries(paths, function(path, done) { + fs.stat(path, function(error, stats) { + if (error) { + writeError(error, path); + done(); + return; + } + if (stats.isDirectory()) { + traverseDir(path, result, done); + } + else { + try { + result[path] = parser.parseSource(fs.readFileSync(path)); + } catch(error) { + writeError(error, path); + } + finally { + done(); + } + } + }); +}, function() { + var resultsPaths = Object.keys(result); + if (resultsPaths.length === 0) { + // we must have gotten an error + process.exit(1); + } + if (paths.length === 1) { // a single path? + fs.stat(paths[0], function(error, stats) { + exitWithResult(stats.isDirectory() ? result : result[resultsPaths[0]]); + }); + } else { + exitWithResult(result); + } +}); diff --git a/website/react-docgen/flow/recast.js b/website/react-docgen/flow/recast.js new file mode 100644 index 000000000..8d87bb65c --- /dev/null +++ b/website/react-docgen/flow/recast.js @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * A minimal set of declarations to make flow work with the recast API. + */ + +type ASTNode = Object; + +declare class Scope { + lookup(name: string): ?Scope; + getBindings(): Object>; +} + +declare class NodePath { + node: ASTNode; + parent: NodePath; + scope: Scope; + + get(...x: (string|number)): NodePath; + each(f: (p: NodePath) => void): void; + map(f: (p: NodePath) => T): Array; +} diff --git a/website/react-docgen/lib/Documentation.js b/website/react-docgen/lib/Documentation.js new file mode 100644 index 000000000..d4f9bc2d0 --- /dev/null +++ b/website/react-docgen/lib/Documentation.js @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +type PropDescriptor = { + type?: { + name: string; + value?: any; + }; + required?: boolean; + defaultValue?: any; + description?: string; +}; + +class Documentation { + _props: Object; + _description: string; + _composes: Array; + + constructor() { + this._props = {}; + this._description = ''; + this._composes = []; + } + + addComposes(moduleName: string) { + if (this._composes.indexOf(moduleName) === -1) { + this._composes.push(moduleName); + } + } + + getDescription(): string { + return this._description; + } + + setDescription(description: string): void { + this._description = description; + } + + getPropDescriptor(propName: string): PropDescriptor { + var propDescriptor = this._props[propName]; + if (!propDescriptor) { + propDescriptor = this._props[propName] = {}; + } + return propDescriptor; + } + + toObject(): Object { + var obj = { + description: this._description, + props: this._props + }; + + if (this._composes.length) { + obj.composes = this._composes; + } + return obj; + } +} + +module.exports = Documentation; diff --git a/website/react-docgen/lib/ReactDocumentationParser.js b/website/react-docgen/lib/ReactDocumentationParser.js new file mode 100644 index 000000000..8e73d47d7 --- /dev/null +++ b/website/react-docgen/lib/ReactDocumentationParser.js @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +/** + * How this parser works: + * + * 1. For each given file path do: + * + * a. Find component definition + * -. Find the rvalue module.exports assignment. + * Otherwise inspect assignments to exports. If there are multiple + * components that are exported, we don't continue with parsing the file. + * -. If the previous step results in a variable name, resolve it. + * -. Extract the object literal from the React.createClass call. + * + * b. Execute definition handlers (handlers working with the object + * expression). + * + * c. For each property of the definition object, execute the registered + * callbacks, if they are eligible for this property. + * + * 2. Return the aggregated results + */ + +type Handler = (documentation: Documentation, path: NodePath) => void; + +var Documentation = require('./Documentation'); + +var expressionTo = require('./utils/expressionTo'); +var getPropertyName = require('./utils/getPropertyName'); +var isReactModuleName = require('./utils/isReactModuleName'); +var match = require('./utils/match'); +var resolveToValue = require('./utils/resolveToValue'); +var resolveToModule = require('./utils/resolveToModule'); +var recast = require('recast'); +var n = recast.types.namedTypes; + +function ignore() { + return false; +} + +/** + * Returns true if the statement is of form `foo = bar;`. + * + * @param {object} node + * @return {bool} + */ +function isAssignmentStatement(node) { + return match(node, {expression: {operator: '='}}); +} + +/** + * Returns true if the expression is of form `exports.foo = bar;` or + * `modules.exports = foo;`. + * + * @param {object} node + * @return {bool} + */ +function isExportsOrModuleExpression(path) { + if (!n.AssignmentExpression.check(path.node) || + !n.MemberExpression.check(path.node.left)) { + return false; + } + var exprArr = expressionTo.Array(path.get('left')); + return (exprArr[0] === 'module' && exprArr[1] === 'exports') || + exprArr[0] == 'exports'; +} + +/** + * Returns true if the expression is a function call of the form + * `React.createClass(...)`. + * + * @param {object} node + * @param {array} scopeChain + * @return {bool} + */ +function isReactCreateClassCall(path) { + if (!match(path.node, {callee: {property: {name: 'createClass'}}})) { + return false; + } + var module = resolveToModule(path.get('callee', 'object')); + return module && isReactModuleName(module); +} + +/** + * Given an AST, this function tries to find the object expression that is + * passed to `React.createClass`, by resolving all references properly. + * + * @param {object} ast + * @return {?object} + */ +function findComponentDefinition(ast) { + var definition; + + recast.visit(ast, { + visitFunctionDeclaration: ignore, + visitFunctionExpression: ignore, + visitIfStatement: ignore, + visitWithStatement: ignore, + visitSwitchStatement: ignore, + visitTryStatement: ignore, + visitWhileStatement: ignore, + visitDoWhileStatement: ignore, + visitForStatement: ignore, + visitForInStatement: ignore, + visitAssignmentExpression: function(path) { + // Ignore anything that is not `exports.X = ...;` or + // `module.exports = ...;` + if (!isExportsOrModuleExpression(path)) { + return false; + } + // Resolve the value of the right hand side. It should resolve to a call + // expression, something like React.createClass + path = resolveToValue(path.get('right')); + if (!isReactCreateClassCall(path)) { + return false; + } + if (definition) { // If a file exports multiple components, ... complain! + throw new Error(ReactDocumentationParser.ERROR_MULTIPLE_DEFINITIONS); + } + // We found React.createClass. Lets get cracking! + definition = resolveToValue(path.get('arguments', 0)); + return false; + } + }); + + return definition; +} + + +class ReactDocumentationParser { + _componentHandlers: Array; + _propertyHandlers: Object; + + constructor() { + this._componentHandlers = []; + this._propertyHandlers = Object.create(null); + } + + /** + * Handlers extract information from the component definition. + * + * If "property" is not provided, the handler is passed the whole component + * definition. + */ + addHandler(handler: Handler, property?: string): void { + if (!property) { + this._componentHandlers.push(handler); + } else { + if (!this._propertyHandlers[property]) { + this._propertyHandlers[property] = []; + } + this._propertyHandlers[property].push(handler); + } + } + + /** + * Takes JavaScript source code and returns an object with the information + * extract from it. + */ + parseSource(source: string): Object { + var documentation = new Documentation(); + var ast = recast.parse(source); + // Find the component definition first. The return value should be + // an ObjectExpression. + var componentDefinition = findComponentDefinition(ast.program); + if (!componentDefinition) { + throw new Error(ReactDocumentationParser.ERROR_MISSING_DEFINITION); + } + + // Execute all the handlers to extract the information + this._executeHandlers(documentation, componentDefinition); + + return documentation.toObject(); + } + + _executeHandlers(documentation, componentDefinition: NodePath) { + componentDefinition.get('properties').each(propertyPath => { + var name = getPropertyName(propertyPath); + if (!this._propertyHandlers[name]) { + return; + } + var propertyValuePath = propertyPath.get('value'); + this._propertyHandlers[name].forEach( + handler => handler(documentation, propertyValuePath) + ); + }); + + this._componentHandlers.forEach( + handler => handler(documentation, componentDefinition) + ); + } +} + +ReactDocumentationParser.ERROR_MISSING_DEFINITION = + 'No suitable component definition found.'; + +ReactDocumentationParser.ERROR_MULTIPLE_DEFINITIONS = + 'Multiple exported component definitions found.'; + +module.exports = ReactDocumentationParser; diff --git a/website/react-docgen/lib/__tests__/ReactDocumentationParser-test.js b/website/react-docgen/lib/__tests__/ReactDocumentationParser-test.js new file mode 100644 index 000000000..52ede4145 --- /dev/null +++ b/website/react-docgen/lib/__tests__/ReactDocumentationParser-test.js @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +"use strict"; + +require('mock-modules').autoMockOff(); + +describe('React documentation parser', function() { + var ReactDocumentationParser; + var parser; + + beforeEach(function() { + ReactDocumentationParser = require('../ReactDocumentationParser'); + parser = new ReactDocumentationParser(); + }); + + it('errors if component definition is not found', function() { + var source = 'var React = require("React");'; + + expect(function() { + parser.parseSource(source); + }).toThrow(ReactDocumentationParser.ERROR_MISSING_DEFINITION); + }); + + it('finds React.createClass', function() { + var source = [ + 'var React = require("React");', + 'var Component = React.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).not.toThrow(); + }); + + it('finds React.createClass, independent of the var name', function() { + var source = [ + 'var R = require("React");', + 'var Component = R.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).not.toThrow(); + }); + + it('does not process X.createClass of other modules', function() { + var source = [ + 'var R = require("NoReact");', + 'var Component = R.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).toThrow(ReactDocumentationParser.ERROR_MISSING_DEFINITION); + }); + + it('finds assignments to exports', function() { + var source = [ + 'var R = require("React");', + 'var Component = R.createClass({});', + 'exports.foo = 42;', + 'exports.Component = Component;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).not.toThrow(); + }); + + it('errors if multiple components are exported', function() { + var source = [ + 'var R = require("React");', + 'var ComponentA = R.createClass({});', + 'var ComponentB = R.createClass({});', + 'exports.ComponentA = ComponentA;', + 'exports.ComponentB = ComponentB;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).toThrow(ReactDocumentationParser.ERROR_MULTIPLE_DEFINITIONS); + }); + + it('accepts multiple definitions if only one is exported', function() { + var source = [ + 'var R = require("React");', + 'var ComponentA = R.createClass({});', + 'var ComponentB = R.createClass({});', + 'exports.ComponentB = ComponentB;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).not.toThrow(); + + source = [ + 'var R = require("React");', + 'var ComponentA = R.createClass({});', + 'var ComponentB = R.createClass({});', + 'module.exports = ComponentB;' + ].join('\n'); + + expect(function() { + parser.parseSource(source); + }).not.toThrow(); + }); +}); diff --git a/website/react-docgen/lib/handlers/__tests__/componentDocblockHandler-test.js b/website/react-docgen/lib/handlers/__tests__/componentDocblockHandler-test.js new file mode 100644 index 000000000..fd0dfd1d6 --- /dev/null +++ b/website/react-docgen/lib/handlers/__tests__/componentDocblockHandler-test.js @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +"use strict"; + +jest.autoMockOff(); + +describe('React documentation parser', function() { + var parser; + + beforeEach(function() { + parser = new (require('../../ReactDocumentationParser')); + parser.addHandler(require('../componentDocblockHandler')); + }); + + it('finds docblocks for component definitions', function() { + var source = [ + 'var React = require("React");', + '/**', + ' * Component description', + ' */', + 'var Component = React.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + props: {}, + description: 'Component description' + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('ignores other types of comments', function() { + var source = [ + 'var React = require("React");', + '/*', + ' * This is not a docblock', + ' */', + 'var Component = React.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + props: {}, + description: '' + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + + + source = [ + 'var React = require("React");', + '// Inline comment', + 'var Component = React.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + expectedResult = { + props: {}, + description: '' + }; + + result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('only considers the docblock directly above the definition', function() { + var source = [ + 'var React = require("React");', + '/**', + ' * This is the wrong docblock', + ' */', + 'var something_else = "foo";', + 'var Component = React.createClass({});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + props: {}, + description: '' + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/website/react-docgen/lib/handlers/__tests__/defaultValueHandler-test.js b/website/react-docgen/lib/handlers/__tests__/defaultValueHandler-test.js new file mode 100644 index 000000000..7902d3ee7 --- /dev/null +++ b/website/react-docgen/lib/handlers/__tests__/defaultValueHandler-test.js @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +"use strict"; + +jest.autoMockOff(); + +var module_template = [ + 'var React = require("React");', + 'var PropTypes = React.PropTypes;', + 'var Component = React.createClass(%s);', + 'module.exports = Component;' +].join('\n'); + +function getSource(definition) { + return module_template.replace('%s', definition); +} + +describe('React documentation parser', function() { + var parser; + + beforeEach(function() { + parser = new (require('../../ReactDocumentationParser')); + parser.addHandler(require('../defaultValueHandler'), 'getDefaultProps'); + }); + + it ('should find prop default values that are literals', function() { + var source = getSource([ + '{', + ' getDefaultProps: function() {', + ' return {', + ' foo: "bar",', + ' bar: 42,', + ' baz: ["foo", "bar"],', + ' abc: {xyz: abc.def, 123: 42}', + ' };', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + foo: { + defaultValue: { + value: '"bar"', + computed: false + } + }, + bar: { + defaultValue: { + value: '42', + computed: false + } + }, + baz: { + defaultValue: { + value: '["foo", "bar"]', + computed: false + } + }, + abc: { + defaultValue: { + value: '{xyz: abc.def, 123: 42}', + computed: false + } + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/website/react-docgen/lib/handlers/__tests__/propDocblockHandler-test.js b/website/react-docgen/lib/handlers/__tests__/propDocblockHandler-test.js new file mode 100644 index 000000000..90f035b53 --- /dev/null +++ b/website/react-docgen/lib/handlers/__tests__/propDocblockHandler-test.js @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +"use strict"; + +jest.autoMockOff(); + +var module_template = [ + 'var React = require("React");', + 'var PropTypes = React.PropTypes;', + 'var Component = React.createClass(%s);', + 'module.exports = Component;' +].join('\n'); + +function getSource(definition) { + return module_template.replace('%s', definition); +} + +describe('React documentation parser', function() { + var parser; + + beforeEach(function() { + parser = new (require('../../ReactDocumentationParser')); + parser.addHandler(require('../propDocblockHandler'), 'propTypes'); + }); + + it('finds docblocks for prop types', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' /**', + ' * Foo comment', + ' */', + ' foo: Prop.bool,', + '', + ' /**', + ' * Bar comment', + ' */', + ' bar: Prop.bool,', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + foo: { + description: 'Foo comment' + }, + bar: { + description: 'Bar comment' + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('can handle multline comments', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' /**', + ' * Foo comment with', + ' * many lines!', + ' *', + ' * even with empty lines in between', + ' */', + ' foo: Prop.bool', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + foo: { + description: + 'Foo comment with\nmany lines!\n\neven with empty lines in between' + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('ignores non-docblock comments', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' /**', + ' * Foo comment', + ' */', + ' // TODO: remove this comment', + ' foo: Prop.bool,', + '', + ' /**', + ' * Bar comment', + ' */', + ' /* This is not a doc comment */', + ' bar: Prop.bool,', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + foo: { + description: 'Foo comment' + }, + bar: { + description: 'Bar comment' + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('only considers the comment with the property below it', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' /**', + ' * Foo comment', + ' */', + ' foo: Prop.bool,', + ' bar: Prop.bool,', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + foo: { + description: 'Foo comment' + }, + bar: { + description: '' + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('understands and ignores the spread operator', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' ...Foo.propTypes,', + ' /**', + ' * Foo comment', + ' */', + ' foo: Prop.bool,', + ' bar: Prop.bool,', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + foo: { + description: 'Foo comment' + }, + bar: { + description: '' + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/website/react-docgen/lib/handlers/__tests__/propTypeHandler-test.js b/website/react-docgen/lib/handlers/__tests__/propTypeHandler-test.js new file mode 100644 index 000000000..6aa3cc759 --- /dev/null +++ b/website/react-docgen/lib/handlers/__tests__/propTypeHandler-test.js @@ -0,0 +1,456 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +"use strict"; + +jest.autoMockOff(); + +var module_template = [ + 'var React = require("React");', + 'var PropTypes = React.PropTypes;', + 'var Component = React.createClass(%s);', + 'module.exports = Component;' +].join('\n'); + +function getSource(definition) { + return module_template.replace('%s', definition); +} + +describe('React documentation parser', function() { + var parser; + + beforeEach(function() { + parser = new (require('../../ReactDocumentationParser')); + parser.addHandler(require('../propTypeHandler'), 'propTypes'); + }); + + it('finds definitions via React.PropTypes', function() { + var source = [ + 'var React = require("React");', + 'var Prop = React.PropTypes;', + 'var Prop1 = require("React").PropTypes;', + 'var Component = React.createClass({', + ' propTypes: {', + ' foo: Prop.bool,', + ' bar: Prop1.bool,', + ' }', + '});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + description: '', + props: { + foo: { + type: {name: 'bool'}, + required: false + }, + bar: { + type: {name: 'bool'}, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('finds definitions via the ReactPropTypes module', function() { + var source = [ + 'var React = require("React");', + 'var Prop = require("ReactPropTypes");', + 'var Component = React.createClass({', + ' propTypes: {', + ' foo: Prop.bool,', + ' }', + '});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + description: '', + props: { + foo: { + type: {name: 'bool'}, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('detects simple prop types', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' array_prop: PropTypes.array,', + ' bool_prop: PropTypes.bool,', + ' func_prop: PropTypes.func,', + ' number_prop: PropTypes.number,', + ' object_prop: PropTypes.object,', + ' string_prop: PropTypes.string,', + ' element_prop: PropTypes.element,', + ' any_prop: PropTypes.any,', + ' node_prop: PropTypes.node', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props:{ + array_prop: { + type: {name: 'array'}, + required: false + }, + bool_prop: { + type: {name: 'bool'}, + required: false + }, + func_prop: { + type: {name: 'func'}, + required: false + }, + number_prop: { + type: {name: 'number'}, + required: false + }, + object_prop: { + type: {name: 'object'}, + required: false + }, + string_prop: { + type: {name: 'string'}, + required: false + }, + element_prop: { + type: {name: 'element'}, + required: false + }, + any_prop: { + type: {name: 'any'}, + required: false + }, + node_prop: { + type: {name: 'node'}, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it.only('detects complex prop types', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' oneOf_prop: PropTypes.oneOf(["foo", "bar"]),', + ' oneOfType_prop:', + ' PropTypes.oneOfType([PropTypes.number, PropTypes.bool]),', + ' oneOfType_custom_prop:', + ' PropTypes.oneOfType([xyz]),', + ' instanceOf_prop: PropTypes.instanceOf(Foo),', + ' arrayOf_prop: PropTypes.arrayOf(PropTypes.string),', + ' shape_prop:', + ' PropTypes.shape({foo: PropTypes.string, bar: PropTypes.bool}),', + ' shape_custom_prop:', + ' PropTypes.shape({foo: xyz})', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props:{ + oneOf_prop: { + type: { + name: 'enum', + value: [ + {value: '"foo"', computed: false}, + {value: '"bar"', computed: false} + ] + }, + required: false + }, + oneOfType_prop: { + type: { + name:'union', + value: [ + {name: 'number'}, + {name: 'bool'} + ] + }, + required: false + }, + oneOfType_custom_prop: { + type: { + name:'union', + value: [ + {name: 'custom'} + ] + }, + required: false + }, + instanceOf_prop: { + type: { + name: 'instance', + value: 'Foo' + }, + required: false + }, + arrayOf_prop: { + type: { + name: 'arrayof', + value: {name: 'string'} + }, + required: false + }, + shape_prop: { + type: { + name: 'shape', + value: { + foo: {name: 'string'}, + bar: {name: 'bool'} + } + }, + required: false + }, + shape_custom_prop: { + type: { + name: 'shape', + value: { + foo: {name: 'custom'}, + } + }, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('resolves variables to their values', function() { + var source = [ + 'var React = require("React");', + 'var PropTypes = React.PropTypes;', + 'var shape = {bar: PropTypes.string};', + 'var Component = React.createClass({', + ' propTypes: {', + ' foo: PropTypes.shape(shape)', + ' }', + '});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + description: '', + props: { + foo: { + type: { + name: 'shape', + value: { + bar: {name: 'string'} + } + }, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('detects whether a prop is required', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' array_prop: PropTypes.array.isRequired,', + ' bool_prop: PropTypes.bool.isRequired,', + ' func_prop: PropTypes.func.isRequired,', + ' number_prop: PropTypes.number.isRequired,', + ' object_prop: PropTypes.object.isRequired,', + ' string_prop: PropTypes.string.isRequired,', + ' element_prop: PropTypes.element.isRequired,', + ' any_prop: PropTypes.any.isRequired,', + ' oneOf_prop: PropTypes.oneOf(["foo", "bar"]).isRequired,', + ' oneOfType_prop: ', + ' PropTypes.oneOfType([PropTypes.number, PropTypes.bool]).isRequired,', + ' instanceOf_prop: PropTypes.instanceOf(Foo).isRequired', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props:{ + array_prop: { + type: {name: 'array'}, + required: true + }, + bool_prop: { + type: {name: 'bool'}, + required: true + }, + func_prop: { + type: {name: 'func'}, + required: true + }, + number_prop: { + type: {name: 'number'}, + required: true + }, + object_prop: { + type: {name: 'object'}, + required: true + }, + string_prop: { + type: {name: 'string'}, + required: true + }, + element_prop: { + type: {name: 'element'}, + required: true + }, + any_prop: { + type: {name: 'any'}, + required: true + }, + oneOf_prop: { + type: { + name: 'enum', + value: [ + {value: '"foo"', computed: false}, + {value: '"bar"', computed: false} + ] + }, + required: true + }, + oneOfType_prop: { + type: { + name: 'union', + value: [ + {name: 'number'}, + {name: 'bool'} + ] + }, + required: true + }, + instanceOf_prop: { + type: { + name: 'instance', + value: 'Foo' + }, + required: true + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('detects custom validation functions', function() { + var source = getSource([ + '{', + ' propTypes: {', + ' custom_prop: function() {}', + ' }', + '}' + ].join('\n')); + + var expectedResult = { + description: '', + props: { + custom_prop: { + type: {name: 'custom'}, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('only considers definitions from React or ReactPropTypes', function() { + var source = [ + 'var React = require("React");', + 'var PropTypes = React.PropTypes;', + 'var Prop = require("Foo");', + 'var Component = React.createClass({', + ' propTypes: {', + ' custom_propA: PropTypes.bool,', + ' custom_propB: Prop.bool.isRequired', + ' }', + '});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + description: '', + props: { + custom_propA: { + type: {name: 'bool'}, + required: false + }, + custom_propB: { + type: {name: 'custom'}, + required: false + } + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); + + it('understands the spread operator', function() { + var source = [ + 'var React = require("React");', + 'var PropTypes = React.PropTypes;', + 'var Foo = require("Foo.react");', + 'var props = {bar: PropTypes.bool};', + 'var Component = React.createClass({', + ' propTypes: {', + ' ...Foo.propTypes,', + ' ...props,', + ' foo: PropTypes.number', + ' }', + '});', + 'module.exports = Component;' + ].join('\n'); + + var expectedResult = { + description: '', + composes: ['Foo.react'], + props:{ + foo: { + type: {name: 'number'}, + required: false + }, + bar: { + type: {name: 'bool'}, + required: false + }, + } + }; + + var result = parser.parseSource(source); + expect(result).toEqual(expectedResult); + }); +}); diff --git a/website/react-docgen/lib/handlers/componentDocblockHandler.js b/website/react-docgen/lib/handlers/componentDocblockHandler.js new file mode 100644 index 000000000..8cb876a06 --- /dev/null +++ b/website/react-docgen/lib/handlers/componentDocblockHandler.js @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var Documentation = require('../Documentation'); + +var n = require('recast').types.namedTypes; +var getDocblock = require('../utils/docblock').getDocblock; + +/** + * Finds the nearest block comment before the component definition. + */ +function componentDocblockHandler( + documentation: Documentation, + path: NodePath +) { + var description = ''; + // Find parent statement (e.g. var Component = React.createClass(path);) + while (path && !n.Statement.check(path.node)) { + path = path.parent; + } + if (path) { + description = getDocblock(path) || ''; + } + documentation.setDescription(description); +} + +module.exports = componentDocblockHandler; diff --git a/website/react-docgen/lib/handlers/defaultValueHandler.js b/website/react-docgen/lib/handlers/defaultValueHandler.js new file mode 100644 index 000000000..4d7c003af --- /dev/null +++ b/website/react-docgen/lib/handlers/defaultValueHandler.js @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var Documentation = require('../Documentation'); + +var expressionTo = require('../utils/expressionTo'); +var getPropertyName = require('../utils/getPropertyName'); +var recast = require('recast'); +var resolveToValue = require('../utils/resolveToValue'); +var types = recast.types.namedTypes; +var visit = recast.types.visit; + +function getDefaultValue(path) { + var node = path.node; + var defaultValue; + if (types.Literal.check(node)) { + defaultValue = node.raw; + } else { + path = resolveToValue(path); + node = path.node; + defaultValue = expressionTo.String(path); + } + if (typeof defaultValue !== 'undefined') { + return { + value: defaultValue, + computed: types.CallExpression.check(node) || + types.MemberExpression.check(node) || + types.Identifier.check(node) + }; + } +} + +function defaultValueHandler(documentation: Documentation, path: NodePath) { + if (!types.FunctionExpression.check(path.node)) { + return; + } + + // Find the value that is returned from the function and process it if it is + // an object literal. + var objectExpressionPath; + visit(path.get('body'), { + visitFunction: () => false, + visitReturnStatement: function(path) { + var resolvedPath = resolveToValue(path.get('argument')); + if (types.ObjectExpression.check(resolvedPath.node)) { + objectExpressionPath = resolvedPath; + } + return false; + } + }); + + if (objectExpressionPath) { + objectExpressionPath.get('properties').each(function(propertyPath) { + var propDescriptor = documentation.getPropDescriptor( + getPropertyName(propertyPath) + ); + var defaultValue = getDefaultValue(propertyPath.get('value')); + if (defaultValue) { + propDescriptor.defaultValue = defaultValue; + } + }); + } +} + +module.exports = defaultValueHandler; diff --git a/website/react-docgen/lib/handlers/propDocBlockHandler.js b/website/react-docgen/lib/handlers/propDocBlockHandler.js new file mode 100644 index 000000000..d1642e975 --- /dev/null +++ b/website/react-docgen/lib/handlers/propDocBlockHandler.js @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var Documentation = require('../Documentation'); + +var types = require('recast').types.namedTypes; +var getDocblock = require('../utils/docblock').getDocblock; +var getPropertyName = require('../utils/getPropertyName'); + +function propDocBlockHandler(documentation: Documentation, path: NodePath) { + if (!types.ObjectExpression.check(path.node)) { + return; + } + + path.get('properties').each(function(propertyPath) { + // we only support documentation of actual properties, not spread + if (types.Property.check(propertyPath.node)) { + var propDescriptor = documentation.getPropDescriptor( + getPropertyName(propertyPath) + ); + propDescriptor.description = getDocblock(propertyPath) || ''; + } + }); +} + +module.exports = propDocBlockHandler; diff --git a/website/react-docgen/lib/handlers/propTypeHandler.js b/website/react-docgen/lib/handlers/propTypeHandler.js new file mode 100644 index 000000000..0d04b2e98 --- /dev/null +++ b/website/react-docgen/lib/handlers/propTypeHandler.js @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var Documentation = require('../Documentation'); + +var expressionTo = require('../utils/expressionTo'); +var getNameOrValue = require('../utils/getNameOrValue'); +var getPropertyName = require('../utils/getPropertyName'); +var isReactModuleName = require('../utils/isReactModuleName'); +var resolveToModule = require('../utils/resolveToModule'); +var resolveToValue = require('../utils/resolveToValue'); +var types = require('recast').types.namedTypes; + +var simplePropTypes = { + array: 1, + bool: 1, + func: 1, + number: 1, + object: 1, + string: 1, + any: 1, + element: 1, + node: 1 +}; + +function isPropTypesExpression(path) { + var moduleName = resolveToModule(path); + if (moduleName) { + return isReactModuleName(moduleName) || moduleName === 'ReactPropTypes'; + } + return false; +} + +function getEnumValues(path) { + return path.get('elements').map(function(elementPath) { + return { + value: expressionTo.String(elementPath), + computed: !types.Literal.check(elementPath.node) + }; + }); +} + +function getPropTypeOneOf(path) { + types.CallExpression.assert(path.node); + + var argumentPath = path.get('arguments', 0); + var type = {name: 'enum'}; + if (!types.ArrayExpression.check(argumentPath.node)) { + type.computed = true; + type.value = expressionTo.String(argumentPath); + } else { + type.value = getEnumValues(argumentPath); + } + return type; +} + +function getPropTypeOneOfType(path) { + types.CallExpression.assert(path.node); + + var argumentPath = path.get('arguments', 0); + var type = {name: 'union'}; + if (!types.ArrayExpression.check(argumentPath.node)) { + type.computed = true; + type.value = expressionTo.String(argumentPath); + } else { + type.value = argumentPath.get('elements').map(getPropType); + } + return type; +} + +function getPropTypeArrayOf(path) { + types.CallExpression.assert(path.node); + + var argumentPath = path.get('arguments', 0); + var type = {name: 'arrayof'}; + var subType = getPropType(argumentPath); + + if (subType.name === 'unknown') { + type.value = expressionTo.String(argumentPath); + type.computed = true; + } else { + type.value = subType; + } + return type; +} + +function getPropTypeShape(path) { + types.CallExpression.assert(path.node); + + var valuePath = path.get('arguments', 0); + var type: {name: string; value: any;} = {name: 'shape', value: 'unkown'}; + if (!types.ObjectExpression.check(valuePath.node)) { + valuePath = resolveToValue(valuePath); + } + + if (types.ObjectExpression.check(valuePath.node)) { + type.value = {}; + valuePath.get('properties').each(function(propertyPath) { + type.value[getPropertyName(propertyPath)] = + getPropType(propertyPath.get('value')); + }); + } + + return type; +} + +function getPropTypeInstanceOf(path) { + types.CallExpression.assert(path.node); + + return { + name: 'instance', + value: expressionTo.String(path.get('arguments', 0)) + }; +} + +var propTypes = { + oneOf: getPropTypeOneOf, + oneOfType: getPropTypeOneOfType, + instanceOf: getPropTypeInstanceOf, + arrayOf: getPropTypeArrayOf, + shape: getPropTypeShape +}; + +/** + * Tries to identify the prop type by the following rules: + * + * Member expressions which resolve to the `React` or `ReactPropTypes` module + * are inspected to see whether their properties are prop types. Strictly + * speaking we'd have to test whether the Member expression resolves to + * require('React').PropTypes, but we are not doing this right now for + * simplicity. + * + * Everything else is treated as custom validator + */ +function getPropType(path) { + var node = path.node; + if (types.FunctionExpression.check(node) || !isPropTypesExpression(path)) { + return {name: 'custom'}; + } + + var expressionParts = []; + + if (types.MemberExpression.check(node)) { + // React.PropTypes.something.isRequired + if (isRequired(path)) { + path = path.get('object'); + node = path.node; + } + // React.PropTypes.something + expressionParts = expressionTo.Array(path); + } + if (types.CallExpression.check(node)) { + // React.PropTypes.something() + expressionParts = expressionTo.Array(path.get('callee')); + } + + // React.PropTypes.something -> something + var propType = expressionParts.pop(); + var type; + if (propType in propTypes) { + type = propTypes[propType](path); + } else { + type = {name: (propType in simplePropTypes) ? propType : 'unknown'}; + } + return type; +} + +/** + * Returns true of the prop is required, according to its type defintion + */ +function isRequired(path) { + if (types.MemberExpression.check(path.node)) { + var expressionParts = expressionTo.Array(path); + if (expressionParts[expressionParts.length - 1] === 'isRequired') { + return true; + } + } + return false; +} + +/** + * Handles member expressions of the form + * + * ComponentA.propTypes + * + * it resolves ComponentA to its module name and adds it to the "composes" entry + * in the documentation. + */ +function amendComposes(documentation, path) { + var node = path.node; + if (!types.MemberExpression.check(node) || + getNameOrValue(path.get('property')) !== 'propTypes' || + !types.Identifier.check(node.object)) { + return; + } + + var moduleName = resolveToModule(path.get('object')); + if (moduleName) { + documentation.addComposes(moduleName); + } +} + +function amendPropTypes(documentation, path) { + path.get('properties').each(function(propertyPath) { + switch (propertyPath.node.type) { + case types.Property.name: + var type = getPropType(propertyPath.get('value')); + if (type) { + var propDescriptor = documentation.getPropDescriptor( + getPropertyName(propertyPath) + ); + propDescriptor.type = type; + propDescriptor.required = type.name !== 'custom' && + isRequired(propertyPath.get('value')); + } + break; + case types.SpreadProperty.name: + var resolvedValuePath = resolveToValue(propertyPath.get('argument')); + switch (resolvedValuePath.node.type) { + case types.ObjectExpression.name: // normal object literal + amendPropTypes(documentation, resolvedValuePath); + break; + case types.MemberExpression.name: + amendComposes(documentation, resolvedValuePath); + break; + } + break; + } + }); +} + +function propTypeHandler(documentation: Documentation, path: NodePath) { + path = resolveToValue(path); + switch (path.node.type) { + case types.ObjectExpression.name: + amendPropTypes(documentation, path); + break; + case types.MemberExpression.name: + amendComposes(documentation, path); + } +} + +module.exports = propTypeHandler; diff --git a/website/react-docgen/lib/main.js b/website/react-docgen/lib/main.js new file mode 100644 index 000000000..d2b184c8b --- /dev/null +++ b/website/react-docgen/lib/main.js @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +/** + * Extractor for React documentation in JavaScript. + */ +var ReactDocumentationParser = require('./ReactDocumentationParser'); +var parser = new ReactDocumentationParser(); + +parser.addHandler( + require('./handlers/propTypeHandler'), + 'propTypes' +); +parser.addHandler( + require('./handlers/propDocBlockHandler'), + 'propTypes' +); +parser.addHandler( + require('./handlers/defaultValueHandler'), + 'getDefaultProps' +); + +parser.addHandler( + require('./handlers/componentDocblockHandler') +); + +module.exports = parser; diff --git a/website/react-docgen/lib/utils/__tests__/docblock-test.js b/website/react-docgen/lib/utils/__tests__/docblock-test.js new file mode 100644 index 000000000..23e3d1eb1 --- /dev/null +++ b/website/react-docgen/lib/utils/__tests__/docblock-test.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +"use strict"; + +jest.autoMockOff(); + +describe('docblock', function() { + + describe('getDoclets', function() { + var getDoclets; + + beforeEach(function() { + getDoclets = require('../docblock').getDoclets; + }); + + it('extacts single line doclets', function() { + expect(getDoclets('@foo bar\n@bar baz')) + .toEqual({foo: 'bar', bar: 'baz'}); + }); + + it('extacts multi line doclets', function() { + expect(getDoclets('@foo bar\nbaz\n@bar baz')) + .toEqual({foo: 'bar\nbaz', bar: 'baz'}); + }); + + it('extacts boolean doclets', function() { + expect(getDoclets('@foo bar\nbaz\n@abc\n@bar baz')) + .toEqual({foo: 'bar\nbaz', abc: true, bar: 'baz'}); + }); + }); + +}); diff --git a/website/react-docgen/lib/utils/docblock.js b/website/react-docgen/lib/utils/docblock.js new file mode 100644 index 000000000..09f888a88 --- /dev/null +++ b/website/react-docgen/lib/utils/docblock.js @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * Helper functions to work with docblock comments. + * @flow + */ +"use strict"; + +var types = require('recast').types.namedTypes; +var docletPattern = /^@(\w+)(?:$|\s((?:[^](?!^@\w))*))/gmi; + +function parseDocblock(str) { + var lines = str.split('\n'); + for (var i = 0, l = lines.length; i < l; i++) { + lines[i] = lines[i].replace(/^\s*\*\s?/, ''); + } + return lines.join('\n').trim(); +} + +/** + * Given a path, this function returns the closest preceding docblock if it + * exists. + */ +function getDocblock(path: NodePath): ?string { + if (path.node.comments) { + var comments = path.node.comments.leading.filter(function(comment) { + return comment.type === 'Block' && comment.value.indexOf('*\n') === 0; + }); + if (comments.length > 0) { + return parseDocblock(comments[comments.length - 1].value); + } + } + return null; +} + +/** + * Given a string, this functions returns an object with doclet names as keys + * and their "content" as values. + */ +function getDoclets(str: string): Object { + var doclets = Object.create(null); + var match = docletPattern.exec(str); + + for (; match; match = docletPattern.exec(str)) { + doclets[match[1]] = match[2] || true; + } + + return doclets; +} + +exports.getDocblock = getDocblock; +exports.getDoclets = getDoclets; diff --git a/website/react-docgen/lib/utils/expressionTo.js b/website/react-docgen/lib/utils/expressionTo.js new file mode 100644 index 000000000..aa713a81b --- /dev/null +++ b/website/react-docgen/lib/utils/expressionTo.js @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var resolveToValue = require('./resolveToValue'); +var types = require('recast').types.namedTypes; + +/** + * Splits a MemberExpression or CallExpression into parts. + * E.g. foo.bar.baz becomes ['foo', 'bar', 'baz'] + */ +function toArray(path: NodePath): Array { + var parts = [path]; + var result = []; + + while (parts.length > 0) { + path = parts.shift(); + var node = path.node; + if (types.CallExpression.check(node)) { + parts.push(path.get('callee')); + continue; + } else if (types.MemberExpression.check(node)) { + parts.push(path.get('object')); + if (node.computed) { + var resolvedPath = resolveToValue(path.get('property')); + if (resolvedPath !== undefined) { + result = result.concat(toArray(resolvedPath)); + } else { + result.push(''); + } + } else { + result.push(node.property.name); + } + continue; + } else if (types.Identifier.check(node)) { + result.push(node.name); + continue; + } else if (types.Literal.check(node)) { + result.push(node.raw); + continue; + } else if (types.ThisExpression.check(node)) { + result.push('this'); + continue; + } else if (types.ObjectExpression.check(node)) { + var properties = path.get('properties').map(function(property) { + return toString(property.get('key')) + + ': ' + + toString(property.get('value')); + }); + result.push('{' + properties.join(', ') + '}'); + continue; + } else if(types.ArrayExpression.check(node)) { + result.push('[' + path.get('elements').map(toString).join(', ') + ']'); + continue; + } + } + + return result.reverse(); +} + +/** + * Creates a string representation of a member expression. + */ +function toString(path: NodePath): string { + return toArray(path).join('.'); +} + +exports.String = toString; +exports.Array = toArray; diff --git a/website/react-docgen/lib/utils/getNameOrValue.js b/website/react-docgen/lib/utils/getNameOrValue.js new file mode 100644 index 000000000..9c63d2fe6 --- /dev/null +++ b/website/react-docgen/lib/utils/getNameOrValue.js @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var types = require('recast').types.namedTypes; + +/** + * If node is an Identifier, it returns its name. If it is a literal, it returns + * its value. + */ +function getNameOrValue(path: NodePath, raw?: boolean): string { + var node = path.node; + switch (node.type) { + case types.Identifier.name: + return node.name; + case types.Literal.name: + return raw ? node.raw : node.value; + default: + throw new TypeError('Argument must be an Identifier or a Literal'); + } +} + +module.exports = getNameOrValue; diff --git a/website/react-docgen/lib/utils/getPropertyName.js b/website/react-docgen/lib/utils/getPropertyName.js new file mode 100644 index 000000000..ae70396f4 --- /dev/null +++ b/website/react-docgen/lib/utils/getPropertyName.js @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var getNameOrValue = require('./getNameOrValue'); +var types = require('recast').types.namedTypes; + +/** + * In an ObjectExpression, the name of a property can either be an identifier + * or a literal (or dynamic, but we don't support those). This function simply + * returns the value of the literal or name of the identifier. + */ +function getPropertyName(propertyPath: NodePath): string { + if (propertyPath.node.computed) { + throw new TypeError('Propery name must be an Identifier or a Literal'); + } + + return getNameOrValue(propertyPath.get('key'), false); +} + +module.exports = getPropertyName; diff --git a/website/react-docgen/lib/utils/isReactModuleName.js b/website/react-docgen/lib/utils/isReactModuleName.js new file mode 100644 index 000000000..1b9f8878b --- /dev/null +++ b/website/react-docgen/lib/utils/isReactModuleName.js @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var reactModules = ['react', 'react/addons']; + +/** + * Takes a module name (string) and returns true if it refers to a root react + * module name. + */ +function isReactModuleName(moduleName: string): boolean { + return reactModules.some(function(reactModuleName) { + return reactModuleName === moduleName.toLowerCase(); + }); +} + +module.exports = isReactModuleName; diff --git a/website/react-docgen/lib/utils/match.js b/website/react-docgen/lib/utils/match.js new file mode 100644 index 000000000..9365d0ff8 --- /dev/null +++ b/website/react-docgen/lib/utils/match.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +/** + * This function takes an AST node and matches it against "pattern". Pattern + * is simply a (nested) object literal and it is traversed to see whether node + * contains those (nested) properties with the provided values. + */ +function match(node: ASTNOde, pattern: Object): boolean { + if (!node) { + return false; + } + for (var prop in pattern) { + if (!node[prop]) { + return false; + } + if (pattern[prop] && typeof pattern[prop] === 'object') { + if (!match(node[prop], pattern[prop])) { + return false; + } + } else if (pattern[prop] !== pattern[prop]) { + return false; + } + } + return true; +} + +module.exports = match; diff --git a/website/react-docgen/lib/utils/resolveToModule.js b/website/react-docgen/lib/utils/resolveToModule.js new file mode 100644 index 000000000..d60769b33 --- /dev/null +++ b/website/react-docgen/lib/utils/resolveToModule.js @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var match = require('./match'); +var resolveToValue = require('./resolveToValue'); +var types = require('recast').types.namedTypes; + +/** + * Given a path (e.g. call expression, member expression or identifier), + * this function tries to find the name of module from which the "root value" + * was imported. + */ +function resolveToModule(path: NodePath): ?string { + var node = path.node; + switch (node.type) { + case types.VariableDeclarator.name: + if (node.init) { + return resolveToModule(path.get('init')); + } + break; + case types.CallExpression.name: + if (match(node.callee, {type: types.Identifier.name, name: 'require'})) { + return node['arguments'][0].value; + } + return resolveToModule(path.get('callee')); + case types.Identifier.name: + var valuePath = resolveToValue(path); + if (valuePath !== path) { + return resolveToModule(valuePath); + } + break; + case types.MemberExpression.name: + while (path && types.MemberExpression.check(path.node)) { + path = path.get('object'); + } + if (path) { + return resolveToModule(path); + } + } +} + +module.exports = resolveToModule; diff --git a/website/react-docgen/lib/utils/resolveToValue.js b/website/react-docgen/lib/utils/resolveToValue.js new file mode 100644 index 000000000..4e352d75f --- /dev/null +++ b/website/react-docgen/lib/utils/resolveToValue.js @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/** + * @flow + */ +"use strict"; + +var types = require('recast').types.namedTypes; + +/** + * If the path is an identifier, it is resolved in the scope chain. + * If it is an assignment expression, it resolves to the right hand side. + * + * Else the path itself is returned. + */ +function resolveToValue(path: NodePath): NodePath { + var node = path.node; + if (types.AssignmentExpression.check(node)) { + if (node.operator === '=') { + return resolveToValue(node.get('right')); + } + } else if (types.Identifier.check(node)) { + var scope = path.scope.lookup(node.name); + if (scope) { + var bindings = scope.getBindings()[node.name]; + if (bindings.length > 0) { + var parentPath = scope.getBindings()[node.name][0].parent; + if (types.VariableDeclarator.check(parentPath.node)) { + parentPath = parentPath.get('init'); + } + return resolveToValue(parentPath); + } + } + } + return path; +} + +module.exports = resolveToValue; diff --git a/website/react-docgen/package.json b/website/react-docgen/package.json new file mode 100644 index 000000000..9974df250 --- /dev/null +++ b/website/react-docgen/package.json @@ -0,0 +1,35 @@ +{ + "name": "react-docgen", + "version": "1.0.0", + "description": "Extract information from React components for documentation generation", + "bin": { + "react-docgen": "bin/react-docgen.js" + }, + "main": "dist/main.js", + "scripts": { + "watch": "jsx lib/ dist/ --harmony --strip-types -w", + "build": "rm -rf dist/ && jsx lib/ dist/ --harmony --strip-types --no-cache-dir", + "prepublish": "npm run build", + "test": "jest" + }, + "keywords": [ + "react", + "documentation" + ], + "author": "Felix Kling", + "license": "BSD-3-Clause", + "dependencies": { + "async": "^0.9.0", + "node-dir": "^0.1.6", + "nomnom": "^1.8.1", + "recast": "^0.9.17" + }, + "devDependencies": { + "jest-cli": "^0.2.2", + "react-tools": "^0.12.2" + }, + "jest": { + "scriptPreprocessor": "./preprocessor", + "testPathDirs": ["lib"] + } +} diff --git a/website/react-docgen/preprocessor.js b/website/react-docgen/preprocessor.js new file mode 100644 index 000000000..f827426d4 --- /dev/null +++ b/website/react-docgen/preprocessor.js @@ -0,0 +1,9 @@ +"use strict"; + +var reactTools = require('react-tools'); + +function process(source) { + return reactTools.transform(source, {harmony: true, stripTypes: true}); +} + +exports.process = process;