support mergeProps

This commit is contained in:
Amour1688 2020-05-16 21:24:51 +08:00
parent 8f18b5630a
commit 42e3292779
5 changed files with 369 additions and 241 deletions

View File

@ -1,8 +1,6 @@
{ {
"presets": [ "presets": [
"@babel/env" "@babel/env",
],
"plugins": [
"./src/index.js" "./src/index.js"
] ]
} }

View File

@ -1,5 +1,7 @@
const { h, mergeProps } = require('vue');
module.exports = { module.exports = {
globals: { globals: {
"_h": require('vue').h // TODO: for jest error _h is not defined "_h": h,
"_mergeProps": mergeProps
} }
} }

View File

@ -0,0 +1,287 @@
const syntaxJsx = require('@babel/plugin-syntax-jsx').default;
const t = require('@babel/types');
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const helperModuleImports = require('@babel/helper-module-imports');
const xlinkRE = /^xlink([A-Z])/;
const eventRE = /^on[A-Z][a-z]+$/;
const rootAttributes = ['class', 'style'];
/**
* click --> onClick
*/
const transformOn = (event = '') => `on${event[0].toUpperCase()}${event.substr(1)}`;
const filterEmpty = (value) => value !== undefined && value !== null;
/**
* Transform JSXMemberExpression to MemberExpression
* @param path JSXMemberExpression
* @returns MemberExpression
*/
const transformJSXMemberExpression = (path) => {
const objectPath = path.get('object');
const propertyPath = path.get('property');
const transformedObject = objectPath.isJSXMemberExpression()
? transformJSXMemberExpression(objectPath)
: objectPath.isJSXIdentifier()
? t.identifier(objectPath.node.name)
: t.nullLiteral();
const transformedProperty = t.identifier(propertyPath.get('name').node);
return t.memberExpression(transformedObject, transformedProperty);
};
/**
* Get tag (first attribute for h) from JSXOpeningElement
* @param path JSXOpeningElement
* @returns Identifier | StringLiteral | MemberExpression
*/
const getTag = (path) => {
const namePath = path.get('openingElement').get('name');
if (namePath.isJSXIdentifier()) {
const { name } = namePath.node;
if (path.scope.hasBinding(name) && !htmlTags.includes(name) && !svgTags.includes(name)) {
return t.identifier(name);
}
return t.stringLiteral(name);
}
if (namePath.isJSXMemberExpression()) {
return transformJSXMemberExpression(namePath);
}
throw new Error(`getTag: ${namePath.type} is not supported`);
};
const getJSXAttributeName = (path) => {
const nameNode = path.node.name;
if (t.isJSXIdentifier(nameNode)) {
return nameNode.name;
}
return `${nameNode.namespace.name}:${nameNode.name.name}`;
};
const getJSXAttributeValue = (path, injected) => {
const valuePath = path.get('value');
if (valuePath.isJSXElement()) {
return transformJSXElement(valuePath, injected);
}
if (valuePath.isStringLiteral()) {
return valuePath.node;
}
if (valuePath.isJSXExpressionContainer()) {
return transformJSXExpressionContainer(valuePath);
}
return null;
};
const transformJSXAttribute = (path, attributesToMerge, injected) => {
let name = getJSXAttributeName(path);
if (name === 'on') {
const { properties = [] } = getJSXAttributeValue(path);
properties.forEach((property) => {
property.key = t.identifier(transformOn(property.key.name));
});
return t.spreadElement(t.objectExpression(properties));
}
if (rootAttributes.includes(name) || eventRE.test(name)) {
attributesToMerge.push(
t.objectExpression([
t.objectProperty(
t.stringLiteral(
name,
),
getJSXAttributeValue(path, injected),
),
]),
);
return null;
}
if (name.match(xlinkRE)) {
name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`);
}
return t.objectProperty(
t.stringLiteral(
name,
),
getJSXAttributeValue(path, injected) || t.booleanLiteral(true),
);
};
const transformJSXSpreadAttribute = (path, attributesToMerge) => {
const argument = path.get('argument').node;
const { properties } = argument;
if (!properties) {
return t.spreadElement(argument);
}
return t.spreadElement(t.objectExpression(properties.filter((property) => {
const { key, value } = property;
const name = key.value;
if (rootAttributes.includes(name)) {
attributesToMerge.push(
t.objectExpression([
t.objectProperty(
t.stringLiteral(name),
value,
),
]),
);
return false;
}
return true;
})));
};
const transformAttribute = (path, attributesToMerge, injected) => (path.isJSXAttribute()
? transformJSXAttribute(path, attributesToMerge, injected)
: transformJSXSpreadAttribute(path, attributesToMerge));
const getAttributes = (path, injected) => {
const attributes = path.get('openingElement').get('attributes');
if (attributes.length === 0) {
return t.nullLiteral();
}
const attributesToMerge = [];
const attributeArray = [];
attributes
.forEach((attribute) => {
const attr = transformAttribute(attribute, attributesToMerge, injected);
if (attr) {
attributeArray.push(attr);
}
});
return t.callExpression(
injected.mergeProps,
[
...attributesToMerge,
t.objectExpression(attributeArray),
],
);
};
/**
* Transform JSXText to StringLiteral
* @param path JSXText
* @returns StringLiteral
*/
const transformJSXText = (path) => {
const { node } = path;
const lines = node.value.split(/\r\n|\n|\r/);
let lastNonEmptyLine = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/[^ \t]/)) {
lastNonEmptyLine = i;
}
}
let str = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isFirstLine = i === 0;
const isLastLine = i === lines.length - 1;
const isLastNonEmptyLine = i === lastNonEmptyLine;
// replace rendered whitespace tabs with spaces
let trimmedLine = line.replace(/\t/g, ' ');
// trim whitespace touching a newline
if (!isFirstLine) {
trimmedLine = trimmedLine.replace(/^[ ]+/, '');
}
// trim whitespace touching an endline
if (!isLastLine) {
trimmedLine = trimmedLine.replace(/[ ]+$/, '');
}
if (trimmedLine) {
if (!isLastNonEmptyLine) {
trimmedLine += ' ';
}
str += trimmedLine;
}
}
return str !== '' ? t.stringLiteral(str) : null;
};
/**
* Transform JSXExpressionContainer to Expression
* @param path JSXExpressionContainer
* @returns Expression
*/
const transformJSXExpressionContainer = (path) => path.get('expression').node;
/**
* Transform JSXSpreadChild
* @param path JSXSpreadChild
* @returns SpreadElement
*/
const transformJSXSpreadChild = (path) => t.spreadElement(path.get('expression').node);
/**
* Get children from Array of JSX children
* @param paths Array<JSXText | JSXExpressionContainer | JSXSpreadChild | JSXElement>
* @param injected {}
* @returns Array<Expression | SpreadElement>
*/
const getChildren = (paths, injected) => paths
.map((path) => {
if (path.isJSXText()) {
return transformJSXText(path);
}
if (path.isJSXExpressionContainer()) {
return transformJSXExpressionContainer(path);
}
if (path.isJSXSpreadChild()) {
return transformJSXSpreadChild(path);
}
if (path.isCallExpression()) {
return path.node;
}
if (path.isJSXElement()) {
return transformJSXElement(path, injected);
}
throw new Error(`getChildren: ${path.type} is not supported`);
}).filter(filterEmpty);
const transformJSXElement = (path, injected) => t.callExpression(injected.h, [
getTag(path),
getAttributes(path, injected),
t.arrayExpression(getChildren(path.get('children'), injected)),
]);
module.exports = () => ({
name: 'babel-plugin-transform-vue-jsx',
inherits: syntaxJsx,
visitor: {
JSXElement: {
exit(path, state) {
if (!state.vueCreateElementInjected) {
state.vueCreateElementInjected = helperModuleImports.addNamed(path, 'h', 'vue');
}
if (!state.vueMergePropsInjected) {
state.vueMergePropsInjected = helperModuleImports.addNamed(path, 'mergeProps', 'vue');
}
path.replaceWith(
transformJSXElement(path, {
h: state.vueCreateElementInjected,
mergeProps: state.vueMergePropsInjected,
}),
);
},
},
},
});

View File

@ -1,235 +1,7 @@
const syntaxJsx = require('@babel/plugin-syntax-jsx').default; const babelPluginTransformVueJsx = require('./babel-plugin-transform-vue-jsx');
const t = require('@babel/types');
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const helperModuleImports = require('@babel/helper-module-imports');
const xlinkRE = /^xlink([A-Z])/;
/**
* click --> onClick
*/
const transformOn = (event = '') => `on${event[0].toUpperCase()}${event.substr(1)}`;
const filterEmpty = (value) => value !== undefined && value !== null;
/**
* Transform JSXMemberExpression to MemberExpression
* @param path JSXMemberExpression
* @returns MemberExpression
*/
const transformJSXMemberExpression = (path) => {
const objectPath = path.get('object');
const propertyPath = path.get('property');
const transformedObject = objectPath.isJSXMemberExpression()
? transformJSXMemberExpression(objectPath)
: objectPath.isJSXIdentifier()
? t.identifier(objectPath.node.name)
: t.nullLiteral();
const transformedProperty = t.identifier(propertyPath.get('name').node);
return t.memberExpression(transformedObject, transformedProperty);
};
/**
* Get tag (first attribute for h) from JSXOpeningElement
* @param path JSXOpeningElement
* @returns Identifier | StringLiteral | MemberExpression
*/
const getTag = (path) => {
const namePath = path.get('openingElement').get('name');
if (namePath.isJSXIdentifier()) {
const { name } = namePath.node;
if (path.scope.hasBinding(name) && !htmlTags.includes(name) && !svgTags.includes(name)) {
return t.identifier(name);
}
return t.stringLiteral(name);
}
if (namePath.isJSXMemberExpression()) {
return transformJSXMemberExpression(namePath);
}
throw new Error(`getTag: ${namePath.type} is not supported`);
};
const getJSXAttributeName = (path) => {
const nameNode = path.node.name;
if (t.isJSXIdentifier(nameNode)) {
return nameNode.name;
}
return `${nameNode.namespace.name}:${nameNode.name.name}`;
};
const getJSXAttributeValue = (path, injected) => {
const valuePath = path.get('value');
if (valuePath.isJSXElement()) {
return transformJSXElement(valuePath, injected);
}
if (valuePath.isStringLiteral()) {
return valuePath.node;
}
if (valuePath.isJSXExpressionContainer()) {
return transformJSXExpressionContainer(valuePath);
}
return null;
};
const transformJSXAttribute = (path, injected) => {
let name = getJSXAttributeName(path);
if (name === 'on') {
const { properties = [] } = getJSXAttributeValue(path);
properties.forEach((property) => {
property.key = t.identifier(transformOn(property.key.name));
});
return t.spreadElement(t.objectExpression(properties));
}
if (name.match(xlinkRE)) {
name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`);
}
return t.objectProperty(
t.stringLiteral(
name,
),
getJSXAttributeValue(path, injected) || t.booleanLiteral(true),
);
};
const transformJSXSpreadAttribute = (path) => t.spreadElement(path.get('argument').node);
const transformAttribute = (path, injected) => (path.isJSXAttribute()
? transformJSXAttribute(path, injected)
: transformJSXSpreadAttribute(path));
const getAttributes = (path, injected) => {
const attributes = path.get('openingElement').get('attributes');
if (attributes.length === 0) {
return t.nullLiteral();
}
// return t.callExpression(injected.mergeProps, [attributes
// .map((el) => transformAttribute(el, injected))]);
return t.objectExpression(attributes
.map((el) => transformAttribute(el, injected)));
};
/**
* Transform JSXText to StringLiteral
* @param path JSXText
* @returns StringLiteral
*/
const transformJSXText = (path) => {
const { node } = path;
const lines = node.value.split(/\r\n|\n|\r/);
let lastNonEmptyLine = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].match(/[^ \t]/)) {
lastNonEmptyLine = i;
}
}
let str = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isFirstLine = i === 0;
const isLastLine = i === lines.length - 1;
const isLastNonEmptyLine = i === lastNonEmptyLine;
// replace rendered whitespace tabs with spaces
let trimmedLine = line.replace(/\t/g, ' ');
// trim whitespace touching a newline
if (!isFirstLine) {
trimmedLine = trimmedLine.replace(/^[ ]+/, '');
}
// trim whitespace touching an endline
if (!isLastLine) {
trimmedLine = trimmedLine.replace(/[ ]+$/, '');
}
if (trimmedLine) {
if (!isLastNonEmptyLine) {
trimmedLine += ' ';
}
str += trimmedLine;
}
}
return str !== '' ? t.stringLiteral(str) : null;
};
/**
* Transform JSXExpressionContainer to Expression
* @param path JSXExpressionContainer
* @returns Expression
*/
const transformJSXExpressionContainer = (path) => path.get('expression').node;
/**
* Transform JSXSpreadChild
* @param path JSXSpreadChild
* @returns SpreadElement
*/
const transformJSXSpreadChild = (path) => t.spreadElement(path.get('expression').node);
/**
* Get children from Array of JSX children
* @param paths Array<JSXText | JSXExpressionContainer | JSXSpreadChild | JSXElement>
* @param injected {}
* @returns Array<Expression | SpreadElement>
*/
const getChildren = (paths, injected) => paths
.map((path) => {
if (path.isJSXText()) {
return transformJSXText(path);
}
if (path.isJSXExpressionContainer()) {
return transformJSXExpressionContainer(path);
}
if (path.isJSXSpreadChild()) {
return transformJSXSpreadChild(path);
}
if (path.isCallExpression()) {
return path.node;
}
if (path.isJSXElement()) {
return transformJSXElement(path, injected);
}
throw new Error(`getChildren: ${path.type} is not supported`);
}).filter(filterEmpty);
const transformJSXElement = (path, injected) => t.callExpression(injected.h, [
getTag(path),
getAttributes(path, injected),
t.arrayExpression(getChildren(path.get('children'), injected)),
]);
module.exports = () => ({ module.exports = () => ({
inherits: syntaxJsx, plugins: [
visitor: { babelPluginTransformVueJsx,
JSXElement: { ],
exit(path, state) {
if (!state.vueCreateElementInjected) {
state.vueCreateElementInjected = helperModuleImports.addNamed(path, 'h', 'vue');
}
if (!state.vueMergePropsInjected) {
state.vueMergePropsInjected = helperModuleImports.addNamed(path, 'mergeProps', 'vue');
}
path.replaceWith(
transformJSXElement(path, {
h: state.vueCreateElementInjected,
mergeProps: state.vueMergePropsInjected,
}),
);
},
},
},
}); });

