diff --git a/packages/sanity/src/core/i18n/Translate.tsx b/packages/sanity/src/core/i18n/Translate.tsx index 3b36ba0dba9..1204f427303 100644 --- a/packages/sanity/src/core/i18n/Translate.tsx +++ b/packages/sanity/src/core/i18n/Translate.tsx @@ -1,6 +1,7 @@ import {type TFunction} from 'i18next' import {type ComponentType, createElement, type ReactNode, useMemo} from 'react' +import {useListFormat} from '../hooks/useListFormat' import {type CloseTagToken, simpleParser, type TextToken, type Token} from './simpleParser' const COMPONENT_NAME_RE = /^[A-Z]/ @@ -20,6 +21,8 @@ const RECOGNIZED_HTML_TAGS = [ 'sup', ] +type FormatterFns = {list: (value: Iterable) => string} + /** * A map of component names to React components. The component names are the names used within the * locale resources, eg a key of `SearchTerm` should be rendered as `` or @@ -106,25 +109,35 @@ export function Translate(props: TranslationProps) { }) const tokens = useMemo(() => simpleParser(translated), [translated]) - return <>{render(tokens, props.values, props.components || {})} + const listFormat = useListFormat() + const formatters: FormatterFns = { + list: (listValues: Iterable) => listFormat.format(listValues), + } + return <>{render(tokens, props.values, props.components || {}, formatters)} } function render( tokens: Token[], values: TranslationProps['values'], componentMap: TranslateComponentMap, + formatters: FormatterFns, ): ReactNode { const [head, ...tail] = tokens if (!head) { return null } if (head.type === 'interpolation') { + const value = values ? values[head.variable] : undefined + if (typeof value === 'undefined') { + return `{{${head.variable}}}` + } + + const formattedValue = applyFormatters(value, head.formatters || [], formatters) + return ( <> - {!values || typeof values[head.variable] === 'undefined' - ? `{{${head.variable}}}` - : values[head.variable]} - {render(tail, values, componentMap)} + {formattedValue} + {render(tail, values, componentMap, formatters)} ) } @@ -132,7 +145,7 @@ function render( return ( <> {head.text} - {render(tail, values, componentMap)} + {render(tail, values, componentMap, formatters)} ) } @@ -145,7 +158,7 @@ function render( return ( <> - {render(tail, values, componentMap)} + {render(tail, values, componentMap, formatters)} ) } @@ -171,15 +184,33 @@ function render( return Component ? ( <> - {render(children, values, componentMap)} - {render(remaining, values, componentMap)} + {render(children, values, componentMap, formatters)} + {render(remaining, values, componentMap, formatters)} ) : ( <> - {createElement(head.name, {}, render(children, values, componentMap))} - {render(remaining, values, componentMap)} + {createElement(head.name, {}, render(children, values, componentMap, formatters))} + {render(remaining, values, componentMap, formatters)} ) } return null } + +function applyFormatters( + value: Required['values'][string], + formatters: string[], + formatterFns: FormatterFns, +): string { + let formattedValue = value + for (const formatter of formatters) { + if (formatter === 'list') { + if (Array.isArray(value)) { + formattedValue = formatterFns.list(value) + } else { + throw new Error('List formatter used on non-array value') + } + } + } + return `${formattedValue}` +} diff --git a/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx b/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx index fc75d1817fc..c37cfd201f2 100644 --- a/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx +++ b/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx @@ -137,4 +137,18 @@ describe('Translate component', () => { 'An escaped, interpolated thing', ) }) + + it('it allows using list formatter for interpolated values', async () => { + const wrapper = await getWrapper([ + createBundle({peopleSignedUp: '{{count}} people signed up: {{people, list}}'}), + ]) + const people = ['Bjørge', 'Rita', 'Espen'] + const {findByTestId} = render( + , + {wrapper}, + ) + expect(await findByTestId('output')).toHaveTextContent( + '3 people signed up: Bjørge, Rita, and Espen', + ) + }) }) diff --git a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts index 8bc7f2b3734..9f3a7ed46c7 100644 --- a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts +++ b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts @@ -107,11 +107,18 @@ describe('simpleParser', () => { {type: 'tagClose', name: 'Bold'}, ]) }) + test('interpolations with allowed formatters', () => { + expect(simpleParser('{{count}} people signed up: {{people, list}}')).toMatchObject([ + {type: 'interpolation', variable: 'count'}, + {type: 'text', text: ' people signed up: '}, + {type: 'interpolation', variable: 'people', formatters: ['list']}, + ]) + }) }) 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('other formatters in interpolations', () => { + expect(() => simpleParser('This is not allowed: {{count, number}}')).toThrow( + `Interpolations with formatters are not supported when using . Found "{{count, number}}". Utilize "useTranslation" instead, or format the values passed to ahead of time.`, ) }) test('unpaired tags', () => { diff --git a/packages/sanity/src/core/i18n/simpleParser.ts b/packages/sanity/src/core/i18n/simpleParser.ts index 869f8d63ae2..d9c9fdd80ee 100644 --- a/packages/sanity/src/core/i18n/simpleParser.ts +++ b/packages/sanity/src/core/i18n/simpleParser.ts @@ -33,6 +33,7 @@ export type TextToken = { export type InterpolationToken = { type: 'interpolation' variable: string + formatters?: string[] } /** @@ -154,14 +155,21 @@ function textTokenWithInterpolation(text: string): Token[] { } 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(',')) { + const [variable, ...formatters] = interpolation + .replace(/^\{\{|\}\}$/g, '') + .trim() + .split(/\s*,\s*/) + + // To save us from reimplementing all of i18next's formatter logic, we only curently support the + // `list` formatter, and only without any arguments. This may change in the future, but deeming + // this good enough for now. + if (formatters.length === 1 && formatters[0] === 'list') { + return {type: 'interpolation', variable, formatters} + } + + if (formatters.length > 0) { 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.`, + `Interpolations with formatters are not supported when using . Found "${interpolation}". Utilize "useTranslation" instead, or format the values passed to ahead of time.`, ) }