diff --git a/src/generators/dom/visitors/attributes/addComponentAttributes.js b/src/generators/dom/visitors/attributes/addComponentAttributes.js index 888b3ca3d5..5175a644c6 100644 --- a/src/generators/dom/visitors/attributes/addComponentAttributes.js +++ b/src/generators/dom/visitors/attributes/addComponentAttributes.js @@ -1,4 +1,4 @@ -import createBinding from './binding/index.js'; +import addComponentBinding from './addComponentBinding.js'; import deindent from '../../../../utils/deindent.js'; export default function addComponentAttributes ( generator, node, local ) { @@ -112,7 +112,7 @@ export default function addComponentAttributes ( generator, node, local ) { } else if ( attribute.type === 'Binding' ) { - createBinding( generator, node, attribute, generator.current, local ); + addComponentBinding( generator, node, attribute, generator.current, local ); } else if ( attribute.type === 'Ref' ) { diff --git a/src/generators/dom/visitors/attributes/addComponentBinding.js b/src/generators/dom/visitors/attributes/addComponentBinding.js new file mode 100644 index 0000000000..f7ca07a106 --- /dev/null +++ b/src/generators/dom/visitors/attributes/addComponentBinding.js @@ -0,0 +1,60 @@ +import deindent from '../../../../utils/deindent.js'; +import flattenReference from '../../../../utils/flattenReference.js'; +import getSetter from './binding/getSetter.js'; + +export default function createBinding ( generator, node, attribute, current, local ) { + const { name } = flattenReference( attribute.value ); + const { snippet, contexts, dependencies } = generator.contextualise( attribute.value ); + + if ( dependencies.length > 1 ) throw new Error( 'An unexpected situation arose. Please raise an issue at https://github.com/sveltejs/svelte/issues — thanks!' ); + + contexts.forEach( context => { + if ( !~local.allUsedContexts.indexOf( context ) ) local.allUsedContexts.push( context ); + }); + + const contextual = name in current.contexts; + + let obj; + let prop; + + if ( contextual ) { + obj = current.listNames[ name ]; + prop = current.indexNames[ name ]; + } else if ( attribute.value.type === 'MemberExpression' ) { + prop = `'[✂${attribute.value.property.start}-${attribute.value.property.end}✂]}'`; + obj = `root.[✂${attribute.value.object.start}-${attribute.value.object.end}✂]}`; + } else { + obj = 'root'; + prop = `'${name}'`; + } + + local.bindings.push({ + name: attribute.name, + value: snippet, + obj, + prop + }); + + const setter = getSetter({ current, name, context: '_context', attribute, dependencies, snippet, value: 'value' }); + + generator.hasComplexBindings = true; + + local.init.addBlock( deindent` + var ${local.name}_updating = false; + + component._bindings.push( function () { + if ( ${local.name}._torndown ) return; + ${local.name}.observe( '${attribute.name}', function ( value ) { + ${local.name}_updating = true; + ${setter} + ${local.name}_updating = false; + }); + }); + ` ); + + local.update.addBlock( deindent` + if ( !${local.name}_updating && ${dependencies.map( dependency => `'${dependency}' in changed` ).join( '||' )} ) { + ${local.name}._set({ ${attribute.name}: ${snippet} }); + } + ` ); +} diff --git a/src/generators/dom/visitors/attributes/addElementAttributes.js b/src/generators/dom/visitors/attributes/addElementAttributes.js index 6c85f6a73c..57605a9a1e 100644 --- a/src/generators/dom/visitors/attributes/addElementAttributes.js +++ b/src/generators/dom/visitors/attributes/addElementAttributes.js @@ -1,5 +1,5 @@ import attributeLookup from './lookup.js'; -import createBinding from './binding/index.js'; +import addElementBinding from './addElementBinding'; import deindent from '../../../../utils/deindent.js'; import flattenReference from '../../../../utils/flattenReference.js'; @@ -204,7 +204,7 @@ export default function addElementAttributes ( generator, node, local ) { } else if ( attribute.type === 'Binding' ) { - createBinding( generator, node, attribute, generator.current, local ); + addElementBinding( generator, node, attribute, generator.current, local ); } else if ( attribute.type === 'Ref' ) { diff --git a/src/generators/dom/visitors/attributes/addElementBinding.js b/src/generators/dom/visitors/attributes/addElementBinding.js new file mode 100644 index 0000000000..c96ed057d7 --- /dev/null +++ b/src/generators/dom/visitors/attributes/addElementBinding.js @@ -0,0 +1,106 @@ +import deindent from '../../../../utils/deindent.js'; +import flattenReference from '../../../../utils/flattenReference.js'; +import getSetter from './binding/getSetter.js'; + +export default function createBinding ( generator, node, attribute, current, local ) { + const { name } = flattenReference( attribute.value ); + const { snippet, contexts, dependencies } = generator.contextualise( attribute.value ); + + if ( dependencies.length > 1 ) throw new Error( 'An unexpected situation arose. Please raise an issue at https://github.com/sveltejs/svelte/issues — thanks!' ); + + contexts.forEach( context => { + if ( !~local.allUsedContexts.indexOf( context ) ) local.allUsedContexts.push( context ); + }); + + const handler = current.getUniqueName( `${local.name}ChangeHandler` ); + + const isMultipleSelect = node.name === 'select' && node.attributes.find( attr => attr.name.toLowerCase() === 'multiple' ); // TODO ensure that this is a static attribute + const value = getBindingValue( local, node, attribute, isMultipleSelect ); + const eventName = getBindingEventName( node ); + + let setter = getSetter({ current, name, context: '__svelte', attribute, dependencies, snippet, value }); + + // special case + if ( node.name === 'select' && !isMultipleSelect ) { + setter = `var selectedOption = ${local.name}.selectedOptions[0] || ${local.name}.options[0];\n` + setter; + } + + let updateElement; + + if ( node.name === 'select' ) { + const value = generator.current.getUniqueName( 'value' ); + const i = generator.current.getUniqueName( 'i' ); + const option = generator.current.getUniqueName( 'option' ); + + const ifStatement = isMultipleSelect ? + deindent` + ${option}.selected = ~${value}.indexOf( ${option}.__value );` : + deindent` + if ( ${option}.__value === ${value} ) { + ${option}.selected = true; + break; + }`; + + updateElement = deindent` + var ${value} = ${snippet}; + for ( var ${i} = 0; ${i} < ${local.name}.options.length; ${i} += 1 ) { + var ${option} = ${local.name}.options[${i}]; + + ${ifStatement} + } + `; + } else { + updateElement = `${local.name}.${attribute.name} = ${snippet};`; + } + + local.init.addBlock( deindent` + var ${local.name}_updating = false; + + function ${handler} () { + ${local.name}_updating = true; + ${setter} + ${local.name}_updating = false; + } + + ${generator.helper( 'addEventListener' )}( ${local.name}, '${eventName}', ${handler} ); + ` ); + + node.initialUpdate = updateElement; + + local.update.addLine( deindent` + if ( !${local.name}_updating ) { + ${updateElement} + } + ` ); + + generator.current.builders.teardown.addLine( deindent` + ${generator.helper( 'removeEventListener' )}( ${local.name}, '${eventName}', ${handler} ); + ` ); +} + +function getBindingEventName ( node ) { + if ( node.name === 'input' ) { + const typeAttribute = node.attributes.find( attr => attr.type === 'Attribute' && attr.name === 'type' ); + const type = typeAttribute ? typeAttribute.value[0].data : 'text'; // TODO in validation, should throw if type attribute is not static + + return type === 'checkbox' || type === 'radio' ? 'change' : 'input'; + } + + if ( node.name === 'textarea' ) { + return 'input'; + } + + return 'change'; +} + +function getBindingValue ( local, node, attribute, isMultipleSelect ) { + if ( isMultipleSelect ) { + return `[].map.call( ${local.name}.selectedOptions, function ( option ) { return option.__value; })`; + } + + if ( node.name === 'select' ) { + return 'selectedOption && selectedOption.__value'; + } + + return `${local.name}.${attribute.name}`; +} \ No newline at end of file diff --git a/src/generators/dom/visitors/attributes/binding/getSetter.js b/src/generators/dom/visitors/attributes/binding/getSetter.js new file mode 100644 index 0000000000..64c822df7c --- /dev/null +++ b/src/generators/dom/visitors/attributes/binding/getSetter.js @@ -0,0 +1,34 @@ +import deindent from '../../../../../utils/deindent.js'; + +export default function getSetter ({ current, name, context, attribute, dependencies, snippet, value }) { + if ( name in current.contexts ) { + const prop = dependencies[0]; + const tail = attribute.value.type === 'MemberExpression' ? getTailSnippet( attribute.value ) : ''; + + return deindent` + var list = this.${context}.${current.listNames[ name ]}; + var index = this.${context}.${current.indexNames[ name ]}; + list[index]${tail} = ${value}; + + component._set({ ${prop}: component.get( '${prop}' ) }); + `; + } + + if ( attribute.value.type === 'MemberExpression' ) { + return deindent` + var ${name} = component.get( '${name}' ); + ${snippet} = ${value}; + component._set({ ${name}: ${name} }); + `; + } + + return `component._set({ ${name}: ${value} });`; +} + +function getTailSnippet ( node ) { + const end = node.end; + while ( node.type === 'MemberExpression' ) node = node.object; + const start = node.end; + + return `[✂${start}-${end}✂]`; +} \ No newline at end of file diff --git a/src/generators/dom/visitors/attributes/binding/index.js b/src/generators/dom/visitors/attributes/binding/index.js deleted file mode 100644 index 2425aaaad7..0000000000 --- a/src/generators/dom/visitors/attributes/binding/index.js +++ /dev/null @@ -1,190 +0,0 @@ -import deindent from '../../../../../utils/deindent.js'; -import isReference from '../../../../../utils/isReference.js'; -import flattenReference from '../../../../../utils/flattenReference.js'; - -export default function createBinding ( generator, node, attribute, current, local ) { - const parts = attribute.value.split( '.' ); - - const deep = parts.length > 1; - const contextual = parts[0] in current.contexts; - - if ( contextual && !~local.allUsedContexts.indexOf( parts[0] ) ) { - local.allUsedContexts.push( parts[0] ); - } - - if ( local.isComponent ) { - let obj; - let prop; - let value; - - if ( contextual ) { - obj = current.listNames[ parts[0] ]; - prop = current.indexNames[ parts[0] ]; - value = attribute.value; - } else { - prop = `'${parts.slice( -1 )}'`; - obj = parts.length > 1 ? `root.${parts.slice( 0, -1 ).join( '.' )}` : `root`; - value = `root.${attribute.value}`; - } - - local.bindings.push({ name: attribute.name, value, obj, prop }); - } - - const handler = current.getUniqueName( `${local.name}ChangeHandler` ); - let setter; - - let eventName = 'change'; - if ( node.name === 'input' ) { - const typeAttribute = node.attributes.find( attr => attr.type === 'Attribute' && attr.name === 'type' ); - const type = typeAttribute ? typeAttribute.value[0].data : 'text'; // TODO in validation, should throw if type attribute is not static - - if ( type !== 'checkbox' && type !== 'radio' ) { - eventName = 'input'; - } - } - - else if ( node.name === 'textarea' ) { - eventName = 'input'; - } - - const isMultipleSelect = node.name === 'select' && node.attributes.find( attr => attr.name.toLowerCase() === 'multiple' ); // TODO ensure that this is a static attribute - let value; - - if ( local.isComponent ) { - value = 'value'; - } else if ( node.name === 'select' ) { - if ( isMultipleSelect ) { - value = `[].map.call( ${local.name}.selectedOptions, function ( option ) { return option.__value; })`; - } else { - value = 'selectedOption && selectedOption.__value'; - } - } else { - value = `${local.name}.${attribute.name}`; - } - - if ( contextual ) { - // find the top-level property that this is a child of - let fragment = current; - let prop = parts[0]; - - do { - if ( fragment.expression && fragment.context === prop ) { - if ( !isReference( fragment.expression ) ) { - // TODO this should happen in prior validation step - throw new Error( `${prop} is read-only, it cannot be bound` ); - } - - prop = flattenReference( fragment.expression ).name; - } - } while ( fragment = fragment.parent ); - - generator.expectedProperties[ prop ] = true; - - const listName = current.listNames[ parts[0] ]; - const indexName = current.indexNames[ parts[0] ]; - - const context = local.isComponent ? `_context` : `__svelte`; - - setter = deindent` - var list = this.${context}.${listName}; - var index = this.${context}.${indexName}; - list[index]${parts.slice( 1 ).map( part => `.${part}` ).join( '' )} = ${value}; - - component._set({ ${prop}: component.get( '${prop}' ) }); - `; - } else { - if ( deep ) { - setter = deindent` - var ${parts[0]} = component.get( '${parts[0]}' ); - ${parts[0]}.${parts.slice( 1 ).join( '.' )} = ${value}; - component._set({ ${parts[0]}: ${parts[0]} }); - `; - } else { - setter = `component._set({ ${attribute.value}: ${value} });`; - } - - generator.expectedProperties[ parts[0] ] = true; - } - - // special case - if ( node.name === 'select' && !isMultipleSelect ) { - setter = `var selectedOption = ${local.name}.selectedOptions[0] || ${local.name}.options[0];\n` + setter; - } - - if ( local.isComponent ) { - generator.hasComplexBindings = true; - - local.init.addBlock( deindent` - var ${local.name}_updating = false; - - component._bindings.push( function () { - if ( ${local.name}._torndown ) return; - ${local.name}.observe( '${attribute.name}', function ( value ) { - ${local.name}_updating = true; - ${setter} - ${local.name}_updating = false; - }); - }); - ` ); - - const dependencies = parts[0] in current.contexts ? current.contextDependencies[ parts[0] ] : [ parts[0] ]; - - local.update.addBlock( deindent` - if ( !${local.name}_updating && ${dependencies.map( dependency => `'${dependency}' in changed` ).join( '||' )} ) { - ${local.name}._set({ ${attribute.name}: ${contextual ? attribute.value : `root.${attribute.value}`} }); - } - ` ); - } else { - let updateElement; - - if ( node.name === 'select' ) { - const value = generator.current.getUniqueName( 'value' ); - const i = generator.current.getUniqueName( 'i' ); - const option = generator.current.getUniqueName( 'option' ); - - const ifStatement = isMultipleSelect ? - deindent` - ${option}.selected = ~${value}.indexOf( ${option}.__value );` : - deindent` - if ( ${option}.__value === ${value} ) { - ${option}.selected = true; - break; - }`; - - updateElement = deindent` - var ${value} = ${contextual ? attribute.value : `root.${attribute.value}`}; - for ( var ${i} = 0; ${i} < ${local.name}.options.length; ${i} += 1 ) { - var ${option} = ${local.name}.options[${i}]; - - ${ifStatement} - } - `; - } else { - updateElement = `${local.name}.${attribute.name} = ${contextual ? attribute.value : `root.${attribute.value}`};`; - } - - local.init.addBlock( deindent` - var ${local.name}_updating = false; - - function ${handler} () { - ${local.name}_updating = true; - ${setter} - ${local.name}_updating = false; - } - - ${generator.helper( 'addEventListener' )}( ${local.name}, '${eventName}', ${handler} ); - ` ); - - node.initialUpdate = updateElement; - - local.update.addLine( deindent` - if ( !${local.name}_updating ) { - ${updateElement} - } - ` ); - - generator.current.builders.teardown.addLine( deindent` - ${generator.helper( 'removeEventListener' )}( ${local.name}, '${eventName}', ${handler} ); - ` ); - } -} diff --git a/src/generators/server-side-rendering/index.js b/src/generators/server-side-rendering/index.js index 285fb3cca6..efe0d13ea7 100644 --- a/src/generators/server-side-rendering/index.js +++ b/src/generators/server-side-rendering/index.js @@ -1,5 +1,6 @@ import deindent from '../../utils/deindent.js'; import CodeBuilder from '../../utils/CodeBuilder.js'; +import flattenReference from '../../utils/flattenReference.js'; import processCss from '../shared/processCss.js'; import visitors from './visitors/index.js'; import Generator from '../Generator.js'; @@ -16,11 +17,13 @@ class SsrGenerator extends Generator { this.current.conditions.map( c => `(${c})` ) ); + const { keypath } = flattenReference( binding.value ); + this.bindings.push( deindent` if ( ${conditions.join( '&&' )} ) { tmp = ${name}.data(); - if ( '${binding.value}' in tmp ) { - root.${binding.name} = tmp.${binding.value}; + if ( '${keypath}' in tmp ) { + root.${binding.name} = tmp.${keypath}; settled = false; } } diff --git a/src/generators/server-side-rendering/visitors/Component.js b/src/generators/server-side-rendering/visitors/Component.js index c44d58fe51..895792a7d0 100644 --- a/src/generators/server-side-rendering/visitors/Component.js +++ b/src/generators/server-side-rendering/visitors/Component.js @@ -1,3 +1,5 @@ +import flattenReference from '../../../utils/flattenReference.js'; + export default { enter ( generator, node ) { function stringify ( chunk ) { @@ -42,8 +44,8 @@ export default { return `${attribute.name}: ${value}`; }) .concat( bindings.map( binding => { - const parts = binding.value.split( '.' ); - const value = parts[0] in generator.current.contexts ? binding.value : `root.${binding.value}`; + const { name, keypath } = flattenReference( binding.value ); + const value = name in generator.current.contexts ? keypath : `root.${keypath}`; return `${binding.name}: ${value}`; })) .join( ', ' ); diff --git a/src/parse/read/directives.js b/src/parse/read/directives.js index 5d62899dc1..a52cbfabba 100644 --- a/src/parse/read/directives.js +++ b/src/parse/read/directives.js @@ -1,4 +1,4 @@ -import { parse } from 'acorn'; +import { parse, parseExpressionAt } from 'acorn'; import spaces from '../../utils/spaces.js'; export function readEventHandlerDirective ( parser, start, name ) { @@ -66,7 +66,7 @@ export function readEventHandlerDirective ( parser, start, name ) { } export function readBindingDirective ( parser, start, name ) { - let value = name; // shorthand – bind:foo equivalent to bind:foo='foo' + let value; if ( parser.eat( '=' ) ) { const quoteMark = ( @@ -75,12 +75,37 @@ export function readBindingDirective ( parser, start, name ) { null ); - value = parser.read( /([a-zA-Z_$][a-zA-Z0-9_$]*)(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*/ ); - if ( !value ) parser.error( `Expected valid property name` ); + const a = parser.index; + + // this is a bit of a hack so that we can give Acorn something parseable + let b; + if ( quoteMark ) { + b = parser.index = parser.template.indexOf( quoteMark, parser.index ); + } else { + parser.readUntil( /[\s\r\n\/>]/ ); + b = parser.index; + } + + const source = spaces( a ) + parser.template.slice( a, b ); + value = parseExpressionAt( source, a ); + + if ( value.type !== 'Identifier' && value.type !== 'MemberExpression' ) { + parser.error( `Expected valid property name` ); + } + + parser.allowWhitespace(); if ( quoteMark ) { parser.eat( quoteMark, true ); } + } else { + // shorthand – bind:foo equivalent to bind:foo='foo' + value = { + type: 'Identifier', + start: start + 5, + end: parser.index, + name + }; } return { diff --git a/src/utils/flattenReference.js b/src/utils/flattenReference.js index c975c65095..da92d5588f 100644 --- a/src/utils/flattenReference.js +++ b/src/utils/flattenReference.js @@ -12,5 +12,5 @@ export default function flatten ( node ) { if ( !name ) return null; parts.unshift( name ); - return { name, keypath: parts.join( '.' ) }; + return { name, parts, keypath: parts.join( '.' ) }; } diff --git a/test/generator/samples/binding-input-checkbox/_config.js b/test/generator/samples/binding-input-checkbox/_config.js index e12eb2ee48..ba943e5d77 100644 --- a/test/generator/samples/binding-input-checkbox/_config.js +++ b/test/generator/samples/binding-input-checkbox/_config.js @@ -2,7 +2,9 @@ export default { data: { foo: true }, + html: `\n

true

`, + test ( assert, component, target, window ) { const input = target.querySelector( 'input' ); assert.equal( input.checked, true ); diff --git a/test/parser/samples/binding-shorthand/output.json b/test/parser/samples/binding-shorthand/output.json index 61c24975a2..ba22c66d33 100644 --- a/test/parser/samples/binding-shorthand/output.json +++ b/test/parser/samples/binding-shorthand/output.json @@ -15,7 +15,12 @@ "end": 16, "type": "Binding", "name": "foo", - "value": "foo" + "value": { + "type": "Identifier", + "start": 13, + "end": 16, + "name": "foo" + } } ], "children": [] diff --git a/test/parser/samples/binding/output.json b/test/parser/samples/binding/output.json index ec1fcd2862..f307779950 100644 --- a/test/parser/samples/binding/output.json +++ b/test/parser/samples/binding/output.json @@ -15,7 +15,12 @@ "end": 24, "type": "Binding", "name": "value", - "value": "name" + "value": { + "start": 19, + "end": 23, + "type": "Identifier", + "name": "name" + } } ], "children": [] diff --git a/test/sourcemaps/samples/binding-shorthand/input.html b/test/sourcemaps/samples/binding-shorthand/input.html new file mode 100644 index 0000000000..8a1b993b6f --- /dev/null +++ b/test/sourcemaps/samples/binding-shorthand/input.html @@ -0,0 +1,11 @@ + + + diff --git a/test/sourcemaps/samples/binding-shorthand/test.js b/test/sourcemaps/samples/binding-shorthand/test.js new file mode 100644 index 0000000000..8aa8c050a9 --- /dev/null +++ b/test/sourcemaps/samples/binding-shorthand/test.js @@ -0,0 +1,21 @@ +export function test ({ assert, smc, locateInSource, locateInGenerated }) { + const expected = locateInSource( 'potato' ); + + let loc; + + loc = locateInGenerated( 'potato' ); + loc = locateInGenerated( 'potato', loc.character + 1 ); + loc = locateInGenerated( 'potato', loc.character + 1 ); // we need the third instance of 'potato' + + const actual = smc.originalPositionFor({ + line: loc.line + 1, + column: loc.column + }); + + assert.deepEqual( actual, { + source: 'input.html', + name: null, + line: expected.line + 1, + column: expected.column + }); +} diff --git a/test/sourcemaps/samples/binding/input.html b/test/sourcemaps/samples/binding/input.html new file mode 100644 index 0000000000..c15533acdd --- /dev/null +++ b/test/sourcemaps/samples/binding/input.html @@ -0,0 +1 @@ + diff --git a/test/sourcemaps/samples/binding/test.js b/test/sourcemaps/samples/binding/test.js new file mode 100644 index 0000000000..a8803c0063 --- /dev/null +++ b/test/sourcemaps/samples/binding/test.js @@ -0,0 +1,34 @@ +export function test ({ assert, smc, locateInSource, locateInGenerated }) { + const expected = locateInSource( 'foo.bar.baz' ); + + let loc; + let actual; + + loc = locateInGenerated( 'foo.bar.baz' ); + + actual = smc.originalPositionFor({ + line: loc.line + 1, + column: loc.column + }); + + assert.deepEqual( actual, { + source: 'input.html', + name: null, + line: expected.line + 1, + column: expected.column + }); + + loc = locateInGenerated( 'foo.bar.baz', loc.character + 1 ); + + actual = smc.originalPositionFor({ + line: loc.line + 1, + column: loc.column + }); + + assert.deepEqual( actual, { + source: 'input.html', + name: null, + line: expected.line + 1, + column: expected.column + }); +}