} from '../src/ast'
import { baseParse } from '../src/parser'
+import { Program } from '@babel/types'
/* eslint jest/no-disabled-tests: "off" */
})
})
+ describe('expression parsing', () => {
+ test('interpolation', () => {
+ const ast = baseParse(`{{ a + b }}`, { prefixIdentifiers: true })
+ // @ts-ignore
+ expect((ast.children[0] as InterpolationNode).content.ast?.type).toBe(
+ 'BinaryExpression'
+ )
+ })
+
+ test('v-bind', () => {
+ const ast = baseParse(`<div :[key+1]="foo()" />`, {
+ prefixIdentifiers: true
+ })
+ const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
+ // @ts-ignore
+ expect(dir.arg?.ast?.type).toBe('BinaryExpression')
+ // @ts-ignore
+ expect(dir.exp?.ast?.type).toBe('CallExpression')
+ })
+
+ test('v-on multi statements', () => {
+ const ast = baseParse(`<div @click="a++;b++" />`, {
+ prefixIdentifiers: true
+ })
+ const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
+ // @ts-ignore
+ expect(dir.exp?.ast?.type).toBe('Program')
+ expect((dir.exp?.ast as Program).body).toMatchObject([
+ { type: 'ExpressionStatement' },
+ { type: 'ExpressionStatement' }
+ ])
+ })
+
+ test('v-slot', () => {
+ const ast = baseParse(`<Comp #foo="{ a, b }" />`, {
+ prefixIdentifiers: true
+ })
+ const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
+ // @ts-ignore
+ expect(dir.exp?.ast?.type).toBe('ArrowFunctionExpression')
+ })
+
+ test('v-for', () => {
+ const ast = baseParse(`<div v-for="({ a, b }, key, index) of a.b" />`, {
+ prefixIdentifiers: true
+ })
+ const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
+ const { source, value, key, index } = dir.forParseResult!
+ // @ts-ignore
+ expect(source.ast?.type).toBe('MemberExpression')
+ // @ts-ignore
+ expect(value?.ast?.type).toBe('ArrowFunctionExpression')
+ expect(key?.ast).toBeNull() // simple ident
+ expect(index?.ast).toBeNull() // simple ident
+ })
+ })
+
describe('Errors', () => {
// HTML parsing errors as specified at
// https://html.spec.whatwg.org/multipage/parsing.html#parse-errors
template: string,
options: CompilerOptions = {}
) {
- const ast = parse(template)
+ const ast = parse(template, options)
transform(ast, {
prefixIdentifiers: true,
nodeTransforms: [transformIf, transformExpression],
} from './runtimeHelpers'
import { PropsExpression } from './transforms/transformElement'
import { ImportItem, TransformContext } from './transform'
+import { Node as BabelNode } from '@babel/types'
// Vue template is a platform-agnostic superset of HTML (syntax only).
// More namespaces can be declared by platform specific compilers.
content: string
isStatic: boolean
constType: ConstantTypes
+ /**
+ * - `null` means the expression is a simple identifier that doesn't need
+ * parsing
+ * - `false` means there was a parsing error
+ */
+ ast?: BabelNode | null | false
/**
* Indicates this is an identifier for a hoist vnode call and points to the
* hoisted node.
export interface CompoundExpressionNode extends Node {
type: NodeTypes.COMPOUND_EXPRESSION
+ /**
+ * - `null` means the expression is a simple identifier that doesn't need
+ * parsing
+ * - `false` means there was a parsing error
+ */
+ ast?: BabelNode | null | false
children: (
| SimpleExpressionNode
| CompoundExpressionNode
}
const rootExp =
- root.type === 'Program' &&
- root.body[0].type === 'ExpressionStatement' &&
- root.body[0].expression
+ root.type === 'Program'
+ ? root.body[0].type === 'ExpressionStatement' && root.body[0].expression
+ : root
walk(root, {
enter(node: Node & { scopeIds?: Set<string> }, parent: Node | undefined) {
* This defaults to `true` in development and `false` in production builds.
*/
comments?: boolean
+ /**
+ * Parse JavaScript expressions with Babel.
+ * @default false
+ */
+ prefixIdentifiers?: boolean
+ /**
+ * A list of parser plugins to enable for `@babel/parser`, which is used to
+ * parse expressions in bindings and interpolations.
+ * https://babeljs.io/docs/en/next/babel-parser#plugins
+ */
+ expressionPlugins?: ParserPlugin[]
}
export type HoistTransform = (
defaultOnError,
defaultOnWarn
} from './errors'
-import { forAliasRE, isCoreComponent, isStaticArgOf } from './utils'
+import {
+ forAliasRE,
+ isCoreComponent,
+ isSimpleIdentifier,
+ isStaticArgOf
+} from './utils'
import { decodeHTML } from 'entities/lib/decode.js'
+import {
+ parse,
+ parseExpression,
+ type ParserOptions as BabelOptions
+} from '@babel/parser'
type OptionalOptions =
| 'decodeEntities'
| 'whitespace'
| 'isNativeTag'
| 'isBuiltInComponent'
+ | 'expressionPlugins'
| keyof CompilerCompatOptions
export type MergedParserOptions = Omit<
isCustomElement: NO,
onError: defaultOnError,
onWarn: defaultOnWarn,
- comments: __DEV__
+ comments: __DEV__,
+ prefixIdentifiers: false
}
let currentOptions: MergedParserOptions = defaultParserOptions
}
addNode({
type: NodeTypes.INTERPOLATION,
- content: createSimpleExpression(exp, false, getLoc(innerStart, innerEnd)),
+ content: createExp(exp, false, getLoc(innerStart, innerEnd)),
loc: getLoc(start, end)
})
},
setLocEnd((currentProp as AttributeNode).nameLoc, end)
} else {
const isStatic = arg[0] !== `[`
- ;(currentProp as DirectiveNode).arg = createSimpleExpression(
+ ;(currentProp as DirectiveNode).arg = createExp(
isStatic ? arg : arg.slice(1, -1),
isStatic,
getLoc(start, end),
}
} else {
// directive
- currentProp.exp = createSimpleExpression(
+ let expParseMode = ExpParseMode.Normal
+ if (!__BROWSER__) {
+ if (currentProp.name === 'for') {
+ expParseMode = ExpParseMode.Skip
+ } else if (currentProp.name === 'slot') {
+ expParseMode = ExpParseMode.Params
+ } else if (
+ currentProp.name === 'on' &&
+ currentAttrValue.includes(';')
+ ) {
+ expParseMode = ExpParseMode.Statements
+ }
+ }
+ currentProp.exp = createExp(
currentAttrValue,
false,
- getLoc(currentAttrStartIndex, currentAttrEndIndex)
+ getLoc(currentAttrStartIndex, currentAttrEndIndex),
+ ConstantTypes.NOT_CONSTANT,
+ expParseMode
)
if (currentProp.name === 'for') {
currentProp.forParseResult = parseForExpression(currentProp.exp)
const [, LHS, RHS] = inMatch
- const createAliasExpression = (content: string, offset: number) => {
+ const createAliasExpression = (
+ content: string,
+ offset: number,
+ asParam = false
+ ) => {
const start = loc.start.offset + offset
const end = start + content.length
- return createSimpleExpression(content, false, getLoc(start, end))
+ return createExp(
+ content,
+ false,
+ getLoc(start, end),
+ ConstantTypes.NOT_CONSTANT,
+ asParam ? ExpParseMode.Params : ExpParseMode.Normal
+ )
}
const result: ForParseResult = {
let keyOffset: number | undefined
if (keyContent) {
keyOffset = exp.indexOf(keyContent, trimmedOffset + valueContent.length)
- result.key = createAliasExpression(keyContent, keyOffset)
+ result.key = createAliasExpression(keyContent, keyOffset, true)
}
if (iteratorMatch[2]) {
result.key
? keyOffset! + keyContent.length
: trimmedOffset + valueContent.length
- )
+ ),
+ true
)
}
}
}
if (valueContent) {
- result.value = createAliasExpression(valueContent, trimmedOffset)
+ result.value = createAliasExpression(valueContent, trimmedOffset, true)
}
return result
return attr
}
-function emitError(code: ErrorCodes, index: number) {
- currentOptions.onError(createCompilerError(code, getLoc(index, index)))
+enum ExpParseMode {
+ Normal,
+ Params,
+ Statements,
+ Skip
+}
+
+function createExp(
+ content: SimpleExpressionNode['content'],
+ isStatic: SimpleExpressionNode['isStatic'] = false,
+ loc: SourceLocation,
+ constType: ConstantTypes = ConstantTypes.NOT_CONSTANT,
+ parseMode = ExpParseMode.Normal
+) {
+ const exp = createSimpleExpression(content, isStatic, loc, constType)
+ if (
+ !__BROWSER__ &&
+ !isStatic &&
+ currentOptions.prefixIdentifiers &&
+ parseMode !== ExpParseMode.Skip &&
+ content.trim()
+ ) {
+ if (isSimpleIdentifier(content)) {
+ exp.ast = null // fast path
+ return exp
+ }
+ try {
+ const plugins = currentOptions.expressionPlugins
+ const options: BabelOptions = {
+ plugins: plugins ? [...plugins, 'typescript'] : ['typescript']
+ }
+ if (parseMode === ExpParseMode.Statements) {
+ // v-on with multi-inline-statements, pad 1 char
+ exp.ast = parse(` ${content} `, options).program
+ } else if (parseMode === ExpParseMode.Params) {
+ exp.ast = parseExpression(`(${content})=>{}`, options)
+ } else {
+ // normal exp, wrap with parens
+ exp.ast = parseExpression(`(${content})`, options)
+ }
+ } catch (e: any) {
+ exp.ast = false // indicate an error
+ emitError(ErrorCodes.X_INVALID_EXPRESSION, loc.start.offset, e.message)
+ }
+ }
+ return exp
+}
+
+function emitError(code: ErrorCodes, index: number, message?: string) {
+ currentOptions.onError(
+ createCompilerError(code, getLoc(index, index), undefined, message)
+ )
}
function reset() {
// bail constant on parens (function invocation) and dot (member access)
const bailConstant = constantBailRE.test(rawExp)
- if (isSimpleIdentifier(rawExp)) {
+ let ast = node.ast
+
+ if (ast === false) {
+ // ast being false means it has caused an error already during parse phase
+ return node
+ }
+
+ if (ast === null || (!ast && isSimpleIdentifier(rawExp))) {
const isScopeVarReference = context.identifiers[rawExp]
const isAllowedGlobal = isGloballyAllowed(rawExp)
const isLiteral = isLiteralWhitelisted(rawExp)
return node
}
- let ast: any
- // exp needs to be parsed differently:
- // 1. Multiple inline statements (v-on, with presence of `;`): parse as raw
- // exp, but make sure to pad with spaces for consistent ranges
- // 2. Expressions: wrap with parens (for e.g. object expressions)
- // 3. Function arguments (v-for, v-slot): place in a function argument position
- const source = asRawStatements
- ? ` ${rawExp} `
- : `(${rawExp})${asParams ? `=>{}` : ``}`
- try {
- ast = parse(source, {
- plugins: context.expressionPlugins
- }).program
- } catch (e: any) {
- context.onError(
- createCompilerError(
- ErrorCodes.X_INVALID_EXPRESSION,
- node.loc,
- undefined,
- e.message
+ if (!ast) {
+ // exp needs to be parsed differently:
+ // 1. Multiple inline statements (v-on, with presence of `;`): parse as raw
+ // exp, but make sure to pad with spaces for consistent ranges
+ // 2. Expressions: wrap with parens (for e.g. object expressions)
+ // 3. Function arguments (v-for, v-slot): place in a function argument position
+ const source = asRawStatements
+ ? ` ${rawExp} `
+ : `(${rawExp})${asParams ? `=>{}` : ``}`
+ try {
+ ast = parse(source, {
+ plugins: context.expressionPlugins
+ }).program
+ } catch (e: any) {
+ context.onError(
+ createCompilerError(
+ ErrorCodes.X_INVALID_EXPRESSION,
+ node.loc,
+ undefined,
+ e.message
+ )
)
- )
- return node
+ return node
+ }
}
type QualifiedId = Identifier & PrefixMeta
let ret
if (children.length) {
ret = createCompoundExpression(children, node.loc)
+ ret.ast = ast
} else {
ret = node
ret.constType = bailConstant
})"
`;
+exports[`SFC compile <script setup> > dev mode import usage check > property access (whitespace) 1`] = `
+"import { defineComponent as _defineComponent } from 'vue'
+import { Foo, Bar, Baz } from './foo'
+
+export default /*#__PURE__*/_defineComponent({
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+
+return { get Foo() { return Foo } }
+}
+
+})"
+`;
+
+exports[`SFC compile <script setup> > dev mode import usage check > property access 1`] = `
+"import { defineComponent as _defineComponent } from 'vue'
+import { Foo, Bar, Baz } from './foo'
+
+export default /*#__PURE__*/_defineComponent({
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+
+return { get Foo() { return Foo } }
+}
+
+})"
+`;
+
+exports[`SFC compile <script setup> > dev mode import usage check > spread operator 1`] = `
+"import { defineComponent as _defineComponent } from 'vue'
+import { Foo, Bar, Baz } from './foo'
+
+export default /*#__PURE__*/_defineComponent({
+ setup(__props, { expose: __expose }) {
+ __expose();
+
+
+return { get Foo() { return Foo } }
+}
+
+})"
+`;
+
exports[`SFC compile <script setup> > dev mode import usage check > template ref 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
import { foo, bar, Baz } from './foo'
import { useCssVars, ref } from 'vue'
const msg = ref()
</script>
-
+
<style>
.foo {
color: v-bind(msg)
)
assertCode(content)
})
+
+ // https://github.com/nuxt/nuxt/issues/22416
+ test('property access', () => {
+ const { content } = compile(`
+ <script setup lang="ts">
+ import { Foo, Bar, Baz } from './foo'
+ </script>
+ <template>
+ <div>{{ Foo.Bar.Baz }}</div>
+ </template>
+ `)
+ expect(content).toMatch('return { get Foo() { return Foo } }')
+ assertCode(content)
+ })
+
+ test('spread operator', () => {
+ const { content } = compile(`
+ <script setup lang="ts">
+ import { Foo, Bar, Baz } from './foo'
+ </script>
+ <template>
+ <div v-bind="{ ...Foo.Bar.Baz }"></div>
+ </template>
+ `)
+ expect(content).toMatch('return { get Foo() { return Foo } }')
+ assertCode(content)
+ })
+
+ test('property access (whitespace)', () => {
+ const { content } = compile(`
+ <script setup lang="ts">
+ import { Foo, Bar, Baz } from './foo'
+ </script>
+ <template>
+ <div>{{ Foo . Bar . Baz }}</div>
+ </template>
+ `)
+ expect(content).toMatch('return { get Foo() { return Foo } }')
+ assertCode(content)
+ })
})
describe('inlineTemplate mode', () => {
options?: Partial<SFCScriptCompileOptions>,
parseOptions?: SFCParseOptions
) {
- const { descriptor } = parse(src, parseOptions)
+ const { descriptor, errors } = parse(src, parseOptions)
+ if (errors.length) {
+ console.warn(errors[0])
+ }
return compileScript(descriptor, {
...options,
id: mockId
pad?: boolean | 'line' | 'space'
ignoreEmpty?: boolean
compiler?: TemplateCompiler
+ parseExpressions?: boolean
}
export interface SFCBlock {
sourceRoot = '',
pad = false,
ignoreEmpty = true,
- compiler = CompilerDOM
+ compiler = CompilerDOM,
+ parseExpressions = true
}: SFCParseOptions = {}
): SFCParseResult {
const sourceKey =
const errors: (CompilerError | SyntaxError)[] = []
const ast = compiler.parse(source, {
parseMode: 'sfc',
+ prefixIdentifiers: parseExpressions,
onError: e => {
errors.push(e)
}
-import { parseExpression } from '@babel/parser'
import { SFCDescriptor } from '../parse'
import {
NodeTypes,
SimpleExpressionNode,
- forAliasRE,
parserOptions,
walkIdentifiers,
- TemplateChildNode
+ TemplateChildNode,
+ ExpressionNode
} from '@vue/compiler-dom'
import { createCache } from '../cache'
import { camelize, capitalize, isBuiltInDirective } from '@vue/shared'
* when not using inline mode.
*/
export function isImportUsed(local: string, sfc: SFCDescriptor): boolean {
- return new RegExp(
- // #4274 escape $ since it's a special char in regex
- // (and is the only regex special char that is valid in identifiers)
- `[^\\w$_]${local.replace(/\$/g, '\\$')}[^\\w$_]`
- ).test(resolveTemplateUsageCheckString(sfc))
+ return resolveTemplateUsageCheckString(sfc).has(local)
}
-const templateUsageCheckCache = createCache<string>()
+const templateUsageCheckCache = createCache<Set<string>>()
function resolveTemplateUsageCheckString(sfc: SFCDescriptor) {
const { content, ast } = sfc.template!
return cached
}
- let code = ''
+ const ids = new Set<string>()
ast!.children.forEach(walk)
!parserOptions.isNativeTag!(node.tag) &&
!parserOptions.isBuiltInComponent!(node.tag)
) {
- code += `,${camelize(node.tag)},${capitalize(camelize(node.tag))}`
+ ids.add(camelize(node.tag))
+ ids.add(capitalize(camelize(node.tag)))
}
for (let i = 0; i < node.props.length; i++) {
const prop = node.props[i]
if (prop.type === NodeTypes.DIRECTIVE) {
if (!isBuiltInDirective(prop.name)) {
- code += `,v${capitalize(camelize(prop.name))}`
+ ids.add(`v${capitalize(camelize(prop.name))}`)
}
// process dynamic directive arguments
if (prop.arg && !(prop.arg as SimpleExpressionNode).isStatic) {
- code += `,${stripStrings(
- (prop.arg as SimpleExpressionNode).content
- )}`
+ extractIdentifiers(ids, prop.arg)
}
- if (prop.exp) {
- code += `,${processExp(
- (prop.exp as SimpleExpressionNode).content,
- prop.name
- )}`
+ if (prop.name === 'for') {
+ extractIdentifiers(ids, prop.forParseResult!.source)
+ } else if (prop.exp) {
+ extractIdentifiers(ids, prop.exp)
}
}
if (
prop.name === 'ref' &&
prop.value?.content
) {
- code += `,${prop.value.content}`
+ ids.add(prop.value.content)
}
}
node.children.forEach(walk)
break
case NodeTypes.INTERPOLATION:
- code += `,${processExp((node.content as SimpleExpressionNode).content)}`
+ extractIdentifiers(ids, node.content)
break
}
}
- code += ';'
- templateUsageCheckCache.set(content, code)
- return code
+ templateUsageCheckCache.set(content, ids)
+ return ids
}
-function processExp(exp: string, dir?: string): string {
- if (/ as\s+\w|<.*>|:/.test(exp)) {
- if (dir === 'slot') {
- exp = `(${exp})=>{}`
- } else if (dir === 'on') {
- exp = `()=>{return ${exp}}`
- } else if (dir === 'for') {
- const inMatch = exp.match(forAliasRE)
- if (inMatch) {
- let [, LHS, RHS] = inMatch
- // #6088
- LHS = LHS.trim().replace(/^\(|\)$/g, '')
- return processExp(`(${LHS})=>{}`) + processExp(RHS)
- }
- }
- let ret = ''
- // has potential type cast or generic arguments that uses types
- const ast = parseExpression(exp, { plugins: ['typescript'] })
- walkIdentifiers(ast, node => {
- ret += `,` + node.name
- })
- return ret
- }
- return stripStrings(exp)
-}
-
-function stripStrings(exp: string) {
- return exp
- .replace(/'[^']*'|"[^"]*"/g, '')
- .replace(/`[^`]+`/g, stripTemplateString)
-}
-
-function stripTemplateString(str: string): string {
- const interpMatch = str.match(/\${[^}]+}/g)
- if (interpMatch) {
- return interpMatch.map(m => m.slice(2, -1)).join(',')
+function extractIdentifiers(ids: Set<string>, node: ExpressionNode) {
+ if (node.ast) {
+ walkIdentifiers(node.ast, n => ids.add(n.name))
+ } else if (node.ast === null) {
+ ids.add((node as SimpleExpressionNode).content)
}
- return ''
}