From f87050fba1c29551ac83968c291b421a0c3f156c Mon Sep 17 00:00:00 2001 From: Espen Hovlandsdal Date: Fri, 20 Oct 2023 11:50:05 -0700 Subject: [PATCH] feat(i18n): allow certain simple HTML tags when using Translate component --- .../components/TranslateExample.tsx | 40 +++++---- dev/test-studio/locales/index.ts | 2 + packages/sanity/src/core/i18n/Translate.tsx | 39 +++++++-- .../core/i18n/__tests__/Translate.test.tsx | 84 ++++++++++--------- .../core/i18n/__tests__/simpleParser.test.ts | 50 ++++++++++- packages/sanity/src/core/i18n/simpleParser.ts | 29 +++++-- 6 files changed, 171 insertions(+), 73 deletions(-) diff --git a/dev/test-studio/components/TranslateExample.tsx b/dev/test-studio/components/TranslateExample.tsx index cacef3662d7..fd0493d5f34 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 72f5f503ca3..789f21c44ec 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 89bfa32fa9a..c36256802a2 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 aa77e607219..e461a9d786f 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 9ac5fc7d1f8..03cc4310d1f 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 f9944dc6cc8..27e300075e8 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 = ''