Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dx(compiler-dom): warn on invalid html nesting #10734

Merged
merged 3 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type CompilerError, compile } from '../../src'

describe('validate html nesting', () => {
it('should warn with p > div', () => {
let err: CompilerError | undefined
compile(`<p><div></div></p>`, {
onWarn: e => (err = e),
})
expect(err).toBeDefined()
expect(err!.message).toMatch(`<div> cannot be child of <p>`)
})

it('should not warn with select > hr', () => {
let err: CompilerError | undefined
compile(`<select><hr></select>`, {
onWarn: e => (err = e),
})
expect(err).toBeUndefined()
})
})
195 changes: 195 additions & 0 deletions packages/compiler-dom/src/htmlNesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Copied from https://github.com/MananTank/validate-html-nesting
* with ISC license
*
* To avoid runtime dependency on validate-html-nesting
* This file should not change very often in the original repo
* but we may need to keep it up-to-date from time to time.
*/

/**
* returns true if given parent-child nesting is valid HTML
*/
export function isValidHTMLNesting(parent: string, child: string): boolean {
// if we know the list of children that are the only valid children for the given parent
if (parent in onlyValidChildren) {
return onlyValidChildren[parent].has(child)
}

// if we know the list of parents that are the only valid parents for the given child
if (child in onlyValidParents) {
return onlyValidParents[child].has(parent)
}

// if we know the list of children that are NOT valid for the given parent
if (parent in knownInvalidChildren) {
// check if the child is in the list of invalid children
// if so, return false
if (knownInvalidChildren[parent].has(child)) return false
}

// if we know the list of parents that are NOT valid for the given child
if (child in knownInvalidParents) {
// check if the parent is in the list of invalid parents
// if so, return false
if (knownInvalidParents[child].has(parent)) return false
}

return true
}

const headings = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
const emptySet = new Set([])

/**
* maps element to set of elements that can be it's children, no other */
const onlyValidChildren: Record<string, Set<string>> = {
head: new Set([
'base',
'basefront',
'bgsound',
'link',
'meta',
'title',
'noscript',
'noframes',
'style',
'script',
'template',
]),
optgroup: new Set(['option']),
select: new Set(['optgroup', 'option', 'hr']),
// table
table: new Set(['caption', 'colgroup', 'tbody', 'tfoot', 'thead']),
tr: new Set(['td', 'th']),
colgroup: new Set(['col']),
tbody: new Set(['tr']),
thead: new Set(['tr']),
tfoot: new Set(['tr']),
// these elements can not have any children elements
script: emptySet,
iframe: emptySet,
option: emptySet,
textarea: emptySet,
style: emptySet,
title: emptySet,
}

/** maps elements to set of elements which can be it's parent, no other */
const onlyValidParents: Record<string, Set<string>> = {
// sections
html: emptySet,
body: new Set(['html']),
head: new Set(['html']),
// table
td: new Set(['tr']),
colgroup: new Set(['table']),
caption: new Set(['table']),
tbody: new Set(['table']),
tfoot: new Set(['table']),
col: new Set(['colgroup']),
th: new Set(['tr']),
thead: new Set(['table']),
tr: new Set(['tbody', 'thead', 'tfoot']),
// data list
dd: new Set(['dl', 'div']),
dt: new Set(['dl', 'div']),
// other
figcaption: new Set(['figure']),
// li: new Set(["ul", "ol"]),
summary: new Set(['details']),
area: new Set(['map']),
} as const

/** maps element to set of elements that can not be it's children, others can */
const knownInvalidChildren: Record<string, Set<string>> = {
p: new Set([
'address',
'article',
'aside',
'blockquote',
'center',
'details',
'dialog',
'dir',
'div',
'dl',
'fieldset',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'li',
'main',
'nav',
'menu',
'ol',
'p',
'pre',
'section',
'table',
'ul',
]),
svg: new Set([
'b',
'blockquote',
'br',
'code',
'dd',
'div',
'dl',
'dt',
'em',
'embed',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'menu',
'meta',
'ol',
'p',
'pre',
'ruby',
's',
'small',
'span',
'strong',
'sub',
'sup',
'table',
'u',
'ul',
'var',
]),
} as const

/** maps element to set of elements that can not be it's parent, others can */
const knownInvalidParents: Record<string, Set<string>> = {
a: new Set(['a']),
button: new Set(['button']),
dd: new Set(['dd', 'dt']),
dt: new Set(['dd', 'dt']),
form: new Set(['form']),
li: new Set(['li']),
h1: headings,
h2: headings,
h3: headings,
h4: headings,
h5: headings,
h6: headings,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation seems a bit oversimplified which will miss mismatches like p > a > div.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, but there's currently no way to retrieve ancestor nodes.

parent: ParentNode | null
// we could use a stack but in practice we've only ever needed two layers up
// so this is more efficient
grandParent: ParentNode | null

3 changes: 2 additions & 1 deletion packages/compiler-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ import { transformShow } from './transforms/vShow'
import { transformTransition } from './transforms/Transition'
import { stringifyStatic } from './transforms/stringifyStatic'
import { ignoreSideEffectTags } from './transforms/ignoreSideEffectTags'
import { validateHtmlNesting } from './transforms/validateHtmlNesting'
import { extend } from '@vue/shared'

export { parserOptions }

export const DOMNodeTransforms: NodeTransform[] = [
transformStyle,
...(__DEV__ ? [transformTransition] : []),
...(__DEV__ ? [transformTransition, validateHtmlNesting] : []),
]

export const DOMDirectiveTransforms: Record<string, DirectiveTransform> = {
Expand Down
27 changes: 27 additions & 0 deletions packages/compiler-dom/src/transforms/validateHtmlNesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
type CompilerError,
ElementTypes,
type NodeTransform,
NodeTypes,
} from '@vue/compiler-core'
import { isValidHTMLNesting } from '../htmlNesting'

export const validateHtmlNesting: NodeTransform = (node, context) => {
if (
node.type === NodeTypes.ELEMENT &&
node.tagType === ElementTypes.ELEMENT &&
context.parent &&
context.parent.type === NodeTypes.ELEMENT &&
context.parent.tagType === ElementTypes.ELEMENT &&
!isValidHTMLNesting(context.parent.tag, node.tag)
) {
const error = new SyntaxError(
`<${node.tag}> cannot be child of <${context.parent.tag}>, ` +
'according to HTML specifications. ' +
'This can cause hydration errors or ' +
'potentially disrupt future functionality.',
) as CompilerError
error.loc = node.loc
context.onWarn(error)
}
}