move attribute logic

pull/992/head
Rich Harris 8 years ago
parent e8e05e7160
commit 425d01996f

@ -1,2 +1 @@
--bail
test/test.js

@ -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);
}
}

@ -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';
}

@ -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;

@ -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';
}

@ -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';
}

@ -0,0 +1,7 @@
import Node from './shared/Node';
export default class Binding extends Node {
name: string;
value: Node[]
expression: Node
}

@ -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:
// <option>{{foo}}</option>
// <option value='{{foo}}'>{{foo}}</option>
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 <textarea>
// children treated the same way as a value attribute
if (this.children.length > 0) {
this.attributes.push(new Attribute({
generator: this.generator,
name: 'value',
value: this.children,
parent: this
}));
this.children = [];
}
}
// special case — in a case like this...
//
// <select bind:value='foo'>
@ -209,20 +225,6 @@ export default class Element extends Node {
}
}
if (this.name === 'textarea') {
// this is an egregious hack, but it's the easiest way to get <textarea>
// children treated the same way as a value attribute
if (this.children.length > 0) {
this.attributes.push({
type: 'Attribute',
name: 'value',
value: this.children,
});
this.children = [];
}
}
// insert static children with textContent or innerHTML
if (!childState.namespace && this.canUseInnerHTML && this.children.length > 0) {
if (this.children.length === 1 && this.children[0].type === 'Text') {
@ -242,8 +244,8 @@ export default class Element extends Node {
addBindings(this.generator, block, childState, this);
this.attributes.filter((a: Node) => a.type === 'Attribute').forEach((attribute: Node) => {
visitAttribute(this.generator, block, childState, this, attribute);
this.attributes.filter((a: Attribute) => a.type === 'Attribute').forEach((attribute: Attribute) => {
attribute.render(block, childState);
});
// event handlers
@ -429,7 +431,7 @@ export default class Element extends Node {
getStaticAttributeValue(name: string) {
const attribute = this.attributes.find(
(attr: Node) => attr.name.toLowerCase() === name
(attr: Attribute) => attr.type === 'Attribute' && attr.name.toLowerCase() === name
);
if (!attribute) return null;

@ -0,0 +1,7 @@
import Node from './shared/Node';
export default class EventHandler extends Node {
name: string;
value: Node[]
expression: Node
}

@ -0,0 +1,7 @@
import Node from './shared/Node';
export default class Ref extends Node {
name: string;
value: Node[]
expression: Node
}

@ -0,0 +1,7 @@
import Node from './shared/Node';
export default class Transition extends Node {
name: string;
value: Node[]
expression: Node
}

@ -1,37 +1,47 @@
import Node from './shared/Node';
import Attribute from './Attribute';
import AwaitBlock from './AwaitBlock';
import Binding from './Binding';
import CatchBlock from './CatchBlock';
import Comment from './Comment';
import Component from './Component';
import EachBlock from './EachBlock';
import Element from './Element';
import ElseBlock from './ElseBlock';
import EventHandler from './EventHandler';
import Fragment from './Fragment';
import IfBlock from './IfBlock';
import MustacheTag from './MustacheTag';
import PendingBlock from './PendingBlock';
import RawMustacheTag from './RawMustacheTag';
import Ref from './Ref';
import Slot from './Slot';
import Text from './Text';
import ThenBlock from './ThenBlock';
import Transition from './Transition';
import Window from './Window';
const nodes: Record<string, any> = {
Attribute,
AwaitBlock,
Binding,
CatchBlock,
Comment,
Component,
EachBlock,
Element,
ElseBlock,
EventHandler,
Fragment,
IfBlock,
MustacheTag,
PendingBlock,
RawMustacheTag,
Ref,
Slot,
Text,
ThenBlock,
Transition,
Window
};

Loading…
Cancel
Save