@ou-imdt/utils
Version:
Utility library for interactive media development
369 lines (331 loc) • 11.9 kB
JavaScript
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;
}
}