diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..4ff711a --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + stage: 0, + loose: true, + optional: ["runtime"] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c2402ef..0c8c248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ **Note**: Gaps between patch versions are faulty/broken releases. +## v0.8.12 + +- **New Feature** + - Documentation tool: `parse` module, fix #24 + - Documentation tool: `toMarkdown` module, fix #25 + ## v0.8.11 - **New Feature** diff --git a/README.md b/README.md index cc20c15..fca9ddb 100644 --- a/README.md +++ b/README.md @@ -4,25 +4,46 @@ # Features -- builds on [tcomb](https://github.com/gcanti/tcomb) library -- additional fine grained type checks, nestable at arbitrary level - **by default props are required**, a saner default since it's quite easy to forget `.isRequired` - **checks for unwanted additional props** +- **documentation** (types and comments) can be automatically extracted +- additional fine grained type checks, nestable at arbitrary level +- builds on [tcomb](https://github.com/gcanti/tcomb), [tcomb-validation](https://github.com/gcanti/tcomb-validation), [tcomb-doc](https://github.com/gcanti/tcomb-doc) libraries + +# Prop types + +## The `@props` decorator (ES7) + +For an equivalent implementation in ES5 see the `propTypes` function below. + +**Signature** + +```js +type Props = {[key: string]: TcombType}; + +@props(type: Props | TcombType, options?: Object) +``` + +where + +- `type` can be a map `string -> TcombType`, a `tcomb` struct or a refinement of a struct +- `options`: + - `strict: boolean` (default `true`) if `true` checks for unwanted additional props -# ES7 decorator +**Example** (ES7) ```js import { props, t } from 'tcomb-react'; const Gender = t.enums.of(['Male', 'Female'], 'Gender'); -const URL = t.subtype(t.String, s => s.startsWith('http'), 'URL'); +const URL = t.refinement(t.String, (s) => s.startsWith('http'), 'URL'); @props({ - name: t.String, - surname: t.maybe(t.String), - age: t.Number, - gender: Gender, - avatar: URL + name: t.String, // a required string + surname: t.maybe(t.String), // an optional string + age: t.Number, // a required number + gender: Gender, // an enum + avatar: URL // a refinement }) class Card extends React.Component { @@ -38,18 +59,9 @@ class Card extends React.Component { } ``` -**Note**. `@props` can accepts a subtype of a struct (see [The subtype combinator](https://github.com/gcanti/tcomb/blob/master/GUIDE.md#the-subtype-combinator)). +**Unwanted additional props** -```js -@props(t.subtype(t.struct({ - name: t.String, - ... -}), () => { ... })) -``` - -## Unwanted additional props - -By default tcomb-react checks unwanted additional props: +By default `tcomb-react` checks for unwanted additional props: ```js @props({ @@ -72,7 +84,7 @@ class Person extends React.Component { ``` -ouput to console: +**Output** ``` Warning: Failed propType: [tcomb] Invalid additional prop(s): @@ -84,42 +96,31 @@ Warning: Failed propType: [tcomb] Invalid additional prop(s): supplied to Person. ``` -You can **opt-out** passing an additional argument `{ strict: false }`: +**Note**. You can **opt-out** passing the `option` argument `{ strict: false }`. -```js -@props({ - name: t.String -}, { strict: false }) -class Person extends React.Component { +## The `propTypes` function (ES5) - render() { - return ( -
-

{this.props.name}

