Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add separate tag parser #3194

Closed
wants to merge 3 commits into from
Closed
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
feat: add html escaper
stepan662 committed Apr 17, 2023
commit c2949a62925746825eddbacdfa687aeb4ac1abf0
65 changes: 49 additions & 16 deletions packages/core/src/Controller/Plugins/Plugins.ts
Original file line number Diff line number Diff line change
@@ -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<ObserverMiddleware> | 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,15 +303,16 @@ 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);

const language = getLanguage();
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;
14 changes: 13 additions & 1 deletion packages/core/src/types/plugin.ts
Original file line number Diff line number Diff line change
@@ -112,13 +112,22 @@ export type WrapperMiddleware = {
export type FormatterMiddlewareFormatParams = {
translation: string;
language: string;
params: Record<string, any> | 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;
13 changes: 13 additions & 0 deletions packages/web/src/TagParser/HtmlEscaper.ts
Original file line number Diff line number Diff line change
@@ -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;
};
45 changes: 39 additions & 6 deletions packages/web/src/TagParser/TagParser.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
import { FinalFormatterMiddleware, TolgeePlugin } from '@tolgee/core';
import {
FinalFormatterMiddleware,
TolgeePlugin,
ParamsFormatterMiddleware,
TranslateParams,
} from '@tolgee/core';
import { parser } from './parser';
import { tagEscape } from './tagEscape';

function createTagParser(): FinalFormatterMiddleware {
function createParamsEscaper(): ParamsFormatterMiddleware {
return {
format: ({ translation, params }) => parser(translation, params),
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;
},
};
}

export const TagParser = (): TolgeePlugin => (tolgee, tools) => {
tools.setFinalFormatter(createTagParser());
return tolgee;
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;
};
18 changes: 14 additions & 4 deletions packages/web/src/TagParser/handleTag.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { htmlEscape } from './htmlEscape';
import { Token } from './tokenizer';

export function handleTag(
startToken: Token | undefined,
stack: Token[],
params: Record<string, any> | undefined,
fullText: string
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' &&
@@ -70,14 +80,14 @@ export function handleTag(
getParamFunc(token.name)
) {
// opening tag - call recursively
addToContent(handleTag(token, stack, params, fullText));
addToContent(handleTag(token, stack, params, fullText, escapeHtml));
} else {
// treat everything else as text
addToContent(token.text);
addToContent(escape(token.text));
}
}
if (startToken !== undefined) {
prependContent(startToken.text);
prependContent(escape(startToken.text));
}
return simplifyContent();
}
13 changes: 13 additions & 0 deletions packages/web/src/TagParser/htmlEscape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const charMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};

export function htmlEscape(text: string) {
return text?.replace(/[&<>"']/g, (match: string) => {
return charMap[match] || match;
});
}
14 changes: 12 additions & 2 deletions packages/web/src/TagParser/parser.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { handleTag } from './handleTag';
import { tokenizer } from './tokenizer';

export function parser(text: string, params?: Record<string, any>) {
export function parser(
text: string,
params?: Record<string, any>,
escapeHtml?: boolean
) {
const tokens = tokenizer(text);

const result = handleTag(undefined, [...tokens], params, text);
const result = handleTag(
undefined,
[...tokens],
params,
text,
Boolean(escapeHtml)
);

return result;
}
39 changes: 39 additions & 0 deletions packages/web/src/TagParser/tagEscape.ts
Original file line number Diff line number Diff line change
@@ -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;
}
19 changes: 17 additions & 2 deletions packages/web/src/TagParser/tokenizer.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { CHAR_ESCAPE, ESCAPABLE_CHARS } from './tagEscape';

const STATE_TEXT = 0,
STATE_TAG_START = 1,
STATE_TAG_NAME = 2,
STATE_SELF_CLOSING = 3;
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_SELF_CLOSING
| typeof STATE_ESCAPE_MAYBE;

