diff --git a/.changeset/separate-external-store-conditions.md b/.changeset/separate-external-store-conditions.md new file mode 100644 index 000000000..5e9da39c0 --- /dev/null +++ b/.changeset/separate-external-store-conditions.md @@ -0,0 +1,9 @@ +--- +"@generaltranslation/react-core": major +"gt-react": major +"gt-i18n": major +"gt-tanstack-start": major +"gt-next": major +--- + +useSyncExternalStore refactor diff --git a/packages/i18n/src/condition-store/ReadonlyConditionStore.ts b/packages/i18n/src/condition-store/ReadonlyConditionStore.ts new file mode 100644 index 000000000..9d845b78e --- /dev/null +++ b/packages/i18n/src/condition-store/ReadonlyConditionStore.ts @@ -0,0 +1,47 @@ +import type { + LocaleResolverConfig, + ReadonlyConditionStoreInterface as ReadonlyConditionStoreContract, +} from "../i18n-manager/types"; +import { createLocaleResolver, type LocaleCandidates } from "./localeResolver"; + +export type ReadonlyConditionStoreParams = LocaleResolverConfig & { + locale: LocaleCandidates; + enableI18n?: boolean; +}; + +export class ReadonlyConditionStore implements ReadonlyConditionStoreContract { + /** + * @deprecated use getI18nManager().determineLocale() instead + */ + protected readonly resolveLocale: (candidates?: LocaleCandidates) => string; + protected locale: string; + protected enableI18n: boolean; + + constructor({ + locale, + enableI18n = true, + ...localeConfig + }: ReadonlyConditionStoreParams) { + /** + * TODO: change this to getI18nManager().determineLocale() but this + * currently creates a circular dependency + */ + this.resolveLocale = createLocaleResolver(localeConfig); + this.locale = this.resolveLocale(locale); + this.enableI18n = enableI18n; + } + + getLocale = (): string => { + return this.locale; + }; + + getEnableI18n = (): boolean => { + return this.enableI18n; + }; + + // --- no-op methods --- // + + setLocale = (locale: LocaleCandidates): void => {}; + + setEnableI18n = (enableI18n: boolean): void => {}; +} diff --git a/packages/i18n/src/condition-store/WritableConditionStore.ts b/packages/i18n/src/condition-store/WritableConditionStore.ts new file mode 100644 index 000000000..c9e46424f --- /dev/null +++ b/packages/i18n/src/condition-store/WritableConditionStore.ts @@ -0,0 +1,22 @@ +import type { LocaleCandidates } from "../i18n-manager"; +import type { WritableConditionStoreInterface as WritableConditionStoreContract } from "../i18n-manager/types"; +import { + ReadonlyConditionStore, + type ReadonlyConditionStoreParams, +} from "./ReadonlyConditionStore"; + +export type WritableConditionStoreParams = ReadonlyConditionStoreParams; + +export class WritableConditionStore + extends ReadonlyConditionStore + implements WritableConditionStoreContract +{ + setLocale = (locale: LocaleCandidates): void => { + console.log("WritableConditionStore.setLocale", locale); + this.locale = this.resolveLocale(locale); + }; + + setEnableI18n = (enableI18n: boolean): void => { + this.enableI18n = enableI18n; + }; +} diff --git a/packages/i18n/src/i18n-manager/condition-store/__tests__/localeResolver.test.ts b/packages/i18n/src/condition-store/__tests__/localeResolver.test.ts similarity index 100% rename from packages/i18n/src/i18n-manager/condition-store/__tests__/localeResolver.test.ts rename to packages/i18n/src/condition-store/__tests__/localeResolver.test.ts diff --git a/packages/i18n/src/condition-store/createConditionStoreSingleton.ts b/packages/i18n/src/condition-store/createConditionStoreSingleton.ts new file mode 100644 index 000000000..ae73ca688 --- /dev/null +++ b/packages/i18n/src/condition-store/createConditionStoreSingleton.ts @@ -0,0 +1,31 @@ +import type { ReadonlyConditionStoreInterface } from "../i18n-manager/types"; + +let conditionStore: ReadonlyConditionStoreInterface | undefined; + +export function createConditionStoreSingleton< + T extends ReadonlyConditionStoreInterface, +>(notInitializedMessage: string) { + function getConditionStore(): T { + /** + * TODO: each package throws a different error message + */ + if (!conditionStore) { + throw new Error(notInitializedMessage); + } + return conditionStore as T; + } + + function setConditionStore(nextConditionStore: T): void { + conditionStore = nextConditionStore; + } + + function isConditionStoreInitialized(): boolean { + return conditionStore !== undefined; + } + + return { + getConditionStore, + setConditionStore, + isConditionStoreInitialized, + }; +} diff --git a/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts b/packages/i18n/src/condition-store/localeResolver.ts similarity index 78% rename from packages/i18n/src/i18n-manager/condition-store/localeResolver.ts rename to packages/i18n/src/condition-store/localeResolver.ts index 73c36309a..c050d8173 100644 --- a/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts +++ b/packages/i18n/src/condition-store/localeResolver.ts @@ -1,6 +1,6 @@ -import { LocaleConfig } from '@generaltranslation/format'; -import { libraryDefaultLocale } from 'generaltranslation/internal'; -import type { ConditionStoreConfig } from '../types'; +import { LocaleConfig } from "@generaltranslation/format"; +import { libraryDefaultLocale } from "generaltranslation/internal"; +import type { LocaleResolverConfig } from "../i18n-manager/types"; export type LocaleCandidates = string | string[] | undefined; @@ -8,7 +8,7 @@ function normalizeConditionStoreConfig({ defaultLocale, locales, customMapping, -}: ConditionStoreConfig = {}) { +}: LocaleResolverConfig = {}) { const fallbackLocale = defaultLocale || libraryDefaultLocale; return { defaultLocale: fallbackLocale, @@ -26,17 +26,17 @@ type NormalizedConditionStoreConfig = ReturnType< */ export function determineSupportedLocale( candidates: LocaleCandidates, - config: ConditionStoreConfig = {} + config: LocaleResolverConfig = {}, ): string | undefined { return determineSupportedLocaleWithConfig( candidates, - normalizeConditionStoreConfig(config) + normalizeConditionStoreConfig(config), ); } function determineSupportedLocaleWithConfig( candidates: LocaleCandidates, - config: NormalizedConditionStoreConfig + config: NormalizedConditionStoreConfig, ): string | undefined { if ( candidates == null || @@ -54,7 +54,7 @@ function determineSupportedLocaleWithConfig( */ export function resolveSupportedLocale( candidates: LocaleCandidates, - config: ConditionStoreConfig = {} + config: LocaleResolverConfig = {}, ): string { const normalizedConfig = normalizeConditionStoreConfig(config); return ( @@ -63,7 +63,7 @@ export function resolveSupportedLocale( ); } -export function createLocaleResolver(config: ConditionStoreConfig = {}) { +export function createLocaleResolver(config: LocaleResolverConfig = {}) { const normalizedConfig = normalizeConditionStoreConfig(config); return (candidates?: LocaleCandidates): string => determineSupportedLocaleWithConfig(candidates, normalizedConfig) || diff --git a/packages/i18n/src/condition-store/singleton-operations.ts b/packages/i18n/src/condition-store/singleton-operations.ts new file mode 100644 index 000000000..36c990b72 --- /dev/null +++ b/packages/i18n/src/condition-store/singleton-operations.ts @@ -0,0 +1,9 @@ +import { createConditionStoreSingleton } from "./createConditionStoreSingleton"; +import { WritableConditionStore } from "./WritableConditionStore"; + +export const { + getConditionStore: getWritableConditionStore, + setConditionStore: setWritableConditionStore, +} = createConditionStoreSingleton( + "WritableConditionStore is not initialized.", +); diff --git a/packages/i18n/src/helpers/locale.ts b/packages/i18n/src/helpers/locale.ts index 982525281..def067bf2 100644 --- a/packages/i18n/src/helpers/locale.ts +++ b/packages/i18n/src/helpers/locale.ts @@ -1,7 +1,5 @@ -import { - getCurrentLocale, - getI18nManager, -} from '../i18n-manager/singleton-operations'; +import { getWritableConditionStore } from "../condition-store/singleton-operations"; +import { getI18nManager } from "../i18n-manager/singleton-operations"; /** * Get the current locale @@ -12,7 +10,7 @@ import { * console.log(locale); // 'en-US' */ export function getLocale() { - return getCurrentLocale(); + return getWritableConditionStore().getLocale(); } /** @@ -52,7 +50,7 @@ export function getDefaultLocale() { * @example * const localeProperties = getLocaleProperties('en-US'); */ -export function getLocaleProperties(locale = getCurrentLocale()) { +export function getLocaleProperties(locale = getLocale()) { const i18nManager = getI18nManager(); const gtInstance = i18nManager.getGTClass(); return gtInstance.getLocaleProperties(locale); diff --git a/packages/i18n/src/i18n-manager/I18nManager.ts b/packages/i18n/src/i18n-manager/I18nManager.ts index 2d39626a5..3012ec857 100644 --- a/packages/i18n/src/i18n-manager/I18nManager.ts +++ b/packages/i18n/src/i18n-manager/I18nManager.ts @@ -1,36 +1,46 @@ -import { publishValidationResults } from './validation/publishValidationResults'; -import logger from '../logs/logger'; -import { I18nManagerConfig, I18nManagerConstructorParams } from './types'; -import { validateConfig } from './validation/validateConfig'; -import { Translation } from './translations-manager/utils/types/translation-data'; -import { libraryDefaultLocale } from 'generaltranslation/internal'; -import { GT } from 'generaltranslation'; -import { LocaleConfig, standardizeLocale } from '@generaltranslation/format'; -import type { CustomMapping } from '@generaltranslation/format/types'; -import { LookupOptions } from '../translation-functions/types/options'; -import { getGTServicesEnabled } from './utils/getGTServicesEnabled'; +import { publishValidationResults } from "./validation/publishValidationResults"; +import logger from "../logs/logger"; +import { I18nManagerConfig, I18nManagerConstructorParams } from "./types"; +import { validateConfig } from "./validation/validateConfig"; +import { Translation } from "./translations-manager/utils/types/translation-data"; +import { libraryDefaultLocale } from "generaltranslation/internal"; +import { GT } from "generaltranslation"; +import { LocaleConfig, standardizeLocale } from "@generaltranslation/format"; +import type { CustomMapping } from "@generaltranslation/format/types"; +import { LookupOptions } from "../translation-functions/types/options"; +import { getGTServicesEnabled } from "./utils/getGTServicesEnabled"; import { SafeTranslationsLoader, TranslationsLoader, -} from './translations-manager/translations-loaders/types'; -import { createTranslateManyFactory } from './translations-manager/utils/createTranslateMany'; -import { routeCreateTranslationLoader } from './translations-manager/translations-loaders/routeCreateTranslationLoader'; -import { getLoadTranslationsType } from './utils/getLoadTranslationsType'; -import { LocalesCache } from './translations-manager/LocalesCache'; -import type { Locale } from './translations-manager/LocalesCache'; -import type { Hash } from './translations-manager/TranslationsCache'; +} from "./translations-manager/translations-loaders/types"; +import { createTranslateManyFactory } from "./translations-manager/utils/createTranslateMany"; +import { routeCreateTranslationLoader } from "./translations-manager/translations-loaders/routeCreateTranslationLoader"; +import { getLoadTranslationsType } from "./utils/getLoadTranslationsType"; +import { LocalesCache } from "./translations-manager/LocalesCache"; +import type { Locale } from "./translations-manager/LocalesCache"; +import type { Hash } from "./translations-manager/TranslationsCache"; import type { Dictionary, DictionaryEntry, DictionaryObject, -} from './translations-manager/DictionaryCache'; -import { resolveDictionaryLookupOptions } from './translations-manager/utils/dictionary-helpers'; -import { DictionarySourceNotFoundError } from './translations-manager/utils/DictionarySourceNotFoundError'; -import { createLifecycleCallbacks } from './lifecycle-hooks/createLifecycleCallbacks'; -import { EventEmitter } from './event-subscription/EventEmitter'; -import { subscribeLifecycleCallbacks } from './lifecycle-hooks/subscribeLifecycleCallbacks'; -import { TRANSLATIONS_CACHE_MISS_EVENT_NAME } from './event-subscription/types'; -import type { I18nEvents } from './event-subscription/types'; +} from "./translations-manager/DictionaryCache"; +import { resolveDictionaryLookupOptions } from "./translations-manager/utils/dictionary-helpers"; +import { DictionarySourceNotFoundError } from "./translations-manager/utils/DictionarySourceNotFoundError"; +import { createLifecycleCallbacks } from "./lifecycle-hooks/createLifecycleCallbacks"; +import { EventEmitter } from "./event-subscription/EventEmitter"; +import { subscribeLifecycleCallbacks } from "./lifecycle-hooks/subscribeLifecycleCallbacks"; +import { TRANSLATIONS_CACHE_MISS_EVENT_NAME } from "./event-subscription/types"; +import type { I18nEvents } from "./event-subscription/types"; +import { + createLocaleResolver, + LocaleCandidates, +} from "../condition-store/localeResolver"; + +type RuntimeEnvironment = { + DEV?: boolean; + MODE?: string; + NODE_ENV?: string; +}; /** * Default translation timeout in milliseconds for a runtime translation request @@ -48,7 +58,7 @@ type TranslationResolver = < T extends U = U, >( message: T, - options?: LookupOptions + options?: LookupOptions, ) => T | undefined; /** @@ -82,6 +92,8 @@ class I18nManager< */ private localeConfig: LocaleConfig; + public determineLocale: (candidates?: LocaleCandidates) => string; + /** * Creates an instance of I18nManager. * TODO: resolve gtConfig from just file path @@ -93,7 +105,7 @@ class I18nManager< // Validation const validationResults = validateConfig(params); - publishValidationResults(validationResults, 'I18nManager: '); + publishValidationResults(validationResults, "I18nManager: "); // Setup this.config = standardizeConfig(params); @@ -102,6 +114,12 @@ class I18nManager< locales: this.config.locales, customMapping: this.config.customMapping, }); + this.determineLocale = createLocaleResolver({ + defaultLocale: this.config.defaultLocale, + locales: this.config.locales, + customMapping: this.config.customMapping, + }); + // Create cache miss handlers const loadTranslations = routeCreateTranslationLoader({ loadTranslations: params.loadTranslations, @@ -122,17 +140,17 @@ class I18nManager< const createTranslateMany = createTranslateManyFactory( this.getGTClassClean(), runtimeTranslationTimeout, - runtimeTranslationMetadata + runtimeTranslationMetadata, ); // Subscribe lifecycle callbacks subscribeLifecycleCallbacks(params.lifecycle ?? {}, (...args) => - this.subscribe(...args) + this.subscribe(...args), ); const lifecycle = createLifecycleCallbacks( (...args) => this.emit(...args), - (eventName) => this.hasListeners(eventName) + (eventName) => this.hasListeners(eventName), ); // Setup locale-scoped caches @@ -163,10 +181,10 @@ class I18nManager< */ subscribeToTranslationsCacheMiss( listener: ( - event: I18nEvents[typeof TRANSLATIONS_CACHE_MISS_EVENT_NAME] + event: I18nEvents[typeof TRANSLATIONS_CACHE_MISS_EVENT_NAME], ) => void, locale: Locale, - hash: Hash + hash: Hash, ) { return this.subscribe(TRANSLATIONS_CACHE_MISS_EVENT_NAME, (event) => { if (event.locale !== locale || event.hash !== hash) { @@ -213,17 +231,43 @@ class I18nManager< */ getGTClass(locale?: string): GT { return this.getGTClassClean( - locale ? this.resolveLocale(locale) : undefined + locale ? this._resolveLocale(locale) : undefined, ); } /** * Is translation enabled? + * @deprecated use condition store instead + * TODO: move this to condition store */ isTranslationEnabled(): boolean { return this.config.enableI18n; } + /** + * Returns true when development hot reload runtime translation requests can run. + */ + isDevHotReloadEnabled(): boolean { + return ( + !!this.config.devApiKey && + !!this.config.projectId && + this.isRuntimeUrlEnabled() && + this.config.environment === "development" + ); + } + + // ========== Translation Updates ========== // + + updateTranslations( + translationsSnapshot: Record>, + ): void { + this.localesCache.updateTranslations(translationsSnapshot); + } + + updateDictionaries(dictionarySnapshot: Record): void { + this.localesCache.updateDictionaries(dictionarySnapshot); + } + // ========== Translation Loading ========== // /** @@ -236,14 +280,26 @@ class I18nManager< // ========== Translation Resolution ========== // - // ----- New Operations ----- // + /** + * Used for checking the status of a translation load + */ + hasTranslations(locale: string): boolean { + try { + const translationLocale = this.resolveCacheLocale(locale); + if (!translationLocale) return false; + return this.localesCache.getTranslations(translationLocale) !== undefined; + } catch (error) { + this.handleError(error); + return false; + } + } /** * Loads in translations for a given locale * Edge case usage: access the translations object directly */ async loadTranslations( - locale: string + locale: string, ): Promise> { try { // Validate @@ -308,7 +364,7 @@ class I18nManager< */ lookupDictionaryObj( locale: string, - id: string + id: string, ): DictionaryObject | undefined { try { const dictionaryLocale = this.resolveDictionaryCacheLocale(locale); @@ -325,7 +381,7 @@ class I18nManager< */ async lookupDictionaryWithFallback( locale: string, - id: string + id: string, ): Promise { try { const dictionaryLocale = this.resolveCacheLocale(locale); @@ -340,7 +396,7 @@ class I18nManager< if (dictionaryEntry === undefined) { dictionaryEntry = await dictionaryCache.materializeEntry( id, - this.getSourceDictionaryEntry(id) + this.getSourceDictionaryEntry(id), ); } return dictionaryEntry; @@ -356,7 +412,7 @@ class I18nManager< */ async lookupDictionaryObjWithFallback( locale: string, - id: string + id: string, ): Promise { try { const dictionaryLocale = this.resolveCacheLocale(locale); @@ -381,7 +437,7 @@ class I18nManager< return await dictionaryCache.materializeValue( id, sourceObject, - targetObject + targetObject, ); } catch (error) { this.handleError(error); @@ -392,16 +448,16 @@ class I18nManager< private async translateDictionaryEntry( locale: Locale, id: string, - sourceEntry: DictionaryEntry + sourceEntry: DictionaryEntry, ): Promise { const translation = await this.lookupTranslationWithFallbackResolved( locale, sourceEntry.entry as TranslationValue, - resolveDictionaryLookupOptions(sourceEntry.options) + resolveDictionaryLookupOptions(sourceEntry.options), ); - if (typeof translation !== 'string') { + if (typeof translation !== "string") { throw new Error( - `Dictionary entry "${id}" could not be translated into a string. Check the source entry and translation loader output.` + `Dictionary entry "${id}" could not be translated into a string. Check the source entry and translation loader output.`, ); } return translation; @@ -417,7 +473,7 @@ class I18nManager< private getSourceDictionaryObject( id: string, - { throwOnMissing = true }: { throwOnMissing?: boolean } = {} + { throwOnMissing = true }: { throwOnMissing?: boolean } = {}, ): DictionaryObject | undefined { const sourceObject = this.getDefaultDictionaryCache()?.getValue(id); if (sourceObject === undefined && throwOnMissing) { @@ -440,7 +496,7 @@ class I18nManager< lookupTranslation( locale: string, message: T, - options: LookupOptions + options: LookupOptions, ): T | undefined { try { // Validate @@ -473,13 +529,13 @@ class I18nManager< >( locale: string, message: T, - options: LookupOptions + options: LookupOptions, ): Promise { try { return await this.lookupTranslationWithFallbackResolved( locale, message, - options + options, ); } catch (error) { this.handleError(error); @@ -501,7 +557,7 @@ class I18nManager< prefetchEntries: { message: TranslationValue; options: LookupOptions; - }[] = [] + }[] = [], ): Promise> { try { // Validate @@ -518,11 +574,11 @@ class I18nManager< translationLocale, (entryLocale) => this.resolveCacheLocale(entryLocale) ?? - this.resolveLocale(entryLocale) + this._resolveLocale(entryLocale), ); if (resolvedPrefetchEntries.length !== prefetchEntries.length) { logger.warn( - `I18nManager: getLookupTranslation(): prefetchEntries must all be the same locale, ignoring all entries that are not for ${translationLocale}` + `I18nManager: getLookupTranslation(): prefetchEntries must all be the same locale, ignoring all entries that are not for ${translationLocale}`, ); } @@ -533,7 +589,7 @@ class I18nManager< await Promise.all( resolvedPrefetchEntries .filter((entry) => txCache.get(entry) == null) - .map((entry) => txCache.miss(entry)) + .map((entry) => txCache.miss(entry)), ); // Create translation resolver @@ -562,7 +618,7 @@ class I18nManager< resolveTranslationSync = ( locale: string, message: T, - options: LookupOptions + options: LookupOptions, ) => { return this.lookupTranslation(locale, message, options); }; @@ -574,7 +630,7 @@ class I18nManager< * @deprecated use loadTranslations instead */ async getTranslations( - locale: string + locale: string, ): Promise> { try { return this.loadTranslations(locale); @@ -595,7 +651,7 @@ class I18nManager< * @deprecated use getLookupTranslation instead */ async getTranslationResolver( - locale: string + locale: string, ): Promise> { return this.getLookupTranslation(locale); } @@ -629,6 +685,15 @@ class I18nManager< ); } + public sanitizeLocale(locale: string): string | undefined { + try { + return this._resolveLocale(locale); + } catch (error) { + this.handleError(error); + return undefined; + } + } + /** * Handle errors * Soft error in production, throw in development @@ -639,20 +704,20 @@ class I18nManager< } switch (this.config.environment) { - case 'development': + case "development": throw error; - case 'production': + case "production": default: - logger.error('I18nManager: ' + error); + logger.error("I18nManager: " + error); break; } } - private resolveLocale(locale: string) { + private _resolveLocale(locale: string) { const resolvedLocale = this.localeConfig.determineLocale(locale); if (!this.localeConfig.isValidLocale(locale) || !resolvedLocale) { throw new Error( - `Locale "${locale}" is not valid. Use a valid BCP 47 locale code or add a custom mapping.` + `Locale "${locale}" is not valid. Use a valid BCP 47 locale code or add a custom mapping.`, ); } return resolvedLocale; @@ -661,15 +726,16 @@ class I18nManager< /** * Resolve the locale key used to load/read locale caches. * Returns undefined when the requested locale can use source content. + * TODO: this maybe made redundant by resolveLocale() */ private resolveCacheLocale(locale: string) { - const resolvedLocale = this.resolveLocale(locale); + const resolvedLocale = this._resolveLocale(locale); if (this.requiresTranslation(resolvedLocale)) { return resolvedLocale; } const aliasLocale = this.localeConfig.resolveAliasLocale( - standardizeLocale(locale) + standardizeLocale(locale), ); if (this.requiresTranslation(aliasLocale)) { return aliasLocale; @@ -690,7 +756,7 @@ class I18nManager< private resolveLookupOptions( options: LookupOptions = {} as LookupOptions, - translationLocale?: string + translationLocale?: string, ) { if (!options.$locale) { return options; @@ -700,10 +766,14 @@ class I18nManager< $locale: translationLocale ?? this.resolveCacheLocale(options.$locale) ?? - this.resolveLocale(options.$locale), + this._resolveLocale(options.$locale), }; } + private isRuntimeUrlEnabled(): boolean { + return this.config.runtimeUrl !== null && this.config.runtimeUrl !== ""; + } + private async lookupTranslationWithFallbackResolved< T extends TranslationValue = TranslationValue, >(locale: string, message: T, options: LookupOptions): Promise { @@ -738,9 +808,9 @@ class I18nManager< locales: Array.from( new Set( this.config.locales.map((locale) => - this.localeConfig.resolveCanonicalLocale(locale) - ) - ) + this.localeConfig.resolveCanonicalLocale(locale), + ), + ), ), customMapping: this.config.customMapping, projectId: this.config.projectId, @@ -761,7 +831,7 @@ export { I18nManager }; * @returns The standardized config */ function standardizeConfig( - config: I18nManagerConstructorParams + config: I18nManagerConstructorParams, ) { const gtServicesEnabled = getGTServicesEnabled(config); @@ -772,7 +842,7 @@ function standardizeConfig( }); return { - environment: config.environment || 'production', + environment: config.environment || getRuntimeEnvironment(), enableI18n: config.enableI18n !== undefined ? config.enableI18n : true, projectId: config.projectId, devApiKey: config.devApiKey, @@ -788,6 +858,27 @@ function standardizeConfig( }; } +function getRuntimeEnvironment(): "development" | "production" { + const processEnv = typeof process === "undefined" ? undefined : process.env; + if (processEnv?.NODE_ENV === "development") { + return "development"; + } + + const importMetaEnv = ( + import.meta as ImportMeta & { + env?: RuntimeEnvironment; + } + ).env; + if (importMetaEnv?.MODE) { + return importMetaEnv.MODE === "development" ? "development" : "production"; + } + if (importMetaEnv?.DEV === true) { + return "development"; + } + + return "production"; +} + /** * Dedupe locales and add defaultLocale */ @@ -820,7 +911,7 @@ function standardizeLocales(config: { const defaultLocale = standardizeLocale(config.defaultLocale); const locales = config.locales.map((locale) => { const mappedLocale = - typeof config.customMapping?.[locale] === 'string' + typeof config.customMapping?.[locale] === "string" ? config.customMapping?.[locale] : config.customMapping?.[locale]?.code; if (mappedLocale) { @@ -834,13 +925,13 @@ function standardizeLocales(config: { const customMapping = Object.fromEntries( Object.entries(config.customMapping || {}).map(([key, value]) => [ key, - typeof value === 'string' + typeof value === "string" ? standardizeLocale(value) : { ...value, ...(value.code ? { code: standardizeLocale(value.code) } : {}), }, - ]) + ]), ); return { @@ -860,7 +951,7 @@ function standardizeLocales(config: { function resolvePrefetchEntriesByLocale( prefetchEntries: PrefetchEntry[], locale: string, - resolveLocale: (locale: string) => string + resolveLocale: (locale: string) => string, ) { return prefetchEntries.flatMap((entry) => { const entryLocale = entry.options.$locale; diff --git a/packages/i18n/src/i18n-manager/__tests__/I18nManager.test.ts b/packages/i18n/src/i18n-manager/__tests__/I18nManager.test.ts index c71492061..8e7295a64 100644 --- a/packages/i18n/src/i18n-manager/__tests__/I18nManager.test.ts +++ b/packages/i18n/src/i18n-manager/__tests__/I18nManager.test.ts @@ -69,6 +69,54 @@ describe('I18nManager', () => { expect(result).toBe(translatedString); }); + it('enables dev hot reload with dev credentials, a project id, and development environment', () => { + const manager = createManager({ + devApiKey: 'dev-key', + projectId: 'project-id', + environment: 'development', + }); + + expect(manager.isDevHotReloadEnabled()).toBe(true); + }); + + it.each([ + { + name: 'missing dev API key', + config: { + projectId: 'project-id', + environment: 'development', + }, + }, + { + name: 'missing project id', + config: { + devApiKey: 'dev-key', + environment: 'development', + }, + }, + { + name: 'disabled runtime URL', + config: { + devApiKey: 'dev-key', + projectId: 'project-id', + runtimeUrl: null, + environment: 'development', + }, + }, + { + name: 'production environment', + config: { + devApiKey: 'dev-key', + projectId: 'project-id', + environment: 'production', + }, + }, + ])('disables dev hot reload for $name', ({ config }) => { + const manager = createManager(config); + + expect(manager.isDevHotReloadEnabled()).toBe(false); + }); + // ===== NEW BEHAVIOR TESTS ===== // it('loadTranslations() returns Record', async () => { @@ -184,6 +232,53 @@ describe('I18nManager', () => { }); }); + it('updateDictionaries() updates locale dictionary lookups', () => { + const manager = createManager({ + dictionary: { + greeting: 'Hello', + cta: 'Click me', + navigation: { + about: 'About', + }, + }, + }); + + manager.updateDictionaries({ + en: { + greeting: 'Hi', + navigation: { + home: 'Home', + }, + }, + fr: { + greeting: 'Bonjour', + navigation: { + home: 'Accueil', + }, + }, + }); + + expect(manager.lookupDictionary('en', 'greeting')).toEqual({ + entry: 'Hi', + options: {}, + }); + expect(manager.lookupDictionaryObj('en', 'navigation')).toEqual({ + about: 'About', + home: 'Home', + }); + expect(manager.lookupDictionary('en', 'cta')).toEqual({ + entry: 'Click me', + options: {}, + }); + expect(manager.lookupDictionary('fr', 'greeting')).toEqual({ + entry: 'Bonjour', + options: {}, + }); + expect(manager.lookupDictionaryObj('fr', 'navigation')).toEqual({ + home: 'Accueil', + }); + }); + it.each([ { name: 'dialect locale', diff --git a/packages/i18n/src/i18n-manager/condition-store/createConditionStoreSingleton.ts b/packages/i18n/src/i18n-manager/condition-store/createConditionStoreSingleton.ts deleted file mode 100644 index e72c5f324..000000000 --- a/packages/i18n/src/i18n-manager/condition-store/createConditionStoreSingleton.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ConditionStore } from '../types'; -import { setConditionStore as setCurrentConditionStore } from '../singleton-operations'; - -export function createConditionStoreSingleton( - notInitializedMessage: string -) { - let conditionStore: T | undefined; - - function getConditionStore(): T { - if (!conditionStore) { - throw new Error(notInitializedMessage); - } - return conditionStore; - } - - function setConditionStore(nextConditionStore: T): void { - conditionStore = nextConditionStore; - setCurrentConditionStore(nextConditionStore); - } - - return { - getConditionStore, - setConditionStore, - }; -} diff --git a/packages/i18n/src/i18n-manager/index.ts b/packages/i18n/src/i18n-manager/index.ts index d227e4263..305706cac 100644 --- a/packages/i18n/src/i18n-manager/index.ts +++ b/packages/i18n/src/i18n-manager/index.ts @@ -1,12 +1,15 @@ // Classes -export { I18nManager } from './I18nManager'; -export { createConditionStoreSingleton } from './condition-store/createConditionStoreSingleton'; +export { I18nManager } from "./I18nManager"; +export { ReadonlyConditionStore } from "../condition-store/ReadonlyConditionStore"; +export type { ReadonlyConditionStoreParams } from "../condition-store/ReadonlyConditionStore"; +export { WritableConditionStore } from "../condition-store/WritableConditionStore"; +export type { WritableConditionStoreParams } from "../condition-store/WritableConditionStore"; export { createLocaleResolver, determineSupportedLocale, resolveSupportedLocale, -} from './condition-store/localeResolver'; -export type { LocaleCandidates } from './condition-store/localeResolver'; +} from "../condition-store/localeResolver"; +export type { LocaleCandidates } from "../condition-store/localeResolver"; // Events export { @@ -14,12 +17,7 @@ export { LOCALES_CACHE_MISS_EVENT_NAME, LOCALES_DICTIONARY_CACHE_MISS_EVENT_NAME, TRANSLATIONS_CACHE_MISS_EVENT_NAME, -} from './event-subscription/types'; +} from "./event-subscription/types"; // Functions -export { - getCurrentLocale, - getI18nManager, - setI18nManager, - setConditionStore, -} from './singleton-operations'; +export { getI18nManager, setI18nManager } from "./singleton-operations"; diff --git a/packages/i18n/src/i18n-manager/singleton-operations.ts b/packages/i18n/src/i18n-manager/singleton-operations.ts index 5663c9003..3cf338d94 100644 --- a/packages/i18n/src/i18n-manager/singleton-operations.ts +++ b/packages/i18n/src/i18n-manager/singleton-operations.ts @@ -1,29 +1,26 @@ -import { libraryDefaultLocale } from 'generaltranslation/internal'; -import { I18nManager } from './I18nManager'; -import logger from '../logs/logger'; -import { Translation } from './translations-manager/utils/types/translation-data'; -import type { ConditionStore } from './types'; +import { libraryDefaultLocale } from "generaltranslation/internal"; +import { I18nManager } from "./I18nManager"; +import logger from "../logs/logger"; +import { Translation } from "./translations-manager/utils/types/translation-data"; +import { createConditionStoreSingleton } from "../condition-store/createConditionStoreSingleton"; +import { WritableConditionStoreInterface } from "./types"; // Singleton instance of I18nManager let i18nManager: I18nManager | undefined = undefined; -let fallbackDefaultLocale: string = libraryDefaultLocale; -// Used before wrapper runtimes install a condition store; tracks the active manager default. -const fallbackConditionStore: ConditionStore = { - getLocale: () => fallbackDefaultLocale, -}; -let conditionStore: ConditionStore = fallbackConditionStore; /** * Get the singleton instance of I18nManager * @returns The singleton instance of I18nManager * @template U - The type of the translation that will be cached + * + * Note: should not be consumed by gt-react, consumers should use a wrapper */ export function getI18nManager(): | I18nManager | I18nManager { if (!i18nManager) { logger.warn( - 'getI18nManager(): I18nManager was not initialized. Falling back to the default locale until initializeGT() configures translations.' + "getI18nManager(): I18nManager was not initialized. Falling back to the default locale until initializeGT() configures translations.", ); i18nManager = new I18nManager({ defaultLocale: libraryDefaultLocale, @@ -33,37 +30,16 @@ export function getI18nManager(): return i18nManager; } -/** - * Resolve the current locale from the configured runtime condition source. - */ -export function getCurrentLocale(): string { - return conditionStore.getLocale(); -} - -/** - * Configure the runtime condition source. - */ -export function setConditionStore(nextConditionStore: ConditionStore): void { - conditionStore = nextConditionStore; -} - -/** - * Reset the runtime condition source to the active manager's default-locale fallback. - */ -function resetConditionStore(): void { - conditionStore = fallbackConditionStore; -} - /** * Configure the singleton instance of I18nManager * @param config - The configuration for the I18nManager * * Wrapper libraries will export a configure function that will call this function. + * + * Note: should not be consumed by gt-react, consumers should use a wrapper */ export function setI18nManager( - i18nManagerInstance: I18nManager + i18nManagerInstance: I18nManager, ): void { i18nManager = i18nManagerInstance as unknown as I18nManager; - fallbackDefaultLocale = i18nManagerInstance.getDefaultLocale(); - resetConditionStore(); } diff --git a/packages/i18n/src/i18n-manager/translations-manager/DictionaryCache.ts b/packages/i18n/src/i18n-manager/translations-manager/DictionaryCache.ts index 22fa7e2da..1a48dfc0f 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/DictionaryCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/DictionaryCache.ts @@ -3,6 +3,7 @@ import { getDictionaryEntry, getDictionaryValue, getDictionaryValueAtPath, + isDictionaryObject, setDictionaryValueAtPath, } from './utils/dictionary-helpers'; import { materializeDictionaryValue } from './utils/materialize-dictionary'; @@ -124,6 +125,10 @@ export class DictionaryCache { return cloneDictionaryValue(this.cache); } + public update(dictionary: Dictionary): void { + mergeDictionary(this.cache, dictionary); + } + public async materializeValue( key: DictionaryKey, sourceValue: DictionaryValue, @@ -188,3 +193,14 @@ export class DictionaryCache { } } } + +function mergeDictionary(target: Dictionary, source: Dictionary): void { + for (const [key, value] of Object.entries(source)) { + const targetValue = target[key]; + if (isDictionaryObject(targetValue) && isDictionaryObject(value)) { + mergeDictionary(targetValue, value); + } else { + target[key] = cloneDictionaryValue(value); + } + } +} diff --git a/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts b/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts index bde99f76d..3ac018ec1 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts @@ -1,25 +1,25 @@ -import { DictionaryCache } from './DictionaryCache'; +import { DictionaryCache } from "./DictionaryCache"; import type { Dictionary, DictionaryEntry, DictionaryKey, DictionaryLoader, -} from './DictionaryCache'; -import { ResourceCache } from './ResourceCache'; -import { TranslationsCache } from './TranslationsCache'; +} from "./DictionaryCache"; +import { ResourceCache } from "./ResourceCache"; +import { TranslationsCache } from "./TranslationsCache"; import type { Hash, TranslationBatchConfig, TranslationKey, -} from './TranslationsCache'; -import type { CreateTranslateMany } from './utils/createTranslateMany'; -import type { Translation } from './utils/types/translation-data'; -import type { SafeTranslationsLoader } from './translations-loaders/types'; +} from "./TranslationsCache"; +import type { CreateTranslateMany } from "./utils/createTranslateMany"; +import type { Translation } from "./utils/types/translation-data"; +import type { SafeTranslationsLoader } from "./translations-loaders/types"; import type { I18nManagerCacheLifecycleCallbacks, LifecycleCallback, LifecycleParam, -} from '../lifecycle-hooks/types'; +} from "../lifecycle-hooks/types"; /** * Just being explicit about the purpose of this type @@ -29,7 +29,7 @@ export type Locale = string; type TranslateLocaleDictionaryEntry = ( locale: Locale, key: DictionaryKey, - sourceEntry: DictionaryEntry + sourceEntry: DictionaryEntry, ) => Promise; type LocalesCacheParams = { @@ -50,6 +50,14 @@ export class LocalesCache { TranslationsCache >; private readonly dictionaries: ResourceCache; + private readonly createTranslationsCache: ( + locale: Locale, + init: Record, + ) => TranslationsCache; + private readonly createDictionaryCache: ( + locale: Locale, + init: Dictionary, + ) => DictionaryCache; constructor({ ttl, @@ -62,15 +70,22 @@ export class LocalesCache { translateDictionaryEntry, lifecycle, }: LocalesCacheParams) { + this.createTranslationsCache = createTranslationsCacheFactory({ + lifecycle, + createTranslateMany, + batchConfig, + }); + this.createDictionaryCache = (locale, init) => + createDictionaryCache({ + locale, + dictionary: init, + translate: translateDictionaryEntry, + lifecycle, + }); this.translations = new ResourceCache({ ttl, load: async (locale) => - new TranslationsCache({ - init: await loadTranslations(locale), - lifecycle: createTranslationsCacheLifecycle(locale, lifecycle), - translateMany: createTranslateMany(locale), - batchConfig, - }), + this.createTranslationsCache(locale, await loadTranslations(locale)), lifecycle: { onHit: lifecycle.onLocalesCacheHit, onMiss: lifecycle.onLocalesCacheMiss, @@ -80,12 +95,7 @@ export class LocalesCache { this.dictionaries = new ResourceCache({ ttl, load: async (locale) => - createDictionaryCache({ - locale, - dictionary: await loadDictionary(locale), - translate: translateDictionaryEntry, - lifecycle, - }), + this.createDictionaryCache(locale, await loadDictionary(locale)), lifecycle: { onHit: lifecycle.onLocalesDictionaryCacheHit, onMiss: lifecycle.onLocalesDictionaryCacheMiss, @@ -94,24 +104,19 @@ export class LocalesCache { this.dictionaries.set( defaultLocale, - createDictionaryCache({ - locale: defaultLocale, - dictionary, - translate: translateDictionaryEntry, - lifecycle, - }), - { expiresAt: -1 } + this.createDictionaryCache(defaultLocale, dictionary), + { expiresAt: -1 }, ); } public getTranslations( - locale: Locale + locale: Locale, ): TranslationsCache | undefined { return this.translations.get(locale); } public getOrLoadTranslations( - locale: Locale + locale: Locale, ): Promise> { return this.translations.getOrLoad(locale); } @@ -123,11 +128,59 @@ export class LocalesCache { public getOrLoadDictionary(locale: Locale): Promise { return this.dictionaries.getOrLoad(locale); } + + public updateTranslations( + translations: Record>, + ): void { + for (const locale in translations) { + const translationsCache = this.translations.get(locale); + if (translationsCache) { + translationsCache.update(translations[locale]); + } else { + this.translations.set( + locale, + this.createTranslationsCache(locale, translations[locale]), + ); + } + } + } + + public updateDictionaries(dictionaries: Record): void { + for (const locale in dictionaries) { + const dictionaryCache = this.dictionaries.get(locale); + if (dictionaryCache) { + dictionaryCache.update(dictionaries[locale]); + } else { + this.dictionaries.set( + locale, + this.createDictionaryCache(locale, dictionaries[locale]), + ); + } + } + } +} + +function createTranslationsCacheFactory({ + lifecycle, + createTranslateMany, + batchConfig, +}: { + lifecycle: I18nManagerCacheLifecycleCallbacks; + createTranslateMany: CreateTranslateMany; + batchConfig?: TranslationBatchConfig; +}) { + return (locale: Locale, init: Record) => + new TranslationsCache({ + init, + lifecycle: createTranslationsCacheLifecycle(locale, lifecycle), + translateMany: createTranslateMany(locale), + batchConfig, + }); } function createTranslationsCacheLifecycle( locale: Locale, - lifecycle: I18nManagerCacheLifecycleCallbacks + lifecycle: I18nManagerCacheLifecycleCallbacks, ): LifecycleParam< TranslationKey, Hash, @@ -164,7 +217,7 @@ function createDictionaryCache({ onMiss: withLocale(locale, onDictionaryCacheMiss), onDictionaryObjectCacheHit: withLocale( locale, - onDictionaryObjectCacheHit + onDictionaryObjectCacheHit, ), }, }); @@ -180,7 +233,7 @@ function withLocale( cacheValue: CacheValue; outputValue: OutputValue; }) => void) - | undefined + | undefined, ): LifecycleCallback | undefined { return callback ? (params) => callback({ locale, ...params }) : undefined; } diff --git a/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts b/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts index 25fdc4e9a..1e9f87354 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts @@ -217,6 +217,10 @@ export class TranslationsCache { return translationPromise; } + public update(translations: Record): void { + this.cache = { ...this.cache, ...translations }; + } + private flushNow(): void { if (this.batchTimer) { clearTimeout(this.batchTimer); diff --git a/packages/i18n/src/i18n-manager/translations-manager/__tests__/DictionaryCache.test.ts b/packages/i18n/src/i18n-manager/translations-manager/__tests__/DictionaryCache.test.ts index a34bca04c..796c3c802 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/__tests__/DictionaryCache.test.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/__tests__/DictionaryCache.test.ts @@ -136,6 +136,45 @@ describe('DictionaryCache', () => { expect(result).toBeUndefined(); }); + it('update() merges the cached dictionary', () => { + const cache = new DictionaryCache({ + init: dictionary, + runtimeTranslate, + }); + + const updatedDictionary: Dictionary = { + greeting: 'Hi', + user: { + profile: { + title: 'Title', + }, + }, + }; + cache.update(updatedDictionary); + updatedDictionary.user = { + profile: { + title: 'Changed', + }, + }; + + expect(cache.getEntry('greeting')).toEqual({ + entry: 'Hi', + options: {}, + }); + expect(cache.getEntry('cta')).toEqual({ + entry: 'Click me', + options: {}, + }); + expect(cache.getEntry('user.profile.name')).toEqual({ + entry: 'Name', + options: {}, + }); + expect(cache.getEntry('user.profile.title')).toEqual({ + entry: 'Title', + options: {}, + }); + }); + it('materializeEntry() rejects because fallback is not implemented', async () => { const cache = new DictionaryCache({ init: {}, diff --git a/packages/i18n/src/i18n-manager/translations-manager/__tests__/LocalesCache.test.ts b/packages/i18n/src/i18n-manager/translations-manager/__tests__/LocalesCache.test.ts index aaa9f27a8..899313d3b 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/__tests__/LocalesCache.test.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/__tests__/LocalesCache.test.ts @@ -288,5 +288,52 @@ describe('LocalesCache', () => { expect(cache.getDictionary('en')).toBeDefined(); }); + + it('updateDictionaries() updates locale dictionary caches', async () => { + const cache = createCache(); + await cache.getOrLoadDictionary('fr'); + + cache.updateDictionaries({ + en: { + greeting: 'Hi', + navigation: { + home: 'Home', + }, + }, + fr: { + greeting: 'Salut', + user: { + title: 'Titre', + }, + navigation: { + home: 'Accueil', + }, + }, + }); + + expect(cache.getDictionary('en')?.getInternalCache()).toEqual({ + greeting: 'Hi', + navigation: { + home: 'Home', + }, + cta: ['Click me', { $context: 'button', $maxChars: 12 }], + }); + expect(cache.getDictionary('en')?.getEntry('cta')).toEqual({ + entry: 'Click me', + options: { $context: 'button', $maxChars: 12 }, + }); + expect(cache.getDictionary('fr')?.getInternalCache()).toEqual({ + greeting: 'Salut', + cta: ['Cliquez', { context: 'button' }], + user: { + name: 'Nom', + title: 'Titre', + }, + navigation: { + home: 'Accueil', + }, + }); + expect(mockLoadDictionary).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/packages/i18n/src/i18n-manager/translations-manager/utils/dictionary-helpers.ts b/packages/i18n/src/i18n-manager/translations-manager/utils/dictionary-helpers.ts index 056465640..14c9f99f1 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/utils/dictionary-helpers.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/utils/dictionary-helpers.ts @@ -31,10 +31,14 @@ export function assertSafeDictionaryPathSegment( } } -export function isDictionaryObject(value: unknown): value is Dictionary { +export function isDictionaryValue(value: unknown): value is Dictionary { return typeof value === 'object' && value != null && !Array.isArray(value); } +export function isDictionaryObject(value: unknown): value is Dictionary { + return isDictionaryValue(value); +} + export function cloneDictionaryValue( value: Value ): Value { diff --git a/packages/i18n/src/i18n-manager/types.ts b/packages/i18n/src/i18n-manager/types.ts index 733503bdd..a57d52600 100644 --- a/packages/i18n/src/i18n-manager/types.ts +++ b/packages/i18n/src/i18n-manager/types.ts @@ -1,14 +1,14 @@ -import type { RuntimeTranslateManyOptions } from 'generaltranslation/internal'; -import type { CustomMapping } from '@generaltranslation/format/types'; -import type { GTConfig } from '../config/types'; -import type { TranslationsLoader } from './translations-manager/translations-loaders/types'; -import type { Translation } from './translations-manager/utils/types/translation-data'; -import type { LifecycleCallbacks } from './lifecycle-hooks/types'; +import type { RuntimeTranslateManyOptions } from "generaltranslation/internal"; +import type { CustomMapping } from "@generaltranslation/format/types"; +import type { GTConfig } from "../config/types"; +import type { TranslationsLoader } from "./translations-manager/translations-loaders/types"; +import type { Translation } from "./translations-manager/utils/types/translation-data"; +import type { LifecycleCallbacks } from "./lifecycle-hooks/types"; +import type { TranslationBatchConfig } from "./translations-manager/TranslationsCache"; import type { Dictionary, DictionaryLoader, -} from './translations-manager/DictionaryCache'; -import type { TranslationBatchConfig } from './translations-manager/TranslationsCache'; +} from "./translations-manager/DictionaryCache"; export type DictionaryConfig = | { @@ -31,14 +31,14 @@ type RuntimeTranslationConfig = { export type I18nManagerConstructorParams< TranslationValue extends Translation = Translation, > = DictionaryConfig & - Omit & { + Omit & { /** * Locale cache TTL in milliseconds. Undefined uses the default TTL, null * disables expiry, and a number sets an explicit TTL. */ cacheExpiryTime?: number | null; loadTranslations?: TranslationsLoader; - environment?: 'development' | 'production'; + environment?: "development" | "production"; batchConfig?: TranslationBatchConfig; runtimeTranslation?: RuntimeTranslationConfig; // Cache lifecycle hooks @@ -50,10 +50,16 @@ export type I18nManagerConstructorParams< * I18nManager class configuration */ export type I18nManagerConfig = { - environment: 'development' | 'production'; + environment: "development" | "production"; defaultLocale: string; locales: string[]; customMapping: CustomMapping; + /** + * @deprecated + * Perhaps we can keep this around, but more for + * doing an initial load, but it may get overwritten + * so like a "initialEnableI18n" flag? + */ enableI18n: boolean; projectId?: string; devApiKey?: string; @@ -72,7 +78,7 @@ export type I18nManagerConfig = { /** * Shared configuration used by condition stores to resolve locales. */ -export type ConditionStoreConfig = { +export type LocaleResolverConfig = { defaultLocale?: string; locales?: string[]; customMapping?: CustomMapping; @@ -84,21 +90,35 @@ export type ConditionStoreConfig = { * Locale is the first condition exposed by this contract; additional runtime * conditions can be added here as needed. */ -export interface ConditionStore { +export interface ReadonlyConditionStoreInterface { getLocale(): string; + getEnableI18n(): boolean; + + // --- no-op methods --- // + /** + * ReadonlyConditionStore is used in SSR GTProvider + * These have to be included to avoid throwing errors during SSR + * const setLocale = useSetLocale(); + * setLocale('es-ES'); // -> cannot invoke undefined function + */ + setLocale(locale: string): void; + setEnableI18n(enabled: boolean): void; } /** * Condition store contract for runtimes that can persist locale changes. */ -export interface WritableConditionStore extends ConditionStore { +export interface WritableConditionStoreInterface + extends ReadonlyConditionStoreInterface { setLocale(locale: string): void; + setEnableI18n(enabled: boolean): void; } /** * Condition store contract for runtimes with scoped locale context. */ -export interface ScopedConditionStore extends ConditionStore { +export interface ScopedConditionStoreInterface + extends ReadonlyConditionStoreInterface { run(locale: string, callback: () => T): T; } diff --git a/packages/i18n/src/internal-types.ts b/packages/i18n/src/internal-types.ts index dd1b6c6ac..7db22a0a6 100644 --- a/packages/i18n/src/internal-types.ts +++ b/packages/i18n/src/internal-types.ts @@ -4,15 +4,15 @@ export type { TranslationsLoader, I18nManagerConfig, LifecycleCallbacks, - ConditionStoreConfig, - ConditionStore, - WritableConditionStore, - ScopedConditionStore, + LocaleResolverConfig, + ReadonlyConditionStoreInterface, + WritableConditionStoreInterface, + ScopedConditionStoreInterface, Dictionary, DictionaryLoader, DictionaryConfig, -} from './i18n-manager/types'; -export type { LocaleCandidates } from './i18n-manager/condition-store/localeResolver'; +} from "./i18n-manager/types"; +export type { LocaleCandidates } from "./condition-store/localeResolver"; export type { DictionaryValue, DictionaryEntry, @@ -21,10 +21,17 @@ export type { DictionaryOptions, DictionaryPath, DictionaryKey, -} from './i18n-manager/translations-manager/DictionaryCache'; +} from "./i18n-manager/translations-manager/DictionaryCache"; +export type { ReadonlyConditionStoreParams } from "./condition-store/ReadonlyConditionStore"; +export type { WritableConditionStoreParams } from "./condition-store/WritableConditionStore"; // Translation Options (Function types exported by /types) -export type * from './translation-functions/types/options'; +export type * from "./translation-functions/types/options"; +export type { InlineTranslationOptionsFields } from "./translation-functions/types/options"; // Config -export type * from './config/types'; +export type * from "./config/types"; + +// Internal types +export type { Hash } from "./i18n-manager/translations-manager/TranslationsCache"; +export type { Locale } from "./i18n-manager/translations-manager/LocalesCache"; diff --git a/packages/i18n/src/internal.ts b/packages/i18n/src/internal.ts index 6e713b084..ec75fa4d5 100644 --- a/packages/i18n/src/internal.ts +++ b/packages/i18n/src/internal.ts @@ -1,4 +1,28 @@ -export * from './translation-functions/internal'; -export * from './i18n-manager'; -export * from './translation-functions/utils/interpolation/interpolateIcuMessage'; -export * from './helpers'; +export * from "./translation-functions/internal"; +export * from "./i18n-manager"; +export * from "./translation-functions/utils/interpolation/interpolateIcuMessage"; +export * from "./helpers"; +export { interpolateMessage } from "./translation-functions/utils/interpolation/interpolateMessage"; +export { createLookupOptions } from "./translation-functions/internal/helpers"; +export { isEncodedTranslationOptions } from "./translation-functions/utils/isEncodedTranslationOptions"; +export { extractVariables } from "./utils/extractVariables"; +export { + getDictionaryListenerKey, + getTranslateListenerKey, +} from "./utils/listenerKeys"; +export type { + DictionaryListenerLookup, + TranslateListenerLookup, +} from "./utils/listenerKeys"; +export { + getDictionaryEntry, + isDictionaryValue, + getDictionaryValue, + resolveDictionaryLookupOptions, +} from "./i18n-manager/translations-manager/utils/dictionary-helpers"; + +export { + getI18nManager, + setI18nManager, +} from "./i18n-manager/singleton-operations"; +export { createConditionStoreSingleton } from "./condition-store/createConditionStoreSingleton"; diff --git a/packages/i18n/src/translation-functions/internal/getGT.ts b/packages/i18n/src/translation-functions/internal/getGT.ts index a7a887378..24972bb68 100644 --- a/packages/i18n/src/translation-functions/internal/getGT.ts +++ b/packages/i18n/src/translation-functions/internal/getGT.ts @@ -1,12 +1,10 @@ -import { - getCurrentLocale, - getI18nManager, -} from '../../i18n-manager/singleton-operations'; -import { InlineTranslationOptions } from '../types/options'; -import { GTFunctionType } from '../types/functions'; -import { interpolateMessage } from '../utils/interpolation/interpolateMessage'; -import { createLookupOptions } from './helpers'; -import type { StringFormat } from '@generaltranslation/format/types'; +import { getI18nManager } from "../../i18n-manager/singleton-operations"; +import { InlineTranslationOptions } from "../types/options"; +import { GTFunctionType } from "../types/functions"; +import { interpolateMessage } from "../utils/interpolation/interpolateMessage"; +import { createLookupOptions } from "./helpers"; +import type { StringFormat } from "@generaltranslation/format/types"; +import { getLocale } from "../../helpers/locale"; /** * Returns the gt function that registers a string at build time and resolves its translation at runtime. @@ -19,7 +17,7 @@ import type { StringFormat } from '@generaltranslation/format/types'; export async function getGT(): Promise { // Get the translation resolver const i18nManager = getI18nManager(); - const locale = getCurrentLocale(); + const locale = getLocale(); await i18nManager.loadTranslations(locale); const sourceLocale = i18nManager.getDefaultLocale(); @@ -41,20 +39,20 @@ export async function getGT(): Promise { */ const gt: GTFunctionType = ( message: string, - options: InlineTranslationOptions = {} + options: InlineTranslationOptions = {}, ) => { const targetLocale = options.$locale ?? locale; const lookupOptions = createLookupOptions( targetLocale, options, - 'ICU' + "ICU", ); // Lookup translation const translation = i18nManager.lookupTranslation( lookupOptions.$locale, message, - lookupOptions + lookupOptions, ); // Format result diff --git a/packages/i18n/src/translation-functions/internal/getTranslations.ts b/packages/i18n/src/translation-functions/internal/getTranslations.ts index 721bd8d60..c030b8050 100644 --- a/packages/i18n/src/translation-functions/internal/getTranslations.ts +++ b/packages/i18n/src/translation-functions/internal/getTranslations.ts @@ -1,23 +1,11 @@ -import { - getCurrentLocale, - getI18nManager, -} from '../../i18n-manager/singleton-operations'; -import { DictionaryTranslationOptions } from '../types/options'; -import { TFunctionType } from '../types/functions'; -import { interpolateMessage } from '../utils/interpolation/interpolateMessage'; -import { createLookupOptions } from './helpers'; -import { extractVariables } from '../../utils/extractVariables'; -import type { StringFormat } from '@generaltranslation/format/types'; -import type { - DictionaryEntry, - DictionaryValue, -} from '../../i18n-manager/translations-manager/DictionaryCache'; -import { - getDictionaryEntry, - isDictionaryObject, - resolveDictionaryLookupOptions, -} from '../../i18n-manager/translations-manager/utils/dictionary-helpers'; -import type { DictionaryObjectTranslation } from '../types/functions'; +import { getI18nManager } from "../../i18n-manager/singleton-operations"; +import { DictionaryTranslationOptions } from "../types/options"; +import { TFunctionType } from "../types/functions"; +import { renderDictionaryEntry } from "./renderDictionaryEntry"; +import { renderDictionaryObject } from "./renderDictionaryObject"; +import { resolveDictionaryLookupOptions } from "../../i18n-manager/translations-manager/utils/dictionary-helpers"; +import type { DictionaryObjectTranslation } from "../types/functions"; +import { getLocale } from "../../helpers/locale"; /** * Returns the t function that translates a dictionary entry based on its id and options. @@ -30,7 +18,7 @@ import type { DictionaryObjectTranslation } from '../types/functions'; */ export async function getTranslations(): Promise { const i18nManager = getI18nManager(); - const locale = getCurrentLocale(); + const locale = getLocale(); await Promise.all([ i18nManager.loadDictionary(locale), i18nManager.loadTranslations(locale), @@ -55,7 +43,7 @@ export async function getTranslations(): Promise { */ const t = (( id: string, - options: DictionaryTranslationOptions = {} + options: DictionaryTranslationOptions = {}, ): string => { const sourceEntry = i18nManager.lookupDictionary(sourceLocale, id); if (sourceEntry === undefined) { @@ -63,16 +51,16 @@ export async function getTranslations(): Promise { } const targetEntry = i18nManager.lookupDictionary(locale, id); const dictionaryOptions = resolveDictionaryLookupOptions( - sourceEntry.options + sourceEntry.options, ); const target = targetEntry?.entry ?? i18nManager.lookupTranslation( locale, sourceEntry.entry, - dictionaryOptions + dictionaryOptions, ); - return renderEntry({ + return renderDictionaryEntry({ sourceLocale, targetLocale: locale, sourceEntry, @@ -99,128 +87,17 @@ export async function getTranslations(): Promise { throw new Error(`Dictionary entry ${id} cannot be found`); } const targetObject = i18nManager.lookupDictionaryObj(locale, id); - return renderObject({ sourceObject, targetObject }); + return renderDictionaryObject({ + sourceObject, + targetObject, + translate: (sourceEntry, dictionaryOptions) => + i18nManager.lookupTranslation( + locale, + sourceEntry.entry, + dictionaryOptions, + ), + }); }; - function renderObject({ - sourceObject, - targetObject, - }: { - sourceObject: DictionaryValue | undefined; - targetObject: DictionaryValue | undefined; - }): DictionaryObjectTranslation { - const targetEntry = getDictionaryEntry(targetObject); - if (targetEntry !== undefined) { - return targetEntry.entry; - } - - if (isDictionaryObject(targetObject)) { - if (!isDictionaryObject(sourceObject)) { - return renderObject({ - sourceObject: targetObject, - targetObject: undefined, - }); - } - - return renderDictionaryObject({ - sourceObject, - targetObject, - }); - } - - const sourceEntry = getDictionaryEntry(sourceObject); - if (sourceEntry !== undefined) { - // Fallback to translations cache - const dictionaryOptions = resolveDictionaryLookupOptions( - sourceEntry.options - ); - - const target = i18nManager.lookupTranslation( - locale, - sourceEntry.entry, - dictionaryOptions - ); - if (target !== undefined) { - return target; - } - - // Fallback to source entry - return sourceEntry.entry; - } - - if (isDictionaryObject(sourceObject)) { - return renderDictionaryObject({ - sourceObject, - targetObject: undefined, - }); - } - - throw new Error('Dictionary object cannot be rendered'); - } - - function renderDictionaryObject({ - sourceObject, - targetObject, - }: { - sourceObject: DictionaryValue; - targetObject: DictionaryValue | undefined; - }): DictionaryObjectTranslation { - if (!isDictionaryObject(sourceObject)) { - return renderObject({ sourceObject, targetObject }); - } - const result: Record = {}; - const keys = new Set([ - ...Object.keys(sourceObject), - ...(isDictionaryObject(targetObject) ? Object.keys(targetObject) : []), - ]); - - for (const key of Array.from(keys)) { - const renderedChild = renderObject({ - sourceObject: sourceObject[key], - targetObject: isDictionaryObject(targetObject) - ? targetObject[key] - : undefined, - }); - if (renderedChild !== undefined) { - result[key] = renderedChild; - } - } - - return result; - } - return t; } - -function renderEntry({ - sourceLocale, - targetLocale, - sourceEntry, - target, - dictionaryOptions, - options = {}, -}: { - sourceLocale: string; - targetLocale: string; - sourceEntry: DictionaryEntry; - target: string | undefined; - dictionaryOptions: ReturnType; - options?: DictionaryTranslationOptions; -}): string { - const interpolationOptions = extractVariables(options); - const lookupOptions = createLookupOptions( - targetLocale, - { - ...dictionaryOptions, - ...interpolationOptions, - }, - dictionaryOptions.$format ?? 'ICU' - ); - - return interpolateMessage({ - source: sourceEntry.entry, - target, - options: lookupOptions, - sourceLocale, - }); -} diff --git a/packages/i18n/src/translation-functions/internal/index.ts b/packages/i18n/src/translation-functions/internal/index.ts index 3d5490d22..3966e2259 100644 --- a/packages/i18n/src/translation-functions/internal/index.ts +++ b/packages/i18n/src/translation-functions/internal/index.ts @@ -5,3 +5,5 @@ export * from './tx'; export * from './sync-translation-resolution'; export * from './jsx-resolution'; export * from './helpers'; +export * from './renderDictionaryEntry'; +export * from './renderDictionaryObject'; diff --git a/packages/i18n/src/translation-functions/internal/renderDictionaryEntry.ts b/packages/i18n/src/translation-functions/internal/renderDictionaryEntry.ts new file mode 100644 index 000000000..8290c9e90 --- /dev/null +++ b/packages/i18n/src/translation-functions/internal/renderDictionaryEntry.ts @@ -0,0 +1,41 @@ +import { createLookupOptions } from './helpers'; +import { extractVariables } from '../../utils/extractVariables'; +import { interpolateMessage } from '../utils/interpolation/interpolateMessage'; +import type { + DictionaryLookupOptions, + DictionaryTranslationOptions, +} from '../types/options'; +import type { DictionaryEntry } from '../../i18n-manager/translations-manager/DictionaryCache'; +import type { StringFormat } from '@generaltranslation/format/types'; + +export function renderDictionaryEntry({ + sourceLocale, + targetLocale, + sourceEntry, + target, + dictionaryOptions, + options = {}, +}: { + sourceLocale: string; + targetLocale: string; + sourceEntry: DictionaryEntry; + target: string | undefined; + dictionaryOptions: DictionaryLookupOptions; + options?: DictionaryTranslationOptions; +}): string { + const lookupOptions = createLookupOptions( + targetLocale, + { + ...dictionaryOptions, + ...extractVariables(options), + }, + dictionaryOptions.$format + ); + + return interpolateMessage({ + source: sourceEntry.entry, + target, + options: lookupOptions, + sourceLocale, + }); +} diff --git a/packages/i18n/src/translation-functions/internal/renderDictionaryObject.ts b/packages/i18n/src/translation-functions/internal/renderDictionaryObject.ts new file mode 100644 index 000000000..3983d904a --- /dev/null +++ b/packages/i18n/src/translation-functions/internal/renderDictionaryObject.ts @@ -0,0 +1,101 @@ +import { + getDictionaryEntry, + isDictionaryValue, + resolveDictionaryLookupOptions, +} from '../../i18n-manager/translations-manager/utils/dictionary-helpers'; +import type { DictionaryObjectTranslation } from '../types/functions'; +import type { DictionaryLookupOptions } from '../types/options'; +import type { + DictionaryEntry, + DictionaryValue, +} from '../../i18n-manager/translations-manager/DictionaryCache'; + +export function renderDictionaryObject({ + sourceObject, + targetObject, + translate, +}: { + sourceObject: DictionaryValue | undefined; + targetObject: DictionaryValue | undefined; + translate?: ( + sourceEntry: DictionaryEntry, + dictionaryOptions: DictionaryLookupOptions + ) => string | undefined; +}): DictionaryObjectTranslation { + const targetEntry = getDictionaryEntry(targetObject); + if (targetEntry !== undefined) { + return targetEntry.entry; + } + + if (isDictionaryValue(targetObject)) { + if (!isDictionaryValue(sourceObject)) { + return renderDictionaryObject({ + sourceObject: targetObject, + targetObject: undefined, + translate, + }); + } + + return renderDictionaryObjectChildren({ + sourceObject, + targetObject, + translate, + }); + } + + const sourceEntry = getDictionaryEntry(sourceObject); + if (sourceEntry !== undefined) { + const dictionaryOptions = resolveDictionaryLookupOptions( + sourceEntry.options + ); + return translate?.(sourceEntry, dictionaryOptions) ?? sourceEntry.entry; + } + + if (isDictionaryValue(sourceObject)) { + return renderDictionaryObjectChildren({ + sourceObject, + targetObject: undefined, + translate, + }); + } + + throw new Error('Dictionary object cannot be rendered'); +} + +function renderDictionaryObjectChildren({ + sourceObject, + targetObject, + translate, +}: { + sourceObject: DictionaryValue; + targetObject: DictionaryValue | undefined; + translate?: ( + sourceEntry: DictionaryEntry, + dictionaryOptions: DictionaryLookupOptions + ) => string | undefined; +}): DictionaryObjectTranslation { + if (!isDictionaryValue(sourceObject)) { + return renderDictionaryObject({ sourceObject, targetObject, translate }); + } + + const result: Record = {}; + const keys = new Set([ + ...Object.keys(sourceObject), + ...(isDictionaryValue(targetObject) ? Object.keys(targetObject) : []), + ]); + + for (const key of Array.from(keys)) { + const renderedChild = renderDictionaryObject({ + sourceObject: sourceObject[key], + targetObject: isDictionaryValue(targetObject) + ? targetObject[key] + : undefined, + translate, + }); + if (renderedChild !== undefined) { + result[key] = renderedChild; + } + } + + return result; +} diff --git a/packages/i18n/src/translation-functions/internal/tx.ts b/packages/i18n/src/translation-functions/internal/tx.ts index 330708d74..af2860a7e 100644 --- a/packages/i18n/src/translation-functions/internal/tx.ts +++ b/packages/i18n/src/translation-functions/internal/tx.ts @@ -1,11 +1,11 @@ -import { RuntimeTranslationOptions } from '../types/options'; -import type { StringFormat } from '@generaltranslation/format/types'; -import { resolveStringContentWithRuntimeFallback } from './helpers'; -import { getCurrentLocale } from '../../i18n-manager/singleton-operations'; +import { RuntimeTranslationOptions } from "../types/options"; +import type { StringFormat } from "@generaltranslation/format/types"; +import { resolveStringContentWithRuntimeFallback } from "./helpers"; +import { getLocale } from "../../helpers/locale"; type RuntimeTranslationOptionsWithFormat = Omit< RuntimeTranslationOptions, - '$format' + "$format" > & { $format?: StringFormat; }; @@ -26,12 +26,12 @@ type RuntimeTranslationOptionsWithFormat = Omit< */ export async function tx( content: string, - options: RuntimeTranslationOptionsWithFormat = {} + options: RuntimeTranslationOptionsWithFormat = {}, ): Promise { const locale = - typeof options.$locale === 'string' ? options.$locale : getCurrentLocale(); + typeof options.$locale === "string" ? options.$locale : getLocale(); return resolveStringContentWithRuntimeFallback(locale, content, { - $format: 'STRING', + $format: "STRING", ...options, }); } diff --git a/packages/i18n/src/translation-functions/t.ts b/packages/i18n/src/translation-functions/t.ts index 5e907aea4..94b043e38 100644 --- a/packages/i18n/src/translation-functions/t.ts +++ b/packages/i18n/src/translation-functions/t.ts @@ -1,6 +1,6 @@ -import { resolveStringContentWithFallback } from './internal/helpers'; -import { InlineTranslationOptions } from './types/options'; -import { getCurrentLocale } from '../i18n-manager/singleton-operations'; +import { resolveStringContentWithFallback } from "./internal/helpers"; +import { InlineTranslationOptions } from "./types/options"; +import { getLocale } from "../helpers/locale"; /** * Translate a message @@ -9,9 +9,9 @@ import { getCurrentLocale } from '../i18n-manager/singleton-operations'; * @returns The translated message. */ export function t(message: string, options: InlineTranslationOptions = {}) { - const locale = options.$locale ?? getCurrentLocale(); + const locale = options.$locale ?? getLocale(); return resolveStringContentWithFallback(locale, message, { - $format: 'ICU', + $format: "ICU", ...options, }); } diff --git a/packages/i18n/src/translation-functions/types/options.ts b/packages/i18n/src/translation-functions/types/options.ts index 8efb66897..80e9b3173 100644 --- a/packages/i18n/src/translation-functions/types/options.ts +++ b/packages/i18n/src/translation-functions/types/options.ts @@ -3,7 +3,9 @@ import type { StringFormat, } from '@generaltranslation/format/types'; -// TODO: next major version, this should be Record +/** + * Values that get interpolated + */ export type BaseTranslationOptions = Record; // For t() @@ -21,7 +23,7 @@ export type DictionaryOptions = BaseTranslationOptions & { * Options for string resolution * Used by the gt() function */ -export type InlineTranslationOptions = BaseTranslationOptions & { +export type InlineTranslationOptionsFields = { $context?: string; $id?: string; /** The data format for the message (e.g., 'ICU', 'STRING'). Defaults to 'ICU'. */ @@ -40,11 +42,20 @@ export type InlineTranslationOptions = BaseTranslationOptions & { $_source?: string; }; +export type InlineTranslationOptions = InlineTranslationOptionsFields & + BaseTranslationOptions; + /** * Options for string resolution * Used by the m() function */ -export type InlineResolveOptions = BaseTranslationOptions; +export type InlineResolveOptions = BaseTranslationOptions & { + $_hash?: string; + $locale?: string; + $format?: StringFormat; + $maxChars?: number; + $id?: string; +}; /** * Options for string registration @@ -83,10 +94,11 @@ export type JsxTranslationOptions = { * Resolution options - options needed to perform a resolution for a given content */ export type LookupOptions = - | (Omit & { - $format: StringFormat; - $locale?: string; - }) + | (BaseTranslationOptions & + (Omit & { + $format: StringFormat; + $locale?: string; + })) | (JsxTranslationOptions & { $format: 'JSX'; $locale?: string; diff --git a/packages/i18n/src/utils/extractVariables.ts b/packages/i18n/src/utils/extractVariables.ts index 168ec54d7..df39016e8 100644 --- a/packages/i18n/src/utils/extractVariables.ts +++ b/packages/i18n/src/utils/extractVariables.ts @@ -1,4 +1,5 @@ import { BaseTranslationOptions } from '../translation-functions/types/options'; + /** * Given an object of options, returns an object with no gt-related options * diff --git a/packages/i18n/src/utils/listenerKeys.ts b/packages/i18n/src/utils/listenerKeys.ts new file mode 100644 index 000000000..572de3054 --- /dev/null +++ b/packages/i18n/src/utils/listenerKeys.ts @@ -0,0 +1,36 @@ +import type { LookupOptions } from '../translation-functions/types/options'; +import type { Translation } from '../types'; +import { hashMessage } from './hashMessage'; + +export type TranslateListenerLookup = + | { + locale: string; + message: T; + options: LookupOptions; + } + | { + locale: string; + hash: string; + }; + +export type DictionaryListenerLookup = { + locale: string; + id: string; +}; + +export function getTranslateListenerKey( + lookup: TranslateListenerLookup +): string { + const hash = + 'hash' in lookup + ? lookup.hash + : hashMessage(lookup.message, lookup.options); + return `${lookup.locale}:${hash}`; +} + +export function getDictionaryListenerKey({ + locale, + id, +}: DictionaryListenerLookup): string { + return `${locale}:${id}`; +} diff --git a/packages/node/src/async-i18n-manager/AsyncConditionStore.ts b/packages/node/src/async-i18n-manager/AsyncConditionStore.ts index ac570219b..6f4abddea 100644 --- a/packages/node/src/async-i18n-manager/AsyncConditionStore.ts +++ b/packages/node/src/async-i18n-manager/AsyncConditionStore.ts @@ -1,19 +1,22 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { createLocaleResolver } from 'gt-i18n/internal'; +import { AsyncLocalStorage } from "node:async_hooks"; import type { - LocaleCandidates, - ConditionStoreConfig, + LocaleResolverConfig, ScopedConditionStore, -} from 'gt-i18n/internal/types'; +} from "gt-i18n/internal/types"; +import { getI18nManager } from "gt-i18n/internal"; type Store = { locale: string; + enableI18n?: boolean; }; const OUTSIDE_SCOPE_MESSAGE = - 'AsyncConditionStore: getLocale() called outside of a withGT() scope.'; + "AsyncConditionStore: getLocale() called outside of a withGT() scope."; -type AsyncConditionStoreConstructorParams = ConditionStoreConfig & { +const ENABLE_I18N_MESSAGE = + "AsyncConditionStore: getEnableI18n() called outside of a withGT() scope."; + +type AsyncConditionStoreConstructorParams = LocaleResolverConfig & { store?: AsyncLocalStorage; }; @@ -22,7 +25,6 @@ type AsyncConditionStoreConstructorParams = ConditionStoreConfig & { */ export class AsyncConditionStore implements ScopedConditionStore { private store: AsyncLocalStorage; - private resolveLocale: (candidates?: LocaleCandidates) => string; constructor({ defaultLocale, @@ -31,26 +33,42 @@ export class AsyncConditionStore implements ScopedConditionStore { store, }: AsyncConditionStoreConstructorParams = {}) { this.store = store ?? new AsyncLocalStorage(); - this.resolveLocale = createLocaleResolver({ - defaultLocale, - locales, - customMapping, - }); } + /** + * TODO: should this be a static function + * */ run(locale: string, callback: () => T): T { - return this.store.run({ locale: this.resolveLocale(locale) }, callback); + return this.store.run( + { locale: getI18nManager().determineLocale(locale) }, + callback, + ); } getLocale(): string { const store = this.store.getStore(); if (!store) { - if (process.env.NODE_ENV === 'production') { + if (process.env.NODE_ENV === "production") { console.warn(OUTSIDE_SCOPE_MESSAGE); - return this.resolveLocale(); + return getI18nManager().determineLocale(); } throw new Error(OUTSIDE_SCOPE_MESSAGE); } - return this.resolveLocale(store.locale); + return getI18nManager().determineLocale(store.locale); + } + + /** + * TODO: implement + */ + getEnableI18n(): boolean { + const store = this.store.getStore(); + if (!store) { + if (process.env.NODE_ENV === "production") { + console.warn(ENABLE_I18N_MESSAGE); + return true; + } + throw new Error(ENABLE_I18N_MESSAGE); + } + return store.enableI18n ?? true; } } diff --git a/packages/node/src/async-i18n-manager/singleton-operations.ts b/packages/node/src/async-i18n-manager/singleton-operations.ts index 2eae3e332..6a2f8c292 100644 --- a/packages/node/src/async-i18n-manager/singleton-operations.ts +++ b/packages/node/src/async-i18n-manager/singleton-operations.ts @@ -1,9 +1,9 @@ -import { createConditionStoreSingleton } from 'gt-i18n/internal'; -import type { AsyncConditionStore } from './AsyncConditionStore'; +import { createConditionStoreSingleton } from "gt-i18n/internal"; +import type { AsyncConditionStore } from "./AsyncConditionStore"; export const { getConditionStore: getAsyncConditionStore, setConditionStore: setAsyncConditionStore, } = createConditionStoreSingleton( - 'AsyncConditionStore not initialized. Invoke initializeGT() to initialize.' + "AsyncConditionStore not initialized. Invoke initializeGT() to initialize.", ); diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 142461f31..a1573d192 100755 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -11,7 +11,7 @@ ], "sideEffects": false, "peerDependencies": { - "react": ">=16.8.0" + "react": ">=18.0.0" }, "dependencies": { "@generaltranslation/format": "workspace:*", @@ -60,6 +60,11 @@ "require": "./dist/internal.cjs.min.cjs", "import": "./dist/internal.esm.min.mjs" }, + "./context": { + "types": "./dist/context.d.ts", + "require": "./dist/context.cjs.min.cjs", + "import": "./dist/context.esm.min.mjs" + }, "./errors": { "types": "./dist/errors.d.ts", "require": "./dist/errors.cjs.min.cjs", @@ -77,6 +82,9 @@ "internal": [ "./dist/internal.d.ts" ], + "context": [ + "./dist/context.d.ts" + ], "errors": [ "./dist/errors.d.ts" ] @@ -88,6 +96,9 @@ "@generaltranslation/react-core/internal": [ "./dist/internal" ], + "@generaltranslation/react-core/context": [ + "./dist/context" + ], "@generaltranslation/react-core/types": [ "./dist/types.d.ts" ], diff --git a/packages/react-core/src/components/branches/Branch.tsx b/packages/react-core/src/components/branches/Branch.tsx new file mode 100644 index 000000000..cf6e33f0d --- /dev/null +++ b/packages/react-core/src/components/branches/Branch.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; + +// ===== Component ===== // + +/** + * External-store version of the `` component. + */ +function Branch({ + children, + branch, + ...branches +}: { + children?: ReactNode; + branch?: string | number | boolean; + [key: string]: ReactNode; +}): ReactNode { + let branchKey = branch?.toString(); + if (typeof branchKey === 'string' && branchKey.startsWith('data-')) { + branchKey = undefined; + } + return branchKey && typeof branches[branchKey] !== 'undefined' + ? branches[branchKey] + : children; +} + +function GtInternalBranch(props: { + children?: ReactNode; + branch?: string | number | boolean; + [key: string]: ReactNode; +}): ReactNode { + return Branch(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +Branch._gtt = 'branch'; +GtInternalBranch._gtt = 'branch-automatic'; + +// ===== Exports ===== // + +export { GtInternalBranch, Branch }; diff --git a/packages/react-core/src/components/branches/Plural.tsx b/packages/react-core/src/components/branches/Plural.tsx new file mode 100644 index 000000000..18b87b0b2 --- /dev/null +++ b/packages/react-core/src/components/branches/Plural.tsx @@ -0,0 +1,40 @@ +import getPluralBranch from "../../utils/plurals/getPluralBranch"; +import type { ReactNode } from "react"; +import { useFormatLocales } from "../../hooks/utils"; + +// ===== Component ===== // + +function Plural({ + children, + n, + locales: localesProp = [], + ...branches +}: { + children?: ReactNode; + n: number; + locales?: string[]; + [key: string]: ReactNode; +}): ReactNode { + const locales = useFormatLocales(localesProp); + if (typeof n !== "number") { + return children; + } + return getPluralBranch(n, locales, branches) || children; +} + +function GtInternalPlural(props: { + children?: ReactNode; + n: number; + locales?: string[]; + [key: string]: ReactNode; +}): ReactNode { + return Plural(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +Plural._gtt = "plural"; +GtInternalPlural._gtt = "plural-automatic"; + +// ===== Exports ===== // + +export { GtInternalPlural, Plural }; diff --git a/packages/react-core/src/components/derivation/Derive.tsx b/packages/react-core/src/components/derivation/Derive.tsx new file mode 100644 index 000000000..dd1a9383e --- /dev/null +++ b/packages/react-core/src/components/derivation/Derive.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +// ===== Component ===== // + +function Derive({ children }: { children: T }): T { + return children; +} + +function GtInternalDerive({ + children, +}: { + children: T; +}): T { + return children; +} + +/** @internal _gtt - The GT transformation for the component. */ +Derive._gtt = 'derive'; +GtInternalDerive._gtt = 'derive-automatic'; + +// ===== Exports ===== // + +export { GtInternalDerive, Derive }; diff --git a/packages/react-core/src/components/helpers/InternalLocaleSelector.tsx b/packages/react-core/src/components/helpers/InternalLocaleSelector.tsx new file mode 100644 index 000000000..0ecfa8dc1 --- /dev/null +++ b/packages/react-core/src/components/helpers/InternalLocaleSelector.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { CustomMapping, LocaleProperties } from 'generaltranslation/types'; + +/** + * Capitalizes the first letter of a string if applicable. + * For strings that do not use capitalization, it returns the string unchanged. + * @param {string} str - The string to capitalize. + * @returns {string} The string with the first letter capitalized if applicable. + */ +function capitalizeName(str: string): string { + if (!str) return ''; + return str.charAt(0).toUpperCase() + (str.length > 1 ? str.slice(1) : ''); +} + +/** + * Internal helper to convert deprecated customNames prop to the new customMapping format. + * Used for backward compatibility with previous LocaleSelector API. + * @param {object} customNames - Mapping of locale to display name. + * @returns {CustomMapping | undefined} The converted mapping or undefined. + * @internal + */ +function _convertCustomNamesToMapping( + customNames?: { [key: string]: string } | undefined +): CustomMapping | undefined { + if (!customNames) return undefined; + const result: CustomMapping = {}; + for (const locale in customNames) { + result[locale] = { name: customNames[locale] }; + } + return result; +} + +/** + * A dropdown component that allows users to select a locale. + * @param {string} locale - The currently selected locale. + * @param {string[]} locales - The list of all available locales. + * @param {function} setLocale - Function to update the current locale. + * @param {function} getLocaleProperties - Function to retrieve properties for a given locale. + * @param {object} [customNames] - (deprecated) An optional object to map locales to custom names. Use `customMapping` instead. + * @param {CustomMapping} [customMapping] - An optional object to map locales to custom display names, emojis, or other properties. + * @returns {React.ReactElement | null} The rendered locale dropdown component or null to prevent rendering. + * + * @internal + */ +export function InternalLocaleSelector({ + locale, + locales, + customNames, + customMapping = _convertCustomNamesToMapping(customNames), + setLocale, + getLocaleProperties, + ...props +}: { + locale: string; + locales: string[]; + customNames?: { [key: string]: string }; + customMapping?: CustomMapping; + setLocale: (locale: string) => void; + getLocaleProperties: (locale: string) => LocaleProperties; + [key: string]: any; +}): React.JSX.Element | null { + // Get display name + const getDisplayName = (locale: string) => { + if (customMapping && customMapping[locale]) { + if (typeof customMapping[locale] === 'string') + return customMapping[locale]; + if (customMapping[locale].name) return customMapping[locale].name; + } + return capitalizeName(getLocaleProperties(locale).nativeNameWithRegionCode); + }; + + // If no locales are returned, just render nothing or handle gracefully + if (!locales || locales.length === 0 || !setLocale) { + return null; + } + + return ( + + ); +} diff --git a/packages/react-core/src/components/helpers/LocaleSelector.tsx b/packages/react-core/src/components/helpers/LocaleSelector.tsx new file mode 100644 index 000000000..08c55d6f2 --- /dev/null +++ b/packages/react-core/src/components/helpers/LocaleSelector.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useLocaleSelector } from '../../hooks/useLocaleSelector'; +import { CustomMapping } from 'generaltranslation/types'; +import { InternalLocaleSelector } from './InternalLocaleSelector'; + +/** + * A dropdown component that allows users to select a locale. + * @param {string[]} [locales] - An optional list of locales to use for the dropdown. If not provided, the list of locales from the `` context is used. + * @param {object} [customNames] - (deprecated) An optional object to map locales to custom names. Use `customMapping` instead. + * @param {CustomMapping} [customMapping] - An optional object to map locales to custom display names, emojis, or other properties. + * @returns {React.ReactElement | null} The rendered locale dropdown component or null to prevent rendering. + */ +export function LocaleSelector({ + locales: _locales, + ...props +}: { + locales?: string[]; + customNames?: { [key: string]: string }; + customMapping?: CustomMapping; + [key: string]: any; +}): React.JSX.Element | null { + // Get locale selector properties + const { locale, locales, setLocale, getLocaleProperties } = + useLocaleSelector(_locales); + + return ( + + ); +} diff --git a/packages/react-core/src/components/translation/T.tsx b/packages/react-core/src/components/translation/T.tsx new file mode 100644 index 000000000..27e5dac32 --- /dev/null +++ b/packages/react-core/src/components/translation/T.tsx @@ -0,0 +1,159 @@ +import { useCallback, useMemo } from "react"; +import addGTIdentifier from "../../utils/internal/addGTIdentifier"; +import { removeInjectedT } from "../../utils/internal/removeInjectedT"; +import writeChildrenAsObjects from "../../utils/internal/writeChildrenAsObjects"; +import renderDefaultChildren from "../../utils/rendering/renderDefaultChildren"; +import renderTranslatedChildren from "../../utils/rendering/renderTranslatedChildren"; +import { renderVariable } from "../../utils/rendering/renderVariable"; +import { useLocale } from "../../hooks/context-hooks"; +import { + useDefaultLocale, + useTranslate, +} from "../../hooks/external-store-hooks"; +import type { JsxTranslationOptions as JsxTranslationOptionsWithSugar } from "gt-i18n/types"; +import type { JsxChildren } from "generaltranslation/types"; +import type { TaggedChildren } from "../../utils/types"; +import type { ReactNode } from "react"; +import { useShouldTranslate } from "../../hooks/utils"; + +// ===== Component ===== // + +/** + * External-store version of the `` component. + */ +function T( + props: { + children: ReactNode; + } & JsxTranslationOptions, +): ReactNode { + return useComputeT(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +T._gtt = "translate-client"; + +export { T }; + +// ===== Render Logic ===== // + +function useComputeT({ + children: sourceChildren, + ...params +}: { + children: ReactNode; +} & JsxTranslationOptions): ReactNode { + // Prepare our source children for rendering + const { + defaultLocale, + locale, + targetOptions, + taggedSourceChildren, + sourceJsxChildren, + shouldTranslate, + } = usePrepSourceRender({ + sourceChildren, + params, + }); + + // Create a function to render source children + const renderSourceChildren = useCallback(() => { + return renderDefaultChildren({ + children: taggedSourceChildren, + defaultLocale, + renderVariable, + }); + }, [defaultLocale, taggedSourceChildren]); + + // Lookup translation in cache + const targetJsxChildren = useTranslate({ + locale, + message: sourceJsxChildren, + options: targetOptions, + }); + + // Render source children + if (!shouldTranslate || targetJsxChildren == null) { + return renderSourceChildren(); + } + + // Render translated children if found in cache + return renderTranslatedChildren({ + source: taggedSourceChildren, + target: targetJsxChildren, + locales: [locale, defaultLocale], + renderVariable, + }); +} + +// ===== Source Preparation ===== // + +function usePrepSourceRender({ + sourceChildren, + params, +}: { + sourceChildren: ReactNode; + params: JsxTranslationOptions; +}): { + defaultLocale: string; + locale: string; + taggedSourceChildren: TaggedChildren; + sourceJsxChildren: JsxChildren; + targetOptions: JsxTranslationOptionsWithSugar & { + $format: "JSX"; + $locale: string; + }; + shouldTranslate: boolean; +} { + const locale = useLocale(); + const defaultLocale = useDefaultLocale(); + const shouldTranslate = useShouldTranslate(); + const taggedSourceChildren = useMemo( + () => addGTIdentifier(removeInjectedT(sourceChildren)), + [sourceChildren], + ); + const sourceJsxChildren = useMemo( + () => writeChildrenAsObjects(taggedSourceChildren), + [taggedSourceChildren], + ); + const options = useMemo(() => normalizeParameters(params), [params]); + const targetOptions = useMemo( + () => ({ ...options, $locale: locale }), + [locale, options], + ); + + return { + defaultLocale, + locale, + taggedSourceChildren, + sourceJsxChildren, + targetOptions, + shouldTranslate, + }; +} + +// ===== Options ===== // + +function normalizeParameters( + parameters: { + context?: string; + id?: string; + _hash?: string; + } & JsxTranslationOptions, +): JsxTranslationOptionsWithSugar & { $format: "JSX" } { + return { + ...parameters, + $format: "JSX", + $context: parameters.$context ?? parameters.context, + $id: parameters.$id ?? parameters.id, + $_hash: parameters.$_hash ?? parameters._hash, + }; +} + +// ===== Types ===== // + +type StripDollarPrefix = { + [K in keyof T as K extends `$${infer Rest}` ? Rest : K]: T[K]; +}; + +type JsxTranslationOptions = StripDollarPrefix & + JsxTranslationOptionsWithSugar; diff --git a/packages/react-core/src/components/variables/Currency.tsx b/packages/react-core/src/components/variables/Currency.tsx new file mode 100644 index 000000000..8e47683af --- /dev/null +++ b/packages/react-core/src/components/variables/Currency.tsx @@ -0,0 +1,45 @@ +import { getReactI18nManager } from '../../i18n-manager/singleton-operations'; +import { useFormatLocales } from '../../hooks/utils'; + +// ===== Component ===== // + +function GtInternalCurrency({ + children, + currency = 'USD', + options = {}, + locales: localesProp = [], +}: { + children: number | string | null | undefined; + currency?: string; + options?: Intl.NumberFormatOptions; + locales?: string[]; + name?: string; +}): string | null { + const locales = useFormatLocales(localesProp); + const gt = getReactI18nManager().getGTClass(); + if (children == null) return null; + const parsedNumber = + typeof children === 'string' ? parseFloat(children) : children; + return gt.formatCurrency(parsedNumber, currency, { + locales, + ...options, + }); +} + +function Currency(props: { + children: number | string | null | undefined; + currency?: string; + options?: Intl.NumberFormatOptions; + locales?: string[]; + name?: string; +}): string | null { + return GtInternalCurrency(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +GtInternalCurrency._gtt = 'variable-currency-automatic'; +Currency._gtt = 'variable-currency'; + +// ===== Exports ===== // + +export { GtInternalCurrency, Currency }; diff --git a/packages/react-core/src/components/variables/DateTime.tsx b/packages/react-core/src/components/variables/DateTime.tsx new file mode 100644 index 000000000..f4c4fc0e3 --- /dev/null +++ b/packages/react-core/src/components/variables/DateTime.tsx @@ -0,0 +1,40 @@ +import { getReactI18nManager } from '../../i18n-manager/singleton-operations'; +import { useFormatLocales } from '../../hooks/utils'; + +// ===== Component ===== // + +function GtInternalDateTime({ + children, + options = {}, + locales: localesProp = [], +}: { + children: Date | null | undefined; + locales?: string[]; + options?: Intl.DateTimeFormatOptions; + name?: string; +}): string | null { + const locales = useFormatLocales(localesProp); + // TODO: theres a world in which we don't need the i18n manager, if user passes their own params + const gt = getReactI18nManager().getGTClass(); + if (children == null) return null; + return gt + .formatDateTime(children, { locales, ...options }) + .replace(/[\u200F\u202B\u202E]/g, ''); +} + +function DateTime(props: { + children: Date | null | undefined; + locales?: string[]; + options?: Intl.DateTimeFormatOptions; + name?: string; +}): string | null { + return GtInternalDateTime(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +GtInternalDateTime._gtt = 'variable-datetime-automatic'; +DateTime._gtt = 'variable-datetime'; + +// ===== Exports ===== // + +export { GtInternalDateTime, DateTime }; diff --git a/packages/react-core/src/components/variables/Num.tsx b/packages/react-core/src/components/variables/Num.tsx new file mode 100644 index 000000000..36b682513 --- /dev/null +++ b/packages/react-core/src/components/variables/Num.tsx @@ -0,0 +1,39 @@ +import { getReactI18nManager } from '../../i18n-manager/singleton-operations'; +import { useFormatLocales } from '../../hooks/utils'; + +// ===== Component ===== // + +function GtInternalNum({ + children, + options = {}, + locales: localesProp = [], +}: { + children: number | string | null | undefined; + options?: Intl.NumberFormatOptions; + locales?: string[]; + name?: string; +}): string | null { + const locales = useFormatLocales(localesProp); + const gt = getReactI18nManager().getGTClass(); + if (children == null) return null; + const parsedNumber = + typeof children === 'string' ? parseFloat(children) : children; + return gt.formatNum(parsedNumber, { locales, ...options }); +} + +function Num(props: { + children: number | string | null | undefined; + options?: Intl.NumberFormatOptions; + locales?: string[]; + name?: string; +}): string | null { + return GtInternalNum(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +GtInternalNum._gtt = 'variable-number-automatic'; +Num._gtt = 'variable-number'; + +// ===== Exports ===== // + +export { GtInternalNum, Num }; diff --git a/packages/react-core/src/components/variables/RelativeTime.tsx b/packages/react-core/src/components/variables/RelativeTime.tsx new file mode 100644 index 000000000..6ef54d2fd --- /dev/null +++ b/packages/react-core/src/components/variables/RelativeTime.tsx @@ -0,0 +1,75 @@ +import { getReactI18nManager } from '../../i18n-manager/singleton-operations'; +import { useFormatLocales } from '../../hooks/utils'; + +// ===== Component ===== // + +function GtInternalRelativeTime({ + date, + children, + value, + unit, + baseDate, + locales: localesProp = [], + options = {}, +}: { + date?: Date | null | undefined; + children?: Date | null | undefined; + name?: string; + value?: number; + unit?: Intl.RelativeTimeFormatUnit; + baseDate?: Date; + locales?: string[]; + options?: Intl.RelativeTimeFormatOptions; +}): string | null { + const locales = useFormatLocales(localesProp); + const gt = getReactI18nManager().getGTClass(); + const resolvedDate = date ?? children; + + if (process.env.NODE_ENV === 'development' && value !== undefined && !unit) { + // eslint-disable-next-line no-console + console.warn( + ': `value` was provided without `unit`. The `value` prop will be ignored.' + ); + } + + if (value !== undefined && unit) { + return gt.formatRelativeTime(value, unit, { + locales, + numeric: options.numeric, + style: options.style, + localeMatcher: options.localeMatcher, + }); + } + + if (resolvedDate != null) { + return gt.formatRelativeTimeFromDate(resolvedDate, { + locales, + baseDate: baseDate ?? new Date(), + numeric: options.numeric, + style: options.style, + localeMatcher: options.localeMatcher, + }); + } + + return null; +} + +function RelativeTime(props: { + date?: Date | null | undefined; + children?: Date | null | undefined; + value?: number; + unit?: Intl.RelativeTimeFormatUnit; + baseDate?: Date; + locales?: string[]; + options?: Intl.RelativeTimeFormatOptions; +}): string | null { + return GtInternalRelativeTime(props); +} + +/** @internal _gtt - The GT transformation for the component. */ +GtInternalRelativeTime._gtt = 'variable-relative-time-automatic'; +RelativeTime._gtt = 'variable-relative-time'; + +// ===== Exports ===== // + +export { GtInternalRelativeTime, RelativeTime }; diff --git a/packages/react-core/src/components/variables/Var.tsx b/packages/react-core/src/components/variables/Var.tsx new file mode 100644 index 000000000..74612593f --- /dev/null +++ b/packages/react-core/src/components/variables/Var.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; + +// ===== Shared Logic ===== // + +function computeVar({ children }: { children: T }): T { + return children; +} + +// ===== Component ===== // + +/** + * External-store version of the `` component. + */ +function Var({ + children, +}: { + children: T; + name?: string; +}): T { + return computeVar({ children }); +} + +function GtInternalVar({ + children, +}: { + children: T; + name?: string; +}): T { + return computeVar({ children }); +} + +/** @internal _gtt - The GT transformation for the component. */ +Var._gtt = 'variable-variable'; +GtInternalVar._gtt = 'variable-variable-automatic'; + +// ===== Exports ===== // + +export { GtInternalVar, Var, computeVar }; diff --git a/packages/react-core/src/condition-store/singleton-operations.ts b/packages/react-core/src/condition-store/singleton-operations.ts new file mode 100644 index 000000000..5c16dfc79 --- /dev/null +++ b/packages/react-core/src/condition-store/singleton-operations.ts @@ -0,0 +1,9 @@ +import { createConditionStoreSingleton } from "gt-i18n/internal"; +import type { WritableConditionStoreInterface } from "gt-i18n/internal/types"; + +export const { + getConditionStore: getWritableConditionStore, + setConditionStore: setWritableConditionStore, +} = createConditionStoreSingleton( + "WritableConditionStore is not initialized.", +); diff --git a/packages/react-core/src/context.ts b/packages/react-core/src/context.ts new file mode 100644 index 000000000..36d62b758 --- /dev/null +++ b/packages/react-core/src/context.ts @@ -0,0 +1,71 @@ +// ===== Components ===== // +export { Branch } from "./components/branches/Branch"; +export { Plural } from "./components/branches/Plural"; +export { Derive } from "./components/derivation/Derive"; +export { LocaleSelector } from "./components/helpers/LocaleSelector"; +export { T } from "./components/translation/T"; +export { Currency } from "./components/variables/Currency"; +export { DateTime } from "./components/variables/DateTime"; +export { Num } from "./components/variables/Num"; +export { RelativeTime } from "./components/variables/RelativeTime"; +export { Var } from "./components/variables/Var"; + +// ===== Hooks ===== // +export { + useLocale, + useSetLocale, + useEnableI18n, + useSetEnableI18n, +} from "./hooks/context-hooks"; +export { + useCustomMapping, + useDefaultLocale, + useLocales, +} from "./hooks/external-store-hooks"; +export { useGT } from "./hooks/useGT"; +export { useMessages } from "./hooks/useMessages"; +export { useTranslations } from "./hooks/useTranslations"; +export { useLocaleSelector } from "./hooks/useLocaleSelector"; +export { useFormatLocales } from "./hooks/utils"; + +// ===== Functions ===== // +export { getTranslationsSnapshot } from "./functions/helpers/getTranslationsSnapshot"; +export { + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, +} from "gt-i18n"; + +// ===== Internal ===== // +export { InternalGTProvider } from "./context/InternalGTProvider"; +export { internalInitializeGTSPA } from "./setup/initializeGTSPA"; +export { internalInitializeGTSSR } from "./setup/initializeGTSSR"; +export { getI18nStore, setI18nStore } from "./i18n-store/singleton-operations"; +export { + setRenderStrategy, + getRenderStrategy, + setStoresInitialized, +} from "./setup/globals"; +export { + getWritableConditionStore as getConditionStore, + setWritableConditionStore as setConditionStore, +} from "./condition-store/singleton-operations"; +export { WritableConditionStore } from "gt-i18n/internal"; +export type { WritableConditionStoreParams } from "gt-i18n/internal"; +export { + getReactI18nManager, + setReactI18nManager, +} from "./i18n-manager/singleton-operations"; +export { I18nStore } from "./i18n-store/I18nStore"; +export type { I18nStoreParams } from "./i18n-store/I18nStore"; +export type { InternalGTProviderProps } from "./deprecated/types-dir/config"; +export type { ReloadLocaleType } from "./i18n-store/storeTypes"; +export type { + ReactI18nManager, + ReactI18nManagerParams, +} from "./i18n-manager/ReactI18nManager"; diff --git a/packages/react-core/src/context/GTContext.ts b/packages/react-core/src/context/GTContext.ts new file mode 100644 index 000000000..5b6770b24 --- /dev/null +++ b/packages/react-core/src/context/GTContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +export type GTContextType = { + locale: string; + setLocale: (locale: string) => void; + enableI18n: boolean; + setEnableI18n: (enableI18n: boolean) => void; +}; + +export const GTContext = createContext(null); diff --git a/packages/react-core/src/context/InternalGTProvider.tsx b/packages/react-core/src/context/InternalGTProvider.tsx new file mode 100644 index 000000000..a691889be --- /dev/null +++ b/packages/react-core/src/context/InternalGTProvider.tsx @@ -0,0 +1,101 @@ +import { GTContext, GTContextType } from "./GTContext"; +import { + useCallback, + useMemo, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { getI18nStore, setI18nStore } from "../i18n-store/singleton-operations"; +import { type WritableConditionStoreParams } from "gt-i18n/internal"; +import type { ReloadLocaleType } from "../i18n-store/storeTypes"; +import { + setStoresInitialized, + getI18nStoreInitialized, +} from "../setup/globals"; +import { I18nStore, I18nStoreParams } from "../i18n-store/I18nStore"; + +export type InternalGTProviderProps = WritableConditionStoreParams & + I18nStoreParams & { + children?: ReactNode; + fallback?: ReactNode; + /** + * Reloads server side props when locale changes + * To reload translations only from the client, + * omit this prop + * */ + reloadLocale?: ReloadLocaleType; + }; + +// ===== Component ===== // + +/** + * - Shared provider logic btwn client and server providers + * - It is assumed that the I18nManager and ConditionStore are already initialized. + * - This is not userfacing, it should be wrapped in a userfacing provider + * - If you want to override i18nManager or conditionStore, do so by calling + * initializeState() (or your own version of it) before GTProvider is + * rendered + * - locale is required + * + * TODO: server side: only pass newly loaded translations to the client + */ +export function InternalGTProvider({ + children, + fallback, + ...config +}: InternalGTProviderProps) { + // ------ Initialization ------ // + + if (!getI18nStoreInitialized()) { + const i18nStore = new I18nStore(config); + setI18nStore(i18nStore); + + setStoresInitialized(true); + } + + // ------ Context ------ // + + const locale = useSyncExternalStore( + getI18nStore().subscribeToLocale, + getI18nStore().getLocaleSnapshot, + getI18nStore().getLocaleSnapshot, + ); + const setLocale = useCallback((locale: string) => { + getI18nStore().setLocale(locale); + }, []); + const enableI18n = useSyncExternalStore( + getI18nStore().subscribeToEnableI18n, + getI18nStore().getEnableI18nSnapshot, + getI18nStore().getEnableI18nSnapshot, + ); + const setEnableI18n = useCallback((enableI18n: boolean) => { + getI18nStore().setEnableI18n(enableI18n); + }, []); + const { status } = useSyncExternalStore( + getI18nStore().subscribeToTranslationStatus, + getI18nStore().getTranslationStatusSnapshot, + getI18nStore().getTranslationStatusSnapshot, + ); + + const context: GTContextType = useMemo( + () => ({ + locale, + enableI18n, + setLocale, + setEnableI18n, + }), + [locale, enableI18n, setLocale, setEnableI18n], + ); + + // ------ Rendering ------ // + + // Show fallback when translations are loading (client only) from a locale change + // locale will not be updated until the translations are loaded + const display = !(status === "loading" && !config.reloadLocale); + + return ( + + {display ? children : fallback} + + ); +} diff --git a/packages/react-core/src/__tests__/react-core-package.test.ts b/packages/react-core/src/deprecated/__tests__/react-core-package.test.ts similarity index 92% rename from packages/react-core/src/__tests__/react-core-package.test.ts rename to packages/react-core/src/deprecated/__tests__/react-core-package.test.ts index f6b40430f..613042b57 100644 --- a/packages/react-core/src/__tests__/react-core-package.test.ts +++ b/packages/react-core/src/deprecated/__tests__/react-core-package.test.ts @@ -17,8 +17,10 @@ import { } from 'typescript'; import { beforeAll, describe, expect, it } from 'vitest'; -const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -const runtimeEntryNames = ['errors', 'index', 'internal']; +const packageRoot = dirname( + dirname(dirname(dirname(fileURLToPath(import.meta.url)))) +); +const runtimeEntryNames = ['context', 'errors', 'index', 'internal']; const runtimeArtifactNames = runtimeEntryNames .flatMap((entryName) => [ `${entryName}.cjs.min.cjs`, @@ -67,6 +69,10 @@ function isWorkspaceSubpath(specifier: string): boolean { ); } +function isAllowedExternalizedSubpath(file: string, specifier: string): boolean { + return file.startsWith('context.') && specifier.startsWith('gt-i18n/'); +} + function getModuleSpecifiers(file: string): string[] { const code = readFileSync(join(packageRoot, 'dist', file), 'utf8'); const sourceFile = createSourceFile( @@ -153,6 +159,7 @@ describe('@generaltranslation/react-core package exports', () => { const externalizedSubpaths = getRuntimeArtifactNames().flatMap((file) => { return getModuleSpecifiers(file) .filter(isWorkspaceSubpath) + .filter((specifier) => !isAllowedExternalizedSubpath(file, specifier)) .map((specifier) => `${file}: ${specifier}`); }); diff --git a/packages/react-core/src/branches/Branch.tsx b/packages/react-core/src/deprecated/branches/Branch.tsx similarity index 100% rename from packages/react-core/src/branches/Branch.tsx rename to packages/react-core/src/deprecated/branches/Branch.tsx diff --git a/packages/react-core/src/branches/plurals/Plural.tsx b/packages/react-core/src/deprecated/branches/plurals/Plural.tsx similarity index 84% rename from packages/react-core/src/branches/plurals/Plural.tsx rename to packages/react-core/src/deprecated/branches/plurals/Plural.tsx index 340928f94..ef2b1f3de 100644 --- a/packages/react-core/src/branches/plurals/Plural.tsx +++ b/packages/react-core/src/deprecated/branches/plurals/Plural.tsx @@ -1,8 +1,8 @@ -import { useContext } from 'react'; -import { getPluralBranch } from '../../internal'; -import { createPluralMissingError } from '../../errors-dir/createErrors'; -import { libraryDefaultLocale } from 'generaltranslation/internal'; -import { GTContext } from '../../provider/GTContext'; +import { ReactNode, useContext } from "react"; +import { getPluralBranch } from "../../../internal"; +import { createPluralMissingError } from "../../errors-dir/createErrors"; +import { libraryDefaultLocale } from "generaltranslation/internal"; +import { GTContext } from "../../provider/GTContext"; /** * The `` component dynamically renders content based on the plural form of the given number (`n`). @@ -40,7 +40,7 @@ function Plural({ children?: React.ReactNode; n?: number; locales?: string; - [key: string]: unknown; + [key: string]: ReactNode; }): React.JSX.Element { const context = useContext(GTContext); let defaultLocale; @@ -52,11 +52,11 @@ function Plural({ ...(locales ? [locales] : []), defaultLocale || libraryDefaultLocale, ]; - if (typeof n !== 'number') + if (typeof n !== "number") throw new Error(createPluralMissingError(children)); return <>{getPluralBranch(n, providerLocales, branches) || children}; } -Plural._gtt = 'plural'; +Plural._gtt = "plural"; export default Plural; diff --git a/packages/react-core/src/deprecated/branches/plurals/getPluralBranch.ts b/packages/react-core/src/deprecated/branches/plurals/getPluralBranch.ts new file mode 100644 index 000000000..bc1c65b68 --- /dev/null +++ b/packages/react-core/src/deprecated/branches/plurals/getPluralBranch.ts @@ -0,0 +1,27 @@ +import { + getPluralForm, + isAcceptedPluralForm, +} from 'generaltranslation/internal'; +import { ReactNode } from 'react'; + +/** + * Main function to get the appropriate branch based on the provided number and branches. + * + * @param {number} n - The number to determine the branch for. + * @param {any} branches - The object containing possible branches. + * @returns {any} The determined branch. + */ +export default function getPluralBranch( + n: number, + locales: string[], + branches: Record +) { + let branchName = ''; + let branch = null; + if (typeof n === 'number' && !branch && branches) { + const pluralForms = Object.keys(branches).filter(isAcceptedPluralForm); + branchName = getPluralForm(n, pluralForms, locales); + } + if (branchName && !branch) branch = branches[branchName]; + return branch; +} diff --git a/packages/react-core/src/dictionaries/__tests__/collectUntranslatedEntries.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/collectUntranslatedEntries.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/collectUntranslatedEntries.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/collectUntranslatedEntries.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/getDictionaryEntry.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/getDictionaryEntry.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/getDictionaryEntry.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/getDictionaryEntry.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/getEntryAndMetadata.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/getEntryAndMetadata.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/getEntryAndMetadata.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/getEntryAndMetadata.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/getSubtree.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/getSubtree.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/getSubtree.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/getSubtree.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/indexDict.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/indexDict.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/indexDict.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/indexDict.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/injectAndMerge.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/injectAndMerge.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/injectAndMerge.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/injectAndMerge.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/injectEntry.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/injectEntry.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/injectEntry.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/injectEntry.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/injectFallbacks.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/injectFallbacks.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/injectFallbacks.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/injectFallbacks.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/injectHashes.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/injectHashes.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/injectHashes.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/injectHashes.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/injectTranslations.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/injectTranslations.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/injectTranslations.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/injectTranslations.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/isDictionaryEntry.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/isDictionaryEntry.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/isDictionaryEntry.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/isDictionaryEntry.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/loadDictionaryHelper.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/loadDictionaryHelper.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/loadDictionaryHelper.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/loadDictionaryHelper.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/mergeDictionaries.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/mergeDictionaries.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/mergeDictionaries.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/mergeDictionaries.test.ts diff --git a/packages/react-core/src/dictionaries/__tests__/stripMetadataFromEntries.test.ts b/packages/react-core/src/deprecated/dictionaries/__tests__/stripMetadataFromEntries.test.ts similarity index 100% rename from packages/react-core/src/dictionaries/__tests__/stripMetadataFromEntries.test.ts rename to packages/react-core/src/deprecated/dictionaries/__tests__/stripMetadataFromEntries.test.ts diff --git a/packages/react-core/src/dictionaries/collectUntranslatedEntries.ts b/packages/react-core/src/deprecated/dictionaries/collectUntranslatedEntries.ts similarity index 100% rename from packages/react-core/src/dictionaries/collectUntranslatedEntries.ts rename to packages/react-core/src/deprecated/dictionaries/collectUntranslatedEntries.ts diff --git a/packages/react-core/src/dictionaries/getDictionaryEntry.ts b/packages/react-core/src/deprecated/dictionaries/getDictionaryEntry.ts similarity index 100% rename from packages/react-core/src/dictionaries/getDictionaryEntry.ts rename to packages/react-core/src/deprecated/dictionaries/getDictionaryEntry.ts diff --git a/packages/react-core/src/dictionaries/getEntryAndMetadata.ts b/packages/react-core/src/deprecated/dictionaries/getEntryAndMetadata.ts similarity index 100% rename from packages/react-core/src/dictionaries/getEntryAndMetadata.ts rename to packages/react-core/src/deprecated/dictionaries/getEntryAndMetadata.ts diff --git a/packages/react-core/src/dictionaries/getSubtree.ts b/packages/react-core/src/deprecated/dictionaries/getSubtree.ts similarity index 100% rename from packages/react-core/src/dictionaries/getSubtree.ts rename to packages/react-core/src/deprecated/dictionaries/getSubtree.ts diff --git a/packages/react-core/src/dictionaries/indexDict.ts b/packages/react-core/src/deprecated/dictionaries/indexDict.ts similarity index 100% rename from packages/react-core/src/dictionaries/indexDict.ts rename to packages/react-core/src/deprecated/dictionaries/indexDict.ts diff --git a/packages/react-core/src/dictionaries/injectAndMerge.ts b/packages/react-core/src/deprecated/dictionaries/injectAndMerge.ts similarity index 100% rename from packages/react-core/src/dictionaries/injectAndMerge.ts rename to packages/react-core/src/deprecated/dictionaries/injectAndMerge.ts diff --git a/packages/react-core/src/dictionaries/injectEntry.ts b/packages/react-core/src/deprecated/dictionaries/injectEntry.ts similarity index 100% rename from packages/react-core/src/dictionaries/injectEntry.ts rename to packages/react-core/src/deprecated/dictionaries/injectEntry.ts diff --git a/packages/react-core/src/dictionaries/injectFallbacks.ts b/packages/react-core/src/deprecated/dictionaries/injectFallbacks.ts similarity index 100% rename from packages/react-core/src/dictionaries/injectFallbacks.ts rename to packages/react-core/src/deprecated/dictionaries/injectFallbacks.ts diff --git a/packages/react-core/src/dictionaries/injectHashes.ts b/packages/react-core/src/deprecated/dictionaries/injectHashes.ts similarity index 100% rename from packages/react-core/src/dictionaries/injectHashes.ts rename to packages/react-core/src/deprecated/dictionaries/injectHashes.ts diff --git a/packages/react-core/src/dictionaries/injectTranslations.ts b/packages/react-core/src/deprecated/dictionaries/injectTranslations.ts similarity index 100% rename from packages/react-core/src/dictionaries/injectTranslations.ts rename to packages/react-core/src/deprecated/dictionaries/injectTranslations.ts diff --git a/packages/react-core/src/dictionaries/isDictionaryEntry.ts b/packages/react-core/src/deprecated/dictionaries/isDictionaryEntry.ts similarity index 100% rename from packages/react-core/src/dictionaries/isDictionaryEntry.ts rename to packages/react-core/src/deprecated/dictionaries/isDictionaryEntry.ts diff --git a/packages/react-core/src/dictionaries/loadDictionaryHelper.ts b/packages/react-core/src/deprecated/dictionaries/loadDictionaryHelper.ts similarity index 100% rename from packages/react-core/src/dictionaries/loadDictionaryHelper.ts rename to packages/react-core/src/deprecated/dictionaries/loadDictionaryHelper.ts diff --git a/packages/react-core/src/dictionaries/mergeDictionaries.ts b/packages/react-core/src/deprecated/dictionaries/mergeDictionaries.ts similarity index 100% rename from packages/react-core/src/dictionaries/mergeDictionaries.ts rename to packages/react-core/src/deprecated/dictionaries/mergeDictionaries.ts diff --git a/packages/react-core/src/dictionaries/stripMetadataFromEntries.ts b/packages/react-core/src/deprecated/dictionaries/stripMetadataFromEntries.ts similarity index 100% rename from packages/react-core/src/dictionaries/stripMetadataFromEntries.ts rename to packages/react-core/src/deprecated/dictionaries/stripMetadataFromEntries.ts diff --git a/packages/react-core/src/errors-dir/constants.ts b/packages/react-core/src/deprecated/errors-dir/constants.ts similarity index 100% rename from packages/react-core/src/errors-dir/constants.ts rename to packages/react-core/src/deprecated/errors-dir/constants.ts diff --git a/packages/react-core/src/errors-dir/createErrors.ts b/packages/react-core/src/deprecated/errors-dir/createErrors.ts similarity index 54% rename from packages/react-core/src/errors-dir/createErrors.ts rename to packages/react-core/src/deprecated/errors-dir/createErrors.ts index 5c589ac1b..75718c082 100644 --- a/packages/react-core/src/errors-dir/createErrors.ts +++ b/packages/react-core/src/deprecated/errors-dir/createErrors.ts @@ -1,115 +1,115 @@ -import { getLocaleProperties } from '@generaltranslation/format'; +import { getLocaleProperties } from "@generaltranslation/format"; import { createReactCoreDiagnostic, formatDiagnosticErrorDetails, -} from './diagnostics'; -import { PACKAGE_NAME } from './constants'; +} from "./diagnostics"; +import { PACKAGE_NAME } from "./constants"; // ---- ERRORS ---- // export const projectIdMissingError = createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Runtime translation needs a project ID', - fix: 'Add projectId to your configuration or set GT_PROJECT_ID in your environment', - docsUrl: 'https://generaltranslation.com/dashboard', + severity: "Error", + whatHappened: "Runtime translation needs a project ID", + fix: "Add projectId to your configuration or set GT_PROJECT_ID in your environment", + docsUrl: "https://generaltranslation.com/dashboard", }); export const devApiKeyProductionError = createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Production environments cannot use a development API key', - fix: 'Replace it with a production API key before deploying', + severity: "Error", + whatHappened: "Production environments cannot use a development API key", + fix: "Replace it with a production API key before deploying", }); export const apiKeyInProductionError = createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'The API key is available to client-side production code', - fix: 'Move translation credentials to a server-only environment before deploying', + severity: "Error", + whatHappened: "The API key is available to client-side production code", + fix: "Move translation credentials to a server-only environment before deploying", }); export const createNoAuthError = createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Runtime translation is not configured', - fix: 'Add projectId and devApiKey to your environment, or pass them to directly', + severity: "Error", + whatHappened: "Runtime translation is not configured", + fix: "Add projectId and devApiKey to your environment, or pass them to directly", }); export const createPluralMissingError = (children: unknown) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: ` could not choose a plural form for "${children}"`, fix: 'Pass the required "n" option to ', }); export const createClientSideTDictionaryCollisionError = (id: string) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: ` conflicts with a dictionary entry using the same ID`, - fix: 'Rename the id or the dictionary key so each translation source has a unique ID', + fix: "Rename the id or the dictionary key so each translation source has a unique ID", }); export const createClientSideTHydrationError = (id: string) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: ` is rendering in a client component without a saved translation`, - why: 'This can cause hydration mismatches', - fix: 'Use a dictionary with useGT() or push translations from the command line before rendering this component on the client', + why: "This can cause hydration mismatches", + fix: "Use a dictionary with useGT() or push translations from the command line before rendering this component on the client", }); export const dynamicTranslationError = createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Runtime translations could not be loaded', - wayOut: 'Source content will render as a fallback', - fix: 'Check your runtime translation configuration and try again', + severity: "Error", + whatHappened: "Runtime translations could not be loaded", + wayOut: "Source content will render as a fallback", + fix: "Check your runtime translation configuration and try again", }); export const createGenericRuntimeTranslationError = ( id: string | undefined, hash: string, - error?: unknown + error?: unknown, ) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: id ? `Translation could not be found for id "${id}" and hash "${hash}"` : `Translation could not be found for hash "${hash}"`, - wayOut: 'Source content will render as a fallback', - fix: 'Push translations again or check that runtime translation is configured', + wayOut: "Source content will render as a fallback", + fix: "Push translations again or check that runtime translation is configured", details: formatDiagnosticErrorDetails(error), }); export const runtimeTranslationError = createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Runtime translation could not be completed', + severity: "Error", + whatHappened: "Runtime translation could not be completed", }); -export const customLoadTranslationsError = (locale: string = '') => +export const customLoadTranslationsError = (locale: string = "") => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: `Locally stored translations could not be loaded${locale ? ` for "${locale}"` : ''}`, - fix: 'If you use loadTranslations(), make sure it returns translations for the requested locale', + severity: "Error", + whatHappened: `Locally stored translations could not be loaded${locale ? ` for "${locale}"` : ""}`, + fix: "If you use loadTranslations(), make sure it returns translations for the requested locale", }); -export const customLoadDictionaryWarning = (locale: string = '') => +export const customLoadDictionaryWarning = (locale: string = "") => createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: `The local dictionary could not be loaded${locale ? ` for "${locale}"` : ''}`, - fix: 'If you use loadDictionary(), make sure it returns a dictionary for the requested locale', + severity: "Warning", + whatHappened: `The local dictionary could not be loaded${locale ? ` for "${locale}"` : ""}`, + fix: "If you use loadDictionary(), make sure it returns a dictionary for the requested locale", }); export const missingVariablesError = (variables: string[], message: string) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: `The message "${message}" is missing variables: "${variables.join('", "')}"`, - fix: 'Provide values for these variables before rendering the translation', + fix: "Provide values for these variables before rendering the translation", }); export const createStringRenderError = ( message: string, id: string | undefined, - error?: unknown + error?: unknown, ) => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: `The string ${id ? `for id "${id}" ` : ''}could not be rendered`, + severity: "Error", + whatHappened: `The string ${id ? `for id "${id}" ` : ""}could not be rendered`, fix: `Check the message syntax and variables for: "${message}"`, details: formatDiagnosticErrorDetails(error), }); @@ -117,123 +117,123 @@ export const createStringRenderError = ( export const createStringTranslationError = ( string: string, id?: string, - functionName = 'tx' + functionName = "tx", ) => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: `${functionName}("${string}")${id ? ` with id "${id}"` : ''} could not find a translation`, - wayOut: 'Source content will render as a fallback', - fix: 'Push translations again or check your dictionary/runtime translation configuration', + severity: "Error", + whatHappened: `${functionName}("${string}")${id ? ` with id "${id}"` : ""} could not find a translation`, + wayOut: "Source content will render as a fallback", + fix: "Push translations again or check your dictionary/runtime translation configuration", }); export const invalidLocalesError = (locales: string[]) => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Invalid locale codes in your configuration', + severity: "Error", + whatHappened: "Invalid locale codes in your configuration", fix: 'Specify a list of valid locales or use "customMapping" to define aliases for the invalid locales', details: locales, }); export const invalidCanonicalLocalesError = (locales: string[]) => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'Invalid canonical locale codes in your configuration', - fix: 'Use valid BCP 47 locale codes before starting translation', + severity: "Error", + whatHappened: "Invalid canonical locale codes in your configuration", + fix: "Use valid BCP 47 locale codes before starting translation", details: locales, }); export const createEmptyIdError = () => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 't.obj() received an empty id', - fix: 'Pass a non-empty dictionary id', + severity: "Error", + whatHappened: "t.obj() received an empty id", + fix: "Pass a non-empty dictionary id", }); export const createSubtreeNotFoundError = (id: string) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: `Dictionary subtree "${id}" could not be found`, - fix: 'Check that the id matches your dictionary structure', + fix: "Check that the id matches your dictionary structure", }); export const createDictionaryEntryError = () => createReactCoreDiagnostic({ - severity: 'Error', - whatHappened: 'A dictionary entry cannot be injected as a subtree', - fix: 'Pass a dictionary object instead', + severity: "Error", + whatHappened: "A dictionary entry cannot be injected as a subtree", + fix: "Pass a dictionary object instead", }); export const createCannotInjectDictionaryEntryError = () => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: - 'A dictionary entry cannot be merged into another dictionary entry', - fix: 'Pass a dictionary subtree instead', + "A dictionary entry cannot be merged into another dictionary entry", + fix: "Pass a dictionary subtree instead", }); export const createInvalidIcuDictionaryEntryError = (id: string | undefined) => createReactCoreDiagnostic({ - severity: 'Error', + severity: "Error", whatHappened: `Dictionary entry "${id}" contains invalid ICU syntax`, - fix: 'Fix the ICU message before rendering this translation', + fix: "Fix the ICU message before rendering this translation", }); // ---- WARNINGS ---- // export const projectIdMissingWarning = createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: 'Runtime translation needs a project ID', - fix: 'Add projectId to or set GT_PROJECT_ID in your environment', - docsUrl: 'https://generaltranslation.com/dashboard', + severity: "Warning", + whatHappened: "Runtime translation needs a project ID", + fix: "Add projectId to or set GT_PROJECT_ID in your environment", + docsUrl: "https://generaltranslation.com/dashboard", }); export const createNoEntryFoundWarning = (id: string) => createReactCoreDiagnostic({ - severity: 'Warning', + severity: "Warning", whatHappened: `No valid dictionary entry was found for id "${id}"`, - wayOut: 'Source content will render as a fallback', + wayOut: "Source content will render as a fallback", }); export const createInvalidDictionaryEntryWarning = (id: string) => createReactCoreDiagnostic({ - severity: 'Warning', + severity: "Warning", whatHappened: `Dictionary entry "${id}" is invalid`, - wayOut: 'Source content will render as a fallback until the entry is fixed', + wayOut: "Source content will render as a fallback until the entry is fixed", }); export const createInvalidIcuDictionaryEntryWarning = (id: string) => createReactCoreDiagnostic({ - severity: 'Warning', + severity: "Warning", whatHappened: `Dictionary entry "${id}" contains invalid ICU syntax`, - wayOut: 'Source content will render as a fallback until the entry is fixed', + wayOut: "Source content will render as a fallback until the entry is fixed", }); export const createNoEntryTranslationWarning = ( id: string, - prefixedId: string + prefixedId: string, ) => createReactCoreDiagnostic({ - severity: 'Warning', + severity: "Warning", whatHappened: `t("${id}") could not find a translation for dictionary item "${prefixedId}"`, - wayOut: 'Source content will render as a fallback', + wayOut: "Source content will render as a fallback", }); export const createMismatchingHashWarning = ( expectedHash: string, - receivedHash: string + receivedHash: string, ) => createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: 'Translation hashes do not match', - reassurance: 'The translation will still render', - fix: 'Update your translations to the newest version to avoid stale content', + severity: "Warning", + whatHappened: "Translation hashes do not match", + reassurance: "The translation will still render", + fix: "Update your translations to the newest version to avoid stale content", details: [`expected ${expectedHash}`, `received ${receivedHash}`], }); export const APIKeyMissingWarn = createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: 'Runtime translation needs a development API key', - fix: 'Find your development API key at generaltranslation.com/dashboard, or set runtimeUrl to an empty string to disable runtime translation', + severity: "Warning", + whatHappened: "Runtime translation needs a development API key", + fix: "Find your development API key at generaltranslation.com/dashboard, or set runtimeUrl to an empty string to disable runtime translation", }); export const createUnsupportedLocalesWarning = (locales: string[]) => @@ -242,17 +242,17 @@ export const createUnsupportedLocalesWarning = (locales: string[]) => const { name } = getLocaleProperties(locale); return `${locale} (${name})`; }) - .join(', ')}`; + .join(", ")}`; export const runtimeTranslationTimeoutWarning = createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: 'Runtime translation timed out', + severity: "Warning", + whatHappened: "Runtime translation timed out", }); export const createUnsupportedLocaleWarning = ( validatedLocale: string, newLocale: string, - packageName: string = PACKAGE_NAME + packageName: string = PACKAGE_NAME, ) => { return ( `${packageName} Warning: "${newLocale}" is not a supported locale. ` + @@ -262,20 +262,20 @@ export const createUnsupportedLocaleWarning = ( }; export const dictionaryMissingWarning = createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: 'No dictionary was found', - fix: 'Pass a dictionary to or configure a dictionary loader before rendering translations', + severity: "Warning", + whatHappened: "No dictionary was found", + fix: "Pass a dictionary to or configure a dictionary loader before rendering translations", }); export const createStringRenderWarning = ( message: string, id: string | undefined, - error?: unknown + error?: unknown, ) => createReactCoreDiagnostic({ - severity: 'Warning', - whatHappened: `The string ${id ? `for id "${id}" ` : ''}could not be rendered`, - wayOut: 'Source content will render as a fallback', + severity: "Warning", + whatHappened: `The string ${id ? `for id "${id}" ` : ""}could not be rendered`, + wayOut: "Source content will render as a fallback", fix: `Check the message syntax and variables for: "${message}"`, details: formatDiagnosticErrorDetails(error), }); diff --git a/packages/react-core/src/errors-dir/diagnostics.ts b/packages/react-core/src/deprecated/errors-dir/diagnostics.ts similarity index 100% rename from packages/react-core/src/errors-dir/diagnostics.ts rename to packages/react-core/src/deprecated/errors-dir/diagnostics.ts diff --git a/packages/react-core/src/errors-dir/internalErrors.ts b/packages/react-core/src/deprecated/errors-dir/internalErrors.ts similarity index 100% rename from packages/react-core/src/errors-dir/internalErrors.ts rename to packages/react-core/src/deprecated/errors-dir/internalErrors.ts diff --git a/packages/react-core/src/hooks/useDefaultLocale.ts b/packages/react-core/src/deprecated/hooks/useDefaultLocale.ts similarity index 100% rename from packages/react-core/src/hooks/useDefaultLocale.ts rename to packages/react-core/src/deprecated/hooks/useDefaultLocale.ts diff --git a/packages/react-core/src/hooks/useGTClass.ts b/packages/react-core/src/deprecated/hooks/useGTClass.ts similarity index 100% rename from packages/react-core/src/hooks/useGTClass.ts rename to packages/react-core/src/deprecated/hooks/useGTClass.ts diff --git a/packages/react-core/src/hooks/useLocale.ts b/packages/react-core/src/deprecated/hooks/useLocale.ts similarity index 100% rename from packages/react-core/src/hooks/useLocale.ts rename to packages/react-core/src/deprecated/hooks/useLocale.ts diff --git a/packages/react-core/src/hooks/useLocaleDirection.ts b/packages/react-core/src/deprecated/hooks/useLocaleDirection.ts similarity index 100% rename from packages/react-core/src/hooks/useLocaleDirection.ts rename to packages/react-core/src/deprecated/hooks/useLocaleDirection.ts diff --git a/packages/react-core/src/deprecated/hooks/useLocaleSelector.ts b/packages/react-core/src/deprecated/hooks/useLocaleSelector.ts new file mode 100644 index 000000000..b5b4ed7dd --- /dev/null +++ b/packages/react-core/src/deprecated/hooks/useLocaleSelector.ts @@ -0,0 +1,48 @@ +import { useCallback, useMemo } from 'react'; +import useGTContext from '../provider/GTContext'; + +/** + * + * Gets the list of properties for using a locale selector. + * Provides locale management utilities for the application. + * @param locales an optional list of locales to use for the drop down. These locales must be a subset of the locales provided by the `` context. When not provided, the list of locales from the `` context is used. + * + * @returns {Object} An object containing locale-related utilities: + * @returns {string} return.locale - The currently selected locale. + * @returns {string[]} return.locales - The list of all available locales. + * @returns {(locale: string) => void} return.setLocale - Function to update the current locale. + * @returns {(locale: string) => LocaleProperties} return.getLocaleProperties - Function to retrieve properties for a given locale. + */ +export default function useLocaleSelector(locales?: string[]) { + // Retrieve the locale, locales, and setLocale function + const { locales: contextLocales, locale, setLocale, gt } = useGTContext(); + + // sort + const sortedLocales = useMemo(() => { + if (!contextLocales || contextLocales.length === 0) { + return []; + } + const collator = new Intl.Collator(); + return [...contextLocales].sort((a, b) => + collator.compare( + gt.getLocaleProperties(a).nativeNameWithRegionCode, + gt.getLocaleProperties(b).nativeNameWithRegionCode + ) + ); + }, [contextLocales, gt]); + + // create getLocaleProperties callback + const getLocalePropertiesCallback = useCallback( + (locale: string) => { + return gt.getLocaleProperties(locale); + }, + [gt] + ); + + return { + locale, + locales: locales ? locales : sortedLocales, + setLocale, + getLocaleProperties: getLocalePropertiesCallback, + }; +} diff --git a/packages/react-core/src/hooks/useLocales.ts b/packages/react-core/src/deprecated/hooks/useLocales.ts similarity index 100% rename from packages/react-core/src/hooks/useLocales.ts rename to packages/react-core/src/deprecated/hooks/useLocales.ts diff --git a/packages/react-core/src/hooks/useRegion.ts b/packages/react-core/src/deprecated/hooks/useRegion.ts similarity index 100% rename from packages/react-core/src/hooks/useRegion.ts rename to packages/react-core/src/deprecated/hooks/useRegion.ts diff --git a/packages/react-core/src/hooks/useRegionSelector.ts b/packages/react-core/src/deprecated/hooks/useRegionSelector.ts similarity index 100% rename from packages/react-core/src/hooks/useRegionSelector.ts rename to packages/react-core/src/deprecated/hooks/useRegionSelector.ts diff --git a/packages/react-core/src/hooks/useSetLocale.ts b/packages/react-core/src/deprecated/hooks/useSetLocale.ts similarity index 100% rename from packages/react-core/src/hooks/useSetLocale.ts rename to packages/react-core/src/deprecated/hooks/useSetLocale.ts diff --git a/packages/react-core/src/hooks/useVersionId.ts b/packages/react-core/src/deprecated/hooks/useVersionId.ts similarity index 100% rename from packages/react-core/src/hooks/useVersionId.ts rename to packages/react-core/src/deprecated/hooks/useVersionId.ts diff --git a/packages/react-core/src/internal/addGTIdentifier.ts b/packages/react-core/src/deprecated/internal/addGTIdentifier.ts similarity index 100% rename from packages/react-core/src/internal/addGTIdentifier.ts rename to packages/react-core/src/deprecated/internal/addGTIdentifier.ts diff --git a/packages/react-core/src/internal/flattenDictionary.ts b/packages/react-core/src/deprecated/internal/flattenDictionary.ts similarity index 100% rename from packages/react-core/src/internal/flattenDictionary.ts rename to packages/react-core/src/deprecated/internal/flattenDictionary.ts diff --git a/packages/react-core/src/internal/removeInjectedT.ts b/packages/react-core/src/deprecated/internal/removeInjectedT.ts similarity index 100% rename from packages/react-core/src/internal/removeInjectedT.ts rename to packages/react-core/src/deprecated/internal/removeInjectedT.ts diff --git a/packages/react-core/src/internal/writeChildrenAsObjects.ts b/packages/react-core/src/deprecated/internal/writeChildrenAsObjects.ts similarity index 100% rename from packages/react-core/src/internal/writeChildrenAsObjects.ts rename to packages/react-core/src/deprecated/internal/writeChildrenAsObjects.ts diff --git a/packages/react-core/src/messages/messages.ts b/packages/react-core/src/deprecated/messages/messages.ts similarity index 100% rename from packages/react-core/src/messages/messages.ts rename to packages/react-core/src/deprecated/messages/messages.ts diff --git a/packages/react-core/src/promises/dangerouslyUsable.ts b/packages/react-core/src/deprecated/promises/dangerouslyUsable.ts similarity index 100% rename from packages/react-core/src/promises/dangerouslyUsable.ts rename to packages/react-core/src/deprecated/promises/dangerouslyUsable.ts diff --git a/packages/react-core/src/promises/reactHasUse.ts b/packages/react-core/src/deprecated/promises/reactHasUse.ts similarity index 100% rename from packages/react-core/src/promises/reactHasUse.ts rename to packages/react-core/src/deprecated/promises/reactHasUse.ts diff --git a/packages/react-core/src/provider/GTContext.ts b/packages/react-core/src/deprecated/provider/GTContext.ts similarity index 52% rename from packages/react-core/src/provider/GTContext.ts rename to packages/react-core/src/deprecated/provider/GTContext.ts index ce3f260c2..056a18a2e 100644 --- a/packages/react-core/src/provider/GTContext.ts +++ b/packages/react-core/src/deprecated/provider/GTContext.ts @@ -1,13 +1,13 @@ -import { createContext, useContext } from 'react'; -import { GTContextType } from '../types-dir/context'; +import { createContext, useContext } from "react"; +import { GTContextType } from "../types-dir/context"; export const GTContext = createContext(undefined); export default function useGTContext( - errorString = 'useGTContext() must be used within a !' + errorString = "useGTContext() must be used within a !", ): GTContextType { const context = useContext(GTContext); - if (typeof context === 'undefined') { + if (typeof context === "undefined") { throw new Error(errorString); } return context; diff --git a/packages/react-core/src/provider/GTProvider.tsx b/packages/react-core/src/deprecated/provider/GTProvider.tsx similarity index 100% rename from packages/react-core/src/provider/GTProvider.tsx rename to packages/react-core/src/deprecated/provider/GTProvider.tsx diff --git a/packages/react-core/src/deprecated/provider/__tests__/i18n-store.test.ts b/packages/react-core/src/deprecated/provider/__tests__/i18n-store.test.ts new file mode 100644 index 000000000..776e0f5a8 --- /dev/null +++ b/packages/react-core/src/deprecated/provider/__tests__/i18n-store.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; +import { I18nManager, WritableConditionStore } from 'gt-i18n/internal'; +import { + setWritableConditionStore as setConditionStore, +} from '../../../condition-store/singleton-operations'; +import { setReactI18nManager } from '../../../i18n-manager/singleton-operations'; +import { I18nStore } from '../../../i18n-store/I18nStore'; + +function createManager() { + return new I18nManager({ + defaultLocale: 'en', + locales: ['en', 'fr', 'es'], + dictionary: { + greeting: 'Hello', + user: { + profile: { + name: 'Name', + }, + }, + }, + loadDictionary: vi.fn().mockResolvedValue({ + greeting: 'Bonjour', + user: { + profile: { + name: 'Nom', + }, + }, + }), + loadTranslations: vi.fn().mockResolvedValue({ + hash: 'Bonjour', + }), + }); +} + +function createStores(locale = 'en') { + const manager = createManager(); + setReactI18nManager(manager); + + const conditionStore = new WritableConditionStore({ locale }); + setConditionStore(conditionStore); + + const i18nStore = new I18nStore({}); + return { i18nStore }; +} + +async function flushAsyncUpdates() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('external store i18n wiring', () => { + it('notifies locale subscribers when locale changes', () => { + const { i18nStore } = createStores('fr'); + const localeListener = vi.fn(); + const unsubscribeLocale = i18nStore.subscribeToLocale(localeListener); + + i18nStore.setLocale('en'); + + expect(i18nStore.getLocaleSnapshot()).toBe('en'); + expect(localeListener).toHaveBeenCalledTimes(1); + + unsubscribeLocale(); + }); + + it('notifies translation subscribers for matching cache updates', async () => { + const { i18nStore } = createStores(); + const matchingListener = vi.fn(); + const otherHashListener = vi.fn(); + const otherLocaleListener = vi.fn(); + + const unsubscribeMatching = i18nStore.subscribeToTranslate( + { + locale: 'fr', + message: 'Hello', + options: { $format: 'ICU', $_hash: 'hash' }, + }, + matchingListener + ); + const unsubscribeOtherHash = i18nStore.subscribeToTranslate( + { + locale: 'fr', + message: 'Other', + options: { $format: 'ICU', $_hash: 'other' }, + }, + otherHashListener + ); + const unsubscribeOtherLocale = i18nStore.subscribeToTranslate( + { + locale: 'es', + message: 'Hello', + options: { $format: 'ICU', $_hash: 'hash' }, + }, + otherLocaleListener + ); + + i18nStore.translate({ + locale: 'fr', + message: 'Hello', + options: { + $format: 'ICU', + $_hash: 'hash', + }, + }); + await flushAsyncUpdates(); + + expect( + i18nStore.getTranslateSnapshot({ + locale: 'fr', + message: 'Hello', + options: { + $format: 'ICU', + $_hash: 'hash', + }, + }) + ).toBe('Bonjour'); + + expect(matchingListener).toHaveBeenCalledTimes(1); + expect(otherHashListener).not.toHaveBeenCalled(); + expect(otherLocaleListener).not.toHaveBeenCalled(); + + unsubscribeMatching(); + unsubscribeOtherHash(); + unsubscribeOtherLocale(); + }); + + it('notifies translate many subscribers for matching cache updates', async () => { + const { i18nStore } = createStores(); + const listener = vi.fn(); + const lookups = [ + { + locale: 'fr', + message: 'Hello', + options: { $format: 'ICU' as const, $_hash: 'hash' }, + }, + { + locale: 'fr', + message: 'Other', + options: { $format: 'ICU' as const, $_hash: 'other' }, + }, + ]; + + const initialSnapshot = i18nStore.getTranslateManySnapshot(lookups); + expect(i18nStore.getTranslateManySnapshot(lookups)).toBe(initialSnapshot); + + const unsubscribe = i18nStore.subscribeToTranslateMany(lookups, listener); + + i18nStore.translate({ + locale: 'fr', + message: 'Hello', + options: { + $format: 'ICU', + $_hash: 'hash', + }, + }); + await flushAsyncUpdates(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(i18nStore.getTranslateManySnapshot(lookups)).toEqual([ + 'Bonjour', + undefined, + ]); + + unsubscribe(); + }); + + it('notifies dictionary entry subscribers for matching cache misses', async () => { + const { i18nStore } = createStores(); + const matchingListener = vi.fn(); + const otherListener = vi.fn(); + + const unsubscribeMatching = i18nStore.subscribeToDictionaryEntry( + { locale: 'fr', id: 'greeting' }, + matchingListener + ); + const unsubscribeOther = i18nStore.subscribeToDictionaryEntry( + { locale: 'fr', id: 'other' }, + otherListener + ); + + i18nStore.translateDictionaryEntry({ locale: 'fr', id: 'greeting' }); + await flushAsyncUpdates(); + + expect(matchingListener).toHaveBeenCalledTimes(1); + expect(otherListener).not.toHaveBeenCalled(); + + unsubscribeMatching(); + unsubscribeOther(); + }); + + it('notifies dictionary object subscribers for matching cache misses', async () => { + const { i18nStore } = createStores(); + const matchingListener = vi.fn(); + const otherListener = vi.fn(); + + const unsubscribeMatching = i18nStore.subscribeToDictionaryObject( + { locale: 'fr', id: 'user.profile' }, + matchingListener + ); + const unsubscribeOther = i18nStore.subscribeToDictionaryObject( + { locale: 'fr', id: 'other' }, + otherListener + ); + + i18nStore.translateDictionaryObject({ locale: 'fr', id: 'user.profile' }); + await flushAsyncUpdates(); + + expect(matchingListener).toHaveBeenCalledTimes(1); + expect(otherListener).not.toHaveBeenCalled(); + + unsubscribeMatching(); + unsubscribeOther(); + }); +}); diff --git a/packages/react-core/src/provider/config/defaultProps.ts b/packages/react-core/src/deprecated/provider/config/defaultProps.ts similarity index 100% rename from packages/react-core/src/provider/config/defaultProps.ts rename to packages/react-core/src/deprecated/provider/config/defaultProps.ts diff --git a/packages/react-core/src/provider/helpers/isSSREnabled.ts b/packages/react-core/src/deprecated/provider/helpers/isSSREnabled.ts similarity index 100% rename from packages/react-core/src/provider/helpers/isSSREnabled.ts rename to packages/react-core/src/deprecated/provider/helpers/isSSREnabled.ts diff --git a/packages/react-core/src/provider/helpers/validateString.ts b/packages/react-core/src/deprecated/provider/helpers/validateString.ts similarity index 100% rename from packages/react-core/src/provider/helpers/validateString.ts rename to packages/react-core/src/deprecated/provider/helpers/validateString.ts diff --git a/packages/react-core/src/provider/hooks/locales/types.ts b/packages/react-core/src/deprecated/provider/hooks/locales/types.ts similarity index 100% rename from packages/react-core/src/provider/hooks/locales/types.ts rename to packages/react-core/src/deprecated/provider/hooks/locales/types.ts diff --git a/packages/react-core/src/provider/hooks/locales/useDetermineLocale.ts b/packages/react-core/src/deprecated/provider/hooks/locales/useDetermineLocale.ts similarity index 100% rename from packages/react-core/src/provider/hooks/locales/useDetermineLocale.ts rename to packages/react-core/src/deprecated/provider/hooks/locales/useDetermineLocale.ts diff --git a/packages/react-core/src/provider/hooks/locales/useLocaleState.ts b/packages/react-core/src/deprecated/provider/hooks/locales/useLocaleState.ts similarity index 100% rename from packages/react-core/src/provider/hooks/locales/useLocaleState.ts rename to packages/react-core/src/deprecated/provider/hooks/locales/useLocaleState.ts diff --git a/packages/react-core/src/provider/hooks/translation/__tests__/useCreateInternalUseGTFunction.test.ts b/packages/react-core/src/deprecated/provider/hooks/translation/__tests__/useCreateInternalUseGTFunction.test.ts similarity index 100% rename from packages/react-core/src/provider/hooks/translation/__tests__/useCreateInternalUseGTFunction.test.ts rename to packages/react-core/src/deprecated/provider/hooks/translation/__tests__/useCreateInternalUseGTFunction.test.ts diff --git a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts b/packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseGTFunction.ts similarity index 98% rename from packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts rename to packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseGTFunction.ts index c26518c94..7b4cad537 100644 --- a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts +++ b/packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseGTFunction.ts @@ -20,6 +20,7 @@ import { indexVars, VAR_IDENTIFIER, } from 'generaltranslation/internal'; +import { InlineResolveOptions } from 'gt-i18n/types'; type MReturnType = T extends string ? string : T; @@ -59,7 +60,7 @@ export default function useCreateInternalUseGTFunction({ ) => string; _mFunction: ( message: T, - options?: Record, + options?: InlineResolveOptions, preloadedTranslations?: Translations ) => T extends string ? string : T; _filterMessagesForPreload: (_messages: _Messages) => _Messages; @@ -336,7 +337,7 @@ export default function useCreateInternalUseGTFunction({ const _mFunction = ( encodedMsg: T, - options: Record = {}, + options: InlineResolveOptions = {}, preloadedTranslations: Translations | undefined ): T extends string ? string : T => { // Return if message is not a string diff --git a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseTranslationsFunction.ts b/packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseTranslationsFunction.ts similarity index 100% rename from packages/react-core/src/provider/hooks/translation/useCreateInternalUseTranslationsFunction.ts rename to packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseTranslationsFunction.ts diff --git a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseTranslationsObjFunction.ts b/packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseTranslationsObjFunction.ts similarity index 100% rename from packages/react-core/src/provider/hooks/translation/useCreateInternalUseTranslationsObjFunction.ts rename to packages/react-core/src/deprecated/provider/hooks/translation/useCreateInternalUseTranslationsObjFunction.ts diff --git a/packages/react-core/src/provider/hooks/types.ts b/packages/react-core/src/deprecated/provider/hooks/types.ts similarity index 100% rename from packages/react-core/src/provider/hooks/types.ts rename to packages/react-core/src/deprecated/provider/hooks/types.ts diff --git a/packages/react-core/src/provider/hooks/useEnableI18n.ts b/packages/react-core/src/deprecated/provider/hooks/useEnableI18n.ts similarity index 100% rename from packages/react-core/src/provider/hooks/useEnableI18n.ts rename to packages/react-core/src/deprecated/provider/hooks/useEnableI18n.ts diff --git a/packages/react-core/src/provider/hooks/useErrorChecks.ts b/packages/react-core/src/deprecated/provider/hooks/useErrorChecks.ts similarity index 100% rename from packages/react-core/src/provider/hooks/useErrorChecks.ts rename to packages/react-core/src/deprecated/provider/hooks/useErrorChecks.ts diff --git a/packages/react-core/src/provider/hooks/useLoadDictionary.ts b/packages/react-core/src/deprecated/provider/hooks/useLoadDictionary.ts similarity index 100% rename from packages/react-core/src/provider/hooks/useLoadDictionary.ts rename to packages/react-core/src/deprecated/provider/hooks/useLoadDictionary.ts diff --git a/packages/react-core/src/provider/hooks/useLoadTranslations.ts b/packages/react-core/src/deprecated/provider/hooks/useLoadTranslations.ts similarity index 100% rename from packages/react-core/src/provider/hooks/useLoadTranslations.ts rename to packages/react-core/src/deprecated/provider/hooks/useLoadTranslations.ts diff --git a/packages/react-core/src/provider/hooks/useRegionState.ts b/packages/react-core/src/deprecated/provider/hooks/useRegionState.ts similarity index 100% rename from packages/react-core/src/provider/hooks/useRegionState.ts rename to packages/react-core/src/deprecated/provider/hooks/useRegionState.ts diff --git a/packages/react-core/src/provider/hooks/useRuntimeTranslation.ts b/packages/react-core/src/deprecated/provider/hooks/useRuntimeTranslation.ts similarity index 100% rename from packages/react-core/src/provider/hooks/useRuntimeTranslation.ts rename to packages/react-core/src/deprecated/provider/hooks/useRuntimeTranslation.ts diff --git a/packages/react-core/src/rendering/__tests__/branchRendering.test.ts b/packages/react-core/src/deprecated/rendering/__tests__/branchRendering.test.ts similarity index 100% rename from packages/react-core/src/rendering/__tests__/branchRendering.test.ts rename to packages/react-core/src/deprecated/rendering/__tests__/branchRendering.test.ts diff --git a/packages/react-core/src/rendering/getDefaultRenderSettings.ts b/packages/react-core/src/deprecated/rendering/getDefaultRenderSettings.ts similarity index 100% rename from packages/react-core/src/rendering/getDefaultRenderSettings.ts rename to packages/react-core/src/deprecated/rendering/getDefaultRenderSettings.ts diff --git a/packages/react-core/src/rendering/getGTTag.ts b/packages/react-core/src/deprecated/rendering/getGTTag.ts similarity index 100% rename from packages/react-core/src/rendering/getGTTag.ts rename to packages/react-core/src/deprecated/rendering/getGTTag.ts diff --git a/packages/react-core/src/rendering/isVariableObject.ts b/packages/react-core/src/deprecated/rendering/isVariableObject.ts similarity index 100% rename from packages/react-core/src/rendering/isVariableObject.ts rename to packages/react-core/src/deprecated/rendering/isVariableObject.ts diff --git a/packages/react-core/src/deprecated/rendering/renderDefaultChildren.tsx b/packages/react-core/src/deprecated/rendering/renderDefaultChildren.tsx new file mode 100644 index 000000000..278813ba1 --- /dev/null +++ b/packages/react-core/src/deprecated/rendering/renderDefaultChildren.tsx @@ -0,0 +1,106 @@ +import React, { ReactNode } from "react"; +import getVariableProps, { + isVariableElementProps, +} from "../variables/_getVariableProps"; +import { libraryDefaultLocale } from "generaltranslation/internal"; +import getPluralBranch from "../branches/plurals/getPluralBranch"; +import { + RenderVariable, + TaggedChild, + TaggedChildren, + TaggedElement, +} from "../types-dir/types"; +import getGTTag from "./getGTTag"; + +export default function renderDefaultChildren({ + children, + defaultLocale = libraryDefaultLocale, + renderVariable, +}: { + children: TaggedChildren; + defaultLocale: string; + renderVariable: RenderVariable; +}): React.ReactNode { + const handleSingleChildElement = (child: TaggedElement): ReactNode => { + const generaltranslation = getGTTag(child); + + // Variable + if (isVariableElementProps(child.props)) { + const { variableType, variableValue, variableOptions, injectionType } = + getVariableProps(child.props); + return renderVariable({ + variableType, + variableValue, + variableOptions, + locales: [defaultLocale], + injectionType, + }); + } + + // Plural + if (generaltranslation?.transformation === "plural") { + const branches = generaltranslation.branches || {}; + if (typeof child.props.n !== "number") { + return child.props.children != null + ? handleChildren(child.props.children) + : null; + } + const resolvedBranch = getPluralBranch( + child.props.n, + [defaultLocale], + branches, + ); + return handleChildren( + (resolvedBranch !== null + ? resolvedBranch + : child.props.children) as TaggedChildren, + ); + } + + // Branch + if (generaltranslation?.transformation === "branch") { + const { children, branch } = child.props; + const branches = generaltranslation.branches || {}; + const branchKey = + branch == null || branch === "" ? undefined : branch.toString(); + return handleChildren( + branchKey && branches[branchKey] !== undefined + ? branches[branchKey] + : children, + ); + } + + // Fragment + if (generaltranslation?.transformation === "fragment") { + return React.createElement(React.Fragment, { + key: child.props.key, + children: handleChildren(child.props.children), + }); + } + + // Default + if (child.props.children) { + return React.cloneElement(child, { + ...child.props, + "data-_gt": undefined, + children: handleChildren(child.props.children) as TaggedChildren, + }); + } + return React.cloneElement(child, { ...child.props, "data-_gt": undefined }); + }; + + const handleSingleChild = (child: TaggedChild): ReactNode => { + if (React.isValidElement(child)) { + return handleSingleChildElement(child); + } + return child; + }; + + const handleChildren = (children: TaggedChildren): ReactNode => { + return Array.isArray(children) + ? React.Children.map(children, handleSingleChild) + : handleSingleChild(children); + }; + + return handleChildren(children); +} diff --git a/packages/react-core/src/rendering/renderSkeleton.tsx b/packages/react-core/src/deprecated/rendering/renderSkeleton.tsx similarity index 100% rename from packages/react-core/src/rendering/renderSkeleton.tsx rename to packages/react-core/src/deprecated/rendering/renderSkeleton.tsx diff --git a/packages/react-core/src/deprecated/rendering/renderTranslatedChildren.tsx b/packages/react-core/src/deprecated/rendering/renderTranslatedChildren.tsx new file mode 100644 index 000000000..6163dffe7 --- /dev/null +++ b/packages/react-core/src/deprecated/rendering/renderTranslatedChildren.tsx @@ -0,0 +1,293 @@ +import React, { ReactNode } from "react"; +import { + TaggedChildren, + TaggedElement, + TranslatedChildren, + RenderVariable, + TranslatedElement, + VariableProps, +} from "../types-dir/types"; +import getVariableProps, { + isVariableElementProps, +} from "../variables/_getVariableProps"; +import renderDefaultChildren from "./renderDefaultChildren"; +import { isVariable, libraryDefaultLocale } from "generaltranslation/internal"; +import getPluralBranch from "../branches/plurals/getPluralBranch"; +import { + HTML_CONTENT_PROPS, + HtmlContentPropValuesRecord, +} from "@generaltranslation/format/types"; +import getGTTag from "./getGTTag"; + +function renderTranslatedElement({ + sourceElement, + targetElement, + locales = [libraryDefaultLocale], + renderVariable, +}: { + sourceElement: TaggedElement; + targetElement: TranslatedElement; + locales: string[]; + renderVariable: RenderVariable; +}): React.ReactNode { + // Get props and generaltranslation + const { props: sourceProps } = sourceElement; + const sourceGT = sourceProps["data-_gt"]; + const transformation = sourceGT?.transformation; + + // Get translated props + const unprocessedTargetGT = targetElement.d; + const translatedProps: HtmlContentPropValuesRecord = {}; + if (unprocessedTargetGT) { + Object.entries(HTML_CONTENT_PROPS).forEach(([minifiedName, fullName]) => { + if ( + unprocessedTargetGT[minifiedName as keyof typeof HTML_CONTENT_PROPS] + ) { + translatedProps[fullName] = unprocessedTargetGT[ + minifiedName as keyof typeof HTML_CONTENT_PROPS + ] as string; + } + }); + } + + // plural (choose a branch) + if (transformation === "plural") { + const n = sourceElement.props.n; + if (typeof n !== "number") { + return renderDefaultChildren({ + children: sourceElement, + defaultLocale: locales[0], + renderVariable, + }); + } + const sourceBranches = sourceGT.branches || {}; + const resolvedSourceBranch = getPluralBranch(n, locales, sourceBranches); + const sourceBranch = + resolvedSourceBranch !== null + ? resolvedSourceBranch + : sourceElement.props.children; + const targetBranches = targetElement.d?.b || {}; + const resolvedTargetBranch = getPluralBranch(n, locales, targetBranches); + const targetBranch = + resolvedTargetBranch !== null ? resolvedTargetBranch : targetElement.c; + return renderTranslatedChildren({ + source: sourceBranch as TaggedChildren, + target: targetBranch as TranslatedChildren, + locales, + renderVariable, + }); + } + + // branch (choose a branch) + if (transformation === "branch") { + const { branch, children } = sourceProps; + const branchKey = + branch == null || branch === "" ? undefined : branch.toString(); + const sourceBranches = sourceGT.branches || {}; + const targetBranches = targetElement.d?.b || {}; + const sourceBranch = + branchKey && sourceBranches[branchKey] !== undefined + ? sourceBranches[branchKey] + : children; + const targetBranch = + branchKey && targetBranches[branchKey] !== undefined + ? targetBranches[branchKey] + : targetElement.c; + return renderTranslatedChildren({ + source: sourceBranch as TaggedChildren, + target: targetBranch as TranslatedChildren, + locales, + renderVariable, + }); + } + + // fragment (create a valid fragment) + if (transformation === "fragment" && targetElement.c) { + return React.createElement(React.Fragment, { + key: sourceElement.props.key, + children: renderTranslatedChildren({ + source: sourceProps.children as TaggedChildren, + target: targetElement.c, + locales, + renderVariable, + }) as TaggedChildren, + }); + } + + // other + if (sourceProps?.children && targetElement?.c) { + return React.cloneElement(sourceElement, { + ...sourceProps, + ...translatedProps, + "data-_gt": undefined, + children: renderTranslatedChildren({ + source: sourceProps.children as TaggedChildren, + target: targetElement.c, + locales, + renderVariable, + }) as TaggedChildren, + }); + } + + // fallback + return renderDefaultChildren({ + children: sourceElement, + defaultLocale: locales[0], + renderVariable, + }); +} + +export default function renderTranslatedChildren({ + source, + target, + locales = [libraryDefaultLocale], + renderVariable, +}: { + source: TaggedChildren; + target: TranslatedChildren; + locales: string[]; + renderVariable: RenderVariable; +}): ReactNode { + // Most straightforward case, return a valid React node + if ((target === null || typeof target === "undefined") && source) + return renderDefaultChildren({ + children: source, + defaultLocale: locales[0], + renderVariable, + }); + if (typeof target === "string") return target; + + // Convert source to an array in case target has multiple children where source only has one + if (Array.isArray(target) && !Array.isArray(source) && source) + source = [source]; + + // Multiple children + if (Array.isArray(source) && Array.isArray(target)) { + // Track the variables + const variables: Record = {}; + const variablesOptions: Record = + {}; + const variableInjectionTypes: Record< + string, + VariableProps["injectionType"] + > = {}; + + // Extract source elements + // Extract variable props + // Filter out variable elements + const sourceElements: TaggedElement[] = source.filter( + (sourceChild): sourceChild is TaggedElement => { + if (React.isValidElement(sourceChild)) { + if (isVariableElementProps(sourceChild.props)) { + const { + variableName, + variableValue, + variableOptions, + injectionType, + } = getVariableProps(sourceChild.props); + variables[variableName] = variableValue; + variablesOptions[variableName] = variableOptions; + variableInjectionTypes[variableName] = injectionType; + } else { + return true; + } + } + return false; + }, + ); + + // TODO: pre-index these to avoid O(n/2) lookups + const findMatchingSourceElement = ( + targetElement: TranslatedElement, + ): TaggedElement | undefined => { + return ( + sourceElements.find((sourceChild): sourceChild is TaggedElement => { + const generaltranslation = getGTTag(sourceChild); + if (typeof generaltranslation?.id !== "undefined") { + const sourceId = generaltranslation.id; + const targetId = targetElement.i; + return sourceId === targetId; + } + return false; + }) || sourceElements.shift() + ); // assumes fixed order, not recommended + }; + + // map target to source + return target.map((targetChild, index) => { + if (typeof targetChild === "string") + return ( + {targetChild} + ); + + // Render variable + if (isVariable(targetChild)) { + return ( + + {renderVariable({ + variableType: targetChild.v || "v", + variableValue: variables[targetChild.k], + variableOptions: variablesOptions[targetChild.k], + locales, + injectionType: variableInjectionTypes[targetChild.k] || "manual", + })} + + ); + } + + // Render element (targetChild is a TranslatedElement) + const matchingSourceElement = findMatchingSourceElement( + targetChild as TranslatedElement, + ); + if (!matchingSourceElement) return null; + return ( + + {renderTranslatedElement({ + sourceElement: matchingSourceElement, + targetElement: targetChild, + locales, + renderVariable, + })} + + ); + }); + } + + // Single child + if (target && typeof target === "object" && !Array.isArray(target)) { + const targetType: "variable" | "element" = isVariable(target) + ? "variable" + : "element"; + + if (React.isValidElement(source)) { + if (targetType === "element") { + return renderTranslatedElement({ + sourceElement: source, + targetElement: target as TranslatedElement, + locales, + renderVariable, + }); + } + + // Render variable + if (isVariableElementProps(source.props)) { + const { variableValue, variableOptions, variableType, injectionType } = + getVariableProps(source.props); + return renderVariable({ + variableType, + variableValue, + variableOptions, + locales, + injectionType, + }); + } + } + } + + // fallback + return renderDefaultChildren({ + children: source, + defaultLocale: locales[0], + renderVariable, + }); +} diff --git a/packages/react-core/src/rendering/renderVariable.tsx b/packages/react-core/src/deprecated/rendering/renderVariable.tsx similarity index 100% rename from packages/react-core/src/rendering/renderVariable.tsx rename to packages/react-core/src/deprecated/rendering/renderVariable.tsx diff --git a/packages/react-core/src/translation/T.tsx b/packages/react-core/src/deprecated/translation/T.tsx similarity index 82% rename from packages/react-core/src/translation/T.tsx rename to packages/react-core/src/deprecated/translation/T.tsx index 0ed323548..8828dda27 100644 --- a/packages/react-core/src/translation/T.tsx +++ b/packages/react-core/src/deprecated/translation/T.tsx @@ -1,15 +1,15 @@ -import React, { Suspense } from 'react'; -import renderDefaultChildren from '../rendering/renderDefaultChildren'; -import { addGTIdentifier, writeChildrenAsObjects } from '../internal'; -import useGTContext from '../provider/GTContext'; -import renderTranslatedChildren from '../rendering/renderTranslatedChildren'; -import { useMemo } from 'react'; -import renderVariable from '../rendering/renderVariable'; -import { hashSource } from 'generaltranslation/id'; -import renderSkeleton from '../rendering/renderSkeleton'; -import { TranslatedChildren } from '../types-dir/types'; -import { useable } from '../promises/dangerouslyUsable'; -import reactUse from '../utils/use'; +import React, { Suspense } from "react"; +import renderDefaultChildren from "../rendering/renderDefaultChildren"; +import { addGTIdentifier, writeChildrenAsObjects } from "../../internal"; +import useGTContext from "../provider/GTContext"; +import renderTranslatedChildren from "../rendering/renderTranslatedChildren"; +import { useMemo } from "react"; +import renderVariable from "../rendering/renderVariable"; +import { hashSource } from "generaltranslation/id"; +import renderSkeleton from "../rendering/renderSkeleton"; +import { TranslatedChildren } from "../types-dir/types"; +import { useable } from "../promises/dangerouslyUsable"; +import reactUse from "../utils/use"; /** * Build-time translation component that renders its children in the user's given locale. @@ -62,7 +62,7 @@ function T({ id = id ?? options?.$id; context = context ?? options?.$context; const maxChars = - typeof options?.$maxChars === 'number' ? options.$maxChars : undefined; + typeof options?.$maxChars === "number" ? options.$maxChars : undefined; const { translations, @@ -87,7 +87,7 @@ function T({ translationEntry = translations?.[id]; } - if (typeof translationEntry === 'undefined' && _hash) { + if (typeof translationEntry === "undefined" && _hash) { translationEntry = translations?.[_hash]; } @@ -98,7 +98,7 @@ function T({ !translationRequired || // Translation not required translationEntry // Translation already exists under the id ) { - return [undefined, '']; + return [undefined, ""]; } // calculate hash const childrenAsObjects = writeChildrenAsObjects(taggedChildren); @@ -107,7 +107,7 @@ function T({ ...(context && { context }), ...(maxChars != null && { maxChars: Math.abs(maxChars) }), ...(id && { id }), - dataFormat: 'JSX', + dataFormat: "JSX", }); return [childrenAsObjects, hash]; }, [ @@ -120,7 +120,7 @@ function T({ ]); // get translation entry on hash - if (typeof translationEntry === 'undefined') { + if (typeof translationEntry === "undefined") { translationEntry = translations?.[hash]; } @@ -195,7 +195,7 @@ function T({ const resolvedTranslation = reactUse( useable( [ - 'getTranslationPromise', // prefix key + "getTranslationPromise", // prefix key developmentApiEnabled, JSON.stringify(childrenAsObjects), locale, @@ -204,8 +204,8 @@ function T({ context, maxChars, ], - () => getTranslationPromise() - ) + () => getTranslationPromise(), + ), ); return ( {resolvedTranslation} @@ -213,9 +213,9 @@ function T({ } let loadingFallback; - if (renderSettings.method === 'skeleton') { + if (renderSettings.method === "skeleton") { loadingFallback = renderSkeleton(); - } else if (renderSettings.method === 'replace') { + } else if (renderSettings.method === "replace") { loadingFallback = renderDefault(); } else { // default @@ -229,6 +229,6 @@ function T({ ); } /** @internal _gtt - The GT transformation for the component. */ -T._gtt = 'translate-client'; +T._gtt = "translate-client"; export default T; diff --git a/packages/react-core/src/translation/hooks/useGT.tsx b/packages/react-core/src/deprecated/translation/hooks/useGT.tsx similarity index 100% rename from packages/react-core/src/translation/hooks/useGT.tsx rename to packages/react-core/src/deprecated/translation/hooks/useGT.tsx diff --git a/packages/react-core/src/translation/hooks/useMessages.ts b/packages/react-core/src/deprecated/translation/hooks/useMessages.ts similarity index 100% rename from packages/react-core/src/translation/hooks/useMessages.ts rename to packages/react-core/src/deprecated/translation/hooks/useMessages.ts diff --git a/packages/react-core/src/translation/hooks/useTranslations.tsx b/packages/react-core/src/deprecated/translation/hooks/useTranslations.tsx similarity index 100% rename from packages/react-core/src/translation/hooks/useTranslations.tsx rename to packages/react-core/src/deprecated/translation/hooks/useTranslations.tsx diff --git a/packages/react-core/src/types-dir/config.ts b/packages/react-core/src/deprecated/types-dir/config.ts similarity index 100% rename from packages/react-core/src/types-dir/config.ts rename to packages/react-core/src/deprecated/types-dir/config.ts diff --git a/packages/react-core/src/types-dir/context.ts b/packages/react-core/src/deprecated/types-dir/context.ts similarity index 94% rename from packages/react-core/src/types-dir/context.ts rename to packages/react-core/src/deprecated/types-dir/context.ts index 96792105a..d54381225 100644 --- a/packages/react-core/src/types-dir/context.ts +++ b/packages/react-core/src/deprecated/types-dir/context.ts @@ -9,6 +9,7 @@ import { } from './types'; import { TranslateIcuCallback, TranslateChildrenCallback } from './runtime'; import { GT } from 'generaltranslation'; +import { InlineResolveOptions } from 'gt-i18n/types'; export type GTContextType = { gt: GT; @@ -21,7 +22,7 @@ export type GTContextType = { ) => string; _mFunction: ( encodedMsg: T, - options?: Record, + options?: InlineResolveOptions, preloadedTranslations?: Translations ) => T extends string ? string : T; _filterMessagesForPreload: (_messages: _Messages) => _Messages; diff --git a/packages/react-core/src/types-dir/runtime.ts b/packages/react-core/src/deprecated/types-dir/runtime.ts similarity index 100% rename from packages/react-core/src/types-dir/runtime.ts rename to packages/react-core/src/deprecated/types-dir/runtime.ts diff --git a/packages/react-core/src/types-dir/types.ts b/packages/react-core/src/deprecated/types-dir/types.ts similarity index 100% rename from packages/react-core/src/types-dir/types.ts rename to packages/react-core/src/deprecated/types-dir/types.ts diff --git a/packages/react-core/src/ui/LocaleSelector.tsx b/packages/react-core/src/deprecated/ui/LocaleSelector.tsx similarity index 100% rename from packages/react-core/src/ui/LocaleSelector.tsx rename to packages/react-core/src/deprecated/ui/LocaleSelector.tsx diff --git a/packages/react-core/src/ui/RegionSelector.tsx b/packages/react-core/src/deprecated/ui/RegionSelector.tsx similarity index 100% rename from packages/react-core/src/ui/RegionSelector.tsx rename to packages/react-core/src/deprecated/ui/RegionSelector.tsx diff --git a/packages/react-core/src/ui/types.ts b/packages/react-core/src/deprecated/ui/types.ts similarity index 100% rename from packages/react-core/src/ui/types.ts rename to packages/react-core/src/deprecated/ui/types.ts diff --git a/packages/react-core/src/deprecated/utils/cookies.ts b/packages/react-core/src/deprecated/utils/cookies.ts new file mode 100644 index 000000000..74b20780a --- /dev/null +++ b/packages/react-core/src/deprecated/utils/cookies.ts @@ -0,0 +1,16 @@ +/** + * Cookie name for tracking the referrer locale + * @deprecated move to gt-react + */ +export const defaultLocaleCookieName = "generaltranslation.locale"; +/** + * Cookie name for tracking the user's selected region + * @deprecated move to gt-react + */ +export const defaultRegionCookieName = "generaltranslation.region"; +/** + * Cookie name for persisting the enableI18n feature flag + * "true" or "false" + * @deprecated move to gt-react + */ +export const defaultEnableI18nCookieName = "generaltranslation.enable-i18n"; diff --git a/packages/react-core/src/utils/fetchTranslations.ts b/packages/react-core/src/deprecated/utils/fetchTranslations.ts similarity index 100% rename from packages/react-core/src/utils/fetchTranslations.ts rename to packages/react-core/src/deprecated/utils/fetchTranslations.ts diff --git a/packages/react-core/src/deprecated/utils/types.ts b/packages/react-core/src/deprecated/utils/types.ts new file mode 100644 index 000000000..faf477dfe --- /dev/null +++ b/packages/react-core/src/deprecated/utils/types.ts @@ -0,0 +1,8 @@ +export type AuthFromEnvParams = { + projectId?: string; + devApiKey?: string; +}; +export type AuthFromEnvReturn = { + projectId: string; + devApiKey?: string; +}; diff --git a/packages/react-core/src/utils/use.ts b/packages/react-core/src/deprecated/utils/use.ts similarity index 100% rename from packages/react-core/src/utils/use.ts rename to packages/react-core/src/deprecated/utils/use.ts diff --git a/packages/react-core/src/deprecated/utils/utils.tsx b/packages/react-core/src/deprecated/utils/utils.tsx new file mode 100644 index 000000000..30bdceb9a --- /dev/null +++ b/packages/react-core/src/deprecated/utils/utils.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { TaggedElement, TaggedElementProps } from '../types-dir/types'; +import { AuthFromEnvParams, AuthFromEnvReturn } from './types'; +import { createInternalUsageError } from '../errors-dir/internalErrors'; + +export function isValidTaggedElement(target: unknown): target is TaggedElement { + return React.isValidElement(target); +} + +/** + * @deprecated - this function is to always be overridden by a wrapper react package + */ +export function readAuthFromEnv(_params: AuthFromEnvParams): AuthFromEnvReturn { + throw createInternalUsageError('readAuthFromEnv'); +} diff --git a/packages/react-core/src/variables/Currency.tsx b/packages/react-core/src/deprecated/variables/Currency.tsx similarity index 100% rename from packages/react-core/src/variables/Currency.tsx rename to packages/react-core/src/deprecated/variables/Currency.tsx diff --git a/packages/react-core/src/variables/DateTime.tsx b/packages/react-core/src/deprecated/variables/DateTime.tsx similarity index 100% rename from packages/react-core/src/variables/DateTime.tsx rename to packages/react-core/src/deprecated/variables/DateTime.tsx diff --git a/packages/react-core/src/variables/Derive.tsx b/packages/react-core/src/deprecated/variables/Derive.tsx similarity index 100% rename from packages/react-core/src/variables/Derive.tsx rename to packages/react-core/src/deprecated/variables/Derive.tsx diff --git a/packages/react-core/src/variables/Num.tsx b/packages/react-core/src/deprecated/variables/Num.tsx similarity index 100% rename from packages/react-core/src/variables/Num.tsx rename to packages/react-core/src/deprecated/variables/Num.tsx diff --git a/packages/react-core/src/variables/RelativeTime.tsx b/packages/react-core/src/deprecated/variables/RelativeTime.tsx similarity index 100% rename from packages/react-core/src/variables/RelativeTime.tsx rename to packages/react-core/src/deprecated/variables/RelativeTime.tsx diff --git a/packages/react-core/src/variables/Var.tsx b/packages/react-core/src/deprecated/variables/Var.tsx similarity index 100% rename from packages/react-core/src/variables/Var.tsx rename to packages/react-core/src/deprecated/variables/Var.tsx diff --git a/packages/react-core/src/variables/_getVariableProps.ts b/packages/react-core/src/deprecated/variables/_getVariableProps.ts similarity index 100% rename from packages/react-core/src/variables/_getVariableProps.ts rename to packages/react-core/src/deprecated/variables/_getVariableProps.ts diff --git a/packages/react-core/src/variables/getVariableName.ts b/packages/react-core/src/deprecated/variables/getVariableName.ts similarity index 100% rename from packages/react-core/src/variables/getVariableName.ts rename to packages/react-core/src/deprecated/variables/getVariableName.ts diff --git a/packages/react-core/src/errors.ts b/packages/react-core/src/errors.ts index ca04bcb4d..5a3e98a86 100644 --- a/packages/react-core/src/errors.ts +++ b/packages/react-core/src/errors.ts @@ -1,3 +1,3 @@ -import { createUnsupportedLocaleWarning } from './errors-dir/createErrors'; +import { createUnsupportedLocaleWarning } from "./deprecated/errors-dir/createErrors"; export { createUnsupportedLocaleWarning }; diff --git a/packages/react-core/src/functions/helpers/getTranslationsSnapshot.ts b/packages/react-core/src/functions/helpers/getTranslationsSnapshot.ts new file mode 100644 index 000000000..cc3a4be3c --- /dev/null +++ b/packages/react-core/src/functions/helpers/getTranslationsSnapshot.ts @@ -0,0 +1,18 @@ +import { Hash, Locale } from 'gt-i18n/internal/types'; +import { Translation } from 'gt-i18n/types'; +import { getReactI18nManager } from '../../i18n-manager/singleton-operations'; + +/** + * Returns a promise of serializable cached translations that + * can be passed to a provider for hydration + * + * TODO: perhaps should be moved to /i18n if allowing for type generics + */ +export async function getTranslationsSnapshot( + locale: Locale +): Promise>> { + const i18nManager = getReactI18nManager(); + const translations = await i18nManager.loadTranslations(locale); + // Only pass translations for the given locale to minimize the size of the snapshot + return { [locale]: translations }; +} diff --git a/packages/react-core/src/functions/translation/t.ts b/packages/react-core/src/functions/translation/t.ts new file mode 100644 index 000000000..b341539fc --- /dev/null +++ b/packages/react-core/src/functions/translation/t.ts @@ -0,0 +1,185 @@ +import { + getLocale, + resolveTranslationSync, + resolveTranslationSyncWithFallback, +} from 'gt-i18n/internal'; +import type { InlineTranslationOptions } from 'gt-i18n/types'; +import { getRenderStrategy } from '../../setup/globals'; + +/** + * NOTE: t() is the only function exported from the 'gt-react' entry point. + * All other functions in i18n-context are exported from the 'gt-react/browser' entry point. + */ + +/** + * Translate a message + * @param {string} message - The message to translate. + * @param {InlineTranslationOptions} [options] - The options for the translation. + * @returns {string} The translated message. + * + * This is a BROWSER ONLY function. + * + * @example + * t('Hello, world!'); // Translates 'Hello, world!' + * + * @example + * t('Hello, {name}!', { name: 'John' }); // Translates 'Hello, John!' + * + * @example + * t`Hello, ${name}` // Translate via tagged template literal + * + * TODO: enforce enableI18n + * + */ +export const t: StringOrTemplateSyncResolutionFunction = ( + messageOrStrings: string | TemplateStringsArray, + ...values: unknown[] +) => { + // Fail on SSR applications + if (getRenderStrategy() === 'server-render') { + console.warn( + createTranslationFailedDueToBrowserEnvironmentWarning(messageOrStrings) + ); + } + + // t("Hello, {name}!", { name: "John" }) + if (typeof messageOrStrings === 'string') { + const options = values.at(0) as InlineTranslationOptions | undefined; + const locale = options?.$locale ?? getLocale(); + return resolveTranslationSyncWithFallback( + locale, + messageOrStrings, + options + ); + } + + // t`Hello, ${name}` + return handleTaggedTemplateLiteralTranslation(messageOrStrings, values); +}; + +// ----- Helper Functions ----- // + +/** + * Handle tagged template literal translation + * @param messageOrStrings - The message or strings to translate. + * @param values - The values to interpolate. + * @returns The translated message. + * + * This is triggered when there has been no compiler transformation + * + * Try looking up interpolated template first + * If not found, resolve uninterpolated message + */ +function handleTaggedTemplateLiteralTranslation( + messageOrStrings: TemplateStringsArray, + values: unknown[] +): string { + const locale = getLocale(); + // for tagged template literals, there has been no compiler transformation + // (1) lookup interpolated template (aka derived message) + const interpolatedTemplate = interpolateTemplateLiteral( + messageOrStrings, + values + ); + const translatedInterpolatedTemplate = resolveTranslationSync( + locale, + interpolatedTemplate + ); + if (translatedInterpolatedTemplate) return translatedInterpolatedTemplate; + + // (2) resolve uninterpolated message + const { message, variables } = extractInterpolatableValues( + messageOrStrings, + values + ); + return resolveTranslationSyncWithFallback(locale, message, variables); +} + +/** + * Given a TemplateStringsArray, and values, return the uninterpolated message and variables. + * @param strings - The template strings. + * @param values - The values to interpolate. + * @returns The interpolated message and variables. + */ +function extractInterpolatableValues( + strings: TemplateStringsArray, + values: unknown[] +): { + message: string; + variables: Record; +} { + // String parts + const parts: string[] = []; + // Variables + const variables: Record = {}; + let varIndex = 0; + + for (let i = 0; i < strings.length; i++) { + // Add the cooked text from the quasi (use cooked to handle escape sequences) + parts.push(strings[i]); + + // If there's a corresponding expression, create a variable placeholder + if (i < values.length) { + const key = varIndex.toString(); + parts.push(`{${key}}`); + variables[key] = values[i]; + varIndex++; + } + } + + return { + message: parts.join(''), + variables, + }; +} + +/** + * Interpolate a template literal + * @param message - The message to interpolate. + * @param variables - The variables to interpolate. + * @returns The interpolated message. + */ +function interpolateTemplateLiteral( + strings: TemplateStringsArray, + values: unknown[] +): string { + return strings + .map((string, index) => { + return string + (values[index] ?? ''); + }) + .join(''); +} + +// ----- Constants ----- // + +// TODO: for following three functions, resturcture them to be more organized + +// TODO: rename this +const createTranslationFailedDueToBrowserEnvironmentWarning = ( + message: string | TemplateStringsArray | undefined +) => + `@generaltranslation/react-core Warning: Translation failed for t("${typeof message === 'string' ? message : '`' + message?.join('${}') + '`'}") because it was used outside of a browser environment or your SPA did not initialize GT correctly. Falling back to original message.`; + +/** + * Overloaded type for the `t` function. + * - Tagged template: t`Hello, ${name}` (transformed by the compiler plugin at build time) + * - Function call: t("Hello, {name}", { name: "John" }) + * + * {@link TemplateSyncResolutionFunction} + * {@link SyncResolutionFunction} + */ +interface StringOrTemplateSyncResolutionFunction { + (strings: TemplateStringsArray, ...values: unknown[]): string; + (message: string, options?: InlineTranslationOptions): string; +} + +/** + * Type for the `t` function when used as a tagged template literal. + * @param strings - The template strings. + * @param values - The values to interpolate. + * @returns The translated message. + */ +type TemplateSyncResolutionFunction = ( + strings: TemplateStringsArray, + ...values: unknown[] +) => string; diff --git a/packages/react-core/src/hooks/context-hooks.ts b/packages/react-core/src/hooks/context-hooks.ts new file mode 100644 index 000000000..a78f0398d --- /dev/null +++ b/packages/react-core/src/hooks/context-hooks.ts @@ -0,0 +1,43 @@ +import { GTContext, GTContextType } from "../context/GTContext"; +import { getRenderStrategy } from "../setup/globals"; +import { getWritableConditionStore } from "../condition-store/singleton-operations"; +import { useContext } from "react"; + +/** + * @internal + */ +function useGTContext(property: keyof GTContextType): GTContextType { + const conditionStore = useContext(GTContext); + if (!conditionStore) { + if (getRenderStrategy() === "SPA") { + // No need for useSyncExternalStore for SPA apps as reload will always trigger a re-render + const conditionStore = getWritableConditionStore(); + return { + locale: conditionStore.getLocale(), + enableI18n: conditionStore.getEnableI18n(), + setLocale: conditionStore.setLocale, + setEnableI18n: conditionStore.setEnableI18n, + }; + } + throw new Error( + `use${(property as string).charAt(0).toUpperCase() + (property as string).slice(1)}() is being accessed outside of a . Make sure to add a to the top of your component tree.`, + ); + } + return conditionStore; +} + +export function useLocale(): string { + return useGTContext("locale").locale; +} + +export function useSetLocale(): (locale: string) => void { + return useGTContext("setLocale").setLocale; +} + +export function useEnableI18n(): boolean { + return useGTContext("enableI18n").enableI18n; +} + +export function useSetEnableI18n(): (enableI18n: boolean) => void { + return useGTContext("setEnableI18n").setEnableI18n; +} diff --git a/packages/react-core/src/hooks/external-store-hooks.ts b/packages/react-core/src/hooks/external-store-hooks.ts new file mode 100644 index 000000000..c3f4c1585 --- /dev/null +++ b/packages/react-core/src/hooks/external-store-hooks.ts @@ -0,0 +1,153 @@ +import { useMemo, useSyncExternalStore } from "react"; +import type { Translation } from "gt-i18n/types"; +import type { + TranslateLookup, + TranslateManySnapshot, + TranslateSnapshot, + DictionaryLookup, + DictionaryEntrySnapshot, + DictionaryObjectSnapshot, +} from "../i18n-store/storeTypes"; +import type { CustomMapping } from "generaltranslation/types"; +import { getI18nStore } from "../i18n-store/singleton-operations"; +import type { RuntimeTranslationScope } from "../i18n-store/RuntimeTranslationScope"; +import type { RuntimeDictionaryScope } from "../i18n-store/RuntimeDictionaryScope"; +import { getReactI18nManager } from "../i18n-manager/singleton-operations"; + +/** + * @internal + */ +export function useTranslate( + lookup: TranslateLookup, +): TranslateSnapshot { + const store = getI18nStore(); + const translation = useSyncExternalStore( + (listener) => store.subscribeToTranslate(lookup, listener), + () => store.getTranslateSnapshot(lookup), + () => store.getTranslateSnapshot(lookup), + ); + if (translation == null && getReactI18nManager().isDevHotReloadEnabled()) { + store.translate(lookup); + } + return translation; +} + +/** + * @internal + */ +export function useTranslateMany( + lookups: readonly TranslateLookup[], +): TranslateManySnapshot { + const store = getI18nStore(); + const translations = useSyncExternalStore( + (listener) => store.subscribeToTranslateMany(lookups, listener), + () => store.getTranslateManySnapshot(lookups), + () => store.getTranslateManySnapshot(lookups), + ); + const devHotReloadEnabled = getReactI18nManager().isDevHotReloadEnabled(); + translations.forEach((translation, index) => { + if (translation == null && devHotReloadEnabled) { + store.translate(lookups[index]); + } + }); + return translations; +} + +/** + * @internal + */ +export function useDictionaryEntry( + lookup: DictionaryLookup, +): DictionaryEntrySnapshot { + const store = getI18nStore(); + const dictionaryEntry = useSyncExternalStore( + (listener) => store.subscribeToDictionaryEntry(lookup, listener), + () => store.getDictionaryEntrySnapshot(lookup), + () => store.getDictionaryEntrySnapshot(lookup), + ); + if ( + dictionaryEntry == null && + getReactI18nManager().isDevHotReloadEnabled() + ) { + store.translateDictionaryEntry(lookup); + } + return dictionaryEntry; +} + +/** + * @internal + */ +export function useDictionaryObject( + lookup: DictionaryLookup, +): DictionaryObjectSnapshot { + const store = getI18nStore(); + const dictionaryObject = useSyncExternalStore( + (listener) => store.subscribeToDictionaryObject(lookup, listener), + () => store.getDictionaryObjectSnapshot(lookup), + () => store.getDictionaryObjectSnapshot(lookup), + ); + if ( + dictionaryObject == null && + getReactI18nManager().isDevHotReloadEnabled() + ) { + store.translateDictionaryObject(lookup); + } + return dictionaryObject; +} + +export function useCustomMapping(): CustomMapping { + const store = getI18nStore(); + return useSyncExternalStore( + store.subscribeToCustomMapping, + store.getCustomMappingSnapshot, + store.getCustomMappingSnapshot, + ); +} + +export function useDefaultLocale(): string { + const store = getI18nStore(); + return useSyncExternalStore( + store.subscribeToDefaultLocale, + store.getDefaultLocaleSnapshot, + store.getDefaultLocaleSnapshot, + ); +} + +export function useLocales(): readonly string[] { + const store = getI18nStore(); + return useSyncExternalStore( + store.subscribeToLocales, + store.getLocalesSnapshot, + store.getLocalesSnapshot, + ); +} + +/** + * Used for dev translation tracking + */ +export function useRuntimeTranslationScope(): RuntimeTranslationScope { + const store = getI18nStore(); + + const scope = useMemo(() => { + return store.createRuntimeTranslationScope(); + }, [store]); + + useSyncExternalStore(scope.subscribe, scope.getSnapshot, scope.getSnapshot); + + return scope; +} + +/** + * Used for dev dictionary tracking + */ +export function useRuntimeDictionaryScope(): RuntimeDictionaryScope { + const store = getI18nStore(); + + const scope = useMemo(() => { + return store.createRuntimeDictionaryScope(); + }, [store]); + + useSyncExternalStore(scope.subscribe, scope.getSnapshot, scope.getSnapshot); + + return scope; +} diff --git a/packages/react-core/src/hooks/useGT.ts b/packages/react-core/src/hooks/useGT.ts new file mode 100644 index 000000000..113581e91 --- /dev/null +++ b/packages/react-core/src/hooks/useGT.ts @@ -0,0 +1,110 @@ +import { useCallback, useMemo } from "react"; +import { createLookupOptions } from "gt-i18n/internal"; +import type { InlineTranslationOptionsFields } from "gt-i18n/internal/types"; +import { + useDefaultLocale, + useRuntimeTranslationScope, + useTranslateMany, +} from "./external-store-hooks"; +import { useLocale } from "./context-hooks"; +import { useShouldTranslate } from "./utils"; +import { getReactI18nManager } from "../i18n-manager/singleton-operations"; +import type { TranslateLookup } from "../i18n-store/storeTypes"; +import type { GTFunctionType, InlineTranslationOptions } from "gt-i18n/types"; +import type { StringFormat } from "@generaltranslation/format/types"; +import { interpolateMessage } from "gt-i18n/internal"; + +const EMPTY_TRANSLATE_LOOKUPS: TranslateLookup[] = []; + +type Message = InlineTranslationOptionsFields & { + message: string; +}; + +// ===== Hook ===== // + +export function useGT(_messages?: Message[]): GTFunctionType { + const locale = useLocale(); + const defaultLocale = useDefaultLocale(); + const shouldTranslate = useShouldTranslate(); + const scope = useRuntimeTranslationScope(); + const devHotReloadEnabled = getReactI18nManager().isDevHotReloadEnabled(); + + // Compiler optimization: pre-fetch translations + useSubscribeToExtractedMessages( + locale, + shouldTranslate, + devHotReloadEnabled, + _messages ?? [], + ); + + /** + * gt() string translation callback + */ + return useCallback( + (message: string, options: InlineTranslationOptions = {}) => { + if (!shouldTranslate) { + return interpolateMessage({ + options, + source: message, + sourceLocale: defaultLocale, + }); + } + + const lookupOptions = createLookupOptions( + options.$locale ?? locale, + options, + "ICU", + ); + const translation = getReactI18nManager().lookupTranslation( + lookupOptions.$locale, + message, + lookupOptions, + ); + + if (translation == null && devHotReloadEnabled) { + scope.translate({ + locale: lookupOptions.$locale, + message, + options: lookupOptions, + }); + } + + return interpolateMessage({ + source: message, + target: translation, + options: lookupOptions, + sourceLocale: defaultLocale, + }); + }, + [defaultLocale, devHotReloadEnabled, locale, scope, shouldTranslate], + ); +} + +// ----- Helpers ----- // + +function useSubscribeToExtractedMessages( + locale: string, + shouldTranslate: boolean, + devHotReloadEnabled: boolean, + messages: Message[], +) { + const lookups = useMemo(() => { + if (!messages?.length || !shouldTranslate || !devHotReloadEnabled) { + return EMPTY_TRANSLATE_LOOKUPS; + } + return messages.map(({ message, ...options }) => { + const targetLocale = options.$locale ?? locale; + const lookupOptions = createLookupOptions( + targetLocale, + options, + "ICU", + ); + return { + locale: targetLocale, + message, + options: lookupOptions, + }; + }); + }, [messages, locale, shouldTranslate, devHotReloadEnabled]); + useTranslateMany(lookups); +} diff --git a/packages/react-core/src/hooks/useLocaleSelector.ts b/packages/react-core/src/hooks/useLocaleSelector.ts index b5b4ed7dd..c1e9af8ca 100644 --- a/packages/react-core/src/hooks/useLocaleSelector.ts +++ b/packages/react-core/src/hooks/useLocaleSelector.ts @@ -1,5 +1,7 @@ import { useCallback, useMemo } from 'react'; -import useGTContext from '../provider/GTContext'; +import { useCustomMapping, useLocales } from './external-store-hooks'; +import { useLocale, useSetLocale } from './context-hooks'; +import { getLocaleProperties } from 'generaltranslation'; /** * @@ -13,9 +15,12 @@ import useGTContext from '../provider/GTContext'; * @returns {(locale: string) => void} return.setLocale - Function to update the current locale. * @returns {(locale: string) => LocaleProperties} return.getLocaleProperties - Function to retrieve properties for a given locale. */ -export default function useLocaleSelector(locales?: string[]) { +export function useLocaleSelector(locales?: string[]) { // Retrieve the locale, locales, and setLocale function - const { locales: contextLocales, locale, setLocale, gt } = useGTContext(); + const contextLocales = useLocales(); + const customMapping = useCustomMapping(); + const locale = useLocale(); + const setLocale = useSetLocale(); // sort const sortedLocales = useMemo(() => { @@ -25,18 +30,18 @@ export default function useLocaleSelector(locales?: string[]) { const collator = new Intl.Collator(); return [...contextLocales].sort((a, b) => collator.compare( - gt.getLocaleProperties(a).nativeNameWithRegionCode, - gt.getLocaleProperties(b).nativeNameWithRegionCode + getLocaleProperties(a, locale, customMapping).nativeNameWithRegionCode, + getLocaleProperties(b, locale, customMapping).nativeNameWithRegionCode ) ); - }, [contextLocales, gt]); + }, [contextLocales, locale, customMapping]); // create getLocaleProperties callback const getLocalePropertiesCallback = useCallback( (locale: string) => { - return gt.getLocaleProperties(locale); + return getLocaleProperties(locale, locale, customMapping); }, - [gt] + [locale, customMapping] ); return { diff --git a/packages/react-core/src/hooks/useMessages.ts b/packages/react-core/src/hooks/useMessages.ts new file mode 100644 index 000000000..c394e6ce9 --- /dev/null +++ b/packages/react-core/src/hooks/useMessages.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import { decodeOptions } from "gt-i18n"; +import { useGT } from "./useGT"; +import type { _Messages } from "../utils/types"; +import type { InlineResolveOptions, MFunctionType } from "gt-i18n/types"; +import { isEncodedTranslationOptions } from "gt-i18n/internal"; + +// ===== Hook ===== // + +export function useMessages(_messages?: _Messages): MFunctionType { + const gt = useGT(_messages); + + return useCallback( + ( + encodedMsg: T, + options: InlineResolveOptions = {}, + ): T extends string ? string : T => { + if (encodedMsg == null) { + return encodedMsg as T extends string ? string : T; + } + + const decodedOptions = decodeOptions(encodedMsg) ?? {}; + if (isEncodedTranslationOptions(decodedOptions)) { + return gt(decodedOptions.$_source, decodedOptions) as T extends string + ? string + : T; + } + + return gt(encodedMsg, options) as T extends string ? string : T; + }, + [gt], + ); +} diff --git a/packages/react-core/src/hooks/useTranslations.ts b/packages/react-core/src/hooks/useTranslations.ts new file mode 100644 index 000000000..ccb5e0d31 --- /dev/null +++ b/packages/react-core/src/hooks/useTranslations.ts @@ -0,0 +1,132 @@ +import { useCallback, useMemo } from 'react'; +import { + extractVariables, + renderDictionaryEntry, + renderDictionaryObject, + resolveDictionaryLookupOptions, +} from 'gt-i18n/internal'; +import { + useDefaultLocale, + useDictionaryObject, + useRuntimeDictionaryScope, +} from './external-store-hooks'; +import { useLocale } from './context-hooks'; +import { useShouldTranslate } from './utils'; +import { getReactI18nManager } from '../i18n-manager/singleton-operations'; +import { useGT } from './useGT'; +import type { + DictionaryObjectTranslation, + DictionaryTranslationOptions, +} from 'gt-i18n/types'; + +// ===== Hook ===== // + +export function useTranslations(id?: string): UseTranslationsFunction { + const locale = useLocale(); + const defaultLocale = useDefaultLocale(); + const shouldTranslate = useShouldTranslate(); + const scope = useRuntimeDictionaryScope(); + const gt = useGT(); + const rootId = id ?? ''; + const devHotReloadEnabled = getReactI18nManager().isDevHotReloadEnabled(); + + useDictionaryObject({ locale: defaultLocale, id: rootId }); + useDictionaryObject({ locale, id: rootId }); + + const translateEntry = useCallback( + (suffix: string, options: DictionaryTranslationOptions = {}) => { + const i18nManager = getReactI18nManager(); + const entryId = getEntryId(id, suffix); + const sourceEntry = i18nManager.lookupDictionary(defaultLocale, entryId); + if (sourceEntry === undefined) { + throw new Error(`Dictionary entry ${entryId} cannot be found`); + } + if (!shouldTranslate) { + return gt(sourceEntry.entry, { + ...resolveDictionaryLookupOptions(sourceEntry.options), + ...extractVariables(options), + $locale: defaultLocale, + }); + } + + const targetEntry = i18nManager.lookupDictionary(locale, entryId); + if (targetEntry === undefined && devHotReloadEnabled) { + scope.translateEntry({ locale, id: entryId }); + } + + if (targetEntry?.entry != null) { + return renderDictionaryEntry({ + sourceLocale: defaultLocale, + targetLocale: locale, + sourceEntry, + target: targetEntry.entry, + dictionaryOptions: resolveDictionaryLookupOptions( + sourceEntry.options + ), + options, + }); + } + + return gt(sourceEntry.entry, { + ...resolveDictionaryLookupOptions(sourceEntry.options), + ...extractVariables(options), + $locale: locale, + }); + }, + [defaultLocale, devHotReloadEnabled, gt, id, locale, scope, shouldTranslate] + ); + + const translateObject = useCallback( + (suffix: string) => { + const i18nManager = getReactI18nManager(); + const entryId = getEntryId(id, suffix); + const sourceObject = i18nManager.lookupDictionaryObj( + defaultLocale, + entryId + ); + if (sourceObject === undefined) { + throw new Error(`Dictionary entry ${entryId} cannot be found`); + } + + let targetObject = undefined; + if (shouldTranslate) { + targetObject = i18nManager.lookupDictionaryObj(locale, entryId); + if (targetObject === undefined && devHotReloadEnabled) { + scope.translateObject({ locale, id: entryId }); + } + } + + return renderDictionaryObject({ + sourceObject, + targetObject, + translate: (sourceEntry, dictionaryOptions) => + i18nManager.lookupTranslation( + shouldTranslate ? locale : defaultLocale, + sourceEntry.entry, + dictionaryOptions + ), + }); + }, + [defaultLocale, devHotReloadEnabled, id, locale, scope, shouldTranslate] + ); + + return useMemo( + () => Object.assign(translateEntry, { obj: translateObject }), + [translateEntry, translateObject] + ); +} + +// ===== Lookup Helpers ===== // + +function getEntryId(prefix: string | undefined, suffix: string): string { + return prefix ? `${prefix}.${suffix}` : suffix; +} + +// ===== Types ===== // + +export type UseTranslationsFunction = (( + id: string, + options?: DictionaryTranslationOptions +) => string) & { + obj: (id: string) => DictionaryObjectTranslation; +}; diff --git a/packages/react-core/src/hooks/utils.ts b/packages/react-core/src/hooks/utils.ts new file mode 100644 index 000000000..4128319e3 --- /dev/null +++ b/packages/react-core/src/hooks/utils.ts @@ -0,0 +1,31 @@ +import { + useCustomMapping, + useDefaultLocale, + useLocales, +} from './external-store-hooks'; +import { useEnableI18n, useLocale } from './context-hooks'; +import { requiresTranslation } from 'generaltranslation/core'; + +export function useFormatLocales(localesProp: string[] = []): string[] { + const locale = useLocale(); + const defaultLocale = useDefaultLocale(); + const shouldTranslate = useShouldTranslate(); + return shouldTranslate + ? [...localesProp, locale, defaultLocale] + : [defaultLocale]; +} + +/** + * Returns true if (1) i18n enabled and (2) translation is required + */ +export function useShouldTranslate(): boolean { + const enableI18n = useEnableI18n(); + const defaultLocale = useDefaultLocale(); + const locale = useLocale(); + const locales = useLocales(); + const customMapping = useCustomMapping(); + return ( + enableI18n && + requiresTranslation(defaultLocale, locale, [...locales], customMapping) + ); +} diff --git a/packages/react-core/src/i18n-manager/ReactI18nManager.ts b/packages/react-core/src/i18n-manager/ReactI18nManager.ts new file mode 100644 index 000000000..13fbf0571 --- /dev/null +++ b/packages/react-core/src/i18n-manager/ReactI18nManager.ts @@ -0,0 +1,9 @@ +import type { I18nManager } from 'gt-i18n/internal'; +import type { I18nManagerConstructorParams } from 'gt-i18n/internal/types'; +import type { Translation } from 'gt-i18n/types'; + +export type ReactI18nManager = Pick< + I18nManager, + keyof I18nManager +>; +export type ReactI18nManagerParams = I18nManagerConstructorParams; diff --git a/packages/react-core/src/i18n-manager/singleton-operations.ts b/packages/react-core/src/i18n-manager/singleton-operations.ts new file mode 100644 index 000000000..d6ba7777e --- /dev/null +++ b/packages/react-core/src/i18n-manager/singleton-operations.ts @@ -0,0 +1,16 @@ +import * as i18nInternal from 'gt-i18n/internal'; +import type { I18nManager } from 'gt-i18n/internal'; +import type { Translation } from 'gt-i18n/types'; +import type { ReactI18nManager } from './ReactI18nManager'; + +// ===== I18n Manager ===== // + +export function getReactI18nManager(): ReactI18nManager { + return i18nInternal.getI18nManager() as ReactI18nManager; +} + +export function setReactI18nManager(i18nManager: ReactI18nManager): void { + i18nInternal.setI18nManager( + i18nManager as I18nManager + ); +} diff --git a/packages/react-core/src/i18n-store/I18nStore.ts b/packages/react-core/src/i18n-store/I18nStore.ts new file mode 100644 index 000000000..5f4f07d47 --- /dev/null +++ b/packages/react-core/src/i18n-store/I18nStore.ts @@ -0,0 +1,449 @@ +import { + getDictionaryListenerKey, + getTranslateListenerKey, +} from "gt-i18n/internal"; +import type { CustomMapping } from "generaltranslation/types"; +import type { + DictionaryEntrySnapshot, + DictionaryLookup, + DictionaryObjectSnapshot, + ListenerSet, + StoreListener, + TranslateLookup, + TranslateManySnapshot, + TranslateSnapshot, + Unsubscribe, + ReloadLocaleType, +} from "./storeTypes"; +import type { Translation } from "gt-i18n/types"; +import type { Hash, Locale, LocaleCandidates } from "gt-i18n/internal/types"; +import { getWritableConditionStore } from "../condition-store/singleton-operations"; +import { getReactI18nManager } from "../i18n-manager/singleton-operations"; +import { RuntimeTranslationScope } from "./RuntimeTranslationScope"; +import { RuntimeDictionaryScope } from "./RuntimeDictionaryScope"; + +type TranslationStatusType = + | { status: "loading"; locale: string } + | { status: "ready" }; + +type EntryCacheEvent = { + locale: string; + id: string; +}; +type TranslateStoreListener = (lookup: TranslateLookup) => void; +type DictionaryStoreEvent = EntryCacheEvent; +type DictionaryStoreListener = (event: DictionaryStoreEvent) => void; + +/** + * @param reloadLocale - If provided, will trigger on a locale change + * instead of a locale state update. This is used when triggering this function + * will (1) load in new translations and (2) update the locale: + * - SSR apps: reload server side props, pass new tx obj and new locale to client + * thru the provider + * - SPA: trigger a module-level reload of the app (eg browser refresh or RN reload) + * + * If it is not provided, this will trigger an async load of translations. In the meantime, + * a loading state will be shown. Finally, an event will be emitted updating locale state + * with synchronous access to new translations. + */ +export type I18nStoreParams = { reloadLocale?: ReloadLocaleType }; + +/** + * A subscription wrapper around the I18nManager and the ConditionStore + * + * It is assumed that the I18nManager and the ConditionStore are already initialized. + * It is assumed that the locale and translations are already sync accessible. + */ +export class I18nStore { + // ===== Listener Sets ===== // + + private defaultLocaleListeners: ListenerSet = new Set(); + private localesListeners: ListenerSet = new Set(); + private customMappingListeners: ListenerSet = new Set(); + private enableI18nListeners: ListenerSet = new Set(); + private translateListeners = new Set(); + private translateManySnapshotCache = new WeakMap< + readonly TranslateLookup[], + TranslateManySnapshot + >(); + private dictionaryEntryListeners = new Set(); + private dictionaryObjectListeners = new Set(); + private localeListeners: ListenerSet = new Set(); + + private translationStatusListeners: ListenerSet = new Set(); + private translationStatus: TranslationStatusType = { status: "ready" }; + + private reloadLocale?: ReloadLocaleType; + + /** + * ConditionStore and I18nManager must be already initialized + */ + constructor({ reloadLocale }: I18nStoreParams) { + try { + getWritableConditionStore(); + getReactI18nManager(); + } catch (error) { + throw new Error("Failed to initialize I18nStore. Reason: " + error); + } + this.reloadLocale = reloadLocale; + } + + // ===== Manager Config Subscriptions ===== // + + subscribeToDefaultLocale = (listener: StoreListener): Unsubscribe => { + return this.subscribeToStaticSet(this.defaultLocaleListeners, listener); + }; + + subscribeToLocales = (listener: StoreListener): Unsubscribe => { + return this.subscribeToStaticSet(this.localesListeners, listener); + }; + + subscribeToCustomMapping = (listener: StoreListener): Unsubscribe => { + return this.subscribeToStaticSet(this.customMappingListeners, listener); + }; + + // ===== ConditionStore Subscriptions ===== // + + subscribeToLocale = (listener: StoreListener): Unsubscribe => { + return this.subscribeToStaticSet(this.localeListeners, listener); + }; + + subscribeToEnableI18n = (listener: StoreListener): Unsubscribe => { + return this.subscribeToStaticSet(this.enableI18nListeners, listener); + }; + + // ===== I18nManager Subscriptions ===== // + + subscribeToTranslate( + lookup: TranslateLookup, + listener: StoreListener, + ): Unsubscribe { + const lookupKey = getTranslateListenerKey(lookup); + const wrappedListener: TranslateStoreListener = (lookup) => { + if (getTranslateListenerKey(lookup) === lookupKey) { + listener(); + } + }; + return this.subscribeToTranslateSet(wrappedListener); + } + + subscribeToTranslateMany( + lookups: readonly TranslateLookup[], + listener: StoreListener, + ): Unsubscribe { + const unsubscribes = lookups.map((lookup) => + this.subscribeToTranslate(lookup, listener), + ); + return () => { + unsubscribes.forEach((unsubscribe) => unsubscribe()); + }; + } + + subscribeToDictionaryEntry( + lookup: DictionaryLookup, + listener: StoreListener, + ): Unsubscribe { + const lookupKey = getDictionaryListenerKey(lookup); + const wrappedListener: DictionaryStoreListener = (event) => { + if (dictionaryEntryEventMatchesLookup(event, lookupKey)) { + listener(); + } + }; + return this.subscribeToDictionarySet( + this.dictionaryEntryListeners, + wrappedListener, + ); + } + + subscribeToDictionaryObject( + lookup: DictionaryLookup, + listener: StoreListener, + ): Unsubscribe { + const lookupKey = getDictionaryListenerKey(lookup); + const wrappedListener: DictionaryStoreListener = (event) => { + if (dictionaryObjectEventMatchesLookup(event, lookupKey)) { + listener(); + } + }; + return this.subscribeToDictionarySet( + this.dictionaryObjectListeners, + wrappedListener, + ); + } + + // ===== Manager Config Snapshots ===== // + + getDefaultLocaleSnapshot = (): string => { + return getReactI18nManager().getDefaultLocale(); + }; + + getLocalesSnapshot = (): readonly string[] => { + return getReactI18nManager().getLocales(); + }; + + getCustomMappingSnapshot = (): CustomMapping => { + return getReactI18nManager().getCustomMapping(); + }; + + // ===== ConditionStore Snapshots ===== // + + getLocaleSnapshot = (): string => { + return getWritableConditionStore().getLocale(); + }; + + getEnableI18nSnapshot = (): boolean => { + return getWritableConditionStore().getEnableI18n(); + }; + + // ===== I18nManager Snapshots ===== // + + getTranslateSnapshot = ({ + locale, + message, + options, + }: TranslateLookup): TranslateSnapshot => { + return getReactI18nManager().lookupTranslation(locale, message, options); + }; + + getTranslateManySnapshot = ( + lookups: readonly TranslateLookup[], + ): TranslateManySnapshot => { + const nextSnapshot = lookups.map((lookup) => + this.getTranslateSnapshot(lookup), + ); + const previousSnapshot = this.translateManySnapshotCache.get(lookups); + if ( + previousSnapshot && + previousSnapshot.length === nextSnapshot.length && + previousSnapshot.every((value, index) => + Object.is(value, nextSnapshot[index]), + ) + ) { + return previousSnapshot as TranslateManySnapshot; + } + + this.translateManySnapshotCache.set(lookups, nextSnapshot); + return nextSnapshot; + }; + + getDictionaryEntrySnapshot = ({ + locale, + id, + }: DictionaryLookup): DictionaryEntrySnapshot => { + return getReactI18nManager().lookupDictionary(locale, id); + }; + + getDictionaryObjectSnapshot = ({ + locale, + id, + }: DictionaryLookup): DictionaryObjectSnapshot => { + return getReactI18nManager().lookupDictionaryObj(locale, id); + }; + + // ===== runtime translation ===== // + + translate = (lookup: TranslateLookup): void => { + getReactI18nManager() + .lookupTranslationWithFallback( + lookup.locale, + lookup.message, + lookup.options, + ) + .then((translation) => { + if (translation == null) { + // TODO: warn about runtime translation failure + } + this.emitTranslateEvent(lookup); + }); + }; + + translateDictionaryEntry = (lookup: DictionaryLookup): void => { + getReactI18nManager() + .lookupDictionaryWithFallback(lookup.locale, lookup.id) + .then((dictionaryEntry) => { + if (dictionaryEntry == null) { + // TODO: warn about runtime dictionary translation failure + } + this.emitDictionaryEvent(lookup); + }); + }; + + translateDictionaryObject = (lookup: DictionaryLookup): void => { + getReactI18nManager() + .lookupDictionaryObjWithFallback(lookup.locale, lookup.id) + .then((dictionaryObject) => { + if (dictionaryObject == null) { + // TODO: warn about runtime dictionary translation failure + } + this.emitDictionaryEvent(lookup); + }); + }; + + // ----- scopes ----- // + + createRuntimeTranslationScope = (): RuntimeTranslationScope => { + return new RuntimeTranslationScope(); + }; + + createRuntimeDictionaryScope = (): RuntimeDictionaryScope => { + return new RuntimeDictionaryScope(); + }; + + // ===== set locale operations ===== // + + /** + * Set locale triggers an async translation load. We only + * update to the new locale after the translation is complete. + * + * We push updates to translationStatus for subscribers to hook into + * for triggering a re-render. + * + * For any SSR, instead we would want to skip a lot of this logic and + * trigger the server side props to be reloaded instead. + */ + setLocale = (newLocale: string): void => { + // Sanitize locale + const i18nManager = getReactI18nManager(); + const locale = i18nManager.sanitizeLocale(newLocale); + if (!locale) { + return; + } + + // Abort client-reload logic if overrideSetLocale is provided + // We dont emit an event here because it is assumed that the locale + // gets updated via this reload (eg browser refresh/SSR reload) + if (this.reloadLocale) { + getWritableConditionStore().setLocale(locale); + this.reloadLocale(locale); + return; + } + + // If already loaded, just immediately emit the status update + if ( + !i18nManager.requiresTranslation(locale) || + i18nManager.hasTranslations(locale) + ) { + this.updateTranslationStatus({ status: "ready" }); + getWritableConditionStore().setLocale(locale); + this.localeListeners.forEach((listener) => listener()); + return; + } + + // Load new translations and update status and locale + this.updateTranslationStatus({ status: "loading", locale }); + getReactI18nManager() + .loadTranslations(locale) + .then(() => { + // dedupe update + if ( + this.translationStatus.status === "ready" || + this.translationStatus.locale !== locale + ) { + return; + } + this.updateTranslationStatus({ status: "ready" }); + getWritableConditionStore().setLocale(locale); + this.localeListeners.forEach((listener) => listener()); + }); + }; + + /** + * When disabled, we don't show any translations, no formatting, no new requests + * technically, a user can still switch locales, but we do not fire off any requests + */ + setEnableI18n = (enableI18n: boolean): void => { + getWritableConditionStore().setEnableI18n(enableI18n); + this.updateTranslationStatus({ status: "ready" }); + this.enableI18nListeners.forEach((listener) => listener()); + }; + + subscribeToTranslationStatus = (listener: StoreListener): Unsubscribe => { + return this.subscribeToStaticSet(this.translationStatusListeners, listener); + }; + + getTranslationStatusSnapshot = (): TranslationStatusType => { + return this.translationStatus; + }; + + private updateTranslationStatus = (txStatus: TranslationStatusType): void => { + // Need to create a new object, otherwise rerender will not trigger + if (txStatus.status === "loading") { + this.translationStatus = { status: "loading", locale: txStatus.locale }; + } else { + this.translationStatus = { status: "ready" }; + } + this.translationStatusListeners.forEach((listener) => listener()); + }; + + private subscribeToStaticSet( + listenerSet: ListenerSet, + listener: StoreListener, + ): Unsubscribe { + listenerSet.add(listener); + return () => { + listenerSet.delete(listener); + }; + } + + private subscribeToTranslateSet( + listener: TranslateStoreListener, + ): Unsubscribe { + this.translateListeners.add(listener); + return () => { + this.translateListeners.delete(listener); + }; + } + + private subscribeToDictionarySet( + listenerSet: Set, + listener: DictionaryStoreListener, + ): Unsubscribe { + listenerSet.add(listener); + return () => { + listenerSet.delete(listener); + }; + } + + // ===== Listener Utilities ===== // + + private emitTranslateEvent(event: TranslateLookup): void { + this.translateListeners.forEach((listener) => listener(event)); + } + + private emitDictionaryEvent(event: DictionaryStoreEvent): void { + this.dictionaryEntryListeners.forEach((listener) => listener(event)); + this.dictionaryObjectListeners.forEach((listener) => { + listener(event); + }); + } +} + +// ===== Lookup Keys ===== // + +function getDictionaryLookupFromKey(lookupKey: string): DictionaryLookup { + const separatorIndex = lookupKey.indexOf(":"); + return { + locale: lookupKey.slice(0, separatorIndex), + id: lookupKey.slice(separatorIndex + 1), + }; +} + +// ===== Event Matching ===== // + +function dictionaryEntryEventMatchesLookup( + event: DictionaryStoreEvent, + lookupKey: string, +): boolean { + return getDictionaryListenerKey(event) === lookupKey; +} + +function dictionaryObjectEventMatchesLookup( + event: DictionaryStoreEvent, + lookupKey: string, +): boolean { + const { locale, id } = getDictionaryLookupFromKey(lookupKey); + if (locale !== event.locale) { + return false; + } + return id === "" || event.id === id || event.id.startsWith(`${id}.`); +} diff --git a/packages/react-core/src/i18n-store/RuntimeDictionaryScope.ts b/packages/react-core/src/i18n-store/RuntimeDictionaryScope.ts new file mode 100644 index 000000000..a5c468a4e --- /dev/null +++ b/packages/react-core/src/i18n-store/RuntimeDictionaryScope.ts @@ -0,0 +1,64 @@ +import { getI18nStore } from './singleton-operations'; +import type { DictionaryLookup, Unsubscribe } from './storeTypes'; +import { getDictionaryListenerKey } from 'gt-i18n/internal'; +import { getReactI18nManager } from '../i18n-manager/singleton-operations'; + +/** + * Tracks dictionary lookups discovered by useTranslations callbacks. + */ +export class RuntimeDictionaryScope { + private version = 0; + private listeners = new Set<() => void>(); + private pendingEntries = new Map(); + private pendingObjects = new Map(); + + translateEntry(lookup: DictionaryLookup) { + if (!getReactI18nManager().isDevHotReloadEnabled()) return; + + const key = getDictionaryListenerKey(lookup); + if (this.pendingEntries.has(key)) return; + + const store = getI18nStore(); + const unsubscribe = store.subscribeToDictionaryEntry(lookup, () => + this.notifyResolved(key, this.pendingEntries) + ); + this.pendingEntries.set(key, unsubscribe); + store.translateDictionaryEntry(lookup); + } + + translateObject(lookup: DictionaryLookup) { + if (!getReactI18nManager().isDevHotReloadEnabled()) return; + + const key = getDictionaryListenerKey(lookup); + if (this.pendingObjects.has(key)) return; + + const store = getI18nStore(); + const unsubscribe = store.subscribeToDictionaryObject(lookup, () => + this.notifyResolved(key, this.pendingObjects) + ); + this.pendingObjects.set(key, unsubscribe); + store.translateDictionaryObject(lookup); + } + + getSnapshot = () => this.version; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private notifyResolved(key: string, pending: Map): void { + const unsubscribe = pending.get(key); + if (!unsubscribe) return; + + unsubscribe(); + pending.delete(key); + + if (this.pendingEntries.size === 0 && this.pendingObjects.size === 0) { + this.version++; + this.listeners.forEach((listener) => listener()); + } + } +} diff --git a/packages/react-core/src/i18n-store/RuntimeTranslationScope.ts b/packages/react-core/src/i18n-store/RuntimeTranslationScope.ts new file mode 100644 index 000000000..fde6ca600 --- /dev/null +++ b/packages/react-core/src/i18n-store/RuntimeTranslationScope.ts @@ -0,0 +1,51 @@ +import { getI18nStore } from './singleton-operations'; +import type { TranslateLookup, Unsubscribe } from './storeTypes'; +import { getTranslateListenerKey } from 'gt-i18n/internal'; +import { getReactI18nManager } from '../i18n-manager/singleton-operations'; + +/** + * Owned by I18nStore, this should not be imported to any other files + */ +export class RuntimeTranslationScope { + private version = 0; + private listeners = new Set<() => void>(); + private pendingKeys = new Map(); + + translate(lookup: TranslateLookup) { + if (!getReactI18nManager().isDevHotReloadEnabled()) return; + + const key = getTranslateListenerKey(lookup); + if (this.pendingKeys.has(key)) return; + const store = getI18nStore(); + const unsubscribe = store.subscribeToTranslate(lookup, () => + this.notifyResolved(lookup) + ); + this.pendingKeys.set(key, unsubscribe); + store.translate(lookup); + } + + /** + * Only notify when all lookups are resolved + */ + notifyResolved(lookup: TranslateLookup) { + const key = getTranslateListenerKey(lookup); + if (!this.pendingKeys.has(key)) return; + const unsubscribe = this.pendingKeys.get(key); + unsubscribe?.(); + this.pendingKeys.delete(key); + + if (this.pendingKeys.size === 0) { + this.version++; + this.listeners.forEach((listener) => listener()); + } + } + + getSnapshot = () => this.version; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; +} diff --git a/packages/react-core/src/i18n-store/singleton-operations.ts b/packages/react-core/src/i18n-store/singleton-operations.ts new file mode 100644 index 000000000..b73a95111 --- /dev/null +++ b/packages/react-core/src/i18n-store/singleton-operations.ts @@ -0,0 +1,16 @@ +import { I18nStore } from './I18nStore'; + +// ===== I18n Store ===== // + +let i18nStore: I18nStore | undefined; + +export function getI18nStore(): I18nStore { + if (!i18nStore) { + throw new Error('I18nStore is not initialized.'); + } + return i18nStore; +} + +export function setI18nStore(nextStore: I18nStore): void { + i18nStore = nextStore; +} diff --git a/packages/react-core/src/i18n-store/storeTypes.ts b/packages/react-core/src/i18n-store/storeTypes.ts new file mode 100644 index 000000000..fe6cc8f80 --- /dev/null +++ b/packages/react-core/src/i18n-store/storeTypes.ts @@ -0,0 +1,41 @@ +import type { + DictionaryEntry, + DictionaryObject, + LookupOptions, + Translation, +} from "gt-i18n/types"; + +// ----- Listeners ----- // + +export type Unsubscribe = () => void; +export type StoreListener = () => void; +export type ListenerSet = Set; + +/** + * A function that is called when a locale changes. + * Typically used to reload server-rendered props or + * trigger a client-side reload of the app. + */ +export type ReloadLocaleType = (locale: string) => void; + +// ----- Lookups ----- // + +export type TranslateLookup = { + locale: string; + message: T; + options: LookupOptions; +}; + +export type DictionaryLookup = { + locale: string; + id: string; +}; +// ----- Snapshots ----- // + +export type TranslateSnapshot = + | T + | undefined; +export type TranslateManySnapshot = + readonly TranslateSnapshot[]; +export type DictionaryEntrySnapshot = DictionaryEntry | undefined; +export type DictionaryObjectSnapshot = DictionaryObject | undefined; diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts index 4e43ab035..2bb67d11a 100644 --- a/packages/react-core/src/index.ts +++ b/packages/react-core/src/index.ts @@ -1,43 +1,43 @@ -import T from './translation/T'; -import useGT from './translation/hooks/useGT'; -import useTranslations from './translation/hooks/useTranslations'; -import useDefaultLocale from './hooks/useDefaultLocale'; -import useLocale from './hooks/useLocale'; -import useVersionId from './hooks/useVersionId'; -import useRegion from './hooks/useRegion'; -import GTProvider from './provider/GTProvider'; -import Var from './variables/Var'; -import Num from './variables/Num'; -import Currency from './variables/Currency'; -import DateTime from './variables/DateTime'; -import RelativeTime from './variables/RelativeTime'; -import { Static, Derive } from './variables/Derive'; -import Plural from './branches/plurals/Plural'; -import Branch from './branches/Branch'; -import useLocales from './hooks/useLocales'; -import useSetLocale from './hooks/useSetLocale'; -import LocaleSelector from './ui/LocaleSelector'; -import useLocaleSelector from './hooks/useLocaleSelector'; -import RegionSelector from './ui/RegionSelector'; +import T from "./deprecated/translation/T"; +import useGT from "./deprecated/translation/hooks/useGT"; +import useTranslations from "./deprecated/translation/hooks/useTranslations"; +import useDefaultLocale from "./deprecated/hooks/useDefaultLocale"; +import useLocale from "./deprecated/hooks/useLocale"; +import useVersionId from "./deprecated/hooks/useVersionId"; +import useRegion from "./deprecated/hooks/useRegion"; +import GTProvider from "./deprecated/provider/GTProvider"; +import Var from "./deprecated/variables/Var"; +import Num from "./deprecated/variables/Num"; +import Currency from "./deprecated/variables/Currency"; +import DateTime from "./deprecated/variables/DateTime"; +import RelativeTime from "./deprecated/variables/RelativeTime"; +import { Static, Derive } from "./deprecated/variables/Derive"; +import Plural from "./deprecated/branches/plurals/Plural"; +import Branch from "./deprecated/branches/Branch"; +import useLocales from "./deprecated/hooks/useLocales"; +import useSetLocale from "./deprecated/hooks/useSetLocale"; +import LocaleSelector from "./deprecated/ui/LocaleSelector"; +import useLocaleSelector from "./deprecated/hooks/useLocaleSelector"; +import RegionSelector from "./deprecated/ui/RegionSelector"; import type { DictionaryTranslationOptions, InlineTranslationOptions, RuntimeTranslationOptions, -} from './types-dir/types'; -import { useGTClass, useLocaleProperties } from './hooks/useGTClass'; -import { useRegionSelector } from './hooks/useRegionSelector'; -import { useLocaleDirection } from './hooks/useLocaleDirection'; -import { msg, decodeMsg, decodeOptions } from './messages/messages'; -import useMessages from './translation/hooks/useMessages'; -import { GTContext } from './provider/GTContext'; -import useRuntimeTranslation from './provider/hooks/useRuntimeTranslation'; -import useCreateInternalUseGTFunction from './provider/hooks/translation/useCreateInternalUseGTFunction'; -import useCreateInternalUseTranslationsFunction from './provider/hooks/translation/useCreateInternalUseTranslationsFunction'; -import { useCreateInternalUseTranslationsObjFunction } from './provider/hooks/translation/useCreateInternalUseTranslationsObjFunction'; +} from "./deprecated/types-dir/types"; +import { useGTClass, useLocaleProperties } from "./deprecated/hooks/useGTClass"; +import { useRegionSelector } from "./deprecated/hooks/useRegionSelector"; +import { useLocaleDirection } from "./deprecated/hooks/useLocaleDirection"; +import { msg, decodeMsg, decodeOptions } from "./deprecated/messages/messages"; +import useMessages from "./deprecated/translation/hooks/useMessages"; +import { GTContext } from "./deprecated/provider/GTContext"; +import useRuntimeTranslation from "./deprecated/provider/hooks/useRuntimeTranslation"; +import useCreateInternalUseGTFunction from "./deprecated/provider/hooks/translation/useCreateInternalUseGTFunction"; +import useCreateInternalUseTranslationsFunction from "./deprecated/provider/hooks/translation/useCreateInternalUseTranslationsFunction"; +import { useCreateInternalUseTranslationsObjFunction } from "./deprecated/provider/hooks/translation/useCreateInternalUseTranslationsObjFunction"; -export * from 'gt-i18n/fallbacks'; +export * from "gt-i18n/fallbacks"; -export { declareStatic, derive, declareVar, decodeVars } from 'gt-i18n'; +export { declareStatic, derive, declareVar, decodeVars } from "gt-i18n"; export { Var, diff --git a/packages/react-core/src/internal.ts b/packages/react-core/src/internal.ts index c0a4ea2b0..91c13f685 100644 --- a/packages/react-core/src/internal.ts +++ b/packages/react-core/src/internal.ts @@ -1,43 +1,46 @@ // No useContext related exports should go through here! -import flattenDictionary from './internal/flattenDictionary'; -import addGTIdentifier from './internal/addGTIdentifier'; -import { removeInjectedT } from './internal/removeInjectedT'; -import writeChildrenAsObjects from './internal/writeChildrenAsObjects'; -import getPluralBranch from './branches/plurals/getPluralBranch'; +import flattenDictionary from "./deprecated/internal/flattenDictionary"; +import addGTIdentifier from "./deprecated/internal/addGTIdentifier"; +import { removeInjectedT } from "./deprecated/internal/removeInjectedT"; +import writeChildrenAsObjects from "./deprecated/internal/writeChildrenAsObjects"; +import getPluralBranch from "./deprecated/branches/plurals/getPluralBranch"; import { getDictionaryEntry, isValidDictionaryEntry, -} from './dictionaries/getDictionaryEntry'; -import getEntryAndMetadata from './dictionaries/getEntryAndMetadata'; -import getVariableProps from './variables/_getVariableProps'; -import isVariableObject from './rendering/isVariableObject'; -import getVariableName from './variables/getVariableName'; -import renderDefaultChildren from './rendering/renderDefaultChildren'; -import renderTranslatedChildren from './rendering/renderTranslatedChildren'; -import { getDefaultRenderSettings } from './rendering/getDefaultRenderSettings'; -import renderSkeleton from './rendering/renderSkeleton'; +} from "./deprecated/dictionaries/getDictionaryEntry"; +import getEntryAndMetadata from "./deprecated/dictionaries/getEntryAndMetadata"; +import getVariableProps from "./deprecated/variables/_getVariableProps"; +import isVariableObject from "./deprecated/rendering/isVariableObject"; +import getVariableName from "./deprecated/variables/getVariableName"; +import renderDefaultChildren from "./deprecated/rendering/renderDefaultChildren"; +import renderTranslatedChildren from "./deprecated/rendering/renderTranslatedChildren"; +import { getDefaultRenderSettings } from "./deprecated/rendering/getDefaultRenderSettings"; +import renderSkeleton from "./deprecated/rendering/renderSkeleton"; import { defaultLocaleCookieName, defaultRegionCookieName, defaultEnableI18nCookieName, -} from './utils/cookies'; -import mergeDictionaries from './dictionaries/mergeDictionaries'; -import { reactHasUse } from './promises/reactHasUse'; -import { getSubtree, getSubtreeWithCreation } from './dictionaries/getSubtree'; -import { injectEntry } from './dictionaries/injectEntry'; -import { isDictionaryEntry } from './dictionaries/isDictionaryEntry'; -import { stripMetadataFromEntries } from './dictionaries/stripMetadataFromEntries'; -import { injectHashes } from './dictionaries/injectHashes'; -import { injectTranslations } from './dictionaries/injectTranslations'; -import { injectFallbacks } from './dictionaries/injectFallbacks'; -import { injectAndMerge } from './dictionaries/injectAndMerge'; -import { collectUntranslatedEntries } from './dictionaries/collectUntranslatedEntries'; -import { msg, decodeMsg, decodeOptions } from './messages/messages'; -import { Static, Derive } from './variables/Derive'; +} from "./deprecated/utils/cookies"; +import mergeDictionaries from "./deprecated/dictionaries/mergeDictionaries"; +import { reactHasUse } from "./deprecated/promises/reactHasUse"; +import { + getSubtree, + getSubtreeWithCreation, +} from "./deprecated/dictionaries/getSubtree"; +import { injectEntry } from "./deprecated/dictionaries/injectEntry"; +import { isDictionaryEntry } from "./deprecated/dictionaries/isDictionaryEntry"; +import { stripMetadataFromEntries } from "./deprecated/dictionaries/stripMetadataFromEntries"; +import { injectHashes } from "./deprecated/dictionaries/injectHashes"; +import { injectTranslations } from "./deprecated/dictionaries/injectTranslations"; +import { injectFallbacks } from "./deprecated/dictionaries/injectFallbacks"; +import { injectAndMerge } from "./deprecated/dictionaries/injectAndMerge"; +import { collectUntranslatedEntries } from "./deprecated/dictionaries/collectUntranslatedEntries"; +import { msg, decodeMsg, decodeOptions } from "./deprecated/messages/messages"; +import { Static, Derive } from "./deprecated/variables/Derive"; -export * from 'gt-i18n/fallbacks'; -export { declareStatic, derive, declareVar, decodeVars } from 'gt-i18n'; +export * from "gt-i18n/fallbacks"; +export { declareStatic, derive, declareVar, decodeVars } from "gt-i18n"; export { addGTIdentifier, diff --git a/packages/react-core/src/setup/globals.ts b/packages/react-core/src/setup/globals.ts new file mode 100644 index 000000000..50508bae6 --- /dev/null +++ b/packages/react-core/src/setup/globals.ts @@ -0,0 +1,42 @@ +/** + * Helps us distinguish behavior for SPA vs server-rendered apps + * - server-rendered apps must use context + * - SPA apps can synchronously access the locale + */ +export type RenderStrategy = "SPA" | "server-render"; + +declare global { + var __generaltranslation: { + renderStrategy: RenderStrategy | undefined; + /** + * @deprecated use a dedicated function instead instead of second source of truth + */ + i18nStoreInitialized: boolean; + }; +} + +globalThis.__generaltranslation = { + renderStrategy: undefined, + i18nStoreInitialized: false, +}; + +export function getRenderStrategy(): RenderStrategy { + if (!globalThis.__generaltranslation.renderStrategy) { + throw new Error( + "Cannot access render strategy. GT has not been initialized.", + ); + } + return globalThis.__generaltranslation.renderStrategy; +} + +export function getI18nStoreInitialized(): boolean { + return globalThis.__generaltranslation.i18nStoreInitialized; +} + +export function setRenderStrategy(renderStrategy: RenderStrategy): void { + globalThis.__generaltranslation.renderStrategy = renderStrategy; +} + +export function setStoresInitialized(storesInitialized: boolean): void { + globalThis.__generaltranslation.i18nStoreInitialized = storesInitialized; +} diff --git a/packages/react-core/src/setup/initializeGTSPA.ts b/packages/react-core/src/setup/initializeGTSPA.ts new file mode 100644 index 000000000..3731874d0 --- /dev/null +++ b/packages/react-core/src/setup/initializeGTSPA.ts @@ -0,0 +1,36 @@ +import { I18nManager, WritableConditionStore } from "gt-i18n/internal"; +import type { WritableConditionStoreParams } from "gt-i18n/internal"; +import type { Translation } from "gt-i18n/types"; +import { setReactI18nManager } from "../i18n-manager/singleton-operations"; +import type { ReactI18nManagerParams } from "../i18n-manager/ReactI18nManager"; +import { I18nStore, I18nStoreParams } from "../i18n-store/I18nStore"; +import { setRenderStrategy, setStoresInitialized } from "./globals"; +import { setWritableConditionStore } from "../condition-store/singleton-operations"; +import { setI18nStore } from "../i18n-store/singleton-operations"; + +/** + * Initialize GT for an SPA + * - i18nManager + * - conditionStore + * - i18nStore + * + * @deprecated moved to /react and /react-native + */ +export function internalInitializeGTSPA( + config: I18nStoreParams & + ReactI18nManagerParams & + WritableConditionStoreParams, +): void { + setRenderStrategy("SPA"); + + const i18nManager = new I18nManager(config); + setReactI18nManager(i18nManager); + + const conditionStore = new WritableConditionStore(config); + setWritableConditionStore(conditionStore); + + const i18nStore = new I18nStore(config); + setI18nStore(i18nStore); + + setStoresInitialized(true); +} diff --git a/packages/react-core/src/setup/initializeGTSSR.ts b/packages/react-core/src/setup/initializeGTSSR.ts new file mode 100644 index 000000000..5e32551e3 --- /dev/null +++ b/packages/react-core/src/setup/initializeGTSSR.ts @@ -0,0 +1,19 @@ +import { I18nManager } from "gt-i18n/internal"; +import type { Translation } from "gt-i18n/types"; +import type { ReactI18nManagerParams } from "../i18n-manager/ReactI18nManager"; +import { setRenderStrategy } from "./globals"; +import { setReactI18nManager } from "../i18n-manager/singleton-operations"; + +/** + * Initialize GT for a server-side rendered application + * - i18nManager + * + * ConditionStore and I18nStore are initialized in the provider at request time + * TODO: auto detect if can find gt.config.json files + */ +export function internalInitializeGTSSR(config: ReactI18nManagerParams): void { + setRenderStrategy("server-render"); + + const i18nManager = new I18nManager(config); + setReactI18nManager(i18nManager); +} diff --git a/packages/react-core/src/types.ts b/packages/react-core/src/types.ts index c5ffd553a..fb6ea3428 100644 --- a/packages/react-core/src/types.ts +++ b/packages/react-core/src/types.ts @@ -1,4 +1,7 @@ -import type { MFunctionType, GTFunctionType } from './types-dir/types'; +import type { + MFunctionType, + GTFunctionType, +} from "./deprecated/types-dir/types"; import type { Dictionary, RenderMethod, @@ -20,25 +23,34 @@ import type { _Message, _Messages, TaggedChildren, -} from './types-dir/types'; -import type { GTContextType } from './types-dir/context'; +} from "./deprecated/types-dir/types"; +import type { GTContextType } from "./deprecated/types-dir/context"; -import type { AuthFromEnvParams, AuthFromEnvReturn } from './utils/types'; +import type { + AuthFromEnvParams, + AuthFromEnvReturn, +} from "./deprecated/utils/types"; import type { UseDetermineLocaleParams, UseDetermineLocaleReturn, -} from './provider/hooks/locales/types'; +} from "./deprecated/provider/hooks/locales/types"; import type { UseRegionStateParams, UseRegionStateReturn, UseEnableI18nParams, UseEnableI18nReturn, -} from './provider/hooks/types'; -import type { LocaleSelectorProps, RegionSelectorProps } from './ui/types'; +} from "./deprecated/provider/hooks/types"; +import type { + LocaleSelectorProps, + RegionSelectorProps, +} from "./deprecated/ui/types"; -import type { GTProp } from '@generaltranslation/format/types'; +import type { GTProp } from "@generaltranslation/format/types"; -import type { InternalGTProviderProps, GTConfig } from './types-dir/config'; +import type { + InternalGTProviderProps, + GTConfig, +} from "./deprecated/types-dir/config"; export type { Dictionary, diff --git a/packages/react-core/src/utils/cookies.ts b/packages/react-core/src/utils/cookies.ts deleted file mode 100644 index 75c0d1812..000000000 --- a/packages/react-core/src/utils/cookies.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Cookie name for tracking the referrer locale - */ -export const defaultLocaleCookieName = 'generaltranslation.locale'; -/** - * Cookie name for tracking the user's selected region - */ -export const defaultRegionCookieName = 'generaltranslation.region'; -/** - * Cookie name for persisting the enableI18n feature flag - * "true" or "false" - */ -export const defaultEnableI18nCookieName = 'generaltranslation.enable-i18n'; diff --git a/packages/react-core/src/utils/internal/addGTIdentifier.ts b/packages/react-core/src/utils/internal/addGTIdentifier.ts new file mode 100644 index 000000000..d5e25a0b7 --- /dev/null +++ b/packages/react-core/src/utils/internal/addGTIdentifier.ts @@ -0,0 +1,137 @@ +import React, { ReactElement, isValidElement, ReactNode } from 'react'; +import { isAcceptedPluralForm } from 'generaltranslation/internal'; +import { + GTTag, + TaggedChild, + TaggedChildren, + TaggedElement, + TaggedElementProps, +} from '../types'; +import { + Transformation, + TransformationPrefix, + VariableTransformationSuffix, +} from 'generaltranslation/types'; + +type GTComponentType = { + _gtt?: Transformation; +}; + +export default function addGTIdentifier( + children: ReactNode, + startingIndex: number = 0 +): TaggedChildren { + // Object to keep track of the current index for GT IDs + let index = startingIndex; + + /** + * Function to create a GTTag object for a ReactElement + * @param child - The ReactElement for which the GTTag is created + * @returns - The GTTag object + */ + const createGTTag = (child: ReactElement>): GTTag => { + const { type, props } = child; + index += 1; + const result: GTTag = { id: index, injectionType: 'manual' }; + let transformation: Transformation | undefined; + try { + transformation = + typeof type === 'function' ? (type as GTComponentType)._gtt : undefined; + } catch { + /* empty */ + } + if (transformation) { + const transformationParts = transformation.split('-'); + // If the component was inserted automatically by the compiler + if ( + transformationParts[1] === 'automatic' || + transformationParts[2] === 'automatic' + ) { + result.injectionType = 'automatic'; + } + + if (transformationParts[0] === 'translate') { + // Convert nested to fragments + // This will nullify translation specific attributes of child, i.e. id, context, etc. + transformationParts[0] = 'fragment'; + } + if (transformationParts[0] === 'variable') { + result.variableType = + (transformationParts?.[1] as VariableTransformationSuffix) || + 'variable'; + } + if (transformationParts[0] === 'plural') { + const pluralBranches = Object.entries(props).reduce( + (acc, [branchName, branch]) => { + if (isAcceptedPluralForm(branchName)) { + (acc as Record)[branchName] = + addGTIdentifier(branch as ReactNode, index); + } + return acc; + }, + {} as Record + ); + if (Object.keys(pluralBranches).length) + result.branches = pluralBranches; + } + if (transformationParts[0] === 'branch') { + const { children: _children, branch: _branch, ...branches } = props; + // Filter out data-* attributes injected by build tools + const filteredBranches = Object.fromEntries( + Object.entries(branches).filter(([key]) => !key.startsWith('data-')) + ); + const resultBranches = Object.entries(filteredBranches).reduce( + (acc, [branchName, branch]) => { + (acc as Record)[branchName] = + addGTIdentifier(branch as ReactNode, index); + return acc; + }, + {} as Record + ); + if (Object.keys(resultBranches).length) + result.branches = resultBranches; + } + result.transformation = transformationParts[0] as TransformationPrefix; + } + return result; + }; + + function handleSingleChildElement( + child: ReactElement> + ): TaggedElement { + const { props } = child; + + // Create new props for the element, including the GT identifier and a key + const generaltranslation: GTTag = createGTTag(child); + const newProps: TaggedElementProps = { + ...props, + 'data-_gt': generaltranslation, + }; + if (props.children && !generaltranslation.variableType) { + newProps.children = handleChildren(props.children as ReactNode); + } + if (child.type === React.Fragment) { + newProps['data-_gt'].transformation = 'fragment'; + } + return React.cloneElement(child, newProps) as TaggedElement; + } + + function handleSingleChild(child: ReactNode): TaggedChild { + if (isValidElement(child)) { + return handleSingleChildElement( + child as ReactElement> + ); + } + return child; + } + + function handleChildren(children: ReactNode): TaggedChildren { + if (Array.isArray(children)) { + return React.Children.map(children, handleSingleChild); + } else { + return handleSingleChild(children); + } + } + + return handleChildren(children); +} diff --git a/packages/react-core/src/utils/internal/removeInjectedT.ts b/packages/react-core/src/utils/internal/removeInjectedT.ts new file mode 100644 index 000000000..55e04606e --- /dev/null +++ b/packages/react-core/src/utils/internal/removeInjectedT.ts @@ -0,0 +1,204 @@ +import { isAcceptedPluralForm } from "generaltranslation/internal"; +import { InjectionType, TransformationPrefix } from "generaltranslation/types"; +import { + ReactNode, + ReactElement, + isValidElement, + cloneElement, + Children, +} from "react"; + +/** + * Remove injected _T components at runtime. This is only for i18n-context T components to use. + * This is necessary because when dealing with deriving fragmented content. The compiler will always + * inject a `_T` component. + * + * Only remove if within a `` or `` component as this scopes this behavior + * to only where it can actually appear. + */ +export function removeInjectedT(children: ReactNode): ReactNode { + return handleChildren(children, 0); +} + +// ----- Core Logic ----- // + +/** + * Traverses a single child element and removes the injected _T component. + * @param child - The child element to traverse. + * @param derivationDepth - The depth of the derivation. + * @returns The traversed child element. + * + * Derivation depth is used for tracking whether or not to apply the _T removal transformation. + * + * Rules: + * 1. Variable components (Var, Num, Currency, DateTime) - hands off + * 2. Branching components (Branch, Plural) - explore respective branches + * 3. Derivation components (Derive, Static) - add/remove derivation depth + * 4. Translation components (T) - remove _T if within a derivation context + * 5. Then move on to processing the element's children + */ +function handleSingleChildElement( + child: ReactElement, + derivationDepth: number, +): ReactNode { + const { type: elementType, props: elementProps } = child; + const transformation = getTransformation(elementType); + // unlikely edge case: encountered an element with props that cannot be processed + if (typeof elementProps !== "object" || elementProps === null) { + return child; + } + + if (transformation) { + const { componentType, injectionType } = transformation; + + // (1) If the element is a variable component, hands off + if (componentType === "variable") { + return child; + } + + // (2) If the element is a branching component, explore respective branches + else if (componentType === "branch") { + // Traverse into each branch (this also includes the children property) + const newProps = Object.entries(elementProps).reduce< + Record + >((acc, [branchName, branch]) => { + if (branchName !== "branch" && !branchName.startsWith("data-")) { + acc[branchName] = handleSingleChild(branch, derivationDepth); + } else { + // Skip recursion on non-translated branches + acc[branchName] = branch; + } + return acc; + }, {}); + + return cloneElement(child, { + ...newProps, + }); + } else if (componentType === "plural") { + // Traverse into each branch (this also includes the children property) + const newProps = Object.entries(elementProps).reduce< + Record + >((acc, [branchName, branch]) => { + if (isAcceptedPluralForm(branchName) || branchName === "children") { + acc[branchName] = handleSingleChild(branch, derivationDepth); + } else { + // Skip Recursion on non-translated branches + acc[branchName] = branch; + } + return acc; + }, {}); + + return cloneElement(child, { + ...newProps, + }); + } + + // (3) If the element is a derivation component, add/remove derivation depth + else if (componentType === "derive") { + return cloneElement(child, { + ...elementProps, + ...("children" in elementProps && { + children: handleChildren( + elementProps.children as ReactNode, + derivationDepth + 1, + ), + }), + }); + } + + // (4) If the element is a translation component, remove _T if within a derivation context, just return the children + else if ( + componentType === "translate" && + injectionType === "automatic" && + derivationDepth > 0 + ) { + return "children" in elementProps + ? handleChildren(elementProps.children as ReactNode, derivationDepth) + : undefined; + } + + // Note: componentType === 'translate': means that there is a <_T> inside of a /<_T> + else if (componentType === "translate" && injectionType === "automatic") { + console.warn(warnNestedInternalTComponent); + } + } + + // (5) Recurse into children + return cloneElement(child, { + ...elementProps, + ...("children" in elementProps && { + children: handleChildren( + elementProps.children as ReactNode, + derivationDepth, + ), + }), + }); +} + +// ----- Traversal ----- // + +/** + * Traverses a single child react node and removes the injected _T component. + */ +function handleSingleChild( + child: ReactNode, + derivationDepth: number, +): ReactNode { + if (isValidElement(child)) { + return handleSingleChildElement(child, derivationDepth); + } + return child; +} + +/** + * Traverses an array of children and removes the injected _T component. + * + */ +function handleChildren( + children: ReactNode, + derivationDepth: number, +): ReactNode { + if (Array.isArray(children)) { + return Children.map(children, (child) => + handleSingleChild(child, derivationDepth), + ); + } + return handleSingleChild(children, derivationDepth); +} + +// ----- Helper Functions ----- // + +/** + * Extracts the transformation from the element type. + * @param elementType - The element type to extract the transformation from. + * @returns The transformation. + */ +function getTransformation(elementType: ReactElement["type"]): + | { + componentType: TransformationPrefix; + injectionType: InjectionType; + } + | undefined { + // Extract transformation string + const transformation = + typeof elementType === "function" && "_gtt" in elementType + ? elementType._gtt + : undefined; + if (transformation == null || typeof transformation !== "string") + return undefined; + + // Extract metadata from transformation string + const parts = transformation.split("-"); + const componentType = parts[0] as TransformationPrefix; + const injectionType = + parts[1] === "automatic" || parts[2] === "automatic" + ? "automatic" + : "manual"; + + return { + componentType, + injectionType, + }; +} + +const warnNestedInternalTComponent = `'@generaltranslation/react-core Warning: A <_T> component was found injected outside of a boundary. This may affect translation resolution for this component.`; diff --git a/packages/react-core/src/utils/internal/writeChildrenAsObjects.ts b/packages/react-core/src/utils/internal/writeChildrenAsObjects.ts new file mode 100644 index 000000000..538b4cdf3 --- /dev/null +++ b/packages/react-core/src/utils/internal/writeChildrenAsObjects.ts @@ -0,0 +1,179 @@ +import getVariableName from "../variables/getVariableName"; +import { TaggedChild, TaggedChildren, TaggedElement } from "../../utils/types"; +import { isValidTaggedElement } from "../../utils/utils"; +import { minifyVariableType } from "generaltranslation/internal"; +import { + GTProp, + HTML_CONTENT_PROPS, + HtmlContentPropKeysRecord, + JsxChild, + JsxChildren, + JsxElement, + Variable, +} from "@generaltranslation/format/types"; +import type { Transformation } from "generaltranslation/types"; + +/** + * Gets the tag name of a React element. + * @param {ReactElement} child - The React element. + * @returns {string} - The tag name of the React element. + */ +const getTagName = (child: TaggedElement): string => { + if (!child) return ""; + const { type, props } = child; + if (type && typeof type === "function") { + if ( + "displayName" in type && + typeof type.displayName === "string" && + type.displayName + ) + return type.displayName; + if ("name" in type && typeof type.name === "string" && type.name) + return type.name; + } + if (type && typeof type === "string") return type; + if (props.href) return "a"; + if (props["data-_gt"]?.id) return `C${props["data-_gt"].id}`; + return "function"; +}; +const createGTProp = ( + transformation: Transformation, + props: Record, + branches?: Record, +): GTProp | undefined => { + // Add translatable HTML content props + let newGTProp: GTProp = Object.entries(HTML_CONTENT_PROPS).reduce( + (acc, [minifiedName, fullName]) => { + const value = props[fullName]; + if (typeof value === "string") { + acc[minifiedName as keyof HtmlContentPropKeysRecord] = value; + } + return acc; + }, + {}, + ); + + // Check if plural + if (transformation === "plural" && branches) { + const newBranches: Record = {}; + Object.entries(branches).forEach( + ([key, value]: [string, TaggedChildren]) => { + newBranches[key] = writeChildrenAsObjects(value); + }, + ); + newGTProp = { ...newGTProp, b: newBranches, t: "p" }; + } + if (transformation === "branch" && branches) { + const newBranches: Record = {}; + Object.entries(branches).forEach( + ([key, value]: [string, TaggedChildren]) => { + newBranches[key] = writeChildrenAsObjects(value); + }, + ); + newGTProp = { ...newGTProp, b: newBranches, t: "b" }; + } + + return Object.keys(newGTProp).length ? newGTProp : undefined; +}; + +/** + * Handles a single child element. + * @param {TaggedElement} child - The child to handle. + * @returns {JsxElement | Variable} The minified element. + */ +const handleSingleChildElement = ( + child: TaggedElement, +): JsxElement | Variable => { + const { props } = child; + const minifiedElement: JsxElement = { + t: getTagName(child), + }; + if (props["data-_gt"]) { + // Get generaltranslation props + const generaltranslation = props["data-_gt"]; + + // Check if variable + const transformation = generaltranslation.transformation; + if (transformation === "variable") { + const variableType = generaltranslation.variableType || "variable"; + const variableName = getVariableName(props, variableType); + const minifiedVariableType = minifyVariableType(variableType); + return { + i: generaltranslation.id, + k: variableName, + v: minifiedVariableType, + }; + } + + // Add id + minifiedElement.i = generaltranslation.id; + + // Add GT prop + minifiedElement.d = createGTProp( + transformation as Transformation, + props, + generaltranslation.branches, + ); + + // Add translatable HTML content props + let newGTProp: GTProp = Object.entries(HTML_CONTENT_PROPS).reduce( + (acc, [minifiedName, fullName]) => { + const value = props[fullName]; + if (typeof value === "string") { + acc[minifiedName as keyof HtmlContentPropKeysRecord] = value; + } + return acc; + }, + {}, + ); + + // Check if plural + if (transformation === "plural" && generaltranslation.branches) { + const newBranches: Record = {}; + Object.entries(generaltranslation.branches).forEach( + ([key, value]: [string, TaggedChildren]) => { + newBranches[key] = writeChildrenAsObjects(value); + }, + ); + newGTProp = { ...newGTProp, b: newBranches, t: "p" }; + } + if (transformation === "branch" && generaltranslation.branches) { + const newBranches: Record = {}; + Object.entries(generaltranslation.branches).forEach( + ([key, value]: [string, TaggedChildren]) => { + newBranches[key] = writeChildrenAsObjects(value); + }, + ); + newGTProp = { ...newGTProp, b: newBranches, t: "b" }; + } + + minifiedElement.d = Object.keys(newGTProp).length ? newGTProp : undefined; + } + if (props.children) { + minifiedElement.c = writeChildrenAsObjects(props.children); + } + return minifiedElement; +}; + +const handleSingleChild = (child: TaggedChild): JsxChild => { + if (isValidTaggedElement(child)) { + return handleSingleChildElement(child); + } + if (typeof child === "number") return child.toString(); + return child as JsxChild; +}; + +/** + * Transforms children elements into objects, processing each child recursively if needed. + * TaggedChildren are transformed into JsxChildren + * @param {Children} children - The children to process. + * @returns {object} The processed children as objects. + */ +export default function writeChildrenAsObjects( + children: TaggedChildren, +): JsxChildren { + const result = Array.isArray(children) + ? children.map(handleSingleChild) + : handleSingleChild(children); + return result; +} diff --git a/packages/react-core/src/branches/plurals/getPluralBranch.ts b/packages/react-core/src/utils/plurals/getPluralBranch.ts similarity index 90% rename from packages/react-core/src/branches/plurals/getPluralBranch.ts rename to packages/react-core/src/utils/plurals/getPluralBranch.ts index a0b52ee96..288af40c5 100644 --- a/packages/react-core/src/branches/plurals/getPluralBranch.ts +++ b/packages/react-core/src/utils/plurals/getPluralBranch.ts @@ -10,10 +10,10 @@ import { * @param {any} branches - The object containing possible branches. * @returns {any} The determined branch. */ -export default function getPluralBranch( +export default function getPluralBranch( n: number, locales: string[], - branches: Record + branches: Record ) { let branchName = ''; let branch = null; diff --git a/packages/react-core/src/utils/rendering/getGTTag.ts b/packages/react-core/src/utils/rendering/getGTTag.ts new file mode 100644 index 000000000..5c87034ff --- /dev/null +++ b/packages/react-core/src/utils/rendering/getGTTag.ts @@ -0,0 +1,8 @@ +import { TaggedElement, GTTag } from "../types"; + +export default function getGTTag(child: TaggedElement): GTTag | null { + if (child && child.props && child.props["data-_gt"]) { + return child.props["data-_gt"]; + } + return null; +} diff --git a/packages/react-core/src/rendering/renderDefaultChildren.tsx b/packages/react-core/src/utils/rendering/renderDefaultChildren.tsx similarity index 74% rename from packages/react-core/src/rendering/renderDefaultChildren.tsx rename to packages/react-core/src/utils/rendering/renderDefaultChildren.tsx index 471d6e093..85fead1b0 100644 --- a/packages/react-core/src/rendering/renderDefaultChildren.tsx +++ b/packages/react-core/src/utils/rendering/renderDefaultChildren.tsx @@ -1,16 +1,16 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode } from "react"; import getVariableProps, { isVariableElementProps, -} from '../variables/_getVariableProps'; -import { libraryDefaultLocale } from 'generaltranslation/internal'; -import getPluralBranch from '../branches/plurals/getPluralBranch'; +} from "../variables/_getVariableProps"; +import { libraryDefaultLocale } from "generaltranslation/internal"; import { RenderVariable, TaggedChild, TaggedChildren, TaggedElement, -} from '../types-dir/types'; -import getGTTag from './getGTTag'; +} from "../types"; +import getGTTag from "./getGTTag"; +import getPluralBranch from "../plurals/getPluralBranch"; export default function renderDefaultChildren({ children, @@ -38,9 +38,9 @@ export default function renderDefaultChildren({ } // Plural - if (generaltranslation?.transformation === 'plural') { + if (generaltranslation?.transformation === "plural") { const branches = generaltranslation.branches || {}; - if (typeof child.props.n !== 'number') { + if (typeof child.props.n !== "number") { return child.props.children != null ? handleChildren(child.props.children) : null; @@ -48,30 +48,30 @@ export default function renderDefaultChildren({ const resolvedBranch = getPluralBranch( child.props.n, [defaultLocale], - branches + branches, ); return handleChildren( (resolvedBranch !== null ? resolvedBranch - : child.props.children) as TaggedChildren + : child.props.children) as TaggedChildren, ); } // Branch - if (generaltranslation?.transformation === 'branch') { + if (generaltranslation?.transformation === "branch") { const { children, branch } = child.props; const branches = generaltranslation.branches || {}; const branchKey = - branch == null || branch === '' ? undefined : branch.toString(); + branch == null || branch === "" ? undefined : branch.toString(); return handleChildren( branchKey && branches[branchKey] !== undefined ? branches[branchKey] - : children + : children, ); } // Fragment - if (generaltranslation?.transformation === 'fragment') { + if (generaltranslation?.transformation === "fragment") { return React.createElement(React.Fragment, { key: child.props.key, children: handleChildren(child.props.children), @@ -82,11 +82,11 @@ export default function renderDefaultChildren({ if (child.props.children) { return React.cloneElement(child, { ...child.props, - 'data-_gt': undefined, + "data-_gt": undefined, children: handleChildren(child.props.children) as TaggedChildren, }); } - return React.cloneElement(child, { ...child.props, 'data-_gt': undefined }); + return React.cloneElement(child, { ...child.props, "data-_gt": undefined }); }; const handleSingleChild = (child: TaggedChild): ReactNode => { diff --git a/packages/react-core/src/rendering/renderTranslatedChildren.tsx b/packages/react-core/src/utils/rendering/renderTranslatedChildren.tsx similarity index 83% rename from packages/react-core/src/rendering/renderTranslatedChildren.tsx rename to packages/react-core/src/utils/rendering/renderTranslatedChildren.tsx index 9622731b2..aa7014e95 100644 --- a/packages/react-core/src/rendering/renderTranslatedChildren.tsx +++ b/packages/react-core/src/utils/rendering/renderTranslatedChildren.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode } from "react"; import { TaggedChildren, TaggedElement, @@ -6,18 +6,18 @@ import { RenderVariable, TranslatedElement, VariableProps, -} from '../types-dir/types'; +} from "../types"; import getVariableProps, { isVariableElementProps, -} from '../variables/_getVariableProps'; -import renderDefaultChildren from './renderDefaultChildren'; -import { isVariable, libraryDefaultLocale } from 'generaltranslation/internal'; -import getPluralBranch from '../branches/plurals/getPluralBranch'; +} from "../variables/_getVariableProps"; +import renderDefaultChildren from "./renderDefaultChildren"; +import { isVariable, libraryDefaultLocale } from "generaltranslation/internal"; +import getPluralBranch from "../plurals/getPluralBranch"; import { HTML_CONTENT_PROPS, HtmlContentPropValuesRecord, -} from '@generaltranslation/format/types'; -import getGTTag from './getGTTag'; +} from "@generaltranslation/format/types"; +import getGTTag from "./getGTTag"; function renderTranslatedElement({ sourceElement, @@ -32,7 +32,7 @@ function renderTranslatedElement({ }): React.ReactNode { // Get props and generaltranslation const { props: sourceProps } = sourceElement; - const sourceGT = sourceProps['data-_gt']; + const sourceGT = sourceProps["data-_gt"]; const transformation = sourceGT?.transformation; // Get translated props @@ -51,9 +51,9 @@ function renderTranslatedElement({ } // plural (choose a branch) - if (transformation === 'plural') { + if (transformation === "plural") { const n = sourceElement.props.n; - if (typeof n !== 'number') { + if (typeof n !== "number") { return renderDefaultChildren({ children: sourceElement, defaultLocale: locales[0], @@ -79,10 +79,10 @@ function renderTranslatedElement({ } // branch (choose a branch) - if (transformation === 'branch') { + if (transformation === "branch") { const { branch, children } = sourceProps; const branchKey = - branch == null || branch === '' ? undefined : branch.toString(); + branch == null || branch === "" ? undefined : branch.toString(); const sourceBranches = sourceGT.branches || {}; const targetBranches = targetElement.d?.b || {}; const sourceBranch = @@ -102,7 +102,7 @@ function renderTranslatedElement({ } // fragment (create a valid fragment) - if (transformation === 'fragment' && targetElement.c) { + if (transformation === "fragment" && targetElement.c) { return React.createElement(React.Fragment, { key: sourceElement.props.key, children: renderTranslatedChildren({ @@ -119,7 +119,7 @@ function renderTranslatedElement({ return React.cloneElement(sourceElement, { ...sourceProps, ...translatedProps, - 'data-_gt': undefined, + "data-_gt": undefined, children: renderTranslatedChildren({ source: sourceProps.children as TaggedChildren, target: targetElement.c, @@ -149,13 +149,13 @@ export default function renderTranslatedChildren({ renderVariable: RenderVariable; }): ReactNode { // Most straightforward case, return a valid React node - if ((target === null || typeof target === 'undefined') && source) + if ((target === null || typeof target === "undefined") && source) return renderDefaultChildren({ children: source, defaultLocale: locales[0], renderVariable, }); - if (typeof target === 'string') return target; + if (typeof target === "string") return target; // Convert source to an array in case target has multiple children where source only has one if (Array.isArray(target) && !Array.isArray(source) && source) @@ -164,12 +164,12 @@ export default function renderTranslatedChildren({ // Multiple children if (Array.isArray(source) && Array.isArray(target)) { // Track the variables - const variables: Record = {}; - const variablesOptions: Record = + const variables: Record = {}; + const variablesOptions: Record = {}; const variableInjectionTypes: Record< string, - VariableProps['injectionType'] + VariableProps["injectionType"] > = {}; // Extract source elements @@ -193,17 +193,17 @@ export default function renderTranslatedChildren({ } } return false; - } + }, ); // TODO: pre-index these to avoid O(n/2) lookups const findMatchingSourceElement = ( - targetElement: TranslatedElement + targetElement: TranslatedElement, ): TaggedElement | undefined => { return ( sourceElements.find((sourceChild): sourceChild is TaggedElement => { const generaltranslation = getGTTag(sourceChild); - if (typeof generaltranslation?.id !== 'undefined') { + if (typeof generaltranslation?.id !== "undefined") { const sourceId = generaltranslation.id; const targetId = targetElement.i; return sourceId === targetId; @@ -215,7 +215,7 @@ export default function renderTranslatedChildren({ // map target to source return target.map((targetChild, index) => { - if (typeof targetChild === 'string') + if (typeof targetChild === "string") return ( {targetChild} ); @@ -225,11 +225,11 @@ export default function renderTranslatedChildren({ return ( {renderVariable({ - variableType: targetChild.v || 'v', + variableType: targetChild.v || "v", variableValue: variables[targetChild.k], variableOptions: variablesOptions[targetChild.k], locales, - injectionType: variableInjectionTypes[targetChild.k] || 'manual', + injectionType: variableInjectionTypes[targetChild.k] || "manual", })} ); @@ -237,7 +237,7 @@ export default function renderTranslatedChildren({ // Render element (targetChild is a TranslatedElement) const matchingSourceElement = findMatchingSourceElement( - targetChild as TranslatedElement + targetChild as TranslatedElement, ); if (!matchingSourceElement) return null; return ( @@ -254,13 +254,13 @@ export default function renderTranslatedChildren({ } // Single child - if (target && typeof target === 'object' && !Array.isArray(target)) { - const targetType: 'variable' | 'element' = isVariable(target) - ? 'variable' - : 'element'; + if (target && typeof target === "object" && !Array.isArray(target)) { + const targetType: "variable" | "element" = isVariable(target) + ? "variable" + : "element"; if (React.isValidElement(source)) { - if (targetType === 'element') { + if (targetType === "element") { return renderTranslatedElement({ sourceElement: source, targetElement: target as TranslatedElement, diff --git a/packages/react-core/src/utils/rendering/renderVariable.tsx b/packages/react-core/src/utils/rendering/renderVariable.tsx new file mode 100644 index 000000000..31f074bf1 --- /dev/null +++ b/packages/react-core/src/utils/rendering/renderVariable.tsx @@ -0,0 +1,106 @@ +import { + GtInternalCurrency, + Currency as GtExternalCurrency, +} from "../../components/variables/Currency"; +import { + GtInternalDateTime, + DateTime as GtExternalDateTime, +} from "../../components/variables/DateTime"; +import { + GtInternalNum, + Num as GtExternalNum, +} from "../../components/variables/Num"; +import { + GtInternalRelativeTime, + RelativeTime as GtExternalRelativeTime, +} from "../../components/variables/RelativeTime"; +import { + computeVar, + Var as GtExternalVar, +} from "../../components/variables/Var"; +import type { RelativeTimeFormatOptions, RenderVariable } from "../types"; +import { ReactNode } from "react"; + +// ===== Renderer ===== // + +const renderVariable: RenderVariable = ({ + variableType, + variableValue, + variableOptions, + injectionType, +}) => { + if (variableType === "n") { + const Num = injectionType === "automatic" ? GtExternalNum : GtInternalNum; + const numOptions = variableOptions as Intl.NumberFormatOptions | undefined; + return ( + + {variableValue as number | string | null | undefined} + + ); + } + if (variableType === "d") { + const DateTime = + injectionType === "automatic" ? GtExternalDateTime : GtInternalDateTime; + const dateTimeOptions = variableOptions as + | Intl.DateTimeFormatOptions + | undefined; + return ( + + {variableValue as Date | null | undefined} + + ); + } + if (variableType === "c") { + const Currency = + injectionType === "automatic" ? GtExternalCurrency : GtInternalCurrency; + const currencyOptions = variableOptions as + | Intl.NumberFormatOptions + | undefined; + return ( + + {variableValue as number | string | null | undefined} + + ); + } + if (variableType === "rt") { + const RelativeTime = + injectionType === "automatic" + ? GtExternalRelativeTime + : GtInternalRelativeTime; + const relativeTimeOptions = variableOptions as + | RelativeTimeFormatOptions + | undefined; + if (typeof variableValue === "number" && relativeTimeOptions?.unit) { + return ( + + ); + } + const dateValue = + variableValue instanceof Date + ? variableValue + : typeof variableValue === "string" || typeof variableValue === "number" + ? new Date(variableValue) + : undefined; + return ( + + ); + } + return injectionType === "automatic" ? ( + computeVar({ children: variableValue as ReactNode }) + ) : ( + {variableValue as ReactNode} + ); +}; + +// ===== Exports ===== // + +export { renderVariable }; diff --git a/packages/react-core/src/utils/types.ts b/packages/react-core/src/utils/types.ts index faf477dfe..d869a1aa2 100644 --- a/packages/react-core/src/utils/types.ts +++ b/packages/react-core/src/utils/types.ts @@ -1,8 +1,84 @@ -export type AuthFromEnvParams = { - projectId?: string; - devApiKey?: string; +import type { + Variable, + GTProp, + VariableType, +} from '@generaltranslation/format/types'; +import type { + VariableTransformationSuffix, + TransformationPrefix, + InjectionType, +} from 'generaltranslation/types'; +import React from 'react'; + +/** + * TaggedElement is a React element with a GTProp property. + */ +export type GTTag = { + id: number; + injectionType: InjectionType; + transformation?: TransformationPrefix; + branches?: Record; + variableType?: VariableTransformationSuffix; }; -export type AuthFromEnvReturn = { - projectId: string; - devApiKey?: string; +export type TaggedElementProps = Record & { + 'data-_gt': GTTag; + children?: TaggedChildren; + branch?: string | number | boolean; + n?: number; + key?: React.Key; }; +export type TaggedElement = React.ReactElement; +export type TaggedChild = + | Exclude + | TaggedElement; +export type TaggedChildren = TaggedChild[] | TaggedChild; + +// ----- TRANSLATION ----- // + +/** + * Translated content types + * TODO: move these types to JsxElement etc from generaltranslation/types + * remember to omit the t property (tag name) from the translated element + */ +export type TranslatedElement = { + i?: number; + d?: GTProp; + c?: TranslatedChildren; +}; +export type TranslatedChild = TranslatedElement | string | Variable; +export type TranslatedChildren = TranslatedChild | TranslatedChild[]; + +export type RelativeTimeFormatOptions = Intl.RelativeTimeFormatOptions & { + unit?: Intl.RelativeTimeFormatUnit; + baseDate?: Date; +}; +export type VariableProps = { + /** Whether the variable was automatically injected by the compiler */ + variableType: VariableType; + variableValue: unknown; + variableOptions: + | Intl.NumberFormatOptions + | Intl.DateTimeFormatOptions + | RelativeTimeFormatOptions; + variableName: string; + injectionType: InjectionType; +}; + +export type RenderVariable = ({ + variableType, + variableValue, + variableOptions, + locales, + injectionType, +}: Omit & { + locales: string[]; +}) => React.ReactNode; + +export type _Message = { + message: string; + $id?: string; + $context?: string; + $maxChars?: number; + $_hash?: string; +}; +export type _Messages = _Message[]; diff --git a/packages/react-core/src/utils/utils.tsx b/packages/react-core/src/utils/utils.tsx index 30bdceb9a..63dc8e7e6 100644 --- a/packages/react-core/src/utils/utils.tsx +++ b/packages/react-core/src/utils/utils.tsx @@ -1,15 +1,6 @@ -import React from 'react'; -import { TaggedElement, TaggedElementProps } from '../types-dir/types'; -import { AuthFromEnvParams, AuthFromEnvReturn } from './types'; -import { createInternalUsageError } from '../errors-dir/internalErrors'; +import React from "react"; +import { TaggedElement, TaggedElementProps } from "./types"; export function isValidTaggedElement(target: unknown): target is TaggedElement { return React.isValidElement(target); } - -/** - * @deprecated - this function is to always be overridden by a wrapper react package - */ -export function readAuthFromEnv(_params: AuthFromEnvParams): AuthFromEnvReturn { - throw createInternalUsageError('readAuthFromEnv'); -} diff --git a/packages/react-core/src/utils/variables/_getVariableProps.ts b/packages/react-core/src/utils/variables/_getVariableProps.ts new file mode 100644 index 000000000..d3d6c06bd --- /dev/null +++ b/packages/react-core/src/utils/variables/_getVariableProps.ts @@ -0,0 +1,66 @@ +import { VariableTransformationSuffix } from 'generaltranslation/types'; +import { GTTag, VariableProps } from '../types'; +import getVariableName from './getVariableName'; +import { minifyVariableType } from 'generaltranslation/internal'; + +type VariableElementProps = { + 'data-_gt': GTTag & { + transformation: 'variable'; + }; + [key: string]: unknown; +}; + +export function isVariableElementProps( + props: unknown +): props is VariableElementProps { + return ( + typeof props === 'object' && + !!props && + 'data-_gt' in props && + typeof props['data-_gt'] === 'object' && + !!props['data-_gt'] && + 'transformation' in props['data-_gt'] && + props['data-_gt']?.transformation === 'variable' + ); +} + +export default function getVariableProps( + props: VariableElementProps +): VariableProps { + const variableType: VariableTransformationSuffix = + props['data-_gt']?.variableType || 'variable'; + + const result: VariableProps = { + variableName: getVariableName(props, variableType), + variableType: minifyVariableType(variableType), + injectionType: props['data-_gt']?.injectionType || 'manual', + variableValue: (() => { + if (typeof props.value !== 'undefined') return props.value; + if (typeof props.date !== 'undefined') return props.date; + if (typeof props['data-_gt-unformatted-value'] !== 'undefined') + return props['data-_gt-unformatted-value']; + if (typeof props.children !== 'undefined') return props.children; + return undefined; + })(), + variableOptions: (() => { + const variableOptions = { + ...(typeof props.currency !== 'undefined' && { + currency: props.currency, + }), + ...(typeof props.unit !== 'undefined' && { + unit: props.unit, + }), + ...(typeof props.baseDate !== 'undefined' && { + baseDate: props.baseDate, + }), + ...(typeof props.options !== 'undefined' && props.options), + }; + if (Object.keys(variableOptions).length) return variableOptions; + if (typeof props['data-_gt-variable-options'] === 'string') + return JSON.parse(props['data-_gt-variable-options']); + return props['data-_gt-variable-options'] || undefined; + })(), + }; + + return result; +} diff --git a/packages/react-core/src/utils/variables/getVariableName.ts b/packages/react-core/src/utils/variables/getVariableName.ts new file mode 100644 index 000000000..3ee96f68a --- /dev/null +++ b/packages/react-core/src/utils/variables/getVariableName.ts @@ -0,0 +1,19 @@ +const defaultVariableNames = { + variable: 'value', + number: 'n', + datetime: 'date', + currency: 'cost', + 'relative-time': 'time', +} as const; + +export const baseVariablePrefix = '_gt_'; + +export default function getVariableName( + props: Record = {}, + variableType: keyof typeof defaultVariableNames +): string { + if (typeof props.name === 'string') return props.name; + const baseVariableName = defaultVariableNames[variableType] || 'value'; + const gtTag = props['data-_gt'] as { id?: number } | undefined; + return `${baseVariablePrefix}${baseVariableName}_${gtTag?.id}`; +} diff --git a/packages/react-core/tsdown.config.mts b/packages/react-core/tsdown.config.mts index bddee668f..72b50ac6e 100644 --- a/packages/react-core/tsdown.config.mts +++ b/packages/react-core/tsdown.config.mts @@ -16,8 +16,35 @@ const deps = { ], }; -const entries = ['src/index.ts', 'src/internal.ts', 'src/errors.ts']; +const contextDeps = { + neverBundle: [ + ...deps.neverBundle, + /^gt-i18n$/, + /^gt-i18n\//, + ], + alwaysBundle: [ + /^@generaltranslation\/format\//, + /^generaltranslation\//, + ], +}; + +const bundledEntries = [ + 'src/index.ts', + 'src/internal.ts', + 'src/errors.ts', +]; export default defineConfig( - createTsdownMinifiedDualFormatConfig({ entries, deps }) + [ + ...createTsdownMinifiedDualFormatConfig({ + entries: bundledEntries, + deps, + }), + ...createTsdownMinifiedDualFormatConfig({ + entries: ['src/context.ts'], + deps: contextDeps, + clean: false, + typeEntry: false, + }), + ] ); diff --git a/packages/react-native/src/provider/hooks/locale/useDetermineLocale.ts b/packages/react-native/src/provider/hooks/locale/useDetermineLocale.ts index 75931f19c..43de1dfe7 100644 --- a/packages/react-native/src/provider/hooks/locale/useDetermineLocale.ts +++ b/packages/react-native/src/provider/hooks/locale/useDetermineLocale.ts @@ -1,25 +1,25 @@ -import { useEffect, useState } from 'react'; -import type { Dispatch, SetStateAction } from 'react'; +import { useEffect, useState } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { determineLocale, resolveAliasLocale, -} from '@generaltranslation/format'; -import { libraryDefaultLocale } from 'generaltranslation/internal'; -import type { CustomMapping } from '@generaltranslation/format/types'; +} from "@generaltranslation/format"; +import { libraryDefaultLocale } from "generaltranslation/internal"; +import type { CustomMapping } from "@generaltranslation/format/types"; import type { UseDetermineLocaleParams, UseDetermineLocaleReturn, -} from '@generaltranslation/react-core/types'; -import { createUnsupportedLocaleWarning } from '@generaltranslation/react-core/errors'; -import { PACKAGE_NAME } from '../../../errors-dir/constants'; -import { getNativeLocales } from '../../../utils/getNativeLocales'; -import { nativeStoreGet, nativeStoreSet } from '../../../utils/nativeStore'; +} from "@generaltranslation/react-core/types"; +import { createUnsupportedLocaleWarning } from "@generaltranslation/react-core/errors"; +import { PACKAGE_NAME } from "../../../errors-dir/constants"; +import { getNativeLocales } from "../../../utils/getNativeLocales"; +import { nativeStoreGet, nativeStoreSet } from "../../../utils/nativeStore"; export function useDetermineLocale({ - locale: _locale = '', + locale: _locale = "", defaultLocale = libraryDefaultLocale, locales = [], - localeCookieName = 'generaltranslation.locale', + localeCookieName = "generaltranslation.locale", ssr = false, // not relevant customMapping, enableI18n, @@ -35,8 +35,8 @@ export function useDetermineLocale({ const result = resolveAliasLocale( ssr ? _locale - ? determineLocale(_locale, locales, customMapping) || '' - : '' + ? determineLocale(_locale, locales, customMapping) || "" + : "" : getNewLocale({ _locale, locale: _locale, @@ -46,7 +46,7 @@ export function useDetermineLocale({ customMapping, enableI18n, }), - customMapping + customMapping, ); return result; }; @@ -139,7 +139,7 @@ function getNewLocale({ let preferredLocales = getNativeLocales(); if (preferredLocales.length === 0) preferredLocales = [defaultLocale]; preferredLocales = preferredLocales.map((l) => - resolveAliasLocale(l, customMapping) + resolveAliasLocale(l, customMapping), ); // determine locale @@ -151,7 +151,7 @@ function getNewLocale({ ...preferredLocales, // then prefer browser locale ], locales, - customMapping + customMapping, ) || defaultLocale; if (newLocale) { newLocale = resolveAliasLocale(newLocale, customMapping); @@ -194,11 +194,15 @@ function createSetLocale({ determineLocale(newLocale, locales, customMapping) || locale || defaultLocale, - customMapping + customMapping, ); if (validatedLocale !== newLocale) { console.warn( - createUnsupportedLocaleWarning(validatedLocale, newLocale, PACKAGE_NAME) + createUnsupportedLocaleWarning( + validatedLocale, + newLocale, + PACKAGE_NAME, + ), ); } // persist locale diff --git a/packages/react/package.json b/packages/react/package.json index a427955c6..1f43e9878 100755 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -14,8 +14,8 @@ "./dist/macros.*" ], "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" }, "dependencies": { "@generaltranslation/format": "workspace:*", @@ -86,6 +86,39 @@ "default": "./dist/client.mjs" } }, + "./context": { + "react-server": { + "require": { + "types": "./dist/context.types.d.cts", + "default": "./dist/context.server.cjs" + }, + "import": { + "types": "./dist/context.types.d.mts", + "default": "./dist/context.server.mjs" + }, + "default": "./dist/context.server.mjs" + }, + "browser": { + "require": { + "types": "./dist/context.types.d.cts", + "default": "./dist/context.client.cjs" + }, + "import": { + "types": "./dist/context.types.d.mts", + "default": "./dist/context.client.mjs" + }, + "default": "./dist/context.client.mjs" + }, + "require": { + "types": "./dist/context.types.d.cts", + "default": "./dist/context.server.cjs" + }, + "import": { + "types": "./dist/context.types.d.mts", + "default": "./dist/context.server.mjs" + }, + "default": "./dist/context.server.mjs" + }, "./browser": { "require": { "types": "./dist/browser.d.cts", @@ -115,9 +148,15 @@ "client": [ "./dist/client.d.cts" ], + "context": [ + "./dist/context.types.d.cts" + ], "internal": [ "./dist/internal.d.cts" ], + "setup": [ + "./dist/setup.d.cts" + ], "browser": [ "./dist/browser.d.cts" ], @@ -135,9 +174,15 @@ "gt-react/client": [ "./dist/client" ], + "gt-react/context": [ + "./dist/context.types.d.cts" + ], "gt-react/internal": [ "./dist/internal" ], + "gt-react/setup": [ + "./dist/setup.d.cts" + ], "gt-react/browser": [ "./dist/browser.d.cts" ], diff --git a/packages/react/src/browser.ts b/packages/react/src/browser.ts index f664186cd..da5cce4b6 100644 --- a/packages/react/src/browser.ts +++ b/packages/react/src/browser.ts @@ -1,12 +1,12 @@ -import { BROWSER_ENVIRONMENT_ERROR } from './shared/messages'; -import { enforceBrowser } from './i18n-context/utils/enforceBrowser'; +import { BROWSER_ENVIRONMENT_ERROR } from './deprecated/shared/messages'; +import { enforceBrowser } from './deprecated/i18n-context/utils/enforceBrowser'; enforceBrowser(BROWSER_ENVIRONMENT_ERROR); // ----- Exports ----- // -export * from './i18n-context/setup/index'; -export * from './i18n-context/functions'; -export { LocaleSelector } from './i18n-context/ui/LocaleSelector'; +export * from './deprecated/i18n-context/setup/index'; +export * from './deprecated/i18n-context/functions/index'; +export { LocaleSelector } from './deprecated/i18n-context/ui/LocaleSelector'; export type { SyncResolutionFunction, SyncResolutionFunctionWithFallback, diff --git a/packages/react/src/client.ts b/packages/react/src/client.ts index 0385dd2fb..e348c4e4e 100644 --- a/packages/react/src/client.ts +++ b/packages/react/src/client.ts @@ -1,8 +1,8 @@ 'use client'; -import { ClientProvider } from './react-context/provider/ClientProvider'; -import { LocaleSelector } from './react-context/ui/LocaleSelector'; -import { RegionSelector } from './react-context/ui/RegionSelector'; +import { ClientProvider } from './deprecated/react-context/provider/ClientProvider'; +import { LocaleSelector } from './deprecated/react-context/ui/LocaleSelector'; +import { RegionSelector } from './deprecated/react-context/ui/RegionSelector'; import { T, diff --git a/packages/react/src/condition-store/BrowserConditionStore.ts b/packages/react/src/condition-store/BrowserConditionStore.ts new file mode 100644 index 000000000..8ae4c978c --- /dev/null +++ b/packages/react/src/condition-store/BrowserConditionStore.ts @@ -0,0 +1,82 @@ +import { + getReactI18nManager, + WritableConditionStore, + WritableConditionStoreParams, +} from "@generaltranslation/react-core/context"; +import { getCookieValue, setCookieValue } from "./cookies"; +import { readBrowserLocale } from "./readBrowserLocale"; +import { GetEnableI18n, GetLocale } from "../i18n-manager/types"; +import { + LocaleCandidates, + WritableConditionStoreInterface, +} from "gt-i18n/internal/types"; + +/** + * The configuration for the BrowserConditionStore + * @param {string[]} locales - The accepted locales + * @param {CustomMapping} [customMapping] - The custom mapping + * @param {GetLocale} getLocale - The function to get the locale + * @param {string} [localeCookieName=defaultLocaleCookieName] - The name of the locale cookie to check + */ +export type BrowserConditionStoreParams = WritableConditionStoreParams & { + localeCookieName: string; + enableI18nCookieName: string; + getLocale?: GetLocale; + getEnableI18n?: GetEnableI18n; +}; + +/** + * Condition store implementation for Browser. + */ +export class BrowserConditionStore implements WritableConditionStoreInterface { + private localeCookieName: string; + private enableI18nCookieName: string; + private customGetLocale?: GetLocale; + private customGetEnableI18n?: GetEnableI18n; + + constructor(config: BrowserConditionStoreParams) { + this.customGetLocale = config.getLocale; + this.customGetEnableI18n = config.getEnableI18n; + this.localeCookieName = config.localeCookieName; + this.enableI18nCookieName = config.enableI18nCookieName; + setCookieValue({ + cookieName: this.localeCookieName, + value: getReactI18nManager().determineLocale(config.locale), + }); + } + + getLocale = (): string => { + return getBrowserLocale(this.localeCookieName, this.customGetLocale); + }; + + setLocale = (locale: LocaleCandidates): void => { + setCookieValue({ + cookieName: this.localeCookieName, + value: getReactI18nManager().determineLocale(locale), + }); + window.location.reload(); + }; + + getEnableI18n = (): boolean => { + const cookieEnableI18n = getCookieValue({ + cookieName: this.enableI18nCookieName, + }); + if (cookieEnableI18n === undefined) { + return this.customGetEnableI18n?.() ?? true; + } + return cookieEnableI18n === "true"; + }; + + setEnableI18n = (enableI18n: boolean): void => { + setCookieValue({ + cookieName: this.enableI18nCookieName, + value: enableI18n ? "true" : "false", + }); + }; +} + +function getBrowserLocale(cookieName: string, getLocale?: GetLocale): string { + const candidates = readBrowserLocale(cookieName); + if (getLocale) candidates.push(getLocale()); + return getReactI18nManager().determineLocale(candidates); +} diff --git a/packages/react/src/condition-store/cookies.ts b/packages/react/src/condition-store/cookies.ts new file mode 100644 index 000000000..66e6856af --- /dev/null +++ b/packages/react/src/condition-store/cookies.ts @@ -0,0 +1,34 @@ +/** + * Minimally parses a cookie value for a given cookie name + * @param cookieName - The name of the cookie + * @returns The locale from the cookie or undefined if not found or invalid + */ +export function getCookieValue({ + cookieName, +}: { + cookieName: string; +}): string | undefined { + if (typeof document === 'undefined') return undefined; + const rawCookieValue = document.cookie + .split('; ') + .find((row) => row.startsWith(`${cookieName}=`)) + ?.split('=')[1]; + return rawCookieValue; +} + +/** + * Sets a cookie value for a given cookie name + * @param cookieName - The name of the cookie + * @param value - The value to set + * @returns The value that was set + */ +export function setCookieValue({ + cookieName, + value, +}: { + cookieName: string; + value: string; +}): void { + if (typeof document === 'undefined') return; + document.cookie = `${cookieName}=${value};path=/`; +} diff --git a/packages/react/src/condition-store/createBrowserConditionStore.ts b/packages/react/src/condition-store/createBrowserConditionStore.ts new file mode 100644 index 000000000..ba77b123e --- /dev/null +++ b/packages/react/src/condition-store/createBrowserConditionStore.ts @@ -0,0 +1,68 @@ +import { getReactI18nManager } from "@generaltranslation/react-core/context"; +import type { LocaleCandidates } from "gt-i18n/internal"; +import { + BrowserConditionStore, + BrowserConditionStoreParams, +} from "./BrowserConditionStore"; +import { readBrowserLocale } from "./readBrowserLocale"; +import { + defaultEnableI18nCookieName, + defaultLocaleCookieName, +} from "@generaltranslation/react-core/internal"; +import { getCookieValue } from "./cookies"; + +export type CreateBrowserConditionStoreParams = Omit< + BrowserConditionStoreParams, + "locale" | "enableI18n" | "localeCookieName" | "enableI18nCookieName" +> & { + locale?: LocaleCandidates; + enableI18n?: boolean; + localeCookieName?: string; + enableI18nCookieName?: string; +}; + +/** + * Factory to create a BrowserConditionStore + * + * This exists so we can keep the locale param as required in the constructor + * + * We want the values that we read from the cookies to override as this + * persists state across page reloads + */ +export function createBrowserConditionStore( + config: CreateBrowserConditionStoreParams, +): BrowserConditionStore { + return new BrowserConditionStore({ + ...config, + localeCookieName: defaultLocaleCookieName, + enableI18nCookieName: defaultEnableI18nCookieName, + locale: determineLocale(config), + enableI18n: determineEnableI18n(config), + }); +} + +function determineLocale({ + localeCookieName = defaultLocaleCookieName, + getLocale, + locale, +}: CreateBrowserConditionStoreParams): string { + const candidates = []; + candidates.push(...readBrowserLocale(localeCookieName)); + if (locale) candidates.push(...locale); + if (getLocale) candidates.push(getLocale()); + return getReactI18nManager().determineLocale(candidates); +} + +function determineEnableI18n({ + enableI18n, + enableI18nCookieName = defaultEnableI18nCookieName, + getEnableI18n, +}: CreateBrowserConditionStoreParams): boolean { + const cookieEnableI18n = getCookieValue({ + cookieName: enableI18nCookieName, + }); + if (cookieEnableI18n === undefined) { + return getEnableI18n?.() ?? enableI18n ?? true; + } + return cookieEnableI18n === "true"; +} diff --git a/packages/react/src/condition-store/readBrowserLocale.ts b/packages/react/src/condition-store/readBrowserLocale.ts new file mode 100644 index 000000000..eb054ec3c --- /dev/null +++ b/packages/react/src/condition-store/readBrowserLocale.ts @@ -0,0 +1,17 @@ +import { getCookieValue } from "./cookies"; + +export function readBrowserLocale(localeCookieName: string): string[] { + const candidates = []; + // (1) Check cookie + + const cookieLocale = getCookieValue({ + cookieName: localeCookieName, + }); + if (cookieLocale) candidates.push(cookieLocale); + + // (2) Check navigator locales + const navigatorLocales = navigator?.languages || []; + candidates.push(...navigatorLocales); + + return candidates; +} diff --git a/packages/react/src/condition-store/singleton-operations.ts b/packages/react/src/condition-store/singleton-operations.ts new file mode 100644 index 000000000..290861c0a --- /dev/null +++ b/packages/react/src/condition-store/singleton-operations.ts @@ -0,0 +1,20 @@ +import { + createConditionStoreSingleton, + ReadonlyConditionStore, +} from "gt-i18n/internal"; +import { BrowserConditionStore } from "./BrowserConditionStore"; + +export const { + setConditionStore: setReadonlyConditionStore, + isConditionStoreInitialized: isReadonlyConditionStoreInitialized, +} = createConditionStoreSingleton( + "ReadonlyConditionStore is not initialized.", +); + +export const { + getConditionStore: getBrowserConditionStore, + setConditionStore: setBrowserConditionStore, + isConditionStoreInitialized: isBrowserConditionStoreInitialized, +} = createConditionStoreSingleton( + "BrowserConditionStore is not initialized.", +); diff --git a/packages/react/src/context.client.ts b/packages/react/src/context.client.ts new file mode 100644 index 000000000..bd7151670 --- /dev/null +++ b/packages/react/src/context.client.ts @@ -0,0 +1,45 @@ +"use client"; + +export { CSRGTProvider as GTProvider } from "./provider/CSRGTProvider"; +export { initializeGTSPA } from "./setup/initializeGTSPA"; + +export { + // ===== Components ===== // + Branch, + Plural, + Derive, + LocaleSelector, + T, + Currency, + DateTime, + RelativeTime, + Var, + Num, + // ===== Hooks ===== // + useLocale, + useSetLocale, + useCustomMapping, + useDefaultLocale, + useEnableI18n, + useSetEnableI18n, + useLocales, + useLocaleSelector, + useFormatLocales, + useGT, + useMessages, + useTranslations, + // ===== Functions ===== // + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, + getTranslationsSnapshot, + getReactI18nManager, + setReactI18nManager, + // ===== Setup ===== // + internalInitializeGTSSR as initializeGT, +} from "@generaltranslation/react-core/context"; diff --git a/packages/react/src/context.server.ts b/packages/react/src/context.server.ts new file mode 100644 index 000000000..7afcce0c4 --- /dev/null +++ b/packages/react/src/context.server.ts @@ -0,0 +1,46 @@ +"server-only"; + +export { SSRGTProvider as GTProvider } from "./provider/SSRGTProvider"; +export { initializeGTSPA } from "./setup/initializeGTSPA"; + +export { + // ===== Components ===== // + Branch, + Plural, + Derive, + LocaleSelector, + T, + Currency, + DateTime, + RelativeTime, + Var, + Num, + // ===== Hooks ===== // + useLocale, + useSetLocale, + useCustomMapping, + useDefaultLocale, + useEnableI18n, + useSetEnableI18n, + useLocales, + useLocaleSelector, + useFormatLocales, + useGT, + useMessages, + useTranslations, + // ===== Functions ===== // + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, + getTranslationsSnapshot, + getReactI18nManager, + setReactI18nManager, + // ===== Setup ===== // + internalInitializeGTSSR as initializeGT, + internalInitializeGTSPA, +} from "@generaltranslation/react-core/context"; diff --git a/packages/react/src/context.types.ts b/packages/react/src/context.types.ts new file mode 100644 index 000000000..a35452680 --- /dev/null +++ b/packages/react/src/context.types.ts @@ -0,0 +1,56 @@ +import { CSRGTProvider } from "./provider/CSRGTProvider"; + +/** + * Wrap GTProvider around the content that you want to translate + */ +export const GTProvider: typeof CSRGTProvider = () => { + throw new Error( + "gt-react: You have imported a function from the dedicated types entrypoint. If you are seeing this error, it means something has gone wrong.", + ); +}; + +export { initializeGTSPA } from "./setup/initializeGTSPA"; + +/** + * TODO: throw error if any of these functions are called + */ +export { + // ===== Components ===== // + Branch, + Plural, + Derive, + LocaleSelector, + T, + Currency, + DateTime, + RelativeTime, + Var, + Num, + // ===== Hooks ===== // + useLocale, + useSetLocale, + useCustomMapping, + useDefaultLocale, + useEnableI18n, + useSetEnableI18n, + useLocales, + useLocaleSelector, + useFormatLocales, + useGT, + useMessages, + useTranslations, + // ===== Functions ===== // + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, + getTranslationsSnapshot, + // ===== Setup ===== // + internalInitializeGTSSR as initializeGT, + getReactI18nManager, + setReactI18nManager, +} from "@generaltranslation/react-core/context"; diff --git a/packages/react/src/__tests__/react-package.test.ts b/packages/react/src/deprecated/__tests__/react-package.test.ts similarity index 79% rename from packages/react/src/__tests__/react-package.test.ts rename to packages/react/src/deprecated/__tests__/react-package.test.ts index 3103febab..45f8b9525 100644 --- a/packages/react/src/__tests__/react-package.test.ts +++ b/packages/react/src/deprecated/__tests__/react-package.test.ts @@ -5,12 +5,20 @@ import { fileURLToPath } from 'node:url'; import { beforeAll, describe, expect, it } from 'vitest'; -const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const packageRoot = dirname( + dirname(dirname(dirname(fileURLToPath(import.meta.url)))) +); const runtimeArtifactNames = [ 'browser.cjs', 'browser.mjs', 'client.cjs', 'client.mjs', + 'context.client.cjs', + 'context.client.mjs', + 'context.server.cjs', + 'context.server.mjs', + 'context.types.cjs', + 'context.types.mjs', 'index.cjs', 'index.mjs', 'internal.cjs', @@ -44,6 +52,14 @@ function node(args: string[]): void { execFileSync(process.execPath, args, { cwd: packageRoot, stdio: 'pipe' }); } +function isAllowedExternalizedSubpath(file: string, specifier: string): boolean { + return ( + file.startsWith('context.') && + (specifier.startsWith('@generaltranslation/react-core/') || + specifier.startsWith('gt-i18n/')) + ); +} + describe('gt-react package exports', () => { beforeAll(() => { if (hasBuiltArtifacts()) return; @@ -57,11 +73,14 @@ describe('gt-react package exports', () => { const assert = require('node:assert/strict'); const react = require('gt-react'); const client = require('gt-react/client'); + const context = require('gt-react/context'); const internal = require('gt-react/internal'); assert.equal(typeof react.GTProvider, 'function'); assert.equal(typeof react.T, 'function'); assert.equal(typeof client.ClientProvider, 'function'); + assert.equal(typeof context.GTProvider, 'function'); + assert.equal(typeof context.T, 'function'); assert.equal(typeof internal.renderDefaultChildren, 'function'); `, ]); @@ -75,11 +94,14 @@ describe('gt-react package exports', () => { import assert from 'node:assert/strict'; import { GTProvider, T } from 'gt-react'; import { ClientProvider } from 'gt-react/client'; + import { GTProvider as ContextProvider, T as ContextT } from 'gt-react/context'; import { renderDefaultChildren } from 'gt-react/internal'; assert.equal(typeof GTProvider, 'function'); assert.equal(typeof T, 'function'); assert.equal(typeof ClientProvider, 'function'); + assert.equal(typeof ContextProvider, 'function'); + assert.equal(typeof ContextT, 'function'); assert.equal(typeof renderDefaultChildren, 'function'); `, ]); @@ -124,6 +146,10 @@ describe('gt-react package exports', () => { return [...code.matchAll(workspaceSubpathImportPattern)].map( (match) => `${file}: ${match[1]}` ); + }) + .filter((externalizedSubpath) => { + const [file, specifier] = externalizedSubpath.split(': '); + return !isAllowedExternalizedSubpath(file, specifier); }); expect(externalizedSubpaths).toEqual([]); diff --git a/packages/react/src/compatability/vite-env.d.ts b/packages/react/src/deprecated/compatability/vite-env.d.ts similarity index 100% rename from packages/react/src/compatability/vite-env.d.ts rename to packages/react/src/deprecated/compatability/vite-env.d.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/BrowserConditionStore.ts similarity index 62% rename from packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/BrowserConditionStore.ts index b92ac07d6..1103867bc 100644 --- a/packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts +++ b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/BrowserConditionStore.ts @@ -1,10 +1,13 @@ -import { defaultLocaleCookieName } from '@generaltranslation/react-core/internal'; -import { setCookieValue } from '../../shared/cookies'; +import { + defaultEnableI18nCookieName, + defaultLocaleCookieName, +} from '@generaltranslation/react-core/internal'; +import { getCookieValue, setCookieValue } from '../../shared/cookies'; import { determineLocale } from './utils/determineLocale'; import type { GetLocale } from './utils/types'; import type { - ConditionStoreConfig, - WritableConditionStore, + LocaleResolverConfig, + WritableConditionStoreInterface, } from 'gt-i18n/internal/types'; /** @@ -14,30 +17,33 @@ import type { * @param {GetLocale} getLocale - The function to get the locale * @param {string} [localeCookieName=defaultLocaleCookieName] - The name of the locale cookie to check */ -type BrowserConditionStoreConstructorParams = ConditionStoreConfig & { +type BrowserConditionStoreConstructorParams = LocaleResolverConfig & { getLocale?: GetLocale; localeCookieName?: string; + enableI18nCookieName?: string; }; /** * Condition store implementation for Browser. */ -export class BrowserConditionStore implements WritableConditionStore { - private readonly localeConfig: ConditionStoreConfig; +export class BrowserConditionStore implements WritableConditionStoreInterface { + private readonly localeConfig: LocaleResolverConfig; private readonly customGetLocale?: GetLocale; private readonly localeCookieName: string; - + private readonly enableI18nCookieName: string; /** * @param {BrowserConditionStoreConstructorParams} params - The configuration for the BrowserConditionStore */ constructor({ getLocale, localeCookieName = defaultLocaleCookieName, + enableI18nCookieName = defaultEnableI18nCookieName, ...localeConfig }: BrowserConditionStoreConstructorParams = {}) { this.localeConfig = localeConfig; this.customGetLocale = getLocale; this.localeCookieName = localeCookieName; + this.enableI18nCookieName = enableI18nCookieName; } /** @@ -54,4 +60,13 @@ export class BrowserConditionStore implements WritableConditionStore { setLocale(locale: string): void { setCookieValue(this.localeCookieName, locale); } + + getEnableI18n(): boolean { + const cookieEnableI18n = getCookieValue(this.enableI18nCookieName); + return cookieEnableI18n === 'true' || cookieEnableI18n === undefined; + } + + setEnableI18n(enableI18n: boolean): void { + setCookieValue(this.enableI18nCookieName, enableI18n ? 'true' : 'false'); + } } diff --git a/packages/react/src/i18n-context/browser-i18n-manager/BrowserI18nManager.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/BrowserI18nManager.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/BrowserI18nManager.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/BrowserI18nManager.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/LocalStorageTranslationCache.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/LocalStorageTranslationCache.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/LocalStorageTranslationCache.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/LocalStorageTranslationCache.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/__tests__/BrowserI18nManager.test.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/__tests__/BrowserI18nManager.test.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/__tests__/BrowserI18nManager.test.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/__tests__/BrowserI18nManager.test.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/__tests__/LocalStorageTranslationCache.test.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/__tests__/LocalStorageTranslationCache.test.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/__tests__/LocalStorageTranslationCache.test.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/__tests__/LocalStorageTranslationCache.test.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/singleton-operations.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/singleton-operations.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/singleton-operations.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/singleton-operations.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/utils/__tests__/determineLocale.test.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/__tests__/determineLocale.test.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/utils/__tests__/determineLocale.test.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/__tests__/determineLocale.test.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/utils/constants.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/constants.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/utils/constants.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/constants.ts diff --git a/packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/determineLocale.ts similarity index 92% rename from packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/determineLocale.ts index 40483fe9a..da6e3cc00 100644 --- a/packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts +++ b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/determineLocale.ts @@ -6,7 +6,7 @@ import { determineSupportedLocale, resolveSupportedLocale, } from 'gt-i18n/internal'; -import type { ConditionStoreConfig } from 'gt-i18n/internal/types'; +import type { LocaleResolverConfig } from 'gt-i18n/internal/types'; /** * Determine a user's locale from their browser settings @@ -25,8 +25,8 @@ export function determineLocale({ }: { localeCookieName?: string; getLocale?: GetLocale; -} & ConditionStoreConfig): string { - const localeConfig: ConditionStoreConfig = { +} & LocaleResolverConfig): string { + const localeConfig: LocaleResolverConfig = { defaultLocale, locales, customMapping, diff --git a/packages/react/src/i18n-context/browser-i18n-manager/utils/types.ts b/packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/types.ts similarity index 100% rename from packages/react/src/i18n-context/browser-i18n-manager/utils/types.ts rename to packages/react/src/deprecated/i18n-context/browser-i18n-manager/utils/types.ts diff --git a/packages/react/src/i18n-context/functions/branching/GtInternalBranch.ts b/packages/react/src/deprecated/i18n-context/functions/branching/GtInternalBranch.ts similarity index 100% rename from packages/react/src/i18n-context/functions/branching/GtInternalBranch.ts rename to packages/react/src/deprecated/i18n-context/functions/branching/GtInternalBranch.ts diff --git a/packages/react/src/i18n-context/functions/branching/GtInternalPlural.ts b/packages/react/src/deprecated/i18n-context/functions/branching/GtInternalPlural.ts similarity index 100% rename from packages/react/src/i18n-context/functions/branching/GtInternalPlural.ts rename to packages/react/src/deprecated/i18n-context/functions/branching/GtInternalPlural.ts diff --git a/packages/react/src/i18n-context/functions/branching/__tests__/GtInternalBranch.test.ts b/packages/react/src/deprecated/i18n-context/functions/branching/__tests__/GtInternalBranch.test.ts similarity index 100% rename from packages/react/src/i18n-context/functions/branching/__tests__/GtInternalBranch.test.ts rename to packages/react/src/deprecated/i18n-context/functions/branching/__tests__/GtInternalBranch.test.ts diff --git a/packages/react/src/i18n-context/functions/branching/index.ts b/packages/react/src/deprecated/i18n-context/functions/branching/index.ts similarity index 100% rename from packages/react/src/i18n-context/functions/branching/index.ts rename to packages/react/src/deprecated/i18n-context/functions/branching/index.ts diff --git a/packages/react/src/i18n-context/functions/derivation/GtInternalDerive.ts b/packages/react/src/deprecated/i18n-context/functions/derivation/GtInternalDerive.ts similarity index 100% rename from packages/react/src/i18n-context/functions/derivation/GtInternalDerive.ts rename to packages/react/src/deprecated/i18n-context/functions/derivation/GtInternalDerive.ts diff --git a/packages/react/src/i18n-context/functions/derivation/index.ts b/packages/react/src/deprecated/i18n-context/functions/derivation/index.ts similarity index 100% rename from packages/react/src/i18n-context/functions/derivation/index.ts rename to packages/react/src/deprecated/i18n-context/functions/derivation/index.ts diff --git a/packages/react/src/i18n-context/functions/html-tag-operations.ts b/packages/react/src/deprecated/i18n-context/functions/html-tag-operations.ts similarity index 100% rename from packages/react/src/i18n-context/functions/html-tag-operations.ts rename to packages/react/src/deprecated/i18n-context/functions/html-tag-operations.ts diff --git a/packages/react/src/deprecated/i18n-context/functions/index.ts b/packages/react/src/deprecated/i18n-context/functions/index.ts new file mode 100644 index 000000000..ee193d09a --- /dev/null +++ b/packages/react/src/deprecated/i18n-context/functions/index.ts @@ -0,0 +1,7 @@ +export * from './branching/index'; +export * from './derivation/index'; +export * from './translation/index'; +export * from './variables/index'; +export * from './locale-operations'; +export * from './html-tag-operations'; +export { getVersionId } from 'gt-i18n/internal'; diff --git a/packages/react/src/i18n-context/functions/locale-operations.ts b/packages/react/src/deprecated/i18n-context/functions/locale-operations.ts similarity index 100% rename from packages/react/src/i18n-context/functions/locale-operations.ts rename to packages/react/src/deprecated/i18n-context/functions/locale-operations.ts diff --git a/packages/react/src/i18n-context/functions/translation/GtInternalRuntimeTranslateJsx.ts b/packages/react/src/deprecated/i18n-context/functions/translation/GtInternalRuntimeTranslateJsx.ts similarity index 100% rename from packages/react/src/i18n-context/functions/translation/GtInternalRuntimeTranslateJsx.ts rename to packages/react/src/deprecated/i18n-context/functions/translation/GtInternalRuntimeTranslateJsx.ts diff --git a/packages/react/src/i18n-context/functions/translation/GtInternalRuntimeTranslateString.ts b/packages/react/src/deprecated/i18n-context/functions/translation/GtInternalRuntimeTranslateString.ts similarity index 100% rename from packages/react/src/i18n-context/functions/translation/GtInternalRuntimeTranslateString.ts rename to packages/react/src/deprecated/i18n-context/functions/translation/GtInternalRuntimeTranslateString.ts diff --git a/packages/react/src/i18n-context/functions/translation/GtInternalTranslateJsx.tsx b/packages/react/src/deprecated/i18n-context/functions/translation/GtInternalTranslateJsx.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/translation/GtInternalTranslateJsx.tsx rename to packages/react/src/deprecated/i18n-context/functions/translation/GtInternalTranslateJsx.tsx diff --git a/packages/react/src/i18n-context/functions/translation/__tests__/GtInternalRuntimeTranslate.test.ts b/packages/react/src/deprecated/i18n-context/functions/translation/__tests__/GtInternalRuntimeTranslate.test.ts similarity index 100% rename from packages/react/src/i18n-context/functions/translation/__tests__/GtInternalRuntimeTranslate.test.ts rename to packages/react/src/deprecated/i18n-context/functions/translation/__tests__/GtInternalRuntimeTranslate.test.ts diff --git a/packages/react/src/i18n-context/functions/translation/index.ts b/packages/react/src/deprecated/i18n-context/functions/translation/index.ts similarity index 100% rename from packages/react/src/i18n-context/functions/translation/index.ts rename to packages/react/src/deprecated/i18n-context/functions/translation/index.ts diff --git a/packages/react/src/i18n-context/functions/translation/t.ts b/packages/react/src/deprecated/i18n-context/functions/translation/t.ts similarity index 94% rename from packages/react/src/i18n-context/functions/translation/t.ts rename to packages/react/src/deprecated/i18n-context/functions/translation/t.ts index 3b30df5f7..b099960a6 100644 --- a/packages/react/src/i18n-context/functions/translation/t.ts +++ b/packages/react/src/deprecated/i18n-context/functions/translation/t.ts @@ -35,12 +35,17 @@ export const t: StringOrTemplateSyncResolutionFunction = ( ...values: unknown[] ) => { // Trigger browser environment warning + // TODO: this is not envrionment agnostic, this function (or check) should be moved to gt-react if (typeof window === 'undefined') { console.warn( createTranslationFailedDueToBrowserEnvironmentWarning(messageOrStrings) ); } - + if (1 === 1) { + throw new Error( + 'TODO: this is not envrionment agnostic, this function (or check) should be moved to gt-react' + ); + } // t("Hello, {name}!", { name: "John" }) if (typeof messageOrStrings === 'string') { const options = values.at(0) as InlineTranslationOptions | undefined; diff --git a/packages/react/src/i18n-context/functions/translation/types.ts b/packages/react/src/deprecated/i18n-context/functions/translation/types.ts similarity index 100% rename from packages/react/src/i18n-context/functions/translation/types.ts rename to packages/react/src/deprecated/i18n-context/functions/translation/types.ts diff --git a/packages/react/src/i18n-context/functions/variables/GtInternalCurrency.tsx b/packages/react/src/deprecated/i18n-context/functions/variables/GtInternalCurrency.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/variables/GtInternalCurrency.tsx rename to packages/react/src/deprecated/i18n-context/functions/variables/GtInternalCurrency.tsx diff --git a/packages/react/src/i18n-context/functions/variables/GtInternalDateTime.tsx b/packages/react/src/deprecated/i18n-context/functions/variables/GtInternalDateTime.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/variables/GtInternalDateTime.tsx rename to packages/react/src/deprecated/i18n-context/functions/variables/GtInternalDateTime.tsx diff --git a/packages/react/src/i18n-context/functions/variables/GtInternalNum.tsx b/packages/react/src/deprecated/i18n-context/functions/variables/GtInternalNum.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/variables/GtInternalNum.tsx rename to packages/react/src/deprecated/i18n-context/functions/variables/GtInternalNum.tsx diff --git a/packages/react/src/i18n-context/functions/variables/GtInternalRelativeTime.tsx b/packages/react/src/deprecated/i18n-context/functions/variables/GtInternalRelativeTime.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/variables/GtInternalRelativeTime.tsx rename to packages/react/src/deprecated/i18n-context/functions/variables/GtInternalRelativeTime.tsx diff --git a/packages/react/src/i18n-context/functions/variables/GtInternalVar.tsx b/packages/react/src/deprecated/i18n-context/functions/variables/GtInternalVar.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/variables/GtInternalVar.tsx rename to packages/react/src/deprecated/i18n-context/functions/variables/GtInternalVar.tsx diff --git a/packages/react/src/i18n-context/functions/variables/index.ts b/packages/react/src/deprecated/i18n-context/functions/variables/index.ts similarity index 100% rename from packages/react/src/i18n-context/functions/variables/index.ts rename to packages/react/src/deprecated/i18n-context/functions/variables/index.ts diff --git a/packages/react/src/i18n-context/functions/variables/utils/renderVariable.tsx b/packages/react/src/deprecated/i18n-context/functions/variables/utils/renderVariable.tsx similarity index 100% rename from packages/react/src/i18n-context/functions/variables/utils/renderVariable.tsx rename to packages/react/src/deprecated/i18n-context/functions/variables/utils/renderVariable.tsx diff --git a/packages/react/src/i18n-context/setup/bootstrap.ts b/packages/react/src/deprecated/i18n-context/setup/bootstrap.ts similarity index 100% rename from packages/react/src/i18n-context/setup/bootstrap.ts rename to packages/react/src/deprecated/i18n-context/setup/bootstrap.ts diff --git a/packages/react/src/i18n-context/setup/index.ts b/packages/react/src/deprecated/i18n-context/setup/index.ts similarity index 100% rename from packages/react/src/i18n-context/setup/index.ts rename to packages/react/src/deprecated/i18n-context/setup/index.ts diff --git a/packages/react/src/i18n-context/setup/initializeGT.ts b/packages/react/src/deprecated/i18n-context/setup/initializeGT.ts similarity index 100% rename from packages/react/src/i18n-context/setup/initializeGT.ts rename to packages/react/src/deprecated/i18n-context/setup/initializeGT.ts diff --git a/packages/react/src/i18n-context/setup/types.ts b/packages/react/src/deprecated/i18n-context/setup/types.ts similarity index 100% rename from packages/react/src/i18n-context/setup/types.ts rename to packages/react/src/deprecated/i18n-context/setup/types.ts diff --git a/packages/react/src/i18n-context/ui/LocaleSelector.tsx b/packages/react/src/deprecated/i18n-context/ui/LocaleSelector.tsx similarity index 100% rename from packages/react/src/i18n-context/ui/LocaleSelector.tsx rename to packages/react/src/deprecated/i18n-context/ui/LocaleSelector.tsx diff --git a/packages/react/src/i18n-context/utils/enforceBrowser.ts b/packages/react/src/deprecated/i18n-context/utils/enforceBrowser.ts similarity index 100% rename from packages/react/src/i18n-context/utils/enforceBrowser.ts rename to packages/react/src/deprecated/i18n-context/utils/enforceBrowser.ts diff --git a/packages/react/src/react-context/provider/ClientProvider.tsx b/packages/react/src/deprecated/react-context/provider/ClientProvider.tsx similarity index 100% rename from packages/react/src/react-context/provider/ClientProvider.tsx rename to packages/react/src/deprecated/react-context/provider/ClientProvider.tsx diff --git a/packages/react/src/react-context/provider/GTProvider.tsx b/packages/react/src/deprecated/react-context/provider/GTProvider.tsx similarity index 100% rename from packages/react/src/react-context/provider/GTProvider.tsx rename to packages/react/src/deprecated/react-context/provider/GTProvider.tsx diff --git a/packages/react/src/react-context/provider/helpers/isSSREnabled.ts b/packages/react/src/deprecated/react-context/provider/helpers/isSSREnabled.ts similarity index 100% rename from packages/react/src/react-context/provider/helpers/isSSREnabled.ts rename to packages/react/src/deprecated/react-context/provider/helpers/isSSREnabled.ts diff --git a/packages/react/src/react-context/provider/hooks/locales/useDetermineLocale.ts b/packages/react/src/deprecated/react-context/provider/hooks/locales/useDetermineLocale.ts similarity index 100% rename from packages/react/src/react-context/provider/hooks/locales/useDetermineLocale.ts rename to packages/react/src/deprecated/react-context/provider/hooks/locales/useDetermineLocale.ts diff --git a/packages/react/src/react-context/provider/hooks/useEnableI18n.ts b/packages/react/src/deprecated/react-context/provider/hooks/useEnableI18n.ts similarity index 100% rename from packages/react/src/react-context/provider/hooks/useEnableI18n.ts rename to packages/react/src/deprecated/react-context/provider/hooks/useEnableI18n.ts diff --git a/packages/react/src/react-context/provider/hooks/useRegionState.ts b/packages/react/src/deprecated/react-context/provider/hooks/useRegionState.ts similarity index 100% rename from packages/react/src/react-context/provider/hooks/useRegionState.ts rename to packages/react/src/deprecated/react-context/provider/hooks/useRegionState.ts diff --git a/packages/react/src/react-context/types/config.ts b/packages/react/src/deprecated/react-context/types/config.ts similarity index 100% rename from packages/react/src/react-context/types/config.ts rename to packages/react/src/deprecated/react-context/types/config.ts diff --git a/packages/react/src/react-context/ui/LocaleSelector.tsx b/packages/react/src/deprecated/react-context/ui/LocaleSelector.tsx similarity index 100% rename from packages/react/src/react-context/ui/LocaleSelector.tsx rename to packages/react/src/deprecated/react-context/ui/LocaleSelector.tsx diff --git a/packages/react/src/react-context/ui/RegionSelector.tsx b/packages/react/src/deprecated/react-context/ui/RegionSelector.tsx similarity index 100% rename from packages/react/src/react-context/ui/RegionSelector.tsx rename to packages/react/src/deprecated/react-context/ui/RegionSelector.tsx diff --git a/packages/react/src/react-context/utils/readAuthFromEnv.tsx b/packages/react/src/deprecated/react-context/utils/readAuthFromEnv.tsx similarity index 100% rename from packages/react/src/react-context/utils/readAuthFromEnv.tsx rename to packages/react/src/deprecated/react-context/utils/readAuthFromEnv.tsx diff --git a/packages/react/src/shared/InternalLocaleSelector.tsx b/packages/react/src/deprecated/shared/InternalLocaleSelector.tsx similarity index 100% rename from packages/react/src/shared/InternalLocaleSelector.tsx rename to packages/react/src/deprecated/shared/InternalLocaleSelector.tsx diff --git a/packages/react/src/shared/cookies.ts b/packages/react/src/deprecated/shared/cookies.ts similarity index 100% rename from packages/react/src/shared/cookies.ts rename to packages/react/src/deprecated/shared/cookies.ts diff --git a/packages/react/src/shared/messages.ts b/packages/react/src/deprecated/shared/messages.ts similarity index 100% rename from packages/react/src/shared/messages.ts rename to packages/react/src/deprecated/shared/messages.ts diff --git a/packages/react/src/i18n-context/functions/index.ts b/packages/react/src/i18n-context/functions/index.ts deleted file mode 100644 index d5e439415..000000000 --- a/packages/react/src/i18n-context/functions/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './branching'; -export * from './derivation'; -export * from './translation'; -export * from './variables'; -export * from './locale-operations'; -export * from './html-tag-operations'; -export { getVersionId } from 'gt-i18n/internal'; diff --git a/packages/react/src/i18n-manager/BrowserI18nManager.ts b/packages/react/src/i18n-manager/BrowserI18nManager.ts new file mode 100644 index 000000000..d2dba41a6 --- /dev/null +++ b/packages/react/src/i18n-manager/BrowserI18nManager.ts @@ -0,0 +1,196 @@ +import type { + I18nManagerConstructorParams, + TranslationsLoader, +} from "gt-i18n/internal/types"; +import { I18nManager } from "gt-i18n/internal"; +import type { HtmlTagOptions } from "./types"; +import type { Translation } from "gt-i18n/types"; +import { DEFAULT_HTML_TAG_OPTIONS } from "./constants"; +import { LocalStorageTranslationCache } from "./LocalStorageTranslationCache"; +import { createDiagnosticMessage } from "generaltranslation/internal"; + +/** + * The configuration for the BrowserI18nManager + */ +export type BrowserI18nManagerParams = + I18nManagerConstructorParams & { + htmlTagOptions?: HtmlTagOptions; + }; + +/** + * I18nManager implementation for Browser. + */ +export class BrowserI18nManager extends I18nManager { + /** Customize browser-related behavior */ + private htmlTagOptions?: HtmlTagOptions; + + /** Per-locale localStorage translation caches (dev mode only) */ + private _localStorageCaches!: Record; + + /** Whether dev hot reload JSX (Suspense-based ) is active */ + private _devHotReloadJsx = false; + + constructor(config: BrowserI18nManagerParams) { + // Must be initialized before super() + const { htmlTagOptions, ...managerConfig } = config; + const localStorageCaches: Record = {}; + const devHotReloadEnabled = isDevHotReloadEnabled(config); + const loadTranslations = devHotReloadEnabled + ? wrapLoaderWithLocalStorage( + config.loadTranslations!, + config.projectId!, + localStorageCaches, + ) + : config.loadTranslations; + + // Initialize the I18nManager + super({ + ...managerConfig, + loadTranslations, + }); + + this._localStorageCaches = localStorageCaches; + this._devHotReloadJsx = devHotReloadEnabled; + + this.htmlTagOptions = { + ...DEFAULT_HTML_TAG_OPTIONS, + ...htmlTagOptions, + }; + + // For dev hot reload, we need to write the translations to the localStorage cache + if (devHotReloadEnabled) { + this.subscribe( + "translations-cache-miss", + ({ locale, hash, translation }) => { + const cache = localStorageCaches[locale]; + if (cache) { + cache.write(hash, translation); + } else { + localStorageCaches[locale] = new LocalStorageTranslationCache({ + locale, + projectId: this.config.projectId!, + init: { [hash]: translation }, + }); + } + }, + ); + } + } + + /** + * Whether dev hot reload JSX (Suspense-based ) is active + */ + isDevHotReloadJsx(): boolean { + return this._devHotReloadJsx; + } + + /** + * Get or create a LocalStorageTranslationCache for the given locale. + * Instances are lazily created and cached per locale. + * Returns undefined if not in development mode. + */ + getLocalStorageTranslationCache( + locale: string, + init?: Record, + ): LocalStorageTranslationCache | undefined { + if (!import.meta.env?.DEV) return undefined; + + if (!this._localStorageCaches[locale]) { + this._localStorageCaches[locale] = new LocalStorageTranslationCache({ + locale, + projectId: this.config.projectId!, + init, + }); + } + return this._localStorageCaches[locale]; + } + + /** + * Update the html tag (lang, dir) + * + * @deprecated, TODO: we should use a different system for managing this html tag + * this should just be for managing translations + */ + updateHtmlTag( + locale: string, + htmlTagOptions?: { lang?: string; dir?: "ltr" | "rtl" } & HtmlTagOptions, + ): void { + // Get parameters + const htmlLocale = htmlTagOptions?.lang || locale; + const gtInstance = this.getGTClass(); + const canonicalLocale = gtInstance.resolveCanonicalLocale(htmlLocale); + + // Validate parameters + if (!gtInstance.isValidLocale(canonicalLocale)) { + console.warn(createInvalidLocaleWarning(htmlLocale)); + return; + } + + const localeDirection = + htmlTagOptions?.dir || gtInstance.getLocaleDirection(canonicalLocale); + + // Merge options + const mergedHtmlTagOptions = { + ...this.htmlTagOptions, + ...htmlTagOptions, + }; + + // Update html tag + if (mergedHtmlTagOptions.updateHtmlLangTag) { + document.documentElement.lang = canonicalLocale; + } + if (mergedHtmlTagOptions.updateHtmlDirTag) { + document.documentElement.dir = localeDirection; + } + } +} + +// ===== Helper Functions ===== // + +/** + * Wraps a translation loader to merge localStorage translations in dev mode. + * On each call: runs the original loader, seeds a LocalStorageTranslationCache + * with the result (loader wins over stale localStorage), and returns the merged + * translations — preserving runtime tx() translations from previous sessions. + * + * TODO: this should be moved to wrapping in I18nStore + */ +function wrapLoaderWithLocalStorage( + originalLoader: TranslationsLoader, + projectId: string, + localStorageCaches: Record, +) { + return async (locale: string) => { + const loaderTranslations = await originalLoader(locale); + localStorageCaches[locale] ||= new LocalStorageTranslationCache({ + locale, + projectId, + init: loaderTranslations as Record, + }); + return localStorageCaches[locale].getInternalCache(); + }; +} + +/** + * Determines if dev hot reload is enabled (any flag) + * @param config - The configuration + * @returns True if dev hot reload is enabled, false otherwise + */ +function isDevHotReloadEnabled(config: BrowserI18nManagerParams) { + // TODO: this only works when you've defined a custom loadTranslations function + // meaning CDN users will not have access to this feature + return !!( + import.meta.env?.DEV && + config.loadTranslations && + config.projectId && + config.devApiKey + ); +} + +const createInvalidLocaleWarning = (locale: string) => + createDiagnosticMessage({ + source: "gt-react", + severity: "Warning", + whatHappened: `Locale "${locale}" is not valid`, + fix: "Use a valid BCP 47 locale code or add a custom mapping", + }); diff --git a/packages/react/src/i18n-manager/LocalStorageTranslationCache.ts b/packages/react/src/i18n-manager/LocalStorageTranslationCache.ts new file mode 100644 index 000000000..afb050323 --- /dev/null +++ b/packages/react/src/i18n-manager/LocalStorageTranslationCache.ts @@ -0,0 +1,302 @@ +import { Translation } from 'gt-i18n/types'; + +// TODO: Add purge key/locks to prevent concurrent purges across tabs +// TODO: Add cache key/locks for non-atomic read-modify-write operations across tabs + +// ===== Types ===== // + +/** A cached translation entry with expiry metadata */ +type CachedEntry = { t: Translation; exp: number }; + +// ===== Constants ===== // + +const STORAGE_KEY_PREFIX = 'gt:tx:'; +const PURGE_TIMESTAMP_PREFIX = 'gt:tx:purge:'; +const FLUSH_INTERVAL = 500; +const DEFAULT_MAX_SIZE = 1_000_000; // ~1M characters (localStorage uses UTF-16) +const DEFAULT_TTL_MS = 86_400_000; // 24 hours +const DEFAULT_PURGE_INTERVAL_MS = 300_000; // 5 minutes +const PURGE_TARGET_RATIO = 0.8; // purge down to 80% of max + +// Prevents interval leaks on HMR — keyed by storage key +const activeIntervals = new Map>(); + +// ===== Class ===== // + +/** + * A localStorage-backed translation cache for a single locale. + * Used in development mode only to persist runtime translations across page refreshes. + * + * Entries are stored with per-entry expiry timestamps and the cache is purged + * when estimated size exceeds the configured maximum. + */ +export class LocalStorageTranslationCache { + private _storageKey: string; + private _writeBuffer: Record = {}; + private _flushTimer: ReturnType | null = null; + private _estimatedSize: number = 0; + private _maxSize: number; + private _ttl: number; + private _purgeInterval: number; + private _purgeTimestampKey: string; + + /** + * @param locale - The locale this cache is for + * @param projectId - The project id (namespaces localStorage keys) + * @param init - Optional initial translations to merge on top of localStorage data. + * init values take priority over stale localStorage entries. + * @param maxSize - Maximum cache size in characters (default: ~1M) + * @param ttl - TTL in milliseconds for each entry (default: 24 hours) + * @param purgeInterval - Background purge check interval in ms (default: 5 min) + */ + constructor({ + locale, + projectId, + init, + maxSize, + ttl, + purgeInterval, + }: { + locale: string; + projectId: string; + init?: Record; + maxSize?: number; + ttl?: number; + purgeInterval?: number; + }) { + this._storageKey = `${STORAGE_KEY_PREFIX}${projectId}:${locale}`; + this._purgeTimestampKey = `${PURGE_TIMESTAMP_PREFIX}${projectId}:${locale}`; + this._maxSize = maxSize ?? DEFAULT_MAX_SIZE; + this._ttl = ttl ?? DEFAULT_TTL_MS; + this._purgeInterval = purgeInterval ?? DEFAULT_PURGE_INTERVAL_MS; + + // Merge init values on top (init wins on conflict) + if (init) { + this.initStorage(init); + } + + // Start background purge interval (clears any existing interval for HMR safety) + if (activeIntervals.has(this._storageKey)) { + clearInterval(activeIntervals.get(this._storageKey)!); + } + const intervalId = setInterval( + () => this._backgroundPurge(), + this._purgeInterval + ); + activeIntervals.set(this._storageKey, intervalId); + } + + /** + * Returns the full translation map (cache + pending buffer writes). + * Filters out expired entries. Buffer entries take priority. + */ + getInternalCache(): Record { + const now = Date.now(); + const cache = this._readFromStorage(); + const result: Record = {}; + + for (const [key, entry] of Object.entries(cache)) { + if (entry.exp > now) { + result[key] = entry.t; + } + } + + // Buffer entries are always fresh + Object.assign(result, this._writeBuffer); + return result; + } + + /** + * Queue a translation for writing to localStorage. + * Writes are batched via a debounced flush. + */ + write(hash: string, translation: Translation): void { + this._writeBuffer[hash] = translation; + this._scheduleFlush(); + } + + /** + * Remove specific entries from the cache by hash. + */ + purge(hashes: string[]): void { + const cache = this._readFromStorage(); + for (const hash of hashes) { + delete cache[hash]; + } + this._writeRaw(JSON.stringify(cache)); + } + + // ===== Private Methods ===== // + + /** + * Schedule a flush of the write buffer. + * Uses a leading throttle — the first write in a burst schedules a flush + * after FLUSH_INTERVAL ms; subsequent writes before the timer fires are + * batched into the same flush. + */ + private _scheduleFlush(): void { + if (this._flushTimer) return; // already scheduled + this._flushTimer = setTimeout(() => { + this._flushTimer = null; + this._flush(); + }, FLUSH_INTERVAL); + } + + /** + * Merge the write buffer into the cache and persist to localStorage. + * Purges before writing if estimated size exceeds max. + */ + private _flush(): void { + if (Object.keys(this._writeBuffer).length === 0) return; + + try { + const cache = this._readFromStorage(); + const now = Date.now(); + + // Purge if estimated size exceeds max + if (this._estimatedSize > this._maxSize) { + this._purgeCache(cache, now); + } + + // Merge buffer entries with expiry + const exp = now + this._ttl; + for (const [key, value] of Object.entries(this._writeBuffer)) { + cache[key] = { t: value, exp }; + } + + this._writeRaw(JSON.stringify(cache)); + } catch { + // Silently fail + } + + this._writeBuffer = {}; + } + + /** + * Purge entries from the cache in place. + * Phase 1: Remove expired entries. + * Phase 2: If still over target, drop oldest entries by expiry time. + */ + private _purgeCache(cache: Record, now: number): void { + const keysBeforePurge = Object.keys(cache); + if (keysBeforePurge.length === 0) return; + + const avgEntrySize = this._estimatedSize / keysBeforePurge.length; + + // Phase 1: Remove expired entries + deleteExpiredEntries(cache, now); + + // Phase 2: If still over target, drop oldest entries + const targetSize = this._maxSize * PURGE_TARGET_RATIO; + const maxEntries = Math.floor(targetSize / avgEntrySize); + + const remaining = Object.entries(cache); + if (remaining.length > maxEntries) { + remaining.sort((a, b) => a[1].exp - b[1].exp); // oldest first + const toDrop = remaining.length - maxEntries; + for (let i = 0; i < toDrop; i++) { + delete cache[remaining[i][0]]; + } + } + } + + /** + * Background purge triggered by setInterval. + * Checks the last purge timestamp to avoid redundant work across tabs, + * then removes expired entries. Only writes back if something changed. + * Timestamp is updated after the purge completes. + */ + private _backgroundPurge(): void { + try { + // Check if a purge is needed (another tab may have purged recently) + const raw = localStorage.getItem(this._purgeTimestampKey); + const lastPurge = raw ? parseInt(raw, 10) : 0; + const now = Date.now(); + + if (now - lastPurge < this._purgeInterval) return; + + // Run TTL purge + const cache = this._readFromStorage(); + const keysBefore = Object.keys(cache).length; + + deleteExpiredEntries(cache, now); + + // Only write back if something was actually purged + if (Object.keys(cache).length < keysBefore) { + this._writeRaw(JSON.stringify(cache)); + } + + // Update timestamp after purge completes + localStorage.setItem(this._purgeTimestampKey, String(now)); + } catch { + // Silently fail + } + } + + /** + * Read and parse translations from localStorage. + * Recalibrates estimated size as a side effect. + * Returns empty object on any error (unavailable, corrupt data, etc.) + */ + private _readFromStorage(): Record { + try { + const raw = localStorage.getItem(this._storageKey); + if (!raw) { + this._estimatedSize = 0; + return {}; + } + this._estimatedSize = raw.length; + return JSON.parse(raw) as Record; + } catch { + this._estimatedSize = 0; + return {}; + } + } + + /** + * Persist new entries to localStorage with expiry timestamps. + * Reads current cache, merges buffer on top, writes back. + */ + private initStorage(buffer: Record): void { + try { + const cache = this._readFromStorage(); + const exp = Date.now() + this._ttl; + + for (const [key, value] of Object.entries(buffer)) { + cache[key] = { t: value, exp }; + } + + this._writeRaw(JSON.stringify(cache)); + } catch { + // Silently fail — localStorage may be unavailable or full + } + } + + /** + * Write a pre-serialized string to localStorage and recalibrate estimate. + */ + private _writeRaw(serialized: string): void { + try { + localStorage.setItem(this._storageKey, serialized); + this._estimatedSize = serialized.length; + } catch { + // Silently fail — localStorage may be unavailable or full + } + } +} + +// ===== Helper Functions ===== // + +/** + * Helper function deletes expired entries from a cache in place. + */ +function deleteExpiredEntries( + cache: Record, + now: number = Date.now() +): void { + for (const key of Object.keys(cache)) { + if (cache[key].exp <= now) { + delete cache[key]; + } + } +} diff --git a/packages/react/src/i18n-manager/constants.ts b/packages/react/src/i18n-manager/constants.ts new file mode 100644 index 000000000..96ffff49d --- /dev/null +++ b/packages/react/src/i18n-manager/constants.ts @@ -0,0 +1,6 @@ +import type { HtmlTagOptions } from './types'; + +export const DEFAULT_HTML_TAG_OPTIONS: HtmlTagOptions = { + updateHtmlLangTag: true, + updateHtmlDirTag: true, +}; diff --git a/packages/react/src/i18n-manager/types.ts b/packages/react/src/i18n-manager/types.ts new file mode 100644 index 000000000..2da849eef --- /dev/null +++ b/packages/react/src/i18n-manager/types.ts @@ -0,0 +1,15 @@ +/** + * Type for custom getLocale function + */ +export type GetLocale = () => string; +export type GetEnableI18n = () => boolean; + +/** + * Html tag options + * @param {boolean} updateHtmlLangTag - Whether to update the html lang tag on locale change + * @param {boolean} updateHtmlDirTag - Whether to update the html dir tag on locale change + */ +export type HtmlTagOptions = { + updateHtmlLangTag?: boolean; + updateHtmlDirTag?: boolean; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c70f51a5a..c78b9dc33 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,6 +1,6 @@ -import { GTProvider } from './react-context/provider/GTProvider'; -import { LocaleSelector } from './react-context/ui/LocaleSelector'; -import { RegionSelector } from './react-context/ui/RegionSelector'; +import { GTProvider } from './deprecated/react-context/provider/GTProvider'; +import { LocaleSelector } from './deprecated/react-context/ui/LocaleSelector'; +import { RegionSelector } from './deprecated/react-context/ui/RegionSelector'; import { T, diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts index a5dece209..8f8c652a4 100644 --- a/packages/react/src/internal.ts +++ b/packages/react/src/internal.ts @@ -70,7 +70,7 @@ import type { import type { ClientProviderProps, GTProviderProps, -} from './react-context/types/config'; +} from './deprecated/react-context/types/config'; // Type exports export type { diff --git a/packages/react/src/macros.ts b/packages/react/src/macros.ts index 69b66a7d8..8ab5dc077 100644 --- a/packages/react/src/macros.ts +++ b/packages/react/src/macros.ts @@ -1,5 +1,5 @@ -import { t as _t } from './i18n-context/functions/translation/t'; -import type { TemplateSyncResolutionFunction } from './i18n-context/functions/translation/types'; +import { t as _t } from './deprecated/i18n-context/functions/translation/t'; +import type { TemplateSyncResolutionFunction } from './deprecated/i18n-context/functions/translation/types'; declare global { /** diff --git a/packages/react/src/provider/CSRGTProvider.tsx b/packages/react/src/provider/CSRGTProvider.tsx new file mode 100644 index 000000000..a91107086 --- /dev/null +++ b/packages/react/src/provider/CSRGTProvider.tsx @@ -0,0 +1,45 @@ +import { + getReactI18nManager, + InternalGTProvider, +} from "@generaltranslation/react-core/context"; +import type { SharedGTProviderProps } from "./SharedGTProviderProps"; +import { + getBrowserConditionStore, + isBrowserConditionStoreInitialized, + setBrowserConditionStore, +} from "../condition-store/singleton-operations"; +import { createBrowserConditionStore } from "../condition-store/createBrowserConditionStore"; + +/** + * Client side GTProvider, this is different from server side + * GTProvider because needs to syncrhonize any incoming + * server-side translations + */ +export function CSRGTProvider({ + translations, + dictionary, + defaultLocale = getReactI18nManager().getDefaultLocale(), + locales = getReactI18nManager().getLocales(), + customMapping = getReactI18nManager().getCustomMapping(), + ...props +}: SharedGTProviderProps) { + // TODO: if a specific translation entry changes, but not the locale, this does not trigger a re-render + // TODO: optimize by skipping updateTranslations() if client is responsible for reloading translations + // (eg reloadLocale === undefined), see getI18nStore().updateLocale() in InternalGTProvider + if (!isBrowserConditionStoreInitialized()) { + const conditionStore = createBrowserConditionStore({ + defaultLocale, + locales, + customMapping, + ...props, + }); + setBrowserConditionStore(conditionStore); + } else if (props.reloadLocale) { + // This represents an update from server, so bypass I18nStore + // we only listen to it if we trigger server-side reloads on locale change + getBrowserConditionStore().setLocale(props.locale); + } + getReactI18nManager().updateTranslations(translations); + getReactI18nManager().updateDictionaries(dictionary ?? {}); + return ; +} diff --git a/packages/react/src/provider/SSRGTProvider.tsx b/packages/react/src/provider/SSRGTProvider.tsx new file mode 100644 index 000000000..601be7db3 --- /dev/null +++ b/packages/react/src/provider/SSRGTProvider.tsx @@ -0,0 +1,39 @@ +import { ReadonlyConditionStore } from "gt-i18n/internal"; +import { + isReadonlyConditionStoreInitialized, + setReadonlyConditionStore, +} from "../condition-store/singleton-operations"; +import type { SharedGTProviderProps } from "./SharedGTProviderProps"; +import { + getReactI18nManager, + InternalGTProvider, +} from "@generaltranslation/react-core/context"; + +/** + * For the server side GTProvider, we don't need to synchronize translations + * as this will happen during the loader + * + * TODO: find some way to enforce this is only imported on the server + */ +export function SSRGTProvider({ + translations, + dictionary, + defaultLocale = getReactI18nManager().getDefaultLocale(), + locales = getReactI18nManager().getLocales(), + customMapping = getReactI18nManager().getCustomMapping(), + ...props +}: SharedGTProviderProps) { + // The condition store may already be created at the module level + if (!isReadonlyConditionStoreInitialized()) { + const conditionStore = new ReadonlyConditionStore({ + defaultLocale, + locales, + customMapping, + ...props, + }); + setReadonlyConditionStore(conditionStore); + } + getReactI18nManager().updateTranslations(translations); + getReactI18nManager().updateDictionaries(dictionary ?? {}); + return ; +} diff --git a/packages/react/src/provider/SharedGTProviderProps.ts b/packages/react/src/provider/SharedGTProviderProps.ts new file mode 100644 index 000000000..1024dba0a --- /dev/null +++ b/packages/react/src/provider/SharedGTProviderProps.ts @@ -0,0 +1,16 @@ +import type { InternalGTProviderProps } from "@generaltranslation/react-core/context"; +import type { Dictionary, Translation } from "gt-i18n/types"; +import type { + Locale, + Hash, + ReadonlyConditionStoreParams, +} from "gt-i18n/internal/types"; + +/** + * We force the user to pass translations so they can be synchronously accessed + */ +export type SharedGTProviderProps = InternalGTProviderProps & + ReadonlyConditionStoreParams & { + translations: Record>; + dictionary?: Record; + }; diff --git a/packages/react/src/setup/initializeGTSPA.ts b/packages/react/src/setup/initializeGTSPA.ts new file mode 100644 index 000000000..f34d8d7c2 --- /dev/null +++ b/packages/react/src/setup/initializeGTSPA.ts @@ -0,0 +1,46 @@ +import { + getTranslationsSnapshot, + I18nStoreParams, + setRenderStrategy, + I18nStore, + setI18nStore, + setStoresInitialized, + setConditionStore, + setReactI18nManager, +} from "@generaltranslation/react-core/context"; +import { BrowserI18nManager } from "../i18n-manager/BrowserI18nManager"; +import type { BrowserI18nManagerParams } from "../i18n-manager/BrowserI18nManager"; +import { + createBrowserConditionStore, + CreateBrowserConditionStoreParams, +} from "../condition-store/createBrowserConditionStore"; + +/** + * Initialize GT for an SPA + * - i18nManager + * - conditionStore + * - i18nStore + * + * This is SPA for browser runtime + */ +export async function initializeGTSPA( + config: I18nStoreParams & + BrowserI18nManagerParams & + CreateBrowserConditionStoreParams, +) { + setRenderStrategy("SPA"); + + const i18nManager = new BrowserI18nManager(config); + setReactI18nManager(i18nManager); + + const conditionStore = createBrowserConditionStore(config); + setConditionStore(conditionStore); + + const i18nStore = new I18nStore(config); + setI18nStore(i18nStore); + + setStoresInitialized(true); + + // Block until translations are loaded + await getTranslationsSnapshot(conditionStore.getLocale()); +} diff --git a/packages/react/tsdown.config.mts b/packages/react/tsdown.config.mts index ae3fe4dd7..f69fb66a7 100644 --- a/packages/react/tsdown.config.mts +++ b/packages/react/tsdown.config.mts @@ -17,17 +17,34 @@ const deps = { ], }; +const contextDeps = { + neverBundle: [ + ...deps.neverBundle, + /^@generaltranslation\/react-core\//, + /^gt-i18n$/, + /^gt-i18n\//, + ], + alwaysBundle: [ + /^@generaltranslation\/format\//, + /^generaltranslation\//, + ], +}; + const entries = [ 'src/index.ts', 'src/internal.ts', 'src/client.ts', + 'src/context.client.ts', + 'src/context.server.ts', + 'src/context.types.ts', 'src/browser.ts', 'src/macros.ts', ]; export default defineConfig( entries.flatMap((entry, index) => { - const [cjsConfig, esmConfig] = createTsdownConfig([entry], deps); + const entryDeps = entry.startsWith('src/context.') ? contextDeps : deps; + const [cjsConfig, esmConfig] = createTsdownConfig([entry], entryDeps); return [ { @@ -41,7 +58,7 @@ export default defineConfig( ...esmConfig, deps: { onlyBundle: false, - ...deps, + ...entryDeps, }, }, ]; diff --git a/packages/tanstack-start/package.json b/packages/tanstack-start/package.json index dddec8067..de4371651 100644 --- a/packages/tanstack-start/package.json +++ b/packages/tanstack-start/package.json @@ -2,9 +2,9 @@ "name": "gt-tanstack-start", "version": "0.4.21", "description": "TanStack Start integration for General Translation", - "main": "dist/index.cjs.min.cjs", - "module": "dist/index.esm.min.mjs", - "types": "dist/index.d.ts", + "main": "./dist/index.client.cjs", + "module": "./dist/index.client.mjs", + "types": "./dist/index.types.d.cts", "files": [ "dist", "CHANGELOG.md" @@ -53,18 +53,46 @@ }, "exports": { ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.cjs.min.cjs", - "import": "./dist/index.esm.min.mjs" + "react-server": { + "require": { + "types": "./dist/index.types.d.cts", + "default": "./dist/index.server.cjs" + }, + "import": { + "types": "./dist/index.types.d.mts", + "default": "./dist/index.server.mjs" + }, + "default": "./dist/index.server.mjs" + }, + "browser": { + "require": { + "types": "./dist/index.types.d.cts", + "default": "./dist/index.client.cjs" + }, + "import": { + "types": "./dist/index.types.d.mts", + "default": "./dist/index.client.mjs" + }, + "default": "./dist/index.client.mjs" + }, + "require": { + "types": "./dist/index.types.d.cts", + "default": "./dist/index.server.cjs" + }, + "import": { + "types": "./dist/index.types.d.mts", + "default": "./dist/index.server.mjs" + }, + "default": "./dist/index.server.mjs" }, "./types": { - "types": "./dist/types.d.ts" + "types": "./dist/types.d.cts" } }, "typesVersions": { "*": { "types": [ - "./dist/types.d.ts" + "./dist/types.d.cts" ] } }, @@ -72,7 +100,7 @@ "baseUrl": ".", "paths": { "gt-tanstack-start/types": [ - "./dist/types" + "./dist/types.d.cts" ] } }, diff --git a/packages/tanstack-start/src/condition-store/WritableConditionStore.ts b/packages/tanstack-start/src/condition-store/WritableConditionStore.ts new file mode 100644 index 000000000..599e0493b --- /dev/null +++ b/packages/tanstack-start/src/condition-store/WritableConditionStore.ts @@ -0,0 +1,11 @@ +import { + createConditionStoreSingleton, + WritableConditionStore, +} from "gt-i18n/internal"; + +export const { + getConditionStore: getWritableConditionStore, + setConditionStore: setWritableConditionStore, +} = createConditionStoreSingleton( + "WritableConditionStore is not initialized.", +); diff --git a/packages/tanstack-start/src/functions/determineLocale.ts b/packages/tanstack-start/src/functions/determineLocale.ts index 8a50890a7..c1226bae6 100644 --- a/packages/tanstack-start/src/functions/determineLocale.ts +++ b/packages/tanstack-start/src/functions/determineLocale.ts @@ -2,7 +2,7 @@ import { defaultLocaleCookieName } from 'gt-react/internal'; import { createIsomorphicFn } from '@tanstack/react-start'; import { getRequestHeader, getCookie } from '@tanstack/react-start/server'; import { resolveSupportedLocale } from 'gt-i18n/internal'; -import type { ConditionStoreConfig } from 'gt-i18n/internal/types'; +import type { LocaleResolverConfig } from 'gt-i18n/internal/types'; /** * Determines the locale isomorphicly. @@ -21,7 +21,7 @@ function determineLocaleServer({ defaultLocale, locales, customMapping, -}: ConditionStoreConfig) { +}: LocaleResolverConfig) { const candidates: string[] = []; // (1) Check cookie @@ -61,7 +61,7 @@ function determineLocaleClient({ defaultLocale, locales, customMapping, -}: ConditionStoreConfig) { +}: LocaleResolverConfig) { const candidates: string[] = []; // (1) Check cookie diff --git a/packages/tanstack-start/src/functions/getTranslations.ts b/packages/tanstack-start/src/functions/getTranslations.ts deleted file mode 100644 index 75dc34617..000000000 --- a/packages/tanstack-start/src/functions/getTranslations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getLocale } from 'gt-i18n'; -import type { Translations } from 'gt-react/internal'; -import { isSSREnabled } from '../provider/utils/isSSREnabled'; -import { getI18nManager } from 'gt-i18n/internal'; - -/** - * Put this in the root loader to pass translations to the provider - */ -export async function getTranslations(): Promise { - const locale = isSSREnabled() ? getLocale() : undefined; - if (!locale) return undefined; - const i18nManager = getI18nManager(); - // TODO: improve types by TranslationManager overrides - // need to cast b/c gt-i18n assumes string only translation - return (await i18nManager.loadTranslations(locale)) as Translations; -} diff --git a/packages/tanstack-start/src/index.client.ts b/packages/tanstack-start/src/index.client.ts new file mode 100644 index 000000000..b7d50c570 --- /dev/null +++ b/packages/tanstack-start/src/index.client.ts @@ -0,0 +1,48 @@ +"use client"; + +import { I18nManager } from "gt-i18n/internal"; +import type { Translation } from "gt-i18n/types"; +import { + setReactI18nManager, + type ReactI18nManagerParams, +} from "@generaltranslation/react-core/context"; + +export { + // ===== Components ===== // + Branch, + Plural, + Derive, + LocaleSelector, + T, + Currency, + DateTime, + RelativeTime, + Var, + Num, + GTProvider, + // ===== Hooks ===== // + useLocale, + useSetLocale, + useCustomMapping, + useDefaultLocale, + useEnableI18n, + useSetEnableI18n, + useLocales, + useLocaleSelector, + useFormatLocales, + useGT, + useMessages, + useTranslations, + // ===== Functions ===== // + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, + getTranslationsSnapshot, + // ===== Setup ===== // + initializeGT, +} from "gt-react/context"; diff --git a/packages/tanstack-start/src/index.server.ts b/packages/tanstack-start/src/index.server.ts new file mode 100644 index 000000000..0d68ba0a6 --- /dev/null +++ b/packages/tanstack-start/src/index.server.ts @@ -0,0 +1,39 @@ +export { + // ===== Components ===== // + Branch, + Plural, + Derive, + LocaleSelector, + T, + Currency, + DateTime, + RelativeTime, + Var, + Num, + GTProvider, + // ===== Hooks ===== // + useLocale, + useSetLocale, + useCustomMapping, + useDefaultLocale, + useEnableI18n, + useSetEnableI18n, + useLocales, + useLocaleSelector, + useFormatLocales, + useGT, + useMessages, + useTranslations, + // ===== Functions ===== // + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, + getTranslationsSnapshot, + // ===== Setup ===== // + initializeGT, +} from "gt-react/context"; diff --git a/packages/tanstack-start/src/index.ts b/packages/tanstack-start/src/index.ts deleted file mode 100644 index a10da9d3f..000000000 --- a/packages/tanstack-start/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { initializeGT } from './setup/initializeGT'; -export { GTProvider } from './provider/GTProvider'; -export { getTranslations } from './functions/getTranslations'; - -export { T, Var, LocaleSelector, RelativeTime } from 'gt-react'; -export { getLocale } from 'gt-i18n'; -export { getGT, getMessages, getVersionId } from 'gt-i18n/internal'; diff --git a/packages/tanstack-start/src/index.types.ts b/packages/tanstack-start/src/index.types.ts new file mode 100644 index 000000000..0d68ba0a6 --- /dev/null +++ b/packages/tanstack-start/src/index.types.ts @@ -0,0 +1,39 @@ +export { + // ===== Components ===== // + Branch, + Plural, + Derive, + LocaleSelector, + T, + Currency, + DateTime, + RelativeTime, + Var, + Num, + GTProvider, + // ===== Hooks ===== // + useLocale, + useSetLocale, + useCustomMapping, + useDefaultLocale, + useEnableI18n, + useSetEnableI18n, + useLocales, + useLocaleSelector, + useFormatLocales, + useGT, + useMessages, + useTranslations, + // ===== Functions ===== // + msg, + decodeMsg, + decodeOptions, + derive, + declareVar, + decodeVars, + mFallback, + gtFallback, + getTranslationsSnapshot, + // ===== Setup ===== // + initializeGT, +} from "gt-react/context"; diff --git a/packages/tanstack-start/src/provider/types.ts b/packages/tanstack-start/src/provider/types.ts index 2313fba10..24a37cf1f 100644 --- a/packages/tanstack-start/src/provider/types.ts +++ b/packages/tanstack-start/src/provider/types.ts @@ -1,4 +1,4 @@ -import { GTProviderProps as GTReactProviderProps } from 'gt-react/internal'; +import { GTProviderProps as GTReactProviderProps } from "gt-react/internal"; /** * Props for the GTProvider component @@ -8,4 +8,4 @@ import { GTProviderProps as GTReactProviderProps } from 'gt-react/internal'; * * TODO: use Pick<> syntax */ -export type GTProviderProps = Omit; +export type GTProviderProps = Omit; diff --git a/packages/tanstack-start/src/runtime/TanstackConditionStore.ts b/packages/tanstack-start/src/runtime/TanstackConditionStore.ts deleted file mode 100644 index e744c2f12..000000000 --- a/packages/tanstack-start/src/runtime/TanstackConditionStore.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { determineLocale } from '../functions/determineLocale'; -import type { - ConditionStore, - ConditionStoreConfig, -} from 'gt-i18n/internal/types'; - -/** - * Condition store implementation for Tanstack Start. - */ -export class TanstackConditionStore implements ConditionStore { - private localeConfig: ConditionStoreConfig; - - constructor(localeConfig: ConditionStoreConfig = {}) { - this.localeConfig = localeConfig; - } - - /** - * Get the current locale. - */ - getLocale(): string { - return determineLocale(this.localeConfig); - } -} diff --git a/packages/tanstack-start/src/setup/initializeGT.ts b/packages/tanstack-start/src/setup/initializeGT.ts deleted file mode 100644 index 60a947709..000000000 --- a/packages/tanstack-start/src/setup/initializeGT.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - I18nManager, - setI18nManager, - setConditionStore, -} from 'gt-i18n/internal'; -import type { Translation } from 'gt-i18n/types'; -import type { InitializeGTParams } from './types'; -import { TanstackConditionStore } from '../runtime/TanstackConditionStore'; - -/** - * Configure GT for TanStack Start. This must be called to setup GT for TanStack Start. - * @param {InitializeGTParams} config - The configuration for the GT instance - * TODO: auto detect if can find gt.config.json files - */ -export function initializeGT(params: InitializeGTParams): void { - const i18nManager = new I18nManager(params); - const conditionStore = new TanstackConditionStore({ - defaultLocale: i18nManager.getDefaultLocale(), - locales: i18nManager.getLocales(), - customMapping: i18nManager.getCustomMapping(), - }); - - setI18nManager(i18nManager); - setConditionStore(conditionStore); -} diff --git a/packages/tanstack-start/src/setup/types.ts b/packages/tanstack-start/src/setup/types.ts deleted file mode 100644 index 88499da68..000000000 --- a/packages/tanstack-start/src/setup/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { GTConfig, TranslationsLoader } from 'gt-i18n/internal/types'; - -/** - * Parameters for the initializing GT - * @param {string} params.defaultLocale - The default locale to use - * @param {string[]} params.locales - The locales to support - * @param {object} params.loadTranslations - The custom translation loader to use - */ -export type InitializeGTParams = GTConfig & { - loadTranslations?: TranslationsLoader; -}; - -// Other Reexports -export type { GTConfig, TranslationsLoader } from 'gt-i18n/internal/types'; diff --git a/packages/tanstack-start/src/types.ts b/packages/tanstack-start/src/types.ts index 35bf4d3db..e69de29bb 100644 --- a/packages/tanstack-start/src/types.ts +++ b/packages/tanstack-start/src/types.ts @@ -1 +0,0 @@ -export type { InitializeGTParams } from './setup/types'; diff --git a/packages/tanstack-start/tsdown.config.mts b/packages/tanstack-start/tsdown.config.mts index ad6b10b6d..45abc31e8 100644 --- a/packages/tanstack-start/tsdown.config.mts +++ b/packages/tanstack-start/tsdown.config.mts @@ -1,5 +1,5 @@ import { defineConfig } from 'tsdown'; -import { createTsdownMinifiedDualFormatConfig } from '../../tsdown.preset.mts'; +import { createTsdownConfig } from '../../tsdown.preset.mts'; const deps = { neverBundle: [ @@ -10,21 +10,41 @@ const deps = { /^@tanstack\/react-start$/, /^@tanstack\/react-start\//, /^@generaltranslation\/react-core$/, - /^gt-react$/, - /^gt-i18n$/, - /^generaltranslation$/, - ], - alwaysBundle: [ /^@generaltranslation\/react-core\//, + /^gt-react$/, /^gt-react\//, + /^gt-i18n$/, /^gt-i18n\//, - /^generaltranslation\//, + /^generaltranslation$/, ], + alwaysBundle: [/^generaltranslation\//], }; +const entries = [ + 'src/index.client.ts', + 'src/index.server.ts', + 'src/index.types.ts', + 'src/types.ts', +]; + export default defineConfig( - createTsdownMinifiedDualFormatConfig({ - entries: ['src/index.ts'], - deps, + entries.flatMap((entry, index) => { + const [cjsConfig, esmConfig] = createTsdownConfig([entry], deps); + return [ + { + ...cjsConfig, + clean: index === 0, + define: { + 'import.meta.env': '{}', + }, + }, + { + ...esmConfig, + deps: { + onlyBundle: false, + ...deps, + }, + }, + ]; }) ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 253f43eb2..850f7f059 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1175,10 +1175,10 @@ importers: specifier: workspace:* version: link:../i18n react: - specifier: '>=16.8.0' + specifier: '>=18.0.0' version: 19.1.1 react-dom: - specifier: '>=16.8.0' + specifier: '>=18.0.0' version: 19.1.1(react@19.1.1) devDependencies: '@types/node': @@ -1212,7 +1212,7 @@ importers: specifier: workspace:* version: link:../i18n react: - specifier: '>=16.8.0' + specifier: '>=18.0.0' version: 19.1.1 devDependencies: '@types/node': diff --git a/tsdown.preset.mts b/tsdown.preset.mts index 3b7797f4f..20896eb34 100644 --- a/tsdown.preset.mts +++ b/tsdown.preset.mts @@ -85,6 +85,7 @@ type TsdownMinifiedDualFormatConfigOptions = Pick< UserConfig, 'deps' | 'outDir' > & { + clean?: boolean; entries: string[]; packageDir?: string; /** Defaults to the conventional type entry when it exists. Pass false to skip the types-only CJS artifact. */ @@ -121,6 +122,7 @@ function createRemoveTypeRuntimeArtifactsHook( } export function createTsdownMinifiedDualFormatConfig({ + clean = true, entries, packageDir = process.cwd(), typeEntry, @@ -147,7 +149,7 @@ export function createTsdownMinifiedDualFormatConfig({ entry: [entry], format: ['cjs'] as const, dts: true, - clean: index === 0, + clean: clean && index === 0, }, { ...outputOptions,