mirror of https://github.com/sveltejs/svelte
chore: tweak html tree validation (#12618)
* chore: tweak html tree validation - relax validation in some places where we know the HTML will not break or only break when using SSR - consolidate validation in one place and for better reuse, which results in more cases getting caught at runtime closes #11941 * move more of the validation into more descriptive record * obselete / incorrect (those are not autoclosed, and the invalid ones handled later) * typo * backticks * update tests * Update packages/svelte/messages/compile-errors/template.md Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * Update packages/svelte/messages/compile-warnings/template.md Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --------- Co-authored-by: Rich Harris <rich.harris@vercel.com>pull/12640/head
parent
2e8a205161
commit
3bff87ac66
@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Map of elements that have certain elements that are not allowed inside them, in the sense that they will auto-close the parent/ancestor element.
|
||||
* Theoretically one could take advantage of it but most of the time it will just result in confusing behavior and break when SSR'd.
|
||||
* There are more elements that are invalid inside other elements, but they're not auto-closed and so don't break SSR and are therefore not listed here.
|
||||
* @type {Record<string, { direct: string[]} | { descendant: string[] }>}
|
||||
*/
|
||||
const autoclosing_children = {
|
||||
// based on http://developers.whatwg.org/syntax.html#syntax-tag-omission
|
||||
li: { direct: ['li'] },
|
||||
dt: { descendant: ['dt', 'dd'] },
|
||||
dd: { descendant: ['dt', 'dd'] },
|
||||
p: {
|
||||
descendant: [
|
||||
'address',
|
||||
'article',
|
||||
'aside',
|
||||
'blockquote',
|
||||
'div',
|
||||
'dl',
|
||||
'fieldset',
|
||||
'footer',
|
||||
'form',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'header',
|
||||
'hgroup',
|
||||
'hr',
|
||||
'main',
|
||||
'menu',
|
||||
'nav',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'section',
|
||||
'table',
|
||||
'ul'
|
||||
]
|
||||
},
|
||||
rt: { descendant: ['rt', 'rp'] },
|
||||
rp: { descendant: ['rt', 'rp'] },
|
||||
optgroup: { descendant: ['optgroup'] },
|
||||
option: { descendant: ['option', 'optgroup'] },
|
||||
thead: { direct: ['tbody', 'tfoot'] },
|
||||
tbody: { direct: ['tbody', 'tfoot'] },
|
||||
tfoot: { direct: ['tbody'] },
|
||||
tr: { direct: ['tr', 'tbody'] },
|
||||
td: { direct: ['td', 'th', 'tr'] },
|
||||
th: { direct: ['td', 'th', 'tr'] }
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the tag is either the last in the list of siblings and will be autoclosed,
|
||||
* or not allowed inside the parent tag such that it will auto-close it. The latter results
|
||||
* in the browser repairing the HTML, which will likely result in an error during hydration.
|
||||
* @param {string} current
|
||||
* @param {string} [next]
|
||||
*/
|
||||
export function closing_tag_omitted(current, next) {
|
||||
const disallowed = autoclosing_children[current];
|
||||
if (disallowed) {
|
||||
if (
|
||||
!next ||
|
||||
('direct' in disallowed ? disallowed.direct : disallowed.descendant).includes(next)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of elements that have certain elements that are not allowed inside them, in the sense that the browser will somehow repair the HTML.
|
||||
* There are more elements that are invalid inside other elements, but they're not repaired and so don't break SSR and are therefore not listed here.
|
||||
* @type {Record<string, { direct: string[]} | { descendant: string[]; only?: string[] } | { only: string[] }>}
|
||||
*/
|
||||
const disallowed_children = {
|
||||
...autoclosing_children,
|
||||
optgroup: { only: ['option', '#text'] },
|
||||
// Strictly speaking, seeing an <option> doesn't mean we're in a <select>, but we assume it here
|
||||
option: { only: ['#text'] },
|
||||
form: { descendant: ['form'] },
|
||||
a: { descendant: ['a'] },
|
||||
button: { descendant: ['button'] },
|
||||
h1: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
|
||||
h2: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
|
||||
h3: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
|
||||
h4: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
|
||||
h5: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
|
||||
h6: { descendant: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] },
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inselect
|
||||
select: { only: ['option', 'optgroup', '#text', 'hr', 'script', 'template'] },
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incaption
|
||||
// No special behavior since these rules fall back to "in body" mode for
|
||||
// all except special table nodes which cause bad parsing behavior anyway.
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intd
|
||||
tr: { only: ['th', 'td', 'style', 'script', 'template'] },
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intbody
|
||||
tbody: { only: ['tr', 'style', 'script', 'template'] },
|
||||
thead: { only: ['tr', 'style', 'script', 'template'] },
|
||||
tfoot: { only: ['tr', 'style', 'script', 'template'] },
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-incolgroup
|
||||
colgroup: { only: ['col', 'template'] },
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-intable
|
||||
table: {
|
||||
only: ['caption', 'colgroup', 'tbody', 'thead', 'tfoot', 'style', 'script', 'template']
|
||||
},
|
||||
// https://html.spec.whatwg.org/multipage/syntax.html#parsing-main-inhead
|
||||
head: {
|
||||
only: [
|
||||
'base',
|
||||
'basefont',
|
||||
'bgsound',
|
||||
'link',
|
||||
'meta',
|
||||
'title',
|
||||
'noscript',
|
||||
'noframes',
|
||||
'style',
|
||||
'script',
|
||||
'template'
|
||||
]
|
||||
},
|
||||
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
|
||||
html: { only: ['head', 'body', 'frameset'] },
|
||||
frameset: { only: ['frame'] },
|
||||
'#document': { only: ['html'] }
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns false if the tag is not allowed inside the ancestor tag (which is grandparent and above) such that it will result
|
||||
* in the browser repairing the HTML, which will likely result in an error during hydration.
|
||||
* @param {string} tag
|
||||
* @param {string} ancestor Must not be the parent, but higher up the tree
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function is_tag_valid_with_ancestor(tag, ancestor) {
|
||||
const disallowed = disallowed_children[ancestor];
|
||||
return !disallowed || ('descendant' in disallowed ? !disallowed.descendant.includes(tag) : true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns false if the tag is not allowed inside the parent tag such that it will result
|
||||
* in the browser repairing the HTML, which will likely result in an error during hydration.
|
||||
* @param {string} tag
|
||||
* @param {string} parent_tag
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function is_tag_valid_with_parent(tag, parent_tag) {
|
||||
const disallowed = disallowed_children[parent_tag];
|
||||
|
||||
if (disallowed) {
|
||||
if ('direct' in disallowed && disallowed.direct.includes(tag)) {
|
||||
return false;
|
||||
}
|
||||
if ('descendant' in disallowed && disallowed.descendant.includes(tag)) {
|
||||
return false;
|
||||
}
|
||||
if ('only' in disallowed && disallowed.only) {
|
||||
return disallowed.only.includes(tag);
|
||||
}
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case 'body':
|
||||
case 'caption':
|
||||
case 'col':
|
||||
case 'colgroup':
|
||||
case 'frameset':
|
||||
case 'frame':
|
||||
case 'head':
|
||||
case 'html':
|
||||
case 'tbody':
|
||||
case 'td':
|
||||
case 'tfoot':
|
||||
case 'th':
|
||||
case 'thead':
|
||||
case 'tr':
|
||||
// These tags are only valid with a few parents that have special child
|
||||
// parsing rules - if we're down here, then none of those matched and
|
||||
// so we allow it only if we don't know what the parent is, as all other
|
||||
// cases are invalid (and we only get into this function if we know the parent).
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
@ -0,0 +1 @@
|
||||
<form></form>
|
@ -1,7 +1,11 @@
|
||||
<script>
|
||||
import Component from "./Component.svelte";
|
||||
import Form from './form.svelte';
|
||||
import H1 from './h1.svelte';
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<Component />
|
||||
<H1 />
|
||||
</p>
|
||||
<form>
|
||||
<Form />
|
||||
</form>
|
||||
|
@ -0,0 +1,9 @@
|
||||
<div>
|
||||
<form>
|
||||
{#if foo}
|
||||
<form>
|
||||
<input />
|
||||
</form>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
@ -0,0 +1,14 @@
|
||||
[
|
||||
{
|
||||
"code": "node_invalid_placement_ssr",
|
||||
"message": "`<form>` is invalid inside `<form>`. When rendering this component on the server, the resulting HTML will be modified by the browser, likely resulting in a `hydration_mismatch` warning",
|
||||
"start": {
|
||||
"line": 4,
|
||||
"column": 3
|
||||
},
|
||||
"end": {
|
||||
"line": 6,
|
||||
"column": 10
|
||||
}
|
||||
}
|
||||
]
|
Loading…
Reference in new issue