Repository: sveltejs/svelte (JavaScript) — Bug Fix

Issue:
Implicitly closing tags aren't allowed before closing block
Svelte supports implicitly closing tags like `<li>first <li>second`, but it won't allow them to be closed by a mustache closing block: https://svelte.dev/repl/733548506a2b44819e39157bd6a055d3.

I don't think this is intuitive and would expect svelte to either disallow it everywhere (even though it's valid html) or relax the mustache closing block rules (what I'd personally prefer).


Discussion:

Resolving patch:
diff --git a/src/compiler/parse/state/mustache.ts b/src/compiler/parse/state/mustache.ts
--- a/src/compiler/parse/state/mustache.ts
+++ b/src/compiler/parse/state/mustache.ts
@@ -1,5 +1,6 @@
 import read_context from '../read/context';
 import read_expression from '../read/expression';
+import { closing_tag_omitted } from '../utils/html';
 import { whitespace } from '../../utils/patterns';
 import { trim_start, trim_end } from '../../utils/trim';
 import { Parser } from '../index';
@@ -41,6 +42,12 @@ export default function mustache(parser: Parser) {
 		let block = parser.current();
 		let expected;
 
+		if (closing_tag_omitted(block.name)) {
+			block.end = start;
+			parser.stack.pop();
+			block = parser.current();
+		}
+
 		if (block.type === 'ElseBlock' || block.type === 'PendingBlock' || block.type === 'ThenBlock' || block.type === 'CatchBlock') {
 			block.end = start;
 			parser.stack.pop();
diff --git a/src/compiler/parse/state/tag.ts b/src/compiler/parse/state/tag.ts
--- a/src/compiler/parse/state/tag.ts
+++ b/src/compiler/parse/state/tag.ts
@@ -1,7 +1,7 @@
 import read_expression from '../read/expression';
 import read_script from '../read/script';
 import read_style from '../read/style';
-import { decode_character_references } from '../utils/html';
+import { decode_character_references, closing_tag_omitted } from '../utils/html';
 import { is_void } from '../../utils/names';
 import { Parser } from '../index';
 import { Directive, DirectiveType, Node, Text } from '../../interfaces';
@@ -42,31 +42,6 @@ const SELF = /^svelte:self(?=[\s\/>])/;
 // eslint-disable-next-line no-useless-escape
 const COMPONENT = /^svelte:component(?=[\s\/>])/;
 
-// based on http://developers.whatwg.org/syntax.html#syntax-tag-omission
-const disallowed_contents = new Map([
-	['li', new Set(['li'])],
-	['dt', new Set(['dt', 'dd'])],
-	['dd', new Set(['dt', 'dd'])],
-	[
-		'p',
-		new Set(
-			'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'.split(
-				' '
-			)
-		),
-	],
-	['rt', new Set(['rt', 'rp'])],
-	['rp', new Set(['rt', 'rp'])],
-	['optgroup', new Set(['optgroup'])],
-	['option', new Set(['option', 'optgroup'])],
-	['thead', new Set(['tbody', 'tfoot'])],
-	['tbody', new Set(['tbody', 'tfoot'])],
-	['tfoot', new Set(['tbody'])],
-	['tr', new Set(['tr', 'tbody'])],
-	['td', new Set(['td', 'th', 'tr'])],
-	['th', new Set(['td', 'th', 'tr'])],
-]);
-
 function parent_is_head(stack) {
 	let i = stack.length;
 	while (i--) {
@@ -176,13 +151,9 @@ export default function tag(parser: Parser) {
 		parser.stack.pop();
 
 		return;
-	} else if (disallowed_contents.has(parent.name)) {
-		// can this be a child of the parent element, or does it implicitly
-		// close it, like `<li>one<li>two`?
-		if (disallowed_contents.get(parent.name).has(name)) {
-			parent.end = start;
-			parser.stack.pop();
-		}
+	} else if (closing_tag_omitted(parent.name, name)) {
+		parent.end = start;
+		parser.stack.pop();
 	}
 
 	const unique_names: Set<string> = new Set();
diff --git a/src/compiler/parse/utils/html.ts b/src/compiler/parse/utils/html.ts
--- a/src/compiler/parse/utils/html.ts
+++ b/src/compiler/parse/utils/html.ts
@@ -112,3 +112,40 @@ function validate_code(code: number) {
 
 	return NUL;
 }
+
+// based on http://developers.whatwg.org/syntax.html#syntax-tag-omission
+const disallowed_contents = new Map([
+	['li', new Set(['li'])],
+	['dt', new Set(['dt', 'dd'])],
+	['dd', new Set(['dt', 'dd'])],
+	[
+		'p',
+		new Set(
+			'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'.split(
+				' '
+			)
+		),
+	],
+	['rt', new Set(['rt', 'rp'])],
+	['rp', new Set(['rt', 'rp'])],
+	['optgroup', new Set(['optgroup'])],
+	['option', new Set(['option', 'optgroup'])],
+	['thead', new Set(['tbody', 'tfoot'])],
+	['tbody', new Set(['tbody', 'tfoot'])],
+	['tfoot', new Set(['tbody'])],
+	['tr', new Set(['tr', 'tbody'])],
+	['td', new Set(['td', 'th', 'tr'])],
+	['th', new Set(['td', 'th', 'tr'])],
+]);
+
+// can this be a child of the parent element, or does it implicitly
+// close it, like `<li>one<li>two`?
+export function closing_tag_omitted(current: string, next?: string) {
+	if (disallowed_contents.has(current)) {
+		if (!next || disallowed_contents.get(current).has(next)) {
+			return true;
+		}
+	}
+
+	return false;
+}


Test patch:
diff --git a/test/parser/samples/implicitly-closed-li-block/input.svelte b/test/parser/samples/implicitly-closed-li-block/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/parser/samples/implicitly-closed-li-block/input.svelte
@@ -0,0 +1,7 @@
+<ul>
+	<li>a
+	{#if true}
+		<li>b
+	{/if}
+	<li>c
+</ul>
diff --git a/test/parser/samples/implicitly-closed-li-block/output.json b/test/parser/samples/implicitly-closed-li-block/output.json
new file mode 100644
--- /dev/null
+++ b/test/parser/samples/implicitly-closed-li-block/output.json
@@ -0,0 +1,94 @@
+{
+	"html": {
+		"start": 0,
+		"end": 51,
+		"type": "Fragment",
+		"children": [
+			{
+				"start": 0,
+				"end": 51,
+				"type": "Element",
+				"name": "ul",
+				"attributes": [],
+				"children": [
+					{
+						"start": 4,
+						"end": 6,
+						"type": "Text",
+						"raw": "\n\t",
+						"data": "\n\t"
+					},
+					{
+						"start": 6,
+						"end": 40,
+						"type": "Element",
+						"name": "li",
+						"attributes": [],
+						"children": [
+							{
+								"start": 10,
+								"end": 13,
+								"type": "Text",
+								"raw": "a\n\t",
+								"data": "a\n\t"
+							},
+							{
+								"start": 13,
+								"end": 38,
+								"type": "IfBlock",
+								"expression": {
+									"type": "Literal",
+									"start": 18,
+									"end": 22,
+									"value": true,
+									"raw": "true"
+								},
+								"children": [
+									{
+										"start": 26,
+										"end": 33,
+										"type": "Element",
+										"name": "li",
+										"attributes": [],
+										"children": [
+											{
+												"start": 30,
+												"end": 33,
+												"type": "Text",
+												"raw": "b\n\t",
+												"data": "b\n\t"
+											}
+										]
+									}
+								]
+							},
+							{
+								"start": 38,
+								"end": 40,
+								"type": "Text",
+								"raw": "\n\t",
+								"data": "\n\t"
+							}
+						]
+					},
+					{
+						"start": 40,
+						"end": 46,
+						"type": "Element",
+						"name": "li",
+						"attributes": [],
+						"children": [
+							{
+								"start": 44,
+								"end": 46,
+								"type": "Text",
+								"raw": "c\n",
+								"data": "c\n"
+							}
+						]
+					}
+				]
+			}
+		]
+	}
+}
\ No newline at end of file