From 42e3292779aeb2cec27153b16477a12163ce410c Mon Sep 17 00:00:00 2001 From: Amour1688 Date: Sat, 16 May 2020 21:24:51 +0800 Subject: [PATCH] support mergeProps --- .babelrc | 4 +- .jest.js | 4 +- src/babel-plugin-transform-vue-jsx.js | 287 ++++++++++++++++++++++++++ src/index.js | 236 +-------------------- test/index.test.js | 79 ++++++- 5 files changed, 369 insertions(+), 241 deletions(-) create mode 100644 src/babel-plugin-transform-vue-jsx.js diff --git a/.babelrc b/.babelrc index 87db373..8725d37 100644 --- a/.babelrc +++ b/.babelrc @@ -1,8 +1,6 @@ { "presets": [ - "@babel/env" - ], - "plugins": [ + "@babel/env", "./src/index.js" ] } diff --git a/.jest.js b/.jest.js index e71c516..1c06231 100644 --- a/.jest.js +++ b/.jest.js @@ -1,5 +1,7 @@ +const { h, mergeProps } = require('vue'); module.exports = { globals: { - "_h": require('vue').h // TODO: for jest error _h is not defined + "_h": h, + "_mergeProps": mergeProps } } diff --git a/src/babel-plugin-transform-vue-jsx.js b/src/babel-plugin-transform-vue-jsx.js new file mode 100644 index 0000000..4bed164 --- /dev/null +++ b/src/babel-plugin-transform-vue-jsx.js @@ -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 + * @param injected {} + * @returns Array + */ +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, + }), + ); + }, + }, + }, +}); diff --git a/src/index.js b/src/index.js index ede9908..e840d31 100644 --- a/src/index.js +++ b/src/index.js @@ -1,235 +1,7 @@ -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])/; - -/** - * 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 - * @param injected {} - * @returns Array - */ -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)), -]); +const babelPluginTransformVueJsx = require('./babel-plugin-transform-vue-jsx'); module.exports = () => ({ - 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, - }), - ); - }, - }, - }, + plugins: [ + babelPluginTransformVueJsx, + ], }); diff --git a/test/index.test.js b/test/index.test.js index 9b9e8fe..62b877c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -64,10 +64,40 @@ test('xlink:href', () => { expect(wrapper.attributes()['xlink:href']).toBe('#name'); }); -// // test('Merge class', () => { -// // const wrapper = render(() =>
); -// // expect(wrapper.innerHTML).toBe('
'); -// // }); +test('Merge class', () => { + const wrapper = shallowMount({ + setup() { + return () =>
; + }, + }); + expect(wrapper.html()).toBe('
'); +}); + +test('Merge style', () => { + const propsA = { + style: { + color: 'red', + }, + }; + const propsB = { + style: [ + { + color: 'blue', + width: '200px', + }, + { + width: '300px', + height: '300px', + }, + ], + }; + const wrapper = shallowMount({ + setup() { + return () =>
; + }, + }); + expect(wrapper.html()).toBe('
'); +}); test('JSXSpreadChild', () => { const a = ['1', '2']; @@ -76,7 +106,7 @@ test('JSXSpreadChild', () => { return () =>
{[...a]}
; }, }); - expect(wrapper.text).toBe('12'); + expect(wrapper.text()).toBe('12'); }); test('domProps input[value]', () => { @@ -88,3 +118,42 @@ test('domProps input[value]', () => { }); expect(wrapper.html()).toBe(''); }); + +test('Spread (single object expression)', () => { + const props = { + innerHTML: 123, + other: '1', + }; + const wrapper = shallowMount({ + render() { + return
; + }, + }); + expect(wrapper.html()).toBe('
123
'); +}); + +test('Spread (mixed)', () => { + const calls = []; + const data = { + id: 'hehe', + onClick() { + calls.push(3); + }, + innerHTML: 2, + class: ['a', 'b'], + }; + + shallowMount({ + setup() { + return () => ( +
calls.push(4)} + hook-insert={() => calls.push(2)} + /> + ); + }, + }); +});