UNPKG

@ou-imdt/utils

Version:

Utility library for interactive media development

369 lines (331 loc) 11.9 kB
import { default as Base, defaultState } from '../class/Base.js'; export default class SubberModule extends Base { static get [defaultState]() { return { start: `{{`, end: '}}', fallbackName: 'default' }; }; static operations = { "+": (value, output) => output + value, "-": (value, output) => output - value, "*": (value, output) => output * value, "/": (value, output) => output / value, "%": (value, output) => output % value, "||": (value, output) => output || value, "&&": (value, output) => output && value, "??": (value, output) => output ?? value, default: (value) => value }; static conditionals = { ">": (left, right) => left > right, "<": (left, right) => left < right, ">=": (left, right) => left >= right, "<=": (left, right) => left <= right, "==": (left, right) => left === right, "===": (left, right) => left === right, "!=": (left, right) => left !== right, "!==": (left, right) => left !== right }; constructor() { super(); } /** * Setter for subber options, because it makes things obvious * @param {object} options */ // removed as duplicate of Base.state // set(options) { // Object.keys(options).map((k) => (this[k] = options[k])); // } /** * Escapes special characters in regex (thanks Alex! have some 🍰 as thx) * @param {string} text */ esc(text) { return text.replace(/[.*+\-?^${}()|[\]\\]/g, "\\$&"); } /** * Loops over array of strings trimming and filtering out empty strings * @param {array} arr */ normalise(arr) { return arr.map((i) => i && i.trim()).filter((i) => i); } /** * Scans string for tokens wrapped in options.start and options.end * returns multi-dimension array of token pairs [0] = full token, [1] = token contents. * @param {string} str */ scan(str) { const { esc } = this; const { start, end } = this; const pattern = `${esc(start)}\\s?(.*?)\\s?${esc(end)}`; const reg = new RegExp(pattern, "g"); const tags = Array.from(String(str).matchAll(reg)); return tags; } /** * Resolves method and parameters before executing * @param {string} path * @param {object} data */ handleMethod(path, data) { const parts = Array.from(path.matchAll(/^(.+?)\((.*)\)$/g))[0]; const method = this.resolveValue(parts[1], data); const params = parts[2].split(","); const values = params.map((i) => this.resolveValue(i, data)); if (typeof method !== 'function') { return undefined; } if (method === data[this.fallbackName]) { return method.bind(null, parts[1], ...values); } const result = method(...values); return result; } /** * Check is string is a supported assignment operator * @param {string} str */ isAssignmentOperator(str) { return Object.keys(this.constructor.operations).includes(str); } /** * VERY Simple expression handling for addition, division, multiplication, subtraction * @param {string} path expects expression like {{ (2 + 1) }} {{ ("my age is " + 10) }} * @param {object} data */ handleExpression(path, data) { const inner = path.match(/\(\s?(.*?)\s?\)$/)[1];// path.match(/\(([^()]*)\)/)[1]; const pattern = /([+\-/*%]{1})|(&&|\?\?|\|\|)|([a-zA-Z0-9._$()<>[\]]+)|(["`']{1}.+?["`']{1})/; ///([+\-/*%]{1})|([a-zA-Z0-9._$()]+)|(["`']{1}[a-zA-Z0-9._$()<>\s]+["`']{1})/; const parts = this.normalise(inner.split(pattern)); const result = parts.reduce( (prev, i) => { const { output, operator } = prev; if (this.isAssignmentOperator(i)) return { ...prev, operator: i }; const value = this.resolveValue(i, data); const operation = this.constructor.operations[operator || "default"]; const solution = operation(value, output); return { ...prev, output: solution }; }, { operator: undefined, output: undefined } ); return result.output; } /** * Resolves simple conditions e.g. {{10 > 5}} * @param {string} path * @param {object} data */ handleCondition(path, data) { const parts = this.normalise( path.split(/(<={0,1}|>={0,1}|===|==|!==|!=|!)/) ); if (parts.length === 3) { const left = this.resolveValue(parts[0], data); const check = parts[1]; const right = this.resolveValue(parts[2], data); const conditional = this.constructor.conditionals[check]; return conditional(left, right); } if (parts.length === 2 && parts[0] === "!") { return !this.resolveValue(parts[1], data); } return ""; } /** * Resolves ternary expression * @param {string} path * @param {object} data */ handleTernary(path, data) { const parts = path.split(/\?|:/); const check = this.resolveValue(decodeURI(parts[0]), data); const leftResult = this.resolveValue(parts[1], data); const rightResult = this.resolveValue(parts[2], data); return check ? leftResult : rightResult; } /** * Resolve templates (strings within strings 😨) * @param {string} path * @param {object} data * @returns */ handleTemplate(path, data) { const template = path.split('`')[1]; const value = this.resolveValue(template, data); const result = this.str(value, data); return result; } /** * Resolves data values; flat, dot nested and multidimensional array indexes * @param {string} path * @param {object} data */ handleOther(path, data) { const fallback = this.fallbackName; const parts = this.normalise(path.split(/[.\[\]]/)); // eslint-disable-line const value = parts.reduce((prev, part) => prev?.[part], data) ?? data[fallback]; // bug - returns 'default' // const value = parts.reduce((prev, part) => prev?.[part], data); return value; } /** * Resolves converting numbers from string; needed for basic expressions * @param {string} path */ handleNumber(path) { return path.includes(".") ? parseFloat(path) : parseInt(path, 10); } /** * Resolves boolean, will also resolve {{ false || true }} to undefined when not nested in () * @param {string} path */ handleBoolean(path) { return ["true", "false"].includes(path) ? (path === "true") : undefined; // if (!["true", "false"].includes(path)) return undefined; // return path === "true" ? true : false; } /** * Determines what type of handler is needed; each handler function breaks * up the path into parts and feed each part back into this function recursivly * until all path values are handled * @param {string} path * @param {object} data */ resolveValue(path, data) { // console.log(path, data); path = path.trim(); const isBool = /^true|false$/.test(path); const isTemplate = /^(([`])(.+?)(\2))/.test(path); const isString = /^((["'])(.*?)(\2))/.test(path); const isNumber = /^\d+\.*\d*$/g.test(path); const isMethod = /^.+\(.*\)$/.test(path); const isExpression = /^(\(.+\))/.test(path); const isTernary = /^.*?\?.*?:.*$/.test(path); const isCondition = /^(?!<).+(<={0,1}|>={0,1}|===|==|!==|!=|!).+$/.test( path ); /** * type: 'method', * name: '', * values: [...] */ const isOther = ![ isString, isNumber, isMethod, isCondition, isExpression, isTernary, isTemplate ].includes(true); //for debugging, do not remove until this is stable! // console.log({ // path, // isBool, // isString, // isNumber, // isMethod, // isExpression, // isTernary, // isCondition, // // }); if (isTernary) return this.handleTernary(path, data); if (isExpression) return this.handleExpression(path, data); if (isMethod) return this.handleMethod(path, data); if (isTemplate) return this.handleTemplate(path, data); if (isString) return path.slice(1, -1); // trims of string wrapper if (isCondition && !isString) return this.handleCondition(path, data); if (isBool) return this.handleBoolean(path); // boolean from string if (isNumber) return this.handleNumber(path); if (isOther) return this.handleOther(path, data); } /** * Entry function used to interprets string containing tokens, returns a string with substitutions * Passed in dom elements will be resolved * @param {String} str Original string with tokens * @param {Object} data data object that tokens resolve to * @param {Function} call an optional callback to intercept the value before substitution, must return a value. */ str(str, data, call = ctx => ctx.value) { const relativeIdxs = {}; const tags = Object.freeze(this.scan(str)); const output = tags.reduce((prev, cur, index) => { const [token, path] = cur; const value = this.resolveValue(path.trim(), data); const relativeIndex = relativeIdxs[path] + 1 || 0; const context = { path, value, index, relativeIndex, tags, data }; relativeIdxs[path] = relativeIndex; if (Array.isArray(value)) { context.value = value[relativeIndex]; } if (typeof value === 'function') { context.value = value(context); } return prev.replace(token, call(context) ?? context.value); }, str, 0); return output; } /** * Entry function used to interprets string containing tokens, returns a Dom fragment * Passed in dom elements will be resolved * @param {string} str * @param {object} data */ dom(str, data, call = null) { const placers = {}; const callback = (context) => { const value = (typeof call === 'function') ? call(context) : context.value; const domTypes = [ Element, DocumentFragment, Text ]; if (domTypes.some(type => value instanceof type)) { const { path, relativeIndex } = context; const id = `${path}-${relativeIndex}`; placers[id] = { ...context, value }; return `<i data-subtoken="${id}"></i>`; } return value; // bug - missing return } const processed = this.str(str, data, callback); const output = document.createRange().createContextualFragment(processed); const placeRefs = Array.from(output.querySelectorAll("i[data-subtoken]")); placeRefs.map((refEl) => { const id = refEl.getAttribute('data-subtoken'); const { value } = placers[id]; refEl.replaceWith(...(value instanceof DocumentFragment ? value.childNodes : [value])); // bug... should be frag.childNodes }); return output; } /** * Function to traverse through an object and run subber on each value * This operates on the obj passed on, if you don't want to affect it use the * global js function structuredClone() * example: subber.obj(structuredClone(content), subInData); * @param {object} obj - the object to traverse * @param {object} data - subber data to use for substitutions * @param {function|null} call - an interception callback * @param {options} options - an options object that allows users to change which determines if this.str or this.dom is used for subbing * @returns */ obj(obj, data, call = null, options = { mode: 'str'}) { const fn = options.mode === 'str' ? this.str.bind(this) : this.dom.bind(this); // Use Object.entries() to get an array of the object's key-value pairs Object.entries(obj).forEach(([key, val]) => { if (typeof val === 'object') { // If value is an object, call self with object as new argument return obj[key] = this.obj(val, data, call, options); } else { // If value not object, call callback with value, assign result to property return obj[key] = fn(val, data, call); } }); // Return the mapped object return obj; } }