object-replace-mustache
Version:
replace placeholders of an object with a view like you would use mustache.render for strings
165 lines (159 loc) • 6.12 kB
JavaScript
import { parseScript } from "meriyah";
//#region src/utils.ts
const isPlainObject = (value) => !!value && [void 0, Object].includes(value.constructor);
function escape(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const delimitersMustache = ["{{", "}}"];
const regexForDelimiters = (delimiters$1, flags) => {
return new RegExp(`^${escape(delimiters$1[0])}(.*?)${escape(delimiters$1[1])}$`, flags);
};
//#endregion
//#region src/is-template-string.ts
const isTemplateString = (str, options) => {
if (!str || typeof str !== "string") return false;
return regexForDelimiters(options?.delimiters ?? delimitersMustache).test(str);
};
//#endregion
//#region src/replace-string.ts
function traverseAST(node, visitor) {
if (!node || typeof node !== "object") return;
if (visitor(node) === true) return;
for (const key in node) {
if (key === "type" || key === "loc" || key === "range") continue;
const value = node[key];
if (Array.isArray(value)) for (const item of value) traverseAST(item, visitor);
else if (value && typeof value === "object") traverseAST(value, visitor);
}
}
const prohibitedKeywordRE = /* @__PURE__ */ 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(/\${/)) {
let isValid = true;
let inTemplate = false;
let escaped = false;
for (let i = 0; i < expression.length; i++) {
const char = expression[i];
const next = expression[i + 1];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "`") inTemplate = !inTemplate;
if (char === "$" && next === "{" && !inTemplate) {
isValid = false;
break;
}
}
if (!isValid) 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`);
let ast;
try {
ast = parseScript(expression);
} catch (err) {
return silentOrThrow(err.message || "Failed to parse expression");
}
let unsafe = false;
traverseAST(ast, (node) => {
if (unsafe) return true;
const type = node.type;
if (type === "ThisExpression") {
unsafe = "this is not defined";
return true;
}
if (type === "AssignmentExpression") {
unsafe = "assignment is not allowed in template string";
return true;
}
if (node.type === "VariableDeclaration" || node.type === "FunctionDeclaration") {
unsafe = true;
return true;
} else if (node.type === "Identifier" && unsafeMethods.indexOf(node.name) != -1) {
unsafe = `${node.name} is not defined`;
return true;
}
});
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");
if (withoutStrings.replace(/!==|===|==|!=|>=|<=|=>/g, "").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", `
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 });
//#endregion
//#region src/replace-object.ts
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, options);
return result;
} else if (Array.isArray(item)) return [...item.map((subItem) => recursiveReplace(subItem, view, options))];
return item;
};
//#endregion
//#region src/render.ts
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;
});
//#endregion
export { delimiters, isTemplateString, render, replace, replaceString, replaceStringEjs, replaceStringMustache };