feat: create custom tag parser and tag renderer

pull/3206/head
Ryan Miller 8 months ago
parent 005efc8952
commit a26d8ef61f

@ -1,4 +1,3 @@
import React from 'react';
import type {
GetMessageArgs,
I18nProps,
@ -6,10 +5,16 @@ import type {
LocalizerToken,
} from '../../types/Localizer';
import { useSelector } from 'react-redux';
import { Fragment } from 'react';
import styled from 'styled-components';
import { isDarkTheme } from '../../state/selectors/theme';
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 */
const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span'];
@ -20,25 +25,22 @@ const formattingTagRegex = new RegExp(
'g'
);
const supportedCustomTags = ['emoji'];
const customTagRegex = new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g');
const customTagRegex = new RegExp(
`<(?:${supportedFormattingTags.join('|')})>.*?</(?:${supportedCustomTags.join('|')})>`,
'g'
);
const StyledHtmlRenderer = styled.span<{ darkMode: boolean }>`
const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>`
span {
color: ${props => (props.darkMode ? 'var(--primary-color)' : 'var(--text-primary-color)')};
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 token - The token identifying the message to retrieve and an optional record of substitution variables and their replacement values.
* @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic parts.
* @param 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.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.
*
@ -50,7 +52,8 @@ const StyledHtmlRenderer = styled.span<{ darkMode: boolean }>`
* ```
*/
export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
const darkMode = useSelector(isDarkTheme);
const isDarkTheme = useIsDarkTheme();
const i18nArgs = 'args' in props ? props.args : undefined;
const i18nString = window.i18n<T, LocalizerDictionary[T]>(
@ -58,18 +61,59 @@ export const I18n = <T extends LocalizerToken>(props: I18nProps<T>) => {
);
const containsFormattingTag = i18nString.match(formattingTagRegex);
const containsCustomTag = i18nString.match(customTagRegex);
/** If the string contains a relevant formatting tag, render it as HTML */
if (containsFormattingTag || containsCustomTag) {
return (
<StyledHtmlRenderer darkMode={darkMode}>
<SessionHtmlRenderer tag={props.as} html={i18nString} />
</StyledHtmlRenderer>
);
}
let startTag: CustomTag | null = null;
let endTag: CustomTag | null = null;
const Comp = props.as ?? React.Fragment;
/**
* @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.
*/
i18nString.replace(customTagRegex, (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 <Comp>{i18nString}</Comp>;
return '';
});
const content = containsFormattingTag ? (
/** If the string contains a relevant formatting tag, render it as HTML */
<StyledHtmlRenderer isDarkTheme={isDarkTheme}>
<SessionHtmlRenderer tag={props.as} html={i18nString} />
</StyledHtmlRenderer>
) : (
i18nString
);
const Comp = props.as ?? Fragment;
return (
<Comp>
{startTag ? (
<SessionCustomTagRenderer
tag={startTag}
tagProps={props.startTagProps as CustomTagProps<typeof startTag>}
/>
) : null}
{content}
{endTag ? (
<SessionCustomTagRenderer
tag={endTag}
tagProps={props.endTagProps as CustomTagProps<typeof endTag>}
/>
) : null}
</Comp>
);
};

@ -1,30 +1,40 @@
import React from 'react';
import { nativeEmojiData } from '../../util/emoji';
import styled from 'styled-components';
import { nativeEmojiData } from '../../util/emoji';
const StyledEmoji = styled.span`
font-size: 36px;
margin-left: 8px;
`;
export const supportedCustomTags = ['emoji'] as const;
export type CustomTag = (typeof supportedCustomTags)[number];
/**
* A dictionary of custom tags and their rendering functions.
*/
export const customTag = {
emoji: ({ emoji }: { emoji: string }) => (
<StyledEmoji role={'img'} aria-label={nativeEmojiData?.ariaLabels?.[emoji]}>
{emoji}
</StyledEmoji>
),
};
} as const;
export type CustomTagProps<Tag extends CustomTag> = Parameters<(typeof customTag)[Tag]>[0];
export const SessionCustomTag = <Tag extends keyof typeof customTag>({
/**
* Render a custom tag with its props.
*
* @param tag - The custom tag to render.
* @param tagProps - The props to pass to the custom tag.
*/
export const SessionCustomTagRenderer = <Tag extends CustomTag>({
tag,
props,
tagProps,
}: {
tag: Tag;
props: Parameters<(typeof customTag)[Tag]>[0];
tagProps: CustomTagProps<Tag>;
}) => {
return customTag[tag](props);
};
export const SessionCustomTagRenderer = ({ str }: { str: string }) => {
const splitString = str.split();
return customTag[tag](tagProps);
};

@ -1,6 +1,7 @@
import type { ElementType } from 'react';
import type { Dictionary } from '../localization/locales';
import { LOCALE_DEFAULTS } from '../session/constants';
import { CustomTag, CustomTagProps } from '../components/basic/SessionCustomTagRenderer';
/** A localization dictionary key */
type Token = keyof Dictionary;
@ -23,6 +24,7 @@ type DynamicArgs<LocalizedString extends string> =
? PluralVar | DynamicArgs<PluralOne> | DynamicArgs<PluralOther>
: /** If a string segment has follows the variable form parse its variable name and recursively
* check for more dynamic args */
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- We dont care about _Pre TODO: see if we can remove this infer
LocalizedString extends `${infer _Pre}{${infer Var}}${infer Rest}`
? Var | DynamicArgs<Rest>
: never;
@ -42,7 +44,13 @@ export type GetMessageArgs<T extends Token> = T extends Token
: never;
/** Basic props for all calls of the I18n component */
type I18nBaseProps<T extends Token> = { token: T; as?: ElementType };
type I18nBaseProps<T extends Token> = {
token: T;
as?: ElementType;
// TODO: investigate making these required when required and not required when not required
startTagProps?: CustomStartTagProps<T>;
endTagProps?: CustomEndTagProps<T>;
};
/** The props for the localization component */
export type I18nProps<T extends Token> = T extends Token
@ -53,6 +61,46 @@ export type I18nProps<T extends Token> = T extends Token
: I18nBaseProps<T> & { args: ArgsRecordExcludingDefaults<T> }
: never;
/** The props for custom tags at the start of an i18n strings */
export type CustomStartTagProps<T extends Token> = T extends Token
? Dictionary[T] extends `<${infer Tag}/>${string}`
? Tag extends CustomTag
? CustomTagProps<Tag>
: never
: never
: never;
/**
* This is used to find the end tag. TypeScript navigates from outwards to inwards when doing magic
* with strings. This means we need a recursive type to find the actual end tag.
*
* @example For the string `{name} reacted with <emoji/>`
* The first iteration will find `Tag` as `emoji` because it grabs the first `<` and the last `/>`
* Because it doesn't contain a `<` it will return the Tag.
*
* @example For the string `You, {name} & <span>1 other</span> reacted with <emoji/>`
* The first iteration will find `Tag` as `span>1 other</span> reacted with <emoji` because it
* grabs the first `<` and the last `/>, so we then check if Tag contains a `<`:
* - If it doesn't then we have found it;
* - If it does then we need to run it through the same process again to search deeper.
*/
type CustomEndTag<LocalizedString extends string> =
LocalizedString extends `${string}<${infer Tag}/>${string}` ? FindCustomTag<Tag> : never;
type FindCustomTag<S extends string> = S extends CustomTag
? S
: S extends `${string}<${infer Tag}`
? Tag extends CustomTag
? Tag
: FindCustomTag<Tag>
: never;
/** The props for custom tags at the end of an i18n strings */
type CustomEndTagProps<T extends Token> =
CustomEndTag<Dictionary[T]> extends CustomTag
? CustomTagProps<CustomEndTag<Dictionary[T]>>
: never;
/** The dictionary of localized strings */
export type LocalizerDictionary = Dictionary;

Loading…
Cancel
Save