From 58106ba4f5e71fb96db3445a28292cdce45ee1a5 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 13 Aug 2021 11:18:30 +0200 Subject: [PATCH 1/3] feat: Improve API for rendering raw messages and add docs --- docs/usage.md | 18 +++++ packages/use-intl/src/useTranslations.tsx | 77 ++++++++++++------ .../use-intl/test/useTranslations.test.tsx | 81 +++++++++++++------ 3 files changed, 125 insertions(+), 51 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 67182b5e4..7322b3e17 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -171,6 +171,24 @@ t('richText', { Link ``` +## Raw messages + +Messages are always parsed and therefore e.g. for rich text you need to supply the necessary tags. If you want to avoid the parsing, e.g. because you have raw HTML stored in a message, there's a separate API for this: + +```js +{ + "content": "

Headline

This is raw HTML

" +} +``` + +```js +
+``` + +**Important**: Note that you should sanitize content that you pass to `dangerouslySetInnerHTML` to avoid XSS attacks. + +The value of a raw message can be any valid JSON value: strings, booleans, objects and arrays. + ## Numbers ### `formatNumber` diff --git a/packages/use-intl/src/useTranslations.tsx b/packages/use-intl/src/useTranslations.tsx index b404eb173..894eb69aa 100644 --- a/packages/use-intl/src/useTranslations.tsx +++ b/packages/use-intl/src/useTranslations.tsx @@ -5,7 +5,6 @@ import { ReactElement, ReactNode, ReactNodeArray, - useCallback, useMemo, useRef } from 'react'; @@ -124,23 +123,27 @@ export default function useTranslations(namespace?: string) { } }, [allMessages, namespace, onError]); - const translate = useCallback( - ( + const translate = useMemo(() => { + function getFallbackFromError( + key: string, + code: IntlErrorCode, + message?: string + ) { + const error = new IntlError(code, message); + onError(error); + return getMessageFallback({error, key, namespace}); + } + + function translateFn( /** Use a dot to indicate a level of nesting (e.g. `namespace.nestedLabel`). */ key: string, /** Key value pairs for values to interpolate into the message. */ - values?: {rawMessage?: boolean} & TranslationValues, + values?: TranslationValues, /** Provide custom formats for numbers, dates and times. */ formats?: Partial - ): string | ReactElement | ReactNodeArray => { + ): string | ReactElement | ReactNodeArray { const cachedFormatsByLocale = cachedFormatsByLocaleRef.current; - function getFallbackFromError(code: IntlErrorCode, message?: string) { - const error = new IntlError(code, message); - onError(error); - return getMessageFallback({error, key, namespace}); - } - if (messagesOrError instanceof IntlError) { // We have already warned about this during render return getMessageFallback({ @@ -160,6 +163,7 @@ export default function useTranslations(namespace?: string) { message = resolvePath(messages, key, namespace); } catch (error) { return getFallbackFromError( + key, IntlErrorCode.MISSING_MESSAGE, error.message ); @@ -167,6 +171,7 @@ export default function useTranslations(namespace?: string) { if (typeof message === 'object') { return getFallbackFromError( + key, IntlErrorCode.INSUFFICIENT_PATH, __DEV__ ? `Insufficient path specified for \`${key}\` in \`${namespace}\`.` @@ -174,9 +179,6 @@ export default function useTranslations(namespace?: string) { ); } - if (values?.rawMessage === true) { - return message; - } try { messageFormat = new IntlMessageFormat( @@ -189,6 +191,7 @@ export default function useTranslations(namespace?: string) { ); } catch (error) { return getFallbackFromError( + key, IntlErrorCode.INVALID_MESSAGE, error.message ); @@ -222,21 +225,45 @@ export default function useTranslations(namespace?: string) { : String(formattedMessage); } catch (error) { return getFallbackFromError( + key, IntlErrorCode.FORMATTING_ERROR, error.message ); } - }, - [ - getMessageFallback, - globalFormats, - locale, - messagesOrError, - namespace, - onError, - timeZone - ] - ); + } + + translateFn.raw = (key: string): any => { + if (messagesOrError instanceof IntlError) { + // We have already warned about this during render + return getMessageFallback({ + error: messagesOrError, + key, + namespace + }); + } + const messages = messagesOrError; + + try { + return resolvePath(messages, key, namespace); + } catch (error) { + return getFallbackFromError( + key, + IntlErrorCode.MISSING_MESSAGE, + error.message + ); + } + }; + + return translateFn; + }, [ + getMessageFallback, + globalFormats, + locale, + messagesOrError, + namespace, + onError, + timeZone + ]); return translate; } diff --git a/packages/use-intl/test/useTranslations.test.tsx b/packages/use-intl/test/useTranslations.test.tsx index d1d5e7c6a..130772a70 100644 --- a/packages/use-intl/test/useTranslations.test.tsx +++ b/packages/use-intl/test/useTranslations.test.tsx @@ -122,32 +122,6 @@ it('handles rich text', () => { ); }); -it('can return raw messages without processing them', () => { - function Component() { - const t = useTranslations(); - return ( - - ); - } - - const {container} = render( - Test

{hello}

'}} - > - -
- ); - - expect(container.innerHTML).toBe( - 'Test

{hello}

' - ); -}); - it('handles nested rich text', () => { const {container} = renderMessage( 'This is very important', @@ -283,6 +257,61 @@ it('has a stable reference', () => { screen.getByText('2'); }); +describe('t.raw', () => { + function renderRawMessage( + message: any, + callback: (message: string) => ReactNode + ) { + function Component() { + const t = useTranslations(); + return <>{callback(t.raw('message'))}; + } + + return render( + + + + ); + } + + it('can return raw messages without processing them', () => { + const { + container + } = renderRawMessage( + 'Test

{hello}

', + (message) => + ); + + expect(container.innerHTML).toBe( + 'Test

{hello}

' + ); + }); + + it('can return objects', () => { + renderRawMessage({nested: {object: true}}, (message) => ( + {JSON.stringify(message)} + )); + }); + + it('renders a fallback for unknown messages', () => { + const onError = jest.fn(); + + function Component() { + const t = useTranslations(); + return <>{t.raw('foo')}; + } + + render( + + + + ); + + expect(onError).toHaveBeenCalled(); + screen.getByText('foo'); + }); +}); + describe('error handling', () => { it('allows to configure a fallback', () => { const onError = jest.fn(); From b1d600f8f5a99b4c01dee594d8122d6db1515081 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 13 Aug 2021 11:21:32 +0200 Subject: [PATCH 2/3] Fix lint --- packages/use-intl/src/useTranslations.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/use-intl/src/useTranslations.tsx b/packages/use-intl/src/useTranslations.tsx index 894eb69aa..b606eeced 100644 --- a/packages/use-intl/src/useTranslations.tsx +++ b/packages/use-intl/src/useTranslations.tsx @@ -179,7 +179,6 @@ export default function useTranslations(namespace?: string) { ); } - try { messageFormat = new IntlMessageFormat( message, From a60f0e7ab37574dacb7b593084d790f4c90cbd3c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 13 Aug 2021 11:26:22 +0200 Subject: [PATCH 3/3] Add assertion for object rendering --- packages/use-intl/test/useTranslations.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/use-intl/test/useTranslations.test.tsx b/packages/use-intl/test/useTranslations.test.tsx index 130772a70..f64bcce14 100644 --- a/packages/use-intl/test/useTranslations.test.tsx +++ b/packages/use-intl/test/useTranslations.test.tsx @@ -288,9 +288,11 @@ describe('t.raw', () => { }); it('can return objects', () => { - renderRawMessage({nested: {object: true}}, (message) => ( - {JSON.stringify(message)} - )); + const {container} = renderRawMessage( + {nested: {object: true}}, + (message) => {JSON.stringify(message)} + ); + expect(container.innerHTML).toBe('{"nested":{"object":true}}'); }); it('renders a fallback for unknown messages', () => {