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)
}