Skip to content

Commit

Permalink
fix(i18n): allow using list formatter without arguments in Translate …
Browse files Browse the repository at this point in the history
…function

Fixes #6003
  • Loading branch information
rexxars committed Mar 26, 2024
1 parent 78caa54 commit a1a36d8
Show file tree
Hide file tree
Showing 4 changed files with 81 additions and 21 deletions.
53 changes: 42 additions & 11 deletions packages/sanity/src/core/i18n/Translate.tsx
Original file line number Diff line number Diff line change
@@ -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]/
Expand All @@ -20,6 +21,8 @@ const RECOGNIZED_HTML_TAGS = [
'sup',
]

type FormatterFns = {list: (value: Iterable<string>) => 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 `<SearchTerm/>` or
Expand Down Expand Up @@ -106,33 +109,43 @@ 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<string>) => 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)}
</>
)
}
if (head.type === 'text') {
return (
<>
{head.text}
{render(tail, values, componentMap)}
{render(tail, values, componentMap, formatters)}
</>
)
}
Expand All @@ -145,7 +158,7 @@ function render(
return (
<>
<Component />
{render(tail, values, componentMap)}
{render(tail, values, componentMap, formatters)}
</>
)
}
Expand All @@ -171,15 +184,33 @@ function render(

return Component ? (
<>
<Component>{render(children, values, componentMap)}</Component>
{render(remaining, values, componentMap)}
<Component>{render(children, values, componentMap, formatters)}</Component>
{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<TranslationProps>['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}`
}
14 changes: 14 additions & 0 deletions packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,18 @@ describe('Translate component', () => {
'An escaped, <strong>interpolated</strong> 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(
<TestComponent i18nKey="peopleSignedUp" values={{count: people.length, people}} />,
{wrapper},
)
expect(await findByTestId('output')).toHaveTextContent(
'3 people signed up: Bjørge, Rita, and Espen',
)
})
})
13 changes: 10 additions & 3 deletions packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Translate>. Found "countries, list". Utilize "useTranslation" instead, or format the values passed to <Translate> 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 <Translate>. Found "{{count, number}}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
})
test('unpaired tags', () => {
Expand Down
22 changes: 15 additions & 7 deletions packages/sanity/src/core/i18n/simpleParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type TextToken = {
export type InterpolationToken = {
type: 'interpolation'
variable: string
formatters?: string[]
}

/**
Expand Down Expand Up @@ -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 <Translate>. Found "${variable}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
`Interpolations with formatters are not supported when using <Translate>. Found "${interpolation}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
}

Expand Down

0 comments on commit a1a36d8

Please sign in to comment.