feat(compiler): handle conditional v-slot
This commit is contained in:
parent
e90b83600a
commit
3d14265102
@ -233,7 +233,7 @@ describe('compiler: v-if', () => {
|
|||||||
})
|
})
|
||||||
expect(onError.mock.calls[0]).toMatchObject([
|
expect(onError.mock.calls[0]).toMatchObject([
|
||||||
{
|
{
|
||||||
code: ErrorCodes.X_ELSE_IF_NO_ADJACENT_IF,
|
code: ErrorCodes.X_ELSE_NO_ADJACENT_IF,
|
||||||
loc: node1.loc
|
loc: node1.loc
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
@ -245,7 +245,7 @@ describe('compiler: v-if', () => {
|
|||||||
)
|
)
|
||||||
expect(onError.mock.calls[1]).toMatchObject([
|
expect(onError.mock.calls[1]).toMatchObject([
|
||||||
{
|
{
|
||||||
code: ErrorCodes.X_ELSE_IF_NO_ADJACENT_IF,
|
code: ErrorCodes.X_ELSE_NO_ADJACENT_IF,
|
||||||
loc: node2.loc
|
loc: node2.loc
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
@ -257,7 +257,7 @@ describe('compiler: v-if', () => {
|
|||||||
)
|
)
|
||||||
expect(onError.mock.calls[2]).toMatchObject([
|
expect(onError.mock.calls[2]).toMatchObject([
|
||||||
{
|
{
|
||||||
code: ErrorCodes.X_ELSE_IF_NO_ADJACENT_IF,
|
code: ErrorCodes.X_ELSE_NO_ADJACENT_IF,
|
||||||
loc: node3.loc
|
loc: node3.loc
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
@ -63,7 +63,6 @@ export const enum ErrorCodes {
|
|||||||
|
|
||||||
// transform errors
|
// transform errors
|
||||||
X_IF_NO_EXPRESSION,
|
X_IF_NO_EXPRESSION,
|
||||||
X_ELSE_IF_NO_ADJACENT_IF,
|
|
||||||
X_ELSE_NO_ADJACENT_IF,
|
X_ELSE_NO_ADJACENT_IF,
|
||||||
X_FOR_NO_EXPRESSION,
|
X_FOR_NO_EXPRESSION,
|
||||||
X_FOR_MALFORMED_EXPRESSION,
|
X_FOR_MALFORMED_EXPRESSION,
|
||||||
@ -140,8 +139,7 @@ export const errorMessages: { [code: number]: string } = {
|
|||||||
|
|
||||||
// transform errors
|
// transform errors
|
||||||
[ErrorCodes.X_IF_NO_EXPRESSION]: `v-if/v-else-if is missing expression.`,
|
[ErrorCodes.X_IF_NO_EXPRESSION]: `v-if/v-else-if is missing expression.`,
|
||||||
[ErrorCodes.X_ELSE_IF_NO_ADJACENT_IF]: `v-else-if has no adjacent v-if.`,
|
[ErrorCodes.X_ELSE_NO_ADJACENT_IF]: `v-else/v-else-if has no adjacent v-if.`,
|
||||||
[ErrorCodes.X_ELSE_NO_ADJACENT_IF]: `v-else has no adjacent v-if.`,
|
|
||||||
[ErrorCodes.X_FOR_NO_EXPRESSION]: `v-for is missing expression.`,
|
[ErrorCodes.X_FOR_NO_EXPRESSION]: `v-for is missing expression.`,
|
||||||
[ErrorCodes.X_FOR_MALFORMED_EXPRESSION]: `v-for has invalid expression.`,
|
[ErrorCodes.X_FOR_MALFORMED_EXPRESSION]: `v-for has invalid expression.`,
|
||||||
[ErrorCodes.X_V_BIND_NO_EXPRESSION]: `v-bind is missing expression.`,
|
[ErrorCodes.X_V_BIND_NO_EXPRESSION]: `v-bind is missing expression.`,
|
||||||
|
@ -16,6 +16,7 @@ import { isString, isArray } from '@vue/shared'
|
|||||||
import { CompilerError, defaultOnError } from './errors'
|
import { CompilerError, defaultOnError } from './errors'
|
||||||
import { TO_STRING, COMMENT, CREATE_VNODE, FRAGMENT } from './runtimeConstants'
|
import { TO_STRING, COMMENT, CREATE_VNODE, FRAGMENT } from './runtimeConstants'
|
||||||
import { createBlockExpression } from './utils'
|
import { createBlockExpression } from './utils'
|
||||||
|
import { isVSlot } from './transforms/vSlot'
|
||||||
|
|
||||||
// There are two types of transforms:
|
// There are two types of transforms:
|
||||||
//
|
//
|
||||||
@ -311,6 +312,11 @@ export function createStructuralDirectiveTransform(
|
|||||||
return (node, context) => {
|
return (node, context) => {
|
||||||
if (node.type === NodeTypes.ELEMENT) {
|
if (node.type === NodeTypes.ELEMENT) {
|
||||||
const { props } = node
|
const { props } = node
|
||||||
|
// structural directive transforms are not concerned with slots
|
||||||
|
// as they are handled separately in vSlot.ts
|
||||||
|
if (node.tagType === ElementTypes.TEMPLATE && props.some(isVSlot)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const exitFns = []
|
const exitFns = []
|
||||||
for (let i = 0; i < props.length; i++) {
|
for (let i = 0; i < props.length; i++) {
|
||||||
const prop = props[i]
|
const prop = props[i]
|
||||||
|
@ -87,9 +87,9 @@ export const transformElement: NodeTransform = (node, context) => {
|
|||||||
args.push(`null`)
|
args.push(`null`)
|
||||||
}
|
}
|
||||||
if (isComponent) {
|
if (isComponent) {
|
||||||
const { slots, hasDynamicSlotName } = buildSlots(node, context)
|
const { slots, hasDynamicSlots } = buildSlots(node, context)
|
||||||
args.push(slots)
|
args.push(slots)
|
||||||
if (hasDynamicSlotName) {
|
if (hasDynamicSlots) {
|
||||||
patchFlag |= PatchFlags.DYNAMIC_SLOTS
|
patchFlag |= PatchFlags.DYNAMIC_SLOTS
|
||||||
}
|
}
|
||||||
} else if (node.children.length === 1) {
|
} else if (node.children.length === 1) {
|
||||||
|
@ -47,7 +47,7 @@ export const transformFor = createStructuralDirectiveTransform(
|
|||||||
// create the loop render function expression now, and add the
|
// create the loop render function expression now, and add the
|
||||||
// iterator on exit after all children have been traversed
|
// iterator on exit after all children have been traversed
|
||||||
const renderExp = createCallExpression(helper(RENDER_LIST), [source])
|
const renderExp = createCallExpression(helper(RENDER_LIST), [source])
|
||||||
const keyProp = findProp(node.props, `key`)
|
const keyProp = findProp(node, `key`)
|
||||||
const fragmentFlag = keyProp
|
const fragmentFlag = keyProp
|
||||||
? PatchFlags.KEYED_FRAGMENT
|
? PatchFlags.KEYED_FRAGMENT
|
||||||
: PatchFlags.UNKEYED_FRAGMENT
|
: PatchFlags.UNKEYED_FRAGMENT
|
||||||
|
@ -114,12 +114,7 @@ export const transformIf = createStructuralDirectiveTransform(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
context.onError(
|
context.onError(
|
||||||
createCompilerError(
|
createCompilerError(ErrorCodes.X_ELSE_NO_ADJACENT_IF, node.loc)
|
||||||
dir.name === 'else'
|
|
||||||
? ErrorCodes.X_ELSE_NO_ADJACENT_IF
|
|
||||||
: ErrorCodes.X_ELSE_IF_NO_ADJACENT_IF,
|
|
||||||
node.loc
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
@ -11,15 +11,24 @@ import {
|
|||||||
ExpressionNode,
|
ExpressionNode,
|
||||||
Property,
|
Property,
|
||||||
TemplateChildNode,
|
TemplateChildNode,
|
||||||
SourceLocation
|
SourceLocation,
|
||||||
|
createConditionalExpression,
|
||||||
|
ConditionalExpression,
|
||||||
|
JSChildNode,
|
||||||
|
SimpleExpressionNode
|
||||||
} from '../ast'
|
} from '../ast'
|
||||||
import { TransformContext, NodeTransform } from '../transform'
|
import { TransformContext, NodeTransform } from '../transform'
|
||||||
import { createCompilerError, ErrorCodes } from '../errors'
|
import { createCompilerError, ErrorCodes } from '../errors'
|
||||||
import { isString } from '@vue/shared'
|
import { mergeExpressions, findNonEmptyDir } from '../utils'
|
||||||
|
|
||||||
export const isVSlot = (p: ElementNode['props'][0]): p is DirectiveNode =>
|
export const isVSlot = (p: ElementNode['props'][0]): p is DirectiveNode =>
|
||||||
p.type === NodeTypes.DIRECTIVE && p.name === 'slot'
|
p.type === NodeTypes.DIRECTIVE && p.name === 'slot'
|
||||||
|
|
||||||
|
const isStaticExp = (p: JSChildNode): p is SimpleExpressionNode =>
|
||||||
|
p.type === NodeTypes.SIMPLE_EXPRESSION && p.isStatic
|
||||||
|
|
||||||
|
const defaultFallback = createSimpleExpression(`undefined`, false)
|
||||||
|
|
||||||
// A NodeTransform that tracks scope identifiers for scoped slots so that they
|
// A NodeTransform that tracks scope identifiers for scoped slots so that they
|
||||||
// don't get prefixed by transformExpression. This transform is only applied
|
// don't get prefixed by transformExpression. This transform is only applied
|
||||||
// in non-browser builds with { prefixIdentifiers: true }
|
// in non-browser builds with { prefixIdentifiers: true }
|
||||||
@ -46,10 +55,10 @@ export function buildSlots(
|
|||||||
context: TransformContext
|
context: TransformContext
|
||||||
): {
|
): {
|
||||||
slots: ObjectExpression
|
slots: ObjectExpression
|
||||||
hasDynamicSlotName: boolean
|
hasDynamicSlots: boolean
|
||||||
} {
|
} {
|
||||||
const slots: Property[] = []
|
const slots: Property[] = []
|
||||||
let hasDynamicSlotName = false
|
let hasDynamicSlots = false
|
||||||
|
|
||||||
// 1. Check for default slot with slotProps on component itself.
|
// 1. Check for default slot with slotProps on component itself.
|
||||||
// <Comp v-slot="{ prop }"/>
|
// <Comp v-slot="{ prop }"/>
|
||||||
@ -61,7 +70,7 @@ export function buildSlots(
|
|||||||
createCompilerError(ErrorCodes.X_NAMED_SLOT_ON_COMPONENT, loc)
|
createCompilerError(ErrorCodes.X_NAMED_SLOT_ON_COMPONENT, loc)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
slots.push(buildSlot(`default`, exp, children, loc))
|
slots.push(buildDefaultSlot(exp, children, loc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Iterate through children and check for template slots
|
// 2. Iterate through children and check for template slots
|
||||||
@ -70,45 +79,127 @@ export function buildSlots(
|
|||||||
let extraneousChild: TemplateChildNode | undefined = undefined
|
let extraneousChild: TemplateChildNode | undefined = undefined
|
||||||
const seenSlotNames = new Set<string>()
|
const seenSlotNames = new Set<string>()
|
||||||
for (let i = 0; i < children.length; i++) {
|
for (let i = 0; i < children.length; i++) {
|
||||||
const child = children[i]
|
const slotElement = children[i]
|
||||||
let slotDir
|
let slotDir
|
||||||
|
|
||||||
if (
|
if (
|
||||||
child.type === NodeTypes.ELEMENT &&
|
slotElement.type !== NodeTypes.ELEMENT ||
|
||||||
child.tagType === ElementTypes.TEMPLATE &&
|
slotElement.tagType !== ElementTypes.TEMPLATE ||
|
||||||
(slotDir = child.props.find(isVSlot))
|
!(slotDir = slotElement.props.find(isVSlot))
|
||||||
) {
|
) {
|
||||||
hasTemplateSlots = true
|
// not a <template v-slot>, skip.
|
||||||
const { children, loc: nodeLoc } = child
|
extraneousChild = extraneousChild || slotElement
|
||||||
const { arg: slotName, exp: slotProps, loc: dirLoc } = slotDir
|
continue
|
||||||
if (explicitDefaultSlot) {
|
}
|
||||||
// already has on-component default slot - this is incorrect usage.
|
|
||||||
context.onError(
|
if (explicitDefaultSlot) {
|
||||||
createCompilerError(ErrorCodes.X_MIXED_SLOT_USAGE, dirLoc)
|
// already has on-component default slot - this is incorrect usage.
|
||||||
|
context.onError(
|
||||||
|
createCompilerError(ErrorCodes.X_MIXED_SLOT_USAGE, slotDir.loc)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTemplateSlots = true
|
||||||
|
const { children: slotChildren, loc: slotLoc } = slotElement
|
||||||
|
const {
|
||||||
|
arg: slotName = createSimpleExpression(`default`, true),
|
||||||
|
exp: slotProps,
|
||||||
|
loc: dirLoc
|
||||||
|
} = slotDir
|
||||||
|
|
||||||
|
// check if name is dynamic.
|
||||||
|
let staticSlotName
|
||||||
|
if (isStaticExp(slotName)) {
|
||||||
|
staticSlotName = slotName ? slotName.content : `default`
|
||||||
|
} else {
|
||||||
|
hasDynamicSlots = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotFunction = createFunctionExpression(
|
||||||
|
slotProps,
|
||||||
|
slotChildren,
|
||||||
|
false,
|
||||||
|
slotChildren.length ? slotChildren[0].loc : slotLoc
|
||||||
|
)
|
||||||
|
|
||||||
|
// check if this slot is conditional (v-if/else/else-if)
|
||||||
|
let vIf
|
||||||
|
let vElse
|
||||||
|
if ((vIf = findNonEmptyDir(slotElement, 'if'))) {
|
||||||
|
hasDynamicSlots = true
|
||||||
|
slots.push(
|
||||||
|
createObjectProperty(
|
||||||
|
slotName,
|
||||||
|
createConditionalExpression(vIf.exp!, slotFunction, defaultFallback)
|
||||||
)
|
)
|
||||||
break
|
)
|
||||||
} else {
|
} else if ((vElse = findNonEmptyDir(slotElement, /^else(-if)?$/))) {
|
||||||
if (
|
hasDynamicSlots = true
|
||||||
!slotName ||
|
// find adjacent v-if slot
|
||||||
(slotName.type === NodeTypes.SIMPLE_EXPRESSION && slotName.isStatic)
|
let vIfBase
|
||||||
) {
|
let i = slots.length
|
||||||
// check duplicate slot names
|
while (i--) {
|
||||||
const name = slotName ? slotName.content : `default`
|
if (slots[i].value.type === NodeTypes.JS_CONDITIONAL_EXPRESSION) {
|
||||||
if (seenSlotNames.has(name)) {
|
vIfBase = slots[i]
|
||||||
context.onError(
|
break
|
||||||
createCompilerError(ErrorCodes.X_DUPLICATE_SLOT_NAMES, dirLoc)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenSlotNames.add(name)
|
|
||||||
} else {
|
|
||||||
hasDynamicSlotName = true
|
|
||||||
}
|
}
|
||||||
slots.push(
|
}
|
||||||
buildSlot(slotName || `default`, slotProps, children, nodeLoc)
|
if (vIfBase) {
|
||||||
|
// check if the v-else and the base v-if has the same slot name
|
||||||
|
if (
|
||||||
|
isStaticExp(vIfBase.key) &&
|
||||||
|
vIfBase.key.content === staticSlotName
|
||||||
|
) {
|
||||||
|
let conditional = vIfBase.value as ConditionalExpression
|
||||||
|
while (
|
||||||
|
conditional.alternate.type === NodeTypes.JS_CONDITIONAL_EXPRESSION
|
||||||
|
) {
|
||||||
|
conditional = conditional.alternate
|
||||||
|
}
|
||||||
|
conditional.alternate = vElse.exp
|
||||||
|
? createConditionalExpression(
|
||||||
|
vElse.exp,
|
||||||
|
slotFunction,
|
||||||
|
defaultFallback
|
||||||
|
)
|
||||||
|
: slotFunction
|
||||||
|
} else {
|
||||||
|
// not the same slot name. generate a separate property.
|
||||||
|
slots.push(
|
||||||
|
createObjectProperty(
|
||||||
|
slotName,
|
||||||
|
createConditionalExpression(
|
||||||
|
// negate baseVIf
|
||||||
|
mergeExpressions(
|
||||||
|
`!(`,
|
||||||
|
(vIfBase.value as ConditionalExpression).test,
|
||||||
|
`)`,
|
||||||
|
...(vElse.exp ? [` && (`, vElse.exp, `)`] : [])
|
||||||
|
),
|
||||||
|
slotFunction,
|
||||||
|
defaultFallback
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.onError(
|
||||||
|
createCompilerError(ErrorCodes.X_ELSE_NO_ADJACENT_IF, vElse.loc)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (!extraneousChild) {
|
} else {
|
||||||
extraneousChild = child
|
// check duplicate static names
|
||||||
|
if (staticSlotName) {
|
||||||
|
if (seenSlotNames.has(staticSlotName)) {
|
||||||
|
context.onError(
|
||||||
|
createCompilerError(ErrorCodes.X_DUPLICATE_SLOT_NAMES, dirLoc)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenSlotNames.add(staticSlotName)
|
||||||
|
}
|
||||||
|
slots.push(createObjectProperty(slotName, slotFunction))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,23 +214,22 @@ export function buildSlots(
|
|||||||
|
|
||||||
if (!explicitDefaultSlot && !hasTemplateSlots) {
|
if (!explicitDefaultSlot && !hasTemplateSlots) {
|
||||||
// implicit default slot.
|
// implicit default slot.
|
||||||
slots.push(buildSlot(`default`, undefined, children, loc))
|
slots.push(buildDefaultSlot(undefined, children, loc))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots: createObjectExpression(slots, loc),
|
slots: createObjectExpression(slots, loc),
|
||||||
hasDynamicSlotName
|
hasDynamicSlots
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSlot(
|
function buildDefaultSlot(
|
||||||
name: string | ExpressionNode,
|
|
||||||
slotProps: ExpressionNode | undefined,
|
slotProps: ExpressionNode | undefined,
|
||||||
children: TemplateChildNode[],
|
children: TemplateChildNode[],
|
||||||
loc: SourceLocation
|
loc: SourceLocation
|
||||||
): Property {
|
): Property {
|
||||||
return createObjectProperty(
|
return createObjectProperty(
|
||||||
isString(name) ? createSimpleExpression(name, true, loc) : name,
|
createSimpleExpression(`default`, true),
|
||||||
createFunctionExpression(
|
createFunctionExpression(
|
||||||
slotProps,
|
slotProps,
|
||||||
children,
|
children,
|
||||||
|
@ -6,12 +6,17 @@ import {
|
|||||||
CallExpression,
|
CallExpression,
|
||||||
SequenceExpression,
|
SequenceExpression,
|
||||||
createSequenceExpression,
|
createSequenceExpression,
|
||||||
createCallExpression
|
createCallExpression,
|
||||||
|
ExpressionNode,
|
||||||
|
CompoundExpressionNode,
|
||||||
|
createCompoundExpression,
|
||||||
|
DirectiveNode
|
||||||
} from './ast'
|
} from './ast'
|
||||||
import { parse } from 'acorn'
|
import { parse } from 'acorn'
|
||||||
import { walk } from 'estree-walker'
|
import { walk } from 'estree-walker'
|
||||||
import { TransformContext } from './transform'
|
import { TransformContext } from './transform'
|
||||||
import { OPEN_BLOCK, CREATE_BLOCK } from './runtimeConstants'
|
import { OPEN_BLOCK, CREATE_BLOCK } from './runtimeConstants'
|
||||||
|
import { isString } from '@vue/shared'
|
||||||
|
|
||||||
// cache node requires
|
// cache node requires
|
||||||
// lazy require dependencies so that they don't end up in rollup's dep graph
|
// lazy require dependencies so that they don't end up in rollup's dep graph
|
||||||
@ -106,12 +111,28 @@ export function assert(condition: boolean, msg?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findNonEmptyDir(
|
||||||
|
node: ElementNode,
|
||||||
|
name: string | RegExp
|
||||||
|
): DirectiveNode | undefined {
|
||||||
|
for (let i = 0; i < node.props.length; i++) {
|
||||||
|
const p = node.props[i]
|
||||||
|
if (
|
||||||
|
p.type === NodeTypes.DIRECTIVE &&
|
||||||
|
p.exp &&
|
||||||
|
(isString(name) ? p.name === name : name.test(p.name))
|
||||||
|
) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function findProp(
|
export function findProp(
|
||||||
props: ElementNode['props'],
|
node: ElementNode,
|
||||||
name: string
|
name: string
|
||||||
): ElementNode['props'][0] | undefined {
|
): ElementNode['props'][0] | undefined {
|
||||||
for (let i = 0; i < props.length; i++) {
|
for (let i = 0; i < node.props.length; i++) {
|
||||||
const p = props[i]
|
const p = node.props[i]
|
||||||
if (p.type === NodeTypes.ATTRIBUTE) {
|
if (p.type === NodeTypes.ATTRIBUTE) {
|
||||||
if (p.name === name && p.value && !p.value.isEmpty) {
|
if (p.name === name && p.value && !p.value.isEmpty) {
|
||||||
return p
|
return p
|
||||||
@ -137,3 +158,18 @@ export function createBlockExpression(
|
|||||||
createCallExpression(context.helper(CREATE_BLOCK), args)
|
createCallExpression(context.helper(CREATE_BLOCK), args)
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeExpressions(
|
||||||
|
...args: (string | ExpressionNode)[]
|
||||||
|
): CompoundExpressionNode {
|
||||||
|
const children: CompoundExpressionNode['children'] = []
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const exp = args[i]
|
||||||
|
if (isString(exp) || exp.type === NodeTypes.SIMPLE_EXPRESSION) {
|
||||||
|
children.push(exp)
|
||||||
|
} else {
|
||||||
|
children.push(...exp.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createCompoundExpression(children)
|
||||||
|
}
|
||||||
|
@ -51,7 +51,7 @@ export function resolveSlots(
|
|||||||
let value = (children as RawSlots)[key]
|
let value = (children as RawSlots)[key]
|
||||||
if (isFunction(value)) {
|
if (isFunction(value)) {
|
||||||
;(slots as any)[key] = normalizeSlot(key, value)
|
;(slots as any)[key] = normalizeSlot(key, value)
|
||||||
} else {
|
} else if (value != null) {
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
warn(
|
warn(
|
||||||
`Non-function value encountered for slot "${key}". ` +
|
`Non-function value encountered for slot "${key}". ` +
|
||||||
|
Loading…
Reference in New Issue
Block a user