refactor: support transformOn

This commit is contained in:
Amour1688
2020-05-26 21:04:56 +08:00
parent 402568dc52
commit 7a1a39a4cb
19 changed files with 161 additions and 95 deletions

View File

@ -0,0 +1,13 @@
{
"presets": [
[
"@babel/env",
{
// "modules": "cjs"
}
]
],
"plugins": [
["./src/index.js", { "transformOn": true }]
]
}

View File

@ -0,0 +1,10 @@
const { h, mergeProps, withDirectives } = require('vue');
module.exports = {
globals: {
'_h': h,
'_mergeProps': mergeProps,
'_withDirectives': withDirectives
},
setupFiles: ['./test/setup.js'],
}

View File

@ -0,0 +1,11 @@
# `babel-plugin-jsx`
> TODO: description
## Usage
```
const babelPluginJsx = require('babel-plugin-jsx');
// TODO: DEMONSTRATE API
```

View File

@ -0,0 +1,53 @@
import { createApp, ref, defineComponent } from 'vue';
const SuperButton = (props, context) => {
const obj = {
mouseover: () => {
context.emit('mouseover');
},
click: () => {
context.emit('click');
},
};
return (
<div class={props.class}>
Super
<button
on={obj}
>
{ props.buttonText }
{context.slots.default()}
</button>
</div>
);
};
SuperButton.inheritAttrs = false;
const App = defineComponent(() => {
const count = ref(0);
const inc = () => {
count.value++;
};
const obj = {
click: inc,
mouseover: inc,
};
return () => (
<div>
Foo {count.value}
<SuperButton
buttonText="VueComponent"
class="xxx"
vShow={true}
on={obj}
>
<button>1234</button>
</SuperButton>
</div>
);
});
createApp(App).mount('#app');

View File

@ -0,0 +1,2 @@
<div id="app"></div>
<script src="/dist/main.js"></script>

View File

@ -0,0 +1,42 @@
{
"name": "@ant-design-vue/babel-plugin-jsx",
"version": "1.0.0",
"description": "Babel plugin for Vue 3.0 JSX",
"author": "Amour1688 <lcz_1996@foxmail.com>",
"homepage": "https://github.com/vueComponent/jsx/tree/master/packages/babel-plugin-jsx#readme",
"license": "MIT",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/vueComponent/jsx.git"
},
"scripts": {
"dev": "webpack-dev-server",
"lint": "eslint --ext .js src",
"test": "jest --config .jest.js"
},
"bugs": {
"url": "https://github.com/vueComponent/jsx/issues"
},
"dependencies": {
"@ant-design-vue/babel-helper-vue-transform-on": "^1.0.0",
"@babel/helper-module-imports": "^7.0.0",
"@babel/plugin-syntax-jsx": "^7.0.0",
"camelcase": "^6.0.0",
"html-tags": "^3.1.0",
"svg-tags": "^1.0.0"
},
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@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",
"vue": "^3.0.0-beta.14",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.10.3"
}
}

View File

@ -0,0 +1,14 @@
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');
module.exports = ({ types: t }, opts = {}) => ({
name: 'babel-plugin-jsx',
inherits: syntaxJsx,
visitor: {
...sugarVModel(t),
...tranformVueJSX(t, opts),
...sugarFragment(t),
},
});

View File

@ -0,0 +1,22 @@
const helperModuleImports = require('@babel/helper-module-imports');
const transformFragment = (t, path, { name }) => {
const children = path.get('children') || [];
return t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier(name), []),
t.jsxClosingElement(t.jsxIdentifier(name)),
children.map(({ node }) => node),
false,
);
};
module.exports = (t) => ({
JSXFragment: {
enter(path) {
if (!path.vueFragment) {
path.vueFragment = helperModuleImports.addNamed(path, 'Fragment', 'vue');
}
path.replaceWith(transformFragment(t, path, path.vueFragment));
},
},
});

View File

