lit-html
Version:
HTML template literals in JavaScript
425 lines (378 loc) • 13.3 kB
text/typescript
/**
* @license
* Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
* This code may only be used under the BSD style license found at
* http://polymer.github.io/LICENSE.txt
* The complete set of authors may be found at
* http://polymer.github.io/AUTHORS.txt
* The complete set of contributors may be found at
* http://polymer.github.io/CONTRIBUTORS.txt
* Code distributed by Google as part of the polymer project is also
* subject to an additional IP rights grant found at
* http://polymer.github.io/PATENTS.txt
*/
// The first argument to JS template tags retain identity across multiple
// calls to a tag for the same literal, so we can cache work done per literal
// in a Map.
const templates = new Map<TemplateStringsArray, Template>();
/**
* Interprets a template literal as an HTML template that can efficiently
* render to and update a container.
*/
export function html(strings: TemplateStringsArray, ...values: any[]): TemplateResult {
let template = templates.get(strings);
if (template === undefined) {
template = new Template(strings);
templates.set(strings, template);
}
return new TemplateResult(template, values);
}
/**
* The return type of `html`, which holds a Template and the values from
* interpolated expressions.
*/
export class TemplateResult {
template: Template;
values: any[];
constructor(template: Template, values: any[]) {
this.template = template;
this.values = values;
}
/**
* Renders this template to a container. To update a container with new values,
* reevaluate the template literal and call `renderTo` of the new result.
*/
renderTo(container: Element|DocumentFragment) {
let instance = container.__templateInstance as TemplateInstance;
if (instance === undefined) {
instance = new TemplateInstance(this.template);
container.__templateInstance = instance;
const fragment = instance._clone();
instance.update(this.values);
container.appendChild(fragment);
} else {
instance.update(this.values);
}
}
}
const exprMarker = '{{}}';
/**
* A placeholder for a dynamic expression in an HTML template.
*
* There are two built-in part types: AttributePart and NodePart. NodeParts
* always represent a single dynamic expression, while AttributeParts may
* represent as many expressions are contained in the attribute.
*
* A Template's parts are mutable, so parts can be replaced or modified
* (possibly to implement different template semantics). The contract is that
* parts can only be replaced, not removed, added or reordered, and parts must
* always consume the correct number of values in their `update()` method.
*
* TODO(justinfagnani): That requirement is a little fragile. A
* TemplateInstance could instead be more careful about which values it gives
* to Part.update().
*/
export class TemplatePart {
constructor(
public type: string,
public index: number,
public name?: string,
public rawName?: string,
public strings?: string[]) {
}
}
export class Template {
private _strings: TemplateStringsArray;
parts: TemplatePart[] = [];
element: HTMLTemplateElement;
constructor(strings: TemplateStringsArray) {
this._strings = strings;
this._parse();
}
private _parse() {
this.element = document.createElement('template');
this.element.innerHTML = this._getTemplateHtml(this._strings);
const walker = document.createTreeWalker(this.element.content,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
let index = -1;
let partIndex = 0;
const nodesToRemove = [];
const attributesToRemove = [];
while (walker.nextNode()) {
index++;
const node = walker.currentNode;
if (node.nodeType === Node.ELEMENT_NODE) {
const attributes = node.attributes;
for (let i = 0; i < attributes.length; i++) {
const attribute = attributes.item(i);
const value = attribute.value;
const strings = value.split(exprMarker);
if (strings.length > 1) {
const attributeString = this._strings[partIndex];
// Trim the trailing literal value if this is an interpolation
const rawNameString = attributeString.substring(0, attributeString.length - strings[0].length);
const match = rawNameString.match(/((?:\w|[.\-_$])+)=["']?$/);
const rawName = match![1];
this.parts.push(new TemplatePart('attribute', index, attribute.name, rawName, strings));
attributesToRemove.push(attribute);
partIndex += strings.length - 1;
}
}
} else if (node.nodeType === Node.TEXT_NODE) {
const strings = node.nodeValue!.split(exprMarker);
if (strings.length > 1) {
// Generate a new text node for each literal and two for each part,
// a start and end
partIndex += strings.length - 1;
for (let i = 0; i < strings.length; i++) {
const string = strings[i];
const literalNode = new Text(string);
node.parentNode!.insertBefore(literalNode, node);
index++;
if (i < strings.length - 1) {
node.parentNode!.insertBefore(new Text(), node);
node.parentNode!.insertBefore(new Text(), node);
this.parts.push(new TemplatePart('node', index));
index += 2;
}
}
index--;
nodesToRemove.push(node);
} else if (!node.nodeValue!.trim()) {
nodesToRemove.push(node);
index--;
}
}
}
// Remove text binding nodes after the walk to not disturb the TreeWalker
for (const n of nodesToRemove) {
n.parentNode!.removeChild(n);
}
for (const a of attributesToRemove) {
a.ownerElement.removeAttribute(a.name);
}
}
private _getTemplateHtml(strings: TemplateStringsArray): string {
const parts = [];
for (let i = 0; i < strings.length; i++) {
parts.push(strings[i]);
if (i < strings.length - 1) {
parts.push(exprMarker);
}
}
return parts.join('');
}
}
export abstract class Part {
instance: TemplateInstance
size?: number;
constructor(instance: TemplateInstance) {
this.instance = instance;
}
abstract setValue(value: any): void;
protected _getValue(value: any) {
if (typeof value === 'function') {
try {
value = value(this);
} catch (e) {
console.error(e);
return;
}
}
if (value === null) {
// `null` as the value of Text node will render the string 'null'
return undefined;
}
return value;
}
}
export class AttributePart extends Part {
element: Element;
name: string;
strings: string[];
constructor(instance: TemplateInstance, element: Element, name: string, strings: string[]) {
super(instance);
console.assert(element.nodeType === Node.ELEMENT_NODE);
this.element = element;
this.name = name;
this.strings = strings;
}
setValue(values: any[]): void {
const strings = this.strings;
let text = '';
for (let i = 0; i < strings.length; i++) {
text += strings[i];
if (i < strings.length - 1) {
const v = this._getValue(values[i]);
if (v && typeof v !== 'string' && v[Symbol.iterator]) {
for (const t of v) {
// TODO: we need to recursively call getValue into iterables...
text += t;
}
} else {
text += v;
}
}
}
this.element.setAttribute(this.name, text);
}
get size(): number {
return this.strings.length - 1;
}
}
export class NodePart extends Part {
startNode: Node;
endNode: Node;
private _previousValue: any;
constructor(instance: TemplateInstance, startNode: Node, endNode: Node) {
super(instance);
this.startNode = startNode;
this.endNode = endNode;
}
setValue(value: any): void {
let node: Node|undefined = undefined;
value = this._getValue(value);
if (value instanceof Node) {
this.clear();
node = value;
} else if (value instanceof TemplateResult) {
let instance: TemplateInstance;
if (this._previousValue && this._previousValue._template === value.template) {
instance = this._previousValue;
} else {
this.clear();
instance = this.instance._createInstance(value.template);
node = instance._clone();
}
instance.update(value.values);
this._previousValue = instance;
} else if (value && typeof value !== 'string' && value[Symbol.iterator]) {
// For an Iterable, we create a new InstancePart per item, then set its
// value to the item. This is a little bit of overhead for every item in
// an Iterable, but it lets us recurse easily and update Arrays of
// TemplateResults that will be commonly returned from expressions like:
// array.map((i) => html`${i}`)
// We reuse this parts startNode as the first part's startNode, and this
// parts endNode as the last part's endNode.
let itemStart = this.startNode;
let itemEnd;
const values = value[Symbol.iterator]() as Iterator<any>;
const previousParts: NodePart[]|undefined = Array.isArray(this._previousValue) ? this._previousValue : undefined;
let previousPartsIndex = 0;
const itemParts = [];
let current = values.next();
let next = values.next();
if (current.done) {
// Empty iterable, just clear
this.clear();
}
while (!current.done) {
// Reuse a previous part if we can, otherwise create a new one
let itemPart: NodePart;
if (previousParts !== undefined && previousPartsIndex < previousParts.length) {
itemPart = previousParts[previousPartsIndex++];
if (next.done && itemPart.endNode !== this.endNode) {
// Since this is the last part we'll use, set it's endNode to the
// container's endNode. Setting the value of this part will clean
// up any residual nodes from a previously longer iterable.
const range = document.createRange();
range.setStartBefore(itemPart.endNode);
range.setEndBefore(this.endNode);
range.deleteContents();
range.detach();
itemPart.endNode = this.endNode;
}
itemEnd = itemPart.endNode;
} else {
if (next.done) {
// on the last item, reuse this part's endNode
itemEnd = this.endNode;
} else {
itemEnd = new Text();
this.endNode.parentNode!.insertBefore(itemEnd, this.endNode);
}
itemPart = new NodePart(this.instance, itemStart, itemEnd);
}
itemPart.setValue(current.value);
itemParts.push(itemPart);
current = next;
next = values.next();
itemStart = itemEnd;
}
this._previousValue = itemParts;
} else {
this.clear();
node = new Text(value);
}
if (node !== undefined) {
this.endNode.parentNode!.insertBefore(node, this.endNode);
}
}
clear() {
this._previousValue = undefined;
const range = document.createRange();
range.setStartAfter(this.startNode);
range.setEndBefore(this.endNode);
range.deleteContents();
range.detach();
}
}
export class TemplateInstance {
_template: Template;
_parts: Part[] = [];
startNode: Node;
endNode: Node;
constructor(template: Template) {
this._template = template;
}
update(values: any[]) {
let valueIndex = 0;
for (const part of this._parts) {
if (part.size === undefined) {
part.setValue(values[valueIndex++]);
} else {
part.setValue(values.slice(valueIndex, valueIndex + part.size));
valueIndex += part.size;
}
}
}
_clone(): DocumentFragment {
const fragment = document.importNode(this._template.element.content, true);
if (this._template.parts.length > 0) {
const walker = document.createTreeWalker(fragment,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
const parts = this._template.parts;
let index = 0;
let partIndex = 0;
let templatePart = parts[0];
let node = walker.nextNode();
while (node != null && partIndex < parts.length) {
if (index === templatePart.index) {
this._parts.push(this._createPart(templatePart, node));
templatePart = parts[++partIndex];
} else {
index++;
node = walker.nextNode();
}
}
}
return fragment;
}
_createPart(templatePart: TemplatePart, node: Node): Part {
if (templatePart.type === 'attribute') {
return new AttributePart(this, node as Element, templatePart.name!, templatePart.strings!);
} else if (templatePart.type === 'node') {
return new NodePart(this, node, node.nextSibling!);
} else {
throw new Error(`unknown part type: ${templatePart.type}`);
}
}
_createInstance(template: Template) {
return new TemplateInstance(template);
}
}
declare global {
interface Node {
__templateInstance?: TemplateInstance;
}
}