UNPKG

lit-html

Version:

HTML template literals in JavaScript

425 lines (378 loc) 13.3 kB
/** * @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; } }