@ -0,0 +1,204 @@
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const camelCase = require('camelcase');
const { addNamed } = require('@babel/helper-module-imports');
const cachedCamelCase = (() => {
const cache = Object.create(null);
return (string) => {
if (!cache[string]) {
cache[string] = camelCase(string);
}
return cache[string];
};
})();
const startsWithCamel = (string, match) => string.startsWith(match)
|| string.startsWith(cachedCamelCase(match));
/**
* Add property to a JSX element
*
* @param t
* @param path JSXOpeningElement
* @param value string
*/
const addProp = (path, value) => {
path.node.attributes.push(value);
};
/**
* Get JSX element tag name
*
* @param path Path<JSXOpeningElement>
*/
const getTagName = (path) => path.get('name.name').node;
/**
* Get JSX element type
*
* @param t
* @param path Path<JSXOpeningElement>
*/
const getType = (t, path) => {
const typePath = path
.get('attributes')
.find(
(attributePath) => t.isJSXAttribute(attributePath)
&& t.isJSXIdentifier(attributePath.get('name'))
&& attributePath.get('name.name').node === 'type'
&& t.isStringLiteral(attributePath.get('value')),
);
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 tag = getTagName(path);
const type = getType(t, path);
addProp(path, t.jsxSpreadAttribute(
t.objectExpression([
t.objectProperty(
t.stringLiteral('onUpdate:modelValue'),
t.arrowFunctionExpression(
[t.identifier('$event')],
t.assignmentExpression('=', value, t.identifier('$event')),
),
),
]),
));
if (isComponent(t, path)) {
addProp(path, t.jsxAttribute(t.jsxIdentifier('modelValue'), t.jsxExpressionContainer(value)));
return null;
}
let modelToUse;
switch (tag) {
case 'select':
if (!path.vueVModelSelect) {
path.vueVModelSelect = addNamed(path, 'vModelSelect', 'vue');
}
modelToUse = path.vueVModelSelect;
break;
case 'textarea':
if (!path.vueVModelText) {
path.vueVModelText = addNamed(path, 'vModelText', 'vue');
}
break;
default:
switch (type) {
case 'checkbox':
if (!path.vueVModelCheckbox) {
path.vueVModelCheckbox = addNamed(path, 'vModelCheckbox', 'vue');
}
modelToUse = path.vueVModelCheckbox;
break;
case 'radio':
if (!path.vueVModelRadio) {
path.vueVModelRadio = addNamed(path, 'vModelRadio', 'vue');
}
modelToUse = path.vueVModelRadio;
break;
default:
if (!path.vueVModelText) {
path.vueVModelText = addNamed(path, 'vModelText', 'vue');
}
modelToUse = path.vueVModelText;
}
}
return modelToUse;
};
/**
* Parse vModel metadata
*
* @param t
* @param path JSXAttribute
* @returns null | Object<{ modifiers: Set<string>, valuePath: Path<Expression>}>
*/
const parseVModel = (t, path) => {
if (t.isJSXNamespacedName(path.get('name')) || !startsWithCamel(path.get('name.name').node, 'v-model')) {
return null;
}
if (!t.isJSXExpressionContainer(path.get('value'))) {
throw new Error('You have to use JSX Expression inside your v-model');
}
const modifiers = path.get('name.name').node.split('_');
return {
modifiers: new Set(modifiers),
value: path.get('value.expression').node,
};
};
module.exports = (t) => ({
JSXAttribute: {
exit(path) {
const parsed = parseVModel(t, path);
if (!parsed) {
return;
}
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);
if (directive) {
path.replaceWith(
t.jsxAttribute(
t.jsxIdentifier('v-_model'), // TODO
t.jsxExpressionContainer(
t.arrayExpression([
directive,
value,
modifiers.size && t.unaryExpression('void', t.numericLiteral(0), true),
modifiers.size && t.objectExpression(
[...modifiers].map(
(modifier) => t.objectProperty(
t.identifier(modifier),
t.booleanLiteral(true),
),
),
),
].filter(Boolean)),
),
),
);
} else {
path.remove();
}
},
},
});