View File

@ -64,10 +64,40 @@ test('xlink:href', () => {
expect(wrapper.attributes()['xlink:href']).toBe('#name'); expect(wrapper.attributes()['xlink:href']).toBe('#name');
}); });
// // test('Merge class', () => { test('Merge class', () => {
// // const wrapper = render(() => <div class="a" {...{ class: 'b' } } />); const wrapper = shallowMount({
// // expect(wrapper.innerHTML).toBe('<div class="a b"></div>'); setup() {
// // }); return () => <div class="a" {...{ class: 'b' } } />;
},
});
expect(wrapper.html()).toBe('<div class="a b"></div>');
});
test('Merge style', () => {
const propsA = {
style: {
color: 'red',
},
};
const propsB = {
style: [
{
color: 'blue',
width: '200px',
},
{
width: '300px',
height: '300px',
},
],
};
const wrapper = shallowMount({
setup() {
return () => <div { ...propsA } { ...propsB } />;
},
});
expect(wrapper.html()).toBe('<div style="color: blue; width: 300px; height: 300px;"></div>');
});
test('JSXSpreadChild', () => { test('JSXSpreadChild', () => {
const a = ['1', '2']; const a = ['1', '2'];
@ -76,7 +106,7 @@ test('JSXSpreadChild', () => {
return () => <div>{[...a]}</div>; return () => <div>{[...a]}</div>;
}, },
}); });
expect(wrapper.text).toBe('12'); expect(wrapper.text()).toBe('12');
}); });
test('domProps input[value]', () => { test('domProps input[value]', () => {
@ -88,3 +118,42 @@ test('domProps input[value]', () => {
}); });
expect(wrapper.html()).toBe('<input type="text">'); expect(wrapper.html()).toBe('<input type="text">');
}); });
test('Spread (single object expression)', () => {
const props = {
innerHTML: 123,
other: '1',
};
const wrapper = shallowMount({
render() {
return <div {...props}></div>;
},
});
expect(wrapper.html()).toBe('<div other="1">123</div>');
});
test('Spread (mixed)', () => {
const calls = [];
const data = {
id: 'hehe',
onClick() {
calls.push(3);
},
innerHTML: 2,
class: ['a', 'b'],
};
shallowMount({
setup() {
return () => (
<div
href="huhu"
{...data}
class={{ c: true }}
onClick={() => calls.push(4)}
hook-insert={() => calls.push(2)}
/>
);
},
});
});