export type Token = {
type: 'text' | 'tag';
@@ -63,6 +67,9 @@ export function tokenizer(source: string) {
createToken('text');
state = STATE_TAG_START;
}
if (char === CHAR_ESCAPE) {
state = STATE_ESCAPE_MAYBE;
}
text += char;
break;

@@ -109,6 +116,14 @@ export function tokenizer(source: string) {
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');
44 changes: 44 additions & 0 deletions packages/web/src/__test__/htmlEscaper.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '<a>hello</a>',
nested: '<a>hello <b>world</b></a>',
with_param: '<a>{param}</a>',
invalid: '<a href="test">{param}</a>',
},
},
});
});

it('manages simple element', () => {
expect(tolgee.t('simple')).toEqual('&lt;a&gt;hello&lt;/a&gt;');
});

it('manages nested element', () => {
expect(tolgee.t('nested')).toEqual(
'&lt;a&gt;hello &lt;b&gt;world&lt;/b&gt;&lt;/a&gt;'
);
});

it('manages params', () => {
expect(tolgee.t('with_param', { param: '<a>inside</a>' })).toEqual(
'&lt;a&gt;&lt;a&gt;inside&lt;/a&gt;&lt;/a&gt;'
);
});

it('manages complex html', () => {
expect(tolgee.t('invalid', { param: 'inside' })).toEqual(
'&lt;a href=&quot;test&quot;&gt;inside&lt;/a&gt;'
);
});
});
80 changes: 80 additions & 0 deletions packages/web/src/__test__/tagParser.test.tsx
Original file line number Diff line number Diff line change
@@ -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}</${name}>`;
}

describe('tag parser', () => {
let tolgee: TolgeeInstance;
beforeEach(() => {
tolgee = TolgeeCore()
.use(FormatSimple())
.init({
language: 'en',
staticData: {
en: {
simple: '<a>hello</a>',
nested: '<a>hello <b>world</b></a>',
with_param: '<a>{param}</a>',
invalid: '<a href="test">{param}</a>',
unclosed: '<a>test</b>',
},
},
});
});

it('manages simple element', () => {
tolgee.addPlugin(TagParser());
expect(tolgee.t('simple', { a: textElement('A') })).toEqual('<A>hello</A>');
});

it('manages nested element', () => {
tolgee.addPlugin(TagParser());
expect(
tolgee.t('nested', { a: textElement('A'), b: textElement('B') })
).toEqual('<A>hello <B>world</B></A>');
});

it('manages param', () => {
tolgee.addPlugin(TagParser());
expect(
tolgee.t('with_param', { a: textElement('A'), param: 'inside' })
).toEqual('<A>inside</A>');
});

it('escapes param', () => {
tolgee.addPlugin(TagParser());
expect(
tolgee.t('with_param', { a: textElement('A'), param: '<a>inside</a>' })
).toEqual('<A><a>inside</a></A>');
});

it('keeps invalid tag', () => {
tolgee.addPlugin(TagParser());
expect(
tolgee.t('invalid', { a: textElement('A'), param: 'inside' })
).toEqual('<a href="test">inside</a>');
});

it('escapes invalid html', () => {
tolgee.addPlugin(TagParser({ escapeHtml: true }));
expect(
tolgee.t('invalid', { a: textElement('A'), param: 'inside' })
).toEqual('&lt;a href=&quot;test&quot;&gt;inside&lt;/a&gt;');
});

it('escapes html in params', () => {
tolgee.addPlugin(TagParser({ escapeHtml: true }));
expect(
tolgee.t('with_param', { a: textElement('A'), param: '<a>inside</a>' })
).toEqual('<A>&lt;a&gt;inside&lt;/a&gt;</A>');
});

it('escapes unclosed tag', () => {
tolgee.addPlugin(TagParser({ escapeHtml: true }));
expect(
tolgee.t('unclosed', { a: textElement('A'), b: textElement('B') })
).toEqual('&lt;a&gt;test&lt;/b&gt;');
});
});
1 change: 1 addition & 0 deletions packages/web/src/typedIndex.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ 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,
2 changes: 1 addition & 1 deletion testapps/react/src/TranslationMethods.tsx
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ export const TranslationMethods = () => {
params={{
b: <b />,
i: <i />,
key: '<b>value</b>',
key: 'value',
}}
>
Hey