View File

@ -0,0 +1,338 @@
const htmlTags = require('html-tags');
const svgTags = require('svg-tags');
const { addNamed, addDefault } = require('@babel/helper-module-imports');
const xlinkRE = /^xlink([A-Z])/;
const eventRE = /^on[A-Z][a-z]+$/;
const rootAttributes = ['class', 'style'];
/**
* 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);
}
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}`;
};
const getJSXAttributeValue = (t, path) => {
const valuePath = path.get('value');
if (valuePath.isJSXElement()) {
return transformJSXElement(t, valuePath);
}
if (valuePath.isStringLiteral()) {
return valuePath.node;
}
if (valuePath.isJSXExpressionContainer()) {
return transformJSXExpressionContainer(valuePath);
}
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;
}
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 (rootAttributes.includes(name) || eventRE.test(name)) {
attributesToMerge.push(
t.objectExpression([
t.objectProperty(
t.stringLiteral(
name,
),
attributeValue,
),
]),
);
return null;
}
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;
}
return true;
})));
};
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 attributesToMerge = [];
const attributeArray = [];
attributes
.forEach((attribute) => {
const attr = transformAttribute(t, attribute, state, attributesToMerge, directives);
if (attr) {
attributeArray.push(attr);
}
});
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;
}
}
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);
/**
* Get children from Array of JSX children
* @param t
* @param paths Array<JSXText | JSXExpressionContainer | JSXSpreadChild | JSXElement>
* @returns Array<Expression | SpreadElement>
*/
const getChildren = (t, paths) => paths
.map((path) => {
if (path.isJSXText()) {
return transformJSXText(t, path);
}
if (path.isJSXExpressionContainer()) {
return transformJSXExpressionContainer(path);
}
if (path.isJSXSpreadChild()) {
return transformJSXSpreadChild(t, path);
}
if (path.isCallExpression()) {
return path.node;
}
if (path.isJSXElement()) {
return transformJSXElement(t, path);
}
throw new Error(`getChildren: ${path.type} is not supported`);
}).filter((value) => (
value !== undefined
&& value !== null
&& !t.isJSXEmptyExpression(value)
));
const transformJSXElement = (t, path, state) => {
const directives = [];
const tag = getTag(t, path);
const children = t.arrayExpression(getChildren(t, path.get('children')));
const h = t.callExpression(state.h, [
tag,
getAttributes(t, path, state, directives),
!t.isStringLiteral(tag)
? t.objectExpression([
t.objectProperty(
t.identifier('default'),
t.callExpression(state.withCtx, [
t.arrowFunctionExpression(
[],
children,
),
]),
),
])
: children,
]);
if (!directives.length) {
return h;
}
return t.callExpression(state.withDirectives, [
h,
t.arrayExpression(directives),
]);
};
const imports = [
'h', 'mergeProps', 'withDirectives',
'resolveDirective', 'vShow', 'withCtx',
];
module.exports = (t) => ({
JSXElement: {
exit(path, state) {
imports.forEach((m) => {
if (!state[m]) {
state[m] = addNamed(path, m, 'vue');
}
});
path.replaceWith(
transformJSXElement(t, path, state),
);
},
},
});

View File

