chrome-devtools-frontend
Version:
Chrome DevTools UI
256 lines (240 loc) • 10.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
*/
import {TemplateResult} from './template-result.js';
/**
* An expression marker with embedded unique key to avoid collision with
* possible text in templates.
*/
export const marker = `{{lit-${String(Math.random()).slice(2)}}}`;
/**
* An expression marker used text-positions, multi-binding attributes, and
* attributes with markup-like text values.
*/
export const nodeMarker = `<!--${marker}-->`;
export const markerRegex = new RegExp(`${marker}|${nodeMarker}`);
/**
* Suffix appended to all bound attribute names.
*/
export const boundAttributeSuffix = '$lit$';
/**
* An updatable Template that tracks the location of dynamic parts.
*/
export class Template {
readonly parts: TemplatePart[] = [];
readonly element: HTMLTemplateElement;
constructor(result: TemplateResult, element: HTMLTemplateElement) {
this.element = element;
const nodesToRemove: Node[] = [];
const stack: Node[] = [];
// Edge needs all 4 parameters present; IE11 needs 3rd parameter to be null
const walker = document.createTreeWalker(
element.content,
133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */,
null,
false);
// Keeps track of the last index associated with a part. We try to delete
// unnecessary nodes, but we never want to associate two different parts
// to the same index. They must have a constant node between.
let lastPartIndex = 0;
let index = -1;
let partIndex = 0;
const {strings, values: {length}} = result;
while (partIndex < length) {
const node = walker.nextNode() as Element | Comment | Text | null;
if (node === null) {
// We've exhausted the content inside a nested template element.
// Because we still have parts (the outer for-loop), we know:
// - There is a template in the stack
// - The walker will find a nextNode outside the template
walker.currentNode = stack.pop()!;
continue;
}
index++;
if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
if ((node as Element).hasAttributes()) {
const attributes = (node as Element).attributes;
const {length} = attributes;
// Per
// https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap,
// attributes are not guaranteed to be returned in document order.
// In particular, Edge/IE can return them out of order, so we cannot
// assume a correspondence between part index and attribute index.
let count = 0;
for (let i = 0; i < length; i++) {
if (endsWith(attributes[i].name, boundAttributeSuffix)) {
count++;
}
}
while (count-- > 0) {
// Get the template literal section leading up to the first
// expression in this attribute
const stringForPart = strings[partIndex];
// Find the attribute name
const name = lastAttributeNameRegex.exec(stringForPart)![2];
// Find the corresponding attribute
// All bound attributes have had a suffix added in
// TemplateResult#getHTML to opt out of special attribute
// handling. To look up the attribute value we also need to add
// the suffix.
const attributeLookupName =
name.toLowerCase() + boundAttributeSuffix;
const attributeValue =
(node as Element).getAttribute(attributeLookupName)!;
(node as Element).removeAttribute(attributeLookupName);
const statics = attributeValue.split(markerRegex);
this.parts.push({type: 'attribute', index, name, strings: statics});
partIndex += statics.length - 1;
}
}
if ((node as Element).tagName === 'TEMPLATE') {
stack.push(node);
walker.currentNode = (node as HTMLTemplateElement).content;
}
} else if (node.nodeType === 3 /* Node.TEXT_NODE */) {
const data = (node as Text).data;
if (data.indexOf(marker) >= 0) {
const parent = node.parentNode!;
const strings = data.split(markerRegex);
const lastIndex = strings.length - 1;
// Generate a new text node for each literal section
// These nodes are also used as the markers for node parts
for (let i = 0; i < lastIndex; i++) {
let insert: Node;
let s = strings[i];
if (s === '') {
insert = createMarker();
} else {
const match = lastAttributeNameRegex.exec(s);
if (match !== null && endsWith(match[2], boundAttributeSuffix)) {
s = s.slice(0, match.index) + match[1] +
match[2].slice(0, -boundAttributeSuffix.length) + match[3];
}
insert = document.createTextNode(s);
}
parent.insertBefore(insert, node);
this.parts.push({type: 'node', index: ++index});
}
// If there's no text, we must insert a comment to mark our place.
// Else, we can trust it will stick around after cloning.
if (strings[lastIndex] === '') {
parent.insertBefore(createMarker(), node);
nodesToRemove.push(node);
} else {
(node as Text).data = strings[lastIndex];
}
// We have a part for each match found
partIndex += lastIndex;
}
} else if (node.nodeType === 8 /* Node.COMMENT_NODE */) {
if ((node as Comment).data === marker) {
const parent = node.parentNode!;
// Add a new marker node to be the startNode of the Part if any of
// the following are true:
// * We don't have a previousSibling
// * The previousSibling is already the start of a previous part
if (node.previousSibling === null || index === lastPartIndex) {
index++;
parent.insertBefore(createMarker(), node);
}
lastPartIndex = index;
this.parts.push({type: 'node', index});
// If we don't have a nextSibling, keep this node so we have an end.
// Else, we can remove it to save future costs.
if (node.nextSibling === null) {
(node as Comment).data = '';
} else {
nodesToRemove.push(node);
index--;
}
partIndex++;
} else {
let i = -1;
while ((i = (node as Comment).data.indexOf(marker, i + 1)) !== -1) {
// Comment node has a binding marker inside, make an inactive part
// The binding won't work, but subsequent bindings will
// TODO (justinfagnani): consider whether it's even worth it to
// make bindings in comments work
this.parts.push({type: 'node', index: -1});
partIndex++;
}
}
}
}
// Remove text binding nodes after the walk to not disturb the TreeWalker
for (const n of nodesToRemove) {
n.parentNode!.removeChild(n);
}
}
}
const endsWith = (str: string, suffix: string): boolean => {
const index = str.length - suffix.length;
return index >= 0 && str.slice(index) === suffix;
};
/**
* 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 type TemplatePart = {
readonly type: 'node'; index: number;
}|{
readonly type: 'attribute';
index: number;
readonly name: string;
readonly strings: ReadonlyArray<string>;
};
export const isTemplatePartActive = (part: TemplatePart) => part.index !== -1;
// Allows `document.createComment('')` to be renamed for a
// small manual size-savings.
export const createMarker = () => document.createComment('');
/**
* This regex extracts the attribute name preceding an attribute-position
* expression. It does this by matching the syntax allowed for attributes
* against the string literal directly preceding the expression, assuming that
* the expression is in an attribute-value position.
*
* See attributes in the HTML spec:
* https://www.w3.org/TR/html5/syntax.html#elements-attributes
*
* " \x09\x0a\x0c\x0d" are HTML space characters:
* https://www.w3.org/TR/html5/infrastructure.html#space-characters
*
* "\0-\x1F\x7F-\x9F" are Unicode control characters, which includes every
* space character except " ".
*
* So an attribute is:
* * The name: any character except a control character, space character, ('),
* ("), ">", "=", or "/"
* * Followed by zero or more space characters
* * Followed by "="
* * Followed by zero or more space characters
* * Followed by:
* * Any character except space, ('), ("), "<", ">", "=", (`), or
* * (") then any non-("), or
* * (') then any non-(')
*/
export const lastAttributeNameRegex =
// eslint-disable-next-line no-control-regex
/([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F "'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/;