import type { ParserOptions } from '../src/options'
import { ErrorCodes } from '../src/errors'
import {
type CommentNode,
ConstantTypes,
type DirectiveNode,
type ElementNode,
ElementTypes,
type InterpolationNode,
NodeTypes,
type Position,
type TextNode,
} from '../src/ast'
import { baseParse } from '../src/parser'
import type { Program } from '@babel/types'
import { Namespaces } from '@vue/shared'
describe('compiler: parse', () => {
describe('Text', () => {
test('simple text', () => {
const ast = baseParse('some text')
const text = ast.children[0] as TextNode
expect(text).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some text',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 9, line: 1, column: 10 },
source: 'some text',
},
})
})
test('simple text with invalid end tag', () => {
const onError = vi.fn()
const ast = baseParse('some text', { onError })
const text = ast.children[0] as TextNode
expect(onError.mock.calls).toMatchObject([
[
{
code: ErrorCodes.X_INVALID_END_TAG,
loc: {
start: { column: 10, line: 1, offset: 9 },
end: { column: 10, line: 1, offset: 9 },
},
},
],
])
expect(text).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some text',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 9, line: 1, column: 10 },
source: 'some text',
},
})
})
test('text with interpolation', () => {
const ast = baseParse('some {{ foo + bar }} text')
const text1 = ast.children[0] as TextNode
const text2 = ast.children[2] as TextNode
expect(text1).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some ',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 5, line: 1, column: 6 },
source: 'some ',
},
})
expect(text2).toStrictEqual({
type: NodeTypes.TEXT,
content: ' text',
loc: {
start: { offset: 20, line: 1, column: 21 },
end: { offset: 25, line: 1, column: 26 },
source: ' text',
},
})
})
test('text with interpolation which has `<`', () => {
const ast = baseParse('some {{ ad }} text')
const text1 = ast.children[0] as TextNode
const text2 = ast.children[2] as TextNode
expect(text1).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some ',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 5, line: 1, column: 6 },
source: 'some ',
},
})
expect(text2).toStrictEqual({
type: NodeTypes.TEXT,
content: ' text',
loc: {
start: { offset: 21, line: 1, column: 22 },
end: { offset: 26, line: 1, column: 27 },
source: ' text',
},
})
})
test('text with mix of tags and interpolations', () => {
const ast = baseParse('some {{ foo < bar + foo }} text')
const text1 = ast.children[0] as TextNode
const text2 = (ast.children[1] as ElementNode).children![1] as TextNode
expect(text1).toStrictEqual({
type: NodeTypes.TEXT,
content: 'some ',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 5, line: 1, column: 6 },
source: 'some ',
},
})
expect(text2).toStrictEqual({
type: NodeTypes.TEXT,
content: ' text',
loc: {
start: { offset: 32, line: 1, column: 33 },
end: { offset: 37, line: 1, column: 38 },
source: ' text',
},
})
})
test('lonely "<" doesn\'t separate nodes', () => {
const ast = baseParse('a < b', {
onError: err => {
if (err.code !== ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME) {
throw err
}
},
})
const text = ast.children[0] as TextNode
expect(text).toStrictEqual({
type: NodeTypes.TEXT,
content: 'a < b',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 5, line: 1, column: 6 },
source: 'a < b',
},
})
})
test('lonely "{{" doesn\'t separate nodes', () => {
const ast = baseParse('a {{ b', {
onError: error => {
if (error.code !== ErrorCodes.X_MISSING_INTERPOLATION_END) {
throw error
}
},
})
const text = ast.children[0] as TextNode
expect(text).toStrictEqual({
type: NodeTypes.TEXT,
content: 'a {{ b',
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 6, line: 1, column: 7 },
source: 'a {{ b',
},
})
})
})
describe('Interpolation', () => {
test('simple interpolation', () => {
const ast = baseParse('{{message}}')
const interpolation = ast.children[0] as InterpolationNode
expect(interpolation).toStrictEqual({
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: `message`,
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 2, line: 1, column: 3 },
end: { offset: 9, line: 1, column: 10 },
source: 'message',
},
},
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 11, line: 1, column: 12 },
source: '{{message}}',
},
})
})
test('it can have tag-like notation', () => {
const ast = baseParse('{{ a {
const ast = baseParse('{{ ad }}')
const interpolation1 = ast.children[0] as InterpolationNode
const interpolation2 = ast.children[1] as InterpolationNode
expect(interpolation1).toStrictEqual({
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: `ad',
loc: {
start: { offset: 12, line: 1, column: 13 },
end: { offset: 15, line: 1, column: 16 },
source: 'c>d',
},
},
loc: {
start: { offset: 9, line: 1, column: 10 },
end: { offset: 18, line: 1, column: 19 },
source: '{{ c>d }}',
},
})
})
test('it can have tag-like notation (3)', () => {
const ast = baseParse('
{msg}
', { delimiters: ['{', '}'], }) const element = ast.children[0] as ElementNode const interpolation = element.children[0] as InterpolationNode expect(interpolation).toStrictEqual({ type: NodeTypes.INTERPOLATION, content: { type: NodeTypes.SIMPLE_EXPRESSION, content: `msg`, isStatic: false, constType: ConstantTypes.NOT_CONSTANT, loc: { start: { offset: 4, line: 1, column: 5 }, end: { offset: 7, line: 1, column: 8 }, source: 'msg', }, }, loc: { start: { offset: 3, line: 1, column: 4 }, end: { offset: 8, line: 1, column: 9 }, source: '{msg}', }, }) }) }) describe('Comment', () => { test('empty comment', () => { const ast = baseParse('') const comment = ast.children[0] as CommentNode expect(comment).toStrictEqual({ type: NodeTypes.COMMENT, content: '', loc: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 7, line: 1, column: 8 }, source: '', }, }) }) test('simple comment', () => { const ast = baseParse('') const comment = ast.children[0] as CommentNode expect(comment).toStrictEqual({ type: NodeTypes.COMMENT, content: 'abc', loc: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 10, line: 1, column: 11 }, source: '', }, }) }) test('two comments', () => { const ast = baseParse('') const comment1 = ast.children[0] as CommentNode const comment2 = ast.children[1] as CommentNode expect(comment1).toStrictEqual({ type: NodeTypes.COMMENT, content: 'abc', loc: { start: { offset: 0, line: 1, column: 1 }, end: { offset: 10, line: 1, column: 11 }, source: '', }, }) expect(comment2).toStrictEqual({ type: NodeTypes.COMMENT, content: 'def', loc: { start: { offset: 10, line: 1, column: 11 }, end: { offset: 20, line: 1, column: 21 }, source: '', }, }) }) test('comments option', () => { const astOptionNoComment = baseParse('', { comments: false }) const astOptionWithComments = baseParse('', { comments: true }) expect(astOptionNoComment.children).toHaveLength(0) expect(astOptionWithComments.children).toHaveLength(1) }) // #2217 test('comments in the tag should be removed when comments option requires it', () => {
const rawText = ``
const astWithComments = baseParse(`${rawText}`, {
comments: true,
})
expect(
(astWithComments.children[0] as ElementNode).children,
).toMatchObject([
{
type: NodeTypes.ELEMENT,
tag: 'p',
},
{
type: NodeTypes.COMMENT,
},
{
type: NodeTypes.ELEMENT,
tag: 'p',
},
])
const astWithoutComments = baseParse(`${rawText}`, {
comments: false,
})
expect(
(astWithoutComments.children[0] as ElementNode).children,
).toMatchObject([
{
type: NodeTypes.ELEMENT,
tag: 'p',
},
{
type: NodeTypes.ELEMENT,
tag: 'p',
},
])
})
})
describe('Element', () => {
test('simple div', () => {
const ast = baseParse('hello')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [],
children: [
{
type: NodeTypes.TEXT,
content: 'hello',
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 10, line: 1, column: 11 },
source: 'hello',
},
},
],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 16, line: 1, column: 17 },
source: 'hello',
},
})
})
test('empty', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 11, line: 1, column: 12 },
source: '',
},
})
})
test('self closing', () => {
const ast = baseParse('after')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [],
children: [],
isSelfClosing: true,
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 6, line: 1, column: 7 },
source: '',
},
})
})
test('void element', () => {
const ast = baseParse('
after', {
isVoidTag: tag => tag === 'img',
})
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'img',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 5, line: 1, column: 6 },
source: '
',
},
})
})
test('self-closing void element', () => {
const ast = baseParse('
after', {
isVoidTag: tag => tag === 'img',
})
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'img',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [],
children: [],
isSelfClosing: true,
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 6, line: 1, column: 7 },
source: '
',
},
})
})
test('template element with directives', () => {
const ast = baseParse('')
const element = ast.children[0]
expect(element).toMatchObject({
type: NodeTypes.ELEMENT,
tagType: ElementTypes.TEMPLATE,
})
})
test('template element without directives', () => {
const ast = baseParse('')
const element = ast.children[0]
expect(element).toMatchObject({
type: NodeTypes.ELEMENT,
tagType: ElementTypes.ELEMENT,
})
})
test('native element with `isNativeTag`', () => {
const ast = baseParse(' ', {
isNativeTag: tag => tag === 'div',
})
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'comp',
tagType: ElementTypes.COMPONENT,
})
expect(ast.children[2]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'Comp',
tagType: ElementTypes.COMPONENT,
})
})
test('native element without `isNativeTag`', () => {
const ast = baseParse(' ')
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'comp',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[2]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'Comp',
tagType: ElementTypes.COMPONENT,
})
})
test('is casting with `isNativeTag`', () => {
const ast = baseParse(
` `,
{
isNativeTag: tag => tag === 'div',
},
)
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.COMPONENT,
})
expect(ast.children[2]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'Comp',
tagType: ElementTypes.COMPONENT,
})
})
test('is casting without `isNativeTag`', () => {
const ast = baseParse(` `)
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.COMPONENT,
})
expect(ast.children[2]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'Comp',
tagType: ElementTypes.COMPONENT,
})
})
test('custom element', () => {
const ast = baseParse(' ', {
isNativeTag: tag => tag === 'div',
isCustomElement: tag => tag === 'comp',
})
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'comp',
tagType: ElementTypes.ELEMENT,
})
})
test('built-in component', () => {
const ast = baseParse(' ', {
isBuiltInComponent: tag => (tag === 'comp' ? Symbol() : void 0),
})
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'div',
tagType: ElementTypes.ELEMENT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'comp',
tagType: ElementTypes.COMPONENT,
})
})
test('slot element', () => {
const ast = baseParse(' ')
expect(ast.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'slot',
tagType: ElementTypes.SLOT,
})
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'Comp',
tagType: ElementTypes.COMPONENT,
})
})
test('attribute with no value', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 14, line: 1, column: 15 },
source: '',
},
})
})
test('attribute with empty value, double quote', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: '',
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 10, line: 1, column: 11 },
source: '""',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 10, line: 1, column: 11 },
source: 'id=""',
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 17, line: 1, column: 18 },
source: '',
},
})
})
test('attribute with empty value, single quote', () => {
const ast = baseParse("")
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: '',
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 10, line: 1, column: 11 },
source: "''",
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 10, line: 1, column: 11 },
source: "id=''",
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 17, line: 1, column: 18 },
source: "",
},
})
})
test('attribute with value, double quote', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: ">'",
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 12, line: 1, column: 13 },
source: '">\'"',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 12, line: 1, column: 13 },
source: 'id=">\'"',
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 19, line: 1, column: 20 },
source: '',
},
})
})
test('attribute with value, single quote', () => {
const ast = baseParse("")
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: '>"',
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 12, line: 1, column: 13 },
source: "'>\"'",
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 12, line: 1, column: 13 },
source: "id='>\"'",
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 19, line: 1, column: 20 },
source: "",
},
})
})
test('attribute with value, unquoted', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: 'a/',
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 10, line: 1, column: 11 },
source: 'a/',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 10, line: 1, column: 11 },
source: 'id=a/',
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 17, line: 1, column: 18 },
source: '',
},
})
})
test('attribute value with >', () => {
const ast = baseParse(
'',
{ parseMode: 'sfc' },
)
const element = ast.children[0] as ElementNode
expect(element).toMatchObject({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'script',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
children: [],
innerLoc: {
start: { column: 67, line: 1, offset: 66 },
end: { column: 67, line: 1, offset: 66 },
},
props: [
{
loc: {
source: 'setup',
end: { column: 14, line: 1, offset: 13 },
start: { column: 9, line: 1, offset: 8 },
},
name: 'setup',
nameLoc: {
source: 'setup',
end: { column: 14, line: 1, offset: 13 },
start: { column: 9, line: 1, offset: 8 },
},
type: NodeTypes.ATTRIBUTE,
value: undefined,
},
{
loc: {
source: 'lang="ts"',
end: { column: 24, line: 1, offset: 23 },
start: { column: 15, line: 1, offset: 14 },
},
name: 'lang',
nameLoc: {
source: 'lang',
end: { column: 19, line: 1, offset: 18 },
start: { column: 15, line: 1, offset: 14 },
},
type: NodeTypes.ATTRIBUTE,
value: {
content: 'ts',
loc: {
source: '"ts"',
end: { column: 24, line: 1, offset: 23 },
start: { column: 20, line: 1, offset: 19 },
},
type: NodeTypes.TEXT,
},
},
{
loc: {
source: 'generic="T extends Record"',
end: { column: 66, line: 1, offset: 65 },
start: { column: 25, line: 1, offset: 24 },
},
name: 'generic',
nameLoc: {
source: 'generic',
end: { column: 32, line: 1, offset: 31 },
start: { column: 25, line: 1, offset: 24 },
},
type: NodeTypes.ATTRIBUTE,
value: {
content: 'T extends Record',
loc: {
source: '"T extends Record"',
end: { column: 66, line: 1, offset: 65 },
start: { column: 33, line: 1, offset: 32 },
},
type: NodeTypes.TEXT,
},
},
],
})
})
test('multiple attributes', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
type: NodeTypes.ELEMENT,
ns: Namespaces.HTML,
tag: 'div',
tagType: ElementTypes.ELEMENT,
codegenNode: undefined,
props: [
{
type: NodeTypes.ATTRIBUTE,
name: 'id',
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'id',
},
value: {
type: NodeTypes.TEXT,
content: 'a',
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 9, line: 1, column: 10 },
source: 'a',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 9, line: 1, column: 10 },
source: 'id=a',
},
},
{
type: NodeTypes.ATTRIBUTE,
name: 'class',
nameLoc: {
start: { offset: 10, line: 1, column: 11 },
end: { offset: 15, line: 1, column: 16 },
source: 'class',
},
value: {
type: NodeTypes.TEXT,
content: 'c',
loc: {
start: { offset: 16, line: 1, column: 17 },
end: { offset: 19, line: 1, column: 20 },
source: '"c"',
},
},
loc: {
start: { offset: 10, line: 1, column: 11 },
end: { offset: 19, line: 1, column: 20 },
source: 'class="c"',
},
},
{
type: NodeTypes.ATTRIBUTE,
name: 'inert',
nameLoc: {
start: { offset: 20, line: 1, column: 21 },
end: { offset: 25, line: 1, column: 26 },
source: 'inert',
},
value: undefined,
loc: {
start: { offset: 20, line: 1, column: 21 },
end: { offset: 25, line: 1, column: 26 },
source: 'inert',
},
},
{
type: NodeTypes.ATTRIBUTE,
name: 'style',
nameLoc: {
start: { offset: 26, line: 1, column: 27 },
end: { offset: 31, line: 1, column: 32 },
source: 'style',
},
value: {
type: NodeTypes.TEXT,
content: '',
loc: {
start: { offset: 32, line: 1, column: 33 },
end: { offset: 34, line: 1, column: 35 },
source: "''",
},
},
loc: {
start: { offset: 26, line: 1, column: 27 },
end: { offset: 34, line: 1, column: 35 },
source: "style=''",
},
},
],
children: [],
loc: {
start: { offset: 0, line: 1, column: 1 },
end: { offset: 41, line: 1, column: 42 },
source: '',
},
})
})
// https://github.com/vuejs/core/issues/4251
test('class attribute should ignore whitespace when parsed', () => {
const ast = baseParse('')
const element = ast.children[0] as ElementNode
expect(element).toStrictEqual({
children: [],
codegenNode: undefined,
loc: {
start: { column: 1, line: 1, offset: 0 },
end: { column: 10, line: 3, offset: 29 },
source: '',
},
ns: Namespaces.HTML,
props: [
{
name: 'class',
nameLoc: {
start: { column: 6, line: 1, offset: 5 },
end: { column: 11, line: 1, offset: 10 },
source: 'class',
},
type: NodeTypes.ATTRIBUTE,
value: {
content: 'c',
loc: {
start: { column: 12, line: 1, offset: 11 },
end: { column: 3, line: 3, offset: 22 },
source: '" \n\t c \t\n "',
},
type: NodeTypes.TEXT,
},
loc: {
start: { column: 6, line: 1, offset: 5 },
end: { column: 3, line: 3, offset: 22 },
source: 'class=" \n\t c \t\n "',
},
},
],
tag: 'div',
tagType: ElementTypes.ELEMENT,
type: NodeTypes.ELEMENT,
})
})
test('directive with no value', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'if',
rawName: 'v-if',
arg: undefined,
modifiers: [],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 9, line: 1, column: 10 },
source: 'v-if',
},
})
})
test('directive with value', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'if',
rawName: 'v-if',
arg: undefined,
modifiers: [],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 11, line: 1, column: 12 },
end: { offset: 12, line: 1, column: 13 },
source: 'a',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 13, line: 1, column: 14 },
source: 'v-if="a"',
},
})
})
test('directive with argument', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: 'v-on:click',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'click',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 11, line: 1, offset: 10 },
end: { column: 16, line: 1, offset: 15 },
source: 'click',
},
},
modifiers: [],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 15, line: 1, column: 16 },
source: 'v-on:click',
},
})
})
// #3494
test('directive argument edge case', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode)
.props[0] as DirectiveNode
expect(directive.arg).toMatchObject({
loc: {
start: { offset: 12, line: 1, column: 13 },
end: { offset: 16, line: 1, column: 17 },
},
})
})
// https://github.com/vuejs/language-tools/issues/2710
test('directive argument edge case (2)', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode)
.props[0] as DirectiveNode
expect(directive.arg).toMatchObject({
content: 'item.item',
loc: {
start: { offset: 6, line: 1, column: 7 },
end: { offset: 15, line: 1, column: 16 },
},
})
})
test('directive with dynamic argument', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: 'v-on:[event]',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'event',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { column: 11, line: 1, offset: 10 },
end: { column: 18, line: 1, offset: 17 },
source: '[event]',
},
},
modifiers: [],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 17, line: 1, column: 18 },
source: 'v-on:[event]',
},
})
})
test('directive with a modifier', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: 'v-on.enter',
arg: undefined,
modifiers: [
{
constType: 3,
content: 'enter',
isStatic: true,
loc: {
end: {
column: 16,
line: 1,
offset: 15,
},
source: 'enter',
start: {
column: 11,
line: 1,
offset: 10,
},
},
type: 4,
},
],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 15, line: 1, column: 16 },
source: 'v-on.enter',
},
})
})
test('directive with two modifiers', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: 'v-on.enter.exact',
arg: undefined,
modifiers: [
{
constType: 3,
content: 'enter',
isStatic: true,
loc: {
end: {
column: 16,
line: 1,
offset: 15,
},
source: 'enter',
start: {
column: 11,
line: 1,
offset: 10,
},
},
type: 4,
},
{
constType: 3,
content: 'exact',
isStatic: true,
loc: {
end: {
column: 22,
line: 1,
offset: 21,
},
source: 'exact',
start: {
column: 17,
line: 1,
offset: 16,
},
},
type: 4,
},
],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 21, line: 1, column: 22 },
source: 'v-on.enter.exact',
},
})
})
test('directive with argument and modifiers', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: 'v-on:click.enter.exact',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'click',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 11, line: 1, offset: 10 },
end: { column: 16, line: 1, offset: 15 },
source: 'click',
},
},
modifiers: [
{
constType: 3,
content: 'enter',
isStatic: true,
loc: {
end: {
column: 22,
line: 1,
offset: 21,
},
source: 'enter',
start: {
column: 17,
line: 1,
offset: 16,
},
},
type: 4,
},
{
constType: 3,
content: 'exact',
isStatic: true,
loc: {
end: {
column: 28,
line: 1,
offset: 27,
},
source: 'exact',
start: {
column: 23,
line: 1,
offset: 22,
},
},
type: 4,
},
],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 27, line: 1, column: 28 },
source: 'v-on:click.enter.exact',
},
})
})
test('directive with dynamic argument and modifiers', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: 'v-on:[a.b].camel',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a.b',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { column: 11, line: 1, offset: 10 },
end: { column: 16, line: 1, offset: 15 },
source: '[a.b]',
},
},
modifiers: [
{
constType: 3,
content: 'camel',
isStatic: true,
loc: {
end: {
column: 22,
line: 1,
offset: 21,
},
source: 'camel',
start: {
column: 17,
line: 1,
offset: 16,
},
},
type: 4,
},
],
exp: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 21, line: 1, column: 22 },
source: 'v-on:[a.b].camel',
},
})
})
test('directive with no name', () => {
let errorCode = -1
const ast = baseParse('', {
onError: err => {
errorCode = err.code as number
},
})
const directive = (ast.children[0] as ElementNode).props[0]
expect(errorCode).toBe(ErrorCodes.X_MISSING_DIRECTIVE_NAME)
expect(directive).toStrictEqual({
type: NodeTypes.ATTRIBUTE,
name: 'v-',
value: undefined,
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'v-',
},
nameLoc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 7, line: 1, column: 8 },
source: 'v-',
},
})
})
test('v-bind shorthand', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'bind',
rawName: ':a',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 7, line: 1, offset: 6 },
end: { column: 8, line: 1, offset: 7 },
source: 'a',
},
},
modifiers: [],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'b',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 9, line: 1, column: 10 },
source: 'b',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 9, line: 1, column: 10 },
source: ':a=b',
},
})
})
test('v-bind .prop shorthand', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'bind',
rawName: '.a',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 7, line: 1, offset: 6 },
end: { column: 8, line: 1, offset: 7 },
source: 'a',
},
},
modifiers: [
{
constType: 0,
content: 'prop',
isStatic: false,
loc: {
end: {
column: 1,
line: 1,
offset: 0,
},
source: '',
start: {
column: 1,
line: 1,
offset: 0,
},
},
type: 4,
},
],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'b',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 9, line: 1, column: 10 },
source: 'b',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 9, line: 1, column: 10 },
source: '.a=b',
},
})
})
test('v-bind shorthand with modifier', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'bind',
rawName: ':a.sync',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 7, line: 1, offset: 6 },
end: { column: 8, line: 1, offset: 7 },
source: 'a',
},
},
modifiers: [
{
constType: 3,
content: 'sync',
isStatic: true,
loc: {
end: {
column: 13,
line: 1,
offset: 12,
},
source: 'sync',
start: {
column: 9,
line: 1,
offset: 8,
},
},
type: 4,
},
],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'b',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 13, line: 1, column: 14 },
end: { offset: 14, line: 1, column: 15 },
source: 'b',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 14, line: 1, column: 15 },
source: ':a.sync=b',
},
})
})
test('v-on shorthand', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: '@a',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 7, line: 1, offset: 6 },
end: { column: 8, line: 1, offset: 7 },
source: 'a',
},
},
modifiers: [],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'b',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 8, line: 1, column: 9 },
end: { offset: 9, line: 1, column: 10 },
source: 'b',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 9, line: 1, column: 10 },
source: '@a=b',
},
})
})
test('v-on shorthand with modifier', () => {
const ast = baseParse('')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'on',
rawName: '@a.enter',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 7, line: 1, offset: 6 },
end: { column: 8, line: 1, offset: 7 },
source: 'a',
},
},
modifiers: [
{
constType: 3,
content: 'enter',
isStatic: true,
loc: {
end: {
column: 14,
line: 1,
offset: 13,
},
source: 'enter',
start: {
column: 9,
line: 1,
offset: 8,
},
},
type: 4,
},
],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'b',
isStatic: false,
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 14, line: 1, column: 15 },
end: { offset: 15, line: 1, column: 16 },
source: 'b',
},
},
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 15, line: 1, column: 16 },
source: '@a.enter=b',
},
})
})
test('v-slot shorthand', () => {
const ast = baseParse(' ')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toStrictEqual({
type: NodeTypes.DIRECTIVE,
name: 'slot',
rawName: '#a',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'a',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: { column: 8, line: 1, offset: 7 },
end: { column: 9, line: 1, offset: 8 },
source: 'a',
},
},
modifiers: [],
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: '{ b }',
isStatic: false,
// The `constType` is the default value and will be determined in transformExpression
constType: ConstantTypes.NOT_CONSTANT,
loc: {
start: { offset: 10, line: 1, column: 11 },
end: { offset: 15, line: 1, column: 16 },
source: '{ b }',
},
},
loc: {
start: { offset: 6, line: 1, column: 7 },
end: { offset: 16, line: 1, column: 17 },
source: '#a="{ b }"',
},
})
})
// #1241 special case for 2.x compat
test('v-slot arg containing dots', () => {
const ast = baseParse(' ')
const directive = (ast.children[0] as ElementNode).props[0]
expect(directive).toMatchObject({
type: NodeTypes.DIRECTIVE,
name: 'slot',
rawName: 'v-slot:foo.bar',
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: 'foo.bar',
isStatic: true,
constType: ConstantTypes.CAN_STRINGIFY,
loc: {
start: {
column: 14,
line: 1,
offset: 13,
},
end: {
column: 21,
line: 1,
offset: 20,
},
},
},
})
})
test('v-pre', () => {
const ast = baseParse(
` {{ bar }}\n` +
` {{ bar }}`,
)
const divWithPre = ast.children[0] as ElementNode
expect(divWithPre.props).toMatchObject([
{
type: NodeTypes.ATTRIBUTE,
name: `:id`,
value: {
type: NodeTypes.TEXT,
content: `foo`,
},
loc: {
start: { line: 1, column: 12 },
end: { line: 1, column: 21 },
},
},
])
expect(divWithPre.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tagType: ElementTypes.ELEMENT,
tag: `Comp`,
})
expect(divWithPre.children[1]).toMatchObject({
type: NodeTypes.TEXT,
content: `{{ bar }}`,
})
// should not affect siblings after it
const divWithoutPre = ast.children[1] as ElementNode
expect(divWithoutPre.props).toMatchObject([
{
type: NodeTypes.DIRECTIVE,
name: `bind`,
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
isStatic: true,
content: `id`,
},
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
isStatic: false,
content: `foo`,
},
loc: {
start: {
line: 2,
column: 6,
},
end: {
line: 2,
column: 15,
},
},
},
])
expect(divWithoutPre.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tagType: ElementTypes.COMPONENT,
tag: `Comp`,
})
expect(divWithoutPre.children[1]).toMatchObject({
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: `bar`,
isStatic: false,
},
})
})
// https://github.com/vuejs/docs/issues/2586
test('v-pre with half-open interpolation', () => {
const ast = baseParse(
`
{{ number
}}
`,
)
expect((ast.children[0] as ElementNode).children).toMatchObject([
{
type: NodeTypes.ELEMENT,
children: [{ type: NodeTypes.TEXT, content: `{{ number ` }],
},
{
type: NodeTypes.ELEMENT,
children: [{ type: NodeTypes.TEXT, content: `}}` }],
},
])
const ast2 = baseParse(`{{ number `)
expect((ast2.children[0] as ElementNode).children).toMatchObject([
{
type: NodeTypes.ELEMENT,
children: [{ type: NodeTypes.TEXT, content: `{{ number ` }],
},
])
const ast3 = baseParse(``, {
parseMode: 'html',
})
expect((ast3.children[0] as ElementNode).children).toMatchObject([
{
type: NodeTypes.ELEMENT,
children: [
{
type: NodeTypes.TEXT,
content: `{{ foo `,
},
],
},
])
})
test('self-closing v-pre', () => {
const ast = baseParse(
`\n {{ bar }}`,
)
// should not affect siblings after it
const divWithoutPre = ast.children[1] as ElementNode
expect(divWithoutPre.props).toMatchObject([
{
type: NodeTypes.DIRECTIVE,
name: `bind`,
arg: {
type: NodeTypes.SIMPLE_EXPRESSION,
isStatic: true,
content: `id`,
},
exp: {
type: NodeTypes.SIMPLE_EXPRESSION,
isStatic: false,
content: `foo`,
},
loc: {
start: {
line: 2,
column: 6,
},
end: {
line: 2,
column: 15,
},
},
},
])
expect(divWithoutPre.children[0]).toMatchObject({
type: NodeTypes.ELEMENT,
tagType: ElementTypes.COMPONENT,
tag: `Comp`,
})
expect(divWithoutPre.children[1]).toMatchObject({
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
content: `bar`,
isStatic: false,
},
})
})
test('end tags are case-insensitive.', () => {
const ast = baseParse('helloafter')
const element = ast.children[0] as ElementNode
const text = element.children[0] as TextNode
expect(text).toStrictEqual({
type: NodeTypes.TEXT,
content: 'hello',
loc: {
start: { offset: 5, line: 1, column: 6 },
end: { offset: 10, line: 1, column: 11 },
source: 'hello',
},
})
})
})
describe('Edge Cases', () => {
test('self closing single tag', () => {
const ast = baseParse('')
expect(ast.children).toHaveLength(1)
expect(ast.children[0]).toMatchObject({ tag: 'div' })
})
test('self closing multiple tag', () => {
const ast = baseParse(
`\n` +
``,
)
expect(ast).toMatchSnapshot()
expect(ast.children).toHaveLength(2)
expect(ast.children[0]).toMatchObject({ tag: 'div' })
expect(ast.children[1]).toMatchObject({ tag: 'p' })
})
test('valid html', () => {
const ast = baseParse(
`\n` +
` \n` +
` \n` +
``,
)
expect(ast).toMatchSnapshot()
expect(ast.children).toHaveLength(1)
const el = ast.children[0] as any
expect(el).toMatchObject({
tag: 'div',
})
expect(el.children).toHaveLength(2)
expect(el.children[0]).toMatchObject({
tag: 'p',
})
expect(el.children[1]).toMatchObject({
type: NodeTypes.COMMENT,
})
})
test('invalid html', () => {
expect(() => {
baseParse(`\n\n\n`)
}).toThrow('Element is missing end tag.')
const spy = vi.fn()
const ast = baseParse(`\n\n\n`, {
onError: spy,
})
expect(spy.mock.calls).toMatchObject([
[
{
code: ErrorCodes.X_MISSING_END_TAG,
loc: {
start: {
offset: 6,
line: 2,
column: 1,
},
},
},
],
[
{
code: ErrorCodes.X_INVALID_END_TAG,
loc: {
start: {
offset: 20,
line: 4,
column: 1,
},
},
},
],
])
expect(ast).toMatchSnapshot()
})
test('parse with correct location info', () => {
const fooSrc = `foo\n is `
const barSrc = `{{ bar }}`
const butSrc = ` but `
const bazSrc = `{{ baz }}`
const [foo, bar, but, baz] = baseParse(
fooSrc + barSrc + butSrc + bazSrc,
).children
let offset = 0
expect(foo.loc.start).toEqual({ line: 1, column: 1, offset })
offset += fooSrc.length
expect(foo.loc.end).toEqual({ line: 2, column: 5, offset })
expect(bar.loc.start).toEqual({ line: 2, column: 5, offset })
const barInner = (bar as InterpolationNode).content
offset += 3
expect(barInner.loc.start).toEqual({ line: 2, column: 8, offset })
offset += 3
expect(barInner.loc.end).toEqual({ line: 2, column: 11, offset })
offset += 3
expect(bar.loc.end).toEqual({ line: 2, column: 14, offset })
expect(but.loc.start).toEqual({ line: 2, column: 14, offset })
offset += butSrc.length
expect(but.loc.end).toEqual({ line: 2, column: 19, offset })
expect(baz.loc.start).toEqual({ line: 2, column: 19, offset })
const bazInner = (baz as InterpolationNode).content
offset += 3
expect(bazInner.loc.start).toEqual({ line: 2, column: 22, offset })
offset += 3
expect(bazInner.loc.end).toEqual({ line: 2, column: 25, offset })
offset += 3
expect(baz.loc.end).toEqual({ line: 2, column: 28, offset })
})
// With standard HTML parsing, the following input would ignore the slash
// and treat "<" and "template" as attributes on the open tag of "Hello",
// causing `` to fail to close, and ``,
{
onError: spy,
},
)
//
expect(ast.children.length).toBe(2)
expect(ast.children[1]).toMatchObject({
type: NodeTypes.ELEMENT,
tag: 'script',
})
})
test('arg should be undefined on shorthand dirs with no arg', () => {
const ast = baseParse(``)
const el = ast.children[0] as ElementNode
expect(el.props[0]).toMatchObject({
type: NodeTypes.DIRECTIVE,
name: 'slot',
exp: undefined,
arg: undefined,
})
})
// edge case found in vue-macros where the input is TS or JSX
test('should reset inRCDATA state', () => {
baseParse(``, { parseMode: 'sfc', onError() {} })
expect(() => baseParse(`{ foo }`)).not.toThrow()
})
test('correct loc when the closing > is foarmatted', () => {
const [span] = baseParse(``).children
expect(span.loc.source).toBe('')
expect(span.loc.start.offset).toBe(0)
expect(span.loc.end.offset).toBe(27)
})
test('correct loc when a line in attribute value ends with &', () => {
const [span] = baseParse(``).children
expect(span.loc.end.line).toBe(2)
})
})
describe('decodeEntities option', () => {
test('use decode by default', () => {
const ast: any = baseParse('><&'"&foo;')
expect(ast.children.length).toBe(1)
expect(ast.children[0].type).toBe(NodeTypes.TEXT)
expect(ast.children[0].content).toBe('><&\'"&foo;')
})
test('should warn in non-browser build', () => {
baseParse('&∪︀', {
decodeEntities: text => text.replace('∪︀', '\u222A\uFE00'),
onError: () => {}, // Ignore errors
})
expect(
`decodeEntities option is passed but will be ignored`,
).toHaveBeenWarned()
})
})
describe('whitespace management when adopting strategy condense', () => {
const parse = (content: string, options?: ParserOptions) =>
baseParse(content, {
whitespace: 'condense',
...options,
})
test('should remove whitespaces at start/end inside an element', () => {
const ast = parse(` `)
expect((ast.children[0] as ElementNode).children.length).toBe(1)
})
test('should remove whitespaces w/ newline between elements', () => {
const ast = parse(` \n \n `)
expect(ast.children.length).toBe(3)
expect(ast.children.every(c => c.type === NodeTypes.ELEMENT)).toBe(true)
})
test('should remove whitespaces adjacent to comments', () => {
const ast = parse(` \n `)
expect(ast.children.length).toBe(3)
expect(ast.children[0].type).toBe(NodeTypes.ELEMENT)
expect(ast.children[1].type).toBe(NodeTypes.COMMENT)
expect(ast.children[2].type).toBe(NodeTypes.ELEMENT)
})
test('should remove whitespaces w/ newline between comments and elements', () => {
const ast = parse(` \n \n `)
expect(ast.children.length).toBe(3)
expect(ast.children[0].type).toBe(NodeTypes.ELEMENT)
expect(ast.children[1].type).toBe(NodeTypes.COMMENT)
expect(ast.children[2].type).toBe(NodeTypes.ELEMENT)
})
test('should NOT remove whitespaces w/ newline between interpolations', () => {
const ast = parse(`{{ foo }} \n {{ bar }}`)
expect(ast.children.length).toBe(3)
expect(ast.children[0].type).toBe(NodeTypes.INTERPOLATION)
expect(ast.children[1]).toMatchObject({
type: NodeTypes.TEXT,
content: ' ',
})
expect(ast.children[2].type).toBe(NodeTypes.INTERPOLATION)
})
test('should NOT remove whitespaces w/ newline between interpolation and comment', () => {
const ast = parse(` \n {{msg}}`)
expect(ast.children.length).toBe(3)
expect(ast.children[0].type).toBe(NodeTypes.COMMENT)
expect(ast.children[1]).toMatchObject({
type: NodeTypes.TEXT,
content: ' ',
})
expect(ast.children[2].type).toBe(NodeTypes.INTERPOLATION)
})
test('should NOT remove whitespaces w/o newline between elements', () => {
const ast = parse(` `)
expect(ast.children.length).toBe(5)
expect(ast.children.map(c => c.type)).toMatchObject([
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
])
})
test('should condense consecutive whitespaces in text', () => {
const ast = parse(` foo \n bar baz `)
expect((ast.children[0] as TextNode).content).toBe(` foo bar baz `)
})
test('should remove leading newline character immediately following the pre element start tag', () => {
const ast = parse(`\n foo bar
`, {
isPreTag: tag => tag === 'pre',
isIgnoreNewlineTag: tag => tag === 'pre',
})
expect(ast.children).toHaveLength(1)
const preElement = ast.children[0] as ElementNode
expect(preElement.children).toHaveLength(1)
expect((preElement.children[0] as TextNode).content).toBe(` foo bar `)
})
test('should NOT remove leading newline character immediately following child-tag of pre element', () => {
const ast = parse(`\n foo bar
`, {
isPreTag: tag => tag === 'pre',
})
const preElement = ast.children[0] as ElementNode
expect(preElement.children).toHaveLength(2)
expect((preElement.children[1] as TextNode).content).toBe(
`\n foo bar `,
)
})
test('self-closing pre tag', () => {
const ast = parse(`\n foo bar`, {
isPreTag: tag => tag === 'pre',
})
const elementAfterPre = ast.children[1] as ElementNode
// should not affect the and condense its whitespace inside
expect((elementAfterPre.children[0] as TextNode).content).toBe(` foo bar`)
})
test('should NOT condense whitespaces in RCDATA text mode', () => {
const ast = parse(``, {
parseMode: 'html',
})
const preElement = ast.children[0] as ElementNode
expect(preElement.children).toHaveLength(1)
expect((preElement.children[0] as TextNode).content).toBe(`Text:\n foo`)
})
})
describe('whitespace management when adopting strategy preserve', () => {
const parse = (content: string, options?: ParserOptions) =>
baseParse(content, {
whitespace: 'preserve',
...options,
})
test('should still remove whitespaces at start/end inside an element', () => {
const ast = parse(` `)
expect((ast.children[0] as ElementNode).children.length).toBe(1)
})
test('should preserve whitespaces w/ newline between elements', () => {
const ast = parse(` \n \n `)
expect(ast.children.length).toBe(5)
expect(ast.children.map(c => c.type)).toMatchObject([
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
])
})
test('should preserve whitespaces adjacent to comments', () => {
const ast = parse(` \n `)
expect(ast.children.length).toBe(5)
expect(ast.children.map(c => c.type)).toMatchObject([
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.COMMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
])
})
test('should preserve whitespaces w/ newline between comments and elements', () => {
const ast = parse(` \n \n `)
expect(ast.children.length).toBe(5)
expect(ast.children.map(c => c.type)).toMatchObject([
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.COMMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
])
})
test('should preserve whitespaces w/ newline between interpolations', () => {
const ast = parse(`{{ foo }} \n {{ bar }}`)
expect(ast.children.length).toBe(3)
expect(ast.children[0].type).toBe(NodeTypes.INTERPOLATION)
expect(ast.children[1]).toMatchObject({
type: NodeTypes.TEXT,
content: ' ',
})
expect(ast.children[2].type).toBe(NodeTypes.INTERPOLATION)
})
test('should preserve whitespaces w/o newline between elements', () => {
const ast = parse(` `)
expect(ast.children.length).toBe(5)
expect(ast.children.map(c => c.type)).toMatchObject([
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
NodeTypes.TEXT,
NodeTypes.ELEMENT,
])
})
test('should preserve consecutive whitespaces in text', () => {
const content = ` foo \n bar baz `
const ast = parse(content)
expect((ast.children[0] as TextNode).content).toBe(content)
})
})
describe('expression parsing', () => {
test('interpolation', () => {
const ast = baseParse(`{{ a + b }}`, { prefixIdentifiers: true })
// @ts-expect-error
expect((ast.children[0] as InterpolationNode).content.ast?.type).toBe(
'BinaryExpression',
)
})
test('v-bind', () => {
const ast = baseParse(``, {
prefixIdentifiers: true,
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
// @ts-expect-error
expect(dir.arg?.ast?.type).toBe('BinaryExpression')
// @ts-expect-error
expect(dir.exp?.ast?.type).toBe('CallExpression')
})
test('v-on multi statements', () => {
const ast = baseParse(``, {
prefixIdentifiers: true,
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
// @ts-expect-error
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(` `, {
prefixIdentifiers: true,
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
// @ts-expect-error
expect(dir.exp?.ast?.type).toBe('ArrowFunctionExpression')
})
test('v-for', () => {
const ast = baseParse(``, {
prefixIdentifiers: true,
})
const dir = (ast.children[0] as ElementNode).props[0] as DirectiveNode
const { source, value, key, index } = dir.forParseResult!
// @ts-expect-error
expect(source.ast?.type).toBe('MemberExpression')
// @ts-expect-error
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
// We ignore some errors that do NOT affect parse result in meaningful ways
// but have non-trivial implementation cost.
const patterns: {
[key: string]: Array<{
code: string
errors: Array<{ type: ErrorCodes; loc: Position }>
options?: Partial
}>
} = {
// ABRUPT_CLOSING_OF_EMPTY_COMMENT: [
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.ABRUPT_CLOSING_OF_EMPTY_COMMENT,
// loc: { offset: 10, line: 1, column: 11 }
// }
// ]
// },
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.ABRUPT_CLOSING_OF_EMPTY_COMMENT,
// loc: { offset: 10, line: 1, column: 11 }
// }
// ]
// },
// {
// code: '',
// errors: []
// }
// ],
CDATA_IN_HTML_CONTENT: [
{
code: '',
errors: [
{
type: ErrorCodes.CDATA_IN_HTML_CONTENT,
loc: { offset: 10, line: 1, column: 11 },
},
],
},
{
code: '',
errors: [],
},
],
DUPLICATE_ATTRIBUTE: [
{
code: '',
errors: [
{
type: ErrorCodes.DUPLICATE_ATTRIBUTE,
loc: { offset: 21, line: 1, column: 22 },
},
],
},
],
// END_TAG_WITH_ATTRIBUTES: [
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.END_TAG_WITH_ATTRIBUTES,
// loc: { offset: 21, line: 1, column: 22 }
// }
// ]
// }
// ],
// END_TAG_WITH_TRAILING_SOLIDUS: [
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS,
// loc: { offset: 20, line: 1, column: 21 }
// }
// ]
// }
// ],
EOF_BEFORE_TAG_NAME: [
{
code: '<',
errors: [
{
type: ErrorCodes.EOF_BEFORE_TAG_NAME,
loc: { offset: 11, line: 1, column: 12 },
},
{
type: ErrorCodes.X_MISSING_END_TAG,
loc: { offset: 0, line: 1, column: 1 },
},
],
},
{
code: '',
errors: [
{
type: ErrorCodes.EOF_BEFORE_TAG_NAME,
loc: { offset: 12, line: 1, column: 13 },
},
{
type: ErrorCodes.X_MISSING_END_TAG,
loc: { offset: 0, line: 1, column: 1 },
},
],
},
],
EOF_IN_CDATA: [
{
code: '',
// errors: [
// {
// type: ErrorCodes.INCORRECTLY_CLOSED_COMMENT,
// loc: { offset: 10, line: 1, column: 11 }
// }
// ]
// }
// ],
// INCORRECTLY_OPENED_COMMENT: [
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.INCORRECTLY_OPENED_COMMENT,
// loc: { offset: 10, line: 1, column: 11 }
// }
// ]
// },
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.INCORRECTLY_OPENED_COMMENT,
// loc: { offset: 10, line: 1, column: 11 }
// }
// ]
// },
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.INCORRECTLY_OPENED_COMMENT,
// loc: { offset: 10, line: 1, column: 11 }
// }
// ]
// },
// // Just ignore doctype.
// {
// code: '',
// errors: []
// }
// ],
// INVALID_FIRST_CHARACTER_OF_TAG_NAME: [
// {
// code: 'a < b',
// errors: [
// {
// type: ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
// loc: { offset: 13, line: 1, column: 14 }
// }
// ]
// },
// {
// code: '<�>',
// errors: [
// {
// type: ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
// loc: { offset: 11, line: 1, column: 12 }
// }
// ]
// },
// {
// code: 'a b',
// errors: [
// {
// type: ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
// loc: { offset: 14, line: 1, column: 15 }
// },
// {
// type: ErrorCodes.X_MISSING_END_TAG,
// loc: { offset: 0, line: 1, column: 1 }
// }
// ]
// },
// {
// code: '�>',
// errors: [
// {
// type: ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
// loc: { offset: 12, line: 1, column: 13 }
// }
// ]
// },
// // Don't throw invalid-first-character-of-tag-name in interpolation
// {
// code: '{{a < b}}',
// errors: []
// }
// ],
MISSING_ATTRIBUTE_VALUE: [
{
code: '',
errors: [
{
type: ErrorCodes.MISSING_ATTRIBUTE_VALUE,
loc: { offset: 18, line: 1, column: 19 },
},
],
},
{
code: '',
errors: [
{
type: ErrorCodes.MISSING_ATTRIBUTE_VALUE,
loc: { offset: 19, line: 1, column: 20 },
},
],
},
{
code: '',
errors: [],
},
],
MISSING_END_TAG_NAME: [
{
code: '>',
errors: [
{
type: ErrorCodes.MISSING_END_TAG_NAME,
loc: { offset: 12, line: 1, column: 13 },
},
],
},
],
// MISSING_WHITESPACE_BETWEEN_ATTRIBUTES: [
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.MISSING_WHITESPACE_BETWEEN_ATTRIBUTES,
// loc: { offset: 23, line: 1, column: 24 }
// }
// ]
// },
// // CR doesn't appear in tokenization phase, but all CR are removed in preprocessing.
// // https://html.spec.whatwg.org/multipage/parsing.html#preprocessing-the-input-stream
// {
// code: '',
// errors: []
// }
// ],
// NESTED_COMMENT: [
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.NESTED_COMMENT,
// loc: { offset: 15, line: 1, column: 16 }
// }
// ]
// },
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.NESTED_COMMENT,
// loc: { offset: 15, line: 1, column: 16 }
// },
// {
// type: ErrorCodes.NESTED_COMMENT,
// loc: { offset: 20, line: 1, column: 21 }
// }
// ]
// },
// {
// code: '',
// errors: [
// {
// type: ErrorCodes.NESTED_COMMENT,
// loc: { offset: 15, line: 1, column: 16 }
// }
// ]
// },
// {
// code: '',
// errors: []
// },
// {
// code: '',
errors: [],
},
],
X_MISSING_END_TAG: [
{
code: '',
errors: [
{
type: ErrorCodes.X_MISSING_END_TAG,
loc: { offset: 10, line: 1, column: 11 },
},
],
},
{
code: '',
errors: [
{
type: ErrorCodes.X_MISSING_END_TAG,
loc: { offset: 10, line: 1, column: 11 },
},
{
type: ErrorCodes.X_MISSING_END_TAG,
loc: { offset: 0, line: 1, column: 1 },
},
],
},
],
X_MISSING_INTERPOLATION_END: [
{
code: '{{ foo',
errors: [
{
type: ErrorCodes.X_MISSING_INTERPOLATION_END,
loc: { offset: 0, line: 1, column: 1 },
},
],
},
{
code: '{{',
errors: [
{
type: ErrorCodes.X_MISSING_INTERPOLATION_END,
loc: { offset: 0, line: 1, column: 1 },
},
],
},
{
code: '{{ foo',
errors: [
{
type: ErrorCodes.X_MISSING_INTERPOLATION_END,
loc: { offset: 5, line: 1, column: 6 },
},
{
type: ErrorCodes.X_MISSING_END_TAG,
loc: { offset: 0, line: 1, column: 1 },
},
],
},
{
code: '{{}}',
errors: [],
},
],
X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END: [
{
code: ``,
errors: [
{
type: ErrorCodes.X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END,
loc: { offset: 15, line: 1, column: 16 },
},
],
},
],
}
for (const key of Object.keys(patterns)) {
describe(key, () => {
for (const { code, errors, options } of patterns[key]) {
test(
code.replace(
/[\r\n]/g,
c => `\\x0${c.codePointAt(0)!.toString(16)};`,
),
() => {
const spy = vi.fn()
const ast = baseParse(code, {
parseMode: 'html',
getNamespace: tag =>
tag === 'svg' ? Namespaces.SVG : Namespaces.HTML,
...options,
onError: spy,
})
expect(
spy.mock.calls.map(([err]) => ({
type: err.code,
loc: err.loc.start,
})),
).toMatchObject(errors)
expect(ast).toMatchSnapshot()
},
)
}
})
}
})
})