Repository: sveltejs/svelte (JavaScript)
Date: 2019-05-17 22:33:34+00:00
Task type: 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).


Issue discussion:

Solution 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