Repository: sveltejs/svelte (JavaScript)
Date: 2018-05-14 23:56:01+00:00
Task type: Bug Fix

Issue:
Slot names are not escaped correctly
When creating slots, it appears that the slot name is not escaped correctly if the name doesn't follow Javascript naming conventions.  For example:

```
<slot name="some-slot"></slot>
```

results in an error similar to:

```
ERROR in ./component/BasePage.sv.html
Module build failed: SyntaxError: component/BasePage.sv.html: Unexpected token, expected ";" (14:27)

  12 | 
  13 | function create_main_fragment(component, ctx) {
> 14 |  var div, slot_content_some-slot = component._slotted["some-slot"], div_class_value;
     |                            ^
  15 | 
  16 |  return {
  17 |          c: function create() {
```

Note the "slot_content_some-slot" variable name, which is invalid.  Removing the "-" fixes the issue.


Issue discussion:

Solution patch:
diff --git a/src/compile/nodes/Component.ts b/src/compile/nodes/Component.ts
--- a/src/compile/nodes/Component.ts
+++ b/src/compile/nodes/Component.ts
@@ -5,7 +5,7 @@ import stringifyProps from '../../utils/stringifyProps';
 import CodeBuilder from '../../utils/CodeBuilder';
 import getTailSnippet from '../../utils/getTailSnippet';
 import getObject from '../../utils/getObject';
-import quoteIfNecessary from '../../utils/quoteIfNecessary';
+import { quoteNameIfNecessary } from '../../utils/quoteIfNecessary';
 import { escape, escapeTemplate, stringify } from '../../utils/stringify';
 import Node from './shared/Node';
 import Block from '../dom/Block';
