UNPKG

object-replace-mustache

Version:

replace placeholders of an object with a view like you would use mustache.render for strings

222 lines (182 loc) 5.75 kB
// shamelessly taken from https://github.com/vuejs/core/blob/main/packages/compiler-core/src/validateExpression.ts // these keywords should not appear inside expressions, but operators like import { regexForDelimiters } from "./utils"; import { parse } from "@babel/parser"; import _traverse from "@babel/traverse"; const traverse: typeof _traverse = typeof _traverse === "function" ? _traverse : (_traverse as any).default; // https://astexplorer.net/ // 'typeof', 'instanceof', and 'in' are allowed 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", ); // strip strings in expressions const stripStringRE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*\$\{|\}(?:[^`\\]|\\.)*`|`(?:[^`\\]|\\.)*`/g; /** * possible values: * * - "JSON": JSON.stringify * - "Object": Object.keys * - "Array": Array.isArray */ const whitelistNamespaces: string[] = [ "true", "false", "null", "undefined", "typeof", "instanceof", "in", "String", "Number", "Boolean", "Symbol", "BigInt", "JSON", "Object", "Array", "Math", "Date", "RegExp", "Error", ]; export type ReplaceTemplateStringOptions = { /** * Specify the delimiters * * @default ['{{', '}}'] */ delimiters?: [string, string]; /** * Whether to throw an error when an error occurs or return the original string * * @default "throw" */ handleError?: "throw" | "ignore"; }; const defineReplaceTemplateString = (options: ReplaceTemplateStringOptions) => (template: string, view = {}) => replaceString(template, view, options); const callbackParamsRegex = /(?:\(([^)]*)\)|\s*(\w+))\s*=>/; function extractCallbackParams(functionString: string) { const match = functionString.match(callbackParamsRegex); return match ? (match[1] ? match[1].split(',').map(param => param.trim().replace(/^\(|\)$/g, '')).filter(Boolean) : [match[2]]) : []; } export const replaceString = ( template: string, view = {}, options?: ReplaceTemplateStringOptions, ): unknown => { const expression = regexForDelimiters( options?.delimiters ?? ["{{", "}}"], ).exec(template)?.[1]; if (!expression) { return template; } function silentOrThrow(err: string | Error) { if (options?.handleError !== "ignore") { throw typeof err === "string" ? new Error(err) : err; } return template; } // throw with nested expression 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: string | boolean = 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`; } }, }); // check for semicolons if (expression.includes(";")) { return silentOrThrow("; is not allowed in template string"); } // check for assignment 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"); } // good to go, it's a compare operator } 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: any) { return silentOrThrow(err); } } }; export const delimiters = { mustache: ["{{", "}}"], ejs: ["<%=", "%>"], } as const; export const replaceStringMustache = defineReplaceTemplateString({ delimiters: delimiters.mustache as any, }); export const replaceStringEjs = defineReplaceTemplateString({ delimiters: delimiters.ejs as any, });