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'
@@ -9,5 +10,6 @@ import JsxDirectiveVHtml from './v-html.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'
@@ -11,5 +12,6 @@ import JsxDirectiveVHtml from './v-html.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 @@
+