object-replace-mustache
Version:
replace placeholders of an object with a view like you would use mustache.render for strings
165 lines (158 loc) • 5.59 kB
JavaScript
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
const isPlainObject = (value) => value && [void 0, Object].includes(value.constructor);
function escape(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const delimitersMustache = ["{{", "}}"];
const regexForDelimiters = (delimiters, flags) => {
return new RegExp(
`^${escape(delimiters[0])}(.*?)${escape(delimiters[1])}$`,
flags
);
};
const isTemplateString = (str, options) => {
if (!str || typeof str !== "string") {
return false;
}
const regex = regexForDelimiters(options?.delimiters ?? delimitersMustache);
return regex.test(str);
};
const traverse = typeof _traverse === "function" ? _traverse : _traverse.default;
const prohibitedKeywordRE = new RegExp(
"\\b" + "arguments,await,break,case,catch,class,const,continue,debugger,default,delete,do,else,enum,export,extends,finally,for,function,if,import,let,new,return,static,super,switch,throw,try,var,void,while,with,yield".split(",").join("\\b|\\b") + "\\b"
);
const stripStringRE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*\$\{|\}(?:[^`\\]|\\.)*`|`(?:[^`\\]|\\.)*`/g;
const defineReplaceTemplateString = (options) => (template, view = {}) => replaceString(template, view, options);
const replaceString = (template, view = {}, options) => {
const expression = regexForDelimiters(
options?.delimiters ?? delimitersMustache
).exec(template)?.[1];
if (!expression) {
return template;
}
function silentOrThrow(err) {
if (options?.handleError !== "ignore") {
throw typeof err === "string" ? new Error(err) : err;
}
return template;
}
if (expression.match(/\${.*?}/)) {
throw new Error("nested expression is not allowed in template string");
}
const withoutStrings = expression.replace(stripStringRE, "");
const allowedVariables = Object.keys(view);
const unsafeMethods = ["require", "eval", "console", "this", "window", "document", "global", "process", "module", "exports", "self", "globalThis"].filter((x) => allowedVariables.indexOf(x) === -1);
const keywordMatch = withoutStrings.match(prohibitedKeywordRE);
if (keywordMatch) {
throw new Error(`${keywordMatch} is not allowed in template string`);
}
const ast = parse(expression);
if (ast.errors?.length) {
return silentOrThrow(ast.errors[0].reasonCode);
}
let unsafe = false;
traverse(ast, {
enter(path) {
if (unsafe) {
return;
}
const type = path.node.type;
if (type === "ThisExpression") {
unsafe = "this is not defined";
return;
}
if (type === "AssignmentExpression") {
unsafe = "assignment is not allowed in template string";
return;
}
if (path.node.type === "VariableDeclaration" || path.node.type === "FunctionDeclaration") {
unsafe = true;
} else if (path.node.type === "Identifier" && unsafeMethods.indexOf(path.node.name) != -1) {
unsafe = `${path.node.name} is not defined`;
}
}
});
if (expression.includes(";")) {
return silentOrThrow("; is not allowed in template string");
}
if (withoutStrings.includes("=")) {
if (withoutStrings.match(/<<=|>>=|>>>=/)) {
return silentOrThrow("assignment is not allowed in template string");
}
const strippedCompare = withoutStrings.replace(/!==|===|==|!=|>=|<=|=>/g, "");
if (strippedCompare.match(/=/g)) {
return silentOrThrow("assignment is not allowed in template string");
}
}
if (withoutStrings.match(/\+\+|--/g)) {
throw new Error("assignment is not allowed in template string");
}
if (unsafe) {
return silentOrThrow(typeof unsafe === "string" ? unsafe : "unsafe operation is not allowed in template string");
} else {
try {
const result = new Function(
"view",
/* js */
`
const tagged = ( ${allowedVariables.join(", ")} ) => ${expression}
return tagged(...Object.values(view))
`
)(view);
if (typeof result === "function") {
return silentOrThrow("function is not allowed in template string");
}
return result;
} catch (err) {
return silentOrThrow(err);
}
}
};
const delimiters = {
mustache: ["{{", "}}"],
ejs: ["<%=", "%>"]
};
const replaceStringMustache = defineReplaceTemplateString({
delimiters: delimiters.mustache
});
const replaceStringEjs = defineReplaceTemplateString({
delimiters: delimiters.ejs
});
const replace = (item, view, options) => {
return recursiveReplace(item, view, options);
};
const recursiveReplace = (item, view, options) => {
if (typeof item === "string") {
return replaceString(item, view, {
delimiters: options?.delimiters,
handleError: options?.handleError ?? "ignore"
});
} else if (isPlainObject(item)) {
const result = {};
for (const key in item) {
result[key] = recursiveReplace(item[key], view);
}
return result;
} else if (Array.isArray(item)) {
return [...item.map((subItem) => recursiveReplace(subItem, view))];
}
return item;
};
const render = (str, view, options) => str.replace(
/\{\{(.*?)\}\}/g,
(m) => {
let result = replaceString(m, view, {
handleError: options?.handleError ?? "ignore",
delimiters: options?.delimiters
});
if (options?.format) {
result = options.format(result);
}
if (result == null) {
return "";
}
return result;
}
);
export { delimiters, isTemplateString, render, replace, replaceString, replaceStringEjs, replaceStringMustache };