diff --git a/mocha.opts b/mocha.opts index af6b17a845..427b029758 100644 --- a/mocha.opts +++ b/mocha.opts @@ -1,2 +1 @@ ---bail test/test.js \ No newline at end of file diff --git a/src/generators/dom/visitors/Element/Attribute.ts b/src/generators/dom/visitors/Element/Attribute.ts deleted file mode 100644 index a812b74fce..0000000000 --- a/src/generators/dom/visitors/Element/Attribute.ts +++ /dev/null @@ -1,237 +0,0 @@ -import attributeLookup from './lookup'; -import deindent from '../../../../utils/deindent'; -import visitStyleAttribute, { optimizeStyle } from './StyleAttribute'; -import { stringify } from '../../../../utils/stringify'; -import getExpressionPrecedence from '../../../../utils/getExpressionPrecedence'; -import { DomGenerator } from '../../index'; -import Block from '../../Block'; -import { Node } from '../../../../interfaces'; -import { State } from '../../interfaces'; - -export default function visitAttribute( - generator: DomGenerator, - block: Block, - state: State, - node: Node, - attribute: Node -) { - const name = attribute.name; - - if (name === 'style') { - const styleProps = optimizeStyle(attribute.value); - if (styleProps) { - visitStyleAttribute(generator, block, state, node, attribute, styleProps); - return; - } - } - - let metadata = state.namespace ? null : attributeLookup[name]; - if (metadata && metadata.appliesTo && !~metadata.appliesTo.indexOf(node.name)) - metadata = null; - - const isIndirectlyBoundValue = - name === 'value' && - (node.name === 'option' || // TODO check it's actually bound - (node.name === 'input' && - node.attributes.find( - (attribute: Node) => - attribute.type === 'Binding' && /checked|group/.test(attribute.name) - ))); - - const propertyName = isIndirectlyBoundValue - ? '__value' - : metadata && metadata.propertyName; - - // xlink is a special case... we could maybe extend this to generic - // namespaced attributes but I'm not sure that's applicable in - // HTML5? - const method = name.slice(0, 6) === 'xlink:' - ? '@setXlinkAttribute' - : '@setAttribute'; - - const isDynamic = - (attribute.value !== true && attribute.value.length > 1) || - (attribute.value.length === 1 && attribute.value[0].type !== 'Text'); - - const isLegacyInputType = generator.legacy && name === 'type' && node.name === 'input'; - - const isDataSet = /^data-/.test(name) && !generator.legacy; - const camelCaseName = isDataSet ? name.replace('data-', '').replace(/(-\w)/g, function (m) { - return m[1].toUpperCase(); - }) : name; - - if (isDynamic) { - let value; - - const allDependencies = new Set(); - let shouldCache; - let hasChangeableIndex; - - // TODO some of this code is repeated in Tag.ts — would be good to - // DRY it out if that's possible without introducing crazy indirection - if (attribute.value.length === 1) { - // single {{tag}} — may be a non-string - const { expression } = attribute.value[0]; - const { indexes } = block.contextualise(expression); - const { dependencies, snippet } = attribute.value[0].metadata; - - value = snippet; - dependencies.forEach(d => { - allDependencies.add(d); - }); - - hasChangeableIndex = Array.from(indexes).some(index => block.changeableIndexes.get(index)); - - shouldCache = ( - expression.type !== 'Identifier' || - block.contexts.has(expression.name) || - hasChangeableIndex - ); - } else { - // '{{foo}} {{bar}}' — treat as string concatenation - value = - (attribute.value[0].type === 'Text' ? '' : `"" + `) + - attribute.value - .map((chunk: Node) => { - if (chunk.type === 'Text') { - return stringify(chunk.data); - } else { - const { indexes } = block.contextualise(chunk.expression); - const { dependencies, snippet } = chunk.metadata; - - if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) { - hasChangeableIndex = true; - } - - dependencies.forEach(d => { - allDependencies.add(d); - }); - - return getExpressionPrecedence(chunk.expression) <= 13 ? `(${snippet})` : snippet; - } - }) - .join(' + '); - - shouldCache = true; - } - - const isSelectValueAttribute = - name === 'value' && state.parentNodeName === 'select'; - - const last = (shouldCache || isSelectValueAttribute) && block.getUniqueName( - `${state.parentNode}_${name.replace(/[^a-zA-Z_$]/g, '_')}_value` - ); - - if (shouldCache || isSelectValueAttribute) block.addVariable(last); - - let updater; - const init = shouldCache ? `${last} = ${value}` : value; - - if (isLegacyInputType) { - block.builders.hydrate.addLine( - `@setInputType(${state.parentNode}, ${init});` - ); - updater = `@setInputType(${state.parentNode}, ${shouldCache ? last : value});`; - } else if (isSelectValueAttribute) { - // annoying special case - const isMultipleSelect = - node.name === 'select' && - node.attributes.find( - (attr: Node) => attr.name.toLowerCase() === 'multiple' - ); // TODO use getStaticAttributeValue - const i = block.getUniqueName('i'); - const option = block.getUniqueName('option'); - - const ifStatement = isMultipleSelect - ? deindent` - ${option}.selected = ~${last}.indexOf(${option}.__value);` - : deindent` - if (${option}.__value === ${last}) { - ${option}.selected = true; - break; - }`; - - updater = deindent` - for (var ${i} = 0; ${i} < ${state.parentNode}.options.length; ${i} += 1) { - var ${option} = ${state.parentNode}.options[${i}]; - - ${ifStatement} - } - `; - - block.builders.hydrate.addBlock(deindent` - ${last} = ${value}; - ${updater} - `); - - block.builders.update.addLine(`${last} = ${value};`); - } else if (propertyName) { - block.builders.hydrate.addLine( - `${state.parentNode}.${propertyName} = ${init};` - ); - updater = `${state.parentNode}.${propertyName} = ${shouldCache || isSelectValueAttribute ? last : value};`; - } else if (isDataSet) { - block.builders.hydrate.addLine( - `${state.parentNode}.dataset.${camelCaseName} = ${init};` - ); - updater = `${state.parentNode}.dataset.${camelCaseName} = ${shouldCache || isSelectValueAttribute ? last : value};`; - } else { - block.builders.hydrate.addLine( - `${method}(${state.parentNode}, "${name}", ${init});` - ); - updater = `${method}(${state.parentNode}, "${name}", ${shouldCache || isSelectValueAttribute ? last : value});`; - } - - if (allDependencies.size || hasChangeableIndex || isSelectValueAttribute) { - const dependencies = Array.from(allDependencies); - const changedCheck = ( - ( block.hasOutroMethod ? `#outroing || ` : '' ) + - dependencies.map(dependency => `changed.${dependency}`).join(' || ') - ); - - const updateCachedValue = `${last} !== (${last} = ${value})`; - - const condition = shouldCache ? - ( dependencies.length ? `(${changedCheck}) && ${updateCachedValue}` : updateCachedValue ) : - changedCheck; - - block.builders.update.addConditional( - condition, - updater - ); - } - } else { - const value = attribute.value === true - ? 'true' - : attribute.value.length === 0 - ? `''` - : stringify(attribute.value[0].data); - - const statement = ( - isLegacyInputType ? `@setInputType(${state.parentNode}, ${value});` : - propertyName ? `${state.parentNode}.${propertyName} = ${value};` : - isDataSet ? `${state.parentNode}.dataset.${camelCaseName} = ${value};` : - `${method}(${state.parentNode}, "${name}", ${value});` - ); - - block.builders.hydrate.addLine(statement); - - // special case – autofocus. has to be handled in a bit of a weird way - if (attribute.value === true && name === 'autofocus') { - block.autofocus = state.parentNode; - } - - // special case — xmlns - if (name === 'xmlns') { - // TODO this attribute must be static – enforce at compile time - state.namespace = attribute.value[0].data; - } - } - - if (isIndirectlyBoundValue) { - const updateValue = `${state.parentNode}.value = ${state.parentNode}.__value;`; - - block.builders.hydrate.addLine(updateValue); - if (isDynamic) block.builders.update.addLine(updateValue); - } -} diff --git a/src/generators/dom/visitors/Element/StyleAttribute.ts b/src/generators/dom/visitors/Element/StyleAttribute.ts deleted file mode 100644 index 36bb79e757..0000000000 --- a/src/generators/dom/visitors/Element/StyleAttribute.ts +++ /dev/null @@ -1,189 +0,0 @@ -import attributeLookup from './lookup'; -import deindent from '../../../../utils/deindent'; -import { stringify } from '../../../../utils/stringify'; -import getExpressionPrecedence from '../../../../utils/getExpressionPrecedence'; -import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue'; -import { DomGenerator } from '../../index'; -import Block from '../../Block'; -import { Node } from '../../../../interfaces'; -import { State } from '../../interfaces'; - -export interface StyleProp { - key: string; - value: Node[]; -} - -export default function visitStyleAttribute( - generator: DomGenerator, - block: Block, - state: State, - node: Node, - attribute: Node, - styleProps: StyleProp[] -) { - styleProps.forEach((prop: StyleProp) => { - let value; - - if (isDynamic(prop.value)) { - const allDependencies = new Set(); - let shouldCache; - let hasChangeableIndex; - - value = - ((prop.value.length === 1 || prop.value[0].type === 'Text') ? '' : `"" + `) + - prop.value - .map((chunk: Node) => { - if (chunk.type === 'Text') { - return stringify(chunk.data); - } else { - const { indexes } = block.contextualise(chunk.expression); - const { dependencies, snippet } = chunk.metadata; - - if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) { - hasChangeableIndex = true; - } - - dependencies.forEach(d => { - allDependencies.add(d); - }); - - return getExpressionPrecedence(chunk.expression) <= 13 ? `( ${snippet} )` : snippet; - } - }) - .join(' + '); - - if (allDependencies.size || hasChangeableIndex) { - const dependencies = Array.from(allDependencies); - const condition = ( - ( block.hasOutroMethod ? `#outroing || ` : '' ) + - dependencies.map(dependency => `changed.${dependency}`).join(' || ') - ); - - block.builders.update.addConditional( - condition, - `@setStyle(${node.var}, "${prop.key}", ${value});` - ); - } - } else { - value = stringify(prop.value[0].data); - } - - block.builders.hydrate.addLine( - `@setStyle(${node.var}, "${prop.key}", ${value});` - ); - }); -} - -export function optimizeStyle(value: Node[]) { - let expectingKey = true; - let i = 0; - - const props: { key: string, value: Node[] }[] = []; - let chunks = value.slice(); - - while (chunks.length) { - const chunk = chunks[0]; - - if (chunk.type !== 'Text') return null; - - const keyMatch = /^\s*([\w-]+):\s*/.exec(chunk.data); - if (!keyMatch) return null; - - const key = keyMatch[1]; - - const offset = keyMatch.index + keyMatch[0].length; - const remainingData = chunk.data.slice(offset); - - if (remainingData) { - chunks[0] = { - start: chunk.start + offset, - end: chunk.end, - type: 'Text', - data: remainingData - }; - } else { - chunks.shift(); - } - - const result = getStyleValue(chunks); - if (!result) return null; - - props.push({ key, value: result.value }); - chunks = result.chunks; - } - - return props; -} - -function getStyleValue(chunks: Node[]) { - const value: Node[] = []; - - let inUrl = false; - let quoteMark = null; - let escaped = false; - - while (chunks.length) { - const chunk = chunks.shift(); - - if (chunk.type === 'Text') { - let c = 0; - while (c < chunk.data.length) { - const char = chunk.data[c]; - - if (escaped) { - escaped = false; - } else if (char === '\\') { - escaped = true; - } else if (char === quoteMark) { - quoteMark === null; - } else if (char === '"' || char === "'") { - quoteMark = char; - } else if (char === ')' && inUrl) { - inUrl = false; - } else if (char === 'u' && chunk.data.slice(c, c + 4) === 'url(') { - inUrl = true; - } else if (char === ';' && !inUrl && !quoteMark) { - break; - } - - c += 1; - } - - if (c > 0) { - value.push({ - type: 'Text', - start: chunk.start, - end: chunk.start + c, - data: chunk.data.slice(0, c) - }); - } - - while (/[;\s]/.test(chunk.data[c])) c += 1; - const remainingData = chunk.data.slice(c); - - if (remainingData) { - chunks.unshift({ - start: chunk.start + c, - end: chunk.end, - type: 'Text', - data: remainingData - }); - - break; - } - } - - else { - value.push(chunk); - } - } - - return { - chunks, - value - }; -} - -function isDynamic(value: Node[]) { - return value.length > 1 || value[0].type !== 'Text'; -} \ No newline at end of file diff --git a/src/generators/dom/visitors/Element/lookup.ts b/src/generators/dom/visitors/Element/lookup.ts deleted file mode 100644 index 83e2815ce6..0000000000 --- a/src/generators/dom/visitors/Element/lookup.ts +++ /dev/null @@ -1,236 +0,0 @@ -// source: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes -const lookup = { - accept: { appliesTo: ['form', 'input'] }, - 'accept-charset': { propertyName: 'acceptCharset', appliesTo: ['form'] }, - accesskey: { propertyName: 'accessKey' }, - action: { appliesTo: ['form'] }, - align: { - appliesTo: [ - 'applet', - 'caption', - 'col', - 'colgroup', - 'hr', - 'iframe', - 'img', - 'table', - 'tbody', - 'td', - 'tfoot', - 'th', - 'thead', - 'tr', - ], - }, - allowfullscreen: { propertyName: 'allowFullscreen', appliesTo: ['iframe'] }, - alt: { appliesTo: ['applet', 'area', 'img', 'input'] }, - async: { appliesTo: ['script'] }, - autocomplete: { appliesTo: ['form', 'input'] }, - autofocus: { appliesTo: ['button', 'input', 'keygen', 'select', 'textarea'] }, - autoplay: { appliesTo: ['audio', 'video'] }, - autosave: { appliesTo: ['input'] }, - bgcolor: { - propertyName: 'bgColor', - appliesTo: [ - 'body', - 'col', - 'colgroup', - 'marquee', - 'table', - 'tbody', - 'tfoot', - 'td', - 'th', - 'tr', - ], - }, - border: { appliesTo: ['img', 'object', 'table'] }, - buffered: { appliesTo: ['audio', 'video'] }, - challenge: { appliesTo: ['keygen'] }, - charset: { appliesTo: ['meta', 'script'] }, - checked: { appliesTo: ['command', 'input'] }, - cite: { appliesTo: ['blockquote', 'del', 'ins', 'q'] }, - class: { propertyName: 'className' }, - code: { appliesTo: ['applet'] }, - codebase: { propertyName: 'codeBase', appliesTo: ['applet'] }, - color: { appliesTo: ['basefont', 'font', 'hr'] }, - cols: { appliesTo: ['textarea'] }, - colspan: { propertyName: 'colSpan', appliesTo: ['td', 'th'] }, - content: { appliesTo: ['meta'] }, - contenteditable: { propertyName: 'contentEditable' }, - contextmenu: {}, - controls: { appliesTo: ['audio', 'video'] }, - coords: { appliesTo: ['area'] }, - data: { appliesTo: ['object'] }, - datetime: { propertyName: 'dateTime', appliesTo: ['del', 'ins', 'time'] }, - default: { appliesTo: ['track'] }, - defer: { appliesTo: ['script'] }, - dir: {}, - dirname: { propertyName: 'dirName', appliesTo: ['input', 'textarea'] }, - disabled: { - appliesTo: [ - 'button', - 'command', - 'fieldset', - 'input', - 'keygen', - 'optgroup', - 'option', - 'select', - 'textarea', - ], - }, - download: { appliesTo: ['a', 'area'] }, - draggable: {}, - dropzone: {}, - enctype: { appliesTo: ['form'] }, - for: { propertyName: 'htmlFor', appliesTo: ['label', 'output'] }, - form: { - appliesTo: [ - 'button', - 'fieldset', - 'input', - 'keygen', - 'label', - 'meter', - 'object', - 'output', - 'progress', - 'select', - 'textarea', - ], - }, - formaction: { appliesTo: ['input', 'button'] }, - headers: { appliesTo: ['td', 'th'] }, - height: { - appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], - }, - hidden: {}, - high: { appliesTo: ['meter'] }, - href: { appliesTo: ['a', 'area', 'base', 'link'] }, - hreflang: { appliesTo: ['a', 'area', 'link'] }, - 'http-equiv': { propertyName: 'httpEquiv', appliesTo: ['meta'] }, - icon: { appliesTo: ['command'] }, - id: {}, - indeterminate: { appliesTo: ['input'] }, - ismap: { propertyName: 'isMap', appliesTo: ['img'] }, - itemprop: {}, - keytype: { appliesTo: ['keygen'] }, - kind: { appliesTo: ['track'] }, - label: { appliesTo: ['track'] }, - lang: {}, - language: { appliesTo: ['script'] }, - loop: { appliesTo: ['audio', 'bgsound', 'marquee', 'video'] }, - low: { appliesTo: ['meter'] }, - manifest: { appliesTo: ['html'] }, - max: { appliesTo: ['input', 'meter', 'progress'] }, - maxlength: { propertyName: 'maxLength', appliesTo: ['input', 'textarea'] }, - media: { appliesTo: ['a', 'area', 'link', 'source', 'style'] }, - method: { appliesTo: ['form'] }, - min: { appliesTo: ['input', 'meter'] }, - multiple: { appliesTo: ['input', 'select'] }, - muted: { appliesTo: ['audio', 'video'] }, - name: { - appliesTo: [ - 'button', - 'form', - 'fieldset', - 'iframe', - 'input', - 'keygen', - 'object', - 'output', - 'select', - 'textarea', - 'map', - 'meta', - 'param', - ], - }, - novalidate: { propertyName: 'noValidate', appliesTo: ['form'] }, - open: { appliesTo: ['details'] }, - optimum: { appliesTo: ['meter'] }, - pattern: { appliesTo: ['input'] }, - ping: { appliesTo: ['a', 'area'] }, - placeholder: { appliesTo: ['input', 'textarea'] }, - poster: { appliesTo: ['video'] }, - preload: { appliesTo: ['audio', 'video'] }, - radiogroup: { appliesTo: ['command'] }, - readonly: { propertyName: 'readOnly', appliesTo: ['input', 'textarea'] }, - rel: { appliesTo: ['a', 'area', 'link'] }, - required: { appliesTo: ['input', 'select', 'textarea'] }, - reversed: { appliesTo: ['ol'] }, - rows: { appliesTo: ['textarea'] }, - rowspan: { propertyName: 'rowSpan', appliesTo: ['td', 'th'] }, - sandbox: { appliesTo: ['iframe'] }, - scope: { appliesTo: ['th'] }, - scoped: { appliesTo: ['style'] }, - seamless: { appliesTo: ['iframe'] }, - selected: { appliesTo: ['option'] }, - shape: { appliesTo: ['a', 'area'] }, - size: { appliesTo: ['input', 'select'] }, - sizes: { appliesTo: ['link', 'img', 'source'] }, - span: { appliesTo: ['col', 'colgroup'] }, - spellcheck: {}, - src: { - appliesTo: [ - 'audio', - 'embed', - 'iframe', - 'img', - 'input', - 'script', - 'source', - 'track', - 'video', - ], - }, - srcdoc: { appliesTo: ['iframe'] }, - srclang: { appliesTo: ['track'] }, - srcset: { appliesTo: ['img'] }, - start: { appliesTo: ['ol'] }, - step: { appliesTo: ['input'] }, - style: { propertyName: 'style.cssText' }, - summary: { appliesTo: ['table'] }, - tabindex: { propertyName: 'tabIndex' }, - target: { appliesTo: ['a', 'area', 'base', 'form'] }, - 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', - 'textarea', - ], - }, - width: { - appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], - }, - wrap: { appliesTo: ['textarea'] }, -}; - -Object.keys(lookup).forEach(name => { - const metadata = lookup[name]; - if (!metadata.propertyName) metadata.propertyName = name; -}); - -export default lookup; diff --git a/src/generators/dom/visitors/shared/isDomNode.ts b/src/generators/dom/visitors/shared/isDomNode.ts deleted file mode 100644 index 18304e3ff1..0000000000 --- a/src/generators/dom/visitors/shared/isDomNode.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DomGenerator } from '../../index'; -import { Node } from '../../../../interfaces'; - -export default function isDomNode(node: Node, generator: DomGenerator) { - if (node.type === 'Element') return !generator.components.has(node.name); - return node.type === 'Text' || node.type === 'MustacheTag'; -} \ No newline at end of file diff --git a/src/generators/nodes/Attribute.ts b/src/generators/nodes/Attribute.ts index daaa9e6b7f..54a37571a2 100644 --- a/src/generators/nodes/Attribute.ts +++ b/src/generators/nodes/Attribute.ts @@ -1,7 +1,676 @@ +import deindent from '../../utils/deindent'; +import { stringify } from '../../utils/stringify'; +import getExpressionPrecedence from '../../utils/getExpressionPrecedence'; +import { DomGenerator } from '../dom/index'; import Node from './shared/Node'; +import Element from './Element'; +import Block from '../dom/Block'; +import State from '../dom/State'; -export default class Attribute extends Node { +export interface StyleProp { + key: string; + value: Node[]; +} + +export default class Attribute { + type: 'Attribute'; + start: number; + end: number; + + generator: DomGenerator; + parent: Element; name: string; value: true | Node[] - expression: Node + expression: Node; + + constructor({ + generator, + name, + value, + parent + }: { + generator: DomGenerator, + name: string, + value: Node[], + parent: Element + }) { + this.type = 'Attribute'; + this.generator = generator; + this.parent = parent; + + this.name = name; + this.value = value; + } + + render( + block: Block, + state: State + ) { + const node = this.parent; + const name = this.name; + + if (name === 'style') { + const styleProps = optimizeStyle(this.value); + if (styleProps) { + this.renderStyle(block, styleProps); + return; + } + } + + let metadata = state.namespace ? null : attributeLookup[name]; + if (metadata && metadata.appliesTo && !~metadata.appliesTo.indexOf(node.name)) + metadata = null; + + const isIndirectlyBoundValue = + name === 'value' && + (node.name === 'option' || // TODO check it's actually bound + (node.name === 'input' && + node.attributes.find( + (attribute: Attribute) => + attribute.type === 'Binding' && /checked|group/.test(attribute.name) + ))); + + const propertyName = isIndirectlyBoundValue + ? '__value' + : metadata && metadata.propertyName; + + // xlink is a special case... we could maybe extend this to generic + // namespaced attributes but I'm not sure that's applicable in + // HTML5? + const method = name.slice(0, 6) === 'xlink:' + ? '@setXlinkAttribute' + : '@setAttribute'; + + const isDynamic = + (this.value !== true && this.value.length > 1) || + (this.value.length === 1 && this.value[0].type !== 'Text'); + + const isLegacyInputType = this.generator.legacy && name === 'type' && this.parent.name === 'input'; + + const isDataSet = /^data-/.test(name) && !this.generator.legacy; + const camelCaseName = isDataSet ? name.replace('data-', '').replace(/(-\w)/g, function (m) { + return m[1].toUpperCase(); + }) : name; + + if (isDynamic) { + let value; + + const allDependencies = new Set(); + let shouldCache; + let hasChangeableIndex; + + // TODO some of this code is repeated in Tag.ts — would be good to + // DRY it out if that's possible without introducing crazy indirection + if (this.value.length === 1) { + // single {{tag}} — may be a non-string + const { expression } = this.value[0]; + const { indexes } = block.contextualise(expression); + const { dependencies, snippet } = this.value[0].metadata; + + value = snippet; + dependencies.forEach(d => { + allDependencies.add(d); + }); + + hasChangeableIndex = Array.from(indexes).some(index => block.changeableIndexes.get(index)); + + shouldCache = ( + expression.type !== 'Identifier' || + block.contexts.has(expression.name) || + hasChangeableIndex + ); + } else { + // '{{foo}} {{bar}}' — treat as string concatenation + value = + (this.value[0].type === 'Text' ? '' : `"" + `) + + this.value + .map((chunk: Node) => { + if (chunk.type === 'Text') { + return stringify(chunk.data); + } else { + const { indexes } = block.contextualise(chunk.expression); + const { dependencies, snippet } = chunk.metadata; + + if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) { + hasChangeableIndex = true; + } + + dependencies.forEach(d => { + allDependencies.add(d); + }); + + return getExpressionPrecedence(chunk.expression) <= 13 ? `(${snippet})` : snippet; + } + }) + .join(' + '); + + shouldCache = true; + } + + const isSelectValueAttribute = + name === 'value' && state.parentNodeName === 'select'; + + const last = (shouldCache || isSelectValueAttribute) && block.getUniqueName( + `${state.parentNode}_${name.replace(/[^a-zA-Z_$]/g, '_')}_value` + ); + + if (shouldCache || isSelectValueAttribute) block.addVariable(last); + + let updater; + const init = shouldCache ? `${last} = ${value}` : value; + + if (isLegacyInputType) { + block.builders.hydrate.addLine( + `@setInputType(${state.parentNode}, ${init});` + ); + updater = `@setInputType(${state.parentNode}, ${shouldCache ? last : value});`; + } else if (isSelectValueAttribute) { + // annoying special case + const isMultipleSelect = + node.name === 'select' && + node.attributes.find( + (attr: Attribute) => attr.name.toLowerCase() === 'multiple' + ); // TODO use getStaticAttributeValue + const i = block.getUniqueName('i'); + const option = block.getUniqueName('option'); + + const ifStatement = isMultipleSelect + ? deindent` + ${option}.selected = ~${last}.indexOf(${option}.__value);` + : deindent` + if (${option}.__value === ${last}) { + ${option}.selected = true; + break; + }`; + + updater = deindent` + for (var ${i} = 0; ${i} < ${state.parentNode}.options.length; ${i} += 1) { + var ${option} = ${state.parentNode}.options[${i}]; + + ${ifStatement} + } + `; + + block.builders.hydrate.addBlock(deindent` + ${last} = ${value}; + ${updater} + `); + + block.builders.update.addLine(`${last} = ${value};`); + } else if (propertyName) { + block.builders.hydrate.addLine( + `${state.parentNode}.${propertyName} = ${init};` + ); + updater = `${state.parentNode}.${propertyName} = ${shouldCache || isSelectValueAttribute ? last : value};`; + } else if (isDataSet) { + block.builders.hydrate.addLine( + `${state.parentNode}.dataset.${camelCaseName} = ${init};` + ); + updater = `${state.parentNode}.dataset.${camelCaseName} = ${shouldCache || isSelectValueAttribute ? last : value};`; + } else { + block.builders.hydrate.addLine( + `${method}(${state.parentNode}, "${name}", ${init});` + ); + updater = `${method}(${state.parentNode}, "${name}", ${shouldCache || isSelectValueAttribute ? last : value});`; + } + + if (allDependencies.size || hasChangeableIndex || isSelectValueAttribute) { + const dependencies = Array.from(allDependencies); + const changedCheck = ( + ( block.hasOutroMethod ? `#outroing || ` : '' ) + + dependencies.map(dependency => `changed.${dependency}`).join(' || ') + ); + + const updateCachedValue = `${last} !== (${last} = ${value})`; + + const condition = shouldCache ? + ( dependencies.length ? `(${changedCheck}) && ${updateCachedValue}` : updateCachedValue ) : + changedCheck; + + block.builders.update.addConditional( + condition, + updater + ); + } + } else { + const value = this.value === true + ? 'true' + : this.value.length === 0 + ? `''` + : stringify(this.value[0].data); + + const statement = ( + isLegacyInputType ? `@setInputType(${state.parentNode}, ${value});` : + propertyName ? `${state.parentNode}.${propertyName} = ${value};` : + isDataSet ? `${state.parentNode}.dataset.${camelCaseName} = ${value};` : + `${method}(${state.parentNode}, "${name}", ${value});` + ); + + block.builders.hydrate.addLine(statement); + + // special case – autofocus. has to be handled in a bit of a weird way + if (this.value === true && name === 'autofocus') { + block.autofocus = state.parentNode; + } + + // special case — xmlns + if (name === 'xmlns') { + // TODO this attribute must be static – enforce at compile time + state.namespace = this.value[0].data; + } + } + + if (isIndirectlyBoundValue) { + const updateValue = `${state.parentNode}.value = ${state.parentNode}.__value;`; + + block.builders.hydrate.addLine(updateValue); + if (isDynamic) block.builders.update.addLine(updateValue); + } + } + + renderStyle( + block: Block, + styleProps: StyleProp[] + ) { + styleProps.forEach((prop: StyleProp) => { + let value; + + if (isDynamic(prop.value)) { + const allDependencies = new Set(); + let shouldCache; + let hasChangeableIndex; + + value = + ((prop.value.length === 1 || prop.value[0].type === 'Text') ? '' : `"" + `) + + prop.value + .map((chunk: Node) => { + if (chunk.type === 'Text') { + return stringify(chunk.data); + } else { + const { indexes } = block.contextualise(chunk.expression); + const { dependencies, snippet } = chunk.metadata; + + if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) { + hasChangeableIndex = true; + } + + dependencies.forEach(d => { + allDependencies.add(d); + }); + + return getExpressionPrecedence(chunk.expression) <= 13 ? `( ${snippet} )` : snippet; + } + }) + .join(' + '); + + if (allDependencies.size || hasChangeableIndex) { + const dependencies = Array.from(allDependencies); + const condition = ( + ( block.hasOutroMethod ? `#outroing || ` : '' ) + + dependencies.map(dependency => `changed.${dependency}`).join(' || ') + ); + + block.builders.update.addConditional( + condition, + `@setStyle(${this.parent.var}, "${prop.key}", ${value});` + ); + } + } else { + value = stringify(prop.value[0].data); + } + + block.builders.hydrate.addLine( + `@setStyle(${this.parent.var}, "${prop.key}", ${value});` + ); + }); + } +} + +// source: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes +const attributeLookup = { + accept: { appliesTo: ['form', 'input'] }, + 'accept-charset': { propertyName: 'acceptCharset', appliesTo: ['form'] }, + accesskey: { propertyName: 'accessKey' }, + action: { appliesTo: ['form'] }, + align: { + appliesTo: [ + 'applet', + 'caption', + 'col', + 'colgroup', + 'hr', + 'iframe', + 'img', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + ], + }, + allowfullscreen: { propertyName: 'allowFullscreen', appliesTo: ['iframe'] }, + alt: { appliesTo: ['applet', 'area', 'img', 'input'] }, + async: { appliesTo: ['script'] }, + autocomplete: { appliesTo: ['form', 'input'] }, + autofocus: { appliesTo: ['button', 'input', 'keygen', 'select', 'textarea'] }, + autoplay: { appliesTo: ['audio', 'video'] }, + autosave: { appliesTo: ['input'] }, + bgcolor: { + propertyName: 'bgColor', + appliesTo: [ + 'body', + 'col', + 'colgroup', + 'marquee', + 'table', + 'tbody', + 'tfoot', + 'td', + 'th', + 'tr', + ], + }, + border: { appliesTo: ['img', 'object', 'table'] }, + buffered: { appliesTo: ['audio', 'video'] }, + challenge: { appliesTo: ['keygen'] }, + charset: { appliesTo: ['meta', 'script'] }, + checked: { appliesTo: ['command', 'input'] }, + cite: { appliesTo: ['blockquote', 'del', 'ins', 'q'] }, + class: { propertyName: 'className' }, + code: { appliesTo: ['applet'] }, + codebase: { propertyName: 'codeBase', appliesTo: ['applet'] }, + color: { appliesTo: ['basefont', 'font', 'hr'] }, + cols: { appliesTo: ['textarea'] }, + colspan: { propertyName: 'colSpan', appliesTo: ['td', 'th'] }, + content: { appliesTo: ['meta'] }, + contenteditable: { propertyName: 'contentEditable' }, + contextmenu: {}, + controls: { appliesTo: ['audio', 'video'] }, + coords: { appliesTo: ['area'] }, + data: { appliesTo: ['object'] }, + datetime: { propertyName: 'dateTime', appliesTo: ['del', 'ins', 'time'] }, + default: { appliesTo: ['track'] }, + defer: { appliesTo: ['script'] }, + dir: {}, + dirname: { propertyName: 'dirName', appliesTo: ['input', 'textarea'] }, + disabled: { + appliesTo: [ + 'button', + 'command', + 'fieldset', + 'input', + 'keygen', + 'optgroup', + 'option', + 'select', + 'textarea', + ], + }, + download: { appliesTo: ['a', 'area'] }, + draggable: {}, + dropzone: {}, + enctype: { appliesTo: ['form'] }, + for: { propertyName: 'htmlFor', appliesTo: ['label', 'output'] }, + form: { + appliesTo: [ + 'button', + 'fieldset', + 'input', + 'keygen', + 'label', + 'meter', + 'object', + 'output', + 'progress', + 'select', + 'textarea', + ], + }, + formaction: { appliesTo: ['input', 'button'] }, + headers: { appliesTo: ['td', 'th'] }, + height: { + appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], + }, + hidden: {}, + high: { appliesTo: ['meter'] }, + href: { appliesTo: ['a', 'area', 'base', 'link'] }, + hreflang: { appliesTo: ['a', 'area', 'link'] }, + 'http-equiv': { propertyName: 'httpEquiv', appliesTo: ['meta'] }, + icon: { appliesTo: ['command'] }, + id: {}, + indeterminate: { appliesTo: ['input'] }, + ismap: { propertyName: 'isMap', appliesTo: ['img'] }, + itemprop: {}, + keytype: { appliesTo: ['keygen'] }, + kind: { appliesTo: ['track'] }, + label: { appliesTo: ['track'] }, + lang: {}, + language: { appliesTo: ['script'] }, + loop: { appliesTo: ['audio', 'bgsound', 'marquee', 'video'] }, + low: { appliesTo: ['meter'] }, + manifest: { appliesTo: ['html'] }, + max: { appliesTo: ['input', 'meter', 'progress'] }, + maxlength: { propertyName: 'maxLength', appliesTo: ['input', 'textarea'] }, + media: { appliesTo: ['a', 'area', 'link', 'source', 'style'] }, + method: { appliesTo: ['form'] }, + min: { appliesTo: ['input', 'meter'] }, + multiple: { appliesTo: ['input', 'select'] }, + muted: { appliesTo: ['audio', 'video'] }, + name: { + appliesTo: [ + 'button', + 'form', + 'fieldset', + 'iframe', + 'input', + 'keygen', + 'object', + 'output', + 'select', + 'textarea', + 'map', + 'meta', + 'param', + ], + }, + novalidate: { propertyName: 'noValidate', appliesTo: ['form'] }, + open: { appliesTo: ['details'] }, + optimum: { appliesTo: ['meter'] }, + pattern: { appliesTo: ['input'] }, + ping: { appliesTo: ['a', 'area'] }, + placeholder: { appliesTo: ['input', 'textarea'] }, + poster: { appliesTo: ['video'] }, + preload: { appliesTo: ['audio', 'video'] }, + radiogroup: { appliesTo: ['command'] }, + readonly: { propertyName: 'readOnly', appliesTo: ['input', 'textarea'] }, + rel: { appliesTo: ['a', 'area', 'link'] }, + required: { appliesTo: ['input', 'select', 'textarea'] }, + reversed: { appliesTo: ['ol'] }, + rows: { appliesTo: ['textarea'] }, + rowspan: { propertyName: 'rowSpan', appliesTo: ['td', 'th'] }, + sandbox: { appliesTo: ['iframe'] }, + scope: { appliesTo: ['th'] }, + scoped: { appliesTo: ['style'] }, + seamless: { appliesTo: ['iframe'] }, + selected: { appliesTo: ['option'] }, + shape: { appliesTo: ['a', 'area'] }, + size: { appliesTo: ['input', 'select'] }, + sizes: { appliesTo: ['link', 'img', 'source'] }, + span: { appliesTo: ['col', 'colgroup'] }, + spellcheck: {}, + src: { + appliesTo: [ + 'audio', + 'embed', + 'iframe', + 'img', + 'input', + 'script', + 'source', + 'track', + 'video', + ], + }, + srcdoc: { appliesTo: ['iframe'] }, + srclang: { appliesTo: ['track'] }, + srcset: { appliesTo: ['img'] }, + start: { appliesTo: ['ol'] }, + step: { appliesTo: ['input'] }, + style: { propertyName: 'style.cssText' }, + summary: { appliesTo: ['table'] }, + tabindex: { propertyName: 'tabIndex' }, + target: { appliesTo: ['a', 'area', 'base', 'form'] }, + 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', + 'textarea', + ], + }, + width: { + appliesTo: ['canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video'], + }, + wrap: { appliesTo: ['textarea'] }, +}; + +Object.keys(attributeLookup).forEach(name => { + const metadata = attributeLookup[name]; + if (!metadata.propertyName) metadata.propertyName = name; +}); + +function optimizeStyle(value: Node[]) { + let expectingKey = true; + let i = 0; + + const props: { key: string, value: Node[] }[] = []; + let chunks = value.slice(); + + while (chunks.length) { + const chunk = chunks[0]; + + if (chunk.type !== 'Text') return null; + + const keyMatch = /^\s*([\w-]+):\s*/.exec(chunk.data); + if (!keyMatch) return null; + + const key = keyMatch[1]; + + const offset = keyMatch.index + keyMatch[0].length; + const remainingData = chunk.data.slice(offset); + + if (remainingData) { + chunks[0] = { + start: chunk.start + offset, + end: chunk.end, + type: 'Text', + data: remainingData + }; + } else { + chunks.shift(); + } + + const result = getStyleValue(chunks); + if (!result) return null; + + props.push({ key, value: result.value }); + chunks = result.chunks; + } + + return props; +} + +function getStyleValue(chunks: Node[]) { + const value: Node[] = []; + + let inUrl = false; + let quoteMark = null; + let escaped = false; + + while (chunks.length) { + const chunk = chunks.shift(); + + if (chunk.type === 'Text') { + let c = 0; + while (c < chunk.data.length) { + const char = chunk.data[c]; + + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quoteMark) { + quoteMark === null; + } else if (char === '"' || char === "'") { + quoteMark = char; + } else if (char === ')' && inUrl) { + inUrl = false; + } else if (char === 'u' && chunk.data.slice(c, c + 4) === 'url(') { + inUrl = true; + } else if (char === ';' && !inUrl && !quoteMark) { + break; + } + + c += 1; + } + + if (c > 0) { + value.push({ + type: 'Text', + start: chunk.start, + end: chunk.start + c, + data: chunk.data.slice(0, c) + }); + } + + while (/[;\s]/.test(chunk.data[c])) c += 1; + const remainingData = chunk.data.slice(c); + + if (remainingData) { + chunks.unshift({ + start: chunk.start + c, + end: chunk.end, + type: 'Text', + data: remainingData + }); + + break; + } + } + + else { + value.push(chunk); + } + } + + return { + chunks, + value + }; +} + +function isDynamic(value: Node[]) { + return value.length > 1 || value[0].type !== 'Text'; } \ No newline at end of file diff --git a/src/generators/nodes/Binding.ts b/src/generators/nodes/Binding.ts new file mode 100644 index 0000000000..816bfd3cb0 --- /dev/null +++ b/src/generators/nodes/Binding.ts @@ -0,0 +1,7 @@ +import Node from './shared/Node'; + +export default class Binding extends Node { + name: string; + value: Node[] + expression: Node +} \ No newline at end of file diff --git a/src/generators/nodes/Element.ts b/src/generators/nodes/Element.ts index 7a087626f6..db78c7675a 100644 --- a/src/generators/nodes/Element.ts +++ b/src/generators/nodes/Element.ts @@ -78,19 +78,35 @@ export default class Element extends Node { } }); - const valueAttribute = this.attributes.find((attribute: Node) => attribute.name === 'value'); + const valueAttribute = this.attributes.find((attribute: Attribute) => attribute.name === 'value'); // Treat these the same way: // // if (this.name === 'option' && !valueAttribute) { this.attributes.push(new Attribute({ - type: 'Attribute', + generator: this.generator, name: 'value', - value: this.children + value: this.children, + parent: this })); } + if (this.name === 'textarea') { + // this is an egregious hack, but it's the easiest way to get