object-replace-mustache
Version:
replace placeholders of an object with a view like you would use mustache.render for strings
260 lines (215 loc) • 6.54 kB
text/typescript
// 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 { delimitersMustache, regexForDelimiters } from './utils'
import { parseScript } from 'meriyah'
// Simple AST traversal function
function traverseAST(node: any, visitor: (node: any) => boolean | void): void {
if (!node || typeof node !== 'object') return
// Call visitor, if it returns true, stop traversal
if (visitor(node) === true) return
// Recursively traverse all properties
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)
}
}
}
// 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
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)
export const replaceString = (
template: string,
view = {},
options?: ReplaceTemplateStringOptions,
): unknown => {
const expression = regexForDelimiters(
options?.delimiters ?? delimitersMustache,
).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
}
// Check for invalid nested expressions (${} outside of template literals)
// Template literals start with backtick, but invalid usage like 'a' + ${} should be caught
const hasInvalidNestedExpr = expression.match(/\${/)
if (hasInvalidNestedExpr) {
// Check if all ${ are within valid template literals (backtick strings)
// Valid: `text ${expr}` or `${expr}`
// Invalid: 'text' + ${expr} or any ${ not in a backtick string
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: any) {
return silentOrThrow(err.message || 'Failed to parse expression')
}
let unsafe: string | boolean = false
traverseAST(ast, (node) => {
if (unsafe) {
return true // Stop traversal
}
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
}
})
// 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,
})