@@ -126,7 +126,7 @@ export default class Component extends Node {
 		const componentInitProperties = [`root: #component.root`];
 
 		if (this.children.length > 0) {
-			const slots = Array.from(this._slots).map(name => `${quoteIfNecessary(name)}: @createFragment()`);
+			const slots = Array.from(this._slots).map(name => `${quoteNameIfNecessary(name)}: @createFragment()`);
 			componentInitProperties.push(`slots: { ${slots.join(', ')} }`);
 
 			this.children.forEach((child: Node) => {
@@ -185,7 +185,7 @@ export default class Component extends Node {
 
 						changes.push(condition ? `${condition} && ${value}` : value);
 					} else {
-						const obj = `{ ${quoteIfNecessary(name)}: ${attr.getValue()} }`;
+						const obj = `{ ${quoteNameIfNecessary(name)}: ${attr.getValue()} }`;
 						initialProps.push(obj);
 
 						changes.push(condition ? `${condition} && ${obj}` : obj);
@@ -602,7 +602,7 @@ export default class Component extends Node {
 			});
 
 			const slotted = Object.keys(appendTarget.slots)
-				.map(name => `${name}: () => \`${appendTarget.slots[name]}\``)
+				.map(name => `${quoteNameIfNecessary(name)}: () => \`${appendTarget.slots[name]}\``)
 				.join(', ');
 
 			options.push(`slotted: { ${slotted} }`);
diff --git a/src/compile/nodes/Element.ts b/src/compile/nodes/Element.ts
--- a/src/compile/nodes/Element.ts
+++ b/src/compile/nodes/Element.ts
@@ -5,7 +5,7 @@ import isVoidElementName from '../../utils/isVoidElementName';
 import validCalleeObjects from '../../utils/validCalleeObjects';
 import reservedNames from '../../utils/reservedNames';
 import fixAttributeCasing from '../../utils/fixAttributeCasing';
-import quoteIfNecessary from '../../utils/quoteIfNecessary';
+import { quoteNameIfNecessary, quotePropIfNecessary } from '../../utils/quoteIfNecessary';
 import Compiler from '../Compiler';
 import Node from './shared/Node';
 import Block from '../dom/Block';
@@ -260,8 +260,9 @@ export default class Element extends Node {
 		const name = this.var;
 
 		const slot = this.attributes.find((attribute: Node) => attribute.name === 'slot');
+		const prop = slot && quotePropIfNecessary(slot.chunks[0].data);
 		const initialMountNode = this.slotted ?
-			`${this.findNearest(/^Component/).var}._slotted.${slot.chunks[0].data}` : // TODO this looks bonkers
+			`${this.findNearest(/^Component/).var}._slotted${prop}` : // TODO this looks bonkers
 			parentNode;
 
 		block.addVariable(name);
@@ -571,7 +572,7 @@ export default class Element extends Node {
 
 					updates.push(condition ? `${condition} && ${snippet}` : snippet);
 				} else {
-					const snippet = `{ ${quoteIfNecessary(attr.name)}: ${attr.getValue()} }`;
+					const snippet = `{ ${quoteNameIfNecessary(attr.name)}: ${attr.getValue()} }`;
 					initialProps.push(snippet);
 
 					updates.push(condition ? `${condition} && ${snippet}` : snippet);
@@ -824,7 +825,8 @@ export default class Element extends Node {
 	remount(name: string) {
 		const slot = this.attributes.find(attribute => attribute.name === 'slot');
 		if (slot) {
-			return `@appendNode(${this.var}, ${name}._slotted.${this.getStaticAttributeValue('slot')});`;
+			const prop = quotePropIfNecessary(slot.chunks[0].data);
+			return `@appendNode(${this.var}, ${name}._slotted${prop});`;
 		}
 
 		return `@appendNode(${this.var}, ${name}._slotted.default);`;
@@ -881,16 +883,16 @@ export default class Element extends Node {
 					if (attribute.name === 'value' && this.name === 'textarea') {
 						textareaContents = attribute.stringifyForSsr();
 					} else if (attribute.isTrue) {
-						args.push(`{ ${quoteIfNecessary(attribute.name)}: true }`);
+						args.push(`{ ${quoteNameIfNecessary(attribute.name)}: true }`);
 					} else if (
 						booleanAttributes.has(attribute.name) &&
 						attribute.chunks.length === 1 &&
 						attribute.chunks[0].type !== 'Text'
 					) {
 						// a boolean attribute with one non-Text chunk
-						args.push(`{ ${quoteIfNecessary(attribute.name)}: ${attribute.chunks[0].snippet} }`);
+						args.push(`{ ${quoteNameIfNecessary(attribute.name)}: ${attribute.chunks[0].snippet} }`);
 					} else {
-						args.push(`{ ${quoteIfNecessary(attribute.name)}: \`${attribute.stringifyForSsr()}\` }`);
+						args.push(`{ ${quoteNameIfNecessary(attribute.name)}: \`${attribute.stringifyForSsr()}\` }`);
 					}
 				}
 			});
@@ -962,7 +964,7 @@ function getClaimStatement(
 ) {
 	const attributes = node.attributes
 		.filter((attr: Node) => attr.type === 'Attribute')
-		.map((attr: Node) => `${quoteIfNecessary(attr.name)}: true`)
+		.map((attr: Node) => `${quoteNameIfNecessary(attr.name)}: true`)
 		.join(', ');
 
 	const name = namespace ? node.name : node.name.toUpperCase();
diff --git a/src/compile/nodes/Slot.ts b/src/compile/nodes/Slot.ts
--- a/src/compile/nodes/Slot.ts
+++ b/src/compile/nodes/Slot.ts
@@ -5,6 +5,11 @@ import Node from './shared/Node';
 import Element from './Element';
 import Attribute from './Attribute';
 import Block from '../dom/Block';
+import { quotePropIfNecessary } from '../../utils/quoteIfNecessary';
+
+function sanitize(name) {
+	return name.replace(/[^a-zA-Z]+/g, '_').replace(/^_/, '').replace(/_$/, '');
+}
 
 export default class Slot extends Element {
 	type: 'Element';
@@ -36,8 +41,8 @@ export default class Slot extends Element {
 		const slotName = this.getStaticAttributeValue('name') || 'default';
 		compiler.slots.add(slotName);
 
-		const content_name = block.getUniqueName(`slot_content_${slotName}`);
-		const prop = !isValidIdentifier(slotName) ? `["${slotName}"]` : `.${slotName}`;
+		const content_name = block.getUniqueName(`slot_content_${sanitize(slotName)}`);
+		const prop = quotePropIfNecessary(slotName);
 		block.addVariable(content_name, `#component._slotted${prop}`);
 
 		const needsAnchorBefore = this.prev ? this.prev.type !== 'Element' : !parentNode;
@@ -151,9 +156,11 @@ export default class Slot extends Element {
 
 	ssr() {
 		const name = this.attributes.find(attribute => attribute.name === 'name');
+
 		const slotName = name && name.chunks[0].data || 'default';
+		const prop = quotePropIfNecessary(slotName);
 
-		this.compiler.target.append(`\${options && options.slotted && options.slotted.${slotName} ? options.slotted.${slotName}() : \``);
+		this.compiler.target.append(`\${options && options.slotted && options.slotted${prop} ? options.slotted${prop}() : \``);
 
 		this.children.forEach((child: Node) => {
 			child.ssr();
diff --git a/src/utils/quoteIfNecessary.ts b/src/utils/quoteIfNecessary.ts
--- a/src/utils/quoteIfNecessary.ts
+++ b/src/utils/quoteIfNecessary.ts
@@ -1,7 +1,12 @@
 import isValidIdentifier from './isValidIdentifier';
 import reservedNames from './reservedNames';
 
-export default function quoteIfNecessary(name) {
+export function quoteNameIfNecessary(name) {
 	if (!isValidIdentifier(name)) return `"${name}"`;
 	return name;
+}
+
+export function quotePropIfNecessary(name) {
+	if (!isValidIdentifier(name)) return `["${name}"]`;
+	return `.${name}`;
 }
\ No newline at end of file


Test patch:
diff --git a/test/runtime/samples/component-slot-name-with-hyphen/Nested.html b/test/runtime/samples/component-slot-name-with-hyphen/Nested.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-slot-name-with-hyphen/Nested.html
@@ -0,0 +1,3 @@
+<div>
+	<slot name="foo-bar"/>
+</div>
\ No newline at end of file
diff --git a/test/runtime/samples/component-slot-name-with-hyphen/_config.js b/test/runtime/samples/component-slot-name-with-hyphen/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-slot-name-with-hyphen/_config.js
@@ -0,0 +1,3 @@
+export default {
+	html: '<div><p slot="foo-bar">Hello</p></div>'
+};
diff --git a/test/runtime/samples/component-slot-name-with-hyphen/main.html b/test/runtime/samples/component-slot-name-with-hyphen/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-slot-name-with-hyphen/main.html
@@ -0,0 +1,13 @@
+<Nested ref:nested>
+	<p slot="foo-bar">Hello</p>
+</Nested>
+
+<script>
+	import Nested from './Nested.html';
+
+	export default {
+		components: {
+			Nested
+		}
+	};
+</script>