-
- ); - } +**Signature** -} -``` +Same as `@props`. -# ES5 +**Example** (ES5) ```js var t = require('tcomb-react').t; var propTypes = require('tcomb-react').propTypes; var Gender = t.enums.of(['Male', 'Female'], 'Gender'); -var URL = t.subtype(t.String, function (s) { return s.startsWith('http'); }, 'URL'); +var URL = t.refinement(t.String, function (s) { return s.startsWith('http'); }, 'URL'); var Card = React.createClass({ propTypes: propTypes({ - name: t.String, - surname: t.maybe(t.String), - age: t.Number, - gender: Gender, - avatar: URL + name: t.String, // a required string + surname: t.maybe(t.String), // an optional string + age: t.Number, // a required number + gender: Gender, // an enum + avatar: URL // a refinement }), render: function () { @@ -134,18 +135,151 @@ var Card = React.createClass({ }); ``` +# Extract documentation from your components + +## The `parse` function + +Given a path to a component file returns a JSON / JavaScript blob containing **props types, default values and comments**. + +**Signature** + +```js +(path: string | Array) => Object +``` + +**Example** + +Source + +```js +import { t, props } from 'tcomb-react' + +/** + * Component description here + * @param name - name description here + * @param surname - surname description here + */ + +@props({ + name: t.String, // a required string + surname: t.maybe(t.String) // an optional string +}) +export default class Card extends React.Component { + + static defaultProps = { + surname: 'Canti' // default value for surname prop + } + + render() { + return ( +
+

{this.props.name}

+

{this.props.surname}

+
+ ); + } +} +``` + +Usage + +```js +import parse from 'tcomb-react/lib/parse' +const json = parse('./components/Card.js') +console.log(JSON.stringify(json, null, 2)) +``` + +Output + +```json +{ + "name": "Card", + "description": "Component description here", + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": true, + "description": "name description here" + }, + "surname": { + "kind": "irreducible", + "name": "String", + "required": false, + "defaultValue": "Canti", + "description": "surname description here" + } + } +} +``` + +**Note**. Since `parse` uses runtime type introspection, your components should be `require`able from your script (you may be required to shim the browser environment). + +**Parsing multiple components** + +```js +import parse from 'tcomb-react/lib/parse' +import path import 'path' +import glob import 'glob' + +function getPath(file) { + return path.resolve(process.cwd(), file); +} + +parse(glob.sync('./components/*.js').map(getPath)); +``` + +## The `toMarkdown` function + +Given a JSON / JavaScript blob returned by `parse` returns a markdown containing the components documentation. + +**Signature** + +```js +(json: Object) => string +``` + +**Example** + +Usage + +```js +import parse from 'tcomb-react/lib/parse' +import toMarkdown from 'tcomb-react/lib/toMarkdown' +const json = parse('./components/Card.js') +console.log(toMarkdown(json)); +``` + +Output + +```markdown +## Card + +Component description here + +**Props** + +- `name: String` name description here +- `surname: String` (optional, default: `"Canti"`) surname description here + +``` + # Augmented pre-defined types +`tcomb-react` exports some useful pre-defined types: + - `t.ReactElement` - `t.ReactNode` - `t.ReactChild` - `t.ReactChildren` +**Example** + ```js import { props, t } from 'tcomb-react'; @props({ - children: t.ReactChild // allow only one child + children: t.ReactChild // only one child is allowed }) class MyComponent extends React.Component { diff --git a/lib/parse.js b/lib/parse.js new file mode 100644 index 0000000..a957829 --- /dev/null +++ b/lib/parse.js @@ -0,0 +1,95 @@ +var fs = require('fs'); +var t = require('../').t; +var toObject = require('tcomb-doc').toObject; +var parseComments = require('get-comments'); +var parseJSDocs = require('doctrine').parse; + +// (path: t.String) => { description: t.String, tags: Array } +function getComments(path) { + var source = fs.readFileSync(path, 'utf8'); + var comments = parseComments(source, true); + var values = comments.map(function (comment) { + return comment.value; + }); + return parseJSDocs(values.join('\n'), { unwrap: true }); +} + +// (comments: t.Object) => { component, props: {} } +function getDescriptions(comments) { + var ret = { + component: comments.description || null, + props: {} + }; + comments.tags.forEach(function (tag) { + if (tag.name) { + ret.props[tag.name] = tag.description || null; + } + }); + return ret; +} + +// (exports: t.Object) => ReactComponent +function getComponent(defaultExport) { + if (defaultExport['default']) { // eslint-disable-line dot-notation + defaultExport = defaultExport['default']; // eslint-disable-line dot-notation + } + return defaultExport.propTypes ? defaultExport : null; +} + +// (component: ReactComponent) => t.String +function getComponentName(component) { + return component.name; +} + +// (component: ReactComponent) => TcombType +function getPropsType(component) { + var propTypes = component.propTypes; + var props = {}; + Object.keys(propTypes).forEach(function (k) { + if (k !== '__strict__' && k !== '__subtype__') { + props[k] = propTypes[k].tcomb; + } + }); + if (propTypes.hasOwnProperty('__subtype__')) { + return t.refinement(t.struct(props), propTypes.__subtype__.predicate); + } + return t.struct(props); +} + +// (component: ReactComponent) => t.Object +function getDefaultProps(component) { + return component.defaultProps || {}; +} + +// (path: t.String) => { name, description, props } +function parse(path) { + if (t.Array.is(path)) { + return path.map(parse).filter(Boolean); + } + var component = getComponent(require(path)); + if (component) { + var comments = getComments(path); + var descriptions = getDescriptions(comments); + var type = toObject(getPropsType(component)); + var props = type.kind === 'refinement' ? type.type.props : type.props; + var defaultProps = getDefaultProps(component); + var name = getComponentName(component); + for (var prop in props) { + if (props.hasOwnProperty(prop)) { + if (defaultProps.hasOwnProperty(prop)) { + props[prop].defaultValue = defaultProps[prop]; + } + if (descriptions.props.hasOwnProperty(prop)) { + props[prop].description = descriptions.props[prop]; + } + } + } + return { + name: name, + description: descriptions.component, + props: props + }; + } +} + +module.exports = parse; diff --git a/lib/toMarkdown.js b/lib/toMarkdown.js new file mode 100644 index 0000000..e352d72 --- /dev/null +++ b/lib/toMarkdown.js @@ -0,0 +1,17 @@ +var t = require('../').t; + +function getProps(props) { + return Object.keys(props).map(function (k) { + var prop = props[k]; + return '- `' + k + ': ' + prop.name + '` ' + (prop.required ? '' : '(optional' + (t.Nil.is(prop.defaultValue) ? '' : ', default: `' + JSON.stringify(prop.defaultValue) + '`') + ') ') + ( prop.description || ''); + }).join('\n'); +} + +function toMarkdown(json) { + if (t.Array.is(json)) { + return json.map(toMarkdown).join('\n'); + } + return '## ' + json.name + '\n' + (json.description ? '\n' + json.description + '\n' : '') + '\n**Props**\n\n' + getProps(json.props) + '\n'; +} + +module.exports = toMarkdown; diff --git a/package.json b/package.json index 484db7e..7e2871e 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,15 @@ { "name": "tcomb-react", - "version": "0.8.11", + "version": "0.8.12", "description": "Type checking for React components", "main": "index.js", + "files": [ + "index.js", + "lib" + ], "scripts": { - "lint": "eslint index.js test", - "test": "npm run lint && npm run mocha", - "mocha": "mocha" + "lint": "eslint index.js lib test", + "test": "mocha --compilers js:babel/register" }, "repository": { "type": "git", @@ -19,14 +22,20 @@ }, "homepage": "https://github.com/gcanti/tcomb-react", "dependencies": { + "doctrine": "0.7.2", + "get-comments": "1.0.1", + "tcomb-doc": "^0.4.0", "tcomb-validation": "^2.2.0", "react": ">=0.13.0" }, "devDependencies": { - "babel-eslint": "^3.1.23", - "eslint": "^0.22.1", - "eslint-plugin-react": "^2.7.1", - "mocha": "^2.2.5" + "babel": "5.8.34", + "babel-core": "5.8.34", + "babel-runtime": "5.8.34", + "babel-eslint": "3.1.30", + "eslint": "0.22.1", + "eslint-plugin-react": "2.7.1", + "mocha": "2.3.2" }, "tags": [ "tcomb", diff --git a/test/fixtures/parse/default/Actual.js b/test/fixtures/parse/default/Actual.js new file mode 100644 index 0000000..6230b97 --- /dev/null +++ b/test/fixtures/parse/default/Actual.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { props, t } from '../../../../.'; + +@props({ + name: t.maybe(t.String) +}) +export default class Component extends React.Component { + + static defaultProps = { + name: 'Giulio' + } + + render() { + return ( +
+

{this.props.name}

+
+ ); + } +} diff --git a/test/fixtures/parse/default/expected.json b/test/fixtures/parse/default/expected.json new file mode 100644 index 0000000..e79ffcc --- /dev/null +++ b/test/fixtures/parse/default/expected.json @@ -0,0 +1,12 @@ +{ + "name": "Component", + "description": null, + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": false, + "defaultValue": "Giulio" + } + } +} \ No newline at end of file diff --git a/test/fixtures/parse/description/Actual.js b/test/fixtures/parse/description/Actual.js new file mode 100644 index 0000000..9fbf42a --- /dev/null +++ b/test/fixtures/parse/description/Actual.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { props, t } from '../../../../.'; + +/** + * component description + */ + +@props({ + name: t.String +}) +export default class Component extends React.Component { + + render() { + return ( +
+

{this.props.name}

+
+ ); + } +} diff --git a/test/fixtures/parse/description/expected.json b/test/fixtures/parse/description/expected.json new file mode 100644 index 0000000..43c13b9 --- /dev/null +++ b/test/fixtures/parse/description/expected.json @@ -0,0 +1,11 @@ +{ + "name": "Component", + "description": "component description", + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": true + } + } +} \ No newline at end of file diff --git a/test/fixtures/parse/example/Actual.js b/test/fixtures/parse/example/Actual.js new file mode 100644 index 0000000..fa635a0 --- /dev/null +++ b/test/fixtures/parse/example/Actual.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { props, t } from '../../../../.'; + +/** + * Component description here + * @param name - name description here + * @param surname - surname description here + */ + +@props({ + name: t.String, // a required string + surname: t.maybe(t.String) // an optional string +}) +export default class Card extends React.Component { + + static defaultProps = { + surname: 'Canti' + } + + render() { + return ( +
+

{this.props.name}

+

{this.props.surname}

+
+ ); + } +} diff --git a/test/fixtures/parse/example/expected.json b/test/fixtures/parse/example/expected.json new file mode 100644 index 0000000..b9d9e7c --- /dev/null +++ b/test/fixtures/parse/example/expected.json @@ -0,0 +1,19 @@ +{ + "name": "Card", + "description": "Component description here", + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": true, + "description": "name description here" + }, + "surname": { + "kind": "irreducible", + "name": "String", + "required": false, + "defaultValue": "Canti", + "description": "surname description here" + } + } +} \ No newline at end of file diff --git a/test/fixtures/parse/optional/Actual.js b/test/fixtures/parse/optional/Actual.js new file mode 100644 index 0000000..636bdbc --- /dev/null +++ b/test/fixtures/parse/optional/Actual.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { props, t } from '../../../../.'; + +@props({ + name: t.maybe(t.String) +}) +export default class Component extends React.Component { + + render() { + return ( +
+

{this.props.name}

+
+ ); + } +} diff --git a/test/fixtures/parse/optional/expected.json b/test/fixtures/parse/optional/expected.json new file mode 100644 index 0000000..3bf4a09 --- /dev/null +++ b/test/fixtures/parse/optional/expected.json @@ -0,0 +1,11 @@ +{ + "name": "Component", + "description": null, + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": false + } + } +} \ No newline at end of file diff --git a/test/fixtures/parse/prop-description/Actual.js b/test/fixtures/parse/prop-description/Actual.js new file mode 100644 index 0000000..d07f541 --- /dev/null +++ b/test/fixtures/parse/prop-description/Actual.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { props, t } from '../../../../.'; + +/** + * @param name - prop description + */ + +@props({ + name: t.String +}) +export default class Component extends React.Component { + + render() { + return ( +
+

{this.props.name}

+
+ ); + } +} diff --git a/test/fixtures/parse/prop-description/expected.json b/test/fixtures/parse/prop-description/expected.json new file mode 100644 index 0000000..612affa --- /dev/null +++ b/test/fixtures/parse/prop-description/expected.json @@ -0,0 +1,12 @@ +{ + "name": "Component", + "description": null, + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": true, + "description": "prop description" + } + } +} \ No newline at end of file diff --git a/test/fixtures/parse/require/Actual.js b/test/fixtures/parse/require/Actual.js new file mode 100644 index 0000000..4d10800 --- /dev/null +++ b/test/fixtures/parse/require/Actual.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { props, t } from '../../../../.'; +import User from './User'; + +/** + * Component description here + * name and surname must be both nil or both specified + * @param name - name description + * @param surname - surname description + */ + +const Props = t.refinement(t.struct({ + name: t.maybe(User.meta.props.name), + surname: t.maybe(User.meta.props.surname) +}), (x) => t.Nil.is(x.name) === t.Nil.is(x.surname)); + +@props(Props) +export default class Component extends React.Component { + + static defaultProps = { + name: 'Giulio', + surname: 'Canti' + } + + render() { + return ( +
+

{this.props.name}

+

{this.props.surname}

+
+ ); + } +} diff --git a/test/fixtures/parse/require/User.js b/test/fixtures/parse/require/User.js new file mode 100644 index 0000000..ede118a --- /dev/null +++ b/test/fixtures/parse/require/User.js @@ -0,0 +1,6 @@ +import { t } from '../../../../.'; + +export default t.struct({ + name: t.String, + surname: t.String +}, 'User'); diff --git a/test/fixtures/parse/require/expected.json b/test/fixtures/parse/require/expected.json new file mode 100644 index 0000000..7ac6311 --- /dev/null +++ b/test/fixtures/parse/require/expected.json @@ -0,0 +1,20 @@ +{ + "name": "Component", + "description": "Component description here\nname and surname must be both nil or both specified", + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": false, + "defaultValue": "Giulio", + "description": "name description" + }, + "surname": { + "kind": "irreducible", + "name": "String", + "required": false, + "defaultValue": "Canti", + "description": "surname description" + } + } +} \ No newline at end of file diff --git a/test/fixtures/parse/required/Actual.js b/test/fixtures/parse/required/Actual.js new file mode 100644 index 0000000..0b597f0 --- /dev/null +++ b/test/fixtures/parse/required/Actual.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { props, t } from '../../../../.'; + +@props({ + name: t.String +}) +export default class Component extends React.Component { + + render() { + return ( +
+

{this.props.name}

+
+ ); + } +} diff --git a/test/fixtures/parse/required/expected.json b/test/fixtures/parse/required/expected.json new file mode 100644 index 0000000..9bb67c3 --- /dev/null +++ b/test/fixtures/parse/required/expected.json @@ -0,0 +1,11 @@ +{ + "name": "Component", + "description": null, + "props": { + "name": { + "kind": "irreducible", + "name": "String", + "required": true + } + } +} \ No newline at end of file diff --git a/test/fixtures/toMarkdown/default/expected.md b/test/fixtures/toMarkdown/default/expected.md new file mode 100644 index 0000000..a1fc0cc --- /dev/null +++ b/test/fixtures/toMarkdown/default/expected.md @@ -0,0 +1,5 @@ +## Component + +**Props** + +- `name: String` (optional, default: `"Giulio"`) diff --git a/test/fixtures/toMarkdown/description/expected.md b/test/fixtures/toMarkdown/description/expected.md new file mode 100644 index 0000000..a16e27f --- /dev/null +++ b/test/fixtures/toMarkdown/description/expected.md @@ -0,0 +1,7 @@ +## Component + +component description + +**Props** + +- `name: String` diff --git a/test/fixtures/toMarkdown/example/expected.md b/test/fixtures/toMarkdown/example/expected.md new file mode 100644 index 0000000..ec6e79c --- /dev/null +++ b/test/fixtures/toMarkdown/example/expected.md @@ -0,0 +1,8 @@ +## Card + +Component description here + +**Props** + +- `name: String` name description here +- `surname: String` (optional, default: `"Canti"`) surname description here \ No newline at end of file diff --git a/test/fixtures/toMarkdown/optional/expected.md b/test/fixtures/toMarkdown/optional/expected.md new file mode 100644 index 0000000..86f0041 --- /dev/null +++ b/test/fixtures/toMarkdown/optional/expected.md @@ -0,0 +1,5 @@ +## Component + +**Props** + +- `name: String` (optional) diff --git a/test/fixtures/toMarkdown/prop-description/expected.md b/test/fixtures/toMarkdown/prop-description/expected.md new file mode 100644 index 0000000..0730594 --- /dev/null +++ b/test/fixtures/toMarkdown/prop-description/expected.md @@ -0,0 +1,5 @@ +## Component + +**Props** + +- `name: String` prop description diff --git a/test/fixtures/toMarkdown/require/expected.md b/test/fixtures/toMarkdown/require/expected.md new file mode 100644 index 0000000..10a4a9e --- /dev/null +++ b/test/fixtures/toMarkdown/require/expected.md @@ -0,0 +1,9 @@ +## Component + +Component description here +name and surname must be both nil or both specified + +**Props** + +- `name: String` (optional, default: `"Giulio"`) name description +- `surname: String` (optional, default: `"Canti"`) surname description \ No newline at end of file diff --git a/test/fixtures/toMarkdown/required/expected.md b/test/fixtures/toMarkdown/required/expected.md new file mode 100644 index 0000000..b326a3c --- /dev/null +++ b/test/fixtures/toMarkdown/required/expected.md @@ -0,0 +1,5 @@ +## Component + +**Props** + +- `name: String` diff --git a/test/test.js b/test/test.js index 156818a..3a315ab 100644 --- a/test/test.js +++ b/test/test.js @@ -8,6 +8,10 @@ var library = require('../index'); var getPropTypes = library.propTypes; var ReactElement = library.ReactElement; var ReactNode = library.ReactNode; +var path = require('path'); +var fs = require('fs'); +var parse = require('../lib/parse'); +var toMarkdown = require('../lib/toMarkdown'); function throwsWithMessage(f, message) { assert.throws(f, function (err) { @@ -219,3 +223,43 @@ describe('pre-defined types', function () { }); }); + +var skipDirectories = { + '.DS_Store': 1 +}; + +describe('parse', function () { + var fixturesDir = path.join(__dirname, 'fixtures/parse'); + fs.readdirSync(fixturesDir).map(function (caseName) { + if ((caseName in skipDirectories)) { + return; + } + it(caseName, function () { + var fixtureDir = path.join(fixturesDir, caseName); + var filepath = path.join(fixtureDir, 'Actual.js'); + var expected = require(path.join(fixtureDir, 'expected.json')); + assert.deepEqual(JSON.stringify(parse(filepath)), JSON.stringify(expected)); + }); + }); +}); + +function trim(str) { + return str.replace(/^\s+|\s+$/, ''); +} + +describe('toMarkdown', function () { + var fixturesDir = path.join(__dirname, 'fixtures/toMarkdown'); + fs.readdirSync(fixturesDir).map(function (caseName) { + if ((caseName in skipDirectories)) { + return; + } + it(caseName, function () { + var actualFixtureDir = path.join(__dirname, 'fixtures/parse', caseName); + var filepath = path.join(actualFixtureDir, 'Actual.js'); + var fixtureDir = path.join(fixturesDir, caseName); + const expected = fs.readFileSync(path.join(fixtureDir, 'expected.md')).toString(); + assert.equal(trim(toMarkdown(parse(filepath))), trim(expected)); + }); + }); +}); +