@ -0,0 +1,244 @@
import { shallowMount } from '@vue/test-utils';
test('should render with render function', () => {
const wrapper = shallowMount({
render() {
return <div>123</div>;
},
});
expect(wrapper.text()).toBe('123');
});
test('should render with setup', () => {
const wrapper = shallowMount({
setup() {
return () => <div>123</div>;
},
});
expect(wrapper.text()).toBe('123');
});
test('Extracts attrs', () => {
const wrapper = shallowMount({
setup() {
return () => <div id="hi" dir="ltr" />;
},
});
expect(wrapper.element.id).toBe('hi');
expect(wrapper.element.dir).toBe('ltr');
});
test('Binds attrs', () => {
const id = 'foo';
const wrapper = shallowMount({
setup() {
return () => <div>{id}</div>;
},
});
expect(wrapper.text()).toBe('foo');
});
test('should not fallthrough with inheritAttrs: false', () => {
const Child = (props) => <div>{props.foo}</div>;
Child.inheritAttrs = false;
const wrapper = shallowMount({
setup() {
return () => (
<Child class="parent" foo={1} />
);
},
});
expect(wrapper.text()).toBe('1');
});
test('Fragment', () => {
const Child = () => <div>123</div>;
Child.inheritAttrs = false;
const wrapper = shallowMount({
setup() {
return () => (
<>
<Child />
<div>456</div>
</>
);
},
});
expect(wrapper.html()).toBe('<div>123</div><div>456</div>');
});
test('xlink:href', () => {
const wrapper = shallowMount({
setup() {
return () => <use xlinkHref={'#name'}></use>;
},
});
expect(wrapper.attributes()['xlink:href']).toBe('#name');
});
test('Merge class', () => {
const wrapper = shallowMount({
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', () => {
const a = ['1', '2'];
const wrapper = shallowMount({
setup() {
return () => <div>{[...a]}</div>;
},
});
expect(wrapper.text()).toBe('12');
});
test('domProps input[value]', () => {
const val = 'foo';
const wrapper = shallowMount({
setup() {
return () => <input type="text" value={val} />;
},
});
expect(wrapper.html()).toBe('<input type="text">');
});
test('domProps input[checked]', () => {
const val = 'foo';
const wrapper = shallowMount({
setup() {
return () => <input checked={val} />;
},
});
expect(wrapper.vm.$.subTree.props.checked).toBe(val);
});
test('domProps option[selected]', () => {
const val = 'foo';
const wrapper = shallowMount({
render() {
return <option selected={val} />;
},
});
expect(wrapper.vm.$.subTree.props.selected).toBe(val);
});
test('domProps video[muted]', () => {
const val = 'foo';
const wrapper = shallowMount({
render() {
return <video muted={val} />;
},
});
expect(wrapper.vm.$.subTree.props.muted).toBe(val);
});
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)', async () => {
const calls = [];
const data = {
id: 'hehe',
onClick() {
calls.push(3);
},
innerHTML: 2,
class: ['a', 'b'],
};
const wrapper = shallowMount({
setup() {
return () => (
<div
href="huhu"
{...data}
class={{ c: true }}
onClick={() => calls.push(4)}
hook-insert={() => calls.push(2)}
/>
);
},
});
expect(wrapper.attributes('id')).toBe('hehe');
expect(wrapper.attributes('href')).toBe('huhu');
expect(wrapper.text()).toBe('2');
expect(wrapper.classes()).toEqual(expect.arrayContaining(['a', 'b', 'c']));
await wrapper.trigger('click');
expect(calls).toEqual(expect.arrayContaining([3, 4]));
});
test('directive', () => {
const calls = [];
const customDirective = {
mounted() {
calls.push(1);
},
};
const wrapper = shallowMount(({
directives: { custom: customDirective },
setup() {
return () => (
<a
v-custom={{
value: 123,
modifiers: { modifier: true },
arg: 'arg',
}}
/>
);
},
}));
const node = wrapper.vm.$.subTree;
expect(calls).toEqual(expect.arrayContaining([1]));
expect(node.dirs).toHaveLength(1);
});

View File

@ -0,0 +1 @@
import 'regenerator-runtime/runtime';

View File

@ -0,0 +1,36 @@
const path = require('path');
const jsxInjectionPATH = 'PACKAGE/lib/jsxInjection';
module.exports = {
mode: 'development',
devtool: 'cheap-module-eval-source-map',
entry: './example/index.js',
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
],
},
resolve: {
alias: {
[jsxInjectionPATH]: path.resolve(
__dirname,
'./lib/jsxInjection',
),
},
},
devServer: {
inline: true,
open: true,
hot: true,
overlay: true,
},
};