From 8ad8454e89cb7e7b66fa0e2660d62cd3fac147bb Mon Sep 17 00:00:00 2001 From: zhiyuanzmj <32807958+zhiyuanzmj@users.noreply.github.com> Date: Fri, 18 Aug 2023 09:25:21 +0800 Subject: [PATCH] feat(jsx-directive): add v-slot directive (#463) * feat: add v-slot directive * feat: add volar plugin * test: add * docs: add v-slot * chore: typo * docs: typo * chore: add changeset --- .changeset/afraid-turkeys-grab.md | 6 + docs/features/jsx-directive.md | 5 + docs/zh-CN/features/jsx-directive.md | 5 + packages/jsx-directive/src/core/index.ts | 33 +++- packages/jsx-directive/src/core/v-slot.ts | 112 +++++++++++ .../tests/__snapshots__/v-slot.test.ts.snap | 33 ++++ .../tests/fixtures/v-slot/child.vue | 13 ++ .../tests/fixtures/v-slot/index.vue | 12 ++ packages/jsx-directive/tests/v-slot.test.ts | 25 +++ packages/volar/src/jsx-directive.ts | 181 ++++++++++++++++-- .../vue2/src/examples/jsx-directive/index.vue | 2 + .../examples/jsx-directive/v-slot/child.vue | 13 ++ .../examples/jsx-directive/v-slot/index.vue | 22 +++ .../vue3/src/examples/jsx-directive/index.vue | 2 + .../examples/jsx-directive/v-slot/child.vue | 13 ++ .../examples/jsx-directive/v-slot/index.vue | 22 +++ 16 files changed, 477 insertions(+), 22 deletions(-) create mode 100644 .changeset/afraid-turkeys-grab.md create mode 100644 packages/jsx-directive/src/core/v-slot.ts create mode 100644 packages/jsx-directive/tests/__snapshots__/v-slot.test.ts.snap create mode 100644 packages/jsx-directive/tests/fixtures/v-slot/child.vue create mode 100644 packages/jsx-directive/tests/fixtures/v-slot/index.vue create mode 100644 packages/jsx-directive/tests/v-slot.test.ts create mode 100644 playground/vue2/src/examples/jsx-directive/v-slot/child.vue create mode 100644 playground/vue2/src/examples/jsx-directive/v-slot/index.vue create mode 100644 playground/vue3/src/examples/jsx-directive/v-slot/child.vue create mode 100644 playground/vue3/src/examples/jsx-directive/v-slot/index.vue diff --git a/.changeset/afraid-turkeys-grab.md b/.changeset/afraid-turkeys-grab.md new file mode 100644 index 000000000..f002b0021 --- /dev/null +++ b/.changeset/afraid-turkeys-grab.md @@ -0,0 +1,6 @@ +--- +'@vue-macros/jsx-directive': minor +'@vue-macros/volar': patch +--- + +Add v-slot directive. diff --git a/docs/features/jsx-directive.md b/docs/features/jsx-directive.md index 751cc9ef2..623723dc3 100644 --- a/docs/features/jsx-directive.md +++ b/docs/features/jsx-directive.md @@ -13,11 +13,14 @@ Vue built-in directives for JSX. | v-once | :white_check_mark: | :x: | | | v-memo | :white_check_mark: | :x: | | | v-html | :white_check_mark: | :white_check_mark: | | +| v-slot | :white_check_mark: | :white_check_mark: | :white_check_mark: | ## Usage ```vue diff --git a/docs/zh-CN/features/jsx-directive.md b/docs/zh-CN/features/jsx-directive.md index 03fee5cca..246b93e83 100644 --- a/docs/zh-CN/features/jsx-directive.md +++ b/docs/zh-CN/features/jsx-directive.md @@ -13,11 +13,14 @@ | v-once | :white_check_mark: | :x: | | | v-memo | :white_check_mark: | :x: | | | v-html | :white_check_mark: | :white_check_mark: | | +| v-slot | :white_check_mark: | :white_check_mark: | :white_check_mark: | ## Usage ```vue diff --git a/packages/jsx-directive/src/core/index.ts b/packages/jsx-directive/src/core/index.ts index fa9f049c7..965f1d22b 100644 --- a/packages/jsx-directive/src/core/index.ts +++ b/packages/jsx-directive/src/core/index.ts @@ -17,6 +17,7 @@ import { transformVIf } from './v-if' import { transformVFor } from './v-for' import { transformVMemo } from './v-memo' import { transformVHtml } from './v-html' +import { transformVSlot } from './v-slot' export type JsxDirectiveNode = { node: JSXElement @@ -53,7 +54,7 @@ export function transformJsxDirective( const s = new MagicString(code) for (const { ast, offset } of asts) { - if (!/\sv-(if|for|memo|once|html)/.test(s.sliceNode(ast, { offset }))) + if (!/\sv-(if|for|memo|once|html|slot)/.test(s.sliceNode(ast, { offset }))) continue const vIfMap = new Map() @@ -62,6 +63,7 @@ export function transformJsxDirective( vForAttribute?: JSXAttribute })[] = [] const vHtmlNodes: JsxDirectiveNode[] = [] + const vSlotSet = new Set() walkAST(ast, { enter(node, parent) { if (node.type !== 'JSXElement') return @@ -69,7 +71,6 @@ export function transformJsxDirective( let vIfAttribute let vForAttribute let vMemoAttribute - let vHtmlAttribute for (const attribute of node.openingElement.attributes) { if (attribute.type !== 'JSXAttribute') continue if ( @@ -79,7 +80,26 @@ export function transformJsxDirective( if (attribute.name.name === 'v-for') vForAttribute = attribute if (['v-memo', 'v-once'].includes(`${attribute.name.name}`)) vMemoAttribute = attribute - if (attribute.name.name === 'v-html') vHtmlAttribute = attribute + if (attribute.name.name === 'v-html') { + vHtmlNodes.push({ + node, + attribute, + }) + } + if ( + (attribute.name.type === 'JSXNamespacedName' + ? attribute.name.namespace + : attribute.name + ).name === 'v-slot' + ) { + vSlotSet.add( + node.openingElement.name.type === 'JSXIdentifier' && + node.openingElement.name.name === 'template' && + parent?.type === 'JSXElement' + ? parent + : node + ) + } } if (vIfAttribute) { @@ -105,12 +125,6 @@ export function transformJsxDirective( vForAttribute, }) } - if (vHtmlAttribute) { - vHtmlNodes.push({ - node, - attribute: vHtmlAttribute, - }) - } }, }) @@ -118,6 +132,7 @@ export function transformJsxDirective( transformVFor(vForNodes, s, offset) version >= 3.2 && transformVMemo(vMemoNodes, s, offset) transformVHtml(vHtmlNodes, s, offset, version) + transformVSlot(Array.from(vSlotSet), s, offset, version) } return generateTransform(s, id) diff --git a/packages/jsx-directive/src/core/v-slot.ts b/packages/jsx-directive/src/core/v-slot.ts new file mode 100644 index 000000000..4e1f942d4 --- /dev/null +++ b/packages/jsx-directive/src/core/v-slot.ts @@ -0,0 +1,112 @@ +import { type MagicString } from '@vue-macros/common' +import { type JSXElement } from '@babel/types' + +export function transformVSlot( + nodes: JSXElement[], + s: MagicString, + offset = 0, + version: number +) { + nodes.reverse().forEach((node) => { + const attribute = node.openingElement.attributes.find( + (attribute) => + attribute.type === 'JSXAttribute' && + (attribute.name.type === 'JSXNamespacedName' + ? attribute.name.namespace + : attribute.name + ).name === 'v-slot' + ) + + const slots = + attribute?.type === 'JSXAttribute' + ? { + [`${ + attribute.name.type === 'JSXNamespacedName' + ? attribute.name.name.name + : 'default' + }`]: { + isTemplateTag: false, + expressionContainer: attribute.value, + children: node.children, + }, + } + : {} + if (!attribute) { + for (const child of node.children) { + let name = 'default' + let expressionContainer + const isTemplateTag = + child.type === 'JSXElement' && + child.openingElement.name.type === 'JSXIdentifier' && + child.openingElement.name.name === 'template' + + if (child.type === 'JSXElement') { + for (const attr of child.openingElement.attributes) { + if (attr.type !== 'JSXAttribute') continue + if (isTemplateTag) { + name = + attr.name.type === 'JSXNamespacedName' + ? attr.name.name.name + : 'default' + } + + if ( + (attr.name.type === 'JSXNamespacedName' + ? attr.name.namespace + : attr.name + ).name === 'v-slot' + ) + expressionContainer = attr.value + } + } + + slots[name] ??= { + isTemplateTag, + expressionContainer, + children: [child], + } + if (!slots[name].isTemplateTag) { + slots[name].expressionContainer = expressionContainer + slots[name].isTemplateTag = isTemplateTag + if (isTemplateTag) { + slots[name].children = [child] + } else { + slots[name].children.push(child) + } + } + } + } + + const result = `${ + version < 3 ? 'scopedSlots' : 'v-slots' + }={{${Object.entries(slots) + .map( + ([name, { expressionContainer, children }]) => + `'${name}': (${ + expressionContainer?.type === 'JSXExpressionContainer' + ? s.sliceNode(expressionContainer.expression, { offset }) + : '' + }) => ${version < 3 ? '' : '<>'}${children + .map((child) => { + const result = s.sliceNode( + child.type === 'JSXElement' && + child.openingElement.name.type === 'JSXIdentifier' && + child.openingElement.name.name === 'template' + ? child.children + : child, + { offset } + ) + s.removeNode(child, { offset }) + return result + }) + .join('')}${version < 3 ? '' : ''}` + ) + .join(',')}}}` + + if (attribute) { + s.overwriteNode(attribute, result, { offset }) + } else { + s.appendLeft(node.openingElement.end! + offset - 1, ` ${result}`) + } + }) +} diff --git a/packages/jsx-directive/tests/__snapshots__/v-slot.test.ts.snap b/packages/jsx-directive/tests/__snapshots__/v-slot.test.ts.snap new file mode 100644 index 000000000..d6af31aa4 --- /dev/null +++ b/packages/jsx-directive/tests/__snapshots__/v-slot.test.ts.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`jsx-vue-directive > vue 2.7 v-slot > ./fixtures/v-slot/index.vue 1`] = ` +" +" +`; + +exports[`jsx-vue-directive > vue 3 v-slot > ./fixtures/v-slot/index.vue 1`] = ` +" +" +`; diff --git a/packages/jsx-directive/tests/fixtures/v-slot/child.vue b/packages/jsx-directive/tests/fixtures/v-slot/child.vue new file mode 100644 index 000000000..f43e7db5b --- /dev/null +++ b/packages/jsx-directive/tests/fixtures/v-slot/child.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/jsx-directive/tests/fixtures/v-slot/index.vue b/packages/jsx-directive/tests/fixtures/v-slot/index.vue new file mode 100644 index 000000000..5d733bd22 --- /dev/null +++ b/packages/jsx-directive/tests/fixtures/v-slot/index.vue @@ -0,0 +1,12 @@ + diff --git a/packages/jsx-directive/tests/v-slot.test.ts b/packages/jsx-directive/tests/v-slot.test.ts new file mode 100644 index 000000000..5b41dc274 --- /dev/null +++ b/packages/jsx-directive/tests/v-slot.test.ts @@ -0,0 +1,25 @@ +import { describe } from 'vitest' +import { testFixtures } from '@vue-macros/test-utils' +import { transformJsxDirective } from '../src/api' + +describe('jsx-vue-directive', () => { + describe('vue 3 v-slot', async () => { + await testFixtures( + import.meta.glob('./fixtures/v-slot/index.{vue,jsx,tsx}', { + eager: true, + as: 'raw', + }), + (_, id, code) => transformJsxDirective(code, id, 3)?.code + ) + }) + + describe('vue 2.7 v-slot', async () => { + await testFixtures( + import.meta.glob('./fixtures/v-slot/index.{vue,jsx,tsx}', { + eager: true, + as: 'raw', + }), + (_, id, code) => transformJsxDirective(code, id, 2.7)?.code + ) + }) +}) diff --git a/packages/volar/src/jsx-directive.ts b/packages/volar/src/jsx-directive.ts index 9e63fc7c4..0219a8f17 100644 --- a/packages/volar/src/jsx-directive.ts +++ b/packages/volar/src/jsx-directive.ts @@ -128,9 +128,142 @@ function transformVFor({ }) } +function transformVSlot({ + nodes, + codes, + ts, + sfc, + source, +}: TransformOptions & { + nodes: import('typescript/lib/tsserverlibrary').JsxElement[] +}) { + nodes.forEach((node) => { + if (!ts.isIdentifier(node.openingElement.tagName)) return + + const attribute = node.openingElement.attributes.properties.find( + (attribute) => + ts.isJsxAttribute(attribute) && + (ts.isJsxNamespacedName(attribute.name) + ? attribute.name.namespace + : attribute.name + ).escapedText === 'v-slot' + ) + + const slots = + attribute && ts.isJsxAttribute(attribute) + ? { + [`${ + ts.isJsxNamespacedName(attribute.name) + ? attribute.name.name.escapedText + : 'default' + }`]: { + isTemplateTag: false, + initializer: attribute.initializer, + children: [...node.children], + }, + } + : {} + if (!attribute) { + for (const child of node.children) { + let name = 'default' + let initializer + const isTemplateTag = + ts.isJsxElement(child) && + ts.isIdentifier(child.openingElement.tagName) && + child.openingElement.tagName.escapedText === 'template' + + if (ts.isJsxElement(child)) { + for (const attr of child.openingElement.attributes.properties) { + if (!ts.isJsxAttribute(attr)) continue + if (isTemplateTag) { + name = ts.isJsxNamespacedName(attr.name) + ? `${attr.name.name.escapedText}` + : 'default' + } + + if ( + (ts.isJsxNamespacedName(attr.name) + ? attr.name.namespace + : attr.name + ).escapedText === 'v-slot' + ) + initializer = attr.initializer + } + } + + slots[name] ??= { + isTemplateTag, + initializer, + children: [child], + } + if (!slots[name].isTemplateTag) { + slots[name].initializer = initializer + slots[name].isTemplateTag = isTemplateTag + if (isTemplateTag) { + slots[name].children = [child] + } else { + slots[name].children.push(child) + } + } + } + } + + const result = [ + ' v-slots={{', + ...Object.entries(slots).flatMap(([name, { initializer, children }]) => [ + `'${name}': (`, + initializer && ts.isJsxExpression(initializer) && initializer.expression + ? [ + `${sfc[source]!.content.slice( + initializer.expression.pos, + initializer.expression.end + )}`, + source, + initializer.expression.pos, + FileRangeCapabilities.full, + ] + : '', + ') => <>', + ...children.map((child) => { + const node = + ts.isJsxElement(child) && + ts.isIdentifier(child.openingElement.tagName) && + child.openingElement.tagName.escapedText === 'template' + ? child.children + : child + replaceSourceRange(codes, source, child.pos, child.end) + return [ + sfc[source]!.content.slice(node.pos, node.end), + source, + node.pos, + FileRangeCapabilities.full, + ] + }), + ',', + ]), + `} as InstanceType['$slots'] }`, + ] as Segment[] + + if (attribute) { + replaceSourceRange(codes, source, attribute.pos, attribute.end, ...result) + } else { + replaceSourceRange( + codes, + source, + node.openingElement.end - 1, + node.openingElement.end - 1, + ...result + ) + } + }) +} + function transformJsxDirective({ codes, sfc, ts, source }: TransformOptions) { - const vIfMap = new Map() - const vForNodes: JsxAttributeNode[] = [] + const vIfAttributeMap = new Map() + const vForAttributes: JsxAttributeNode[] = [] + const vSlotNodeSet = new Set< + import('typescript/lib/tsserverlibrary').JsxElement + >() function walkJsxDirective( node: import('typescript/lib/tsserverlibrary').Node, parent?: import('typescript/lib/tsserverlibrary').Node @@ -143,22 +276,41 @@ function transformJsxDirective({ codes, sfc, ts, source }: TransformOptions) { let vIfAttribute let vForAttribute for (const attribute of properties) { - if (!ts.isJsxAttribute(attribute) || !ts.isIdentifier(attribute.name)) - continue - if (['v-if', 'v-else-if', 'v-else'].includes(attribute.name.escapedText!)) - vIfAttribute = attribute - else if (attribute.name.escapedText === 'v-for') vForAttribute = attribute + if (!ts.isJsxAttribute(attribute)) continue + if (ts.isIdentifier(attribute.name)) { + if ( + ['v-if', 'v-else-if', 'v-else'].includes(attribute.name.escapedText!) + ) + vIfAttribute = attribute + if (attribute.name.escapedText === 'v-for') vForAttribute = attribute + } + if ( + (ts.isJsxNamespacedName(attribute.name) + ? attribute.name.namespace + : attribute.name + ).escapedText === 'v-slot' && + ts.isJsxElement(node) + ) { + vSlotNodeSet.add( + ts.isIdentifier(node.openingElement.tagName) && + node.openingElement.tagName.escapedText === 'template' && + parent && + ts.isJsxElement(parent) + ? parent + : node + ) + } } if (vIfAttribute) { - if (!vIfMap.has(parent!)) vIfMap.set(parent!, []) - vIfMap.get(parent!)?.push({ + if (!vIfAttributeMap.has(parent!)) vIfAttributeMap.set(parent!, []) + vIfAttributeMap.get(parent!)?.push({ node, attribute: vIfAttribute, parent, }) } if (vForAttribute) { - vForNodes.push({ + vForAttributes.push({ node, attribute: vForAttribute, parent: vIfAttribute ? undefined : parent, @@ -174,8 +326,11 @@ function transformJsxDirective({ codes, sfc, ts, source }: TransformOptions) { } sfc[`${source}Ast`]!.forEachChild(walkJsxDirective) - transformVFor({ nodes: vForNodes, codes, sfc, ts, source }) - vIfMap.forEach((nodes) => transformVIf({ nodes, codes, sfc, ts, source })) + transformVSlot({ nodes: Array.from(vSlotNodeSet), codes, sfc, ts, source }) + transformVFor({ nodes: vForAttributes, codes, sfc, ts, source }) + vIfAttributeMap.forEach((nodes) => + transformVIf({ nodes, codes, sfc, ts, source }) + ) } const plugin: VueLanguagePlugin = ({ modules: { typescript: ts } }) => { @@ -186,7 +341,7 @@ const plugin: VueLanguagePlugin = ({ modules: { typescript: ts } }) => { if (embeddedFile.kind !== FileKind.TypeScriptHostFile) return for (const source of ['script', 'scriptSetup'] as const) { - if (!/\s(v-if|v-for)=/.test(`${sfc[source]?.content}`)) continue + if (!/\sv-(if|for|slot)/.test(`${sfc[source]?.content}`)) continue transformJsxDirective({ codes: embeddedFile.content, diff --git a/playground/vue2/src/examples/jsx-directive/index.vue b/playground/vue2/src/examples/jsx-directive/index.vue index 000abfdc3..e4fb3cf14 100644 --- a/playground/vue2/src/examples/jsx-directive/index.vue +++ b/playground/vue2/src/examples/jsx-directive/index.vue @@ -2,6 +2,7 @@ import JsxDirectiveVIf from './v-if.vue' import JsxDirectiveVFor from './v-for.vue' import JsxDirectiveVHtml from './v-html.vue' +import JsxDirectiveVSlot from './v-slot/index.vue' diff --git a/playground/vue2/src/examples/jsx-directive/v-slot/child.vue b/playground/vue2/src/examples/jsx-directive/v-slot/child.vue new file mode 100644 index 000000000..f43e7db5b --- /dev/null +++ b/playground/vue2/src/examples/jsx-directive/v-slot/child.vue @@ -0,0 +1,13 @@ + + + diff --git a/playground/vue2/src/examples/jsx-directive/v-slot/index.vue b/playground/vue2/src/examples/jsx-directive/v-slot/index.vue new file mode 100644 index 000000000..d83d33f8b --- /dev/null +++ b/playground/vue2/src/examples/jsx-directive/v-slot/index.vue @@ -0,0 +1,22 @@ + diff --git a/playground/vue3/src/examples/jsx-directive/index.vue b/playground/vue3/src/examples/jsx-directive/index.vue index 0ae205261..e982469b4 100644 --- a/playground/vue3/src/examples/jsx-directive/index.vue +++ b/playground/vue3/src/examples/jsx-directive/index.vue @@ -3,6 +3,7 @@ import JsxDirectiveVIf from './v-if.vue' import JsxDirectiveVFor from './v-for.vue' import JsxDirectiveVMemo from './v-memo.vue' import JsxDirectiveVHtml from './v-html.vue' +import JsxDirectiveVSlot from './v-slot/index.vue' diff --git a/playground/vue3/src/examples/jsx-directive/v-slot/child.vue b/playground/vue3/src/examples/jsx-directive/v-slot/child.vue new file mode 100644 index 000000000..f43e7db5b --- /dev/null +++ b/playground/vue3/src/examples/jsx-directive/v-slot/child.vue @@ -0,0 +1,13 @@ + + + diff --git a/playground/vue3/src/examples/jsx-directive/v-slot/index.vue b/playground/vue3/src/examples/jsx-directive/v-slot/index.vue new file mode 100644 index 000000000..d83d33f8b --- /dev/null +++ b/playground/vue3/src/examples/jsx-directive/v-slot/index.vue @@ -0,0 +1,22 @@ +