Skip to content

Commit

Permalink
feat(i18n): allow certain simple HTML tags when using Translate compo…
Browse files Browse the repository at this point in the history
…nent (#5114)
  • Loading branch information
rexxars committed Nov 22, 2023
1 parent 26067fc commit 2e1cec4
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 73 deletions.
40 changes: 24 additions & 16 deletions dev/test-studio/components/TranslateExample.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card padding={4}>
<Text>
<Translate
t={t}
i18nKey="translate.example"
components={{
Icon: () => <InfoFilledIcon />,
Red: ({children}) => <span style={{color: 'red'}}>{children}</span>,
Bold: ({children}) => <b>{children}</b>,
}}
values={{
keyword: 'something',
duration: '30',
}}
/>
</Text>
<Stack space={4}>
<Text>{t('use-translation.with-html')}</Text>

<Text>
<Translate t={t} i18nKey="use-translation.with-html" />
</Text>

<Text>
<Translate
t={t}
i18nKey="translate.example"
components={{
Icon: () => <InfoFilledIcon />,
Red: ({children}) => <span style={{color: 'red'}}>{children}</span>,
Bold: ({children}) => <b>{children}</b>,
}}
values={{
keyword: 'something',
duration: '30',
}}
/>
</Text>
</Stack>
</Card>
)
}
2 changes: 2 additions & 0 deletions dev/test-studio/locales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const enUSStrings = {
'structure.root.title': 'Content 🇺🇸',
'translate.example':
'<Icon/> Your search for "<Red>{{keyword}}</Red>" took <Bold>{{duration}}ms</Bold>',
'use-translation.with-html': 'Apparently, <code>code</code> is an HTML element?',
}

const enUS = defineLocaleResourceBundle({
Expand All @@ -23,6 +24,7 @@ const noNB = defineLocaleResourceBundle({
'structure.root.title': 'Innhold 🇳🇴',
'translate.example':
'<Icon/> Ditt søk på "<Red>{{keyword}}</Red>" tok <Bold>{{duration}}</Bold> millisekunder',
'use-translation.with-html': 'Faktisk er <code>code</code> et HTML-element?',
},
})

Expand Down
39 changes: 34 additions & 5 deletions packages/sanity/src/core/i18n/Translate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,8 +30,9 @@ type ComponentMap = Record<
export interface TranslationProps {
t: TFunction
i18nKey: string
components: ComponentMap
context?: string
values?: Record<string, number | string | string[]>
components?: ComponentMap
}

function render(tokens: Token[], componentMap: ComponentMap): ReactNode {
Expand Down Expand Up @@ -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 ? (
<>
<Component>{render(children, componentMap)}</Component>
{render(remaining, componentMap)}
</>
) : (
<>
{React.createElement(head.name, {}, render(children, componentMap))}
{render(remaining, componentMap)}
</>
)
}
return null
Expand All @@ -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])

Expand Down
84 changes: 44 additions & 40 deletions packages/sanity/src/core/i18n/__tests__/Translate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ThemeProvider theme={studioTheme}>
<LocaleProviderBase
locales={[{id: 'en-US', title: 'English'}]}
i18next={i18next}
projectId="test"
sourceId="test"
>
{props.children}
</LocaleProviderBase>
</ThemeProvider>
)

await i18next.init()

return function wrapper({children}: {children: React.ReactNode}) {
return (
<ThemeProvider theme={studioTheme}>
<LocaleProviderBase
locales={[{id: 'en-US', title: 'English'}]}
i18next={i18next}
projectId="test"
sourceId="test"
>
{children}
</LocaleProviderBase>
</ThemeProvider>
)
}
}

function TestComponent(props: TestComponentProps) {
Expand All @@ -57,39 +62,38 @@ function TestComponent(props: TestComponentProps) {

describe('Translate component', () => {
it('it translates a key', async () => {
const {findByTestId} = render(
<TestProviders bundles={[createBundle({title: 'English title'})]}>
<TestComponent i18nKey="title" components={{}} />
</TestProviders>,
)
const wrapper = await getWrapper([createBundle({title: 'English title'})])
const {findByTestId} = render(<TestComponent i18nKey="title" components={{}} />, {wrapper})
expect((await findByTestId('output')).innerHTML).toEqual('English title')
})
it('it renders the key as-is if translation is missing', async () => {
const {findByTestId} = render(
<TestProviders bundles={[createBundle({title: 'English title'})]}>
<TestComponent i18nKey="does-not-exist" components={{}} />
</TestProviders>,
)
const wrapper = await getWrapper([createBundle({title: 'English title'})])
const {findByTestId} = render(<TestComponent i18nKey="does-not-exist" components={{}} />, {
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 <code>embedded</code> thing'})])
const {findByTestId} = render(<TestComponent i18nKey="title" components={{}} />, {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 "<Red>{{keyword}}</Red>" took <Bold>{{duration}}ms</Bold>',
}),
])
const {findByTestId} = render(
<TestProviders
bundles={[
createBundle({
message: 'Your search for "<Red>{{keyword}}</Red>" took <Bold>{{duration}}ms</Bold>',
}),
]}
>
<TestComponent
i18nKey="message"
components={{
Red: ({children}) => <span style={{color: 'red'}}>{children}</span>,
Bold: ({children}) => <b>{children}</b>,
}}
values={{keyword: 'something', duration: '123'}}
/>
</TestProviders>,
<TestComponent
i18nKey="message"
components={{
Red: ({children}) => <span style={{color: 'red'}}>{children}</span>,
Bold: ({children}) => <b>{children}</b>,
}}
values={{keyword: 'something', duration: '123'}}
/>,
{wrapper},
)
expect((await findByTestId('output')).innerHTML).toEqual(
`Your search for "<span style="color: red;">something</span>" took <b>123ms</b>`,
Expand Down
50 changes: 46 additions & 4 deletions packages/sanity/src/core/i18n/__tests__/simpleParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ describe('simpleParser - errors', () => {
'Expected closing tag for <Red>, but found closing tag </Blue> instead. Make sure each opening tag has a matching closing tag.',
)
})
test('tags must be title cased', () => {
expect(() => simpleParser('foo <red> bar</Blue>')).toThrow(
'Invalid tag "<red>". 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 <camelCased>bar</camelCased>')).toThrow(
'Invalid tag "<camelCased>". 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 <Em@ail> bar</Em@ail>')).toThrow(
'Invalid tag "<Em@ail>". Tag names must start with an uppercase letter and can only include letters and numbers.',
'Invalid tag "<Em@ail>". Tag names must be lowercase HTML tags or start with an uppercase letter and can only include letters and numbers.',
)
expect(() => simpleParser('foo <Bold >bar</Bold>')).toThrow(
'Invalid tag "<Bold >". No whitespace allowed in tags.',
Expand All @@ -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 <code>author</code> is not <em>explicitly</em> 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',
},
])
})
})
29 changes: 21 additions & 8 deletions packages/sanity/src/core/i18n/simpleParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type Token = OpenTagToken | CloseTagToken | TextToken
const OPEN_TAG_RE = /<(?<tag>[^\s\d][^/?><]+)\/?>/
const CLOSE_TAG_RE = /<\/(?<tag>[^>]+)>/
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)
Expand All @@ -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
Expand All @@ -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 = ''
Expand Down

0 comments on commit 2e1cec4

Please sign in to comment.