diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..87db373 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": [ + "@babel/env" + ], + "plugins": [ + "./src/index.js" + ] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..0129706 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,26 @@ +{ + "root": true, + "env": { + "browser": true, + "node": true, + "jasmine": true, + "jest": true, + "es6": true + }, + "extends": "eslint-config-airbnb/base", + "parserOptions": { + "parser": "babel-eslint", + "ecmaVersion": 8, + "ecmaFeatures": { + "jsx": true, + "experimentalObjectRestSpread": true + } + }, + "rules": { + "no-nested-ternary": [0], + "no-param-reassign": [0], + "no-use-before-define": [0], + "no-plusplus": [0], + "import/no-extraneous-dependencies": [0] + } +} diff --git a/.gitignore b/.gitignore index 6704566..cab290e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +package-lock.json diff --git a/.jest.js b/.jest.js new file mode 100644 index 0000000..e71c516 --- /dev/null +++ b/.jest.js @@ -0,0 +1,5 @@ +module.exports = { + globals: { + "_h": require('vue').h // TODO: for jest error _h is not defined + } +} diff --git a/README.md b/README.md index 58eef75..070e556 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,24 @@ -# jsx -jsx for vue 3 +# Babel Preset JSX for Vue 3.0 + +To add Vue JSX support. + +## Syntax + +functional component + +```jsx +const App = () =>
+``` + +with setup render + +```jsx +const App = defineComponent(() => { + const count = ref(0); + return () => ( +
+ {count.value} +
+ ) +}) +``` diff --git a/example/index.js b/example/index.js new file mode 100644 index 0000000..64f73d9 --- /dev/null +++ b/example/index.js @@ -0,0 +1,40 @@ +import { createApp, ref, defineComponent } from 'vue'; + +const SuperButton = (props, context) => ( +
+ Super + +
+); + +SuperButton.inheritAttrs = false; + +const App = defineComponent(() => { + const count = ref(0); + const inc = () => { + count.value++; + }; + + return () => ( +
+ Foo {count.value} + +
+ ); +}); + +createApp(App).mount('#app'); diff --git a/index.html b/index.html new file mode 100644 index 0000000..a5affc8 --- /dev/null +++ b/index.html @@ -0,0 +1,2 @@ +
+ diff --git a/package.json b/package.json new file mode 100644 index 0000000..eb447da --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "jsx", + "version": "1.0.0", + "description": "jsx for vue 3", + "main": "index.js", + "scripts": { + "dev": "webpack-dev-server", + "lint": "eslint --ext .js src", + "test": "jest --config .jest.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vueComponent/jsx.git" + }, + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/vueComponent/jsx/issues" + }, + "homepage": "https://github.com/vueComponent/jsx#readme", + "keywords": [ + "vue", + "jsx" + ], + "dependencies": { + "@babel/helper-module-imports": "^7.8.3", + "@babel/plugin-syntax-jsx": "^7.8.3", + "@babel/types": "^7.9.6", + "@vue/babel-preset-jsx": "^1.1.2", + "html-tags": "^3.1.0", + "svg-tags": "^1.0.0" + }, + "devDependencies": { + "@babel/core": "^7.9.6", + "@babel/preset-env": "^7.9.6", + "babel-eslint": "^10.1.0", + "babel-jest": "^26.0.1", + "babel-loader": "^8.1.0", + "eslint": "^7.0.0", + "eslint-config-airbnb": "^18.1.0", + "eslint-plugin-import": "^2.20.2", + "jest": "^26.0.1", + "vue": "^3.0.0-beta.10", + "webpack": "^4.43.0", + "webpack-cli": "^3.3.11", + "webpack-dev-server": "^3.10.3" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..ede9908 --- /dev/null +++ b/src/index.js @@ -0,0 +1,235 @@ +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)), +]); + +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, + }), + ); + }, + }, + }, +}); diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..dc44570 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,65 @@ +import { createApp } from 'vue'; + +const render = (app) => { + const root = document.createElement('div'); + document.body.append(root); + createApp(app).mount(root); + return root; +}; + +test('should render with setup', () => { + const App = { + setup() { + return () =>
123
; + }, + }; + + const wrapper = render(App); + expect(wrapper.innerHTML).toBe('
123
'); +}); + +test('should not fallthrough with inheritAttrs: false', () => { + const Child = (props) =>
{props.foo}
; + + Child.inheritAttrs = false; + + const Parent = () => ( + + ); + + const wrapper = render(Parent); + expect(wrapper.innerHTML).toBe('
1
'); +}); + + +test('should render', () => { + const App = { + render() { + return
1234
; + }, + }; + const wrapper = render(App); + expect(wrapper.innerHTML).toBe('
1234
'); +}); + +test('xlink:href', () => { + const wrapper = render(() => ); + expect(wrapper.innerHTML).toBe(''); +}); + +// test('Merge class', () => { +// const wrapper = render(() =>
); +// expect(wrapper.innerHTML).toBe('
'); +// }); + +test('JSXSpreadChild', () => { + const a = ['1', '2']; + const wrapper = render(() =>
{[...a]}
); + expect(wrapper.innerHTML).toBe('
12
'); +}); + +test('domProps input[value]', () => { + const val = 'foo'; + const wrapper = render(() => ); + expect(wrapper.innerHTML).toBe(''); +}); diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..0a2129b --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,26 @@ +const path = require('path'); + +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/, + }, + ], + }, + devServer: { + inline: true, + open: true, + hot: true, + overlay: true, + }, +};