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