UNPKG

@surface/custom-element

Version:

Provides support of directives and data binding on custom elements.

534 lines (533 loc) 25.3 kB
/* eslint-disable @typescript-eslint/prefer-for-of */ /* eslint-disable max-lines-per-function */ /* eslint-disable max-statements */ /* eslint-disable @typescript-eslint/indent */ import { assert, contains, dashedToCamel, typeGuard } from "@surface/core"; import Expression, { SyntaxError, TypeGuard } from "@surface/expression"; import { scapeBrackets, throwTemplateParseError } from "../common.js"; import ObserverVisitor from "../reactivity/observer-visitor.js"; import { parseDestructuredPattern, parseExpression, parseForLoopStatement, parseInterpolation } from "./expression-parsers.js"; import nativeEvents from "./native-events.js"; import { interpolation } from "./patterns.js"; const DECOMPOSED = Symbol("custom-element:decomposed"); const DIRECTIVE = Symbol("custom-element:directive"); var DirectiveType; (function (DirectiveType) { DirectiveType["If"] = "#if"; DirectiveType["ElseIf"] = "#else-if"; DirectiveType["Else"] = "#else"; DirectiveType["For"] = "#for"; DirectiveType["Inject"] = "#inject"; DirectiveType["InjectKey"] = "#inject-key"; DirectiveType["Placeholder"] = "#placeholder"; DirectiveType["PlaceholderKey"] = "#placeholder-key"; })(DirectiveType || (DirectiveType = {})); const directiveTypes = Object.values(DirectiveType); export default class TemplateParser { constructor(name, stackTrace) { this.indexStack = []; this.templateDescriptor = { directives: { injections: [], logicals: [], loops: [], placeholders: [], }, elements: [], lookup: [], }; this.offsetIndex = 0; this.name = name; this.stackTrace = stackTrace ? [...stackTrace] : [[`<${name}>`], ["#shadow-root"]]; } static internalParse(name, template, stackTrace) { return new TemplateParser(name, stackTrace).parse(template); } static parse(name, template) { const templateElement = document.createElement("template"); templateElement.innerHTML = template; const descriptor = new TemplateParser(name).parse(templateElement); return [templateElement, descriptor]; } static parseReference(name, template) { return new TemplateParser(name).parse(template); } attributeToString(attribute) { return !attribute.value ? attribute.name : `${attribute.name}="${attribute.value}"`; } decomposeDirectives(element) { if (!this.hasDecomposed(element)) { const template = this.elementToTemplate(element); const [directive, ...directives] = this.enumerateDirectives(template.attributes); template[DIRECTIVE] = directive; if (directives.length > 0) { const innerTemplate = template.cloneNode(false); directives.forEach(x => template.removeAttribute(x.name)); innerTemplate.removeAttribute(directive.name); innerTemplate.removeAttribute(`${directive.name}-key`); this.nest(template, innerTemplate); } return template; } return element; } elementToTemplate(element) { const isTemplate = element.nodeName == "TEMPLATE"; if (!isTemplate) { const template = document.createElement("template"); const clone = element.cloneNode(true); for (const attribute of Array.from(clone.attributes).filter(x => directiveTypes.some(directive => x.name.startsWith(directive)))) { clone.attributes.removeNamedItem(attribute.name); template.attributes.setNamedItem(attribute); } template.content.appendChild(clone); element.parentNode.replaceChild(template, element); this.setDecomposed(clone); return template; } return element; } getPath() { return this.indexStack.join("-"); } nodeToString(node) { if (typeGuard(node, node.nodeType == Node.TEXT_NODE)) { return node.nodeValue; } const attributes = Array.from(node.attributes) .map(this.attributeToString) .join(" "); return `<${node.nodeName.toLowerCase()}${node.attributes.length == 0 ? "" : " "}${attributes}>`; } hasDecomposed(element) { return !!element[DECOMPOSED]; } hasTemplateDirectives(element) { return element.getAttributeNames().some(attribute => directiveTypes.some(directive => attribute.startsWith(directive))); } *enumerateAttributes(element) { for (const attribute of Array.from(element.attributes)) { if (attribute.name.startsWith("*")) { const wrapper = document.createAttribute(attribute.name.replace(/^\*/, "")); wrapper.value = attribute.value; element.removeAttributeNode(attribute); element.setAttributeNode(wrapper); yield wrapper; } else if (attribute.name.startsWith(":") || attribute.name.startsWith("@") || attribute.name.startsWith("#") || interpolation.test(attribute.value) && !(/^on\w/.test(attribute.name) && nativeEvents.has(attribute.name))) { yield attribute; } else { attribute.value = scapeBrackets(attribute.value); } } } *enumerateDirectives(namedNodeMap) { const KEYED_DIRECTIVES = [DirectiveType.Inject, DirectiveType.Placeholder]; // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < namedNodeMap.length; i++) { const attribute = namedNodeMap[i]; if (!attribute.name.endsWith("-key")) { const raw = this.attributeToString(attribute); let isKeyed = false; for (const directive of KEYED_DIRECTIVES) { if (attribute.name == directive || attribute.name.startsWith(`${directive}:`)) { const DEFAULT_KEY = "'default'"; const directiveKey = `${directive}-key`; const [type, _key] = attribute.name.split(":"); const hasStaticKey = typeof _key == "string"; const key = hasStaticKey ? `'${_key}'` : `${namedNodeMap[directiveKey]?.value ?? DEFAULT_KEY}`; const rawKey = !hasStaticKey && key != DEFAULT_KEY ? `${directiveKey}=\"${key}\"` : ""; yield { key, name: attribute.name, raw, rawKey, type, value: attribute.value, }; isKeyed = true; break; } } if (!isKeyed) { yield { key: "", name: attribute.name, raw, rawKey: "", type: attribute.name, value: attribute.value, }; } } } } nest(template, innerTemplate) { innerTemplate.content.appendChild(template.content); const decomposed = this.decomposeDirectives(innerTemplate); this.setDecomposed(decomposed); template.content.appendChild(decomposed); } parse(template) { this.trimContent(template.content); this.traverseNode(template.content); return this.templateDescriptor; } parseAttributes(element) { const elementDescriptor = { attributes: [], directives: [], events: [], path: this.indexStack.join("-"), textNodes: [], }; const stackTrace = element.attributes.length > 0 ? [...this.stackTrace] : []; for (const attribute of this.enumerateAttributes(element)) { if (attribute.name.startsWith("@")) { const name = attribute.name.replace("@", ""); const rawExpression = `${attribute.name}=\"${attribute.value}\"`; const unknownExpression = this.tryParseExpression(parseExpression, attribute.value, rawExpression); const expression = TypeGuard.isMemberExpression(unknownExpression) || TypeGuard.isArrowFunctionExpression(unknownExpression) ? unknownExpression : Expression.arrowFunction([], unknownExpression); const observables = ObserverVisitor.observe(expression); const descriptor = { expression, name, observables, rawExpression, stackTrace, }; elementDescriptor.events.push(descriptor); element.removeAttributeNode(attribute); } else if (attribute.name.startsWith("#")) { if (!attribute.name.endsWith("-key")) { const DEFAULT_KEY = "'default'"; const [rawName, rawKey] = attribute.name.split(":"); const name = rawName.replace("#", ""); const rawKeyName = `${rawName}-key`; const dinamicKey = element.attributes[rawKeyName]?.value ?? DEFAULT_KEY; const rawKeyExpression = dinamicKey != DEFAULT_KEY ? `${rawKeyName}=\"${dinamicKey}\"` : ""; const rawExpression = `${attribute.name}=\"${attribute.value}\"`; const keyExpression = !!rawKey ? Expression.literal(rawKey) : this.tryParseExpression(parseExpression, dinamicKey, rawKeyExpression); const expression = this.tryParseExpression(parseExpression, attribute.value || "undefined", rawExpression); const keyObservables = ObserverVisitor.observe(keyExpression); const observables = ObserverVisitor.observe(expression); const descriptor = { expression, keyExpression, keyObservables, name, observables, rawExpression, rawKeyExpression, stackTrace, }; elementDescriptor.directives.push(descriptor); element.removeAttributeNode(attribute); } } else { const raw = this.attributeToString(attribute); const name = attribute.name.replace(/^::?/, ""); const key = dashedToCamel(name); const isTwoWay = attribute.name.startsWith("::"); const isOneWay = !isTwoWay && attribute.name.startsWith(":"); const isInterpolation = !isOneWay && !isTwoWay; const type = isOneWay ? "oneway" : isTwoWay ? "twoway" : "interpolation"; const expression = this.tryParseExpression(isInterpolation ? parseInterpolation : parseExpression, attribute.value, raw); if (isTwoWay && !this.validateMemberExpression(expression, true)) { throwTemplateParseError(`Two way data bind cannot be applied to dynamic properties: "${attribute.value}"`, this.stackTrace); } const observables = ObserverVisitor.observe(expression); if (isInterpolation) { attribute.value = ""; } else { element.removeAttributeNode(attribute); } const attributeDescriptor = { expression, key, name, observables, rawExpression: raw, stackTrace, type, }; elementDescriptor.attributes.push(attributeDescriptor); } } if (elementDescriptor.attributes.length + elementDescriptor.directives.length + elementDescriptor.events.length > 0) { this.templateDescriptor.elements.push(elementDescriptor); this.saveLookup(); } } parseTemplateDirectives(element, nonElementsCount) { const template = this.decomposeDirectives(element); const directive = template[DIRECTIVE]; const stackTrace = [...this.stackTrace]; if (directive.type == DirectiveType.If) { const branches = []; const expression = this.tryParseExpression(parseExpression, directive.value, directive.raw); const descriptor = TemplateParser.internalParse(this.name, template, this.stackTrace); const conditionalBranchDescriptor = { descriptor, expression, observables: ObserverVisitor.observe(expression), path: this.getPath(), rawExpression: directive.raw, stackTrace, }; branches.push(conditionalBranchDescriptor); let nextElementSibling = template.nextElementSibling; this.saveLookup(); const lastIndex = this.indexStack.pop(); const lastStack = this.stackTrace.pop(); const parentChildNodes = this.sliceNodes(template.parentNode, lastIndex); let nodeIndex = 0; let elementIndex = lastIndex - nonElementsCount; while (nextElementSibling && contains(nextElementSibling.getAttributeNames(), [DirectiveType.ElseIf, DirectiveType.Else])) { const simblingTemplate = this.decomposeDirectives(nextElementSibling); const simblingDirective = simblingTemplate[DIRECTIVE]; const value = simblingDirective.type == DirectiveType.Else ? "true" : simblingDirective.value; nodeIndex = parentChildNodes.indexOf(nextElementSibling); this.indexStack.push(nodeIndex + lastIndex); if (!this.hasDecomposed(nextElementSibling)) { this.pushToStack(nextElementSibling, ++elementIndex); } const expression = this.tryParseExpression(parseExpression, value, simblingDirective.raw); const descriptor = TemplateParser.internalParse(this.name, simblingTemplate, this.stackTrace); const conditionalBranchDescriptor = { descriptor, expression, observables: ObserverVisitor.observe(expression), path: this.getPath(), rawExpression: simblingDirective.raw, stackTrace: [...this.stackTrace], }; branches.push(conditionalBranchDescriptor); nextElementSibling = simblingTemplate.nextElementSibling; this.saveLookup(); this.indexStack.pop(); this.stackTrace.pop(); } this.offsetIndex = nodeIndex; this.indexStack.push(lastIndex); this.stackTrace.push(lastStack); this.templateDescriptor.directives.logicals.push({ branches }); } else if (directive.type == DirectiveType.For) { const value = directive.value; const { left, right, operator } = this.tryParseExpression(parseForLoopStatement, value, directive.raw); const descriptor = TemplateParser.internalParse(this.name, template, this.stackTrace); const observables = ObserverVisitor.observe(right); const loopDescriptor = { descriptor, left, observables, operator, path: this.getPath(), rawExpression: directive.raw, right, stackTrace, }; this.templateDescriptor.directives.loops.push(loopDescriptor); this.saveLookup(); } else if (directive.type == DirectiveType.Placeholder) { const { key, raw, rawKey, value } = directive; const keyExpression = this.tryParseExpression(parseExpression, key, rawKey); const expression = this.tryParseExpression(parseExpression, `${value || "undefined"}`, raw); const keyObservables = ObserverVisitor.observe(keyExpression); const observables = ObserverVisitor.observe(expression); const descriptor = TemplateParser.internalParse(this.name, template, this.stackTrace); const placeholderDirective = { descriptor, expression, keyExpression, keyObservables, observables, path: this.getPath(), rawExpression: raw, rawKeyExpression: rawKey, stackTrace, }; this.templateDescriptor.directives.placeholders.push(placeholderDirective); this.saveLookup(); } else if (directive.type == DirectiveType.Inject) { const { key, raw, rawKey, value } = directive; const destructured = /^\s*\{/.test(value); const keyExpression = this.tryParseExpression(parseExpression, key, rawKey); const pattern = this.tryParseExpression(destructured ? parseDestructuredPattern : parseExpression, `${value || "{ }"}`, raw); const keyObservables = ObserverVisitor.observe(keyExpression); const observables = ObserverVisitor.observe(pattern); const descriptor = TemplateParser.internalParse(this.name, template, this.stackTrace); const injectionDescriptor = { descriptor, keyExpression, keyObservables, observables, path: this.getPath(), pattern, rawExpression: raw, rawKeyExpression: rawKey, stackTrace, }; this.templateDescriptor.directives.injections.push(injectionDescriptor); this.saveLookup(); } /* c8 ignore next 5 */ if (!TemplateParser.testEnviroment) { template.removeAttribute(directive.name); template.removeAttribute(`${directive.name}-key`); } } parseTextNode(node) { assert(node.nodeValue); if (interpolation.test(node.nodeValue)) { const rawExpression = node.nodeValue; const expression = this.tryParseExpression(parseInterpolation, rawExpression, `"${rawExpression}"`); const observables = ObserverVisitor.observe(expression); const path = this.indexStack.join("-"); const textNodeDescriptor = { expression, observables, path, rawExpression, stackTrace: [...this.stackTrace], }; const rawParentPath = this.indexStack.slice(0, this.indexStack.length - 1); const parentPath = rawParentPath.join("-"); const element = this.templateDescriptor.elements.find(x => x.path == parentPath); if (element) { element.textNodes.push(textNodeDescriptor); } else { this.templateDescriptor.lookup.push([...rawParentPath]); this.templateDescriptor.elements.push({ attributes: [], directives: [], events: [], path: parentPath, textNodes: [textNodeDescriptor], }); } node.nodeValue = " "; this.saveLookup(); } else { node.nodeValue = scapeBrackets(node.nodeValue); } } pushToStack(node, index) { const stackEntry = []; if (index > 0) { stackEntry.push(`...${index} other(s) node(s)`); } stackEntry.push(this.nodeToString(node)); this.stackTrace.push(stackEntry); } saveLookup() { this.templateDescriptor.lookup.push([...this.indexStack]); } setDecomposed(element) { element[DECOMPOSED] = true; } sliceNodes(element, start) { const nodes = []; for (let index = start; index < element.childNodes.length; index++) { nodes.push(element.childNodes[index]); } return nodes; } traverseNode(node) { let nonElementsCount = 0; for (let index = 0; index < node.childNodes.length; index++) { const childNode = node.childNodes[index]; if ((childNode.nodeType == Node.ELEMENT_NODE || childNode.nodeType == Node.TEXT_NODE) && childNode.nodeName != "SCRIPT" && childNode.nodeName != "STYLE") { this.indexStack.push(index); if (!this.hasDecomposed(childNode)) { this.pushToStack(childNode, index - nonElementsCount); } if (typeGuard(childNode, childNode.nodeType == Node.ELEMENT_NODE)) { if (childNode.hasAttribute(DirectiveType.ElseIf)) { const message = `Unexpected ${DirectiveType.ElseIf} directive. ${DirectiveType.ElseIf} must be used in an element next to an element that uses the ${DirectiveType.ElseIf} directive.`; throwTemplateParseError(message, this.stackTrace); } if (childNode.hasAttribute(DirectiveType.Else)) { const message = `Unexpected ${DirectiveType.Else} directive. ${DirectiveType.Else} must be used in an element next to an element that uses the ${DirectiveType.If} or ${DirectiveType.ElseIf} directive.`; throwTemplateParseError(message, this.stackTrace); } if (this.hasTemplateDirectives(childNode)) { this.offsetIndex = 0; this.parseTemplateDirectives(childNode, nonElementsCount); index += this.offsetIndex; this.indexStack.pop(); this.stackTrace.pop(); continue; } else { this.parseAttributes(childNode); } } else { this.parseTextNode(childNode); nonElementsCount++; } this.traverseNode(childNode); this.indexStack.pop(); this.stackTrace.pop(); } else { nonElementsCount++; } } } trimContent(content) { if (content.firstChild && content.firstChild != content.firstElementChild) { while (content.firstChild.nodeType == Node.TEXT_NODE && content.firstChild.textContent.trim() == "") { content.firstChild.remove(); } } if (content.lastChild && content.lastChild != content.lastElementChild) { while (content.lastChild.nodeType == Node.TEXT_NODE && content.lastChild.textContent.trim() == "") { content.lastChild.remove(); } } } tryParseExpression(parser, expression, rawExpression) { try { return parser(expression); } catch (error) { assert(error instanceof SyntaxError); const message = `Parsing error in ${rawExpression}: ${error.message} at position ${error.index}`; throwTemplateParseError(message, this.stackTrace); } } validateMemberExpression(expression, root) { if (!root && (TypeGuard.isThisExpression(expression) || TypeGuard.isIdentifier(expression))) { return true; } else if (TypeGuard.isMemberExpression(expression) && !expression.optional && (!expression.computed || TypeGuard.isLiteral(expression.property))) { return this.validateMemberExpression(expression.object, false); } return false; } } TemplateParser.testEnviroment = false;