diff --git a/dev/test-studio/components/TranslateExample.tsx b/dev/test-studio/components/TranslateExample.tsx
index cacef3662d75..fd0493d5f348 100644
--- a/dev/test-studio/components/TranslateExample.tsx
+++ b/dev/test-studio/components/TranslateExample.tsx
@@ -1,27 +1,35 @@
import {Translate, useTranslation} from 'sanity'
import React from 'react'
-import {Card, Text} from '@sanity/ui'
+import {Card, Stack, Text} from '@sanity/ui'
import {InfoFilledIcon} from '@sanity/icons'
export function TranslateExample() {
const {t} = useTranslation('testStudio')
return (
-
- ,
- Red: ({children}) => {children},
- Bold: ({children}) => {children},
- }}
- values={{
- keyword: 'something',
- duration: '30',
- }}
- />
-
+
+ {t('use-translation.with-html')}
+
+
+
+
+
+
+ ,
+ Red: ({children}) => {children},
+ Bold: ({children}) => {children},
+ }}
+ values={{
+ keyword: 'something',
+ duration: '30',
+ }}
+ />
+
+
)
}
diff --git a/dev/test-studio/locales/index.ts b/dev/test-studio/locales/index.ts
index 72f5f503ca34..789f21c44ecb 100644
--- a/dev/test-studio/locales/index.ts
+++ b/dev/test-studio/locales/index.ts
@@ -7,6 +7,7 @@ const enUSStrings = {
'structure.root.title': 'Content 🇺🇸',
'translate.example':
' Your search for "{{keyword}}" took {{duration}}ms',
+ 'use-translation.with-html': 'Apparently, code
is an HTML element?',
}
const enUS = defineLocaleResourceBundle({
@@ -23,6 +24,7 @@ const noNB = defineLocaleResourceBundle({
'structure.root.title': 'Innhold 🇳🇴',
'translate.example':
' Ditt søk på "{{keyword}}" tok {{duration}} millisekunder',
+ '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 89bfa32fa9ad..c36256802a23 100644
--- a/packages/sanity/src/core/i18n/Translate.tsx
+++ b/packages/sanity/src/core/i18n/Translate.tsx
@@ -2,6 +2,23 @@ import React, {ComponentType, ReactNode, useMemo} from 'react'
import type {TFunction} from 'i18next'
import {CloseTagToken, simpleParser, TextToken, Token} from './simpleParser'
+const COMPONENT_NAME_RE = /^[A-Z]/
+const RECOGNIZED_HTML_TAGS = [
+ 'abbr',
+ 'address',
+ 'cite',
+ 'code',
+ 'del',
+ 'em',
+ 'ins',
+ 'kbd',
+ 'q',
+ 'samp',
+ 'strong',
+ 'sub',
+ 'sup',
+]
+
type ComponentMap = Record<
string,
ComponentType<{children?: ReactNode}> | keyof JSX.IntrinsicElements
@@ -13,8 +30,9 @@ type ComponentMap = Record<
export interface TranslationProps {
t: TFunction
i18nKey: string
- components: ComponentMap
+ context?: string
values?: Record
+ components?: ComponentMap
}
function render(tokens: Token[], componentMap: ComponentMap): ReactNode {
@@ -52,16 +70,27 @@ function render(tokens: Token[], componentMap: ComponentMap): ReactNode {
}
}
const Component = componentMap[head.name]
- if (!Component) {
- throw new Error(`Component not found: ${head.name}`)
+ if (!Component && COMPONENT_NAME_RE.test(head.name)) {
+ throw new Error(`Component not defined: ${head.name}`)
}
+
+ if (!Component && !RECOGNIZED_HTML_TAGS.includes(head.name)) {
+ throw new Error(`HTML tag "${head.name}" is not allowed`)
+ }
+
const children = tail.slice(0, nextCloseIdx) as TextToken[]
const remaining = tail.slice(nextCloseIdx + 1)
- return (
+
+ return Component ? (
<>
{render(children, componentMap)}
{render(remaining, componentMap)}
>
+ ) : (
+ <>
+ {React.createElement(head.name, {}, render(children, componentMap))}
+ {render(remaining, componentMap)}
+ >
)
}
return null
@@ -71,7 +100,7 @@ function render(tokens: Token[], componentMap: ComponentMap): ReactNode {
* @beta
*/
export function Translate(props: TranslationProps) {
- const translated = props.t(props.i18nKey, props.values)
+ const translated = props.t(props.i18nKey, {context: props.context, replace: props.values})
const tokens = useMemo(() => simpleParser(translated), [translated])
diff --git a/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx b/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
index aa77e607219b..e461a9d786fb 100644
--- a/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
+++ b/packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
@@ -20,25 +20,30 @@ function createBundle(resources: LocaleResourceRecord) {
})
}
-function TestProviders(props: {children: React.ReactNode; bundles: LocaleResourceBundle[]}) {
+async function getWrapper(bundles: LocaleResourceBundle[]) {
const {i18next} = prepareI18n({
projectId: 'test',
dataset: 'test',
name: 'test',
- i18n: {bundles: props.bundles},
+ i18n: {bundles: bundles},
})
- return (
-
-
- {props.children}
-
-
- )
+
+ await i18next.init()
+
+ return function wrapper({children}: {children: React.ReactNode}) {
+ return (
+
+
+ {children}
+
+
+ )
+ }
}
function TestComponent(props: TestComponentProps) {
@@ -57,39 +62,38 @@ function TestComponent(props: TestComponentProps) {
describe('Translate component', () => {
it('it translates a key', async () => {
- const {findByTestId} = render(
-
-
- ,
- )
+ const wrapper = await getWrapper([createBundle({title: 'English title'})])
+ const {findByTestId} = render(, {wrapper})
expect((await findByTestId('output')).innerHTML).toEqual('English title')
})
it('it renders the key as-is if translation is missing', async () => {
- const {findByTestId} = render(
-
-
- ,
- )
+ const wrapper = await getWrapper([createBundle({title: 'English title'})])
+ const {findByTestId} = render(, {
+ wrapper,
+ })
expect((await findByTestId('output')).innerHTML).toEqual('does-not-exist')
})
+ it('it allows using basic, known HTML tags', async () => {
+ const wrapper = await getWrapper([createBundle({title: 'An embedded
thing'})])
+ const {findByTestId} = render(, {wrapper})
+ expect(await findByTestId('output')).toHaveTextContent('An embedded thing')
+ })
it('it supports providing a component map to use for customizing message rendering', async () => {
+ const wrapper = await getWrapper([
+ createBundle({
+ message: 'Your search for "{{keyword}}" took {{duration}}ms',
+ }),
+ ])
const {findByTestId} = render(
- {{keyword}}" took {{duration}}ms',
- }),
- ]}
- >
- {children},
- Bold: ({children}) => {children},
- }}
- values={{keyword: 'something', duration: '123'}}
- />
- ,
+ {children},
+ Bold: ({children}) => {children},
+ }}
+ values={{keyword: 'something', duration: '123'}}
+ />,
+ {wrapper},
)
expect((await findByTestId('output')).innerHTML).toEqual(
`Your search for "something" took 123ms`,
diff --git a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
index 9ac5fc7d1f80..03cc4310d1fa 100644
--- a/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
+++ b/packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
@@ -90,14 +90,14 @@ describe('simpleParser - errors', () => {
'Expected closing tag for , but found closing tag instead. Make sure each opening tag has a matching closing tag.',
)
})
- test('tags must be title cased', () => {
- expect(() => simpleParser('foo bar')).toThrow(
- 'Invalid tag "". Tag names must start with an uppercase letter and can only include letters and numbers.',
+ test('does not allow camelCased tag names', () => {
+ expect(() => simpleParser('foo bar')).toThrow(
+ 'Invalid tag "". Tag names must be lowercase HTML tags or start with an uppercase letter and can only include letters and numbers.',
)
})
test('tags cant contain whitespace or special characters', () => {
expect(() => simpleParser('foo bar')).toThrow(
- 'Invalid tag "". Tag names must start with an uppercase letter and can only include letters and numbers.',
+ 'Invalid tag "". Tag names must be lowercase HTML tags or start with an uppercase letter and can only include letters and numbers.',
)
expect(() => simpleParser('foo bar')).toThrow(
'Invalid tag "". No whitespace allowed in tags.',
@@ -113,4 +113,46 @@ describe('simpleParser - errors', () => {
expect(() => simpleParser('a < 1 < or > bar>')).not.toThrow()
expect(() => simpleParser('0 <2 > 1')).not.toThrow()
})
+ test('regular, lowercase html tag names', () => {
+ expect(
+ simpleParser('the type author
is not explicitly allowed'),
+ ).toMatchObject([
+ {
+ text: 'the type ',
+ type: 'text',
+ },
+ {
+ name: 'code',
+ type: 'tagOpen',
+ },
+ {
+ text: 'author',
+ type: 'text',
+ },
+ {
+ name: 'code',
+ type: 'tagClose',
+ },
+ {
+ text: ' is not ',
+ type: 'text',
+ },
+ {
+ name: 'em',
+ type: 'tagOpen',
+ },
+ {
+ text: 'explicitly',
+ type: 'text',
+ },
+ {
+ name: 'em',
+ type: 'tagClose',
+ },
+ {
+ text: ' allowed',
+ type: 'text',
+ },
+ ])
+ })
})
diff --git a/packages/sanity/src/core/i18n/simpleParser.ts b/packages/sanity/src/core/i18n/simpleParser.ts
index f9944dc6cc8a..27e300075e8f 100644
--- a/packages/sanity/src/core/i18n/simpleParser.ts
+++ b/packages/sanity/src/core/i18n/simpleParser.ts
@@ -16,7 +16,8 @@ export type Token = OpenTagToken | CloseTagToken | TextToken
const OPEN_TAG_RE = /<(?[^\s\d][^/?><]+)\/?>/
const CLOSE_TAG_RE = /<\/(?[^>]+)>/
const SELF_CLOSING_RE = /<[^>]+\/>/
-const VALID_TAG_NAME = /^[A-Z][A-Za-z0-9]+$/
+const VALID_COMPONENT_NAME_RE = /^[A-Z][A-Za-z0-9]+$/
+const VALID_HTML_TAG_NAME_RE = /^[a-z]+$/
function isSelfClosing(tag: string) {
return SELF_CLOSING_RE.test(tag)
@@ -28,6 +29,24 @@ function matchCloseTag(input: string) {
return input.match(CLOSE_TAG_RE)
}
+function validateTagName(tagName: string) {
+ const isValidComponentName = VALID_COMPONENT_NAME_RE.test(tagName)
+ if (isValidComponentName) {
+ return
+ }
+
+ const isValidHtmlTagName = VALID_HTML_TAG_NAME_RE.test(tagName)
+ if (isValidHtmlTagName) {
+ return
+ }
+
+ throw new Error(
+ tagName.trim() === tagName
+ ? `Invalid tag "<${tagName}>". Tag names must be lowercase HTML tags or start with an uppercase letter and can only include letters and numbers.`
+ : `Invalid tag "<${tagName}>". No whitespace allowed in tags.`,
+ )
+}
+
/**
* Parses a string for simple tags
* @param input - input string to parse
@@ -42,13 +61,7 @@ export function simpleParser(input: string): Token[] {
const match = matchOpenTag(remainder)
if (match) {
const tagName = match.groups!.tag
- if (!VALID_TAG_NAME.test(tagName)) {
- throw new Error(
- tagName.trim() === tagName
- ? `Invalid tag "<${tagName}>". Tag names must start with an uppercase letter and can only include letters and numbers."`
- : `Invalid tag "<${tagName}>". No whitespace allowed in tags."`,
- )
- }
+ validateTagName(tagName)
if (text) {
tokens.push({type: 'text', text})
text = ''