Skip to content

Commit

Permalink
feat: Allow setting escape option per parameter replacing
Browse files Browse the repository at this point in the history
Allows to use HTML inside the parameters like the following example.
This will still escape the user input but keep the HTML tags for `a` and `end_a`.
```js
t(
	'app',
	'Click: {a}{userInput}{end_a}',
	{
		a: {
			value: '<a>',
			escape: false,
		},
		userInput,
		end_a: {
			value: '</a>',
			escape: false,
		},
	},
)
```

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed May 4, 2024
1 parent 91214fa commit 7775257
Show file tree
Hide file tree
Showing 2 changed files with 68 additions and 5 deletions.
32 changes: 27 additions & 5 deletions lib/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ interface TranslationOptions {
sanitize?: boolean
}

/** @notExported */
interface TranslationVariableReplacementObject<T> {
/** The value to use for the replacement */
value: T
/** Overwrite the `escape` option just for this replacement */
escape: boolean
}

/** @notExported */
type TranslationVariables = Record<string, string | number | TranslationVariableReplacementObject<string | number>>

/**
* Translate a string
*
Expand All @@ -27,37 +38,48 @@ interface TranslationOptions {
* @param {object} vars map of placeholder key to value
* @param {number} number to replace %n with
* @param {object} [options] options object
* @param {boolean} options.escape enable/disable auto escape of placeholders (by default enabled)
* @param {boolean} options.sanitize enable/disable sanitization (by default enabled)
*
* @return {string}
*/
export function translate(
app: string,
text: string,
vars?: Record<string, string | number>,
vars?: TranslationVariables,
number?: number,
options?: TranslationOptions,
): string {
const defaultOptions = {
const allOptions = {
// defaults
escape: true,
sanitize: true,
// overwrite with user config
...(options || {}),
}
const allOptions = Object.assign({}, defaultOptions, options || {})

const identity = <T, >(value: T): T => value
const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity
const optEscape = allOptions.escape ? escapeHTML : identity

const isValidReplacement = (value: unknown) => typeof value === 'string' || typeof value === 'number'

// TODO: cache this function to avoid inline recreation
// of the same function over and over again in case
// translate() is used in a loop
const _build = (text: string, vars?: Record<string, string | number>, number?: number) => {
const _build = (text: string, vars?: TranslationVariables, number?: number) => {
return text.replace(/%n/g, '' + number).replace(/{([^{}]*)}/g, (match, key) => {
if (vars === undefined || !(key in vars)) {
return optEscape(match)
}

const replacement = vars[key]
if (typeof replacement === 'string' || typeof replacement === 'number') {
if (isValidReplacement(replacement)) {
return optEscape(`${replacement}`)
} else if (typeof replacement === 'object' && isValidReplacement(replacement.value)) {
// Replacement is an object so indiviual escape handling
const escape = replacement.escape !== false ? escapeHTML : identity
return escape(`${replacement.value}`)
} else {
/* This should not happen,
* but the variables are used defined so not allowed types could still be given,
Expand Down
41 changes: 41 additions & 0 deletions tests/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,47 @@ describe('translate', () => {
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('with global placeholder HTML escaping and enabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: true } }, undefined, { escape: true })
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('with global placeholder HTML escaping but disabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: false } }, undefined, { escape: true })
expect(translation).toBe('Hallo <del>Name</del>')
})

it('without global placeholder HTML escaping but enabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: true } }, undefined, { escape: false })
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('without global placeholder HTML escaping and disabled on parameter', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: false } }, undefined, { escape: false })
expect(translation).toBe('Hallo <del>Name</del>')
})

it('with global placeholder HTML escaping and invalid per-parameter escaping', () => {
const text = 'Hello {name}'
// @ts-expect-error We test calling it with an invalid value (missing)
const translation = translate('core', text, { name: { value: '<del>Name</del>' } }, undefined, { escape: true })
// `escape` needs to be an boolean, otherwise we fallback to `false` to prevent security issues
// So in this case `undefined` is falsy but we still enforce escaping as we only accept `false`
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('witout global placeholder HTML escaping and invalid per-parameter escaping', () => {
const text = 'Hello {name}'
// @ts-expect-error We test calling it with an invalid value
const translation = translate('core', text, { name: { value: '<del>Name</del>', escape: 0 } }, undefined, { escape: false })
// `escape` needs to be an boolean, otherwise we fallback to `false` to prevent security issues
expect(translation).toBe('Hallo &lt;del&gt;Name&lt;/del&gt;')
})

it('without placeholder XSS sanitizing', () => {
const text = 'Hello {name}'
const translation = translate('core', text, { name: '<img src=x onerror=alert(1)//>' }, undefined, { sanitize: false, escape: false })
Expand Down

0 comments on commit 7775257

Please sign in to comment.