Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(injector): Add reconcilePropTypes #10

Merged
merged 9 commits into from
Apr 6, 2020
29 changes: 26 additions & 3 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ export interface GenerateOptions {
*/
includeJSDoc?: boolean;

/**
* previous source code of the validator for each prop type
*/
previousPropTypesSource?: Map<string, string>;

/**
* Given the `prop`, the `previous` source of the validator and the `generated` source:
* What source should be injected? `previous` is `undefined` if the validator
* didn't exist before
* @default Uses `generated` source
*/
reconcilePropTypes?(
proptype: t.PropTypeNode,
previous: string | undefined,
generated: string
): string;

/**
* Control which PropTypes are included in the final result
* @param proptype The current PropType about to be converted to text
Expand All @@ -46,6 +63,8 @@ export function generate(node: t.Node | t.PropTypeNode[], options: GenerateOptio
sortProptypes = true,
importedName = 'PropTypes',
includeJSDoc = true,
previousPropTypesSource = new Map<string, string>(),
reconcilePropTypes = (_prop: t.PropTypeNode, _previous: string, generated: string) => generated,
shouldInclude,
} = options;

Expand Down Expand Up @@ -112,9 +131,13 @@ export function generate(node: t.Node | t.PropTypeNode[], options: GenerateOptio
}
}

return `${jsDoc(node)}"${node.name}": ${generate(propType, options)}${
isOptional ? '' : '.isRequired'
},`;
const validatorSource = reconcilePropTypes(
node,
previousPropTypesSource.get(node.name),
`${generate(propType, options)}${isOptional ? '' : '.isRequired'},`
);

return `${jsDoc(node)}"${node.name}": ${validatorSource}`;
}

if (t.isInterfaceNode(node)) {
Expand Down
60 changes: 46 additions & 14 deletions src/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ export type InjectOptions = {
* Set this to true to include them instead.
*/
includeUnusedProps?: boolean;
/**
* Given the `prop`, the `previous` source of the validator and the `generated` source:
* What source should be injected?
* @default Uses `generated` source
*/
reconcilePropTypes?: (
prop: t.PropTypeNode,
previous: string | undefined,
generated: string
) => string;
/**
* By default existing PropTypes are left alone, set this to true
* to have them removed before injecting the PropTypes
Expand Down Expand Up @@ -84,8 +94,16 @@ function plugin(
options: InjectOptions = {},
mapOfPropTypes: Map<string, string>
): babel.PluginObj {
const { includeUnusedProps = false, removeExistingPropTypes = false, ...otherOptions } = options;

const {
includeUnusedProps = false,
reconcilePropTypes = (
_prop: t.PropTypeNode,
_previous: string | undefined,
generated: string
) => generated,
removeExistingPropTypes = false,
...otherOptions
} = options;
const shouldInclude: Exclude<InjectOptions['shouldInclude'], undefined> = (data) => {
if (options.shouldInclude) {
const result = options.shouldInclude(data);
Expand All @@ -100,11 +118,12 @@ function plugin(
let importName = '';
let needImport = false;
let alreadyImported = false;
let previousPropTypesSource = new Map<string, string>();

return {
visitor: {
Program: {
enter(path) {
enter(path, state: any) {
if (
!path.node.body.some((n) => {
if (
Expand All @@ -121,19 +140,30 @@ function plugin(
importName = 'PropTypes';
}

if (removeExistingPropTypes) {
path.get('body').forEach((nodePath) => {
const { node } = nodePath;
if (
babelTypes.isExpressionStatement(node) &&
babelTypes.isAssignmentExpression(node.expression, { operator: '=' }) &&
babelTypes.isMemberExpression(node.expression.left) &&
babelTypes.isIdentifier(node.expression.left.property, { name: 'propTypes' })
) {
path.get('body').forEach((nodePath) => {
const { node } = nodePath;
if (
babelTypes.isExpressionStatement(node) &&
babelTypes.isAssignmentExpression(node.expression, { operator: '=' }) &&
babelTypes.isMemberExpression(node.expression.left) &&
babelTypes.isIdentifier(node.expression.left.property, { name: 'propTypes' })
) {
if (babelTypes.isObjectExpression(node.expression.right)) {
const { code } = state.file;

node.expression.right.properties.forEach((property) => {
if (babelTypes.isObjectProperty(property)) {
const validatorSource = code.slice(property.value.start, property.value.end);
previousPropTypesSource.set(property.key.name, validatorSource);
}
});
}

if (removeExistingPropTypes) {
nodePath.remove();
}
});
}
}
});
},
exit(path) {
if (alreadyImported || !needImport) return;
Expand Down Expand Up @@ -267,6 +297,8 @@ function plugin(
const source = generate(props, {
...otherOptions,
importedName: importName,
previousPropTypesSource,
reconcilePropTypes,
shouldInclude: (prop) => shouldInclude({ component: props, prop, usedProps }),
});

Expand Down
24 changes: 16 additions & 8 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ for (const testCase of testCases) {
const astPath = path.join(dirname, 'output.json');
const outputPath = path.join(dirname, 'output.js');
const optionsPath = path.join(dirname, 'options.ts');
const inputJS = path.join(dirname, 'input.js');

it(testName, () => {
const options: TestOptions = fs.existsSync(optionsPath) ? require(optionsPath).default : {};
Expand All @@ -42,21 +43,28 @@ for (const testCase of testCases) {
}
//#endregion

let result = '';
// For d.ts files we just generate the AST
let inputSource = null;
if (testCase.endsWith('.d.ts')) {
result = ttp.generate(ast, options.generator);
}
// For .tsx? files we transpile them and inject the proptypes
else {
const transpiled = ttp.ts.transpileModule(fs.readFileSync(testCase, 'utf8'), {
try {
inputSource = fs.readFileSync(inputJS, { encoding: 'utf8' });
} catch (error) {}
} else {
inputSource = ttp.ts.transpileModule(fs.readFileSync(testCase, 'utf8'), {
compilerOptions: {
target: ttp.ts.ScriptTarget.ESNext,
jsx: ttp.ts.JsxEmit.Preserve,
},
}).outputText;
}

const injected = ttp.inject(ast, transpiled, options.injector);
let result = '';
// For d.ts-only files we just generate the AST
if (!inputSource) {
result = ttp.generate(ast);
}
// For .tsx? files we transpile them and inject the proptypes
else {
const injected = ttp.inject(ast, inputSource, options.injector);
if (!injected) {
throw new Error('Injection failed');
}
Expand Down
7 changes: 7 additions & 0 deletions test/reconcile-prop-types/input.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

interface Props {
children: React.ReactNode;
}

export default function Component(props: Props): JSX.Element;
29 changes: 29 additions & 0 deletions test/reconcile-prop-types/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { chainPropTypes } from 'some-utils-module';

function Component(props) {
const { children } = props;
return (
<button>
<span>{children}</span>
</button>
);
}

Component.propTypes = {
children: chainPropTypes(PropTypes.node.isRequired, (props) => {
const summary = React.Children.toArray(props.children)[0];
if (isFragment(summary)) {
return new Error('Not accepting Fragments');
}

if (!React.isValidElement(summary)) {
return new Error('First child must be an element');
}

return null;
}),
};

export default Component;
17 changes: 17 additions & 0 deletions test/reconcile-prop-types/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestOptions } from '../types';

const options: TestOptions = {
injector: {
removeExistingPropTypes: true,
reconcilePropTypes: (prop, previous: any, generated) => {
const isCustomValidator = previous !== undefined && !previous.startsWith('PropTypes');

if (isCustomValidator) {
return previous;
}
return generated;
},
},
};

export default options;
29 changes: 29 additions & 0 deletions test/reconcile-prop-types/output.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { chainPropTypes } from 'some-utils-module';

function Component(props) {
const { children } = props;
return (
<button>
<span>{children}</span>
</button>
);
}

Component.propTypes = {
children: chainPropTypes(PropTypes.node.isRequired, (props) => {
const summary = React.Children.toArray(props.children)[0];
if (isFragment(summary)) {
return new Error('Not accepting Fragments');
}

if (!React.isValidElement(summary)) {
return new Error('First child must be an element');
}

return null;
}),
};

export default Component;
19 changes: 19 additions & 0 deletions test/reconcile-prop-types/output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"type": "ProgramNode",
"body": [
{
"type": "ComponentNode",
"name": "Component",
"types": [
{
"type": "PropTypeNode",
"name": "children",
"propType": {
"type": "UnionNode",
"types": [{ "type": "ElementNode", "elementType": "node" }, { "type": "UndefinedNode" }]
}
}
]
}
]
}