diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index b57205d..c29a890 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,5 +19,7 @@ module.exports = { 'no-use-before-define': [0], 'no-plusplus': [0], 'import/no-extraneous-dependencies': [0], + 'consistent-return': [0], + 'no-bitwise': [0] }, }; diff --git a/.gitignore b/.gitignore index 6704566..be65f26 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +dist diff --git a/packages/babel-plugin-jsx/babel.config.js b/packages/babel-plugin-jsx/babel.config.js index fba34fd..4c835be 100644 --- a/packages/babel-plugin-jsx/babel.config.js +++ b/packages/babel-plugin-jsx/babel.config.js @@ -3,11 +3,12 @@ module.exports = { [ '@babel/env', { - // "modules": "cjs" + // modules: 'cjs', }, ], ], plugins: [ - ['./src/index.js', { transformOn: true }], + /* eslint-disable-next-line global-require */ + [require('./dist/index.js'), { transformOn: true }], ], }; diff --git a/packages/babel-plugin-jsx/package.json b/packages/babel-plugin-jsx/package.json index c4b0b6a..6f6723a 100644 --- a/packages/babel-plugin-jsx/package.json +++ b/packages/babel-plugin-jsx/package.json @@ -11,9 +11,10 @@ "url": "git+https://github.com/vueComponent/jsx.git" }, "scripts": { - "dev": "webpack-dev-server", + "dev": "npm run build && webpack-dev-server", + "build": "rollup -c", "lint": "eslint --ext .js src", - "test": "jest" + "test": "jest --coverage" }, "bugs": { "url": "https://github.com/vueComponent/jsx/issues" @@ -30,12 +31,14 @@ "devDependencies": { "@babel/core": "^7.9.6", "@babel/preset-env": "^7.9.6", + "@rollup/plugin-babel": "^5.0.3", "@vue/compiler-dom": "^3.0.0-beta.14", "@vue/test-utils": "^2.0.0-alpha.6", "babel-jest": "^26.0.1", "babel-loader": "^8.1.0", "jest": "^26.0.1", "regenerator-runtime": "^0.13.5", + "rollup": "^2.13.1", "vue": "^3.0.0-beta.14", "webpack": "^4.43.0", "webpack-cli": "^3.3.11", diff --git a/packages/babel-plugin-jsx/rollup.config.js b/packages/babel-plugin-jsx/rollup.config.js new file mode 100644 index 0000000..e71f6f0 --- /dev/null +++ b/packages/babel-plugin-jsx/rollup.config.js @@ -0,0 +1,26 @@ +import babel from '@rollup/plugin-babel'; + +export default { + input: 'src/index.js', + plugins: [ + babel({ + presets: [ + [ + '@babel/preset-env', + { + targets: { + node: 8, + }, + modules: false, + }, + ], + ], + }), + ], + output: [ + { + file: 'dist/index.js', + format: 'cjs', + }, + ], +}; diff --git a/packages/babel-plugin-jsx/src/index.js b/packages/babel-plugin-jsx/src/index.js index 3084b5e..1ae8053 100644 --- a/packages/babel-plugin-jsx/src/index.js +++ b/packages/babel-plugin-jsx/src/index.js @@ -1,9 +1,9 @@ -const syntaxJsx = require('@babel/plugin-syntax-jsx').default; -const tranformVueJSX = require('./transform-vue-jsx'); -const sugarVModel = require('./sugar-v-model'); -const sugarFragment = require('./sugar-fragment'); +import syntaxJsx from '@babel/plugin-syntax-jsx'; +import tranformVueJSX from './transform-vue-jsx'; +import sugarVModel from './sugar-v-model'; +import sugarFragment from './sugar-fragment'; -module.exports = ({ types: t }) => ({ +export default ({ types: t }) => ({ name: 'babel-plugin-jsx', inherits: syntaxJsx, visitor: { diff --git a/packages/babel-plugin-jsx/src/sugar-fragment.js b/packages/babel-plugin-jsx/src/sugar-fragment.js index f7b50e0..673fa77 100644 --- a/packages/babel-plugin-jsx/src/sugar-fragment.js +++ b/packages/babel-plugin-jsx/src/sugar-fragment.js @@ -1,22 +1,30 @@ -const helperModuleImports = require('@babel/helper-module-imports'); +import { addNamespace } from '@babel/helper-module-imports'; -const transformFragment = (t, path, { name }) => { +const transformFragment = (t, path, Fragment) => { const children = path.get('children') || []; return t.jsxElement( - t.jsxOpeningElement(t.jsxIdentifier(name), []), - t.jsxClosingElement(t.jsxIdentifier(name)), + t.jsxOpeningElement(Fragment, []), + t.jsxClosingElement(Fragment), children.map(({ node }) => node), false, ); }; -module.exports = (t) => ({ +export default (t) => ({ JSXFragment: { - enter(path) { - if (!path.vueFragment) { - path.vueFragment = helperModuleImports.addNamed(path, 'Fragment', 'vue'); + enter(path, state) { + if (!state.get('vue')) { + state.set('vue', addNamespace(path, 'vue')); } - path.replaceWith(transformFragment(t, path, path.vueFragment)); + path.replaceWith( + transformFragment( + t, path, + t.jsxMemberExpression( + t.jsxIdentifier(state.get('vue').name), + t.jsxIdentifier('Fragment'), + ), + ), + ); }, }, }); diff --git a/packages/babel-plugin-jsx/src/sugar-v-model.js b/packages/babel-plugin-jsx/src/sugar-v-model.js index 690716b..782ee59 100644 --- a/packages/babel-plugin-jsx/src/sugar-v-model.js +++ b/packages/babel-plugin-jsx/src/sugar-v-model.js @@ -1,8 +1,6 @@ -const htmlTags = require('html-tags'); -const svgTags = require('svg-tags'); -const camelCase = require('camelcase'); -const { addNamed } = require('@babel/helper-module-imports'); - +import camelCase from 'camelcase'; +import { addNamespace } from '@babel/helper-module-imports'; +import { createIdentifier, checkIsComponent } from './utils'; const cachedCamelCase = (() => { const cache = Object.create(null); @@ -55,30 +53,11 @@ const getType = (t, path) => { return typePath ? typePath.get('value.value').node : ''; }; -/** - * Check if a JSXOpeningElement is a component - * - * @param t - * @param path JSXOpeningElement - * @returns boolean - */ -const isComponent = (t, path) => { - const name = path.get('name'); - - if (t.isJSXMemberExpression(name)) { - return true; - } - - const tag = name.get('name').node; - - return !htmlTags.includes(tag) && !svgTags.includes(tag); -}; - /** * @param t * Transform vModel */ -const getModelDirective = (t, path, value) => { +const getModelDirective = (t, path, state, value) => { const tag = getTagName(path); const type = getType(t, path); @@ -94,7 +73,7 @@ const getModelDirective = (t, path, value) => { ]), )); - if (isComponent(t, path)) { + if (checkIsComponent(t, path)) { addProp(path, t.jsxAttribute(t.jsxIdentifier('modelValue'), t.jsxExpressionContainer(value))); return null; } @@ -102,35 +81,21 @@ const getModelDirective = (t, path, value) => { let modelToUse; switch (tag) { case 'select': - if (!path.vueVModelSelect) { - path.vueVModelSelect = addNamed(path, 'vModelSelect', 'vue'); - } - modelToUse = path.vueVModelSelect; + modelToUse = createIdentifier(t, state, 'vModelSelect'); break; case 'textarea': - if (!path.vueVModelText) { - path.vueVModelText = addNamed(path, 'vModelText', 'vue'); - } + modelToUse = createIdentifier(t, state, 'vModelText'); break; default: switch (type) { case 'checkbox': - if (!path.vueVModelCheckbox) { - path.vueVModelCheckbox = addNamed(path, 'vModelCheckbox', 'vue'); - } - modelToUse = path.vueVModelCheckbox; + modelToUse = createIdentifier(t, state, 'vModelCheckbox'); break; case 'radio': - if (!path.vueVModelRadio) { - path.vueVModelRadio = addNamed(path, 'vModelRadio', 'vue'); - } - modelToUse = path.vueVModelRadio; + modelToUse = createIdentifier(t, state, 'vModelRadio'); break; default: - if (!path.vueVModelText) { - path.vueVModelText = addNamed(path, 'vModelText', 'vue'); - } - modelToUse = path.vueVModelText; + modelToUse = createIdentifier(t, state, 'vModelText'); } } @@ -155,6 +120,7 @@ const parseVModel = (t, path) => { } const modifiers = path.get('name.name').node.split('_'); + modifiers.shift(); return { modifiers: new Set(modifiers), @@ -162,23 +128,27 @@ const parseVModel = (t, path) => { }; }; -module.exports = (t) => ({ +export default (t) => ({ JSXAttribute: { - exit(path) { + exit(path, state) { const parsed = parseVModel(t, path); if (!parsed) { return; } + if (!state.get('vue')) { + state.set('vue', addNamespace(path, 'vue')); + } + const { modifiers, value } = parsed; const parent = path.parentPath; // v-model={xx} --> v-_model={[directive, xx, void 0, { a: true, b: true }]} - const directive = getModelDirective(t, parent, value); + const directive = getModelDirective(t, parent, state, value); if (directive) { path.replaceWith( t.jsxAttribute( - t.jsxIdentifier('v-_model'), // TODO + t.jsxIdentifier('_model'), // TODO t.jsxExpressionContainer( t.arrayExpression([ directive, diff --git a/packages/babel-plugin-jsx/src/transform-vue-jsx.js b/packages/babel-plugin-jsx/src/transform-vue-jsx.js index bb763d7..d2028d6 100644 --- a/packages/babel-plugin-jsx/src/transform-vue-jsx.js +++ b/packages/babel-plugin-jsx/src/transform-vue-jsx.js @@ -1,69 +1,48 @@ -const htmlTags = require('html-tags'); -const svgTags = require('svg-tags'); -const { addNamed, addDefault } = require('@babel/helper-module-imports'); +import { addDefault, addNamespace } from '@babel/helper-module-imports'; +import { + createIdentifier, + PatchFlags, + PatchFlagNames, + isDirective, + checkIsComponent, + getTag, + getJSXAttributeName, + transformJSXText, + transformJSXExpressionContainer, + transformJSXSpreadChild, +} from './utils'; const xlinkRE = /^xlink([A-Z])/; -const eventRE = /^on[A-Z][a-z]+$/; +const onRE = /^on[A-Z][a-z]+$/; const rootAttributes = ['class', 'style']; +const isOn = (key) => onRE.test(key); -/** - * Checks if string is describing a directive - * @param src string - */ -const isDirective = (src) => src.startsWith('v-') - || (src.startsWith('v') && src.length >= 2 && src[1] >= 'A' && src[1] <= 'Z'); - -/** - * Transform JSXMemberExpression to MemberExpression - * @param t - * @param path JSXMemberExpression - * @returns MemberExpression - */ -const transformJSXMemberExpression = (t, path) => { - const objectPath = path.get('object'); - const propertyPath = path.get('property'); - - const transformedObject = objectPath.isJSXMemberExpression() - ? transformJSXMemberExpression(t, 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 t - * @param path JSXOpeningElement - * @returns Identifier | StringLiteral | MemberExpression - */ -const getTag = (t, 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); +const transformJSXSpreadAttribute = (t, path, mergeArgs) => { + 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)) { + mergeArgs.push( + t.objectExpression([ + t.objectProperty( + t.stringLiteral(name), + value, + ), + ]), + ); + return false; } - - return t.stringLiteral(name); - } - - if (namePath.isJSXMemberExpression()) { - return transformJSXMemberExpression(t, namePath); - } - throw new Error(`getTag: ${namePath.type} is not supported`); + return true; + }))); }; -const getJSXAttributeName = (t, path) => { - const nameNode = path.node.name; - if (t.isJSXIdentifier(nameNode)) { - return nameNode.name; - } - - return `${nameNode.namespace.name}:${nameNode.name.name}`; -}; +const needToMerge = (name) => rootAttributes.includes(name) || isOn(name); const getJSXAttributeValue = (t, path) => { const valuePath = path.get('value'); @@ -80,183 +59,191 @@ const getJSXAttributeValue = (t, path) => { return null; }; -const transformJSXAttribute = (t, path, state, attributesToMerge, directives) => { - let name = getJSXAttributeName(t, path); - const attributeValue = getJSXAttributeValue(t, path); - if (state.opts.transformOn && (name === 'on' || name === 'nativeOn')) { - const transformOn = addDefault(path, '@ant-design-vue/babel-helper-vue-transform-on', { nameHint: '_transformOn' }); - attributesToMerge.push(t.callExpression( - transformOn, - [attributeValue || t.booleanLiteral(true)], - )); - return null; +/** + * Check if an attribute value is constant + * @param t + * @param path + * @returns boolean + */ +const isConstant = (t, path) => { + if (t.isIdentifier(path)) { + return path.name === 'undefined'; } - if (isDirective(name)) { - const directiveName = name.startsWith('v-') - ? name.replace('v-', '') - : name.replace(`v${name[1]}`, name[1].toLowerCase()); - if (directiveName === '_model') { - directives.push(attributeValue); - } else if (directiveName === 'show') { - directives.push(t.arrayExpression([ - state.vShow, - attributeValue, - ])); - } else { - directives.push(t.arrayExpression([ - t.callExpression(state.resolveDirective, [ - t.stringLiteral(directiveName), - ]), - attributeValue, - ])); - } - return null; + if (t.isArrayExpression(path)) { + return path.elements.every((element) => isConstant(t, element)); } - if (rootAttributes.includes(name) || eventRE.test(name)) { - attributesToMerge.push( - t.objectExpression([ - t.objectProperty( - t.stringLiteral( - name, - ), - attributeValue, - ), - ]), - ); - return null; + if (t.isObjectExpression(path)) { + return path.properties.every((property) => isConstant(t, property.value)); } - if (name.match(xlinkRE)) { - name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`); - } - - return t.objectProperty( - t.stringLiteral( - name, - ), - attributeValue || t.booleanLiteral(true), - ); -}; - -const transformJSXSpreadAttribute = (t, 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; - } + if (t.isLiteral(path)) { return true; - }))); + } + return false; }; -const transformAttribute = (t, path, state, attributesToMerge, directives) => ( - path.isJSXAttribute() - ? transformJSXAttribute(t, path, state, attributesToMerge, directives) - : transformJSXSpreadAttribute(t, path, attributesToMerge)); - -const getAttributes = (t, path, state, directives) => { - const attributes = path.get('openingElement').get('attributes'); - if (attributes.length === 0) { - return t.nullLiteral(); +const buildProps = (t, path, state) => { + const isComponent = checkIsComponent(t, path.get('openingElement')); + const props = path.get('openingElement').get('attributes'); + const directives = []; + if (props.length === 0) { + return { + props: t.nullLiteral(), + directives, + }; } - const attributesToMerge = []; - const attributeArray = []; - attributes - .forEach((attribute) => { - const attr = transformAttribute(t, attribute, state, attributesToMerge, directives); - if (attr) { - attributeArray.push(attr); + const propsExpression = []; + + // patchFlag analysis + let patchFlag = 0; + let hasRef = false; + let hasClassBinding = false; + let hasStyleBinding = false; + let hasHydrationEventBinding = false; + let hasDynamicKeys = false; + + const dynamicPropNames = []; + const mergeArgs = []; + + props + .forEach((prop) => { + if (prop.isJSXAttribute()) { + let name = getJSXAttributeName(t, prop); + + if (name === '_model') { + name = 'onUpdate:modelValue'; + } + + const attributeValue = getJSXAttributeValue(t, prop); + + if (!isConstant(t, attributeValue) || name === 'ref') { + if ( + !isComponent + && isOn(name) + // omit the flag for click handlers becaues hydration gives click + // dedicated fast path. + && name.toLowerCase() !== 'onclick' + // omit v-model handlers + && name !== 'onUpdate:modelValue' + ) { + hasHydrationEventBinding = true; + } + + if (name === 'ref') { + hasRef = true; + } else if (name === 'class' && !isComponent) { + hasClassBinding = true; + } else if (name === 'style' && !isComponent) { + hasStyleBinding = true; + } else if ( + name !== 'key' + && !isDirective(name) + && name !== 'on' + && !dynamicPropNames.includes(name) + ) { + dynamicPropNames.push(name); + } + } + if (state.opts.transformOn && (name === 'on' || name === 'nativeOn')) { + const transformOn = addDefault( + path, + '@ant-design-vue/babel-helper-vue-transform-on', + { nameHint: '_transformOn' }, + ); + mergeArgs.push(t.callExpression( + transformOn, + [attributeValue || t.booleanLiteral(true)], + )); + return; + } + if (isDirective(name) || name === 'onUpdate:modelValue') { + if (name === 'onUpdate:modelValue') { + directives.push(attributeValue); + } else { + const directiveName = name.startsWith('v-') + ? name.replace('v-', '') + : name.replace(`v${name[1]}`, name[1].toLowerCase()); + if (directiveName === 'show') { + directives.push(t.arrayExpression([ + createIdentifier(t, state, 'vShow'), + attributeValue, + ])); + } else { + directives.push(t.arrayExpression([ + t.callExpression(createIdentifier(t, state, 'resolveDirective'), [ + t.stringLiteral(directiveName), + ]), + attributeValue, + ])); + } + } + return; + } + if (needToMerge(name)) { + mergeArgs.push( + t.objectExpression([ + t.objectProperty( + t.stringLiteral( + name, + ), + attributeValue, + ), + ]), + ); + return; + } + if (name.match(xlinkRE)) { + name = name.replace(xlinkRE, (_, firstCharacter) => `xlink:${firstCharacter.toLowerCase()}`); + } + propsExpression.push(t.objectProperty( + t.stringLiteral(name), + attributeValue || t.booleanLiteral(true), + )); + } else { + hasDynamicKeys = true; + propsExpression.push(transformJSXSpreadAttribute(t, prop, mergeArgs)); } }); - return t.callExpression( - state.mergeProps, - [ - ...attributesToMerge, - t.objectExpression(attributeArray), - ], - ); -}; -/** - * Transform JSXText to StringLiteral - * @param t - * @param path JSXText - * @returns StringLiteral - */ -const transformJSXText = (t, 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; + // patchFlag analysis + if (hasDynamicKeys) { + patchFlag |= PatchFlags.FULL_PROPS; + } else { + if (hasClassBinding) { + patchFlag |= PatchFlags.CLASS; + } + if (hasStyleBinding) { + patchFlag |= PatchFlags.STYLE; + } + if (dynamicPropNames.length) { + patchFlag |= PatchFlags.PROPS; + } + if (hasHydrationEventBinding) { + patchFlag |= PatchFlags.HYDRATE_EVENTS; } } - 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; - } + if ( + (patchFlag === 0 || patchFlag === PatchFlags.HYDRATE_EVENTS) + && hasRef + ) { + patchFlag |= PatchFlags.NEED_PATCH; } - return str !== '' ? t.stringLiteral(str) : null; + return { + props: mergeArgs.length ? t.callExpression( + createIdentifier(t, state, 'mergeProps'), + [ + ...mergeArgs, + propsExpression.length && t.objectExpression(propsExpression), + ].filter(Boolean), + ) : t.objectExpression(propsExpression), + directives, + patchFlag, + dynamicPropNames, + }; }; -/** - * Transform JSXExpressionContainer to Expression - * @param path JSXExpressionContainer - * @returns Expression - */ -const transformJSXExpressionContainer = (path) => path.get('expression').node; - -/** - * Transform JSXSpreadChild - * @param t - * @param path JSXSpreadChild - * @returns SpreadElement - */ -const transformJSXSpreadChild = (t, path) => t.spreadElement(path.get('expression').node); - /** * Get children from Array of JSX children * @param t @@ -289,50 +276,64 @@ const getChildren = (t, paths) => paths const transformJSXElement = (t, path, state) => { - const directives = []; const tag = getTag(t, path); const children = t.arrayExpression(getChildren(t, path.get('children'))); - const attributes = getAttributes(t, path, state, directives); - const compatibleProps = addDefault( - path, '@ant-design-vue/babel-helper-vue-compatible-props', { nameHint: '_compatibleProps' }, - ); - const h = t.callExpression(state.h, [ + const { + props, + directives, + patchFlag, + dynamicPropNames = [], + } = buildProps(t, path, state); + + const flagNames = Object.keys(PatchFlagNames) + .map(Number) + .filter((n) => n > 0 && patchFlag & n) + .map((n) => PatchFlagNames[n]) + .join(', '); + + const isComponent = checkIsComponent(t, path.get('openingElement')); + const createVNode = t.callExpression(createIdentifier(t, state, 'createVNode'), [ tag, - state.opts.compatibleProps ? t.callExpression(compatibleProps, [attributes]) : attributes, - !t.isStringLiteral(tag) && !tag.name.includes('Fragment') - ? t.objectExpression([ - t.objectProperty( - t.identifier('default'), - t.callExpression(state.withCtx, [ - t.arrowFunctionExpression( - [], - children, + state.opts.compatibleProps ? t.callExpression(addDefault( + path, '@ant-design-vue/babel-helper-vue-compatible-props', { nameHint: '_compatibleProps' }, + ), [props]) : props, + children.elements.length + ? ( + isComponent + ? t.objectExpression([ + t.objectProperty( + t.identifier('default'), + t.callExpression(createIdentifier(t, state, 'withCtx'), [ + t.arrowFunctionExpression( + [], + children, + ), + ]), ), - ]), - ), - ]) - : children, - ]); + ]) + : children + ) : t.nullLiteral(), + patchFlag && t.addComment(t.numericLiteral(patchFlag), 'leading', ` ${flagNames} `), + dynamicPropNames.length + && t.arrayExpression(dynamicPropNames.map((name) => t.stringLiteral(name))), + ].filter(Boolean)); + if (!directives.length) { - return h; + return createVNode; } - return t.callExpression(state.withDirectives, [ - h, + + return t.callExpression(createIdentifier(t, state, 'withDirectives'), [ + createVNode, t.arrayExpression(directives), ]); }; -const imports = [ - 'h', 'mergeProps', 'withDirectives', - 'resolveDirective', 'vShow', 'withCtx', -]; - -module.exports = (t) => ({ +export default (t) => ({ JSXElement: { exit(path, state) { - imports.forEach((m) => { - state[m] = addNamed(path, m, 'vue'); - }); + if (!state.get('vue')) { + state.set('vue', addNamespace(path, 'vue')); + } path.replaceWith( transformJSXElement(t, path, state), ); diff --git a/packages/babel-plugin-jsx/src/utils.js b/packages/babel-plugin-jsx/src/utils.js new file mode 100644 index 0000000..2bb615c --- /dev/null +++ b/packages/babel-plugin-jsx/src/utils.js @@ -0,0 +1,196 @@ +import htmlTags from 'html-tags'; +import svgTags from 'svg-tags'; + +const PatchFlags = { + TEXT: 1, + CLASS: 1 << 1, + STYLE: 1 << 2, + PROPS: 1 << 3, + FULL_PROPS: 1 << 4, + HYDRATE_EVENTS: 1 << 5, + STABLE_FRAGMENT: 1 << 6, + KEYED_FRAGMENT: 1 << 7, + UNKEYED_FRAGMENT: 1 << 8, + NEED_PATCH: 1 << 9, + DYNAMIC_SLOTS: 1 << 10, + HOISTED: -1, + BAIL: -2, +}; + +// dev only flag -> name mapping +const PatchFlagNames = { + [PatchFlags.TEXT]: 'TEXT', + [PatchFlags.CLASS]: 'CLASS', + [PatchFlags.STYLE]: 'STYLE', + [PatchFlags.PROPS]: 'PROPS', + [PatchFlags.FULL_PROPS]: 'FULL_PROPS', + [PatchFlags.HYDRATE_EVENTS]: 'HYDRATE_EVENTS', + [PatchFlags.STABLE_FRAGMENT]: 'STABLE_FRAGMENT', + [PatchFlags.KEYED_FRAGMENT]: 'KEYED_FRAGMENT', + [PatchFlags.UNKEYED_FRAGMENT]: 'UNKEYED_FRAGMENT', + [PatchFlags.NEED_PATCH]: 'NEED_PATCH', + [PatchFlags.DYNAMIC_SLOTS]: 'DYNAMIC_SLOTS', + [PatchFlags.HOISTED]: 'HOISTED', + [PatchFlags.BAIL]: 'BAIL', +}; + +const createIdentifier = (t, state, id) => t.memberExpression(state.get('vue'), t.identifier(id)); + +/** + * Checks if string is describing a directive + * @param src string + */ +const isDirective = (src) => src.startsWith('v-') + || (src.startsWith('v') && src.length >= 2 && src[1] >= 'A' && src[1] <= 'Z'); + +/** + * Check if a JSXOpeningElement is a component + * + * @param t + * @param path JSXOpeningElement + * @returns boolean + */ +const checkIsComponent = (t, path) => { + const namePath = path.get('name'); + + if (t.isJSXMemberExpression(namePath)) { + return namePath.node.property.name !== 'Fragment'; // For withCtx + } + + const tag = namePath.get('name').node; + + return !htmlTags.includes(tag) && !svgTags.includes(tag); +}; + +/** + * Transform JSXMemberExpression to MemberExpression + * @param t + * @param path JSXMemberExpression + * @returns MemberExpression + */ +const transformJSXMemberExpression = (t, path) => { + const objectPath = path.get('object'); + const propertyPath = path.get('property'); + + const transformedObject = objectPath.isJSXMemberExpression() + ? transformJSXMemberExpression(t, 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 t + * @param path JSXOpeningElement + * @returns Identifier | StringLiteral | MemberExpression + */ +const getTag = (t, 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(t, namePath); + } + throw new Error(`getTag: ${namePath.type} is not supported`); +}; + +const getJSXAttributeName = (t, path) => { + const nameNode = path.node.name; + if (t.isJSXIdentifier(nameNode)) { + return nameNode.name; + } + + return `${nameNode.namespace.name}:${nameNode.name.name}`; +}; + +/** + * Transform JSXText to StringLiteral + * @param t + * @param path JSXText + * @returns StringLiteral + */ +const transformJSXText = (t, 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 t + * @param path JSXSpreadChild + * @returns SpreadElement + */ +const transformJSXSpreadChild = (t, path) => t.spreadElement(path.get('expression').node); + + +export { + createIdentifier, + isDirective, + checkIsComponent, + transformJSXMemberExpression, + getTag, + getJSXAttributeName, + transformJSXText, + transformJSXSpreadChild, + transformJSXExpressionContainer, + PatchFlags, + PatchFlagNames, +}; diff --git a/packages/babel-plugin-jsx/test/coverage.test.js b/packages/babel-plugin-jsx/test/coverage.test.js new file mode 100644 index 0000000..3554791 --- /dev/null +++ b/packages/babel-plugin-jsx/test/coverage.test.js @@ -0,0 +1,13 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { transformSync } from '@babel/core'; +import preset from '../babel.config'; + +test('coverage', () => { + const mainTest = fs.readFileSync(path.resolve(__dirname, './index.test.js')); + transformSync(mainTest, { + babelrc: false, + presets: [preset], + filename: 'index.test.js', + }); +}); diff --git a/packages/babel-plugin-jsx/test/index.test.js b/packages/babel-plugin-jsx/test/index.test.js index c7475b0..8aed483 100644 --- a/packages/babel-plugin-jsx/test/index.test.js +++ b/packages/babel-plugin-jsx/test/index.test.js @@ -1,244 +1,322 @@ -import { shallowMount } from '@vue/test-utils'; +import { ref } from 'vue'; +import { shallowMount, mount } from '@vue/test-utils'; -test('should render with render function', () => { - const wrapper = shallowMount({ - render() { - return
123
; - }, +describe('Transform JSX', () => { + test('should render with render function', () => { + const wrapper = shallowMount({ + render() { + return
123
; + }, + }); + expect(wrapper.text()).toBe('123'); + }); + + test('should render with setup', () => { + const wrapper = shallowMount({ + setup() { + return () =>
123
; + }, + }); + expect(wrapper.text()).toBe('123'); + }); + + test('Extracts attrs', () => { + const wrapper = shallowMount({ + setup() { + return () =>
; + }, + }); + expect(wrapper.element.id).toBe('hi'); + expect(wrapper.element.dir).toBe('ltr'); + }); + + test('Binds attrs', () => { + const id = 'foo'; + const wrapper = shallowMount({ + setup() { + return () =>
{id}
; + }, + }); + expect(wrapper.text()).toBe('foo'); + }); + + test('should not fallthrough with inheritAttrs: false', () => { + const Child = (props) =>
{props.foo}
; + + Child.inheritAttrs = false; + + const wrapper = mount({ + setup() { + return () => ( + + ); + }, + }); + expect(wrapper.text()).toBe('1'); + }); + + test('Fragment', () => { + const Child = () =>
123
; + + Child.inheritAttrs = false; + + const wrapper = mount({ + setup() { + return () => ( + <> + +
456
+ + ); + }, + }); + + expect(wrapper.html()).toBe('
123
456
'); + }); + + test('xlink:href', () => { + const wrapper = shallowMount({ + setup() { + return () => ; + }, + }); + expect(wrapper.attributes()['xlink:href']).toBe('#name'); + }); + + 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']; + const wrapper = shallowMount({ + setup() { + return () =>
{[...a]}
; + }, + }); + expect(wrapper.text()).toBe('12'); + }); + + test('domProps input[value]', () => { + const val = 'foo'; + const wrapper = shallowMount({ + setup() { + return () => ; + }, + }); + expect(wrapper.html()).toBe(''); + }); + + test('domProps input[checked]', () => { + const val = 'foo'; + const wrapper = shallowMount({ + setup() { + return () => ; + }, + }); + + expect(wrapper.vm.$.subTree.props.checked).toBe(val); + }); + + test('domProps option[selected]', () => { + const val = 'foo'; + const wrapper = shallowMount({ + render() { + return