svelte/src/generators/nodes/Attribute.ts

666 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import deindent from '../../utils/deindent';
import { stringify } from '../../utils/stringify';
import fixAttributeCasing from '../../utils/fixAttributeCasing';
import getExpressionPrecedence from '../../utils/getExpressionPrecedence';
import addToSet from '../../utils/addToSet';
import { DomGenerator } from '../dom/index';
import Node from './shared/Node';
import Element from './Element';
import Block from '../dom/Block';
import Expression from './shared/Expression';
export interface StyleProp {
key: string;
value: Node[];
}
export default class Attribute extends Node {
type: 'Attribute';
start: number;
end: number;
compiler: DomGenerator;
parent: Element;
name: string;
isTrue: boolean;
isDynamic: boolean;
chunks: Node[];
dependencies: Set<string>;
expression: Node;
constructor(compiler, parent, info) {
super(compiler, parent, info);
this.name = info.name;
this.isTrue = info.value === true;
this.dependencies = new Set();
this.chunks = this.isTrue
? []
: info.value.map(node => {
if (node.type === 'Text') return node;
const expression = new Expression(compiler, this, node.expression);
addToSet(this.dependencies, expression.dependencies);
return expression;
});
this.isDynamic = this.dependencies.size > 0;
}
render(block: Block) {
const node = this.parent;
const name = fixAttributeCasing(this.name);
if (name === 'style') {
const styleProps = optimizeStyle(this.chunks);
if (styleProps) {
this.renderStyle(block, styleProps);
return;
}
}
let metadata = node.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 isLegacyInputType = this.compiler.legacy && name === 'type' && this.parent.name === 'input';
const isDataSet = /^data-/.test(name) && !this.compiler.legacy && !node.namespace;
const camelCaseName = isDataSet ? name.replace('data-', '').replace(/(-\w)/g, function (m) {
return m[1].toUpperCase();
}) : name;
if (this.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.chunks.length === 1) {
// single {tag} — may be a non-string
const expression = this.chunks[0];
const { dependencies, snippet, indexes } = expression;
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.chunks[0].type === 'Text' ? '' : `"" + `) +
this.chunks
.map((chunk: Node) => {
if (chunk.type === 'Text') {
return stringify(chunk.data);
} else {
const { dependencies, snippet, indexes } = chunk;
if (Array.from(indexes).some(index => block.changeableIndexes.get(index))) {
hasChangeableIndex = true;
}
dependencies.forEach(d => {
allDependencies.add(d);
});
return getExpressionPrecedence(chunk) <= 13 ? `(${snippet})` : snippet;
}
})
.join(' + ');
shouldCache = true;
}
const isSelectValueAttribute =
name === 'value' && node.name === 'select';
const last = (shouldCache || isSelectValueAttribute) && block.getUniqueName(
`${node.var}_${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(${node.var}, ${init});`
);
updater = `@setInputType(${node.var}, ${shouldCache ? last : value});`;
} else if (isSelectValueAttribute) {
// annoying special case
const isMultipleSelect = node.getStaticAttributeValue('multiple');
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} < ${node.var}.options.length; ${i} += 1) {
var ${option} = ${node.var}.options[${i}];
${ifStatement}
}
`;
block.builders.hydrate.addBlock(deindent`
${last} = ${value};
${updater}
`);
block.builders.update.addLine(`${last} = ${value};`);
} else if (propertyName) {
block.builders.hydrate.addLine(
`${node.var}.${propertyName} = ${init};`
);
updater = `${node.var}.${propertyName} = ${shouldCache ? last : value};`;
} else if (isDataSet) {
block.builders.hydrate.addLine(
`${node.var}.dataset.${camelCaseName} = ${init};`
);
updater = `${node.var}.dataset.${camelCaseName} = ${shouldCache ? last : value};`;
} else {
block.builders.hydrate.addLine(
`${method}(${node.var}, "${name}", ${init});`
);
updater = `${method}(${node.var}, "${name}", ${shouldCache ? 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.isTrue
? 'true'
: this.chunks.length === 0 ? `""` : stringify(this.chunks[0].data);
const statement = (
isLegacyInputType
? `@setInputType(${node.var}, ${value});`
: propertyName
? `${node.var}.${propertyName} = ${value};`
: isDataSet
? `${node.var}.dataset.${camelCaseName} = ${value};`
: `${method}(${node.var}, "${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 = node.var;
}
}
if (isIndirectlyBoundValue) {
const updateValue = `${node.var}.value = ${node.var}.__value;`;
block.builders.hydrate.addLine(updateValue);
if (this.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 { dependencies, snippet, indexes } = chunk;
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',
'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',
],
},
volume: { appliesTo: ['audio', 'video'] },
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';
}