diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b16a639 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [10.x, 12.x, 14.x, 15.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + - name: Run test + run: | + npm install + npm test diff --git a/generator/codemods/global-api/__testfixtures__/basic.output.js b/generator/codemods/global-api/__testfixtures__/basic.output.js index 684d042..b0788dd 100644 --- a/generator/codemods/global-api/__testfixtures__/basic.output.js +++ b/generator/codemods/global-api/__testfixtures__/basic.output.js @@ -1,4 +1,5 @@ import { createApp } from 'vue'; import App from './App.vue'; -createApp(App).mount('#app'); +const app = createApp(App); +app.mount('#app'); diff --git a/generator/codemods/global-api/__testfixtures__/custom-root-prop.output.js b/generator/codemods/global-api/__testfixtures__/custom-root-prop.output.js index b9ade05..59e84ff 100644 --- a/generator/codemods/global-api/__testfixtures__/custom-root-prop.output.js +++ b/generator/codemods/global-api/__testfixtures__/custom-root-prop.output.js @@ -1,7 +1,9 @@ import { createApp, h } from 'vue'; import App from './App.vue'; -createApp({ +const app = createApp({ myOption: 'hello!', render: () => h(App), -}).mount('#app'); +}); + +app.mount('#app'); diff --git a/generator/codemods/global-api/__testfixtures__/next-tick.input.js b/generator/codemods/global-api/__testfixtures__/next-tick.input.js new file mode 100644 index 0000000..1d22568 --- /dev/null +++ b/generator/codemods/global-api/__testfixtures__/next-tick.input.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +Vue.nextTick(() => { + console.log('foo'); +}) diff --git a/generator/codemods/global-api/__testfixtures__/next-tick.output.js b/generator/codemods/global-api/__testfixtures__/next-tick.output.js new file mode 100644 index 0000000..e69e67e --- /dev/null +++ b/generator/codemods/global-api/__testfixtures__/next-tick.output.js @@ -0,0 +1,5 @@ +import { nextTick } from 'vue'; + +nextTick(() => { + console.log('foo'); +}) diff --git a/generator/codemods/global-api/__testfixtures__/vue-router.output.js b/generator/codemods/global-api/__testfixtures__/vue-router.output.js index 82ac74e..138eb97 100644 --- a/generator/codemods/global-api/__testfixtures__/vue-router.output.js +++ b/generator/codemods/global-api/__testfixtures__/vue-router.output.js @@ -2,4 +2,5 @@ import { createApp } from 'vue'; import App from './App.vue'; import router from './router'; -createApp(App).use(router).mount('#app'); +const app = createApp(App).use(router); +app.mount('#app'); diff --git a/generator/codemods/global-api/__testfixtures__/vuex.output.js b/generator/codemods/global-api/__testfixtures__/vuex.output.js index f1a5d84..958566a 100644 --- a/generator/codemods/global-api/__testfixtures__/vuex.output.js +++ b/generator/codemods/global-api/__testfixtures__/vuex.output.js @@ -3,6 +3,7 @@ import App from './App.vue'; import store from './store'; import anotherStore from './another-store'; -createApp(App).use(store).mount('#app'); - -createApp(App).use(anotherStore).mount('#app'); +const app = createApp({}).use(h => h(App)); +app.mount('#app'); +const app = createApp({}).use(h => h(App)); +app.mount('#app'); diff --git a/generator/codemods/global-api/__tests__/global-api-test.js b/generator/codemods/global-api/__tests__/global-api-test.js index ab05aaa..f63f9c9 100644 --- a/generator/codemods/global-api/__tests__/global-api-test.js +++ b/generator/codemods/global-api/__tests__/global-api-test.js @@ -8,3 +8,4 @@ defineTest(__dirname, 'index', null, 'custom-root-prop') defineTest(__dirname, 'index', null, 'vue-router') defineTest(__dirname, 'index', null, 'vuex') defineTest(__dirname, 'index', null, 'vue-use') +defineTest(__dirname, 'index', null, 'next-tick') diff --git a/generator/codemods/global-api/create-app-mount.js b/generator/codemods/global-api/create-app-mount.js index 6cba44d..b878986 100644 --- a/generator/codemods/global-api/create-app-mount.js +++ b/generator/codemods/global-api/create-app-mount.js @@ -7,13 +7,21 @@ module.exports = function createAppMount(context) { const { j, root } = context // new Vue(...).$mount() - const mountCalls = root.find(j.CallExpression, n => { - return ( - n.callee.type === 'MemberExpression' && - n.callee.property.name === '$mount' && - n.callee.object.type === 'NewExpression' && - n.callee.object.callee.name === 'Vue' - ) + const mountCalls = root.find(j.ExpressionStatement, { + expression: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { + type: 'NewExpression', + callee: { + type: 'Identifier', + name: 'Vue' + } + }, + property: { type: 'Identifier', name: '$mount' } + } + } }) if (!mountCalls.length) { @@ -23,16 +31,26 @@ module.exports = function createAppMount(context) { const addImport = require('../utils/add-import') addImport(context, { imported: 'createApp' }, 'vue') - mountCalls.replaceWith(({ node }) => { - let rootProps = node.callee.object.arguments[0] - const el = node.arguments[0] + const rootProps = mountCalls.at(0).get().node.expression.callee.object + .arguments + mountCalls.insertBefore( + j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier('app'), + j.callExpression(j.identifier('createApp'), rootProps) + ) + ]) + ) - return j.callExpression( - j.memberExpression( - j.callExpression(j.identifier('createApp'), [rootProps]), - j.identifier('mount') - ), - [el] + const args = mountCalls.at(0).get().node.expression.arguments + mountCalls.insertBefore( + j.expressionStatement( + j.callExpression( + j.memberExpression(j.identifier('app'), j.identifier('mount'), false), + args + ) ) - }) + ) + + mountCalls.remove() } diff --git a/generator/codemods/global-api/global-filter.js b/generator/codemods/global-api/global-filter.js new file mode 100644 index 0000000..714043a --- /dev/null +++ b/generator/codemods/global-api/global-filter.js @@ -0,0 +1,75 @@ +/** + * @param {Object} context + * @param {import('jscodeshift').JSCodeshift} context.j + * @param {ReturnType} context.root + */ +module.exports = function createAppMount(context) { + const { j, root } = context + + // find const appName = createApp(...) + const appDeclare = root.find(j.VariableDeclarator, { + id: { type: 'Identifier' }, + init: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'createApp' + } + } + }) + if (!appDeclare.length) { + return + } + const appName = appDeclare.at(0).get().node.id.name + + // Vue.filter('filterName', function(value) {}) => + // app.config.globalProperties.$filters = { filterName(value) {} } + const filters = root.find(j.ExpressionStatement, { + expression: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'Vue' }, + property: { type: 'Identifier', name: 'filter' } + } + } + }) + if (!filters.length) { + return + } + + const methods = [] + for (let i = 0; i < filters.length; i++) { + const filter = filters.at(i) + const args = filter.get().node.expression.arguments + + methods.push( + j.objectMethod( + 'method', + j.identifier(args[0].value), + args[1].params, + args[1].body + ) + ) + } + + filters + .at(0) + .insertBefore( + j.expressionStatement( + j.assignmentExpression( + '=', + j.memberExpression( + j.identifier(appName), + j.identifier('config.globalProperties.$filters'), + false + ), + j.objectExpression(methods) + ) + ) + ) + + for (let i = 0; i < filters.length; i++) { + filters.at(i).remove() + } +} diff --git a/generator/codemods/global-api/index.js b/generator/codemods/global-api/index.js index 6ab3557..5891f9a 100644 --- a/generator/codemods/global-api/index.js +++ b/generator/codemods/global-api/index.js @@ -11,6 +11,10 @@ module.exports = function(fileInfo, api) { require('./remove-production-tip')(context) require('./remove-vue-use')(context) require('./remove-contextual-h')(context) + require('./next-tick')(context) + require('./observable')(context) + require('./version')(context) + require('./global-filter')(context) // remove extraneous imports const removeExtraneousImport = require('../utils/remove-extraneous-import') diff --git a/generator/codemods/global-api/next-tick.js b/generator/codemods/global-api/next-tick.js new file mode 100644 index 0000000..f713005 --- /dev/null +++ b/generator/codemods/global-api/next-tick.js @@ -0,0 +1,30 @@ +/** + * @param {Object} context + * @param {import('jscodeshift').JSCodeshift} context.j + * @param {ReturnType} context.root + */ +module.exports = function createAppMount(context) { + const { j, root } = context + + // Vue.nextTick(() => {}) + const nextTickCalls = root.find(j.CallExpression, n => { + return ( + n.callee.type === 'MemberExpression' && + n.callee.property.name === 'nextTick' && + n.callee.object.name === 'Vue' + ) + }) + + if (!nextTickCalls.length) { + return + } + + const addImport = require('../utils/add-import') + addImport(context, { imported: 'nextTick' }, 'vue') + + nextTickCalls.replaceWith(({ node }) => { + const el = node.arguments[0] + + return j.callExpression(j.identifier('nextTick'), [el]) + }) +} diff --git a/generator/codemods/global-api/observable.js b/generator/codemods/global-api/observable.js new file mode 100644 index 0000000..9a01221 --- /dev/null +++ b/generator/codemods/global-api/observable.js @@ -0,0 +1,30 @@ +/** + * @param {Object} context + * @param {import('jscodeshift').JSCodeshift} context.j + * @param {ReturnType} context.root + */ +module.exports = function createAppMount(context) { + const { j, root } = context + + // Vue.observable(state) + const observableCalls = root.find(j.CallExpression, n => { + return ( + n.callee.type === 'MemberExpression' && + n.callee.property.name === 'observable' && + n.callee.object.name === 'Vue' + ) + }) + + if (!observableCalls.length) { + return + } + + const addImport = require('../utils/add-import') + addImport(context, { imported: 'reactive' }, 'vue') + + observableCalls.replaceWith(({ node }) => { + const el = node.arguments[0] + + return j.callExpression(j.identifier('reactive'), [el]) + }) +} diff --git a/generator/codemods/global-api/version.js b/generator/codemods/global-api/version.js new file mode 100644 index 0000000..fa8e894 --- /dev/null +++ b/generator/codemods/global-api/version.js @@ -0,0 +1,29 @@ +/** + * @param {Object} context + * @param {import('jscodeshift').JSCodeshift} context.j + * @param {ReturnType} context.root + */ +module.exports = function createAppMount(context) { + const { j, root } = context + + // Vue.version + const versionCalls = root.find(j.MemberExpression, n => { + return ( + n.property.name === 'version' && + n.object.name === 'Vue' + ) + }) + + if (!versionCalls.length) { + return + } + + const addImport = require('../utils/add-import') + addImport(context, { imported: 'version' }, 'vue') + + versionCalls.replaceWith(({ node }) => { + const property = node.property.name + + return j.identifier(property) + }) +} diff --git a/generator/codemods/vue-addition/index.js b/generator/codemods/vue-addition/index.js new file mode 100644 index 0000000..918fa38 --- /dev/null +++ b/generator/codemods/vue-addition/index.js @@ -0,0 +1,28 @@ +module.exports = function(files, filename) { + let content = files[filename] + content = removeEventNative(content) + content = addTransitionFrom(content) + files[filename] = content +} + +// template +// v-on:event.native => v-on:event +// @event.native => @event +function removeEventNative(content) { + const reg = new RegExp( + '(?<=)', + 'g' + ) + return content.replace(reg, '') +} + +// style +// .xxx-enter => .xxx-enter-from +// .xxx-leave => .xxx-leave-from +function addTransitionFrom(content) { + const reg = new RegExp( + '(?<=][\\s\\S]*?\\s\\.[A-Za-z0-9_-]+-)(enter|leave)(?=[,{\\s][\\s\\S]*?)', + 'g' + ) + return content.replace(reg, '$1-from') +} diff --git a/generator/codemods/vue/__testfixtures__/add-emit-declaration.input.js b/generator/codemods/vue/__testfixtures__/add-emit-declaration.input.js new file mode 100644 index 0000000..4b02f03 --- /dev/null +++ b/generator/codemods/vue/__testfixtures__/add-emit-declaration.input.js @@ -0,0 +1,9 @@ +export default { + props: ['text'], + methods: { + input: function(){ + this.$emit('increment'); + this.$emit('decrement'); + } + } +} diff --git a/generator/codemods/vue/__testfixtures__/add-emit-declaration.output.js b/generator/codemods/vue/__testfixtures__/add-emit-declaration.output.js new file mode 100644 index 0000000..0426910 --- /dev/null +++ b/generator/codemods/vue/__testfixtures__/add-emit-declaration.output.js @@ -0,0 +1,11 @@ +export default { + emits: ["increment", "decrement"], + props: ['text'], + + methods: { + input: function(){ + this.$emit('increment'); + this.$emit('decrement'); + } + } +}; diff --git a/generator/codemods/vue/__testfixtures__/rename-lifecycle.input.js b/generator/codemods/vue/__testfixtures__/rename-lifecycle.input.js new file mode 100644 index 0000000..cd67429 --- /dev/null +++ b/generator/codemods/vue/__testfixtures__/rename-lifecycle.input.js @@ -0,0 +1,8 @@ +export default { + destroyed: function () { + console.log('foo') + }, + beforeDestroy: function () { + console.log('bar') + } +} diff --git a/generator/codemods/vue/__testfixtures__/rename-lifecycle.output.js b/generator/codemods/vue/__testfixtures__/rename-lifecycle.output.js new file mode 100644 index 0000000..3f243ec --- /dev/null +++ b/generator/codemods/vue/__testfixtures__/rename-lifecycle.output.js @@ -0,0 +1,8 @@ +export default { + unmounted: function () { + console.log('foo') + }, + beforeUnmount: function () { + console.log('bar') + } +} diff --git a/generator/codemods/vue/__tests__/vue-test.js b/generator/codemods/vue/__tests__/vue-test.js new file mode 100644 index 0000000..3e3c669 --- /dev/null +++ b/generator/codemods/vue/__tests__/vue-test.js @@ -0,0 +1,6 @@ +jest.autoMockOff() + +const { defineTest } = require('jscodeshift/dist/testUtils') + +defineTest(__dirname, 'index', null, 'add-emit-declaration') +defineTest(__dirname, 'index', null, 'rename-lifecycle') diff --git a/generator/codemods/vue/add-emit-declaration.js b/generator/codemods/vue/add-emit-declaration.js new file mode 100644 index 0000000..8959092 --- /dev/null +++ b/generator/codemods/vue/add-emit-declaration.js @@ -0,0 +1,74 @@ +/** + * @param {Object} context + * @param {import('jscodeshift').JSCodeshift} context.j + * @param {ReturnType} context.root + */ +module.exports = function addEmitDeclaration(context) { + const { j, root } = context + + // this.$emit('xxx') => emits: ['xxx'] + const this$emits = root.find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { type: 'ThisExpression' }, + property: { + type: 'Identifier', + name: '$emit' + } + } + }) + + const emits = [] + for (let i = 0; i < this$emits.length; i++) { + const arg = this$emits.at(i).get().node.arguments[0] + if (arg.type === 'StringLiteral') { + emits.push(arg.value) + } + } + + if (emits.length === 0) { + return + } + + const defaultObject = root + .find(j.ExportDefaultDeclaration) + .at(0) + .find(j.ObjectExpression) + .at(0) + + let oldEmits = emits + let emitsProperty = defaultObject.find(j.ObjectProperty, { + key: { + type: 'Identifier', + name: 'emits' + } + }) + if (emitsProperty.length > 0) { + oldEmits = emitsProperty + .at(0) + .get() + .node.value.elements.map(el => el.value) + + let hasChange = false + for (const el of emits) { + if (!oldEmits.includes(el)) { + oldEmits.push(el) + hasChange = true + } + } + if (!hasChange) { + return + } + emitsProperty.remove() + } + + defaultObject.replaceWith(({ node }) => { + node.properties.unshift( + j.objectProperty( + j.identifier('emits'), + j.arrayExpression(oldEmits.map(el => j.stringLiteral(el))) + ) + ) + return node + }) +} diff --git a/generator/codemods/vue/add-watch-deep.js b/generator/codemods/vue/add-watch-deep.js new file mode 100644 index 0000000..edbfe4d --- /dev/null +++ b/generator/codemods/vue/add-watch-deep.js @@ -0,0 +1,124 @@ +/** + * @param {Object} context + * @param {import('jscodeshift').JSCodeshift} context.j + * @param {ReturnType} context.root + */ +module.exports = function addEmitDeclaration(context) { + const { j, root } = context + + // this.$watch(...) add deep option + const this$watches = root.find(j.CallExpression, { + callee: { + type: 'MemberExpression', + object: { type: 'ThisExpression' }, + property: { + type: 'Identifier', + name: '$watch' + } + } + }) + + for (let i = 0; i < this$watches.length; i++) { + const watchFunc = this$watches.at(i) + const deepProperty = watchFunc.find(j.ObjectProperty, { + key: { + type: 'Identifier', + name: 'deep' + } + }) + if (deepProperty.length > 0) { + continue + } + const args = watchFunc.get().node.arguments + if (args.length < 2) { + continue + } + if (args[1].type != 'ObjectExpression') { + if (args.length < 3) { + watchFunc.replaceWith(nodePath => { + nodePath.node.arguments.push(j.objectExpression([])) + return nodePath.node + }) + } + const target = watchFunc.find(j.ObjectExpression).at(0) + target.replaceWith(nodePath => { + nodePath.node.properties.push( + j.objectProperty(j.identifier('deep'), j.booleanLiteral(true)) + ) + return nodePath.node + }) + } + } + + // watch: {...} add deep option + const watchFuncs = root + .find(j.ExportDefaultDeclaration) + .at(0) + .find(j.ObjectExpression) + .at(0) + .find(j.ObjectProperty, { + key: { + type: 'Identifier', + name: 'watch' + } + }) + .at(0) + .find(j.ObjectExpression) + .at(0) + .find(j.ObjectProperty) + + for (let i = 0; i < watchFuncs.length; i++) { + const watchProperty = watchFuncs.at(i) + if (!inExportDefaultLevel(watchProperty, 2)) { + continue + } + const deepProperty = watchProperty.find(j.ObjectProperty, { + key: { + type: 'Identifier', + name: 'deep' + } + }) + if (deepProperty.length > 0) { + continue + } + + if (watchProperty.get().node.value.type === 'ObjectExpression') { + const target = watchProperty.find(j.ObjectExpression).at(0) + target.replaceWith(nodePath => { + nodePath.node.properties.push( + j.objectProperty(j.identifier('deep'), j.booleanLiteral(true)) + ) + return nodePath.node + }) + } else { + watchProperty.replaceWith(nodePath => { + nodePath.node.value = j.objectExpression([ + j.objectProperty(j.identifier('handler'), nodePath.node.value), + j.objectProperty(j.identifier('deep'), j.booleanLiteral(true)) + ]) + return nodePath.node + }) + } + } +} + +function getExportDefaultLevel(collection) { + let path = collection.get() + let level = 0 + while (path) { + if (path.node.type === 'ExportDefaultDeclaration') { + return level + } + path = path.parentPath + level++ + } + return -1 +} + +function inExportDefaultLevel(collection, level) { + const lvl = getExportDefaultLevel(collection) + if (level * 3 === lvl) { + return true + } + return false +} diff --git a/generator/codemods/vue/index.js b/generator/codemods/vue/index.js new file mode 100644 index 0000000..b0d67ee --- /dev/null +++ b/generator/codemods/vue/index.js @@ -0,0 +1,14 @@ +/** @type {import('jscodeshift').Transform} */ +module.exports = function(fileInfo, api) { + const j = api.jscodeshift + const root = j(fileInfo.source) + const context = { j, root } + + require('./add-emit-declaration')(context) + require('./add-watch-deep')(context) + require('./rename-lifecycle')(context) + + return root.toSource({ lineTerminator: '\n' }) +} + +module.exports.parser = 'babylon' diff --git a/generator/codemods/vue/rename-lifecycle.js b/generator/codemods/vue/rename-lifecycle.js new file mode 100644 index 0000000..48f3673 --- /dev/null +++ b/generator/codemods/vue/rename-lifecycle.js @@ -0,0 +1,28 @@ +/** @type {import('jscodeshift').Transform} */ + +const DEPRECATED_LIFECYCLE = Object.create(null) +DEPRECATED_LIFECYCLE.destroyed = 'unmounted' +DEPRECATED_LIFECYCLE.beforeDestroy = 'beforeUnmount' + +module.exports = function renameLifecycle(context) { + const { j, root } = context + + const renameDeprecatedLifecycle = path => { + const name = path.node.key.name + + if ( + DEPRECATED_LIFECYCLE[name] && + path.parent && + path.parent.parent && + path.parent.parent.value.type === 'ExportDefaultDeclaration' + ) { + path.value.key.name = DEPRECATED_LIFECYCLE[name] + } + } + + root.find(j.ObjectProperty).forEach(renameDeprecatedLifecycle) + root.find(j.ObjectMethod).forEach(renameDeprecatedLifecycle) + root.find(j.ClassProperty).forEach(renameDeprecatedLifecycle) + + return root.toSource({ lineTerminator: '\n' }) +} diff --git a/generator/index.js b/generator/index.js index d9f4604..f6d2480 100644 --- a/generator/index.js +++ b/generator/index.js @@ -22,6 +22,14 @@ module.exports = (api) => { const globalAPITransform = require('./codemods/global-api') api.transformScript(api.entryFile, globalAPITransform) + const vueTransform = require('./codemods/vue') + const vueFiles = Object.keys(api.generator.files).filter(el => + el.endsWith('.vue') + ) + for (let i = 0; i < vueFiles.length; i++) { + api.transformScript(vueFiles[i], vueTransform) + } + if (api.hasPlugin('eslint')) { api.extendPackage({ devDependencies: { @@ -126,3 +134,15 @@ module.exports = (api) => { api.exitLog('Documentation available at https://github.com/vuejs/vue-test-utils-next') } } + +module.exports.hooks = api => { + api.postProcessFiles(files => { + const vueTransform = require('./codemods/vue-addition') + const vueFiles = Object.keys(api.generator.files).filter(el => + el.endsWith('.vue') + ) + for (let i = 0; i < vueFiles.length; i++) { + vueTransform(files, vueFiles[i]) + } + }) +}