Skip to content
This repository was archived by the owner on Jul 19, 2025. It is now read-only.

Commit a084df1

Browse files
authored
dx(compiler-dom): warn on invalid html nesting (#10734)
1 parent 5f0c6e4 commit a084df1

File tree

4 files changed

+244
-1
lines changed

4 files changed

+244
-1
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { type CompilerError, compile } from '../../src'
2+
3+
describe('validate html nesting', () => {
4+
it('should warn with p > div', () => {
5+
let err: CompilerError | undefined
6+
compile(`<p><div></div></p>`, {
7+
onWarn: e => (err = e),
8+
})
9+
expect(err).toBeDefined()
10+
expect(err!.message).toMatch(`<div> cannot be child of <p>`)
11+
})
12+
13+
it('should not warn with select > hr', () => {
14+
let err: CompilerError | undefined
15+
compile(`<select><hr></select>`, {
16+
onWarn: e => (err = e),
17+
})
18+
expect(err).toBeUndefined()
19+
})
20+
})
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Copied from https://github.com/MananTank/validate-html-nesting
3+
* with ISC license
4+
*
5+
* To avoid runtime dependency on validate-html-nesting
6+
* This file should not change very often in the original repo
7+
* but we may need to keep it up-to-date from time to time.
8+
*/
9+
10+
/**
11+
* returns true if given parent-child nesting is valid HTML
12+
*/
13+
export function isValidHTMLNesting(parent: string, child: string): boolean {
14+
// if we know the list of children that are the only valid children for the given parent
15+
if (parent in onlyValidChildren) {
16+
return onlyValidChildren[parent].has(child)
17+
}
18+
19+
// if we know the list of parents that are the only valid parents for the given child
20+
if (child in onlyValidParents) {
21+
return onlyValidParents[child].has(parent)
22+
}
23+
24+
// if we know the list of children that are NOT valid for the given parent
25+
if (parent in knownInvalidChildren) {
26+
// check if the child is in the list of invalid children
27+
// if so, return false
28+
if (knownInvalidChildren[parent].has(child)) return false
29+
}
30+
31+
// if we know the list of parents that are NOT valid for the given child
32+
if (child in knownInvalidParents) {
33+
// check if the parent is in the list of invalid parents
34+
// if so, return false
35+
if (knownInvalidParents[child].has(parent)) return false
36+
}
37+
38+
return true
39+
}
40+
41+
const headings = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
42+
const emptySet = new Set([])
43+
44+
/**
45+
* maps element to set of elements that can be it's children, no other */
46+
const onlyValidChildren: Record<string, Set<string>> = {
47+
head: new Set([
48+
'base',
49+
'basefront',
50+
'bgsound',
51+
'link',
52+
'meta',
53+
'title',
54+
'noscript',
55+
'noframes',
56+
'style',
57+
'script',
58+
'template',
59+
]),
60+
optgroup: new Set(['option']),
61+
select: new Set(['optgroup', 'option', 'hr']),
62+
// table
63+
table: new Set(['caption', 'colgroup', 'tbody', 'tfoot', 'thead']),
64+
tr: new Set(['td', 'th']),
65+
colgroup: new Set(['col']),
66+
tbody: new Set(['tr']),
67+
thead: new Set(['tr']),
68+
tfoot: new Set(['tr']),
69+
// these elements can not have any children elements
70+
script: emptySet,
71+
iframe: emptySet,
72+
option: emptySet,
73+
textarea: emptySet,
74+
style: emptySet,
75+
title: emptySet,
76+
}
77+
78+
/** maps elements to set of elements which can be it's parent, no other */
79+
const onlyValidParents: Record<string, Set<string>> = {
80+
// sections
81+
html: emptySet,
82+
body: new Set(['html']),
83+
head: new Set(['html']),
84+
// table
85+
td: new Set(['tr']),
86+
colgroup: new Set(['table']),
87+
caption: new Set(['table']),
88+
tbody: new Set(['table']),
89+
tfoot: new Set(['table']),
90+
col: new Set(['colgroup']),
91+
th: new Set(['tr']),
92+
thead: new Set(['table']),
93+
tr: new Set(['tbody', 'thead', 'tfoot']),
94+
// data list
95+
dd: new Set(['dl', 'div']),
96+
dt: new Set(['dl', 'div']),
97+
// other
98+
figcaption: new Set(['figure']),
99+
// li: new Set(["ul", "ol"]),
100+
summary: new Set(['details']),
101+
area: new Set(['map']),
102+
} as const
103+
104+
/** maps element to set of elements that can not be it's children, others can */
105+
const knownInvalidChildren: Record<string, Set<string>> = {
106+
p: new Set([
107+
'address',
108+
'article',
109+
'aside',
110+
'blockquote',
111+
'center',
112+
'details',
113+
'dialog',
114+
'dir',
115+
'div',
116+
'dl',
117+
'fieldset',
118+
'figure',
119+
'footer',
120+
'form',
121+
'h1',
122+
'h2',
123+
'h3',
124+
'h4',
125+
'h5',
126+
'h6',
127+
'header',
128+
'hgroup',
129+
'hr',
130+
'li',
131+
'main',
132+
'nav',
133+
'menu',
134+
'ol',
135+
'p',
136+
'pre',
137+
'section',
138+
'table',
139+
'ul',
140+
]),
141+
svg: new Set([
142+
'b',
143+
'blockquote',
144+
'br',
145+
'code',
146+
'dd',
147+
'div',
148+
'dl',
149+
'dt',
150+
'em',
151+
'embed',
152+
'h1',
153+
'h2',
154+
'h3',
155+
'h4',
156+
'h5',
157+
'h6',
158+
'hr',
159+
'i',
160+
'img',
161+
'li',
162+
'menu',
163+
'meta',
164+
'ol',
165+
'p',
166+
'pre',
167+
'ruby',
168+
's',
169+
'small',
170+
'span',
171+
'strong',
172+
'sub',
173+
'sup',
174+
'table',
175+
'u',
176+
'ul',
177+
'var',
178+
]),
179+
} as const
180+
181+
/** maps element to set of elements that can not be it's parent, others can */
182+
const knownInvalidParents: Record<string, Set<string>> = {
183+
a: new Set(['a']),
184+
button: new Set(['button']),
185+
dd: new Set(['dd', 'dt']),
186+
dt: new Set(['dd', 'dt']),
187+
form: new Set(['form']),
188+
li: new Set(['li']),
189+
h1: headings,
190+
h2: headings,
191+
h3: headings,
192+
h4: headings,
193+
h5: headings,
194+
h6: headings,
195+
}

packages/compiler-dom/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import { transformShow } from './transforms/vShow'
1919
import { transformTransition } from './transforms/Transition'
2020
import { stringifyStatic } from './transforms/stringifyStatic'
2121
import { ignoreSideEffectTags } from './transforms/ignoreSideEffectTags'
22+
import { validateHtmlNesting } from './transforms/validateHtmlNesting'
2223
import { extend } from '@vue/shared'
2324

2425
export { parserOptions }
2526

2627
export const DOMNodeTransforms: NodeTransform[] = [
2728
transformStyle,
28-
...(__DEV__ ? [transformTransition] : []),
29+
...(__DEV__ ? [transformTransition, validateHtmlNesting] : []),
2930
]
3031

3132
export const DOMDirectiveTransforms: Record<string, DirectiveTransform> = {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
type CompilerError,
3+
ElementTypes,
4+
type NodeTransform,
5+
NodeTypes,
6+
} from '@vue/compiler-core'
7+
import { isValidHTMLNesting } from '../htmlNesting'
8+
9+
export const validateHtmlNesting: NodeTransform = (node, context) => {
10+
if (
11+
node.type === NodeTypes.ELEMENT &&
12+
node.tagType === ElementTypes.ELEMENT &&
13+
context.parent &&
14+
context.parent.type === NodeTypes.ELEMENT &&
15+
context.parent.tagType === ElementTypes.ELEMENT &&
16+
!isValidHTMLNesting(context.parent.tag, node.tag)
17+
) {
18+
const error = new SyntaxError(
19+
`<${node.tag}> cannot be child of <${context.parent.tag}>, ` +
20+
'according to HTML specifications. ' +
21+
'This can cause hydration errors or ' +
22+
'potentially disrupt future functionality.',
23+
) as CompilerError
24+
error.loc = node.loc
25+
context.onWarn(error)
26+
}
27+
}

0 commit comments

Comments
 (0)