diff --git a/dev/test-studio/components/TranslateExample.tsx b/dev/test-studio/components/TranslateExample.tsx index 279765bf3b1..9bd95d44b5c 100644 --- a/dev/test-studio/components/TranslateExample.tsx +++ b/dev/test-studio/components/TranslateExample.tsx @@ -8,11 +8,20 @@ export function TranslateExample() { {t('use-translation.with-html')} - + + {t('use-translation.interpolation-example', { + spaces: 'spaces', + doesNot: 'does not have spaces', + })} + + + {t('translate.with-formatter', { + countries: ['Norway', 'Denmark', 'Sweden'], + })} + - + + hello', + }} + /> + + + + + ) diff --git a/dev/test-studio/locales/index.ts b/dev/test-studio/locales/index.ts index 5d36073f428..0a60e5d9b73 100644 --- a/dev/test-studio/locales/index.ts +++ b/dev/test-studio/locales/index.ts @@ -6,7 +6,11 @@ const enUSStrings = { 'structure.root.title': 'Content 🇺🇸', 'translate.example': ' Your search for "{{keyword}}" took {{duration}}ms', + 'translate.with-xml-in-value': + 'This value has XML in the interpolated value: {{value}}', + 'translate.with-formatter': 'This value has a list-formatter: {{countries, list}}', 'use-translation.with-html': 'Apparently, code is an HTML element?', + 'use-translation.interpolation-example': 'This has {{ spaces }} around it, this one {{doesNot}}', } const enUS = defineLocaleResourceBundle({ @@ -22,6 +26,8 @@ const nbNO = defineLocaleResourceBundle({ 'structure.root.title': 'Innhold 🇳🇴', 'translate.example': ' Ditt søk på "{{keyword}}" tok {{duration}} millisekunder', + 'translate.with-xml-in-value': + 'Denne verdien har XML i en interpolert verdi: {{value}}', 'use-translation.with-html': 'Faktisk er code et HTML-element?', }, }) diff --git a/packages/sanity/src/core/i18n/Translate.tsx b/packages/sanity/src/core/i18n/Translate.tsx index b812ad1ce79..3b36ba0dba9 100644 --- a/packages/sanity/src/core/i18n/Translate.tsx +++ b/packages/sanity/src/core/i18n/Translate.tsx @@ -98,7 +98,7 @@ export function Translate(props: TranslationProps) { */ const translated = props.t(props.i18nKey, { context: props.context, - replace: props.values, + skipInterpolation: true, count: props.values && 'count' in props.values && typeof props.values.count === 'number' ? props.values.count @@ -106,20 +106,33 @@ export function Translate(props: TranslationProps) { }) const tokens = useMemo(() => simpleParser(translated), [translated]) - - return <>{render(tokens, props.components || {})} + return <>{render(tokens, props.values, props.components || {})} } -function render(tokens: Token[], componentMap: TranslateComponentMap): ReactNode { +function render( + tokens: Token[], + values: TranslationProps['values'], + componentMap: TranslateComponentMap, +): ReactNode { const [head, ...tail] = tokens if (!head) { return null } + if (head.type === 'interpolation') { + return ( + <> + {!values || typeof values[head.variable] === 'undefined' + ? `{{${head.variable}}}` + : values[head.variable]} + {render(tail, values, componentMap)} + + ) + } if (head.type === 'text') { return ( <> {head.text} - {render(tail, componentMap)} + {render(tail, values, componentMap)} ) } @@ -132,7 +145,7 @@ function render(tokens: Token[], componentMap: TranslateComponentMap): ReactNode return ( <> - {render(tail, componentMap)} + {render(tail, values, componentMap)} ) } @@ -158,13 +171,13 @@ function render(tokens: Token[], componentMap: TranslateComponentMap): ReactNode return Component ? ( <> - {render(children, componentMap)} - {render(remaining, componentMap)} + {render(children, values, componentMap)} + {render(remaining, values, componentMap)} ) : ( <> - {createElement(head.name, {}, render(children, componentMap))} - {render(remaining, componentMap)} + {createElement(head.name, {}, render(children, values, componentMap))} + {render(remaining, values, componentMap)} ) } diff --git a/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx b/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx index 6cfd1270974..fc75d1817fc 100644 --- a/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx +++ b/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx @@ -105,4 +105,36 @@ describe('Translate component', () => { `Your search for "something" took 123ms`, ) }) + + it('it interpolates values', async () => { + const wrapper = await getWrapper([ + createBundle({title: 'An {{interpolated}} thing'}), + ]) + const {findByTestId} = render( + , + {wrapper}, + ) + expect(await findByTestId('output')).toHaveTextContent('An escaped, interpolated thing') + }) + + it('it escapes HTML inside of interpolated values', async () => { + const wrapper = await getWrapper([ + createBundle({title: 'An {{interpolated}} thing'}), + ]) + const {findByTestId} = render( + interpolated thing'}} + components={{}} + />, + {wrapper}, + ) + expect(await findByTestId('output')).toHaveTextContent( + 'An escaped, interpolated thing', + ) + }) }) diff --git a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts index 3d0f9d8e5a6..8bc7f2b3734 100644 --- a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts +++ b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts @@ -98,16 +98,22 @@ describe('simpleParser', () => { {type: 'tagOpen', name: 'Icon', selfClosing: true}, {type: 'text', text: ' Your search for "'}, {type: 'tagOpen', name: 'Red'}, - {type: 'text', text: '{{keyword}}'}, + {type: 'interpolation', variable: 'keyword'}, {type: 'tagClose', name: 'Red'}, {type: 'text', text: '" took '}, {type: 'tagOpen', name: 'Bold'}, - {type: 'text', text: '{{time}}ms'}, + {type: 'interpolation', variable: 'time'}, + {type: 'text', text: 'ms'}, {type: 'tagClose', name: 'Bold'}, ]) }) }) describe('simpleParser - errors', () => { + test('formatters in interpolations', () => { + expect(() => simpleParser('This is not allowed: {{countries, list}}')).toThrow( + `Interpolations with formatters are not supported when using . Found "countries, list". Utilize "useTranslation" instead, or format the values passed to ahead of time.`, + ) + }) test('unpaired tags', () => { expect(() => simpleParser(' Your search for "{{keyword}}" took {{time}}ms'), diff --git a/packages/sanity/src/core/i18n/simpleParser.ts b/packages/sanity/src/core/i18n/simpleParser.ts index 86d9819d8c8..869f8d63ae2 100644 --- a/packages/sanity/src/core/i18n/simpleParser.ts +++ b/packages/sanity/src/core/i18n/simpleParser.ts @@ -30,13 +30,23 @@ export type TextToken = { * @internal * @hidden */ -export type Token = OpenTagToken | CloseTagToken | TextToken +export type InterpolationToken = { + type: 'interpolation' + variable: string +} + +/** + * @internal + * @hidden + */ +export type Token = OpenTagToken | CloseTagToken | TextToken | InterpolationToken const OPEN_TAG_RE = /^<(?[^\s\d<][^/?><]+)\/?>/ const CLOSE_TAG_RE = /<\/(?[^>]+)>/ const SELF_CLOSING_RE = /<[^>]+\/>/ const VALID_COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]+$/ const VALID_HTML_TAG_NAME_RE = /^[a-z]+$/ +const TEMPLATE_RE = /{{\s*?([^}]+)\s*?}}/g /** * Parses a string for simple tags @@ -58,7 +68,7 @@ export function simpleParser(input: string): Token[] { const tagName = match.groups!.tag validateTagName(tagName) if (text) { - tokens.push({type: 'text', text}) + tokens.push(...textTokenWithInterpolation(text)) text = '' } if (isSelfClosing(match[0])) { @@ -88,7 +98,7 @@ export function simpleParser(input: string): Token[] { ) } if (text) { - tokens.push({type: 'text', text}) + tokens.push(...textTokenWithInterpolation(text)) text = '' } tokens.push({type: 'tagClose', name: tagName}) @@ -111,11 +121,53 @@ export function simpleParser(input: string): Token[] { ) } if (text) { - tokens.push({type: 'text', text}) + tokens.push(...textTokenWithInterpolation(text)) + } + return tokens +} + +function textTokenWithInterpolation(text: string): Token[] { + const tokens: Token[] = [] + + const interpolations = text.matchAll(TEMPLATE_RE) + let lastIndex = 0 + for (const match of interpolations) { + if (typeof match.index === 'undefined') { + continue + } + + const pre = text.slice(lastIndex, match.index) + if (pre.length > 0) { + tokens.push({type: 'text', text: pre}) + } + + tokens.push(parseInterpolation(match[0])) + + lastIndex += pre.length + match[0].length } + + if (lastIndex < text.length) { + tokens.push({type: 'text', text: text.slice(lastIndex)}) + } + return tokens } +function parseInterpolation(interpolation: string): InterpolationToken { + const variable = interpolation.replace(/^\{\{|\}\}$/g, '').trim() + // Disallow formatters for interpolations when using the `Translate` function: + // Since we do not have a _key_ to format (only a substring), we do not want i18next to look up + // a matching string value for the "stub" value. We could potentially change this in the future, + // if we feel it is a useful feature. + if (variable.includes(',')) { + throw new Error( + `Interpolations with formatters are not supported when using . Found "${variable}". Utilize "useTranslation" instead, or format the values passed to ahead of time.`, + ) + } + + return {type: 'interpolation', variable} +} + function isSelfClosing(tag: string) { return SELF_CLOSING_RE.test(tag) }