diff --git a/packages/core/src/Controller/Plugins/Plugins.ts b/packages/core/src/Controller/Plugins/Plugins.ts index 01cf8b7a2a..d5e27795b2 100644 --- a/packages/core/src/Controller/Plugins/Plugins.ts +++ b/packages/core/src/Controller/Plugins/Plugins.ts @@ -22,6 +22,7 @@ import { TolgeeOptionsInternal, FormatErrorHandler, FindPositionsInterface, + ParamsFormatterMiddleware, } from '../../types'; import { DEFAULT_FORMAT_ERROR } from '../State/initState'; @@ -39,6 +40,7 @@ export function Plugins( const instances = { formatters: [] as FormatterMiddleware[], + paramsFormatters: [] as ParamsFormatterMiddleware[], finalFormatter: undefined as FinalFormatterMiddleware | undefined, observer: undefined as ReturnType | undefined, devBackend: undefined as BackendDevMiddleware | undefined, @@ -95,6 +97,14 @@ export function Plugins( } } + function addParamsFormatter( + formatter: ParamsFormatterMiddleware | undefined + ) { + if (formatter) { + instances.paramsFormatters.push(formatter); + } + } + function setFinalFormatter(formatter: FinalFormatterMiddleware | undefined) { instances.finalFormatter = formatter; } @@ -143,6 +153,7 @@ export function Plugins( const pluginTools = Object.freeze({ setFinalFormatter, addFormatter, + addParamsFormatter, setObserver, hasObserver, setUi, @@ -155,8 +166,28 @@ export function Plugins( plugin(tolgeeInstance, pluginTools); } + function handleFormatError( + e: any, + result: string, + props: TranslatePropsInternal + ) { + // eslint-disable-next-line no-console + console.error(e); + const errorMessage = getErrorMessage(e) || DEFAULT_FORMAT_ERROR; + const onFormatError = getInitialOptions().onFormatError; + const formatErrorType = typeof onFormatError; + if (formatErrorType === 'string') { + result = onFormatError as string; + } else if (formatErrorType === 'function') { + result = (onFormatError as FormatErrorHandler)(errorMessage, props); + } else { + result = DEFAULT_FORMAT_ERROR; + } + return result; + } + const self = Object.freeze({ - addPlugin, + addPlugin: addPlugin, run() { const { apiKey, apiUrl, projectId, observerOptions } = @@ -272,8 +303,7 @@ export function Plugins( formatEnabled, ...props }: TranslatePropsInternal & { formatEnabled?: boolean }) { - const { key, translation, defaultValue, noWrap, params, orEmpty, ns } = - props; + const { key, translation, defaultValue, noWrap, orEmpty, ns } = props; const formattableTranslation = translation || defaultValue; let result = formattableTranslation || (orEmpty ? '' : key); @@ -281,6 +311,8 @@ export function Plugins( const isFormatEnabled = formatEnabled || !instances.observer?.outputNotFormattable; + let params = props.params || {}; + const wrap = (result: string) => { if (instances.observer && !noWrap) { return instances.observer.wrap({ @@ -294,6 +326,18 @@ export function Plugins( return result; }; + try { + if (language) { + for (const formatter of instances.paramsFormatters) { + params = formatter.format({ language, params }); + } + } + } catch (e: any) { + result = handleFormatError(e, result, props); + // wrap error message, so it's detectable + return wrap(result); + } + result = wrap(result); try { if (formattableTranslation && language && isFormatEnabled) { @@ -318,20 +362,9 @@ export function Plugins( }); } } catch (e: any) { - // eslint-disable-next-line no-console - console.error(e); - const errorMessage = getErrorMessage(e) || DEFAULT_FORMAT_ERROR; - const onFormatError = getInitialOptions().onFormatError; - const formatErrorType = typeof onFormatError; - if (formatErrorType === 'string') { - result = onFormatError as string; - } else if (formatErrorType === 'function') { - result = (onFormatError as FormatErrorHandler)(errorMessage, props); - } else { - result = DEFAULT_FORMAT_ERROR; - } + result = handleFormatError(e, result, props); // wrap error message, so it's detectable - result = wrap(result); + return wrap(result); } return result; diff --git a/packages/core/src/FormatSimple/FormatSimple.ts b/packages/core/src/FormatSimple/FormatSimple.ts index 1e76e746e1..c834b3e6fa 100644 --- a/packages/core/src/FormatSimple/FormatSimple.ts +++ b/packages/core/src/FormatSimple/FormatSimple.ts @@ -8,6 +8,6 @@ function createFormatSimple(): FinalFormatterMiddleware { } export const FormatSimple = (): TolgeePlugin => (tolgee, tools) => { - tools.setFinalFormatter(createFormatSimple()); + tools.addFormatter(createFormatSimple()); return tolgee; }; diff --git a/packages/core/src/FormatSimple/FormatError.ts b/packages/core/src/FormatSimple/FormatSimpleError.ts similarity index 93% rename from packages/core/src/FormatSimple/FormatError.ts rename to packages/core/src/FormatSimple/FormatSimpleError.ts index 9ca3c169b0..1364ba055e 100644 --- a/packages/core/src/FormatSimple/FormatError.ts +++ b/packages/core/src/FormatSimple/FormatSimpleError.ts @@ -7,7 +7,7 @@ export type ErrorCode = | typeof ERROR_UNEXPECTED_CHAR | typeof ERROR_UNEXPECTED_END; -export class FormatError extends Error { +export class FormatSimpleError extends Error { public readonly code: ErrorCode; public readonly index: number; constructor(code: ErrorCode, index: number, text: string) { diff --git a/packages/core/src/FormatSimple/formatParser.ts b/packages/core/src/FormatSimple/formatParser.ts index 89c8f3754a..eb9ae0a458 100644 --- a/packages/core/src/FormatSimple/formatParser.ts +++ b/packages/core/src/FormatSimple/formatParser.ts @@ -3,8 +3,8 @@ import { ERROR_PARAM_EMPTY, ERROR_UNEXPECTED_CHAR, ERROR_UNEXPECTED_END, - FormatError, -} from './FormatError'; + FormatSimpleError, +} from './FormatSimpleError'; function isWhitespace(ch: string) { return /\s/.test(ch); @@ -46,7 +46,7 @@ export function formatParser(translation: string) { let i = 0; function parsingError(code: ErrorCode): never { - throw new FormatError(code, i, translation); + throw new FormatSimpleError(code, i, translation); } const addText = () => { diff --git a/packages/core/src/FormatSimple/formatter.test.ts b/packages/core/src/FormatSimple/formatter.test.ts index da17c59b83..4052790a8f 100644 --- a/packages/core/src/FormatSimple/formatter.test.ts +++ b/packages/core/src/FormatSimple/formatter.test.ts @@ -6,8 +6,8 @@ import { ERROR_PARAM_EMPTY, ERROR_UNEXPECTED_CHAR, ERROR_UNEXPECTED_END, - FormatError, -} from './FormatError'; + FormatSimpleError, +} from './FormatSimpleError'; function icu(text: string, params?: TranslateParams) { return new IntlMessageFormat(text, 'en', undefined, { @@ -29,13 +29,13 @@ function expectToThrow( code?: ErrorCode, params?: TranslateParams ) { - let error: FormatError | undefined = undefined; + let error: FormatSimpleError | undefined = undefined; try { formatter(text, params); } catch (e) { - error = e as FormatError; + error = e as FormatSimpleError; } - expect(error).toBeInstanceOf(FormatError); + expect(error).toBeInstanceOf(FormatSimpleError); expect(error?.code).toEqual(code); } diff --git a/packages/core/src/types/plugin.ts b/packages/core/src/types/plugin.ts index 925303b75c..5cf476b083 100644 --- a/packages/core/src/types/plugin.ts +++ b/packages/core/src/types/plugin.ts @@ -112,13 +112,22 @@ export type WrapperMiddleware = { export type FormatterMiddlewareFormatParams = { translation: string; language: string; - params: Record | undefined; + params: TranslateParams; }; export type FormatterMiddleware = { format: (props: FormatterMiddlewareFormatParams) => string; }; +export type ParamsFormatterMiddlewareFormatParams = { + language: string; + params: TranslateParams; +}; + +export type ParamsFormatterMiddleware = { + format: (props: ParamsFormatterMiddlewareFormatParams) => TranslateParams; +}; + export type TranslationOnClick = (data: { keysAndDefaults: KeyAndParams[]; event: any; @@ -168,6 +177,9 @@ export interface UiInterface { export type PluginTools = Readonly<{ setFinalFormatter: (formatter: FinalFormatterMiddleware | undefined) => void; addFormatter: (formatter: FormatterMiddleware | undefined) => void; + addParamsFormatter: ( + formatter: ParamsFormatterMiddleware | undefined + ) => void; setObserver: (observer: ObserverMiddleware | undefined) => void; hasObserver: () => boolean; setUi: (ui: UiMiddleware | undefined) => void; diff --git a/packages/format-icu/src/FormatIcu.ts b/packages/format-icu/src/FormatIcu.ts index 2798bc90e7..5f3bb22520 100644 --- a/packages/format-icu/src/FormatIcu.ts +++ b/packages/format-icu/src/FormatIcu.ts @@ -2,6 +2,6 @@ import { TolgeePlugin } from '@tolgee/core'; import { createFormatIcu } from './createFormatIcu'; export const FormatIcu = (): TolgeePlugin => (tolgee, tools) => { - tools.setFinalFormatter(createFormatIcu()); + tools.addFormatter(createFormatIcu()); return tolgee; }; diff --git a/packages/format-icu/src/createFormatIcu.ts b/packages/format-icu/src/createFormatIcu.ts index c8ff891eaa..5329672bdb 100644 --- a/packages/format-icu/src/createFormatIcu.ts +++ b/packages/format-icu/src/createFormatIcu.ts @@ -29,14 +29,10 @@ export const createFormatIcu = (): FinalFormatterMiddleware => { language, params, }) => { - const ignoreTag = !Object.values(params || {}).find( - (p) => typeof p === 'function' - ); - const locale = getLocale(language); return new IntlMessageFormat(translation, locale, undefined, { - ignoreTag, + ignoreTag: true, }).format(params); }; diff --git a/packages/react/src/__integration/T.spec.tsx b/packages/react/src/__integration/T.spec.tsx index ed5a067a5e..1c2b01849b 100644 --- a/packages/react/src/__integration/T.spec.tsx +++ b/packages/react/src/__integration/T.spec.tsx @@ -2,7 +2,13 @@ import { act } from 'react-dom/test-utils'; import React from 'react'; import '@testing-library/jest-dom'; import { render, screen, waitFor } from '@testing-library/react'; -import { TolgeeProvider, DevTools, TolgeeInstance, Tolgee } from '../index'; +import { + TolgeeProvider, + DevTools, + TolgeeInstance, + Tolgee, + TagParser, +} from '../index'; import { FormatIcu } from '@tolgee/format-icu'; import { mockCoreFetch } from '@tolgee/testing/fetchMock'; @@ -69,7 +75,7 @@ describe('T component integration', () => { beforeEach(async () => { fetch.enableMocks(); - tolgee = Tolgee().use(DevTools()).use(FormatIcu()).init({ + tolgee = Tolgee().use(DevTools()).use(TagParser()).use(FormatIcu()).init({ apiUrl: API_URL, apiKey: API_KEY, language: 'cs', diff --git a/packages/web/src/TagParser/HtmlEscaper.ts b/packages/web/src/TagParser/HtmlEscaper.ts new file mode 100644 index 0000000000..8f3b419487 --- /dev/null +++ b/packages/web/src/TagParser/HtmlEscaper.ts @@ -0,0 +1,13 @@ +import { FinalFormatterMiddleware, TolgeePlugin } from '@tolgee/core'; +import { htmlEscape } from './htmlEscape'; + +function createTranslationEscaper(): FinalFormatterMiddleware { + return { + format: ({ translation }) => htmlEscape(translation), + }; +} + +export const HtmlEscaper = (): TolgeePlugin => (tolgee, tools) => { + tools.setFinalFormatter(createTranslationEscaper()); + return tolgee; +}; diff --git a/packages/web/src/TagParser/TagParser.ts b/packages/web/src/TagParser/TagParser.ts new file mode 100644 index 0000000000..99687046c9 --- /dev/null +++ b/packages/web/src/TagParser/TagParser.ts @@ -0,0 +1,46 @@ +import { + FinalFormatterMiddleware, + TolgeePlugin, + ParamsFormatterMiddleware, + TranslateParams, +} from '@tolgee/core'; +import { parser } from './parser'; +import { tagEscape } from './tagEscape'; + +function createParamsEscaper(): ParamsFormatterMiddleware { + return { + format({ params }) { + const result: TranslateParams = {}; + Object.entries(params).forEach(([key, value]) => { + if (typeof value === 'string') { + result[key] = tagEscape(value); + } else { + result[key] = value; + } + }); + return result; + }, + }; +} + +function createTagParser(escapeHtml: boolean): FinalFormatterMiddleware { + return { + format: ({ translation, params }) => + parser(translation, params, escapeHtml), + }; +} + +type Options = { + escapeParams?: boolean; + escapeHtml?: boolean; +}; + +export const TagParser = + (options?: Options): TolgeePlugin => + (tolgee, tools) => { + if (options?.escapeParams ?? true) { + tools.addParamsFormatter(createParamsEscaper()); + } + tools.setFinalFormatter(createTagParser(Boolean(options?.escapeHtml))); + return tolgee; + }; diff --git a/packages/web/src/TagParser/handleTag.ts b/packages/web/src/TagParser/handleTag.ts new file mode 100644 index 0000000000..f782e31a44 --- /dev/null +++ b/packages/web/src/TagParser/handleTag.ts @@ -0,0 +1,93 @@ +import { htmlEscape } from './htmlEscape'; +import { Token } from './tokenizer'; + +export function handleTag( + startToken: Token | undefined, + stack: Token[], + params: Record | undefined, + fullText: string, + escapeHtml: boolean +) { + let token: Token | undefined; + let content: any[] = []; + + function escape(text: any) { + if (typeof text === 'string' && escapeHtml) { + return htmlEscape(text); + } else { + return text; + } + } + + function addToContent(item: any) { + if ( + typeof content[content.length - 1] === 'string' && + typeof item === 'string' + ) { + content[content.length - 1] += item; + } else { + content.push(item); + } + } + + function prependContent(item: any) { + if (typeof content[0] === 'string' && typeof item === 'string') { + content[0] = item + content[0]; + } else { + content = [item, ...content]; + } + } + + function simplifyContent() { + if (content.length === 0) { + return undefined; + } else if (content.length === 1 && typeof content[0] === 'string') { + return content[0]; + } else { + return content; + } + } + + function getParamFunc(name: string) { + const func = params?.[name]; + if (typeof func === 'function') { + return func; + } + return undefined; + } + + while ((token = stack.shift())) { + if ( + token.type === 'tag' && + token.closing && + startToken !== undefined && + token.name === startToken.name + ) { + // matching tag to startToken - closing + const fun = getParamFunc(startToken.name); + return fun(simplifyContent()); + } else if ( + token.type === 'tag' && + token.selfClosing && + getParamFunc(token.name) + ) { + // self-closing - solve in-place + const fun = getParamFunc(token.name); + addToContent(fun()); + } else if ( + token.type === 'tag' && + !token.closing && + getParamFunc(token.name) + ) { + // opening tag - call recursively + addToContent(handleTag(token, stack, params, fullText, escapeHtml)); + } else { + // treat everything else as text + addToContent(escape(token.text)); + } + } + if (startToken !== undefined) { + prependContent(escape(startToken.text)); + } + return simplifyContent(); +} diff --git a/packages/web/src/TagParser/htmlEscape.ts b/packages/web/src/TagParser/htmlEscape.ts new file mode 100644 index 0000000000..f338b63e06 --- /dev/null +++ b/packages/web/src/TagParser/htmlEscape.ts @@ -0,0 +1,13 @@ +const charMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +export function htmlEscape(text: string) { + return text?.replace(/[&<>"']/g, (match: string) => { + return charMap[match] || match; + }); +} diff --git a/packages/web/src/TagParser/parser.test.ts b/packages/web/src/TagParser/parser.test.ts new file mode 100644 index 0000000000..67452fc5d7 --- /dev/null +++ b/packages/web/src/TagParser/parser.test.ts @@ -0,0 +1,131 @@ +import { parser } from './parser'; + +function getText() { + return expect.getState().currentTestName.replace('parser ', ''); +} + +function textElement(name: string) { + return (content: string) => `<${name}>${content}`; +} + +function objectElement(name: string) { + return (content: string) => ({ name, content }); +} + +describe('parser', () => { + it('Text element!', () => { + expect( + parser(getText(), { + a: textElement('A'), + }) + ).toEqual('Text element!'); + }); + + it('Object element!', () => { + expect( + parser(getText(), { + a: objectElement('A'), + }) + ).toEqual(['Object ', { name: 'A', content: 'element' }, '!']); + }); + + it('Ignored element!', () => { + expect(parser(getText())).toEqual('Ignored element!'); + }); + + it('Text
self closing', () => { + expect( + parser(getText(), { + br: textElement('BR'), + }) + ).toEqual('Text
undefined
self closing'); + }); + + it('Object
self closing', () => { + expect( + parser(getText(), { + br: objectElement('BR'), + }) + ).toEqual(['Object ', { name: 'BR', content: undefined }, ' self closing']); + }); + + it('Ignored
self closing', () => { + expect(parser(getText())).toEqual('Ignored
self closing'); + }); + + it('Text nested element and more', () => { + expect( + parser(getText(), { + a: textElement('A'), + b: textElement('B'), + }) + ).toEqual('Text nested element and more'); + }); + + it('Object nested element and more', () => { + expect( + parser(getText(), { + a: objectElement('A'), + b: objectElement('B'), + }) + ).toEqual([ + 'Object ', + { + name: 'A', + content: ['nested ', { name: 'B', content: 'element' }, ' and'], + }, + ' more', + ]); + }); + + it('Ignored nested element and more', () => { + expect(parser(getText())).toEqual( + 'Ignored nested element and more' + ); + }); + + it('test', () => { + expect(parser(getText(), { a: textElement('A') })).toEqual('test'); + }); + + it('ignored when no matching parameter', () => { + expect(parser(getText())).toEqual( + 'ignored when no matching parameter' + ); + }); + + it('Ignored when no function param tag', () => { + expect(parser(getText())).toEqual('Ignored when no function param tag'); + }); + + it('Ignored when unclosed tag', () => { + expect(parser(getText(), { a: textElement('A') })).toEqual( + 'Ignored when unclosed tag' + ); + }); + + it('Text handles white at allowed places test', () => { + expect(parser(getText(), { a: textElement('A') })).toEqual( + 'Text handles white at allowed places test' + ); + }); + + it('Ignored with spaces test', () => { + expect(parser(getText())).toEqual('Ignored with spaces test'); + }); + + it('Ignored partly when crossed first second third', () => { + expect( + parser(getText(), { + a: textElement('A'), + b: textElement('B'), + }) + ).toEqual('Ignored partly when crossed first second third'); + }); + + it('Ignored with params test', () => { + expect(parser(getText(), { a: textElement('A') })).toEqual( + 'Ignored with params test' + ); + }); +}); diff --git a/packages/web/src/TagParser/parser.ts b/packages/web/src/TagParser/parser.ts new file mode 100644 index 0000000000..6780ec742b --- /dev/null +++ b/packages/web/src/TagParser/parser.ts @@ -0,0 +1,20 @@ +import { handleTag } from './handleTag'; +import { tokenizer } from './tokenizer'; + +export function parser( + text: string, + params?: Record, + escapeHtml?: boolean +) { + const tokens = tokenizer(text); + + const result = handleTag( + undefined, + [...tokens], + params, + text, + Boolean(escapeHtml) + ); + + return result; +} diff --git a/packages/web/src/TagParser/tagEscape.ts b/packages/web/src/TagParser/tagEscape.ts new file mode 100644 index 0000000000..541f892be9 --- /dev/null +++ b/packages/web/src/TagParser/tagEscape.ts @@ -0,0 +1,39 @@ +const STATE_TEXT = 0, + STATE_ESCAPE_MAYBE = 1; + +type State = typeof STATE_TEXT | typeof STATE_ESCAPE_MAYBE; + +export const CHAR_ESCAPE = '\\'; + +export const ESCAPABLE_CHARS = new Set(['<', '>', CHAR_ESCAPE]); + +export function tagEscape(source: string) { + let state: State = STATE_TEXT; + let text = ''; + + let i = 0; + + for (i = 0; i < source.length; i++) { + const char = source[i]; + switch (state) { + case STATE_TEXT: + if (char === '<') { + text += CHAR_ESCAPE; + } + if (char === CHAR_ESCAPE) { + state = STATE_ESCAPE_MAYBE; + } + text += char; + break; + + case STATE_ESCAPE_MAYBE: + if (ESCAPABLE_CHARS.has(char)) { + text = text.slice(0, -1) + char; + } else { + text += char; + } + state = STATE_TEXT; + } + } + return text; +} diff --git a/packages/web/src/TagParser/tokenizer.ts b/packages/web/src/TagParser/tokenizer.ts new file mode 100644 index 0000000000..c7d5763c15 --- /dev/null +++ b/packages/web/src/TagParser/tokenizer.ts @@ -0,0 +1,131 @@ +import { CHAR_ESCAPE, ESCAPABLE_CHARS } from './tagEscape'; + +const STATE_TEXT = 0, + STATE_TAG_START = 1, + STATE_TAG_NAME = 2, + STATE_SELF_CLOSING = 3, + STATE_ESCAPE_MAYBE = 4; + +type State = + | typeof STATE_TEXT + | typeof STATE_TAG_START + | typeof STATE_TAG_NAME + | typeof STATE_SELF_CLOSING + | typeof STATE_ESCAPE_MAYBE; + +export type Token = { + type: 'text' | 'tag'; + name: string; + closing: boolean; + selfClosing: boolean; + position: number; + text: string; +}; + +function isWhite(char: string) { + return /\s/g.test(char); +} + +function isNameChar(char: string) { + return /[A-Za-z0-9]/.test(char); +} + +export function tokenizer(source: string) { + const tokens: Token[] = []; + let state: State = STATE_TEXT; + let name = ''; + let closing = false; + let selfClosing = false; + let tokenPosition = 0; + let text = ''; + + let i = 0; + + function createToken(type: Token['type']) { + if (text.length) { + tokens.push({ + type, + name, + closing, + selfClosing, + position: tokenPosition, + text, + }); + closing = false; + selfClosing = false; + name = ''; + tokenPosition = i; + text = ''; + } + } + + for (i = 0; i < source.length; i++) { + const char = source[i]; + switch (state) { + case STATE_TEXT: + if (char === '<') { + createToken('text'); + state = STATE_TAG_START; + } + if (char === CHAR_ESCAPE) { + state = STATE_ESCAPE_MAYBE; + } + text += char; + break; + + case STATE_TAG_START: + text += char; + if (char === '/' && !closing) { + closing = true; + } else if (isNameChar(char)) { + name += char; + state = STATE_TAG_NAME; + } else { + // invalid tag + state = STATE_TEXT; + } + break; + + case STATE_TAG_NAME: + text += char; + if (char === '>') { + createToken('tag'); + state = STATE_TEXT; + } else if (isNameChar(char)) { + name += char.toLowerCase(); + } else if (char === '/' && name !== '') { + // self-closing slash + selfClosing = true; + state = STATE_SELF_CLOSING; + } else if (isWhite(char) && name !== '') { + // skiping white spaces after tag name + break; + } else { + // invalid tag - ignoring + state = STATE_TEXT; + } + break; + + case STATE_SELF_CLOSING: + text += char; + if (char === '>') { + createToken('tag'); + state = STATE_TEXT; + } else { + // invalid tag - ignoring + state = STATE_TEXT; + } + break; + + case STATE_ESCAPE_MAYBE: + if (ESCAPABLE_CHARS.has(char)) { + text = text.slice(0, -1) + char; + } else { + text += char; + } + state = STATE_TEXT; + } + } + createToken('text'); + return tokens; +} diff --git a/packages/web/src/__test__/htmlEscaper.test.tsx b/packages/web/src/__test__/htmlEscaper.test.tsx new file mode 100644 index 0000000000..fe08f766de --- /dev/null +++ b/packages/web/src/__test__/htmlEscaper.test.tsx @@ -0,0 +1,44 @@ +import { FormatSimple, TolgeeCore, TolgeeInstance } from '@tolgee/core'; +import { HtmlEscaper } from '../typedIndex'; + +describe('tag parser', () => { + let tolgee: TolgeeInstance; + beforeEach(() => { + tolgee = TolgeeCore() + .use(HtmlEscaper()) + .use(FormatSimple()) + .init({ + language: 'en', + staticData: { + en: { + simple: 'hello', + nested: 'hello world', + with_param: '{param}', + invalid: '{param}', + }, + }, + }); + }); + + it('manages simple element', () => { + expect(tolgee.t('simple')).toEqual('<a>hello</a>'); + }); + + it('manages nested element', () => { + expect(tolgee.t('nested')).toEqual( + '<a>hello <b>world</b></a>' + ); + }); + + it('manages params', () => { + expect(tolgee.t('with_param', { param: 'inside' })).toEqual( + '<a><a>inside</a></a>' + ); + }); + + it('manages complex html', () => { + expect(tolgee.t('invalid', { param: 'inside' })).toEqual( + '<a href="test">inside</a>' + ); + }); +}); diff --git a/packages/web/src/__test__/tagParser.test.tsx b/packages/web/src/__test__/tagParser.test.tsx new file mode 100644 index 0000000000..2f6d5c32ee --- /dev/null +++ b/packages/web/src/__test__/tagParser.test.tsx @@ -0,0 +1,80 @@ +import { FormatSimple, TolgeeCore, TolgeeInstance } from '@tolgee/core'; +import { TagParser } from '../typedIndex'; + +function textElement(name: string): any { + return (content: string) => `<${name}>${content}`; +} + +describe('tag parser', () => { + let tolgee: TolgeeInstance; + beforeEach(() => { + tolgee = TolgeeCore() + .use(FormatSimple()) + .init({ + language: 'en', + staticData: { + en: { + simple: 'hello', + nested: 'hello world', + with_param: '{param}', + invalid: '{param}', + unclosed: 'test', + }, + }, + }); + }); + + it('manages simple element', () => { + tolgee.addPlugin(TagParser()); + expect(tolgee.t('simple', { a: textElement('A') })).toEqual('hello'); + }); + + it('manages nested element', () => { + tolgee.addPlugin(TagParser()); + expect( + tolgee.t('nested', { a: textElement('A'), b: textElement('B') }) + ).toEqual('hello world'); + }); + + it('manages param', () => { + tolgee.addPlugin(TagParser()); + expect( + tolgee.t('with_param', { a: textElement('A'), param: 'inside' }) + ).toEqual('inside'); + }); + + it('escapes param', () => { + tolgee.addPlugin(TagParser()); + expect( + tolgee.t('with_param', { a: textElement('A'), param: 'inside' }) + ).toEqual('inside'); + }); + + it('keeps invalid tag', () => { + tolgee.addPlugin(TagParser()); + expect( + tolgee.t('invalid', { a: textElement('A'), param: 'inside' }) + ).toEqual('inside'); + }); + + it('escapes invalid html', () => { + tolgee.addPlugin(TagParser({ escapeHtml: true })); + expect( + tolgee.t('invalid', { a: textElement('A'), param: 'inside' }) + ).toEqual('<a href="test">inside</a>'); + }); + + it('escapes html in params', () => { + tolgee.addPlugin(TagParser({ escapeHtml: true })); + expect( + tolgee.t('with_param', { a: textElement('A'), param: 'inside' }) + ).toEqual('<a>inside</a>'); + }); + + it('escapes unclosed tag', () => { + tolgee.addPlugin(TagParser({ escapeHtml: true })); + expect( + tolgee.t('unclosed', { a: textElement('A'), b: textElement('B') }) + ).toEqual('<a>test</b>'); + }); +}); diff --git a/packages/web/src/typedIndex.ts b/packages/web/src/typedIndex.ts index 08d0b144fc..49b5f1ed28 100644 --- a/packages/web/src/typedIndex.ts +++ b/packages/web/src/typedIndex.ts @@ -5,6 +5,8 @@ export { BrowserExtensionPlugin } from './BrowserExtensionPlugin/BrowserExtensio export { LanguageStorage } from './LanguageStorage'; export { LanguageDetector } from './LanguageDetector'; export { BackendFetch } from './BackendFetch'; +export { TagParser } from './TagParser/TagParser'; +export { HtmlEscaper } from './TagParser/HtmlEscaper'; export { TOLGEE_WRAPPED_ONLY_DATA_ATTRIBUTE, TOLGEE_ATTRIBUTE_NAME, diff --git a/testapps/react/src/App.tsx b/testapps/react/src/App.tsx index 603790a33e..6761b7c4ca 100644 --- a/testapps/react/src/App.tsx +++ b/testapps/react/src/App.tsx @@ -1,5 +1,11 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import { Tolgee, TolgeeProvider, BackendFetch, DevTools } from '@tolgee/react'; +import { + Tolgee, + TolgeeProvider, + BackendFetch, + DevTools, + TagParser, +} from '@tolgee/react'; import { Todos } from './Todos'; import { TranslationMethods } from './TranslationMethods'; @@ -8,6 +14,7 @@ import { FormatIcu } from '@tolgee/format-icu'; const tolgee = Tolgee() .use(DevTools()) .use(FormatIcu()) + .use(TagParser()) .use(BackendFetch()) .init({ availableLanguages: ['en', 'cs', 'fr', 'de'], diff --git a/testapps/react/src/TranslationMethods.tsx b/testapps/react/src/TranslationMethods.tsx index 8b7b46bcd7..4c6b76ade8 100644 --- a/testapps/react/src/TranslationMethods.tsx +++ b/testapps/react/src/TranslationMethods.tsx @@ -74,7 +74,7 @@ export const TranslationMethods = () => { params={{ br:
, }} - defaultValue="Key with br

tag" + defaultValue="Key with br
tag" />