From 3f8f4bbfc615328f873f4bf56834e287218608ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E6=99=BA=E5=AD=90=20Kevin=20Deng?= Date: Sun, 21 Jan 2024 17:25:13 +0800 Subject: [PATCH] feat: resolve TS type (#664) --- .github/workflows/ci.yml | 3 + package.json | 8 +- .../package.json | 2 +- packages/babel-plugin-jsx/package.json | 6 +- packages/babel-plugin-jsx/src/index.ts | 345 +++++++++--------- packages/babel-plugin-jsx/src/interface.ts | 5 + .../babel-plugin-jsx/src/transform-vue-jsx.ts | 1 - .../__snapshots__/resolve-type.test.tsx.snap | 17 + .../test/__snapshots__/snapshot.test.ts.snap | 2 + .../test/resolve-type.test.tsx | 21 ++ packages/babel-plugin-resolve-type/README.md | 1 + .../babel-plugin-resolve-type/package.json | 48 +++ .../babel-plugin-resolve-type/src/index.ts | 219 +++++++++++ .../__snapshots__/resolve-type.test.tsx.snap | 157 ++++++++ .../test/resolve-type.test.tsx | 170 +++++++++ .../babel-plugin-resolve-type/tsup.config.ts | 9 + packages/jsx-explorer/package.json | 2 +- pnpm-lock.yaml | 75 +++- tsconfig.json | 4 +- vitest.config.ts | 3 + 20 files changed, 924 insertions(+), 174 deletions(-) create mode 100644 packages/babel-plugin-jsx/test/__snapshots__/resolve-type.test.tsx.snap create mode 100644 packages/babel-plugin-jsx/test/resolve-type.test.tsx create mode 100644 packages/babel-plugin-resolve-type/README.md create mode 100644 packages/babel-plugin-resolve-type/package.json create mode 100644 packages/babel-plugin-resolve-type/src/index.ts create mode 100644 packages/babel-plugin-resolve-type/test/__snapshots__/resolve-type.test.tsx.snap create mode 100644 packages/babel-plugin-resolve-type/test/resolve-type.test.tsx create mode 100644 packages/babel-plugin-resolve-type/tsup.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67b4d93..06b8cfb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Install deps run: pnpm install + - name: Build + run: pnpm run build + - name: Test unit run: pnpm run test diff --git a/package.json b/package.json index c55e107a..ae7b8df8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "vue-jsx-monorepo", + "version": "1.2.0-alpha.0", "private": true, "packageManager": "pnpm@8.14.1", "type": "module", @@ -17,11 +18,15 @@ "jsx" ], "devDependencies": { + "@babel/plugin-syntax-typescript": "^7.22.5", "@rollup/plugin-babel": "^6.0.4", "@types/babel__core": "^7.20.5", + "@types/babel__helper-module-imports": "^7.18.0", + "@types/babel__helper-plugin-utils": "^7.10.1", "@types/node": "^20.11.5", "@typescript-eslint/eslint-plugin": "^6.19.0", "@vitest/coverage-v8": "^1.2.1", + "@vue/babel-plugin-jsx": "workspace:*", "bumpp": "^9.3.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -32,6 +37,5 @@ "typescript": "^5.3.3", "vite": "^5.0.12", "vitest": "^1.2.1" - }, - "version": "1.1.6" + } } diff --git a/packages/babel-helper-vue-transform-on/package.json b/packages/babel-helper-vue-transform-on/package.json index 7ad2c681..61f1cc25 100644 --- a/packages/babel-helper-vue-transform-on/package.json +++ b/packages/babel-helper-vue-transform-on/package.json @@ -1,6 +1,6 @@ { "name": "@vue/babel-helper-vue-transform-on", - "version": "1.1.6", + "version": "1.2.0-alpha.0", "description": "to help transform on", "author": "Amour1688 ", "license": "MIT", diff --git a/packages/babel-plugin-jsx/package.json b/packages/babel-plugin-jsx/package.json index b08e73a0..393af869 100644 --- a/packages/babel-plugin-jsx/package.json +++ b/packages/babel-plugin-jsx/package.json @@ -1,6 +1,6 @@ { "name": "@vue/babel-plugin-jsx", - "version": "1.1.6", + "version": "1.2.0-alpha.0", "description": "Babel plugin for Vue 3 JSX", "author": "Amour1688 ", "homepage": "https://github.com/vuejs/babel-plugin-jsx/tree/dev/packages/babel-plugin-jsx#readme", @@ -24,11 +24,13 @@ ], "dependencies": { "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", "@babel/plugin-syntax-jsx": "^7.23.3", "@babel/template": "^7.22.15", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6", - "@vue/babel-helper-vue-transform-on": "workspace:^", + "@vue/babel-helper-vue-transform-on": "workspace:*", + "@vue/babel-plugin-resolve-type": "workspace:*", "camelcase": "^6.3.0", "html-tags": "^3.3.1", "svg-tags": "^1.0.0" diff --git a/packages/babel-plugin-jsx/src/index.ts b/packages/babel-plugin-jsx/src/index.ts index 4389d77c..d165d160 100644 --- a/packages/babel-plugin-jsx/src/index.ts +++ b/packages/babel-plugin-jsx/src/index.ts @@ -3,9 +3,10 @@ import type * as BabelCore from '@babel/core'; import _template from '@babel/template'; // @ts-expect-error import _syntaxJsx from '@babel/plugin-syntax-jsx'; -// @ts-expect-error import { addNamed, addNamespace, isModule } from '@babel/helper-module-imports'; -import { type NodePath } from '@babel/traverse'; +import { type NodePath, type Visitor } from '@babel/traverse'; +import ResolveType from '@vue/babel-plugin-resolve-type'; +import { declare } from '@babel/helper-plugin-utils'; import transformVueJSX from './transform-vue-jsx'; import sugarFragment from './sugar-fragment'; import type { State, VueJSXPluginOptions } from './interface'; @@ -39,181 +40,197 @@ function interopDefault(m: any) { const syntaxJsx = /*#__PURE__*/ interopDefault(_syntaxJsx); const template = /*#__PURE__*/ interopDefault(_template); -export default ({ types }: typeof BabelCore): BabelCore.PluginObj => ({ - name: 'babel-plugin-jsx', - inherits: /*#__PURE__*/ interopDefault(syntaxJsx), - visitor: { - ...transformVueJSX, - ...sugarFragment, - Program: { - enter(path, state) { - if (hasJSX(path)) { - const importNames = [ - 'createVNode', - 'Fragment', - 'resolveComponent', - 'withDirectives', - 'vShow', - 'vModelSelect', - 'vModelText', - 'vModelCheckbox', - 'vModelRadio', - 'vModelText', - 'vModelDynamic', - 'resolveDirective', - 'mergeProps', - 'createTextVNode', - 'isVNode', - ]; - if (isModule(path)) { - // import { createVNode } from "vue"; - const importMap: Record = {}; - importNames.forEach((name) => { - state.set(name, () => { - if (importMap[name]) { - return types.cloneNode(importMap[name]); - } - const identifier = addNamed(path, name, 'vue', { - ensureLiveReference: true, +export default declare>( + (api, opt, dirname) => { + const { types } = api; + let resolveType: BabelCore.PluginObj | undefined; + if (opt.resolveType !== false) { + if (typeof opt.resolveType === 'boolean') opt.resolveType = {}; + resolveType = ResolveType(api, opt.resolveType, dirname); + } + return { + ...(resolveType || {}), + name: 'babel-plugin-jsx', + inherits: /*#__PURE__*/ interopDefault(syntaxJsx), + visitor: { + ...(resolveType?.visitor as Visitor), + ...transformVueJSX, + ...sugarFragment, + Program: { + enter(path, state) { + if (hasJSX(path)) { + const importNames = [ + 'createVNode', + 'Fragment', + 'resolveComponent', + 'withDirectives', + 'vShow', + 'vModelSelect', + 'vModelText', + 'vModelCheckbox', + 'vModelRadio', + 'vModelText', + 'vModelDynamic', + 'resolveDirective', + 'mergeProps', + 'createTextVNode', + 'isVNode', + ]; + if (isModule(path)) { + // import { createVNode } from "vue"; + const importMap: Record< + string, + t.MemberExpression | t.Identifier + > = {}; + importNames.forEach((name) => { + state.set(name, () => { + if (importMap[name]) { + return types.cloneNode(importMap[name]); + } + const identifier = addNamed(path, name, 'vue', { + ensureLiveReference: true, + }); + importMap[name] = identifier; + return identifier; + }); }); - importMap[name] = identifier; - return identifier; - }); - }); - const { enableObjectSlots = true } = state.opts; - if (enableObjectSlots) { - state.set('@vue/babel-plugin-jsx/runtimeIsSlot', () => { - if (importMap.runtimeIsSlot) { - return importMap.runtimeIsSlot; - } - const { name: isVNodeName } = state.get( - 'isVNode' - )() as t.Identifier; - const isSlot = path.scope.generateUidIdentifier('isSlot'); - const ast = template.ast` - function ${isSlot.name}(s) { - return typeof s === 'function' || (Object.prototype.toString.call(s) === '[object Object]' && !${isVNodeName}(s)); - } - `; - const lastImport = (path.get('body') as NodePath[]) - .filter((p) => p.isImportDeclaration()) - .pop(); - if (lastImport) { - lastImport.insertAfter(ast); - } - importMap.runtimeIsSlot = isSlot; - return isSlot; - }); - } - } else { - // var _vue = require('vue'); - let sourceName: t.Identifier; - importNames.forEach((name) => { - state.set(name, () => { - if (!sourceName) { - sourceName = addNamespace(path, 'vue', { - ensureLiveReference: true, + const { enableObjectSlots = true } = state.opts; + if (enableObjectSlots) { + state.set('@vue/babel-plugin-jsx/runtimeIsSlot', () => { + if (importMap.runtimeIsSlot) { + return importMap.runtimeIsSlot; + } + const { name: isVNodeName } = state.get( + 'isVNode' + )() as t.Identifier; + const isSlot = path.scope.generateUidIdentifier('isSlot'); + const ast = template.ast` + function ${isSlot.name}(s) { + return typeof s === 'function' || (Object.prototype.toString.call(s) === '[object Object]' && !${isVNodeName}(s)); + } + `; + const lastImport = (path.get('body') as NodePath[]) + .filter((p) => p.isImportDeclaration()) + .pop(); + if (lastImport) { + lastImport.insertAfter(ast); + } + importMap.runtimeIsSlot = isSlot; + return isSlot; }); } - return t.memberExpression(sourceName, t.identifier(name)); - }); - }); + } else { + // var _vue = require('vue'); + let sourceName: t.Identifier; + importNames.forEach((name) => { + state.set(name, () => { + if (!sourceName) { + sourceName = addNamespace(path, 'vue', { + ensureLiveReference: true, + }); + } + return t.memberExpression(sourceName, t.identifier(name)); + }); + }); - const helpers: Record = {}; + const helpers: Record = {}; - const { enableObjectSlots = true } = state.opts; - if (enableObjectSlots) { - state.set('@vue/babel-plugin-jsx/runtimeIsSlot', () => { - if (helpers.runtimeIsSlot) { - return helpers.runtimeIsSlot; - } - const isSlot = path.scope.generateUidIdentifier('isSlot'); - const { object: objectName } = state.get( - 'isVNode' - )() as t.MemberExpression; - const ast = template.ast` - function ${isSlot.name}(s) { - return typeof s === 'function' || (Object.prototype.toString.call(s) === '[object Object]' && !${ - (objectName as t.Identifier).name - }.isVNode(s)); - } - `; + const { enableObjectSlots = true } = state.opts; + if (enableObjectSlots) { + state.set('@vue/babel-plugin-jsx/runtimeIsSlot', () => { + if (helpers.runtimeIsSlot) { + return helpers.runtimeIsSlot; + } + const isSlot = path.scope.generateUidIdentifier('isSlot'); + const { object: objectName } = state.get( + 'isVNode' + )() as t.MemberExpression; + const ast = template.ast` + function ${isSlot.name}(s) { + return typeof s === 'function' || (Object.prototype.toString.call(s) === '[object Object]' && !${ + (objectName as t.Identifier).name + }.isVNode(s)); + } + `; - const nodePaths = path.get('body') as NodePath[]; - const lastImport = nodePaths - .filter( - (p) => - p.isVariableDeclaration() && - p.node.declarations.some( - (d) => (d.id as t.Identifier)?.name === sourceName.name + const nodePaths = path.get('body') as NodePath[]; + const lastImport = nodePaths + .filter( + (p) => + p.isVariableDeclaration() && + p.node.declarations.some( + (d) => + (d.id as t.Identifier)?.name === sourceName.name + ) ) - ) - .pop(); - if (lastImport) { - lastImport.insertAfter(ast); + .pop(); + if (lastImport) { + lastImport.insertAfter(ast); + } + return isSlot; + }); } - return isSlot; - }); - } - } - - const { - opts: { pragma = '' }, - file, - } = state; + } - if (pragma) { - state.set('createVNode', () => t.identifier(pragma)); - } + const { + opts: { pragma = '' }, + file, + } = state; - if (file.ast.comments) { - for (const comment of file.ast.comments) { - const jsxMatches = JSX_ANNOTATION_REGEX.exec(comment.value); - if (jsxMatches) { - state.set('createVNode', () => t.identifier(jsxMatches[1])); + if (pragma) { + state.set('createVNode', () => t.identifier(pragma)); } - } - } - } - }, - exit(path) { - const body = path.get('body') as NodePath[]; - const specifiersMap = new Map(); - body - .filter( - (nodePath) => - t.isImportDeclaration(nodePath.node) && - nodePath.node.source.value === 'vue' - ) - .forEach((nodePath) => { - const { specifiers } = nodePath.node as t.ImportDeclaration; - let shouldRemove = false; - specifiers.forEach((specifier) => { - if ( - !specifier.loc && - t.isImportSpecifier(specifier) && - t.isIdentifier(specifier.imported) - ) { - specifiersMap.set(specifier.imported.name, specifier); - shouldRemove = true; + if (file.ast.comments) { + for (const comment of file.ast.comments) { + const jsxMatches = JSX_ANNOTATION_REGEX.exec(comment.value); + if (jsxMatches) { + state.set('createVNode', () => t.identifier(jsxMatches[1])); + } + } } - }); - if (shouldRemove) { - nodePath.remove(); } - }); + }, + exit(path) { + const body = path.get('body') as NodePath[]; + const specifiersMap = new Map(); - const specifiers = [...specifiersMap.keys()].map( - (imported) => specifiersMap.get(imported)! - ); - if (specifiers.length) { - path.unshiftContainer( - 'body', - t.importDeclaration(specifiers, t.stringLiteral('vue')) - ); - } + body + .filter( + (nodePath) => + t.isImportDeclaration(nodePath.node) && + nodePath.node.source.value === 'vue' + ) + .forEach((nodePath) => { + const { specifiers } = nodePath.node as t.ImportDeclaration; + let shouldRemove = false; + specifiers.forEach((specifier) => { + if ( + !specifier.loc && + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported) + ) { + specifiersMap.set(specifier.imported.name, specifier); + shouldRemove = true; + } + }); + if (shouldRemove) { + nodePath.remove(); + } + }); + + const specifiers = [...specifiersMap.keys()].map( + (imported) => specifiersMap.get(imported)! + ); + if (specifiers.length) { + path.unshiftContainer( + 'body', + t.importDeclaration(specifiers, t.stringLiteral('vue')) + ); + } + }, + }, }, - }, - }, -}); + }; + } +); diff --git a/packages/babel-plugin-jsx/src/interface.ts b/packages/babel-plugin-jsx/src/interface.ts index 6eea6b0a..4700e5b6 100644 --- a/packages/babel-plugin-jsx/src/interface.ts +++ b/packages/babel-plugin-jsx/src/interface.ts @@ -1,5 +1,6 @@ import type * as t from '@babel/types'; import type * as BabelCore from '@babel/core'; +import { type Options } from '@vue/babel-plugin-resolve-type'; export type Slots = t.Identifier | t.ObjectExpression | null; @@ -23,4 +24,8 @@ export interface VueJSXPluginOptions { enableObjectSlots?: boolean; /** Replace the function used when compiling JSX expressions */ pragma?: string; + /** + * enabled by default + */ + resolveType?: Options | boolean; } diff --git a/packages/babel-plugin-jsx/src/transform-vue-jsx.ts b/packages/babel-plugin-jsx/src/transform-vue-jsx.ts index 7511a0f2..48d899f1 100644 --- a/packages/babel-plugin-jsx/src/transform-vue-jsx.ts +++ b/packages/babel-plugin-jsx/src/transform-vue-jsx.ts @@ -1,6 +1,5 @@ import * as t from '@babel/types'; import { type NodePath, type Visitor } from '@babel/traverse'; -// @ts-expect-error import { addDefault } from '@babel/helper-module-imports'; import { buildIIFE, diff --git a/packages/babel-plugin-jsx/test/__snapshots__/resolve-type.test.tsx.snap b/packages/babel-plugin-jsx/test/__snapshots__/resolve-type.test.tsx.snap new file mode 100644 index 00000000..4755eb46 --- /dev/null +++ b/packages/babel-plugin-jsx/test/__snapshots__/resolve-type.test.tsx.snap @@ -0,0 +1,17 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`resolve type > runtime props > basic 1`] = ` +"import { createVNode as _createVNode } from "vue"; +interface Props { + foo?: string; +} +const App = defineComponent((props: Props) => _createVNode("div", null, null), { + props: { + foo: { + type: String, + required: false + } + }, + name: "App" +});" +`; diff --git a/packages/babel-plugin-jsx/test/__snapshots__/snapshot.test.ts.snap b/packages/babel-plugin-jsx/test/__snapshots__/snapshot.test.ts.snap index 732fc0ff..dac48f89 100644 --- a/packages/babel-plugin-jsx/test/__snapshots__/snapshot.test.ts.snap +++ b/packages/babel-plugin-jsx/test/__snapshots__/snapshot.test.ts.snap @@ -188,6 +188,8 @@ const A = defineComponent({ }) { return () => _createVNode("span", null, [slots.default()]); } +}, { + name: "A" }); const _a2 = 2; a = _a2; diff --git a/packages/babel-plugin-jsx/test/resolve-type.test.tsx b/packages/babel-plugin-jsx/test/resolve-type.test.tsx new file mode 100644 index 00000000..83926d4f --- /dev/null +++ b/packages/babel-plugin-jsx/test/resolve-type.test.tsx @@ -0,0 +1,21 @@ +import { transformAsync } from '@babel/core'; +// @ts-expect-error missing types +import typescript from '@babel/plugin-syntax-typescript'; +import VueJsx from '../src'; + +describe('resolve type', () => { + describe('runtime props', () => { + test('basic', async () => { + const result = await transformAsync( + ` + interface Props { foo?: string } + const App = defineComponent((props: Props) =>
) + `, + { + plugins: [[typescript, { isTSX: true }], VueJsx], + } + ); + expect(result!.code).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/babel-plugin-resolve-type/README.md b/packages/babel-plugin-resolve-type/README.md new file mode 100644 index 00000000..0b993cbd --- /dev/null +++ b/packages/babel-plugin-resolve-type/README.md @@ -0,0 +1 @@ +# babel-plugin-resolve-type diff --git a/packages/babel-plugin-resolve-type/package.json b/packages/babel-plugin-resolve-type/package.json new file mode 100644 index 00000000..35c34736 --- /dev/null +++ b/packages/babel-plugin-resolve-type/package.json @@ -0,0 +1,48 @@ +{ + "name": "@vue/babel-plugin-resolve-type", + "version": "1.2.0-alpha.0", + "description": "Babel plugin for resolving Vue types.", + "author": "三咲智子 ", + "homepage": "https://github.com/vuejs/babel-plugin-jsx/tree/dev/packages/babel-plugin-resolve-type#readme", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "dev": "./src/index.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vuejs/babel-plugin-jsx" + }, + "scripts": { + "build": "tsup", + "watch": "tsup --watch" + }, + "bugs": { + "url": "https://github.com/vuejs/babel-plugin-jsx/issues" + }, + "files": [ + "dist" + ], + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "dependencies": { + "@babel/code-frame": "^7.22.10", + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/parser": "^7.22.11", + "@vue/compiler-sfc": "^3.4.15" + }, + "devDependencies": { + "@babel/core": "^7.22.11", + "@types/babel__code-frame": "^7.0.3", + "vue": "^3.3.4" + } +} diff --git a/packages/babel-plugin-resolve-type/src/index.ts b/packages/babel-plugin-resolve-type/src/index.ts new file mode 100644 index 00000000..2ed13a2a --- /dev/null +++ b/packages/babel-plugin-resolve-type/src/index.ts @@ -0,0 +1,219 @@ +import type * as BabelCore from '@babel/core'; +import { parseExpression } from '@babel/parser'; +import { + type SimpleTypeResolveContext, + type SimpleTypeResolveOptions, + extractRuntimeEmits, + extractRuntimeProps, +} from '@vue/compiler-sfc'; +import { codeFrameColumns } from '@babel/code-frame'; +import { addNamed } from '@babel/helper-module-imports'; +import { declare } from '@babel/helper-plugin-utils'; + +export { SimpleTypeResolveOptions as Options }; + +export default declare(({ types: t }, options) => { + let ctx: SimpleTypeResolveContext | undefined; + let helpers: Set | undefined; + + return { + name: 'babel-plugin-resolve-type', + pre(file) { + const filename = file.opts.filename || 'unknown.js'; + helpers = new Set(); + ctx = { + filename: filename, + source: file.code, + options, + ast: file.ast.program.body, + isCE: false, + error(msg, node) { + throw new Error( + `[@vue/babel-plugin-resolve-type] ${msg}\n\n${filename}\n${codeFrameColumns( + file.code, + { + start: { + line: node.loc!.start.line, + column: node.loc!.start.column + 1, + }, + end: { + line: node.loc!.end.line, + column: node.loc!.end.column + 1, + }, + } + )}` + ); + }, + helper(key) { + helpers!.add(key); + return `_${key}`; + }, + getString(node) { + return file.code.slice(node.start!, node.end!); + }, + propsTypeDecl: undefined, + propsRuntimeDefaults: undefined, + propsDestructuredBindings: {}, + emitsTypeDecl: undefined, + }; + }, + visitor: { + CallExpression(path) { + if (!ctx) { + throw new Error( + '[@vue/babel-plugin-resolve-type] context is not loaded.' + ); + } + + const { node } = path; + + if (!t.isIdentifier(node.callee, { name: 'defineComponent' })) return; + if (!checkDefineComponent(path)) return; + + const comp = node.arguments[0]; + if (!comp || !t.isFunction(comp)) return; + + let options = node.arguments[1]; + if (!options) { + options = t.objectExpression([]); + node.arguments.push(options); + } + + node.arguments[1] = processProps(comp, options) || options; + node.arguments[1] = processEmits(comp, node.arguments[1]) || options; + }, + VariableDeclarator(path) { + inferComponentName(path); + }, + }, + post(file) { + for (const helper of helpers!) { + addNamed(file.path, `_${helper}`, 'vue'); + } + }, + }; + + function inferComponentName( + path: BabelCore.NodePath + ) { + const id = path.get('id'); + const init = path.get('init'); + if (!id || !id.isIdentifier() || !init || !init.isCallExpression()) return; + + if (!init.get('callee')?.isIdentifier({ name: 'defineComponent' })) return; + if (!checkDefineComponent(init)) return; + + const nameProperty = t.objectProperty( + t.identifier('name'), + t.stringLiteral(id.node.name) + ); + const { arguments: args } = init.node; + if (args.length === 0) return; + + if (args.length === 1) { + init.node.arguments.push(t.objectExpression([])); + } + args[1] = addProperty(t, args[1], nameProperty); + } + + function processProps( + comp: BabelCore.types.Function, + options: + | BabelCore.types.ArgumentPlaceholder + | BabelCore.types.JSXNamespacedName + | BabelCore.types.SpreadElement + | BabelCore.types.Expression + ) { + const props = comp.params[0]; + if (!props) return; + + if (props.type === 'AssignmentPattern') { + ctx!.propsTypeDecl = getTypeAnnotation(props.left); + ctx!.propsRuntimeDefaults = props.right; + } else { + ctx!.propsTypeDecl = getTypeAnnotation(props); + } + + if (!ctx!.propsTypeDecl) return; + + const runtimeProps = extractRuntimeProps(ctx!); + if (!runtimeProps) { + return; + } + + const ast = parseExpression(runtimeProps); + return addProperty( + t, + options, + t.objectProperty(t.identifier('props'), ast) + ); + } + + function processEmits( + comp: BabelCore.types.Function, + options: + | BabelCore.types.ArgumentPlaceholder + | BabelCore.types.JSXNamespacedName + | BabelCore.types.SpreadElement + | BabelCore.types.Expression + ) { + const setupCtx = comp.params[1] && getTypeAnnotation(comp.params[1]); + if ( + !setupCtx || + !t.isTSTypeReference(setupCtx) || + !t.isIdentifier(setupCtx.typeName, { name: 'SetupContext' }) + ) + return; + + const emitType = setupCtx.typeParameters?.params[0]; + if (!emitType) return; + + ctx!.emitsTypeDecl = emitType; + const runtimeEmits = extractRuntimeEmits(ctx!); + + const ast = t.arrayExpression( + Array.from(runtimeEmits).map((e) => t.stringLiteral(e)) + ); + return addProperty( + t, + options, + t.objectProperty(t.identifier('emits'), ast) + ); + } +}); + +function getTypeAnnotation(node: BabelCore.types.Node) { + if ( + 'typeAnnotation' in node && + node.typeAnnotation && + node.typeAnnotation.type === 'TSTypeAnnotation' + ) { + return node.typeAnnotation.typeAnnotation; + } +} + +function checkDefineComponent( + path: BabelCore.NodePath +) { + const defineCompImport = + path.scope.getBinding('defineComponent')?.path.parent; + if (!defineCompImport) return true; + + return ( + defineCompImport.type === 'ImportDeclaration' && + /^@?vue(\/|$)/.test(defineCompImport.source.value) + ); +} + +function addProperty( + t: (typeof BabelCore)['types'], + object: T, + property: BabelCore.types.ObjectProperty +) { + if (t.isObjectExpression(object)) { + object.properties.unshift(property); + } else if (t.isExpression(object)) { + return t.objectExpression([property, t.spreadElement(object)]); + } + return object; +} diff --git a/packages/babel-plugin-resolve-type/test/__snapshots__/resolve-type.test.tsx.snap b/packages/babel-plugin-resolve-type/test/__snapshots__/resolve-type.test.tsx.snap new file mode 100644 index 00000000..b8d338d6 --- /dev/null +++ b/packages/babel-plugin-resolve-type/test/__snapshots__/resolve-type.test.tsx.snap @@ -0,0 +1,157 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`resolve type > defineComponent scope > fake 1`] = ` +"const defineComponent = () => {}; +defineComponent((props: { + msg?: string; +}) => { + return () =>
; +});" +`; + +exports[`resolve type > defineComponent scope > import sub-package 1`] = ` +"import { defineComponent } from 'vue/dist/vue.esm-bundler'; +defineComponent((props: { + msg?: string; +}) => { + return () =>
; +}, { + props: { + msg: { + type: String, + required: false + } + } +});" +`; + +exports[`resolve type > defineComponent scope > w/o import 1`] = ` +"defineComponent((props: { + msg?: string; +}) => { + return () =>
; +}, { + props: { + msg: { + type: String, + required: false + } + } +});" +`; + +exports[`resolve type > infer component name > identifier options 1`] = ` +"import { defineComponent } from 'vue'; +const Foo = defineComponent(() => {}, { + name: "Foo", + ...opts +});" +`; + +exports[`resolve type > infer component name > no options 1`] = ` +"import { defineComponent } from 'vue'; +const Foo = defineComponent(() => {}, { + name: "Foo" +});" +`; + +exports[`resolve type > infer component name > object options 1`] = ` +"import { defineComponent } from 'vue'; +const Foo = defineComponent(() => {}, { + name: "Foo", + foo: 'bar' +});" +`; + +exports[`resolve type > infer component name > rest param 1`] = ` +"import { defineComponent } from 'vue'; +const Foo = defineComponent(() => {}, ...args);" +`; + +exports[`resolve type > runtime emits > basic 1`] = ` +"import { type SetupContext, defineComponent } from 'vue'; +defineComponent((props, { + emit +}: SetupContext<{ + change(val: string): void; + click(): void; +}>) => { + emit('change'); + return () => {}; +}, { + emits: ["change", "click"] +});" +`; + +exports[`resolve type > runtime props > basic 1`] = ` +"import { defineComponent, h } from 'vue'; +interface Props { + msg: string; + optional?: boolean; +} +interface Props2 { + set: Set; +} +defineComponent((props: Props & Props2) => { + return () => h('div', props.msg); +}, { + props: { + msg: { + type: String, + required: true + }, + optional: { + type: Boolean, + required: false + }, + set: { + type: Set, + required: true + } + } +});" +`; + +exports[`resolve type > runtime props > with dynamic default value 1`] = ` +"import { _mergeDefaults } from "vue"; +import { defineComponent, h } from 'vue'; +const defaults = {}; +defineComponent((props: { + msg?: string; +} = defaults) => { + return () => h('div', props.msg); +}, { + props: /*#__PURE__*/_mergeDefaults({ + msg: { + type: String, + required: false + } + }, defaults) +});" +`; + +exports[`resolve type > runtime props > with static default value 1`] = ` +"import { defineComponent, h } from 'vue'; +defineComponent((props: { + msg?: string; +} = { + msg: 'hello' +}) => { + return () => h('div', props.msg); +}, { + props: { + msg: { + type: String, + required: false, + default: 'hello' + } + } +});" +`; + +exports[`resolve type > w/ tsx 1`] = ` +"import { type SetupContext, defineComponent } from 'vue'; +defineComponent(() => { + return () =>
; +}, {});" +`; diff --git a/packages/babel-plugin-resolve-type/test/resolve-type.test.tsx b/packages/babel-plugin-resolve-type/test/resolve-type.test.tsx new file mode 100644 index 00000000..43700225 --- /dev/null +++ b/packages/babel-plugin-resolve-type/test/resolve-type.test.tsx @@ -0,0 +1,170 @@ +import { transformAsync } from '@babel/core'; +// @ts-expect-error missing types +import typescript from '@babel/plugin-syntax-typescript'; +import ResolveType from '../src'; + +async function transform(code: string): Promise { + const result = await transformAsync(code, { + plugins: [[typescript, { isTSX: true }], ResolveType], + }); + return result!.code!; +} + +describe('resolve type', () => { + describe('runtime props', () => { + test('basic', async () => { + const result = await transform( + ` + import { defineComponent, h } from 'vue'; + interface Props { + msg: string; + optional?: boolean; + } + interface Props2 { + set: Set; + } + defineComponent((props: Props & Props2) => { + return () => h('div', props.msg); + }) + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('with static default value', async () => { + const result = await transform( + ` + import { defineComponent, h } from 'vue'; + defineComponent((props: { msg?: string } = { msg: 'hello' }) => { + return () => h('div', props.msg); + }) + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('with dynamic default value', async () => { + const result = await transform( + ` + import { defineComponent, h } from 'vue'; + const defaults = {} + defineComponent((props: { msg?: string } = defaults) => { + return () => h('div', props.msg); + }) + ` + ); + expect(result).toMatchSnapshot(); + }); + }); + + describe('runtime emits', () => { + test('basic', async () => { + const result = await transform( + ` + import { type SetupContext, defineComponent } from 'vue'; + defineComponent( + ( + props, + { emit }: SetupContext<{ change(val: string): void; click(): void }> + ) => { + emit('change'); + return () => {}; + } + ); + ` + ); + expect(result).toMatchSnapshot(); + }); + }); + + test('w/ tsx', async () => { + const result = await transform( + ` + import { type SetupContext, defineComponent } from 'vue'; + defineComponent(() => { + return () =>
; + }); + ` + ); + expect(result).toMatchSnapshot(); + }); + + describe('defineComponent scope', () => { + test('fake', async () => { + const result = await transform( + ` + const defineComponent = () => {}; + defineComponent((props: { msg?: string }) => { + return () =>
; + }); + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('w/o import', async () => { + const result = await transform( + ` + defineComponent((props: { msg?: string }) => { + return () =>
; + }); + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('import sub-package', async () => { + const result = await transform( + ` + import { defineComponent } from 'vue/dist/vue.esm-bundler'; + defineComponent((props: { msg?: string }) => { + return () =>
; + }); + ` + ); + expect(result).toMatchSnapshot(); + }); + }); + + describe('infer component name', () => { + test('no options', async () => { + const result = await transform( + ` + import { defineComponent } from 'vue'; + const Foo = defineComponent(() => {}) + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('object options', async () => { + const result = await transform( + ` + import { defineComponent } from 'vue'; + const Foo = defineComponent(() => {}, { foo: 'bar' }) + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('identifier options', async () => { + const result = await transform( + ` + import { defineComponent } from 'vue'; + const Foo = defineComponent(() => {}, opts) + ` + ); + expect(result).toMatchSnapshot(); + }); + + test('rest param', async () => { + const result = await transform( + ` + import { defineComponent } from 'vue'; + const Foo = defineComponent(() => {}, ...args) + ` + ); + expect(result).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/babel-plugin-resolve-type/tsup.config.ts b/packages/babel-plugin-resolve-type/tsup.config.ts new file mode 100644 index 00000000..7e54eecb --- /dev/null +++ b/packages/babel-plugin-resolve-type/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + target: 'node14', + platform: 'neutral', +}); diff --git a/packages/jsx-explorer/package.json b/packages/jsx-explorer/package.json index 72794614..bb1b5552 100644 --- a/packages/jsx-explorer/package.json +++ b/packages/jsx-explorer/package.json @@ -1,6 +1,6 @@ { "name": "@vue/jsx-explorer", - "version": "1.1.6", + "version": "1.2.0-alpha.0", "type": "module", "private": true, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3575b15a..53609970 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,21 @@ importers: .: devDependencies: + '@babel/plugin-syntax-typescript': + specifier: ^7.22.5 + version: 7.23.3(@babel/core@7.23.7) '@rollup/plugin-babel': specifier: ^6.0.4 version: 6.0.4(@babel/core@7.23.7)(@types/babel__core@7.20.5) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 + '@types/babel__helper-module-imports': + specifier: ^7.18.0 + version: 7.18.3 + '@types/babel__helper-plugin-utils': + specifier: ^7.10.1 + version: 7.10.3 '@types/node': specifier: ^20.11.5 version: 20.11.5 @@ -23,6 +32,9 @@ importers: '@vitest/coverage-v8': specifier: ^1.2.1 version: 1.2.1(vitest@1.2.1) + '@vue/babel-plugin-jsx': + specifier: workspace:* + version: link:packages/babel-plugin-jsx bumpp: specifier: ^9.3.0 version: 9.3.0 @@ -61,6 +73,9 @@ importers: '@babel/helper-module-imports': specifier: ^7.22.15 version: 7.22.15 + '@babel/helper-plugin-utils': + specifier: ^7.22.5 + version: 7.22.5 '@babel/plugin-syntax-jsx': specifier: ^7.23.3 version: 7.23.3(@babel/core@7.23.7) @@ -74,8 +89,11 @@ importers: specifier: ^7.23.6 version: 7.23.6 '@vue/babel-helper-vue-transform-on': - specifier: workspace:^ + specifier: workspace:* version: link:../babel-helper-vue-transform-on + '@vue/babel-plugin-resolve-type': + specifier: workspace:* + version: link:../babel-plugin-resolve-type camelcase: specifier: ^6.3.0 version: 6.3.0 @@ -114,6 +132,34 @@ importers: specifier: ^3.4.15 version: 3.4.15(typescript@5.3.3) + packages/babel-plugin-resolve-type: + dependencies: + '@babel/code-frame': + specifier: ^7.22.10 + version: 7.23.5 + '@babel/helper-module-imports': + specifier: ^7.22.5 + version: 7.22.15 + '@babel/helper-plugin-utils': + specifier: ^7.22.5 + version: 7.22.5 + '@babel/parser': + specifier: ^7.22.11 + version: 7.23.6 + '@vue/compiler-sfc': + specifier: ^3.4.15 + version: 3.4.15 + devDependencies: + '@babel/core': + specifier: ^7.22.11 + version: 7.23.7 + '@types/babel__code-frame': + specifier: ^7.0.3 + version: 7.0.6 + vue: + specifier: ^3.3.4 + version: 3.4.15(typescript@5.3.3) + packages/jsx-explorer: dependencies: '@babel/core': @@ -634,6 +680,16 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true + /@babel/plugin-syntax-typescript@7.23.3(@babel/core@7.23.7): + resolution: {integrity: sha512-9EiNjVJOMwCO+43TqoTrgQ8jMwcAd0sWyXi9RPfIsLTj4R2MADDDQXELhffaUx/uJv2AYcxBgPwH6j4TIA4ytQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.23.7 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + /@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.23.7): resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} @@ -1837,6 +1893,10 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true + /@types/babel__code-frame@7.0.6: + resolution: {integrity: sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA==} + dev: true + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -1853,6 +1913,19 @@ packages: '@babel/types': 7.23.6 dev: true + /@types/babel__helper-module-imports@7.18.3: + resolution: {integrity: sha512-2pyr9Vlriessj2KI85SEF7qma8vA3vzquQMw3wn6kL5lsfjH/YxJ1Noytk4/FJElpYybUbyaC37CVfEgfyme9A==} + dependencies: + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.5 + dev: true + + /@types/babel__helper-plugin-utils@7.10.3: + resolution: {integrity: sha512-FcLBBPXInqKfULB2nvOBskQPcnSMZ0s1Y2q76u9H1NPPWaLcTeq38xBeKfF/RBUECK333qeaqRdYoPSwW7rTNQ==} + dependencies: + '@types/babel__core': 7.20.5 + dev: true + /@types/babel__template@7.4.4: resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} dependencies: diff --git a/tsconfig.json b/tsconfig.json index 4309a1fd..a2b3a7b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ESNext", "module": "ESNext", "lib": ["ES2015", "DOM", "DOM.Iterable"], - "moduleResolution": "node", + "moduleResolution": "bundler", "allowJs": true, "strict": true, "noUnusedLocals": true, @@ -17,6 +17,6 @@ "@vue/babel-plugin-jsx": ["./packages/babel-plugin-jsx/src"], }, "noEmit": true, + "customConditions": ["dev"], }, - "include": ["packages/*/src", "packages/*/test"], } diff --git a/vitest.config.ts b/vitest.config.ts index abe9222a..35ac8af1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,9 @@ import { babel } from '@rollup/plugin-babel'; import Jsx from './packages/babel-plugin-jsx/src'; export default defineConfig({ + resolve: { + conditions: ['dev'], + }, esbuild: { jsx: 'preserve', },