@surface/custom-element
Version:
Provides support of directives and data binding on custom elements.
384 lines (383 loc) • 19.5 kB
JavaScript
/* 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 { buildStackTrace, scapeBrackets, throwTemplateParseError } from "../common.js";
import { parseDestructuredPattern, parseExpression, parseForLoopStatement, parseInterpolation } from "../parsers/expression-parsers.js";
import nativeEvents from "../parsers/native-events.js";
import { interpolation } from "../parsers/patterns.js";
import ObserverVisitor from "../reactivity/observer-visitor.js";
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.index = 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;
return new TemplateParser(name).parse(templateElement);
}
attributeToString(attribute) {
return !attribute.value ? attribute.name : `${attribute.name}="${attribute.value}"`;
}
decomposeDirectives(element) {
const template = this.elementToTemplate(element);
const [directive, ...directives] = this.enumerateDirectives(template.attributes);
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`);
innerTemplate.content.appendChild(template.content);
template.content.appendChild(innerTemplate);
}
return [template, directive];
}
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);
return template;
}
return element;
}
nodeToString(node) {
if (typeGuard(node, node.nodeType == Node.TEXT_NODE)) {
return node.nodeValue;
}
if (typeGuard(node, node.nodeType == Node.COMMENT_NODE)) {
return `<!--${node.nodeValue}-->`;
}
const attributes = Array.from(node.attributes)
.map(this.attributeToString)
.join(" ");
return `<${node.nodeName.toLowerCase()}${node.attributes.length == 0 ? "" : " "}${attributes}>`;
}
hasTemplateDirectives(element) {
return element.getAttributeNames().some(attribute => directiveTypes.some(directive => attribute.startsWith(directive)));
}
*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,
};
}
}
}
}
parse(template) {
return { childs: this.traverseNodes(template.content), type: "fragment" };
}
parseElement(element) {
const attributes = [];
const binds = [];
const directives = [];
const events = [];
// const stackTrace = element.attributes.length > 0 ? [...this.stackTrace] : [];
for (const attribute of Array.from(element.attributes)) {
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);
events.push({ key: name, value: expression });
}
else if (attribute.name.startsWith("#")) {
if (!attribute.name.endsWith("-key")) {
const DEFAULT_KEY = "'default'";
const [rawName, rawKey] = attribute.name.split(":");
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);
directives.push([keyExpression, expression, [keyObservables, observables]]);
element.removeAttributeNode(attribute);
}
}
else if (attribute.name.startsWith(":") || interpolation.test(attribute.value) && !(/^on\w/.test(attribute.name) && nativeEvents.has(attribute.name))) {
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 = "";
}
binds.push({ key, observables, type, value: expression });
}
else {
attributes.push({ key: attribute.name, value: attribute.value });
}
}
const descriptor = {
attributes,
binds,
childs: element.nodeName == "SCRIPT" || element.nodeName == "STYLE"
? [{ observables: [], type: "text", value: Expression.literal(element.textContent) }]
: this.traverseNodes(element),
directives,
events,
tag: element.nodeName,
type: "element",
};
return descriptor;
}
parseDirectives(element, nonElementsCount) {
const [template, directive] = this.decomposeDirectives(element);
// const stackTrace = [...this.stackTrace];
if (directive.type == DirectiveType.If) {
const branches = [];
const expression = this.tryParseExpression(parseExpression, directive.value, directive.raw);
const fragment = TemplateParser.internalParse(this.name, template, this.stackTrace);
const branchDescriptor = {
expression,
fragment,
observables: ObserverVisitor.observe(expression),
};
branches.push(branchDescriptor);
let node = template;
const lastStack = this.stackTrace.pop();
while (node.nextElementSibling && contains(node.nextElementSibling.getAttributeNames(), [DirectiveType.ElseIf, DirectiveType.Else])) {
while (node.nextSibling && node.nextSibling != node.nextElementSibling) {
this.index++;
if (node.nextSibling.nodeType == Node.TEXT_NODE && node.nextSibling.textContent?.trim() != "") {
this.pushToStack(node.nextSibling, this.index - nonElementsCount);
const message = `${"Any content between conditional statement branches will be ignored.\n"}${buildStackTrace(this.stackTrace)}`;
console.warn(message);
this.stackTrace.pop();
}
node = node.nextSibling;
}
const [simblingTemplate, simblingDirective] = this.decomposeDirectives(node.nextElementSibling);
const value = simblingDirective.type == DirectiveType.Else ? "true" : simblingDirective.value;
this.pushToStack(node.nextElementSibling, this.index - nonElementsCount);
this.index++;
const expression = this.tryParseExpression(parseExpression, value, simblingDirective.raw);
const fragment = TemplateParser.internalParse(this.name, simblingTemplate, this.stackTrace);
const conditionalBranchDescriptor = {
expression,
fragment,
observables: ObserverVisitor.observe(expression),
};
branches.push(conditionalBranchDescriptor);
node = simblingTemplate;
this.stackTrace.pop();
}
this.stackTrace.push(lastStack);
return { branches, type: "choice-statement" };
}
else if (directive.type == DirectiveType.For) {
const value = directive.value;
const { left, right, operator } = this.tryParseExpression(parseForLoopStatement, value, directive.raw);
const fragment = TemplateParser.internalParse(this.name, template, this.stackTrace);
const observables = ObserverVisitor.observe(right);
const loopDescriptor = {
fragment,
left,
observables,
operator,
right,
type: "loop-statement",
};
return loopDescriptor;
}
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 fragment = TemplateParser.internalParse(this.name, template, this.stackTrace);
const placeholderDirective = {
fragment,
key: keyExpression,
observables: { key: keyObservables, value: observables },
type: "placeholder-statement",
value: expression,
};
return placeholderDirective;
}
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 fragment = TemplateParser.internalParse(this.name, template, this.stackTrace);
const injectionDescriptor = {
fragment,
key: keyExpression,
observables: { key: keyObservables, value: observables },
type: "injection-statement",
value: pattern,
};
return injectionDescriptor;
}
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 descriptor = {
observables,
type: "text",
value: expression,
};
return descriptor;
}
const descriptor = {
observables: [],
type: "text",
value: Expression.literal(scapeBrackets(node.nodeValue)),
};
return descriptor;
}
pushToStack(node, index) {
const stackEntry = [];
if (index > 0) {
stackEntry.push(`...${index} other(s) node(s)`);
}
stackEntry.push(this.nodeToString(node));
this.stackTrace.push(stackEntry);
}
traverseNodes(node) {
let nonElementsCount = 0;
const nodes = [];
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) {
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.index = index;
nodes.push(this.parseDirectives(childNode, nonElementsCount));
index = this.index;
this.stackTrace.pop();
continue;
}
else {
nodes.push(this.parseElement(childNode));
}
}
else {
nodes.push(this.parseTextNode(childNode));
nonElementsCount++;
}
this.stackTrace.pop();
}
else {
if (childNode.nodeType == Node.COMMENT_NODE) {
nodes.push({ type: "comment", value: childNode.textContent ?? "" });
}
nonElementsCount++;
}
}
return nodes;
}
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;
}
}