Skip to content

Commit

Permalink
fix(i18n): escape interpolated values inside of <Translate> (#5804)
Browse files Browse the repository at this point in the history
  • Loading branch information
rexxars authored Feb 21, 2024
1 parent 32569be commit 78ffbaf
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 18 deletions.
33 changes: 31 additions & 2 deletions dev/test-studio/components/TranslateExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ export function TranslateExample() {
<Card padding={4}>
<Stack space={4}>
<Text>{t('use-translation.with-html')}</Text>

<Text>
{t('use-translation.interpolation-example', {
spaces: 'spaces',
doesNot: 'does not have spaces',
})}
</Text>
<Text>
{t('translate.with-formatter', {
countries: ['Norway', 'Denmark', 'Sweden'],
})}
</Text>
<Text>
<Translate t={t} i18nKey="use-translation.with-html" />
</Text>

<Text>
<Translate
t={t}
Expand All @@ -28,6 +37,26 @@ export function TranslateExample() {
}}
/>
</Text>
<Text>
<Translate
t={t}
i18nKey="translate.with-xml-in-value"
values={{
value: '<svg>hello</svg>',
}}
/>
</Text>

<Text>
<Translate
t={t}
i18nKey="use-translation.interpolation-example"
values={{
spaces: 'spaces',
doesNot: 'does not have spaces',
}}
/>
</Text>
</Stack>
</Card>
)
Expand Down
6 changes: 6 additions & 0 deletions dev/test-studio/locales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ const enUSStrings = {
'structure.root.title': 'Content 🇺🇸',
'translate.example':
'<Icon/> Your search for "<Red>{{keyword}}</Red>" took <Bold>{{duration}}ms</Bold>',
'translate.with-xml-in-value':
'This value has XML in the interpolated value: <strong>{{value}}</strong>',
'translate.with-formatter': 'This value has a list-formatter: {{countries, list}}',
'use-translation.with-html': 'Apparently, <code>code</code> is an HTML element?',
'use-translation.interpolation-example': 'This has {{ spaces }} around it, this one {{doesNot}}',
}

const enUS = defineLocaleResourceBundle({
Expand All @@ -22,6 +26,8 @@ const nbNO = defineLocaleResourceBundle({
'structure.root.title': 'Innhold 🇳🇴',
'translate.example':
'<Icon/> Ditt søk på "<Red>{{keyword}}</Red>" tok <Bold>{{duration}}</Bold> millisekunder',
'translate.with-xml-in-value':
'Denne verdien har XML i en interpolert verdi: <strong>{{value}}</strong>',
'use-translation.with-html': 'Faktisk er <code>code</code> et HTML-element?',
},
})
Expand Down
33 changes: 23 additions & 10 deletions packages/sanity/src/core/i18n/Translate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,41 @@ 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
: undefined,
})

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)}
</>
)
}
Expand All @@ -132,7 +145,7 @@ function render(tokens: Token[], componentMap: TranslateComponentMap): ReactNode
return (
<>
<Component />
{render(tail, componentMap)}
{render(tail, values, componentMap)}
</>
)
}
Expand All @@ -158,13 +171,13 @@ function render(tokens: Token[], componentMap: TranslateComponentMap): ReactNode

return Component ? (
<>
<Component>{render(children, componentMap)}</Component>
{render(remaining, componentMap)}
<Component>{render(children, values, componentMap)}</Component>
{render(remaining, values, componentMap)}
</>
) : (
<>
{createElement(head.name, {}, render(children, componentMap))}
{render(remaining, componentMap)}
{createElement(head.name, {}, render(children, values, componentMap))}
{render(remaining, values, componentMap)}
</>
)
}
Expand Down
32 changes: 32 additions & 0 deletions packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,36 @@ describe('Translate component', () => {
`Your search for "<span style="color: red;">something</span>" took <b>123ms</b>`,
)
})

it('it interpolates values', async () => {
const wrapper = await getWrapper([
createBundle({title: 'An <code>{{interpolated}}</code> thing'}),
])
const {findByTestId} = render(
<TestComponent
i18nKey="title"
values={{interpolated: 'escaped, interpolated'}}
components={{}}
/>,
{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 <code>{{interpolated}}</code> thing'}),
])
const {findByTestId} = render(
<TestComponent
i18nKey="title"
values={{interpolated: 'escaped, <strong>interpolated</strong> thing'}}
components={{}}
/>,
{wrapper},
)
expect(await findByTestId('output')).toHaveTextContent(
'An escaped, <strong>interpolated</strong> thing',
)
})
})
10 changes: 8 additions & 2 deletions packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Translate>. Found "countries, list". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
})
test('unpaired tags', () => {
expect(() =>
simpleParser('<Icon/> Your search for "<Red>{{keyword}}" took <Bold>{{time}}ms</Bold>'),
Expand Down
60 changes: 56 additions & 4 deletions packages/sanity/src/core/i18n/simpleParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /^<(?<tag>[^\s\d<][^/?><]+)\/?>/
const CLOSE_TAG_RE = /<\/(?<tag>[^>]+)>/
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
Expand All @@ -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])) {
Expand Down Expand Up @@ -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})
Expand All @@ -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 <Translate>. Found "${variable}". Utilize "useTranslation" instead, or format the values passed to <Translate> ahead of time.`,
)
}

return {type: 'interpolation', variable}
}

function isSelfClosing(tag: string) {
return SELF_CLOSING_RE.test(tag)
}
Expand Down

0 comments on commit 78ffbaf

Please sign in to comment.