diff --git a/src/generators/dom/visitors/Element/Element.ts b/src/generators/dom/visitors/Element/Element.ts index a5974f9485..5e831becd7 100644 --- a/src/generators/dom/visitors/Element/Element.ts +++ b/src/generators/dom/visitors/Element/Element.ts @@ -107,6 +107,20 @@ export default function visitElement ( generator: DomGenerator, block: Block, st } if ( node.name !== 'select' ) { + if ( node.name === 'textarea' ) { + // this is an egregious hack, but it's the easiest way to get + // children treated the same way as a value attribute + if ( node.children.length > 0 ) { + node.attributes.push({ + type: 'Attribute', + name: 'value', + value: node.children + }); + + node.children = []; + } + } + // value attributes are an annoying special case — it must be handled // *after* its children have been updated visitAttributesAndAddProps(); diff --git a/src/generators/dom/visitors/Element/lookup.ts b/src/generators/dom/visitors/Element/lookup.ts index 41fc249e61..de48507fea 100644 --- a/src/generators/dom/visitors/Element/lookup.ts +++ b/src/generators/dom/visitors/Element/lookup.ts @@ -109,7 +109,7 @@ const lookup = { title: {}, type: { appliesTo: [ 'button', 'input', 'command', 'embed', 'object', 'script', 'source', 'style', 'menu' ] }, usemap: { propertyName: 'useMap', appliesTo: [ 'img', 'input', 'object' ] }, - value: { appliesTo: [ 'button', 'option', 'input', 'li', 'meter', 'progress', 'param', 'select' ] }, + value: { appliesTo: [ 'button', 'option', 'input', 'li', 'meter', 'progress', 'param', 'select', 'textarea' ] }, width: { appliesTo: [ 'canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video' ] }, wrap: { appliesTo: [ 'textarea' ] } }; diff --git a/src/generators/server-side-rendering/visitors/Element.ts b/src/generators/server-side-rendering/visitors/Element.ts index bfa615227a..05267f6bd0 100644 --- a/src/generators/server-side-rendering/visitors/Element.ts +++ b/src/generators/server-side-rendering/visitors/Element.ts @@ -10,6 +10,17 @@ const meta = { ':Window': visitWindow }; +function stringifyAttributeValue ( block: Block, chunks: Node[] ) { + return chunks.map( ( chunk: Node ) => { + if ( chunk.type === 'Text' ) { + return chunk.data; + } + + const { snippet } = block.contextualise( chunk.expression ); + return '${' + snippet + '}'; + }).join( '' ) +} + export default function visitElement ( generator: SsrGenerator, block: Block, node: Node ) { if ( node.name in meta ) { return meta[ node.name ]( generator, block, node ); @@ -21,24 +32,22 @@ export default function visitElement ( generator: SsrGenerator, block: Block, no } let openingTag = `<${node.name}`; + let textareaContents; // awkward special case node.attributes.forEach( ( attribute: Node ) => { if ( attribute.type !== 'Attribute' ) return; - let str = ` ${attribute.name}`; + if ( attribute.name === 'value' && node.name === 'textarea' ) { + textareaContents = stringifyAttributeValue( block, attribute.value ); + } else { + let str = ` ${attribute.name}`; - if ( attribute.value !== true ) { - str += `="` + attribute.value.map( ( chunk: Node ) => { - if ( chunk.type === 'Text' ) { - return chunk.data; - } + if ( attribute.value !== true ) { + str += `="${stringifyAttributeValue( block, attribute.value )}"`; + } - const { snippet } = block.contextualise( chunk.expression ); - return '${' + snippet + '}'; - }).join( '' ) + `"`; + openingTag += str; } - - openingTag += str; }); if ( generator.cssId && !generator.elementDepth ) { @@ -49,13 +58,17 @@ export default function visitElement ( generator: SsrGenerator, block: Block, no generator.append( openingTag ); - generator.elementDepth += 1; + if ( node.name === 'textarea' && textareaContents !== undefined ) { + generator.append( textareaContents ); + } else { + generator.elementDepth += 1; - node.children.forEach( ( child: Node ) => { - visit( generator, block, child ); - }); + node.children.forEach( ( child: Node ) => { + visit( generator, block, child ); + }); - generator.elementDepth -= 1; + generator.elementDepth -= 1; + } if ( !isVoidElementName( node.name ) ) { generator.append( `${node.name}>` ); diff --git a/src/parse/state/tag.ts b/src/parse/state/tag.ts index 97d34db372..47b8b1c7d6 100644 --- a/src/parse/state/tag.ts +++ b/src/parse/state/tag.ts @@ -9,7 +9,6 @@ import { Parser } from '../index'; import { Node } from '../../interfaces'; const validTagName = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/; -const invalidUnquotedAttributeCharacters = /[\s"'=<>\/`]/; const SELF = ':Self'; @@ -181,6 +180,11 @@ export default function tag ( parser: Parser ) { if ( selfClosing ) { element.end = parser.index; + } else if ( name === 'textarea' ) { + // special case + element.children = readSequence( parser, () => parser.template.slice( parser.index, parser.index + 11 ) === '' ); + parser.read( /<\/textarea>/ ); + element.end = parser.index; } else { // don't push self-closing elements onto the stack parser.stack.push( element ); @@ -280,11 +284,41 @@ function readAttribute ( parser: Parser, uniqueNames ) { } function readAttributeValue ( parser: Parser ) { - let quoteMark; + const quoteMark = ( + parser.eat( `'` ) ? `'` : + parser.eat( `"` ) ? `"` : + null + ); + + const regex = ( + quoteMark === `'` ? /'/ : + quoteMark === `"` ? /"/ : + /[\s"'=<>\/`]/ + ); + + const value = readSequence( parser, () => regex.test( parser.template[ parser.index ] ) ); + + if ( quoteMark ) parser.index += 1; + return value; +} - if ( parser.eat( `'` ) ) quoteMark = `'`; - if ( parser.eat( `"` ) ) quoteMark = `"`; +function getShorthandValue ( start: number, name: string ) { + const end = start + name.length; + return [{ + type: 'AttributeShorthand', + start, + end, + expression: { + type: 'Identifier', + start, + end, + name + } + }]; +} + +function readSequence ( parser: Parser, done: () => boolean ) { let currentChunk: Node = { start: parser.index, end: null, @@ -292,16 +326,24 @@ function readAttributeValue ( parser: Parser ) { data: '' }; - const done = quoteMark ? - char => char === quoteMark : - char => invalidUnquotedAttributeCharacters.test( char ); - const chunks = []; while ( parser.index < parser.template.length ) { const index = parser.index; - if ( parser.eat( '{{' ) ) { + if ( done() ) { + currentChunk.end = parser.index; + + if ( currentChunk.data ) chunks.push( currentChunk ); + + chunks.forEach( chunk => { + if ( chunk.type === 'Text' ) chunk.data = decodeCharacterReferences( chunk.data ); + }); + + return chunks; + } + + else if ( parser.eat( '{{' ) ) { if ( currentChunk.data ) { currentChunk.end = index; chunks.push( currentChunk ); @@ -328,39 +370,10 @@ function readAttributeValue ( parser: Parser ) { }; } - else if ( done( parser.template[ parser.index ] ) ) { - currentChunk.end = parser.index; - if ( quoteMark ) parser.index += 1; - - if ( currentChunk.data ) chunks.push( currentChunk ); - - chunks.forEach( chunk => { - if ( chunk.type === 'Text' ) chunk.data = decodeCharacterReferences( chunk.data ); - }); - - return chunks; - } - else { currentChunk.data += parser.template[ parser.index++ ]; } } parser.error( `Unexpected end of input` ); -} - -function getShorthandValue ( start: number, name: string ) { - const end = start + name.length; - - return [{ - type: 'AttributeShorthand', - start, - end, - expression: { - type: 'Identifier', - start, - end, - name - } - }]; -} +} \ No newline at end of file diff --git a/src/validate/html/validateElement.ts b/src/validate/html/validateElement.ts index 6610e7ee69..18c906b298 100644 --- a/src/validate/html/validateElement.ts +++ b/src/validate/html/validateElement.ts @@ -77,6 +77,14 @@ export default function validateElement ( validator: Validator, node: Node ) { validator.error( `Missing transition '${attribute.name}'`, attribute.start ); } } + + else if ( attribute.type === 'Attribute' ) { + if ( attribute.name === 'value' && node.name === 'textarea' ) { + if ( node.children.length ) { + validator.error( `A can have either a value attribute or (equivalently) child content, but not both`, attribute.start ); + } + } + } }); } diff --git a/test/parser/samples/textarea-children/input.html b/test/parser/samples/textarea-children/input.html new file mode 100644 index 0000000000..de50c807d6 --- /dev/null +++ b/test/parser/samples/textarea-children/input.html @@ -0,0 +1,3 @@ + + not actually an element. {{foo}} + \ No newline at end of file diff --git a/test/parser/samples/textarea-children/output.json b/test/parser/samples/textarea-children/output.json new file mode 100644 index 0000000000..4b67ac108a --- /dev/null +++ b/test/parser/samples/textarea-children/output.json @@ -0,0 +1,44 @@ +{ + "hash": 3618147195, + "html": { + "start": 0, + "end": 63, + "type": "Fragment", + "children": [ + { + "start": 0, + "end": 63, + "type": "Element", + "name": "textarea", + "attributes": [], + "children": [ + { + "start": 10, + "end": 40, + "type": "Text", + "data": "\n\tnot actually an element. " + }, + { + "start": 40, + "end": 47, + "type": "MustacheTag", + "expression": { + "type": "Identifier", + "start": 42, + "end": 45, + "name": "foo" + } + }, + { + "start": 47, + "end": 52, + "type": "Text", + "data": "\n" + } + ] + } + ] + }, + "css": null, + "js": null +} \ No newline at end of file diff --git a/test/runtime/samples/textarea-children/_config.js b/test/runtime/samples/textarea-children/_config.js new file mode 100644 index 0000000000..f21519b26a --- /dev/null +++ b/test/runtime/samples/textarea-children/_config.js @@ -0,0 +1,17 @@ +export default { + 'skip-ssr': true, // SSR behaviour is awkwardly different + + data: { + foo: 42 + }, + + html: ``, + + test ( assert, component, target ) { + const textarea = target.querySelector( 'textarea' ); + assert.strictEqual( textarea.value, `\n\tnot actually an element. 42\n` ); + + component.set({ foo: 43 }); + assert.strictEqual( textarea.value, `\n\tnot actually an element. 43\n` ); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/textarea-children/main.html b/test/runtime/samples/textarea-children/main.html new file mode 100644 index 0000000000..de50c807d6 --- /dev/null +++ b/test/runtime/samples/textarea-children/main.html @@ -0,0 +1,3 @@ + + not actually an element. {{foo}} + \ No newline at end of file diff --git a/test/runtime/samples/textarea-value/_config.js b/test/runtime/samples/textarea-value/_config.js new file mode 100644 index 0000000000..89ff63508c --- /dev/null +++ b/test/runtime/samples/textarea-value/_config.js @@ -0,0 +1,17 @@ +export default { + 'skip-ssr': true, // SSR behaviour is awkwardly different + + data: { + foo: 42 + }, + + html: ``, + + test ( assert, component, target ) { + const textarea = target.querySelector( 'textarea' ); + assert.strictEqual( textarea.value, '42' ); + + component.set({ foo: 43 }); + assert.strictEqual( textarea.value, '43' ); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/textarea-value/main.html b/test/runtime/samples/textarea-value/main.html new file mode 100644 index 0000000000..215166ab10 --- /dev/null +++ b/test/runtime/samples/textarea-value/main.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/server-side-rendering/samples/textarea-children/_expected.html b/test/server-side-rendering/samples/textarea-children/_expected.html new file mode 100644 index 0000000000..792567e4ca --- /dev/null +++ b/test/server-side-rendering/samples/textarea-children/_expected.html @@ -0,0 +1,3 @@ + + not actually an element. 42 + \ No newline at end of file diff --git a/test/server-side-rendering/samples/textarea-children/main.html b/test/server-side-rendering/samples/textarea-children/main.html new file mode 100644 index 0000000000..7209683448 --- /dev/null +++ b/test/server-side-rendering/samples/textarea-children/main.html @@ -0,0 +1,11 @@ + + not actually an element. {{foo}} + + + \ No newline at end of file diff --git a/test/server-side-rendering/samples/textarea-value/_expected.html b/test/server-side-rendering/samples/textarea-value/_expected.html new file mode 100644 index 0000000000..cd39aabde5 --- /dev/null +++ b/test/server-side-rendering/samples/textarea-value/_expected.html @@ -0,0 +1 @@ +42 \ No newline at end of file diff --git a/test/server-side-rendering/samples/textarea-value/main.html b/test/server-side-rendering/samples/textarea-value/main.html new file mode 100644 index 0000000000..33375b5785 --- /dev/null +++ b/test/server-side-rendering/samples/textarea-value/main.html @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/test/validator/samples/textarea-value-children/errors.json b/test/validator/samples/textarea-value-children/errors.json new file mode 100644 index 0000000000..21282fb93b --- /dev/null +++ b/test/validator/samples/textarea-value-children/errors.json @@ -0,0 +1,8 @@ +[{ + "message": "A can have either a value attribute or (equivalently) child content, but not both", + "loc": { + "line": 1, + "column": 10 + }, + "pos": 10 +}] \ No newline at end of file diff --git a/test/validator/samples/textarea-value-children/input.html b/test/validator/samples/textarea-value-children/input.html new file mode 100644 index 0000000000..70ba6d5a1f --- /dev/null +++ b/test/validator/samples/textarea-value-children/input.html @@ -0,0 +1,3 @@ + + some illegal text + \ No newline at end of file
not actually an element. {{foo}}
not actually an element. " + }, + { + "start": 40, + "end": 47, + "type": "MustacheTag", + "expression": { + "type": "Identifier", + "start": 42, + "end": 45, + "name": "foo" + } + }, + { + "start": 47, + "end": 52, + "type": "Text", + "data": "
not actually an element. 42
not actually an element. 43