import styled from 'styled-components'; import { Fragment } from 'react'; import { ArgsRecord, GetMessageArgs, I18nProps, LocalizerDictionary, LocalizerToken, } from '../../types/Localizer'; import { useIsDarkTheme } from '../../state/selectors/theme'; import { SessionHtmlRenderer } from './SessionHTMLRenderer'; import { type CustomTag, CustomTagProps, SessionCustomTagRenderer, supportedCustomTags, } from './SessionCustomTagRenderer'; /** An array of supported html tags to render if found in a string */ export const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span']; /** NOTE: self-closing tags must also be listed in {@link supportedFormattingTags} */ const supportedSelfClosingFormattingTags = ['br']; function createSupportedFormattingTagsRegex() { return new RegExp( `<(?:${supportedFormattingTags.join('|')})>.*?|<(?:${supportedSelfClosingFormattingTags.join('|')})\\/>`, 'g' ); } function createSupportedCustomTagsRegex() { return new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g'); } /** * Replaces all html tag identifiers with their escaped equivalents * @param str The string to sanitize * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. * @returns The sanitized string */ export function sanitizeHtmlTags(str: string, identifier: string = ''): string { if (identifier && /[a-zA-Z0-9>/g, `${identifier}>${identifier}`); } /** * Replaces all sanitized html tags with their real equivalents * @param str The string to de-sanitize * @param identifier The identifier used when the args were sanitized * @returns The de-sanitized string */ export function deSanitizeHtmlTags(str: string, identifier: string): string { if (!identifier || /[a-zA-Z0-9>'); } /** * Sanitizes the args to be used in the i18n function * @param args The args to sanitize * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. * @returns The sanitized args */ export function sanitizeArgs( args: Record, identifier?: string ): Record { return Object.fromEntries( Object.entries(args).map(([key, value]) => [ key, typeof value === 'string' ? sanitizeHtmlTags(value, identifier) : value, ]) ); } const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>` * > span { color: ${props => (props.isDarkTheme ? 'var(--primary-color)' : 'var(--text-primary-color)')}; } `; /** * Retrieve a localized message string, substituting dynamic parts where necessary and formatting it as HTML if necessary. * * @param props.token - The token identifying the message to retrieve and an optional record of substitution variables and their replacement values. * @param props.args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic parts. * @param props.as - An optional HTML tag to render the component as. Defaults to a fragment, unless the string contains html tags. In that case, it will render as HTML in a div tag. * @param props.startTagProps - An optional object of props to pass to the start tag. * @param props.endTagProps - An optional object of props to pass to the end tag. * * @returns The localized message string with substitutions and formatting applied. * * @example * ```tsx * * * * ``` */ export const I18n = (props: I18nProps) => { const isDarkMode = useIsDarkTheme(); const args = 'args' in props ? props.args : undefined; const rawString = window.i18n.getRawMessage( ...([props.token, args] as GetMessageArgs) ); const containsFormattingTags = createSupportedFormattingTagsRegex().test(rawString); const cleanArgs = args && containsFormattingTags ? sanitizeArgs(args) : args; let i18nString = window.i18n.formatMessageWithArgs( rawString as LocalizerDictionary[T], cleanArgs as ArgsRecord ) as string; let startTag: CustomTag | null = null; let endTag: CustomTag | null = null; i18nString = i18nString.replace( createSupportedCustomTagsRegex(), /** * @param match - The entire match, including the custom tag. * @param group - The custom tag, without the angle brackets. * @param index - The index of the match in the string. */ (match: string, group: CustomTag, index: number) => { if (index === 0) { startTag = group; } else if (index === i18nString.length - match.length) { endTag = group; } else { /** * If the match is not at the start or end of the string, throw an error. * NOTE: This should never happen as this rule is enforced when the dictionary is generated. */ throw new Error( `Custom tag ${group} (${match}) is not at the start or end (i=${index}) of the string: ${i18nString}` ); } return ''; } ); const content = containsFormattingTags ? ( /** If the string contains a relevant formatting tag, render it as HTML */ ) : ( i18nString ); return ( {startTag ? ( } /> ) : null} {content} {endTag ? ( } /> ) : null} ); };