import * as dom from "./utils_dom"; import {getObjectValue} from "./shared_utils"; const CONFIG_DEFAULT = { attrBind: "data-bind", attrIf: "data-if", attrIfIs: "data-if-is", }; const CONFIG = Object.assign({}, CONFIG_DEFAULT, { attrBind: "bind", attrIf: "if", attrIfIs: "if-is", }); export interface BindOptions { /** * If true then only those data-bind keys in the data map will be bound, * and no `data-bind` fields will be unbound. */ onlyDefined?: boolean; /** If true, then binding/init will not be called on nested templates. */ singleScoped?: boolean; /** Context elemnt. */ contextElement?: HTMLElement; } export interface InflateOptions { skipInit?: boolean; bindOptions?: BindOptions; } export interface TemplateData { fragment: DocumentFragment; preProcessScript: (data: any) => void; } export interface BindingContext { data: any; contextElement: HTMLElement; currentElement: HTMLElement; } // const RGX_COMPARISON = /^\(?([a-z0-9\.\-\[\]'"]+)((?:<|>|==)=?)([a-z0-9\.\-\[\]'"]+)\)?$/i; const RGX_COMPARISON = (() => { // /^\(?([a-z0-9\.\-\[\]'"]+)((?:<|>|==)=?)([a-z0-9\.\-\[\]'"]+)\)?$/i; let value = "((?:\\!*)[_a-z0-9\\.\\-\\[\\]'\"]+)"; let comparison = "((?:<|>|==|\\!=)=?)"; return new RegExp(`^(?:\\!*)\\(?${value}\\s*${comparison}\\s*${value}\\)?$`, "i"); })(); const RGXPART_BIND_FN_TEMPLATE_STRING = "template|tpl"; const RGXPART_BIND_FN_ELEMENT_STRING = "element|el"; const RGX_BIND_FN_TEMPLATE = new RegExp( `^(?:${RGXPART_BIND_FN_TEMPLATE_STRING})\\(([^\\)]+)\\)`, "i", ); const RGX_BIND_FN_ELEMENT = new RegExp( `^(?:${RGXPART_BIND_FN_ELEMENT_STRING})\\(([^\\)]+)\\)`, "i", ); const RGX_BIND_FN_TEMPLATE_OR_ELEMENT = new RegExp( `^(?:${RGXPART_BIND_FN_TEMPLATE_STRING}|${RGXPART_BIND_FN_ELEMENT_STRING})\\(([^\\)]+)\\)`, "i", ); const RGX_BIND_FN_LENGTH = /^(?:length|len|size)\(([^\)]+)\)/i; const RGX_BIND_FN_FORMAT = /^(?:format|fmt)\(([^\,]+),([^\)]+)\)/i; const RGX_BIND_FN_CALL = /^([^\(]+)\(([^\)]*)\)/i; const EMPTY_PREPROCESS_FN = (data: any) => data; // This is used within exec, so we don't need to check the first part since it's // always the lastIndex start position // const RGX_BIND_DECLARATIONS = /\s*((?:[\$_a-z0-9-\.]|\?\?|\|\|)+(?:\([^\)]+\))?)(?::(.*?))?(\s|$)/ig; const RGX_BIND_DECLARATIONS = /\s*(\!*(?:[\$_a-z0-9-\.\'\"]|\?\?|\|\||\&\&|(?:(?:<|>|==|\!=)=?))+(?:\`[^\`]+\`)?(?:\([^\)]*\))?)(?::(.*?))?(\s|$)/gi; /** * Asserts that something is not null of undefined. */ function localAssertNotFalsy(input?: T | null, errorMsg = `Input is not of type.`): T { if (input == null) { throw new Error(errorMsg); } return input; } /** * Cleans a key. */ function cleanKey(key: string) { return key.toLowerCase().trim().replace(/\s/g, ""); } /** * Ensures the value is an array, converting array-like items to an array. */ function toArray(value: any | any[]): any[] { if (Array.isArray(value)) { return value; } if (value instanceof Set) { return Array.from(value); } // Array-like. if (typeof value === "object" && typeof value.length === "number") { return [].slice.call(value); } return [value]; } /** * Flattens an array. */ function flattenArray(arr: any | any[]): any[] { return toArray(arr).reduce((acc, val) => { return acc.concat(Array.isArray(val) ? flattenArray(val) : val); }, []); } /** * Gets an object value by a string lookup. */ function getObjValue(lookup: string, obj: any): any { // If we want to cast as a boolean via a /!+/ prefix. let booleanMatch: string[] = lookup.match(/^(\!+)(.+?)$/i) || []; let booleanNots: string[] = []; if (booleanMatch[1] && booleanMatch[2]) { booleanNots = booleanMatch[1].split(""); lookup = booleanMatch[2]; } let value = getObjectValue(obj, lookup); while (booleanNots.length) { value = !value; booleanNots.shift(); } return value; } /** * Gets a primotove or object value. */ function getPrimitiveOrObjValue(stringValue: string | null | undefined, data: any) { let value; if (stringValue == null) { return stringValue; } let negate = getNegates(stringValue); if (negate != null) { stringValue = stringValue.replace(/^\!+/, ""); } try { const cleanedStringValue = stringValue.replace(/^'(.*)'$/, '"$1"'); value = JSON.parse(cleanedStringValue); } catch (e) { value = getObjValue(stringValue, data); } value = negate !== null ? (negate === 1 ? !value : !!value) : value; return value; } /** * Get the negates for a string. A `null` value means there are no negates, * otherwise a `1` means it should be negated, and a `0` means double-negate * (e.g. cast as boolean). * * 'boolVar' => null * '!boolVar' => 1 * '!!boolVar' => 0 * '!!!boolVar' => 1 * '!!!!boolVar' => 0 */ function getNegates(stringValue: string): number | null { let negate = null; let negateMatches = stringValue.match(/^(\!+)(.*)/); if (negateMatches && negateMatches.length >= 3) { negate = negateMatches[1]!.length % 2; } return negate; } /** * Returns the boolean for a comparison of object values. A `null` value would * be if the experssion does not match a comparison, and undefined if there was * but it's not a known comparison (===, ==, <=, >=, !==, !=, <, >). */ function getStringComparisonExpression( bindingPropName: string, data: any, ): boolean | null | undefined { let comparisonMatches = bindingPropName.match(RGX_COMPARISON); if (!comparisonMatches?.length) { return null; } let a = getPrimitiveOrObjValue(comparisonMatches[1]!, data); let b = getPrimitiveOrObjValue(comparisonMatches[3]!, data); let c = comparisonMatches[2]; let value = (() => { switch (c) { case "===": return a === b; case "==": return a == b; case "<=": return a <= b; case ">=": return a >= b; case "!==": return a !== b; case "!=": return a != b; case "<": return a < b; case ">": return a > b; default: return undefined; } })(); return value; } /** * Replaces a element with children with special attribute copies for * single children. */ function replaceTplElementWithChildren( tplEl: HTMLElement, fragOrElOrEls: DocumentFragment | HTMLElement | Array, ) { const els = Array.isArray(fragOrElOrEls) ? fragOrElOrEls : [fragOrElOrEls]; tplEl.replaceWith(...els); // dom.replaceChild(tplEl, fragOrElOrEls); const numOfChildren = Array.isArray(fragOrElOrEls) ? fragOrElOrEls.length : fragOrElOrEls.childElementCount; if (numOfChildren === 1) { const firstChild = Array.isArray(fragOrElOrEls) ? fragOrElOrEls[0] : fragOrElOrEls.firstElementChild; if (firstChild instanceof Element) { if (tplEl.className.length) { firstChild.className += ` ${tplEl.className}`; } let attr = tplEl.getAttribute("data"); if (attr) { firstChild.setAttribute("data", attr); } attr = tplEl.getAttribute(CONFIG.attrBind); if (attr) { firstChild.setAttribute(CONFIG.attrBind, attr); } } } } /** * Entry point to get data from a binding name. Checks for null coelescing and * logical-or operators. * * Handles templates as well: * * my.value`some string ${value} is cool.` */ function getValueForBinding(bindingPropName: string, context: BindingContext) { console.log("getValueForBinding", bindingPropName, context); const data = context.data; let stringTemplate = null; let stringTemplates = /^(.*?)\`([^\`]*)\`$/.exec(bindingPropName.trim()); if (stringTemplates?.length === 3) { bindingPropName = stringTemplates[1]!; stringTemplate = stringTemplates[2]; } let value = null; let hadALogicalOp = false; const opsToValidation = new Map([ [/\s*\?\?\s*/, (v: any) => v != null], [/\s*\|\|\s*/, (v: any) => !!v], [/\s*\&\&\s*/, (v: any) => !v], ]); for (const [op, fn] of opsToValidation.entries()) { if (bindingPropName.match(op)) { hadALogicalOp = true; const bindingPropNames = bindingPropName.split(op); for (const propName of bindingPropNames) { value = getValueForBindingPropName(propName, context); if (fn(value)) { break; } } break; } } if (!hadALogicalOp) { value = getValueForBindingPropName(bindingPropName, context); } return stringTemplate && value != null ? stringTemplate.replace(/\$\{value\}/g, String(value)) : value; } /** * Gets the value for a binding prop name. */ function getValueForBindingPropName(bindingPropName: string, context: BindingContext) { const data = context.data; let negate = getNegates(bindingPropName); if (negate != null) { bindingPropName = bindingPropName.replace(/^\!+/, ""); } let value; RGX_COMPARISON.lastIndex = 0; if (RGX_COMPARISON.test(bindingPropName)) { value = getStringComparisonExpression(bindingPropName, data); } else if (RGX_BIND_FN_LENGTH.test(bindingPropName)) { bindingPropName = RGX_BIND_FN_LENGTH.exec(bindingPropName)![1]!; value = getPrimitiveOrObjValue(bindingPropName, data); value = (value && value.length) || 0; } else if (RGX_BIND_FN_FORMAT.test(bindingPropName)) { let matches = RGX_BIND_FN_FORMAT.exec(bindingPropName); bindingPropName = matches![1]!; value = getPrimitiveOrObjValue(bindingPropName, data); value = matches![2]!.replace(/^['"]/, "").replace(/['"]$/, "").replace(/\$1/g, value); } else if (RGX_BIND_FN_CALL.test(bindingPropName)) { console.log("-----"); console.log(bindingPropName); let matches = RGX_BIND_FN_CALL.exec(bindingPropName); const functionName = matches![1]!; const maybeDataName = matches![2] ?? null; value = getPrimitiveOrObjValue(maybeDataName, data); console.log(functionName, maybeDataName, value); // First, see if the instance has this call if (typeof value?.[functionName] === "function") { value = value[functionName](value, data, context.currentElement, context.contextElement); } else if (typeof data?.[functionName] === "function") { value = data[functionName](value, data, context.currentElement, context.contextElement); } else if (typeof (context.currentElement as any)?.[functionName] === "function") { value = (context.currentElement as any)[functionName]( value, data, context.currentElement, context.contextElement, ); } else if (typeof (context.contextElement as any)?.[functionName] === "function") { value = (context.contextElement as any)[functionName]( value, data, context.currentElement, context.contextElement, ); } else { console.error( `No method named ${functionName} on data or element instance. Just calling regular value.`, ); value = getPrimitiveOrObjValue(bindingPropName, data); } } else { value = getPrimitiveOrObjValue(bindingPropName, data); } if (value !== undefined) { value = negate !== null ? (negate === 1 ? !value : !!value) : value; } return value; } /** * Removes data-bind attributes, ostensibly "freezing" the current element. * * @param deep Will remove all data-bind attributes when true. default behavior * is only up to the next data-tpl. */ function removeBindingAttributes( elOrEls: DocumentFragment | HTMLElement | HTMLElement[], deep = false, ) { flattenArray(elOrEls || []).forEach((el) => { el.removeAttribute(CONFIG.attrBind); const innerBinds = dom.queryAll(`:scope [${CONFIG.attrBind}]`, el); // If we're deep, then pretend there are no data-tpl. const innerTplBinds = deep ? [] : dom.queryAll(`:scope [data-tpl] [${CONFIG.attrBind}]`); innerBinds.forEach((el) => { if (deep || !innerTplBinds.includes(el)) { el.removeAttribute(CONFIG.attrBind); } }); }); } const templateCache: {[key: string]: TemplateData} = {}; /** * Checks if a template exists. */ export function checkKey(key: string) { return !!templateCache[cleanKey(key)]; } /** * Register a template to it's key and a DocumentFragment to store the markup. * Uses `