import castArray from "lodash/castArray"; import { TFatString, TString } from "./string"; import { localeRoot, LocaleStack, resolveLocales } from "./resolveLocale"; import { searchOrder } from "./searchOrder"; import { checkDictionary, isDictionary, nextDict, rootDict, TDictionaryRoot } from "./dictionary"; /** * @category LangContext */ export type StringPropType = TFatString | TString | string; /** * @category LangContext */ export type TextPropType = TFatString | TString | string | string[]; /** * Properties that can be passed to [[`LangContext.constructor`]] and [[`LangContext.derive`]] * * @category LangContext */ export interface LangContextProps { /** * The default language for the context. Untranslated text will be assumed to be in this * language. Setting `defaultLang` also places the default in the language stack before * any languages in `lang` */ readonly defaultLang?: string; /** * A dictionary to resolve translations. Dictionaries are consulted in order walking up the * context parent chain. */ readonly dictionary?: TDictionaryRoot; /** * Use a tagged section of a current dictionary as the new dictionary. */ readonly dictionaryFromTag?: string; /** * A language or list of languages that are preferred for this context. Any languages provided * here are prepended to the parent's language stack. */ readonly lang?: string | readonly string[]; /** * Set the ambient language - which is used to create a context which can't match the desired * language. The ambience is used to add `lang` attributes to elements that aren't in the * expected language. */ readonly ambient?: string; /** * Whether to pass ambient language down the context stack. */ readonly retainAmbience?: boolean; } /** * A language context. All translation takes place inside a context and contexts * nest to allow their configuration to be modified. Normally you'll get a context * using the [[`useTranslation`]] hook. * * @category LangContext */ export class LangContext { /** * The default language for this context. Used for any non-translated content. */ readonly defaultLang: string = "en"; /** @ignore */ private readonly stack: LocaleStack = localeRoot; /** @ignore */ private readonly parent?: LangContext; /** @ignore */ private readonly ambient?: string; /** @ignore */ public readonly dictionary: TDictionaryRoot; /** * Whether to pass ambient language down the context stack. */ public readonly retainAmbience: boolean = false; /** * Create a new LangContext. Normally you won't need to do this; the root * context is initialised by _Interminimal_ and child contexts are created * using [[`derive`]]. In React use the [[`useTranslation`]] to get the active * [[`LangContext`]]. * * @param props initial properties for this context */ constructor(props: LangContextProps & { parent?: LangContext } = {}) { const { lang, dictionary, ...rest } = props; Object.assign(this, { ...rest }); const baseDict = this.parent ? this.parent.dictionary : rootDict; if (dictionary) { if (process.env.NODE_ENV !== "production") checkDictionary(dictionary); this.dictionary = nextDict.next(baseDict, dictionary); } else { this.dictionary = baseDict; } const lastStack = this.parent ? this.parent.stack : resolveLocales(localeRoot, [this.defaultLang]); this.stack = resolveLocales(lastStack, castArray(lang).filter(Boolean)); } /** * Get the language preference stack for this context. The `languages` * array is always normalised - duplicates are removed. * * ```typescript * const ctx = new LangContext({ lang: "cy", defaultLang: "en" }); * expect(ctx.languages).toEqual(["cy", "en"]); * * const ctx2 = ctx.derive({ lang: "de", defaultLang: "fr" }); * expect(ctx2.languages).toEqual(["de", "fr", "cy", "en"]); * * // "en" de-duplicated from languages * const ctx3 = ctx2.derive({ lang: "en" }); * expect(ctx3.languages).toEqual(["en", "de", "fr", "cy"]); * * // Start from scratch with an explicit lang stack * const ctx4 = new LangContext({ lang: ["en", "de", "fr", "cy"] }); * * // All equivalent stacks are the same object * expect(ctx4.languages).toBe(ctx3.languages); * ``` * * Equivalent language arrays are always the same object. This makes * it possible to use `languages` in e.g. `React.useMemo()` to * perform expensive operations only when the language stack changes. * */ get languages(): string[] { return this.stack as string[]; } /** * The context's language expanded for searching. The expansion of a * particular language stack is canonicalised and cached - different * contexts with the same [[`languages`]] will have the same `search` * property too. * * ```typescript * const ctx = new LangContext({ * lang: ["en-GB-x-foo", "en-US", "fr-CA", "de-AT"] * }); * // Search path expands and groups tags * console.log(ctx.search); * // [ * // "en-GB-x-foo", * // "en-GB", * // "en-US", * // "en", * // "fr-CA", * // "fr", * // "de-AT", * // "de" * // ] * ``` * * The rules for tag expansion are slightly subtle. Notice in the * example above that "en" is only injected after both "en-GB" * and "en-US". */ get search(): string[] { return searchOrder(this.stack) as string[]; } /** * The current language. This is the same as the first element of the [[`languages`]] array. */ get language(): string { return this.stack[0]; } /** * The ambient language. This is defined in contexts which can't match the desired language * so that a `lang=` attribute can be added to nested elements */ get ambience(): string { return this.ambient || this.language; } /** * Create a new context nested below this one overriding any properties as desired. * * ```typescript * const root = new LangContext({ lang: ["en-GB"], defaultLang: "en" }); * const welsh = root.derive({ lang: "cy" }); * console.log(welsh.languages); // ['cy', 'en-GB', 'en'] * ``` * * @param props properties to override * @returns a nested context */ derive(props: LangContextProps): LangContext { // Handle dictionaryFromTag const trDFT = ({ dictionaryFromTag, ...rest }: LangContextProps) => { if (!dictionaryFromTag) return rest; if (props.dictionary) throw new Error(`dictionary and dictionaryFromTag both found`); return { dictionary: this.resolveDictionary(dictionaryFromTag), ...rest }; }; const trDL = ({ defaultLang, ...rest }: LangContextProps) => { if (!defaultLang) return rest; const { lang, ...other } = rest; return { defaultLang, lang: castArray(lang || []).concat(defaultLang), ...other }; }; const { dictionary, stack, ...rest } = this; return new LangContext({ ...rest, ...trDFT(trDL(props)), parent: this }); } /** * Resolve a `[tag]`, string, TString, fat string and translate it according * to this context's languages. * * @param text the thing to translate * @returns a TString with the best language match selected */ translate(text: TextPropType): TString { return this.resolve(text).toLang(this.stack); } /** * This is a convenience method which may be useful when wrapping components * that don't work well with _Interminimal_. For example here's how we can set * the page title using NextJS's `Head` component. * * ```typescript * // Inject page title into a NextJS
component. We have to do the * // translation explicitly because we can't nest a T inside a Head * // Use this component *outside* of any other * const TTitle: ComponentType