From 0c8dd0f3430d68fd78c27f765baf442c2713c000 Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 14 May 2026 16:57:37 -0700 Subject: [PATCH 01/34] feat: add react-core external store context --- .../internal-external-store-entrypoints.md | 6 + .../react-core-external-store-provider.md | 5 + .../separate-external-store-conditions.md | 6 + packages/i18n/src/i18n-manager/I18nManager.ts | 226 ++++--- .../condition-store/localeResolver.ts | 18 +- .../src/i18n-manager/singleton-operations.ts | 44 +- .../translations-manager/Cache.ts | 10 +- .../translations-manager/LocalesCache.ts | 79 ++- .../translations-manager/TranslationsCache.ts | 40 +- packages/i18n/src/i18n-manager/types.ts | 39 +- packages/i18n/src/internal-types.ts | 17 +- packages/i18n/src/internal.ts | 25 +- .../internal/getTranslations.ts | 149 +---- .../translation-functions/internal/index.ts | 2 + .../internal/renderDictionaryEntry.ts | 41 ++ .../internal/renderDictionaryObject.ts | 101 +++ .../internal/sync-translation-resolution.ts | 12 +- .../translation-functions/types/options.ts | 44 +- packages/i18n/src/utils/extractVariables.ts | 27 +- packages/i18n/src/utils/listenerKeys.ts | 34 + .../async-i18n-manager/AsyncConditionStore.ts | 14 +- packages/react-core/package.json | 13 +- .../src/branches/plurals/Plural.tsx | 16 +- .../src/branches/plurals/getPluralBranch.ts | 11 +- packages/react-core/src/context.ts | 62 ++ packages/react-core/src/internal.ts | 60 +- packages/react-core/src/provider/GTContext.ts | 8 +- .../src/provider/__tests__/i18n-store.test.ts | 207 ++++++ .../useCreateInternalUseGTFunction.ts | 87 +-- .../refactor/components/branches/Branch.tsx | 40 ++ .../refactor/components/branches/Plural.tsx | 40 ++ .../refactor/components/derivation/Derive.tsx | 23 + .../helpers/InternalLocaleSelector.tsx | 94 +++ .../components/helpers/LocaleSelector.tsx | 35 + .../src/refactor/components/translation/T.tsx | 159 +++++ .../components/variables/Currency.tsx | 45 ++ .../components/variables/DateTime.tsx | 40 ++ .../src/refactor/components/variables/Num.tsx | 39 ++ .../components/variables/RelativeTime.tsx | 75 +++ .../src/refactor/components/variables/Var.tsx | 38 ++ .../components/variables/renderVariable.tsx | 97 +++ .../condition-store/ReactConditionStore.ts | 45 ++ .../condition-store/singleton-operations.ts | 7 + .../refactor/context/provider/GTContext.ts | 10 + .../context/provider/InternalGTProvider.tsx | 116 ++++ .../helpers/getTranslationsSnapshot.ts | 18 + .../src/refactor/functions/translation/t.ts | 185 ++++++ .../src/refactor/hooks/context-hooks.ts | 42 ++ .../refactor/hooks/external-store-hooks.ts | 148 +++++ .../react-core/src/refactor/hooks/useGT.ts | 108 +++ .../src/refactor/hooks/useLocaleSelector.ts | 53 ++ .../src/refactor/hooks/useMessages.ts | 33 + .../src/refactor/hooks/useTranslations.ts | 133 ++++ .../react-core/src/refactor/hooks/utils.ts | 31 + .../refactor/i18n-manager/ReactI18nManager.ts | 17 + .../i18n-manager/singleton-operations.ts | 15 + .../src/refactor/i18n-store/I18nStore.ts | 625 ++++++++++++++++++ .../i18n-store/RuntimeDictionaryScope.ts | 61 ++ .../i18n-store/RuntimeTranslationScope.ts | 49 ++ .../i18n-store/singleton-operations.ts | 17 + .../src/refactor/i18n-store/storeTypes.ts | 39 ++ .../react-core/src/refactor/setup/globals.ts | 39 ++ .../src/refactor/setup/initializeGTSPA.ts | 38 ++ .../src/refactor/setup/initializeGTSSR.ts | 19 + packages/react-core/src/types-dir/context.ts | 17 +- packages/react-core/tsdown.config.mts | 13 +- packages/react/package.json | 33 +- packages/react/src/context.client.ts | 35 + packages/react/src/context.server.ts | 36 + packages/react/src/context.types.ts | 46 ++ .../BrowserConditionStore.ts | 30 +- .../BrowserI18nManager.ts | 38 +- .../utils/determineLocale.ts | 20 +- .../i18n-context/functions/translation/t.ts | 41 +- .../react/src/i18n-context/setup/bootstrap.ts | 8 +- packages/react/src/internal-external-store.ts | 1 + .../condition-store/BrowserConditionStore.ts | 74 +++ .../src/refactor/condition-store/cookies.ts | 34 + .../condition-store/readBrowserLocale.ts | 18 + .../i18n-manager/BrowserI18nManager.ts | 200 ++++++ .../LocalStorageTranslationCache.ts | 302 +++++++++ .../src/refactor/i18n-manager/constants.ts | 6 + .../react/src/refactor/i18n-manager/types.ts | 14 + .../src/refactor/provider/CSRGTProvider.tsx | 22 + .../src/refactor/provider/SSRGTProvider.tsx | 13 + packages/react/src/refactor/provider/types.ts | 10 + .../src/refactor/setup/initializeGTSPA.ts | 44 ++ packages/react/tsdown.config.mts | 21 +- .../src/functions/determineLocale.ts | 32 +- .../src/runtime/TanstackConditionStore.ts | 10 +- pnpm-lock.yaml | 6 +- 91 files changed, 4560 insertions(+), 540 deletions(-) create mode 100644 .changeset/internal-external-store-entrypoints.md create mode 100644 .changeset/react-core-external-store-provider.md create mode 100644 .changeset/separate-external-store-conditions.md create mode 100644 packages/i18n/src/translation-functions/internal/renderDictionaryEntry.ts create mode 100644 packages/i18n/src/translation-functions/internal/renderDictionaryObject.ts create mode 100644 packages/i18n/src/utils/listenerKeys.ts create mode 100644 packages/react-core/src/context.ts create mode 100644 packages/react-core/src/provider/__tests__/i18n-store.test.ts create mode 100644 packages/react-core/src/refactor/components/branches/Branch.tsx create mode 100644 packages/react-core/src/refactor/components/branches/Plural.tsx create mode 100644 packages/react-core/src/refactor/components/derivation/Derive.tsx create mode 100644 packages/react-core/src/refactor/components/helpers/InternalLocaleSelector.tsx create mode 100644 packages/react-core/src/refactor/components/helpers/LocaleSelector.tsx create mode 100644 packages/react-core/src/refactor/components/translation/T.tsx create mode 100644 packages/react-core/src/refactor/components/variables/Currency.tsx create mode 100644 packages/react-core/src/refactor/components/variables/DateTime.tsx create mode 100644 packages/react-core/src/refactor/components/variables/Num.tsx create mode 100644 packages/react-core/src/refactor/components/variables/RelativeTime.tsx create mode 100644 packages/react-core/src/refactor/components/variables/Var.tsx create mode 100644 packages/react-core/src/refactor/components/variables/renderVariable.tsx create mode 100644 packages/react-core/src/refactor/condition-store/ReactConditionStore.ts create mode 100644 packages/react-core/src/refactor/condition-store/singleton-operations.ts create mode 100644 packages/react-core/src/refactor/context/provider/GTContext.ts create mode 100644 packages/react-core/src/refactor/context/provider/InternalGTProvider.tsx create mode 100644 packages/react-core/src/refactor/functions/helpers/getTranslationsSnapshot.ts create mode 100644 packages/react-core/src/refactor/functions/translation/t.ts create mode 100644 packages/react-core/src/refactor/hooks/context-hooks.ts create mode 100644 packages/react-core/src/refactor/hooks/external-store-hooks.ts create mode 100644 packages/react-core/src/refactor/hooks/useGT.ts create mode 100644 packages/react-core/src/refactor/hooks/useLocaleSelector.ts create mode 100644 packages/react-core/src/refactor/hooks/useMessages.ts create mode 100644 packages/react-core/src/refactor/hooks/useTranslations.ts create mode 100644 packages/react-core/src/refactor/hooks/utils.ts create mode 100644 packages/react-core/src/refactor/i18n-manager/ReactI18nManager.ts create mode 100644 packages/react-core/src/refactor/i18n-manager/singleton-operations.ts create mode 100644 packages/react-core/src/refactor/i18n-store/I18nStore.ts create mode 100644 packages/react-core/src/refactor/i18n-store/RuntimeDictionaryScope.ts create mode 100644 packages/react-core/src/refactor/i18n-store/RuntimeTranslationScope.ts create mode 100644 packages/react-core/src/refactor/i18n-store/singleton-operations.ts create mode 100644 packages/react-core/src/refactor/i18n-store/storeTypes.ts create mode 100644 packages/react-core/src/refactor/setup/globals.ts create mode 100644 packages/react-core/src/refactor/setup/initializeGTSPA.ts create mode 100644 packages/react-core/src/refactor/setup/initializeGTSSR.ts create mode 100644 packages/react/src/context.client.ts create mode 100644 packages/react/src/context.server.ts create mode 100644 packages/react/src/context.types.ts create mode 100644 packages/react/src/internal-external-store.ts create mode 100644 packages/react/src/refactor/condition-store/BrowserConditionStore.ts create mode 100644 packages/react/src/refactor/condition-store/cookies.ts create mode 100644 packages/react/src/refactor/condition-store/readBrowserLocale.ts create mode 100644 packages/react/src/refactor/i18n-manager/BrowserI18nManager.ts create mode 100644 packages/react/src/refactor/i18n-manager/LocalStorageTranslationCache.ts create mode 100644 packages/react/src/refactor/i18n-manager/constants.ts create mode 100644 packages/react/src/refactor/i18n-manager/types.ts create mode 100644 packages/react/src/refactor/provider/CSRGTProvider.tsx create mode 100644 packages/react/src/refactor/provider/SSRGTProvider.tsx create mode 100644 packages/react/src/refactor/provider/types.ts create mode 100644 packages/react/src/refactor/setup/initializeGTSPA.ts diff --git a/.changeset/internal-external-store-entrypoints.md b/.changeset/internal-external-store-entrypoints.md new file mode 100644 index 000000000..f636ab208 --- /dev/null +++ b/.changeset/internal-external-store-entrypoints.md @@ -0,0 +1,6 @@ +--- +'@generaltranslation/react-core': patch +'gt-react': patch +--- + +Add internal-external-store entrypoints for the experimental external store exports. diff --git a/.changeset/react-core-external-store-provider.md b/.changeset/react-core-external-store-provider.md new file mode 100644 index 000000000..848ba0f3f --- /dev/null +++ b/.changeset/react-core-external-store-provider.md @@ -0,0 +1,5 @@ +--- +'@generaltranslation/react-core': patch +--- + +Refactor the experimental external-store implementation around provider-owned I18nManager instances. diff --git a/.changeset/separate-external-store-conditions.md b/.changeset/separate-external-store-conditions.md new file mode 100644 index 000000000..6d17556b2 --- /dev/null +++ b/.changeset/separate-external-store-conditions.md @@ -0,0 +1,6 @@ +--- +'@generaltranslation/react-core': patch +'gt-react': patch +--- + +Separate external-store manager subscriptions from provider condition subscriptions and add translate-many snapshots. diff --git a/packages/i18n/src/i18n-manager/I18nManager.ts b/packages/i18n/src/i18n-manager/I18nManager.ts index 04f005e5c..c03260d74 100644 --- a/packages/i18n/src/i18n-manager/I18nManager.ts +++ b/packages/i18n/src/i18n-manager/I18nManager.ts @@ -1,38 +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 { + determineLocale, + 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 { Locale, LocalesCache } from './translations-manager/LocalesCache'; -import { 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 { Locale, LocalesCache } from "./translations-manager/LocalesCache"; +import { Hash } from "./translations-manager/TranslationsCache"; import type { Dictionary, DictionaryEntry, DictionaryKey, DictionaryObject, -} from './translations-manager/DictionaryCache'; -import { resolveDictionaryLookupOptions } from './translations-manager/utils/dictionary-helpers'; -import { LocalesDictionaryCache } from './translations-manager/LocalesDictionaryCache'; -import type { DictionaryLoader } from './translations-manager/LocalesDictionaryCache'; -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 { LocalesDictionaryCache } from "./translations-manager/LocalesDictionaryCache"; +import type { DictionaryLoader } from "./translations-manager/LocalesDictionaryCache"; +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"; /** * Default translation timeout in milliseconds for a runtime translation request @@ -50,7 +58,7 @@ type TranslationResolver = < T extends U = U, >( message: T, - options?: LookupOptions + options?: LookupOptions, ) => T | undefined; /** @@ -89,6 +97,8 @@ class I18nManager< */ private localeConfig: LocaleConfig; + public determineLocale: (candidates?: LocaleCandidates) => string; + /** * Creates an instance of I18nManager. * TODO: resolve gtConfig from just file path @@ -100,7 +110,7 @@ class I18nManager< // Validation const validationResults = validateConfig(params); - publishValidationResults(validationResults, 'I18nManager: '); + publishValidationResults(validationResults, "I18nManager: "); // Setup this.config = standardizeConfig(params); @@ -109,6 +119,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 = createTranslationLoader(params); const loadDictionary = createDictionaryLoader(params); @@ -119,25 +135,30 @@ 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) + this.emit(...args), ); // Setup translations cache + const initialTranslations = filterInitialTranslations( + params.initialTranslations ?? {}, + this.localeConfig, + ); this.localesCache = new LocalesCache({ loadTranslations, createTranslateMany, lifecycle, ttl: this.config.cacheExpiryTime, batchConfig: this.config.batchConfig, + initialTranslations, }); // Setup dictionary cache @@ -165,10 +186,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) { @@ -215,17 +236,27 @@ 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; } + // ========== Translation Updates ========== // + + updateTranslations( + translationsObj: Record>, + ): void { + this.localesCache.update(translationsObj); + } + // ========== Translation Loading ========== // /** @@ -238,14 +269,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.get(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 @@ -322,7 +365,7 @@ class I18nManager< */ lookupDictionaryObj( locale: string, - id: string + id: string, ): DictionaryObject | undefined { try { const dictionaryLocale = @@ -340,7 +383,7 @@ class I18nManager< */ async lookupDictionaryWithFallback( locale: string, - id: string + id: string, ): Promise { try { const dictionaryLocale = this.resolveCacheLocale(locale); @@ -383,7 +426,7 @@ class I18nManager< */ async lookupDictionaryObjWithFallback( locale: string, - id: string + id: string, ): Promise { try { const dictionaryLocale = this.resolveCacheLocale(locale); @@ -426,7 +469,7 @@ class I18nManager< lookupTranslation( locale: string, message: T, - options: LookupOptions + options: LookupOptions, ): T | undefined { try { // Validate @@ -459,13 +502,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); @@ -487,7 +530,7 @@ class I18nManager< prefetchEntries: { message: TranslationValue; options: LookupOptions; - }[] = [] + }[] = [], ): Promise> { try { // Validate @@ -504,11 +547,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}`, ); } @@ -521,7 +564,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 @@ -550,7 +593,7 @@ class I18nManager< resolveTranslationSync = ( locale: string, message: T, - options: LookupOptions + options: LookupOptions, ) => { return this.lookupTranslation(locale, message, options); }; @@ -562,7 +605,7 @@ class I18nManager< * @deprecated use loadTranslations instead */ async getTranslations( - locale: string + locale: string, ): Promise> { try { return this.loadTranslations(locale); @@ -583,7 +626,7 @@ class I18nManager< * @deprecated use getLookupTranslation instead */ async getTranslationResolver( - locale: string + locale: string, ): Promise> { return this.getLookupTranslation(locale); } @@ -617,6 +660,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 @@ -627,20 +679,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; @@ -649,15 +701,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; @@ -678,7 +731,7 @@ class I18nManager< private resolveLookupOptions( options: LookupOptions = {} as LookupOptions, - translationLocale?: string + translationLocale?: string, ) { if (!options.$locale) { return options; @@ -688,7 +741,7 @@ class I18nManager< $locale: translationLocale ?? this.resolveCacheLocale(options.$locale) ?? - this.resolveLocale(options.$locale), + this._resolveLocale(options.$locale), }; } @@ -718,17 +771,17 @@ class I18nManager< private async dictionaryRuntimeTranslate( locale: Locale, id: DictionaryKey, - sourceEntry: DictionaryEntry + sourceEntry: DictionaryEntry, ): Promise { // Runtime translation 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.`, ); } @@ -749,9 +802,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, @@ -772,7 +825,7 @@ export { I18nManager }; * @returns The standardized config */ function standardizeConfig( - config: I18nManagerConstructorParams + config: I18nManagerConstructorParams, ) { const gtServicesEnabled = getGTServicesEnabled(config); @@ -783,7 +836,7 @@ function standardizeConfig( }); return { - environment: config.environment || 'production', + environment: config.environment || "production", enableI18n: config.enableI18n !== undefined ? config.enableI18n : true, projectId: config.projectId, devApiKey: config.devApiKey, @@ -818,6 +871,31 @@ function dedupeLocales({ }; } +/** + * Remove any invalid locales from initial translations + */ +function filterInitialTranslations( + initialTranslations: Record>, + { locales, customMapping }: LocaleConfig, +) { + return Object.fromEntries( + Object.entries(initialTranslations) + .map(([locale, translations]) => [ + determineLocale(locale, locales, customMapping), + translations, + ]) + .filter(([locale]) => { + if (locale != null) { + return true; + } else { + console.warn( + `I18nManager: filterInitialTranslations(): locale ${locale} is not valid. Removing from initial translations.`, + ); + return false; + } + }), + ); +} /** * Standardize all locales in config * Only apply if using GT services @@ -831,7 +909,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) { @@ -845,13 +923,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 { @@ -871,7 +949,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; @@ -899,7 +977,7 @@ function resolvePrefetchEntriesByLocale( * Helper function for creating a translation loader */ function createTranslationLoader( - params: I18nManagerConstructorParams + params: I18nManagerConstructorParams, ) { return routeCreateTranslationLoader({ loadTranslations: params.loadTranslations, @@ -918,7 +996,7 @@ function createTranslationLoader( * Helper function for creating a dictionary loader */ function createDictionaryLoader( - params: I18nManagerConstructorParams + params: I18nManagerConstructorParams, ): DictionaryLoader { return params.loadDictionary ?? (() => Promise.resolve({})); } diff --git a/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts b/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts index 73c36309a..fa85cc3ac 100644 --- a/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts +++ b/packages/i18n/src/i18n-manager/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 "../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/i18n-manager/singleton-operations.ts b/packages/i18n/src/i18n-manager/singleton-operations.ts index 5663c9003..6af9f5329 100644 --- a/packages/i18n/src/i18n-manager/singleton-operations.ts +++ b/packages/i18n/src/i18n-manager/singleton-operations.ts @@ -1,15 +1,17 @@ -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 type { ConditionStore } from "./types"; // Singleton instance of I18nManager let i18nManager: I18nManager | undefined = undefined; let fallbackDefaultLocale: string = libraryDefaultLocale; +let fallbackEnableI18n: boolean = true; // Used before wrapper runtimes install a condition store; tracks the active manager default. const fallbackConditionStore: ConditionStore = { getLocale: () => fallbackDefaultLocale, + getEnableI18n: () => fallbackEnableI18n, }; let conditionStore: ConditionStore = fallbackConditionStore; @@ -17,6 +19,8 @@ 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 @@ -33,6 +37,22 @@ export function getI18nManager(): return i18nManager; } +/** + * 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, +): void { + i18nManager = i18nManagerInstance as unknown as I18nManager; + fallbackDefaultLocale = i18nManagerInstance.getDefaultLocale(); + resetConditionStore(); +} + /** * Resolve the current locale from the configured runtime condition source. */ @@ -53,17 +73,3 @@ export function setConditionStore(nextConditionStore: ConditionStore): void { 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. - */ -export function setI18nManager( - i18nManagerInstance: I18nManager -): void { - i18nManager = i18nManagerInstance as unknown as I18nManager; - fallbackDefaultLocale = i18nManagerInstance.getDefaultLocale(); - resetConditionStore(); -} diff --git a/packages/i18n/src/i18n-manager/translations-manager/Cache.ts b/packages/i18n/src/i18n-manager/translations-manager/Cache.ts index b3787863f..d003aff18 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/Cache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/Cache.ts @@ -1,7 +1,7 @@ import type { LifecycleCallback, LifecycleParam, -} from '../lifecycle-hooks/types'; +} from "../lifecycle-hooks/types"; function isPlainObject(value: unknown): value is Record { if (value == null || typeof value !== 'object') return false; @@ -32,7 +32,7 @@ abstract class Cache< /** * Cache of items */ - private cache: Record = {} as Record< + public cache: Record = {} as Record< CacheKey, CacheValue >; @@ -69,13 +69,17 @@ abstract class Cache< */ constructor( init: Record, - lifecycle?: LifecycleParam + lifecycle?: LifecycleParam, ) { this.cache = structuredClone(init); this.onHit = lifecycle?.onHit; this.onMiss = lifecycle?.onMiss; } + public updateCache(cache: Record): void { + this.cache = { ...this.cache, ...structuredClone(cache) }; + } + /** * Set the value for a key */ diff --git a/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts b/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts index 7b887a061..3bd084f8d 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts @@ -1,14 +1,14 @@ -import { Cache } from './Cache'; -import { Hash, TranslationKey, TranslationsCache } from './TranslationsCache'; -import type { TranslationBatchConfig } from './TranslationsCache'; -import { Translation } from './utils/types/translation-data'; -import { DEFAULT_CACHE_EXPIRY_TIME } from './utils/constants'; -import { CreateTranslateMany } from './utils/createTranslateMany'; +import { Cache } from "./Cache"; +import { Hash, TranslationKey, TranslationsCache } from "./TranslationsCache"; +import type { TranslationBatchConfig } from "./TranslationsCache"; +import { Translation } from "./utils/types/translation-data"; +import { DEFAULT_CACHE_EXPIRY_TIME } from "./utils/constants"; +import { CreateTranslateMany } from "./utils/createTranslateMany"; import type { LifecycleParam, LocalesCacheLifecycleCallbacks, TranslationsCacheLifecycleCallback, -} from '../lifecycle-hooks/types'; +} from "../lifecycle-hooks/types"; /** * Just being explicit about the purpose of this type @@ -32,7 +32,7 @@ export type CacheEntry = { * TODO: rename this because we are no longer doing try/catch around the translation loader */ export type SafeTranslationsLoader = ( - locale: string + locale: string, ) => Promise>; /** @@ -42,7 +42,7 @@ export class LocalesCache extends Cache< Locale, Locale, CacheEntry, - CacheEntry['translationsCache'] + CacheEntry["translationsCache"] > { /** * Translation loader function @@ -81,6 +81,7 @@ export class LocalesCache extends Cache< batchConfig, loadTranslations, createTranslateMany, + initialTranslations, lifecycle: { onLocalesCacheHit: onHit, onLocalesCacheMiss: onMiss, @@ -90,6 +91,7 @@ export class LocalesCache extends Cache< }: { init?: Record>; ttl?: number | null; + initialTranslations?: Record>; batchConfig?: TranslationBatchConfig; createTranslateMany: CreateTranslateMany; loadTranslations: SafeTranslationsLoader; @@ -105,6 +107,13 @@ export class LocalesCache extends Cache< this._batchConfig = batchConfig; this._onTranslationsCacheHit = onTranslationsCacheHit; this._onTranslationsCacheMiss = onTranslationsCacheMiss; + + // Populate the cache with the initial translations + for (const [locale, translations] of Object.entries( + initialTranslations ?? {}, + )) { + this.setCache(locale, this._createCacheEntry(locale, translations)); + } } /** @@ -113,8 +122,8 @@ export class LocalesCache extends Cache< * @returns The translations */ public get( - key: Locale - ): CacheEntry['translationsCache'] | undefined { + key: Locale, + ): CacheEntry["translationsCache"] | undefined { // Get the cache entry const entry = this.getCache(key); if (!entry || (entry.expiresAt > 0 && entry.expiresAt < Date.now())) { @@ -141,8 +150,8 @@ export class LocalesCache extends Cache< * @returns The translations cache */ public async miss( - key: Locale - ): Promise['translationsCache']> { + key: Locale, + ): Promise["translationsCache"]> { // Miss the cache const cacheValue = await this.missCache(key); @@ -177,14 +186,44 @@ export class LocalesCache extends Cache< * @returns The cache entry */ protected async fallback( - locale: Locale + locale: Locale, ): Promise> { - // Fetch translations - const translationsPromise = this._translationLoader(locale); + const translations = await this._translationLoader(locale); + return this._createCacheEntry(locale, translations); + } + + public update( + translationsObj: Record>, + ): void { + for (const [locale, translations] of Object.entries(translationsObj)) { + const cacheEntry = this.getCache(this.genKey(locale)); + if (cacheEntry) { + // TODO: if a specific translation entry changes, there would be no subscriber update triggered here + cacheEntry.translationsCache.updateCache(translations); + cacheEntry.expiresAt = this.ttl < 0 ? this.ttl : Date.now() + this.ttl; + } else { + // TODO: would we be orphaning subscribers here? or is it okay? + // perhaps we can merge in existing translations with new translations, but ofc could lead to stale data + this.setCache(locale, this._createCacheEntry(locale, translations)); + } + } + } + + // ===== PRIVATE METHODS ===== // - // Cache the translations and expiry timestamp + /** + * Create the cache entry for a given locale + * @param locale - The locale + * @param initialTranslations - The initial translations + * @returns The cache entry + */ + private _createCacheEntry( + locale: Locale, + initialTranslations: Record, + ): CacheEntry { + // Cache the promise and expiry timestamp const translationsCache = new TranslationsCache({ - init: await translationsPromise, + init: initialTranslations, lifecycle: this._createTranslationsCacheLifecycle(locale), translateMany: this._createTranslateMany(locale), batchConfig: this._batchConfig, @@ -194,15 +233,13 @@ export class LocalesCache extends Cache< return { translationsCache, expiresAt }; } - // ===== PRIVATE METHODS ===== // - /** * Create the translations cache lifecycle * @param locale - The locale * @returns The translations cache lifecycle */ private _createTranslationsCacheLifecycle( - locale: Locale + locale: Locale, ): LifecycleParam< TranslationKey, Hash, diff --git a/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts b/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts index 7654f885e..484452199 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts @@ -1,14 +1,14 @@ -import { LookupOptions } from '../../translation-functions/types/options'; -import { Cache } from './Cache'; -import type { LifecycleParam } from '../lifecycle-hooks/types'; -import { Translation } from './utils/types/translation-data'; -import { hashMessage } from '../../utils/hashMessage'; -import type { Content } from '@generaltranslation/format/types'; +import { LookupOptions } from "../../translation-functions/types/options"; +import { Cache } from "./Cache"; +import type { LifecycleParam } from "../lifecycle-hooks/types"; +import { Translation } from "./utils/types/translation-data"; +import { hashMessage } from "../../utils/hashMessage"; +import type { Content } from "@generaltranslation/format/types"; import type { EntryMetadata, TranslateManyEntry, TranslationResult, -} from 'generaltranslation/types'; +} from "generaltranslation/types"; // See gt-next const MAX_BATCH_SIZE = 25; @@ -41,20 +41,20 @@ function getPositiveInteger(value: number | undefined, defaultValue: number) { } function normalizeBatchConfig( - batchConfig?: TranslationBatchConfig + batchConfig?: TranslationBatchConfig, ): Required { return { maxConcurrentRequests: getPositiveInteger( batchConfig?.maxConcurrentRequests, - DEFAULT_BATCH_CONFIG.maxConcurrentRequests + DEFAULT_BATCH_CONFIG.maxConcurrentRequests, ), maxBatchSize: getPositiveInteger( batchConfig?.maxBatchSize, - DEFAULT_BATCH_CONFIG.maxBatchSize + DEFAULT_BATCH_CONFIG.maxBatchSize, ), batchInterval: getPositiveValue( batchConfig?.batchInterval, - DEFAULT_BATCH_CONFIG.batchInterval + DEFAULT_BATCH_CONFIG.batchInterval, ), }; } @@ -90,7 +90,7 @@ type QueueEntry = { * TranslateMany call signature */ export type TranslateMany = ( - sources: Record + sources: Record, ) => Promise>; /** @@ -164,7 +164,7 @@ export class TranslationsCache< * @returns The translation value */ public get( - key: TranslationKey + key: TranslationKey, ): T | undefined { const value = this.getCache(key) as T | undefined; if (value != null && this.onHit) { @@ -184,7 +184,7 @@ export class TranslationsCache< * @returns The translation value */ public async miss( - key: TranslationKey + key: TranslationKey, ): Promise { const value = await this.missCache(key); if (value != null && this.onMiss) { @@ -213,7 +213,7 @@ export class TranslationsCache< * @returns The fallback value */ protected fallback( - key: TranslationKey + key: TranslationKey, ): Promise { // Add translation request to queue const translationPromise = this._enqueueTranslation(key); @@ -277,7 +277,7 @@ export class TranslationsCache< * @returns {Promise} The translation promise */ private _enqueueTranslation( - key: TranslationKey + key: TranslationKey, ): Promise { const hash = this.genKey(key); const options = key.options; @@ -314,14 +314,14 @@ export class TranslationsCache< * @param {QueueEntry[]} batch - The batch of requests to send */ private async _sendBatchRequest( - batch: QueueEntry[] + batch: QueueEntry[], ): Promise { this._activeRequests++; const requests = convertBatchToTranslateManyParams(batch); const response = await this._sendBatchRequestWithErrorHandling( batch, - requests + requests, ); if (response) { this._handleTranslationResponse(batch, response); @@ -335,7 +335,7 @@ export class TranslationsCache< */ private async _sendBatchRequestWithErrorHandling( batch: QueueEntry[], - requests: Record + requests: Record, ): Promise | undefined> { try { return await this._translateMany(requests); @@ -352,7 +352,7 @@ export class TranslationsCache< */ private _handleTranslationResponse( batch: QueueEntry[], - response: Awaited> + response: Awaited>, ): void { for (const entry of batch) { const { key } = entry; diff --git a/packages/i18n/src/i18n-manager/types.ts b/packages/i18n/src/i18n-manager/types.ts index 0afe73f99..1664ce928 100644 --- a/packages/i18n/src/i18n-manager/types.ts +++ b/packages/i18n/src/i18n-manager/types.ts @@ -1,12 +1,16 @@ -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 { Dictionary } from './translations-manager/DictionaryCache'; -import type { DictionaryLoader } from './translations-manager/LocalesDictionaryCache'; -import type { TranslationBatchConfig } from './translations-manager/TranslationsCache'; +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 { Dictionary } from "./translations-manager/DictionaryCache"; +import type { DictionaryLoader } from "./translations-manager/LocalesDictionaryCache"; +import type { + Hash, + TranslationBatchConfig, +} from "./translations-manager/TranslationsCache"; +import { Locale } from "./translations-manager/LocalesCache"; export type DictionaryConfig = | { @@ -29,16 +33,17 @@ 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; + initialTranslations?: Record>; // Cache lifecycle hooks /** @deprecated - move to subscription api instead */ lifecycle?: LifecycleCallbacks; @@ -48,10 +53,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; @@ -70,7 +81,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,6 +95,7 @@ export type ConditionStoreConfig = { */ export interface ConditionStore { getLocale(): string; + getEnableI18n(): boolean; } /** @@ -91,6 +103,7 @@ export interface ConditionStore { */ export interface WritableConditionStore extends ConditionStore { setLocale(locale: string): void; + setEnableI18n(enabled: boolean): void; } /** diff --git a/packages/i18n/src/internal-types.ts b/packages/i18n/src/internal-types.ts index dd1b6c6ac..f43e1a662 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, + LocaleResolverConfig, ConditionStore, WritableConditionStore, ScopedConditionStore, Dictionary, DictionaryLoader, DictionaryConfig, -} from './i18n-manager/types'; -export type { LocaleCandidates } from './i18n-manager/condition-store/localeResolver'; +} from "./i18n-manager/types"; +export type { LocaleCandidates } from "./i18n-manager/condition-store/localeResolver"; export type { DictionaryValue, DictionaryEntry, @@ -21,10 +21,15 @@ export type { DictionaryOptions, DictionaryPath, DictionaryKey, -} from './i18n-manager/translations-manager/DictionaryCache'; +} from "./i18n-manager/translations-manager/DictionaryCache"; // 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..69089037f 100644 --- a/packages/i18n/src/internal.ts +++ b/packages/i18n/src/internal.ts @@ -1,4 +1,21 @@ -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, + resolveDictionaryLookupOptions, +} from "./i18n-manager/translations-manager/utils/dictionary-helpers"; diff --git a/packages/i18n/src/translation-functions/internal/getTranslations.ts b/packages/i18n/src/translation-functions/internal/getTranslations.ts index 17cc025ac..f9048667c 100644 --- a/packages/i18n/src/translation-functions/internal/getTranslations.ts +++ b/packages/i18n/src/translation-functions/internal/getTranslations.ts @@ -4,19 +4,9 @@ import { } 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, - isDictionaryValue, - resolveDictionaryLookupOptions, -} from '../../i18n-manager/translations-manager/utils/dictionary-helpers'; +import { renderDictionaryEntry } from './renderDictionaryEntry'; +import { renderDictionaryObject } from './renderDictionaryObject'; +import { resolveDictionaryLookupOptions } from '../../i18n-manager/translations-manager/utils/dictionary-helpers'; import type { DictionaryObjectTranslation } from '../types/functions'; /** @@ -72,7 +62,7 @@ export async function getTranslations(): Promise { sourceEntry.entry, dictionaryOptions ); - return renderEntry({ + return renderDictionaryEntry({ sourceLocale, targetLocale: locale, sourceEntry, @@ -99,128 +89,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 (isDictionaryValue(targetObject)) { - if (!isDictionaryValue(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 (isDictionaryValue(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 (!isDictionaryValue(sourceObject)) { - return renderObject({ sourceObject, targetObject }); - } - const result: Record = {}; - const keys = new Set([ - ...Object.keys(sourceObject), - ...(isDictionaryValue(targetObject) ? Object.keys(targetObject) : []), - ]); - - for (const key of Array.from(keys)) { - const renderedChild = renderObject({ - sourceObject: sourceObject[key], - targetObject: isDictionaryValue(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/sync-translation-resolution.ts b/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts index 646053371..f3e62b4aa 100644 --- a/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts +++ b/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts @@ -1,8 +1,8 @@ -import { InlineTranslationOptions } from '../types/options'; +import { InlineTranslationOptions } from "../types/options"; import { resolveStringContent, resolveStringContentWithFallback, -} from './helpers'; +} from "./helpers"; /** * Synchronously resolve a translation for a given message and options @@ -14,9 +14,9 @@ import { export function resolveTranslationSync( locale: string, message: string, - options: InlineTranslationOptions = {} + options: InlineTranslationOptions = {}, ) { - return resolveStringContent(locale, message, { $format: 'ICU', ...options }); + return resolveStringContent(locale, message, { $format: "ICU", ...options }); } /** @@ -29,10 +29,10 @@ export function resolveTranslationSync( export function resolveTranslationSyncWithFallback( locale: string, message: string, - options: InlineTranslationOptions = {} + options: InlineTranslationOptions = {}, ) { 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..fe5350c00 100644 --- a/packages/i18n/src/translation-functions/types/options.ts +++ b/packages/i18n/src/translation-functions/types/options.ts @@ -1,9 +1,11 @@ import type { DataFormat, StringFormat, -} from '@generaltranslation/format/types'; +} 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 @@ -65,7 +76,7 @@ export type EncodedTranslationOptions = BaseTranslationOptions & { export type RuntimeTranslationOptions = { $locale?: string; $format?: DataFormat; -} & Omit; +} & Omit; /** * Options for JSX translation @@ -73,7 +84,7 @@ export type RuntimeTranslationOptions = { */ export type JsxTranslationOptions = { // TODO: make this required, but internally, not user facing - $format?: 'JSX'; + $format?: "JSX"; $context?: string; $id?: string; $_hash?: string; @@ -83,18 +94,19 @@ 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'; + $format: "JSX"; $locale?: string; }); export type DictionaryLookupOptions = Omit< InlineTranslationOptions, - '$format' + "$format" > & { $format: StringFormat; }; @@ -104,11 +116,11 @@ export type ResolutionOptions = { * The locale to use for formatting looking up and formatting the message. */ $locale?: string; -} & (T extends 'JSX' +} & (T extends "JSX" ? JsxTranslationOptions & { - $format?: 'JSX'; + $format?: "JSX"; } - : Omit & { + : Omit & { $format?: T; }); @@ -117,7 +129,7 @@ export type ResolutionOptions = { */ export type NormalizedLookupOptions = Omit< ResolutionOptions, - '$format' | '$locale' + "$format" | "$locale" > & { $format: T; $locale: string; diff --git a/packages/i18n/src/utils/extractVariables.ts b/packages/i18n/src/utils/extractVariables.ts index 168ec54d7..6ba7e9ead 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'; +import { BaseTranslationOptions } from "../translation-functions/types/options"; + /** * Given an object of options, returns an object with no gt-related options * @@ -6,21 +7,21 @@ import { BaseTranslationOptions } from '../translation-functions/types/options'; * TODO: next major version, options should be Record */ export function extractVariables( - options: T + options: T, ): BaseTranslationOptions { return Object.fromEntries( Object.entries(options).filter( ([key]) => - key !== '$id' && - key !== '$context' && - key !== '$maxChars' && - key !== '$hash' && // this is already being done in @gt/react-core - key !== '$_hash' && - key !== '$_source' && - key !== '$_fallback' && - key !== '$format' && - key !== '$_locales' && - key !== '$locale' - ) + key !== "$id" && + key !== "$context" && + key !== "$maxChars" && + key !== "$hash" && // this is already being done in @gt/react-core + key !== "$_hash" && + key !== "$_source" && + key !== "$_fallback" && + key !== "$format" && + key !== "$_locales" && + key !== "$locale", + ), ); } diff --git a/packages/i18n/src/utils/listenerKeys.ts b/packages/i18n/src/utils/listenerKeys.ts new file mode 100644 index 000000000..505e72e6f --- /dev/null +++ b/packages/i18n/src/utils/listenerKeys.ts @@ -0,0 +1,34 @@ +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..1b4cd21c3 100644 --- a/packages/node/src/async-i18n-manager/AsyncConditionStore.ts +++ b/packages/node/src/async-i18n-manager/AsyncConditionStore.ts @@ -1,19 +1,19 @@ -import { AsyncLocalStorage } from 'node:async_hooks'; -import { createLocaleResolver } from 'gt-i18n/internal'; +import { AsyncLocalStorage } from "node:async_hooks"; +import { createLocaleResolver } from "gt-i18n/internal"; import type { LocaleCandidates, - ConditionStoreConfig, + LocaleResolverConfig, ScopedConditionStore, -} from 'gt-i18n/internal/types'; +} from "gt-i18n/internal/types"; type Store = { locale: string; }; const OUTSIDE_SCOPE_MESSAGE = - 'AsyncConditionStore: getLocale() called outside of a withGT() scope.'; + "AsyncConditionStore: getLocale() called outside of a withGT() scope."; -type AsyncConditionStoreConstructorParams = ConditionStoreConfig & { +type AsyncConditionStoreConstructorParams = LocaleResolverConfig & { store?: AsyncLocalStorage; }; @@ -45,7 +45,7 @@ export class AsyncConditionStore implements ScopedConditionStore { 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(); } diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 1a3769af3..1df600f8b 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/branches/plurals/Plural.tsx b/packages/react-core/src/branches/plurals/Plural.tsx index 340928f94..731bb84ec 100644 --- a/packages/react-core/src/branches/plurals/Plural.tsx +++ b/packages/react-core/src/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/branches/plurals/getPluralBranch.ts b/packages/react-core/src/branches/plurals/getPluralBranch.ts index a0b52ee96..3b7bb0d7c 100644 --- a/packages/react-core/src/branches/plurals/getPluralBranch.ts +++ b/packages/react-core/src/branches/plurals/getPluralBranch.ts @@ -1,7 +1,8 @@ import { getPluralForm, isAcceptedPluralForm, -} from 'generaltranslation/internal'; +} from "generaltranslation/internal"; +import { ReactNode } from "react"; /** * Main function to get the appropriate branch based on the provided number and branches. @@ -10,14 +11,14 @@ 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 branchName = ""; let branch = null; - if (typeof n === 'number' && !branch && branches) { + if (typeof n === "number" && !branch && branches) { const pluralForms = Object.keys(branches).filter(isAcceptedPluralForm); branchName = getPluralForm(n, pluralForms, locales); } diff --git a/packages/react-core/src/context.ts b/packages/react-core/src/context.ts new file mode 100644 index 000000000..f4705fd88 --- /dev/null +++ b/packages/react-core/src/context.ts @@ -0,0 +1,62 @@ +// ===== Components ===== // +export { Branch } from "./refactor/components/branches/Branch"; +export { Plural } from "./refactor/components/branches/Plural"; +export { Derive } from "./refactor/components/derivation/Derive"; +export { LocaleSelector } from "./refactor/components/helpers/LocaleSelector"; +export { T } from "./refactor/components/translation/T"; +export { Currency } from "./refactor/components/variables/Currency"; +export { DateTime } from "./refactor/components/variables/DateTime"; +export { Num } from "./refactor/components/variables/Num"; +export { RelativeTime } from "./refactor/components/variables/RelativeTime"; +export { Var } from "./refactor/components/variables/Var"; + +// ===== Hooks ===== // +export { + useLocale, + useSetLocale, + useEnableI18n, + useSetEnableI18n, +} from "./refactor/hooks/context-hooks"; +export { + useCustomMapping, + useDefaultLocale, + useLocales, +} from "./refactor/hooks/external-store-hooks"; +export { useGT } from "./refactor/hooks/useGT"; +export { useMessages } from "./refactor/hooks/useMessages"; +export { useTranslations } from "./refactor/hooks/useTranslations"; +export { useLocaleSelector } from "./refactor/hooks/useLocaleSelector"; +export { useFormatLocales } from "./refactor/hooks/utils"; + +// ===== Functions ===== // +export { getTranslationsSnapshot } from "./refactor/functions/helpers/getTranslationsSnapshot"; + +// ===== Internal ===== // +export { InternalGTProvider } from "./refactor/context/provider/InternalGTProvider"; +export { internalInitializeGTSPA } from "./refactor/setup/initializeGTSPA"; +export { internalInitializeGTSSR } from "./refactor/setup/initializeGTSSR"; +export { + getI18nStore, + setI18nStore, +} from "./refactor/i18n-store/singleton-operations"; +export { + setRenderStrategy, + getRenderStrategy, + setStoresInitialized, +} from "./refactor/setup/globals"; +export { + getConditionStore, + setConditionStore, +} from "./refactor/condition-store/singleton-operations"; +export { + getI18nManager, + setI18nManager, +} from "./refactor/i18n-manager/singleton-operations"; +export { I18nStore } from "./refactor/i18n-store/I18nStore"; +export { ReactI18nManager } from "./refactor/i18n-manager/ReactI18nManager"; +export { ReactConditionStore } from "./refactor/condition-store/ReactConditionStore"; +export type { ReactConditionStoreParams } from "./refactor/condition-store/ReactConditionStore"; +export type { I18nStoreParams } from "./refactor/i18n-store/I18nStore"; +export type { InternalGTProviderProps } from "./refactor/context/provider/InternalGTProvider"; +export type { OverrideSetLocaleType } from "./refactor/i18n-store/storeTypes"; +export type { ReactI18nManagerParams } from "./refactor/i18n-manager/ReactI18nManager"; diff --git a/packages/react-core/src/internal.ts b/packages/react-core/src/internal.ts index c0a4ea2b0..bffcdea62 100644 --- a/packages/react-core/src/internal.ts +++ b/packages/react-core/src/internal.ts @@ -1,43 +1,43 @@ // 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 "./internal/flattenDictionary"; +import addGTIdentifier from "./internal/addGTIdentifier"; +import { removeInjectedT } from "./internal/removeInjectedT"; +import writeChildrenAsObjects from "./internal/writeChildrenAsObjects"; +import getPluralBranch from "./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 "./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"; 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 "./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"; -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/provider/GTContext.ts b/packages/react-core/src/provider/GTContext.ts index ce3f260c2..056a18a2e 100644 --- a/packages/react-core/src/provider/GTContext.ts +++ b/packages/react-core/src/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/__tests__/i18n-store.test.ts b/packages/react-core/src/provider/__tests__/i18n-store.test.ts new file mode 100644 index 000000000..56bd0c845 --- /dev/null +++ b/packages/react-core/src/provider/__tests__/i18n-store.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest'; +import { I18nManager } from 'gt-i18n/internal'; +import { I18nExternalStore } from '../../external-store/store/I18nExternalStore'; +import { ProviderConditionStore } from '../../external-store/store/ProviderConditionStore'; + +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 createConditionStore(locale = 'en') { + return new ProviderConditionStore({ + defaultLocale: 'en', + locales: ['en', 'fr', 'es'], + locale, + }); +} + +describe('external store i18n wiring', () => { + it('notifies only locale subscribers when locale changes', () => { + const conditionStore = createConditionStore('en'); + const localeListener = vi.fn(); + const unsubscribeLocale = + conditionStore.subscribeToLocale(localeListener); + + conditionStore.setLocale('fr'); + + expect(conditionStore.getLocale()).toBe('fr'); + expect(localeListener).toHaveBeenCalledTimes(1); + + unsubscribeLocale(); + }); + + it('notifies region subscribers when region changes', () => { + const conditionStore = createConditionStore('en'); + const regionListener = vi.fn(); + const unsubscribeRegion = + conditionStore.subscribeToRegion(regionListener); + + conditionStore.setRegion('US'); + + expect(conditionStore.getRegion()).toBe('US'); + expect(regionListener).toHaveBeenCalledTimes(1); + + unsubscribeRegion(); + }); + + it('notifies translation subscribers for matching cache updates', async () => { + const manager = createManager(); + const externalStore = new I18nExternalStore({ i18nManager: manager }); + const matchingListener = vi.fn(); + const otherHashListener = vi.fn(); + const otherLocaleListener = vi.fn(); + + const unsubscribeMatching = externalStore.subscribeToTranslate( + { + locale: 'fr', + message: 'Hello', + options: { $format: 'ICU', $_hash: 'hash' }, + }, + matchingListener + ); + const unsubscribeOtherHash = externalStore.subscribeToTranslate( + { + locale: 'fr', + message: 'Other', + options: { $format: 'ICU', $_hash: 'other' }, + }, + otherHashListener + ); + const unsubscribeOtherLocale = externalStore.subscribeToTranslate( + { + locale: 'es', + message: 'Hello', + options: { $format: 'ICU', $_hash: 'hash' }, + }, + otherLocaleListener + ); + + await manager.lookupTranslationWithFallback('fr', 'Hello', { + $format: 'ICU', + $_hash: 'hash', + }); + + 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 manager = createManager(); + const externalStore = new I18nExternalStore({ i18nManager: manager }); + 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 = externalStore.getTranslateManySnapshot(lookups); + expect(externalStore.getTranslateManySnapshot(lookups)).toBe( + initialSnapshot + ); + + const unsubscribe = externalStore.subscribeToTranslateMany( + lookups, + listener + ); + + await manager.lookupTranslationWithFallback('fr', 'Hello', { + $format: 'ICU', + $_hash: 'hash', + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(externalStore.getTranslateManySnapshot(lookups)).toEqual([ + 'Bonjour', + undefined, + ]); + + unsubscribe(); + }); + + it('notifies dictionary entry subscribers for matching cache misses', async () => { + const manager = createManager(); + const externalStore = new I18nExternalStore({ i18nManager: manager }); + const matchingListener = vi.fn(); + const otherListener = vi.fn(); + + const unsubscribeMatching = + externalStore.subscribeToDictionaryEntry( + { locale: 'fr', id: 'greeting' }, + matchingListener + ); + const unsubscribeOther = + externalStore.subscribeToDictionaryEntry( + { locale: 'fr', id: 'other' }, + otherListener + ); + + await manager.lookupDictionaryWithFallback('fr', 'greeting'); + + expect(matchingListener).toHaveBeenCalledTimes(1); + expect(otherListener).not.toHaveBeenCalled(); + + unsubscribeMatching(); + unsubscribeOther(); + }); + + it('notifies dictionary object subscribers for matching cache misses', async () => { + const manager = createManager(); + const externalStore = new I18nExternalStore({ i18nManager: manager }); + const matchingListener = vi.fn(); + const otherListener = vi.fn(); + + const unsubscribeMatching = + externalStore.subscribeToDictionaryObject( + { locale: 'fr', id: 'user.profile' }, + matchingListener + ); + const unsubscribeOther = + externalStore.subscribeToDictionaryObject( + { locale: 'fr', id: 'other' }, + otherListener + ); + + await manager.lookupDictionaryObjWithFallback('fr', 'user.profile'); + + expect(matchingListener).toHaveBeenCalledTimes(1); + expect(otherListener).not.toHaveBeenCalled(); + + unsubscribeMatching(); + unsubscribeOther(); + }); +}); diff --git a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts b/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts index c26518c94..5771c5202 100644 --- a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts +++ b/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts @@ -1,25 +1,26 @@ -import { hashSource } from 'generaltranslation/id'; +import { hashSource } from "generaltranslation/id"; import { InlineTranslationOptions, Translations, _Messages, _Message, -} from '../../../types-dir/types'; -import type { StringFormat } from '@generaltranslation/format/types'; -import { TranslateIcuCallback } from '../../../types-dir/runtime'; -import { GT } from 'generaltranslation'; +} from "../../../types-dir/types"; +import type { StringFormat } from "@generaltranslation/format/types"; +import { TranslateIcuCallback } from "../../../types-dir/runtime"; +import { GT } from "generaltranslation"; import { createStringRenderError, createStringRenderWarning, createStringTranslationError, -} from '../../../errors-dir/createErrors'; -import { decodeMsg, decodeOptions } from '../../../messages/messages'; +} from "../../../errors-dir/createErrors"; +import { decodeMsg, decodeOptions } from "../../../messages/messages"; import { extractVars, condenseVars, indexVars, VAR_IDENTIFIER, -} from 'generaltranslation/internal'; +} from "generaltranslation/internal"; +import { InlineResolveOptions } from "gt-i18n/types"; type MReturnType = T extends string ? string : T; @@ -50,17 +51,17 @@ export default function useCreateInternalUseGTFunction({ translationRequired: boolean; developmentApiEnabled: boolean; registerIcuForTranslation: TranslateIcuCallback; - environment: 'development' | 'production' | 'test'; + environment: "development" | "production" | "test"; }): { _gtFunction: ( message: string, options?: InlineTranslationOptions, - preloadedTranslations?: Translations + preloadedTranslations?: Translations, ) => string; _mFunction: ( message: T, - options?: Record, - preloadedTranslations?: Translations + options?: InlineResolveOptions, + preloadedTranslations?: Translations, ) => T extends string ? string : T; _filterMessagesForPreload: (_messages: _Messages) => _Messages; _preloadMessages: (_messages: _Messages) => Promise; @@ -86,7 +87,7 @@ export default function useCreateInternalUseGTFunction({ }: RenderMessageParams) { try { // (1) Try to format message - const declaredVars = extractVars(fallback || ''); + const declaredVars = extractVars(fallback || ""); const formattedMessage = gt.formatMessage( Object.keys(declaredVars).length ? condenseVars(message) : message, { @@ -94,16 +95,16 @@ export default function useCreateInternalUseGTFunction({ variables: { ...variables, ...declaredVars, - [VAR_IDENTIFIER]: 'other', + [VAR_IDENTIFIER]: "other", }, dataFormat: format, - } + }, ); // Apply cutoff formatting const cutoffMessage = gt.formatCutoff(formattedMessage, { maxChars }); return cutoffMessage; } catch (error) { - if (environment === 'production') { + if (environment === "production") { console.warn(createStringRenderWarning(message, id, error)); } else { // (3) If no fallback, throw error (non-prod) @@ -138,9 +139,9 @@ export default function useCreateInternalUseGTFunction({ $maxChars?: number; $id?: string; $_hash?: string; - } = {} + } = {}, ) { - if (!message || typeof message !== 'string') return null; + if (!message || typeof message !== "string") return null; const { $id: id, @@ -151,13 +152,13 @@ export default function useCreateInternalUseGTFunction({ ...variables } = options; const format = - typeof rawFormat === 'string' ? (rawFormat as StringFormat) : undefined; + typeof rawFormat === "string" ? (rawFormat as StringFormat) : undefined; // Update renderContent to use actual variables const renderMessage = ( msg: string, locales: string[], - fallback?: string + fallback?: string, ) => { return renderMessageHelper({ message: msg, @@ -177,7 +178,7 @@ export default function useCreateInternalUseGTFunction({ ...(context && { context }), ...(maxChars != null && { maxChars: Math.abs(maxChars) }), ...(id && { id }), - dataFormat: format || 'ICU', + dataFormat: format || "ICU", }); return { @@ -194,19 +195,19 @@ export default function useCreateInternalUseGTFunction({ function getTranslationData( calculateHash: () => string, id?: string, - _hash?: string + _hash?: string, ) { let translationEntry; - let hash = ''; // empty string because 1) it has to be a string but 2) we don't always need to calculate it + let hash = ""; // empty string because 1) it has to be a string but 2) we don't always need to calculate it if (id) { translationEntry = translations?.[id]; } - if (_hash && typeof translationEntry === 'undefined') { + if (_hash && typeof translationEntry === "undefined") { hash = _hash; translationEntry = translations?.[_hash]; } // Use calculated hash to index - if (typeof translationEntry === 'undefined') { + if (typeof translationEntry === "undefined") { hash = calculateHash(); translationEntry = translations?.[hash]; } @@ -225,7 +226,7 @@ export default function useCreateInternalUseGTFunction({ const { translationEntry, hash } = getTranslationData( calculateHash, id, - _hash + _hash, ); if (!translationEntry) { result.push({ message, ...options, $_hash: hash }); @@ -244,7 +245,7 @@ export default function useCreateInternalUseGTFunction({ const { translationEntry, hash } = getTranslationData( calculateHash, id, - _hash + _hash, ); // Return if no translation needed if (translationEntry) { @@ -270,11 +271,11 @@ export default function useCreateInternalUseGTFunction({ const _gtFunction = ( message: string, options: InlineTranslationOptions = {}, - preloadedTranslations: Translations | undefined + preloadedTranslations: Translations | undefined, ) => { // ----- SET UP ----- // const init = initializeGT(message, options); - if (!init) return ''; + if (!init) return ""; const { id, context, maxChars, _hash, calculateHash, renderMessage } = init; // ----- EARLY RETURN IF TRANSLATION NOT REQUIRED ----- // @@ -286,7 +287,7 @@ export default function useCreateInternalUseGTFunction({ const { translationEntry, hash } = getTranslationData( calculateHash, id, - _hash + _hash, ); // ----- RENDER TRANSLATION ----- // @@ -300,23 +301,23 @@ export default function useCreateInternalUseGTFunction({ return renderMessage( translationEntry as string, [locale, defaultLocale], - message + message, ); } - if (typeof preloadedTranslations?.[hash] !== 'undefined') { + if (typeof preloadedTranslations?.[hash] !== "undefined") { if (preloadedTranslations?.[hash]) { return renderMessage( preloadedTranslations?.[hash] as string, [locale, defaultLocale], - message + message, ); } return renderMessage(message, [defaultLocale]); } if (!developmentApiEnabled) { - console.warn(createStringTranslationError(message, id, 'gt')); + console.warn(createStringTranslationError(message, id, "gt")); return renderMessage(message, [defaultLocale]); } @@ -327,7 +328,7 @@ export default function useCreateInternalUseGTFunction({ ...(context && { context }), ...(id && { id }), ...(maxChars != null && { maxChars }), - hash: hash || '', + hash: hash || "", }, }); @@ -336,8 +337,8 @@ export default function useCreateInternalUseGTFunction({ const _mFunction = ( encodedMsg: T, - options: Record = {}, - preloadedTranslations: Translations | undefined + options: InlineResolveOptions = {}, + preloadedTranslations: Translations | undefined, ): T extends string ? string : T => { // Return if message is not a string if (!encodedMsg) return encodedMsg as MReturnType; @@ -347,7 +348,7 @@ export default function useCreateInternalUseGTFunction({ return _gtFunction( encodedMsg, options, - preloadedTranslations + preloadedTranslations, ) as MReturnType; } @@ -367,7 +368,7 @@ export default function useCreateInternalUseGTFunction({ const renderMessage = ( msg: string, locales: string[], - fallback?: string + fallback?: string, ) => { return renderMessageHelper({ message: msg, @@ -396,23 +397,23 @@ export default function useCreateInternalUseGTFunction({ return renderMessage( translationEntry as string, [locale, defaultLocale], - $_source + $_source, ) as MReturnType; } if (!developmentApiEnabled) { console.warn( - createStringTranslationError($_source, decodeMsg(encodedMsg), 'm') + createStringTranslationError($_source, decodeMsg(encodedMsg), "m"), ); return renderMessage($_source, [defaultLocale]) as MReturnType; } - if (typeof preloadedTranslations?.[$_hash] !== 'undefined') { + if (typeof preloadedTranslations?.[$_hash] !== "undefined") { if (preloadedTranslations?.[$_hash]) { return renderMessage( preloadedTranslations?.[$_hash] as string, [locale, defaultLocale], - $_source + $_source, ) as MReturnType; } return renderMessage($_source, [defaultLocale]) as MReturnType; diff --git a/packages/react-core/src/refactor/components/branches/Branch.tsx b/packages/react-core/src/refactor/components/branches/Branch.tsx new file mode 100644 index 000000000..54485b8cb --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/components/branches/Plural.tsx b/packages/react-core/src/refactor/components/branches/Plural.tsx new file mode 100644 index 000000000..10d2e3057 --- /dev/null +++ b/packages/react-core/src/refactor/components/branches/Plural.tsx @@ -0,0 +1,40 @@ +import getPluralBranch from "../../../branches/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/refactor/components/derivation/Derive.tsx b/packages/react-core/src/refactor/components/derivation/Derive.tsx new file mode 100644 index 000000000..dd1a9383e --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/components/helpers/InternalLocaleSelector.tsx b/packages/react-core/src/refactor/components/helpers/InternalLocaleSelector.tsx new file mode 100644 index 000000000..5621a153c --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/components/helpers/LocaleSelector.tsx b/packages/react-core/src/refactor/components/helpers/LocaleSelector.tsx new file mode 100644 index 000000000..c69b47493 --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/components/translation/T.tsx b/packages/react-core/src/refactor/components/translation/T.tsx new file mode 100644 index 000000000..bc56398da --- /dev/null +++ b/packages/react-core/src/refactor/components/translation/T.tsx @@ -0,0 +1,159 @@ +import { useCallback, useMemo } from "react"; +import addGTIdentifier from "../../../internal/addGTIdentifier"; +import { removeInjectedT } from "../../../internal/removeInjectedT"; +import writeChildrenAsObjects from "../../../internal/writeChildrenAsObjects"; +import renderDefaultChildren from "../../../rendering/renderDefaultChildren"; +import renderTranslatedChildren from "../../../rendering/renderTranslatedChildren"; +import { renderVariable } from "../variables/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 "../../../types-dir/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/refactor/components/variables/Currency.tsx b/packages/react-core/src/refactor/components/variables/Currency.tsx new file mode 100644 index 000000000..059d87f96 --- /dev/null +++ b/packages/react-core/src/refactor/components/variables/Currency.tsx @@ -0,0 +1,45 @@ +import { getI18nManager } from "gt-i18n/internal"; +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 = getI18nManager().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/refactor/components/variables/DateTime.tsx b/packages/react-core/src/refactor/components/variables/DateTime.tsx new file mode 100644 index 000000000..716eb8b71 --- /dev/null +++ b/packages/react-core/src/refactor/components/variables/DateTime.tsx @@ -0,0 +1,40 @@ +import { getI18nManager } from "gt-i18n/internal"; +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 = getI18nManager().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/refactor/components/variables/Num.tsx b/packages/react-core/src/refactor/components/variables/Num.tsx new file mode 100644 index 000000000..94be0d911 --- /dev/null +++ b/packages/react-core/src/refactor/components/variables/Num.tsx @@ -0,0 +1,39 @@ +import { getI18nManager } from "gt-i18n/internal"; +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 = getI18nManager().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/refactor/components/variables/RelativeTime.tsx b/packages/react-core/src/refactor/components/variables/RelativeTime.tsx new file mode 100644 index 000000000..e04be2174 --- /dev/null +++ b/packages/react-core/src/refactor/components/variables/RelativeTime.tsx @@ -0,0 +1,75 @@ +import { getI18nManager } from "gt-i18n/internal"; +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 = getI18nManager().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/refactor/components/variables/Var.tsx b/packages/react-core/src/refactor/components/variables/Var.tsx new file mode 100644 index 000000000..74612593f --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/components/variables/renderVariable.tsx b/packages/react-core/src/refactor/components/variables/renderVariable.tsx new file mode 100644 index 000000000..032693443 --- /dev/null +++ b/packages/react-core/src/refactor/components/variables/renderVariable.tsx @@ -0,0 +1,97 @@ +import { GtInternalCurrency, Currency as GtExternalCurrency } from "./Currency"; +import { GtInternalDateTime, DateTime as GtExternalDateTime } from "./DateTime"; +import { GtInternalNum, Num as GtExternalNum } from "./Num"; +import { + GtInternalRelativeTime, + RelativeTime as GtExternalRelativeTime, +} from "./RelativeTime"; +import { computeVar, Var as GtExternalVar } from "./Var"; +import type { + RelativeTimeFormatOptions, + RenderVariable, +} from "../../../types-dir/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/refactor/condition-store/ReactConditionStore.ts b/packages/react-core/src/refactor/condition-store/ReactConditionStore.ts new file mode 100644 index 000000000..33b3b3044 --- /dev/null +++ b/packages/react-core/src/refactor/condition-store/ReactConditionStore.ts @@ -0,0 +1,45 @@ +import type { WritableConditionStore } from "gt-i18n/internal/types"; +import { getI18nManager } from "../i18n-manager/singleton-operations"; +import { ReactI18nManager } from "../i18n-manager/ReactI18nManager"; + +export type ReactConditionStoreParams = { + locale: string; + enableI18n?: boolean; +}; + +/** + * TODO: consider moving to /i18n, nothing about this seems specific to react only + */ +export class ReactConditionStore implements WritableConditionStore { + private locale: string; + private enableI18n: boolean; + + constructor({ locale, enableI18n = true }: ReactConditionStoreParams) { + let i18nManager: ReactI18nManager; + try { + i18nManager = getI18nManager(); + } catch (error) { + throw new Error( + "Failed to initialize ReactConditionStore. Reason: " + error, + ); + } + this.locale = i18nManager.determineLocale(locale); + this.enableI18n = enableI18n; + } + + getLocale = (): string => { + return this.locale; + }; + + setLocale = (locale: string): void => { + this.locale = getI18nManager().determineLocale(locale); + }; + + getEnableI18n = (): boolean => { + return this.enableI18n; + }; + + setEnableI18n = (enableI18n: boolean): void => { + this.enableI18n = enableI18n; + }; +} diff --git a/packages/react-core/src/refactor/condition-store/singleton-operations.ts b/packages/react-core/src/refactor/condition-store/singleton-operations.ts new file mode 100644 index 000000000..77ae89713 --- /dev/null +++ b/packages/react-core/src/refactor/condition-store/singleton-operations.ts @@ -0,0 +1,7 @@ +import { createConditionStoreSingleton } from "gt-i18n/internal"; +import { ReactConditionStore } from "./ReactConditionStore"; + +export const { getConditionStore, setConditionStore } = + createConditionStoreSingleton( + "ConditionStore is not initialized.", + ); diff --git a/packages/react-core/src/refactor/context/provider/GTContext.ts b/packages/react-core/src/refactor/context/provider/GTContext.ts new file mode 100644 index 000000000..7cd8f0748 --- /dev/null +++ b/packages/react-core/src/refactor/context/provider/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/refactor/context/provider/InternalGTProvider.tsx b/packages/react-core/src/refactor/context/provider/InternalGTProvider.tsx new file mode 100644 index 000000000..3fef2d201 --- /dev/null +++ b/packages/react-core/src/refactor/context/provider/InternalGTProvider.tsx @@ -0,0 +1,116 @@ +import { GTContext, GTContextType } from "./GTContext"; +import { + useCallback, + useMemo, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { + getI18nStore, + setI18nStore, +} from "../../i18n-store/singleton-operations"; +import type { OverrideSetLocaleType } from "../../i18n-store/storeTypes"; +import { setStoresInitialized, storesInitialized } from "../../setup/globals"; +import { I18nStore, I18nStoreParams } from "../../i18n-store/I18nStore"; +import { + ReactConditionStore, + ReactConditionStoreParams, +} from "../../condition-store/ReactConditionStore"; +import { setConditionStore } from "../../condition-store/singleton-operations"; + +export type InternalGTProviderProps = ReactConditionStoreParams & + I18nStoreParams & { + children?: ReactNode; + fallback?: ReactNode; + /** + * Reloads server side props when locale changes + * To reload translations only from the client, + * omit this prop + * */ + overrideSetLocale?: OverrideSetLocaleType; + /** + * From the server + */ + locale: string; + }; + +// ===== Component ===== // + +/** + * - Shared provider logic btwn client and server providers + * - It is assumed that the I18nManager, ConditionStore, and I18nStore 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 and initialTranslations are required + * + * TODO: server side: only pass newly loaded translations to the client + */ +export function InternalGTProvider({ + children, + fallback, + ...config +}: InternalGTProviderProps) { + // ------ Initialization ------ // + + if (!storesInitialized()) { + const conditionStore = new ReactConditionStore(config); + setConditionStore(conditionStore); + + const i18nStore = new I18nStore(config); + setI18nStore(i18nStore); + + setStoresInitialized(true); + } else if (config.overrideSetLocale) { + // This represents an update from server + // we only listen to it if we trigger server-side reloads on locale change + getI18nStore().updateLocale(config.locale); + } + + // ------ 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.overrideSetLocale); + + return ( + + {display ? children : fallback} + + ); +} diff --git a/packages/react-core/src/refactor/functions/helpers/getTranslationsSnapshot.ts b/packages/react-core/src/refactor/functions/helpers/getTranslationsSnapshot.ts new file mode 100644 index 000000000..f48b09ec6 --- /dev/null +++ b/packages/react-core/src/refactor/functions/helpers/getTranslationsSnapshot.ts @@ -0,0 +1,18 @@ +import { Hash, Locale } from "gt-i18n/internal/types"; +import { Translation } from "gt-i18n/types"; +import { getI18nManager } 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 = getI18nManager(); + 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/refactor/functions/translation/t.ts b/packages/react-core/src/refactor/functions/translation/t.ts new file mode 100644 index 000000000..bc832520d --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/hooks/context-hooks.ts b/packages/react-core/src/refactor/hooks/context-hooks.ts new file mode 100644 index 000000000..4842a4287 --- /dev/null +++ b/packages/react-core/src/refactor/hooks/context-hooks.ts @@ -0,0 +1,42 @@ +import { useContext } from "react"; +import { GTContext, GTContextType } from "../context/provider/GTContext"; +import { getRenderStrategy } from "../setup/globals"; +import { getConditionStore } from "../condition-store/singleton-operations"; + +/** + * @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 + return { + locale: getConditionStore().getLocale(), + enableI18n: getConditionStore().getEnableI18n(), + setLocale: getConditionStore().setLocale, + setEnableI18n: getConditionStore().setEnableI18n, + }; + } + throw new Error( + `use${property.charAt(0).toUpperCase() + property.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/refactor/hooks/external-store-hooks.ts b/packages/react-core/src/refactor/hooks/external-store-hooks.ts new file mode 100644 index 000000000..52b382654 --- /dev/null +++ b/packages/react-core/src/refactor/hooks/external-store-hooks.ts @@ -0,0 +1,148 @@ +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"; + +/** + * @internal + */ +export function useTranslate( + lookup: TranslateLookup, +): TranslateSnapshot { + const store = getI18nStore(); + const translation = useSyncExternalStore( + (listener) => store.subscribeToTranslate(lookup, listener), + () => store.getTranslateSnapshot(lookup), + () => store.getTranslateSnapshot(lookup), + ); + // TODO: add runtime translation + // if (translation == null && isDevelopmentApiEnabled()) { + if (translation == null) { + 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), + ); + translations.forEach((translation, index) => { + // TODO: this should only be executed in dev mode + if (translation == null) { + 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) { + 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) { + 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/refactor/hooks/useGT.ts b/packages/react-core/src/refactor/hooks/useGT.ts new file mode 100644 index 000000000..e4abc408d --- /dev/null +++ b/packages/react-core/src/refactor/hooks/useGT.ts @@ -0,0 +1,108 @@ +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 { getI18nManager } from "../i18n-manager/singleton-operations"; +import type { TranslateLookup } from "../i18n-store/storeTypes"; +import type { GTFunctionType, InlineTranslationOptions } from "gt-i18n/types"; +import type { + StringContent, + 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(); + + // Compiler optimization: pre-fetch translations + useSubscribeToExtractedMessages(locale, shouldTranslate, _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 = getI18nManager().lookupTranslation( + lookupOptions.$locale, + message, + lookupOptions, + ); + + // TODO: this should only be executed in dev mode + if (translation == null) { + scope.translate({ + locale: lookupOptions.$locale, + message, + options: lookupOptions, + }); + } + + return interpolateMessage({ + source: message, + target: translation, + options: lookupOptions, + sourceLocale: defaultLocale, + }); + }, + [defaultLocale, locale, scope, shouldTranslate], + ); +} + +// ----- Helpers ----- // + +function useSubscribeToExtractedMessages( + locale: string, + shouldTranslate: boolean, + messages: Message[], +) { + const lookups = useMemo(() => { + // TODO: this should only be executed in dev mode + if (!messages?.length || !shouldTranslate) { + 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]); + useTranslateMany(lookups); +} diff --git a/packages/react-core/src/refactor/hooks/useLocaleSelector.ts b/packages/react-core/src/refactor/hooks/useLocaleSelector.ts new file mode 100644 index 000000000..69e3c489e --- /dev/null +++ b/packages/react-core/src/refactor/hooks/useLocaleSelector.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from "react"; +import { useCustomMapping, useLocales } from "./external-store-hooks"; +import { useLocale, useSetLocale } from "./context-hooks"; +import { getLocaleProperties } from "generaltranslation"; + +/** + * + * 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 function useLocaleSelector(locales?: string[]) { + // Retrieve the locale, locales, and setLocale function + const contextLocales = useLocales(); + const customMapping = useCustomMapping(); + const locale = useLocale(); + const setLocale = useSetLocale(); + + // sort + const sortedLocales = useMemo(() => { + if (!contextLocales || contextLocales.length === 0) { + return []; + } + const collator = new Intl.Collator(); + return [...contextLocales].sort((a, b) => + collator.compare( + getLocaleProperties(a, locale, customMapping).nativeNameWithRegionCode, + getLocaleProperties(b, locale, customMapping).nativeNameWithRegionCode, + ), + ); + }, [contextLocales, locale, customMapping]); + + // create getLocaleProperties callback + const getLocalePropertiesCallback = useCallback( + (locale: string) => { + return getLocaleProperties(locale, locale, customMapping); + }, + [locale, customMapping], + ); + + return { + locale, + locales: locales ? locales : sortedLocales, + setLocale, + getLocaleProperties: getLocalePropertiesCallback, + }; +} diff --git a/packages/react-core/src/refactor/hooks/useMessages.ts b/packages/react-core/src/refactor/hooks/useMessages.ts new file mode 100644 index 000000000..3443da747 --- /dev/null +++ b/packages/react-core/src/refactor/hooks/useMessages.ts @@ -0,0 +1,33 @@ +import { useCallback } from "react"; +import { decodeOptions } from "gt-i18n"; +import { useGT } from "./useGT"; +import type { _Messages } from "../../types-dir/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/refactor/hooks/useTranslations.ts b/packages/react-core/src/refactor/hooks/useTranslations.ts new file mode 100644 index 000000000..05ea7a0ca --- /dev/null +++ b/packages/react-core/src/refactor/hooks/useTranslations.ts @@ -0,0 +1,133 @@ +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 { getI18nManager } 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 ?? ""; + + useDictionaryObject({ locale: defaultLocale, id: rootId }); + useDictionaryObject({ locale, id: rootId }); + + const translateEntry = useCallback( + (suffix: string, options: DictionaryTranslationOptions = {}) => { + const i18nManager = getI18nManager(); + 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); + // TODO: this should only be executed in dev mode + if (targetEntry === undefined) { + 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, gt, id, locale, scope, shouldTranslate], + ); + + const translateObject = useCallback( + (suffix: string) => { + const i18nManager = getI18nManager(); + 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); + // TODO: this should only be executed in dev mode + if (targetObject === undefined) { + scope.translateObject({ locale, id: entryId }); + } + } + + return renderDictionaryObject({ + sourceObject, + targetObject, + translate: (sourceEntry, dictionaryOptions) => + i18nManager.lookupTranslation( + shouldTranslate ? locale : defaultLocale, + sourceEntry.entry, + dictionaryOptions, + ), + }); + }, + [defaultLocale, 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/refactor/hooks/utils.ts b/packages/react-core/src/refactor/hooks/utils.ts new file mode 100644 index 000000000..d7472fd87 --- /dev/null +++ b/packages/react-core/src/refactor/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/refactor/i18n-manager/ReactI18nManager.ts b/packages/react-core/src/refactor/i18n-manager/ReactI18nManager.ts new file mode 100644 index 000000000..54ffc1dda --- /dev/null +++ b/packages/react-core/src/refactor/i18n-manager/ReactI18nManager.ts @@ -0,0 +1,17 @@ +import { I18nManager } from "gt-i18n/internal"; +import type { I18nManagerConstructorParams } from "gt-i18n/internal/types"; +import type { Translation } from "gt-i18n/types"; + +/** + * initialTranslations required + */ +export type ReactI18nManagerParams = I18nManagerConstructorParams; + +/** + * TODO: probably do not need this wrapper + */ +export class ReactI18nManager extends I18nManager { + constructor(config: ReactI18nManagerParams) { + super(config); + } +} diff --git a/packages/react-core/src/refactor/i18n-manager/singleton-operations.ts b/packages/react-core/src/refactor/i18n-manager/singleton-operations.ts new file mode 100644 index 000000000..81c00f1ae --- /dev/null +++ b/packages/react-core/src/refactor/i18n-manager/singleton-operations.ts @@ -0,0 +1,15 @@ +import { ReactI18nManager } from "./ReactI18nManager"; +import { + getI18nManager as getI18nManagerInternal, + setI18nManager as setI18nManagerInternal, +} from "gt-i18n/internal"; + +// ===== I18n Manager ===== // + +export function getI18nManager(): ReactI18nManager { + return getI18nManagerInternal() as ReactI18nManager; +} + +export function setI18nManager(i18nManager: ReactI18nManager): void { + setI18nManagerInternal(i18nManager); +} diff --git a/packages/react-core/src/refactor/i18n-store/I18nStore.ts b/packages/react-core/src/refactor/i18n-store/I18nStore.ts new file mode 100644 index 000000000..f6e91057f --- /dev/null +++ b/packages/react-core/src/refactor/i18n-store/I18nStore.ts @@ -0,0 +1,625 @@ +import { + getDictionaryListenerKey, + getI18nManager, + getTranslateListenerKey, +} from "gt-i18n/internal"; +import { hashSource } from "generaltranslation/id"; +import { indexVars } from "generaltranslation/internal"; +import type { CustomMapping, IcuMessage } from "generaltranslation/types"; +import type { + DictionaryEntrySnapshot, + DictionaryLookup, + DictionaryObjectSnapshot, + ListenerSet, + StoreListener, + TranslateLookup, + TranslateManySnapshot, + TranslateSnapshot, + Unsubscribe, + OverrideSetLocaleType, +} from "./storeTypes"; +import type { + DictionaryValue, + LookupOptions, + Translation, +} from "gt-i18n/types"; +import { getConditionStore } from "../condition-store/singleton-operations"; +import { ReactI18nManagerParams } from "../i18n-manager/ReactI18nManager"; +import { RuntimeTranslationScope } from "./RuntimeTranslationScope"; +import { RuntimeDictionaryScope } from "./RuntimeDictionaryScope"; + +type TranslationStatusType = + | { status: "loading"; locale: string } + | { status: "ready" }; + +type LocaleCacheEvent = { + locale: string; + value: Record; +}; +type EntryCacheEvent = { + locale: string; + id: string; +}; +type TranslateStoreEvent = LocaleCacheEvent | EntryCacheEvent; +type TranslateStoreListener = (lookup: TranslateLookup) => void; +type DictionaryStoreEvent = LocaleCacheEvent | EntryCacheEvent; +type DictionaryStoreListener = (event: DictionaryStoreEvent) => void; + +/** + * @param overrideSetLocale - 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 = { overrideSetLocale?: OverrideSetLocaleType }; + +/** + * 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 overrideSetLocale?: OverrideSetLocaleType; + + /** + * ConditionStore and I18nManager must be already initialized + */ + constructor({ overrideSetLocale }: I18nStoreParams) { + try { + getConditionStore(); + getI18nManager(); + } catch (error) { + throw new Error("Failed to initialize I18nStore. Reason: " + error); + } + this.overrideSetLocale = overrideSetLocale; + } + + // ===== 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 getI18nManager().getDefaultLocale(); + }; + + getLocalesSnapshot = (): readonly string[] => { + return getI18nManager().getLocales(); + }; + + getCustomMappingSnapshot = (): CustomMapping => { + return getI18nManager().getCustomMapping(); + }; + + // ===== ConditionStore Snapshots ===== // + + getLocaleSnapshot = (): string => { + return getConditionStore().getLocale(); + }; + + getEnableI18nSnapshot = (): boolean => { + return getConditionStore().getEnableI18n(); + }; + + // ===== I18nManager Snapshots ===== // + + getTranslateSnapshot = ({ + locale, + message, + options, + }: TranslateLookup): TranslateSnapshot => { + return getI18nManager().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 getI18nManager().lookupDictionary(locale, id); + }; + + getDictionaryObjectSnapshot = ({ + locale, + id, + }: DictionaryLookup): DictionaryObjectSnapshot => { + return getI18nManager().lookupDictionaryObj(locale, id); + }; + + // ===== runtime translation ===== // + + translate = (lookup: TranslateLookup): void => { + getI18nManager() + .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 => { + getI18nManager() + .lookupDictionaryWithFallback(lookup.locale, lookup.id) + .then((dictionaryEntry) => { + if (dictionaryEntry == null) { + // TODO: warn about runtime dictionary translation failure + } + this.emitDictionaryEvent(lookup); + }); + }; + + translateDictionaryObject = (lookup: DictionaryLookup): void => { + getI18nManager() + .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 = getI18nManager(); + 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.overrideSetLocale) { + getConditionStore().setLocale(locale); + this.overrideSetLocale(locale); + return; + } + + // If already loaded, just immediately emit the status update + if ( + !i18nManager.requiresTranslation(locale) || + i18nManager.hasTranslations(locale) + ) { + this.updateTranslationStatus({ status: "ready" }); + getConditionStore().setLocale(locale); + this.localeListeners.forEach((listener) => listener()); + return; + } + + // Load new translations and update status and locale + this.updateTranslationStatus({ status: "loading", locale }); + getI18nManager() + .loadTranslations(locale) + .then(() => { + // dedupe update + if ( + this.translationStatus.status === "ready" || + this.translationStatus.locale !== locale + ) { + return; + } + this.updateTranslationStatus({ status: "ready" }); + getConditionStore().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 => { + getConditionStore().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()); + }; + + /** + * This is triggered by a locale change coming from a new locale prop + * from the GTProvider (along with new translations for that locale) + * + * This case does not require an event emission because this occurs + * before the useSyncExternalStore call in the GTProvider which means + * that the new locale will be immediately available to the subscribers. + * Same for the translations too. + */ + updateLocale = (locale: string): void => { + getConditionStore().setLocale(locale); + }; + + /** + * This is triggered by a GTProvider on client so synchronize with server + * or if done on the server, probably triggered manually + */ + updateTranslations = ( + translationsObj: ReactI18nManagerParams["initialTranslations"] = {}, + ): void => { + getI18nManager().updateTranslations(translationsObj); + }; + + // ===== Subscription Lifecycle ===== // + + /** + * Disconnect from all events and listeners. + */ + public disconnect(): void { + // this.unsubscribeLocalesEvents?.(); + // this.unsubscribeTranslateEvents?.(); + // this.unsubscribeLocalesDictionaryEvents?.(); + // this.unsubscribeDictionaryEntryEvents?.(); + // this.unsubscribeLocalesEvents = undefined; + // this.unsubscribeTranslateEvents = undefined; + // this.unsubscribeLocalesDictionaryEvents = undefined; + // this.unsubscribeDictionaryEntryEvents = undefined; + } + + private subscribeToStaticSet( + listenerSet: ListenerSet, + listener: StoreListener, + ): Unsubscribe { + listenerSet.add(listener); + return () => { + listenerSet.delete(listener); + }; + } + + private subscribeToTranslateSet( + listener: TranslateStoreListener, + ): Unsubscribe { + const hadListeners = this.sourceListenerCount() > 0; + this.translateListeners.add(listener); + if (!hadListeners) { + this.connect(); + } + + return () => { + this.translateListeners.delete(listener); + if (this.sourceListenerCount() === 0) { + this.disconnect(); + } + }; + } + + private subscribeToDictionarySet( + listenerSet: Set, + listener: DictionaryStoreListener, + ): Unsubscribe { + const hadListeners = this.sourceListenerCount() > 0; + listenerSet.add(listener); + if (!hadListeners) { + this.connect(); + } + + return () => { + listenerSet.delete(listener); + if (this.sourceListenerCount() === 0) { + this.disconnect(); + } + }; + } + + private connect(): void { + this.subscribeToManager(); + } + + // ===== Manager Event Wiring ===== // + + private subscribeToManager(): void { + // // Cache events are invalidation signals. Listeners reread snapshots instead + // // of receiving payload values, matching useSyncExternalStore's contract. + // this.unsubscribeLocalesEvents = getI18nManager().subscribe( + // LOCALES_EVENT_NAME, + // (event) => { + // this.emitTranslateEvent({ + // locale: event.locale, + // value: event.translations, + // }); + // }, + // ); + // this.unsubscribeTranslateEvents = getI18nManager().subscribe( + // TRANSLATION_EVENT_NAME, + // (event) => { + // this.emitTranslateEvent({ + // locale: event.locale, + // id: event.hash, + // value: event.translation, + // }); + // }, + // ); + // this.unsubscribeLocalesDictionaryEvents = getI18nManager().subscribe( + // LOCALES_DICTIONARY_EVENT_NAME, + // (event) => { + // this.emitDictionaryEvent({ + // locale: event.locale, + // value: event.dictionary, + // }); + // }, + // ); + // this.unsubscribeDictionaryEntryEvents = getI18nManager().subscribe( + // DICTIONARY_ENTRY_EVENT_NAME, + // (event) => { + // this.emitDictionaryEvent({ + // locale: event.locale, + // id: event.id, + // value: event.dictionaryEntry, + // }); + // }, + // ); + } + + // ===== Listener Utilities ===== // + + // private emitTranslateEvent(event: TranslateStoreEvent): void { + // this.translateListeners.forEach((listener) => listener(event)); + // } + + 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); + }); + } + + private sourceListenerCount(): number { + return ( + this.translateListeners.size + + this.dictionaryEntryListeners.size + + this.dictionaryObjectListeners.size + ); + } +} + +// ===== 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 translateEventMatchesLookup( + event: TranslateStoreEvent, + lookupKey: string, +): boolean { + if ("id" in event) { + return ( + getTranslateListenerKey({ + locale: event.locale, + hash: event.id, + }) === lookupKey + ); + } + return Object.keys(event.value).some( + (hash) => + getTranslateListenerKey({ locale: event.locale, hash }) === lookupKey, + ); +} + +function dictionaryEntryEventMatchesLookup( + event: DictionaryStoreEvent, + lookupKey: string, +): boolean { + if ("id" in event) { + return getDictionaryListenerKey(event) === lookupKey; + } + const { locale, id } = getDictionaryLookupFromKey(lookupKey); + const value = getDictionaryPathValue(event.value, id); + return locale === event.locale && value != null && !isDictionaryObject(value); +} + +function dictionaryObjectEventMatchesLookup( + event: DictionaryStoreEvent, + lookupKey: string, +): boolean { + const { locale, id } = getDictionaryLookupFromKey(lookupKey); + if (locale !== event.locale) { + return false; + } + if ("id" in event) { + return id === "" || event.id === id || event.id.startsWith(`${id}.`); + } + return getDictionaryPathValue(event.value, id) != null; +} + +// ===== Snapshot Helpers ===== // + +function getDictionaryPathValue( + dictionary: Record, + id: string, +): DictionaryValue | undefined { + return id + .split(".") + .filter(Boolean) + .reduce((value, key) => { + if (!isDictionaryObject(value)) { + return undefined; + } + return value[key]; + }, dictionary); +} + +function isDictionaryObject( + value: DictionaryValue | undefined, +): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} diff --git a/packages/react-core/src/refactor/i18n-store/RuntimeDictionaryScope.ts b/packages/react-core/src/refactor/i18n-store/RuntimeDictionaryScope.ts new file mode 100644 index 000000000..de1ab5034 --- /dev/null +++ b/packages/react-core/src/refactor/i18n-store/RuntimeDictionaryScope.ts @@ -0,0 +1,61 @@ +import { getI18nStore } from "./singleton-operations"; +import type { DictionaryLookup, Unsubscribe } from "./storeTypes"; +import { getDictionaryListenerKey } from "gt-i18n/internal"; + +/** + * 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) { + // TODO: enforce dev mode only + 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) { + // TODO: enforce dev mode only + 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/refactor/i18n-store/RuntimeTranslationScope.ts b/packages/react-core/src/refactor/i18n-store/RuntimeTranslationScope.ts new file mode 100644 index 000000000..f7e472e59 --- /dev/null +++ b/packages/react-core/src/refactor/i18n-store/RuntimeTranslationScope.ts @@ -0,0 +1,49 @@ +import { getI18nStore } from "./singleton-operations"; +import type { TranslateLookup, Unsubscribe } from "./storeTypes"; +import { getTranslateListenerKey } from "gt-i18n/internal"; + +/** + * 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) { + // TODO: enforce dev mode only + 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/refactor/i18n-store/singleton-operations.ts b/packages/react-core/src/refactor/i18n-store/singleton-operations.ts new file mode 100644 index 000000000..540909b6b --- /dev/null +++ b/packages/react-core/src/refactor/i18n-store/singleton-operations.ts @@ -0,0 +1,17 @@ +import { I18nStore } from "./I18nStore"; + +// ===== I18n Store ===== // + +let i18nStore: I18nStore | undefined; + +export function getI18nStore(): I18nStore { + if (!i18nStore) { + throw new Error("I18nExternalStore is not initialized."); + } + return i18nStore; +} + +export function setI18nStore(nextStore: I18nStore): void { + i18nStore?.disconnect(); + i18nStore = nextStore; +} diff --git a/packages/react-core/src/refactor/i18n-store/storeTypes.ts b/packages/react-core/src/refactor/i18n-store/storeTypes.ts new file mode 100644 index 000000000..56607a4a4 --- /dev/null +++ b/packages/react-core/src/refactor/i18n-store/storeTypes.ts @@ -0,0 +1,39 @@ +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. + */ +export type OverrideSetLocaleType = (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/refactor/setup/globals.ts b/packages/react-core/src/refactor/setup/globals.ts new file mode 100644 index 000000000..811a2bb1a --- /dev/null +++ b/packages/react-core/src/refactor/setup/globals.ts @@ -0,0 +1,39 @@ +/** + * 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; + storesInitialized: boolean; + }; +} + +globalThis.__generaltranslation = { + renderStrategy: undefined, + storesInitialized: 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 storesInitialized(): boolean { + return globalThis.__generaltranslation.storesInitialized; +} + +export function setRenderStrategy(renderStrategy: RenderStrategy): void { + globalThis.__generaltranslation.renderStrategy = renderStrategy; +} + +export function setStoresInitialized(storesInitialized: boolean): void { + globalThis.__generaltranslation.storesInitialized = storesInitialized; +} diff --git a/packages/react-core/src/refactor/setup/initializeGTSPA.ts b/packages/react-core/src/refactor/setup/initializeGTSPA.ts new file mode 100644 index 000000000..7eec917dc --- /dev/null +++ b/packages/react-core/src/refactor/setup/initializeGTSPA.ts @@ -0,0 +1,38 @@ +import { setI18nManager } from "../i18n-manager/singleton-operations"; +import { + ReactI18nManager, + ReactI18nManagerParams, +} from "../i18n-manager/ReactI18nManager"; +import { + ReactConditionStore, + ReactConditionStoreParams, +} from "../condition-store/ReactConditionStore"; +import { I18nStore, I18nStoreParams } from "../i18n-store/I18nStore"; +import { setRenderStrategy, setStoresInitialized } from "./globals"; +import { setConditionStore } 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 & ReactConditionStoreParams, +): void { + setRenderStrategy("SPA"); + + const i18nManager = new ReactI18nManager(config); + setI18nManager(i18nManager); + + const conditionStore = new ReactConditionStore(config); + setConditionStore(conditionStore); + + const i18nStore = new I18nStore(config); + setI18nStore(i18nStore); + + setStoresInitialized(true); +} diff --git a/packages/react-core/src/refactor/setup/initializeGTSSR.ts b/packages/react-core/src/refactor/setup/initializeGTSSR.ts new file mode 100644 index 000000000..2ff557827 --- /dev/null +++ b/packages/react-core/src/refactor/setup/initializeGTSSR.ts @@ -0,0 +1,19 @@ +import { + ReactI18nManager, + ReactI18nManagerParams, +} from "../i18n-manager/ReactI18nManager"; +import { setRenderStrategy } from "./globals"; +import { setI18nManager } 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 + */ +export function internalInitializeGTSSR(config: ReactI18nManagerParams): void { + setRenderStrategy("server-render"); + + const i18nManager = new ReactI18nManager(config); + setI18nManager(i18nManager); +} diff --git a/packages/react-core/src/types-dir/context.ts b/packages/react-core/src/types-dir/context.ts index 96792105a..00aff5805 100644 --- a/packages/react-core/src/types-dir/context.ts +++ b/packages/react-core/src/types-dir/context.ts @@ -6,9 +6,10 @@ import { _Messages, Dictionary, DictionaryEntry, -} from './types'; -import { TranslateIcuCallback, TranslateChildrenCallback } from './runtime'; -import { GT } from 'generaltranslation'; +} from "./types"; +import { TranslateIcuCallback, TranslateChildrenCallback } from "./runtime"; +import { GT } from "generaltranslation"; +import { InlineResolveOptions } from "gt-i18n/types"; export type GTContextType = { gt: GT; @@ -17,23 +18,23 @@ export type GTContextType = { _gtFunction: ( message: string, options?: InlineTranslationOptions, - preloadedTranslations?: Translations + preloadedTranslations?: Translations, ) => string; _mFunction: ( encodedMsg: T, - options?: Record, - preloadedTranslations?: Translations + options?: InlineResolveOptions, + preloadedTranslations?: Translations, ) => T extends string ? string : T; _filterMessagesForPreload: (_messages: _Messages) => _Messages; _preloadMessages: (_messages: _Messages) => Promise; _dictionaryFunction: ( id: string, - options?: DictionaryTranslationOptions + options?: DictionaryTranslationOptions, ) => string; _dictionaryObjFunction: ( id: string, idWithParent: string, - options?: DictionaryTranslationOptions + options?: DictionaryTranslationOptions, ) => Dictionary | DictionaryEntry | string | undefined; developmentApiEnabled: boolean; locale: string; diff --git a/packages/react-core/tsdown.config.mts b/packages/react-core/tsdown.config.mts index bddee668f..bde322a9a 100644 --- a/packages/react-core/tsdown.config.mts +++ b/packages/react-core/tsdown.config.mts @@ -1,5 +1,5 @@ -import { defineConfig } from 'tsdown'; -import { createTsdownMinifiedDualFormatConfig } from '../../tsdown.preset.mts'; +import { defineConfig } from "tsdown"; +import { createTsdownMinifiedDualFormatConfig } from "../../tsdown.preset.mts"; const deps = { neverBundle: [ @@ -16,8 +16,13 @@ const deps = { ], }; -const entries = ['src/index.ts', 'src/internal.ts', 'src/errors.ts']; +const entries = [ + "src/index.ts", + "src/internal.ts", + "src/context.ts", + "src/errors.ts", +]; export default defineConfig( - createTsdownMinifiedDualFormatConfig({ entries, deps }) + createTsdownMinifiedDualFormatConfig({ entries, deps }), ); diff --git a/packages/react/package.json b/packages/react/package.json index fec7e9198..1c76c82f0 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:*", @@ -76,6 +76,11 @@ "default": "./dist/internal.mjs" } }, + "./internal-external-store": { + "types": "./dist/internal-external-store.d.cts", + "require": "./dist/internal-external-store.cjs.min.cjs", + "import": "./dist/internal-external-store.esm.min.mjs" + }, "./client": { "require": { "types": "./dist/client.d.cts", @@ -86,6 +91,12 @@ "default": "./dist/client.mjs" } }, + "./context": { + "react-server": "./dist/context.server.mjs", + "browser": "./dist/context.client.mjs", + "types": "./dist/context.types.d.cts", + "default": "./dist/context.client.mjs" + }, "./browser": { "require": { "types": "./dist/browser.d.cts", @@ -115,9 +126,18 @@ "client": [ "./dist/client.d.cts" ], + "context": [ + "./dist/context.types.d.cts" + ], "internal": [ "./dist/internal.d.cts" ], + "internal-external-store": [ + "./dist/internal-external-store.d.cts" + ], + "setup": [ + "./dist/setup.d.cts" + ], "browser": [ "./dist/browser.d.cts" ], @@ -135,9 +155,18 @@ "gt-react/client": [ "./dist/client" ], + "gt-react/context": [ + "./dist/context.types.d.cts" + ], "gt-react/internal": [ "./dist/internal" ], + "gt-react/internal-external-store": [ + "./dist/internal-external-store" + ], + "gt-react/setup": [ + "./dist/setup.d.cts" + ], "gt-react/browser": [ "./dist/browser.d.cts" ], diff --git a/packages/react/src/context.client.ts b/packages/react/src/context.client.ts new file mode 100644 index 000000000..a725dd86a --- /dev/null +++ b/packages/react/src/context.client.ts @@ -0,0 +1,35 @@ +"use client"; + +export { CSRGTProvider as GTProvider } from "./refactor/provider/CSRGTProvider"; +export { initializeGTSPA } from "./refactor/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 ===== // + getTranslationsSnapshot, + // ===== 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..aff46ddca --- /dev/null +++ b/packages/react/src/context.server.ts @@ -0,0 +1,36 @@ +"server-only"; + +export { SSRGTProvider as GTProvider } from "./refactor/provider/SSRGTProvider"; +export { initializeGTSPA } from "./refactor/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 ===== // + getTranslationsSnapshot, + // ===== 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..c0d55ac6f --- /dev/null +++ b/packages/react/src/context.types.ts @@ -0,0 +1,46 @@ +import { CSRGTProvider } from "./refactor/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 "./refactor/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 ===== // + getTranslationsSnapshot, + // ===== Setup ===== // + internalInitializeGTSSR as initializeGT, +} from "@generaltranslation/react-core/context"; diff --git a/packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts b/packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts index 9c4c5ec55..85a2104ea 100644 --- a/packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts +++ b/packages/react/src/i18n-context/browser-i18n-manager/BrowserConditionStore.ts @@ -1,11 +1,14 @@ -import { defaultLocaleCookieName } from '@generaltranslation/react-core/internal'; -import { setCookieValue } from './utils/cookies'; -import { determineLocale } from './utils/determineLocale'; -import { GetLocale } from './utils/types'; +import { defaultLocaleCookieName } from "@generaltranslation/react-core/internal"; +import { setCookieValue } from "./utils/cookies"; +import { determineLocale } from "./utils/determineLocale"; +import { GetLocale } from "./utils/types"; import type { ConditionStoreConfig, WritableConditionStore, -} from 'gt-i18n/internal/types'; +} from "gt-i18n/internal/types"; + +type StoreListener = () => void; +type Unsubscribe = () => void; /** * The configuration for the BrowserConditionStore @@ -23,6 +26,7 @@ type BrowserConditionStoreConstructorParams = ConditionStoreConfig & { * Condition store implementation for Browser. */ export class BrowserConditionStore implements WritableConditionStore { + private listeners = new Set(); private localeConfig: ConditionStoreConfig; private customGetLocale?: GetLocale; private localeCookieName: string; @@ -52,9 +56,25 @@ export class BrowserConditionStore implements WritableConditionStore { } setLocale(locale: string): void { + const previousLocale = this.getLocale(); setCookieValue({ cookieName: this.localeCookieName, value: locale, }); + const nextLocale = this.getLocale(); + if (nextLocale !== previousLocale) { + this.emitLocaleChange(); + } + } + + subscribeToLocale(listener: StoreListener): Unsubscribe { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private emitLocaleChange(): void { + this.listeners.forEach((listener) => listener()); } } diff --git a/packages/react/src/i18n-context/browser-i18n-manager/BrowserI18nManager.ts b/packages/react/src/i18n-context/browser-i18n-manager/BrowserI18nManager.ts index 086ae5c08..13fa9c311 100644 --- a/packages/react/src/i18n-context/browser-i18n-manager/BrowserI18nManager.ts +++ b/packages/react/src/i18n-context/browser-i18n-manager/BrowserI18nManager.ts @@ -1,13 +1,13 @@ -import { I18nManager } from 'gt-i18n/internal'; +import { I18nManager } from "gt-i18n/internal"; import type { I18nManagerConstructorParams, TranslationsLoader, -} from 'gt-i18n/internal/types'; -import type { HtmlTagOptions } from './utils/types'; -import type { Translation } from 'gt-i18n/types'; -import { DEFAULT_HTML_TAG_OPTIONS } from './utils/constants'; -import { LocalStorageTranslationCache } from './LocalStorageTranslationCache'; -import { createInvalidLocaleWarning } from '../../shared/messages'; +} from "gt-i18n/internal/types"; +import type { HtmlTagOptions } from "./utils/types"; +import type { Translation } from "gt-i18n/types"; +import { DEFAULT_HTML_TAG_OPTIONS } from "./utils/constants"; +import { LocalStorageTranslationCache } from "./LocalStorageTranslationCache"; +import { createInvalidLocaleWarning } from "../../shared/messages"; /** * The configuration for the BrowserI18nManager @@ -35,14 +35,14 @@ export class BrowserI18nManager extends I18nManager { const { htmlTagOptions, ...managerConfig } = config; const localStorageCaches: Record = {}; const resolved = resolveDevHotReload( - config.files?.gt?.parsingFlags?.devHotReload + config.files?.gt?.parsingFlags?.devHotReload, ); const devHotReloadEnabled = isDevHotReloadEnabled(config); const loadTranslations = devHotReloadEnabled ? wrapLoaderWithLocalStorage( config.loadTranslations!, config.projectId!, - localStorageCaches + localStorageCaches, ) : config.loadTranslations; @@ -63,7 +63,7 @@ export class BrowserI18nManager extends I18nManager { // For dev hot reload, we need to write the translations to the localStorage cache if (devHotReloadEnabled) { this.subscribe( - 'translations-cache-miss', + "translations-cache-miss", ({ locale, hash, translation }) => { const cache = localStorageCaches[locale]; if (cache) { @@ -75,7 +75,7 @@ export class BrowserI18nManager extends I18nManager { init: { [hash]: translation }, }); } - } + }, ); } } @@ -94,7 +94,7 @@ export class BrowserI18nManager extends I18nManager { */ getLocalStorageTranslationCache( locale: string, - init?: Record + init?: Record, ): LocalStorageTranslationCache | undefined { if (!import.meta.env?.DEV) return undefined; @@ -113,7 +113,7 @@ export class BrowserI18nManager extends I18nManager { */ updateHtmlTag( locale: string, - htmlTagOptions?: { lang?: string; dir?: 'ltr' | 'rtl' } & HtmlTagOptions + htmlTagOptions?: { lang?: string; dir?: "ltr" | "rtl" } & HtmlTagOptions, ): void { // Get parameters const htmlLocale = htmlTagOptions?.lang || locale; @@ -156,7 +156,7 @@ export class BrowserI18nManager extends I18nManager { function wrapLoaderWithLocalStorage( originalLoader: TranslationsLoader, projectId: string, - localStorageCaches: Record + localStorageCaches: Record, ) { return async (locale: string) => { const loaderTranslations = await originalLoader(locale); @@ -173,9 +173,9 @@ function wrapLoaderWithLocalStorage( * Resolve the devHotReload config into { strings, jsx } flags. */ function resolveDevHotReload( - value: boolean | { strings?: boolean; jsx?: boolean } | undefined + value: boolean | { strings?: boolean; jsx?: boolean } | undefined, ) { - if (value === undefined || typeof value === 'boolean') { + if (value === undefined || typeof value === "boolean") { return { strings: !!value, jsx: !!value }; } return { strings: value.strings ?? false, jsx: value.jsx ?? false }; @@ -197,16 +197,16 @@ function isDevHotReloadEnabled(config: BrowserI18nManagerConstructorParams) { }; const requirementsMet = Object.values(requirements).every(Boolean); const resolved = resolveDevHotReload( - config.files?.gt?.parsingFlags?.devHotReload + config.files?.gt?.parsingFlags?.devHotReload, ); const anyEnabled = resolved.strings || resolved.jsx; // Only want this to log in development if (import.meta.env?.DEV && anyEnabled && !requirementsMet) { const missingRequirements = Object.keys(requirements).filter( - (key) => !requirements[key] + (key) => !requirements[key], ); console.warn( - `Dev hot reload is enabled, but the requirements are not met: ${missingRequirements.join(', ')}` + `Dev hot reload is enabled, but the requirements are not met: ${missingRequirements.join(", ")}`, ); } return requirementsMet && anyEnabled; diff --git a/packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts b/packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts index f8c52c160..31eadd230 100644 --- a/packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts +++ b/packages/react/src/i18n-context/browser-i18n-manager/utils/determineLocale.ts @@ -1,12 +1,12 @@ -import { getCookieValue } from './cookies'; -import { defaultLocaleCookieName } from '@generaltranslation/react-core/internal'; -import { createNoLocaleCouldBeDeterminedFromCustomGetLocaleWarning } from '../../../shared/messages'; -import type { GetLocale } from './types'; +import { getCookieValue } from "./cookies"; +import { defaultLocaleCookieName } from "@generaltranslation/react-core/internal"; +import { createNoLocaleCouldBeDeterminedFromCustomGetLocaleWarning } from "../../../shared/messages"; +import type { GetLocale } from "./types"; import { determineSupportedLocale, resolveSupportedLocale, -} from 'gt-i18n/internal'; -import type { ConditionStoreConfig } from 'gt-i18n/internal/types'; +} from "gt-i18n/internal"; +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, @@ -39,14 +39,14 @@ export function determineLocale({ // A custom getLocale is authoritative: if it returns an unsupported locale, warn and fall back. const determinedLocale = determineSupportedLocale( customLocale, - localeConfig + localeConfig, ); if (!determinedLocale) { console.warn( createNoLocaleCouldBeDeterminedFromCustomGetLocaleWarning({ customLocale, defaultLocale: resolvedDefaultLocale, - }) + }), ); return resolvedDefaultLocale; } diff --git a/packages/react/src/i18n-context/functions/translation/t.ts b/packages/react/src/i18n-context/functions/translation/t.ts index 43e8445d3..b9708e888 100644 --- a/packages/react/src/i18n-context/functions/translation/t.ts +++ b/packages/react/src/i18n-context/functions/translation/t.ts @@ -2,10 +2,10 @@ import { getLocale, resolveTranslationSync, resolveTranslationSyncWithFallback, -} from 'gt-i18n/internal'; -import type { InlineTranslationOptions } from 'gt-i18n/types'; -import { createTranslationFailedDueToBrowserEnvironmentWarning } from '../../../shared/messages'; -import { StringOrTemplateSyncResolutionFunction } from './types'; +} from "gt-i18n/internal"; +import type { InlineTranslationOptions } from "gt-i18n/types"; +import { createTranslationFailedDueToBrowserEnvironmentWarning } from "../../../shared/messages"; +import { StringOrTemplateSyncResolutionFunction } from "./types"; /** * NOTE: t() is the only function exported from the 'gt-react' entry point. @@ -35,20 +35,25 @@ export const t: StringOrTemplateSyncResolutionFunction = ( ...values: unknown[] ) => { // Trigger browser environment warning - if (typeof window === 'undefined') { + // TODO: this is not envrionment agnostic, this function (or check) should be moved to gt-react + if (typeof window === "undefined") { console.warn( - createTranslationFailedDueToBrowserEnvironmentWarning(messageOrStrings) + 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') { + if (typeof messageOrStrings === "string") { const options = values.at(0) as InlineTranslationOptions | undefined; const locale = options?.$locale ?? getLocale(); return resolveTranslationSyncWithFallback( locale, messageOrStrings, - options + options, ); } @@ -71,25 +76,25 @@ export const t: StringOrTemplateSyncResolutionFunction = ( */ function handleTaggedTemplateLiteralTranslation( messageOrStrings: TemplateStringsArray, - values: unknown[] + 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 + values, ); const translatedInterpolatedTemplate = resolveTranslationSync( locale, - interpolatedTemplate + interpolatedTemplate, ); if (translatedInterpolatedTemplate) return translatedInterpolatedTemplate; // (2) resolve uninterpolated message const { message, variables } = extractInterpolatableValues( messageOrStrings, - values + values, ); return resolveTranslationSyncWithFallback(locale, message, variables); } @@ -102,7 +107,7 @@ function handleTaggedTemplateLiteralTranslation( */ function extractInterpolatableValues( strings: TemplateStringsArray, - values: unknown[] + values: unknown[], ): { message: string; variables: Record; @@ -127,7 +132,7 @@ function extractInterpolatableValues( } return { - message: parts.join(''), + message: parts.join(""), variables, }; } @@ -140,11 +145,11 @@ function extractInterpolatableValues( */ function interpolateTemplateLiteral( strings: TemplateStringsArray, - values: unknown[] + values: unknown[], ): string { return strings .map((string, index) => { - return string + (values[index] ?? ''); + return string + (values[index] ?? ""); }) - .join(''); + .join(""); } diff --git a/packages/react/src/i18n-context/setup/bootstrap.ts b/packages/react/src/i18n-context/setup/bootstrap.ts index 7f60966ff..272520226 100644 --- a/packages/react/src/i18n-context/setup/bootstrap.ts +++ b/packages/react/src/i18n-context/setup/bootstrap.ts @@ -1,7 +1,7 @@ -import { initializeGT } from './initializeGT'; -import { getBrowserI18nManager } from '../browser-i18n-manager/singleton-operations'; -import { InitializeGTParams } from './types'; -import { getLocale } from '../functions/locale-operations'; +import { initializeGT } from "./initializeGT"; +import { getBrowserI18nManager } from "../browser-i18n-manager/singleton-operations"; +import { InitializeGTParams } from "./types"; +import { getLocale } from "../functions/locale-operations"; /** * Initialization function for react-core library invoked to enable synchronous resolution diff --git a/packages/react/src/internal-external-store.ts b/packages/react/src/internal-external-store.ts new file mode 100644 index 000000000..38359ebca --- /dev/null +++ b/packages/react/src/internal-external-store.ts @@ -0,0 +1 @@ +export * from '@generaltranslation/react-core/internal-external-store'; diff --git a/packages/react/src/refactor/condition-store/BrowserConditionStore.ts b/packages/react/src/refactor/condition-store/BrowserConditionStore.ts new file mode 100644 index 000000000..5808ba717 --- /dev/null +++ b/packages/react/src/refactor/condition-store/BrowserConditionStore.ts @@ -0,0 +1,74 @@ +import { + defaultEnableI18nCookieName, + defaultLocaleCookieName, +} from "@generaltranslation/react-core/internal"; +import { + ReactConditionStore, + ReactConditionStoreParams, +} from "@generaltranslation/react-core/context"; +import { getCookieValue, setCookieValue } from "./cookies"; +import { readBrowserLocale } from "./readBrowserLocale"; + +/** + * 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 = Omit< + ReactConditionStoreParams, + "locale" +> & { + locale?: string; + localeCookieName?: string; + enableI18nCookieName?: string; +}; + +/** + * Condition store implementation for Browser. + */ +export class BrowserConditionStore extends ReactConditionStore { + private localeCookieName: string; + private enableI18nCookieName: string; + + constructor({ + localeCookieName = defaultLocaleCookieName, + enableI18nCookieName = defaultEnableI18nCookieName, + ...config + }: BrowserConditionStoreParams) { + const locale = config.locale ?? readBrowserLocale(localeCookieName); + super({ + ...config, + locale, + }); + this.localeCookieName = localeCookieName; + this.enableI18nCookieName = enableI18nCookieName; + } + + getLocale = (): string => { + return readBrowserLocale(this.localeCookieName); + }; + + setLocale = (locale: string): void => { + setCookieValue({ + cookieName: this.localeCookieName, + value: locale, + }); + window.location.reload(); + }; + + getEnableI18n = (): boolean => { + const cookieEnableI18n = getCookieValue({ + cookieName: this.enableI18nCookieName, + }); + return cookieEnableI18n === "true" || cookieEnableI18n === undefined; + }; + + setEnableI18n = (enableI18n: boolean): void => { + setCookieValue({ + cookieName: this.enableI18nCookieName, + value: enableI18n ? "true" : "false", + }); + }; +} diff --git a/packages/react/src/refactor/condition-store/cookies.ts b/packages/react/src/refactor/condition-store/cookies.ts new file mode 100644 index 000000000..9dc0b7145 --- /dev/null +++ b/packages/react/src/refactor/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/refactor/condition-store/readBrowserLocale.ts b/packages/react/src/refactor/condition-store/readBrowserLocale.ts new file mode 100644 index 000000000..974ab7bc9 --- /dev/null +++ b/packages/react/src/refactor/condition-store/readBrowserLocale.ts @@ -0,0 +1,18 @@ +import { getCookieValue } from "./cookies"; +import { getI18nManager } from "@generaltranslation/react-core/context"; + +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 getI18nManager().determineLocale(candidates); +} diff --git a/packages/react/src/refactor/i18n-manager/BrowserI18nManager.ts b/packages/react/src/refactor/i18n-manager/BrowserI18nManager.ts new file mode 100644 index 000000000..2f16daa9d --- /dev/null +++ b/packages/react/src/refactor/i18n-manager/BrowserI18nManager.ts @@ -0,0 +1,200 @@ +import type { + I18nManagerConstructorParams, + TranslationsLoader, +} from "gt-i18n/internal/types"; +import type { HtmlTagOptions } from "./types"; +import type { Translation } from "gt-i18n/types"; +import { DEFAULT_HTML_TAG_OPTIONS } from "./constants"; +import { createInvalidLocaleWarning } from "../../shared/messages"; +import { ReactI18nManager } from "@generaltranslation/react-core/context"; +import { LocalStorageTranslationCache } from "./LocalStorageTranslationCache"; + +/** + * The configuration for the BrowserI18nManager + */ +export type BrowserI18nManagerParams = + I18nManagerConstructorParams & { + htmlTagOptions?: HtmlTagOptions; + }; + +/** + * I18nManager implementation for Browser. + */ +export class BrowserI18nManager extends ReactI18nManager { + /** 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(); + }; +} + +/** + * Resolve the devHotReload config into { strings, jsx } flags. + */ +function resolveDevHotReload( + value: boolean | { strings?: boolean; jsx?: boolean } | undefined, +) { + if (value === undefined || typeof value === "boolean") { + return { strings: !!value, jsx: !!value }; + } + return { strings: value.strings ?? false, jsx: value.jsx ?? false }; +} + +/** + * 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 + ); +} diff --git a/packages/react/src/refactor/i18n-manager/LocalStorageTranslationCache.ts b/packages/react/src/refactor/i18n-manager/LocalStorageTranslationCache.ts new file mode 100644 index 000000000..4a55b1cd9 --- /dev/null +++ b/packages/react/src/refactor/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/refactor/i18n-manager/constants.ts b/packages/react/src/refactor/i18n-manager/constants.ts new file mode 100644 index 000000000..99253c36c --- /dev/null +++ b/packages/react/src/refactor/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/refactor/i18n-manager/types.ts b/packages/react/src/refactor/i18n-manager/types.ts new file mode 100644 index 000000000..6c3c2c5cc --- /dev/null +++ b/packages/react/src/refactor/i18n-manager/types.ts @@ -0,0 +1,14 @@ +/** + * Type for custom getLocale function + */ +export type GetLocale = () => string; + +/** + * 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/refactor/provider/CSRGTProvider.tsx b/packages/react/src/refactor/provider/CSRGTProvider.tsx new file mode 100644 index 000000000..9895b789b --- /dev/null +++ b/packages/react/src/refactor/provider/CSRGTProvider.tsx @@ -0,0 +1,22 @@ +import { + getI18nManager, + InternalGTProvider, +} from "@generaltranslation/react-core/context"; +import type { SharedGTProviderProps } from "./types"; + +/** + * Client side GTProvider, this is different from server side + * GTProvider because needs to syncrhonize any incoming + * server-side translations + */ +export function CSRGTProvider({ + translations, + ...props +}: SharedGTProviderProps) { + // This represents an update from server + // 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 overrideSetLocale === undefined), see getI18nStore().updateLocale() in InternalGTProvider + getI18nManager().updateTranslations(translations); + return ; +} diff --git a/packages/react/src/refactor/provider/SSRGTProvider.tsx b/packages/react/src/refactor/provider/SSRGTProvider.tsx new file mode 100644 index 000000000..3bcf4f7af --- /dev/null +++ b/packages/react/src/refactor/provider/SSRGTProvider.tsx @@ -0,0 +1,13 @@ +import type { SharedGTProviderProps } from "./types"; +import { 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 + */ +export function SSRGTProvider({ + translations: _translations, + ...props +}: SharedGTProviderProps) { + return ; +} diff --git a/packages/react/src/refactor/provider/types.ts b/packages/react/src/refactor/provider/types.ts new file mode 100644 index 000000000..8a4d45a9e --- /dev/null +++ b/packages/react/src/refactor/provider/types.ts @@ -0,0 +1,10 @@ +import type { InternalGTProviderProps } from "@generaltranslation/react-core/context"; +import type { Translation } from "gt-i18n/types"; +import type { Locale, Hash } from "gt-i18n/internal/types"; + +/** + * We force the user to pass translations so they can be synchronously accessed + */ +export type SharedGTProviderProps = InternalGTProviderProps & { + translations: Record>; +}; diff --git a/packages/react/src/refactor/setup/initializeGTSPA.ts b/packages/react/src/refactor/setup/initializeGTSPA.ts new file mode 100644 index 000000000..add74c85f --- /dev/null +++ b/packages/react/src/refactor/setup/initializeGTSPA.ts @@ -0,0 +1,44 @@ +import { + getTranslationsSnapshot, + I18nStoreParams, + setRenderStrategy, + I18nStore, + setI18nStore, + setStoresInitialized, + setConditionStore, + setI18nManager, +} from "@generaltranslation/react-core/context"; +import { BrowserConditionStore } from "../condition-store/BrowserConditionStore"; +import type { BrowserConditionStoreParams } from "../condition-store/BrowserConditionStore"; +import { BrowserI18nManager } from "../i18n-manager/BrowserI18nManager"; +import type { BrowserI18nManagerParams } from "../i18n-manager/BrowserI18nManager"; + +/** + * Initialize GT for an SPA + * - i18nManager + * - conditionStore + * - i18nStore + * + * This is SPA for browser runtime + */ +export async function initializeGTSPA( + config: I18nStoreParams & + BrowserI18nManagerParams & + BrowserConditionStoreParams, +) { + setRenderStrategy("SPA"); + + const i18nManager = new BrowserI18nManager(config); + setI18nManager(i18nManager); + + const conditionStore = new BrowserConditionStore(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..7a2fa23f7 100644 --- a/packages/react/tsdown.config.mts +++ b/packages/react/tsdown.config.mts @@ -1,5 +1,5 @@ -import { defineConfig } from 'tsdown'; -import { createTsdownConfig } from '../../tsdown.preset.mts'; +import { defineConfig } from "tsdown"; +import { createTsdownConfig } from "../../tsdown.preset.mts"; const deps = { neverBundle: [ @@ -18,11 +18,14 @@ const deps = { }; const entries = [ - 'src/index.ts', - 'src/internal.ts', - 'src/client.ts', - 'src/browser.ts', - 'src/macros.ts', + "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( @@ -34,7 +37,7 @@ export default defineConfig( ...cjsConfig, clean: index === 0, define: { - 'import.meta.env': '{}', + "import.meta.env": "{}", }, }, { @@ -45,5 +48,5 @@ export default defineConfig( }, }, ]; - }) + }), ); diff --git a/packages/tanstack-start/src/functions/determineLocale.ts b/packages/tanstack-start/src/functions/determineLocale.ts index 8a50890a7..fe5ff9388 100644 --- a/packages/tanstack-start/src/functions/determineLocale.ts +++ b/packages/tanstack-start/src/functions/determineLocale.ts @@ -1,8 +1,8 @@ -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 { 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 { 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 @@ -29,21 +29,21 @@ function determineLocaleServer({ if (cookie) candidates.push(cookie); // (2) Check headers - if (process.env._GENERALTRANSLATION_IGNORE_BROWSER_LOCALES === 'false') { + if (process.env._GENERALTRANSLATION_IGNORE_BROWSER_LOCALES === "false") { const headers = - getRequestHeader('accept-language') - ?.split(',') - .map((item) => item.split(';')?.[0].trim()) || []; + getRequestHeader("accept-language") + ?.split(",") + .map((item) => item.split(";")?.[0].trim()) || []; if (headers) candidates.push(...headers); } // Warn if no locales could be determined if ( candidates.length === 0 && - process.env._GENERALTRANSLATION_IGNORE_BROWSER_LOCALES === 'false' + process.env._GENERALTRANSLATION_IGNORE_BROWSER_LOCALES === "false" ) { console.warn( - 'gt-tanstack-start(server): no locales could be determined for this request' + "gt-tanstack-start(server): no locales could be determined for this request", ); } @@ -61,14 +61,14 @@ function determineLocaleClient({ defaultLocale, locales, customMapping, -}: ConditionStoreConfig) { +}: LocaleResolverConfig) { const candidates: string[] = []; // (1) Check cookie const cookie = document.cookie - .split('; ') + .split("; ") .find((row) => row.startsWith(`${defaultLocaleCookieName}=`)) - ?.split('=')[1]; + ?.split("=")[1]; if (cookie) candidates.push(cookie); // (2) Check browser locale @@ -77,7 +77,7 @@ function determineLocaleClient({ if (candidates.length === 0) { console.warn( - 'gt-tanstack-start(client): no locales could be determined for this request' + "gt-tanstack-start(client): no locales could be determined for this request", ); } diff --git a/packages/tanstack-start/src/runtime/TanstackConditionStore.ts b/packages/tanstack-start/src/runtime/TanstackConditionStore.ts index e744c2f12..d3370127a 100644 --- a/packages/tanstack-start/src/runtime/TanstackConditionStore.ts +++ b/packages/tanstack-start/src/runtime/TanstackConditionStore.ts @@ -1,16 +1,16 @@ -import { determineLocale } from '../functions/determineLocale'; +import { determineLocale } from "../functions/determineLocale"; import type { ConditionStore, - ConditionStoreConfig, -} from 'gt-i18n/internal/types'; + LocaleResolverConfig, +} from "gt-i18n/internal/types"; /** * Condition store implementation for Tanstack Start. */ export class TanstackConditionStore implements ConditionStore { - private localeConfig: ConditionStoreConfig; + private localeConfig: LocaleResolverConfig; - constructor(localeConfig: ConditionStoreConfig = {}) { + constructor(localeConfig: LocaleResolverConfig = {}) { this.localeConfig = localeConfig; } 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': From 41da9ff220ffc1c7b72ed5d10c035c4035dbb9ef Mon Sep 17 00:00:00 2001 From: Ernest McCarter Date: Thu, 14 May 2026 17:00:15 -0700 Subject: [PATCH 02/34] fix: lint --- packages/i18n/src/i18n-manager/I18nManager.ts | 152 +++++++++--------- .../condition-store/localeResolver.ts | 14 +- .../src/i18n-manager/singleton-operations.ts | 14 +- .../translations-manager/Cache.ts | 4 +- .../translations-manager/LocalesCache.ts | 36 ++--- .../translations-manager/TranslationsCache.ts | 40 ++--- packages/i18n/src/i18n-manager/types.ts | 26 +-- packages/i18n/src/internal-types.ts | 16 +- packages/i18n/src/internal.ts | 22 +-- .../internal/sync-translation-resolution.ts | 12 +- .../translation-functions/types/options.ts | 20 +-- packages/i18n/src/utils/extractVariables.ts | 26 +-- packages/i18n/src/utils/listenerKeys.ts | 4 +- .../async-i18n-manager/AsyncConditionStore.ts | 10 +- .../src/branches/plurals/Plural.tsx | 14 +- .../src/branches/plurals/getPluralBranch.ts | 10 +- packages/react-core/src/context.ts | 66 ++++---- packages/react-core/src/internal.ts | 60 +++---- packages/react-core/src/provider/GTContext.ts | 8 +- .../src/provider/__tests__/i18n-store.test.ts | 42 +++-- .../useCreateInternalUseGTFunction.ts | 84 +++++----- .../refactor/components/branches/Branch.tsx | 10 +- .../refactor/components/branches/Plural.tsx | 12 +- .../helpers/InternalLocaleSelector.tsx | 16 +- .../components/helpers/LocaleSelector.tsx | 8 +- .../src/refactor/components/translation/T.tsx | 46 +++--- .../components/variables/Currency.tsx | 12 +- .../components/variables/DateTime.tsx | 10 +- .../src/refactor/components/variables/Num.tsx | 10 +- .../components/variables/RelativeTime.tsx | 12 +- .../components/variables/renderVariable.tsx | 36 ++--- .../condition-store/ReactConditionStore.ts | 8 +- .../condition-store/singleton-operations.ts | 6 +- .../refactor/context/provider/GTContext.ts | 2 +- .../context/provider/InternalGTProvider.tsx | 26 +-- .../helpers/getTranslationsSnapshot.ts | 8 +- .../src/refactor/functions/translation/t.ts | 36 ++--- .../src/refactor/hooks/context-hooks.ts | 20 +-- .../refactor/hooks/external-store-hooks.ts | 36 ++--- .../react-core/src/refactor/hooks/useGT.ts | 32 ++-- .../src/refactor/hooks/useLocaleSelector.ts | 14 +- .../src/refactor/hooks/useMessages.ts | 16 +- .../src/refactor/hooks/useTranslations.ts | 32 ++-- .../react-core/src/refactor/hooks/utils.ts | 6 +- .../refactor/i18n-manager/ReactI18nManager.ts | 6 +- .../i18n-manager/singleton-operations.ts | 4 +- .../src/refactor/i18n-store/I18nStore.ts | 100 ++++++------ .../i18n-store/RuntimeDictionaryScope.ts | 10 +- .../i18n-store/RuntimeTranslationScope.ts | 8 +- .../i18n-store/singleton-operations.ts | 4 +- .../src/refactor/i18n-store/storeTypes.ts | 2 +- .../react-core/src/refactor/setup/globals.ts | 4 +- .../src/refactor/setup/initializeGTSPA.ts | 18 +-- .../src/refactor/setup/initializeGTSSR.ts | 8 +- packages/react-core/src/types-dir/context.ts | 16 +- packages/react-core/tsdown.config.mts | 14 +- packages/react/src/context.client.ts | 8 +- packages/react/src/context.server.ts | 8 +- packages/react/src/context.types.ts | 8 +- .../BrowserConditionStore.ts | 10 +- .../BrowserI18nManager.ts | 38 ++--- .../utils/determineLocale.ts | 16 +- .../i18n-context/functions/translation/t.ts | 36 ++--- .../react/src/i18n-context/setup/bootstrap.ts | 8 +- .../condition-store/BrowserConditionStore.ts | 14 +- .../src/refactor/condition-store/cookies.ts | 8 +- .../condition-store/readBrowserLocale.ts | 4 +- .../i18n-manager/BrowserI18nManager.ts | 30 ++-- .../LocalStorageTranslationCache.ts | 10 +- .../src/refactor/i18n-manager/constants.ts | 2 +- .../src/refactor/provider/CSRGTProvider.tsx | 4 +- .../src/refactor/provider/SSRGTProvider.tsx | 4 +- packages/react/src/refactor/provider/types.ts | 6 +- .../src/refactor/setup/initializeGTSPA.ts | 14 +- packages/react/tsdown.config.mts | 24 +-- .../src/functions/determineLocale.ts | 28 ++-- .../src/runtime/TanstackConditionStore.ts | 4 +- 77 files changed, 784 insertions(+), 788 deletions(-) diff --git a/packages/i18n/src/i18n-manager/I18nManager.ts b/packages/i18n/src/i18n-manager/I18nManager.ts index c03260d74..b54ea370a 100644 --- a/packages/i18n/src/i18n-manager/I18nManager.ts +++ b/packages/i18n/src/i18n-manager/I18nManager.ts @@ -1,46 +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 { 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 { determineLocale, LocaleConfig, standardizeLocale, -} from "@generaltranslation/format"; -import type { CustomMapping } from "@generaltranslation/format/types"; -import { LookupOptions } from "../translation-functions/types/options"; -import { getGTServicesEnabled } from "./utils/getGTServicesEnabled"; +} 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 { Locale, LocalesCache } from "./translations-manager/LocalesCache"; -import { 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 { Locale, LocalesCache } from './translations-manager/LocalesCache'; +import { Hash } from './translations-manager/TranslationsCache'; import type { Dictionary, DictionaryEntry, DictionaryKey, DictionaryObject, -} from "./translations-manager/DictionaryCache"; -import { resolveDictionaryLookupOptions } from "./translations-manager/utils/dictionary-helpers"; -import { LocalesDictionaryCache } from "./translations-manager/LocalesDictionaryCache"; -import type { DictionaryLoader } from "./translations-manager/LocalesDictionaryCache"; -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 { LocalesDictionaryCache } from './translations-manager/LocalesDictionaryCache'; +import type { DictionaryLoader } from './translations-manager/LocalesDictionaryCache'; +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"; +} from './condition-store/localeResolver'; /** * Default translation timeout in milliseconds for a runtime translation request @@ -58,7 +58,7 @@ type TranslationResolver = < T extends U = U, >( message: T, - options?: LookupOptions, + options?: LookupOptions ) => T | undefined; /** @@ -110,7 +110,7 @@ class I18nManager< // Validation const validationResults = validateConfig(params); - publishValidationResults(validationResults, "I18nManager: "); + publishValidationResults(validationResults, 'I18nManager: '); // Setup this.config = standardizeConfig(params); @@ -135,22 +135,22 @@ 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), + this.emit(...args) ); // Setup translations cache const initialTranslations = filterInitialTranslations( params.initialTranslations ?? {}, - this.localeConfig, + this.localeConfig ); this.localesCache = new LocalesCache({ loadTranslations, @@ -186,10 +186,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) { @@ -236,7 +236,7 @@ class I18nManager< */ getGTClass(locale?: string): GT { return this.getGTClassClean( - locale ? this._resolveLocale(locale) : undefined, + locale ? this._resolveLocale(locale) : undefined ); } @@ -252,7 +252,7 @@ class I18nManager< // ========== Translation Updates ========== // updateTranslations( - translationsObj: Record>, + translationsObj: Record> ): void { this.localesCache.update(translationsObj); } @@ -288,7 +288,7 @@ class I18nManager< * Edge case usage: access the translations object directly */ async loadTranslations( - locale: string, + locale: string ): Promise> { try { // Validate @@ -365,7 +365,7 @@ class I18nManager< */ lookupDictionaryObj( locale: string, - id: string, + id: string ): DictionaryObject | undefined { try { const dictionaryLocale = @@ -383,7 +383,7 @@ class I18nManager< */ async lookupDictionaryWithFallback( locale: string, - id: string, + id: string ): Promise { try { const dictionaryLocale = this.resolveCacheLocale(locale); @@ -426,7 +426,7 @@ class I18nManager< */ async lookupDictionaryObjWithFallback( locale: string, - id: string, + id: string ): Promise { try { const dictionaryLocale = this.resolveCacheLocale(locale); @@ -469,7 +469,7 @@ class I18nManager< lookupTranslation( locale: string, message: T, - options: LookupOptions, + options: LookupOptions ): T | undefined { try { // Validate @@ -502,13 +502,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); @@ -530,7 +530,7 @@ class I18nManager< prefetchEntries: { message: TranslationValue; options: LookupOptions; - }[] = [], + }[] = [] ): Promise> { try { // Validate @@ -547,11 +547,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}` ); } @@ -564,7 +564,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 @@ -593,7 +593,7 @@ class I18nManager< resolveTranslationSync = ( locale: string, message: T, - options: LookupOptions, + options: LookupOptions ) => { return this.lookupTranslation(locale, message, options); }; @@ -605,7 +605,7 @@ class I18nManager< * @deprecated use loadTranslations instead */ async getTranslations( - locale: string, + locale: string ): Promise> { try { return this.loadTranslations(locale); @@ -626,7 +626,7 @@ class I18nManager< * @deprecated use getLookupTranslation instead */ async getTranslationResolver( - locale: string, + locale: string ): Promise> { return this.getLookupTranslation(locale); } @@ -679,11 +679,11 @@ 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; } } @@ -692,7 +692,7 @@ class I18nManager< 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; @@ -710,7 +710,7 @@ class I18nManager< } const aliasLocale = this.localeConfig.resolveAliasLocale( - standardizeLocale(locale), + standardizeLocale(locale) ); if (this.requiresTranslation(aliasLocale)) { return aliasLocale; @@ -731,7 +731,7 @@ class I18nManager< private resolveLookupOptions( options: LookupOptions = {} as LookupOptions, - translationLocale?: string, + translationLocale?: string ) { if (!options.$locale) { return options; @@ -771,17 +771,17 @@ class I18nManager< private async dictionaryRuntimeTranslate( locale: Locale, id: DictionaryKey, - sourceEntry: DictionaryEntry, + sourceEntry: DictionaryEntry ): Promise { // Runtime translation 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.` ); } @@ -802,9 +802,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, @@ -825,7 +825,7 @@ export { I18nManager }; * @returns The standardized config */ function standardizeConfig( - config: I18nManagerConstructorParams, + config: I18nManagerConstructorParams ) { const gtServicesEnabled = getGTServicesEnabled(config); @@ -836,7 +836,7 @@ function standardizeConfig( }); return { - environment: config.environment || "production", + environment: config.environment || 'production', enableI18n: config.enableI18n !== undefined ? config.enableI18n : true, projectId: config.projectId, devApiKey: config.devApiKey, @@ -876,7 +876,7 @@ function dedupeLocales({ */ function filterInitialTranslations( initialTranslations: Record>, - { locales, customMapping }: LocaleConfig, + { locales, customMapping }: LocaleConfig ) { return Object.fromEntries( Object.entries(initialTranslations) @@ -889,11 +889,11 @@ function filterInitialTranslations( return true; } else { console.warn( - `I18nManager: filterInitialTranslations(): locale ${locale} is not valid. Removing from initial translations.`, + `I18nManager: filterInitialTranslations(): locale ${locale} is not valid. Removing from initial translations.` ); return false; } - }), + }) ); } /** @@ -909,7 +909,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) { @@ -923,13 +923,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 { @@ -949,7 +949,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; @@ -977,7 +977,7 @@ function resolvePrefetchEntriesByLocale( * Helper function for creating a translation loader */ function createTranslationLoader( - params: I18nManagerConstructorParams, + params: I18nManagerConstructorParams ) { return routeCreateTranslationLoader({ loadTranslations: params.loadTranslations, @@ -996,7 +996,7 @@ function createTranslationLoader( * Helper function for creating a dictionary loader */ function createDictionaryLoader( - params: I18nManagerConstructorParams, + params: I18nManagerConstructorParams ): DictionaryLoader { return params.loadDictionary ?? (() => Promise.resolve({})); } diff --git a/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts b/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts index fa85cc3ac..19acd7bbe 100644 --- a/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts +++ b/packages/i18n/src/i18n-manager/condition-store/localeResolver.ts @@ -1,6 +1,6 @@ -import { LocaleConfig } from "@generaltranslation/format"; -import { libraryDefaultLocale } from "generaltranslation/internal"; -import type { LocaleResolverConfig } from "../types"; +import { LocaleConfig } from '@generaltranslation/format'; +import { libraryDefaultLocale } from 'generaltranslation/internal'; +import type { LocaleResolverConfig } from '../types'; export type LocaleCandidates = string | string[] | undefined; @@ -26,17 +26,17 @@ type NormalizedConditionStoreConfig = ReturnType< */ export function determineSupportedLocale( candidates: LocaleCandidates, - config: LocaleResolverConfig = {}, + 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: LocaleResolverConfig = {}, + config: LocaleResolverConfig = {} ): string { const normalizedConfig = normalizeConditionStoreConfig(config); return ( diff --git a/packages/i18n/src/i18n-manager/singleton-operations.ts b/packages/i18n/src/i18n-manager/singleton-operations.ts index 6af9f5329..26c8df091 100644 --- a/packages/i18n/src/i18n-manager/singleton-operations.ts +++ b/packages/i18n/src/i18n-manager/singleton-operations.ts @@ -1,13 +1,13 @@ -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 type { ConditionStore } from './types'; // Singleton instance of I18nManager let i18nManager: I18nManager | undefined = undefined; let fallbackDefaultLocale: string = libraryDefaultLocale; -let fallbackEnableI18n: boolean = true; +const fallbackEnableI18n: boolean = true; // Used before wrapper runtimes install a condition store; tracks the active manager default. const fallbackConditionStore: ConditionStore = { getLocale: () => fallbackDefaultLocale, @@ -46,7 +46,7 @@ export function getI18nManager(): * 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(); diff --git a/packages/i18n/src/i18n-manager/translations-manager/Cache.ts b/packages/i18n/src/i18n-manager/translations-manager/Cache.ts index d003aff18..f71adf39d 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/Cache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/Cache.ts @@ -1,7 +1,7 @@ import type { LifecycleCallback, LifecycleParam, -} from "../lifecycle-hooks/types"; +} from '../lifecycle-hooks/types'; function isPlainObject(value: unknown): value is Record { if (value == null || typeof value !== 'object') return false; @@ -69,7 +69,7 @@ abstract class Cache< */ constructor( init: Record, - lifecycle?: LifecycleParam, + lifecycle?: LifecycleParam ) { this.cache = structuredClone(init); this.onHit = lifecycle?.onHit; diff --git a/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts b/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts index 3bd084f8d..672e43de0 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/LocalesCache.ts @@ -1,14 +1,14 @@ -import { Cache } from "./Cache"; -import { Hash, TranslationKey, TranslationsCache } from "./TranslationsCache"; -import type { TranslationBatchConfig } from "./TranslationsCache"; -import { Translation } from "./utils/types/translation-data"; -import { DEFAULT_CACHE_EXPIRY_TIME } from "./utils/constants"; -import { CreateTranslateMany } from "./utils/createTranslateMany"; +import { Cache } from './Cache'; +import { Hash, TranslationKey, TranslationsCache } from './TranslationsCache'; +import type { TranslationBatchConfig } from './TranslationsCache'; +import { Translation } from './utils/types/translation-data'; +import { DEFAULT_CACHE_EXPIRY_TIME } from './utils/constants'; +import { CreateTranslateMany } from './utils/createTranslateMany'; import type { LifecycleParam, LocalesCacheLifecycleCallbacks, TranslationsCacheLifecycleCallback, -} from "../lifecycle-hooks/types"; +} from '../lifecycle-hooks/types'; /** * Just being explicit about the purpose of this type @@ -32,7 +32,7 @@ export type CacheEntry = { * TODO: rename this because we are no longer doing try/catch around the translation loader */ export type SafeTranslationsLoader = ( - locale: string, + locale: string ) => Promise>; /** @@ -42,7 +42,7 @@ export class LocalesCache extends Cache< Locale, Locale, CacheEntry, - CacheEntry["translationsCache"] + CacheEntry['translationsCache'] > { /** * Translation loader function @@ -110,7 +110,7 @@ export class LocalesCache extends Cache< // Populate the cache with the initial translations for (const [locale, translations] of Object.entries( - initialTranslations ?? {}, + initialTranslations ?? {} )) { this.setCache(locale, this._createCacheEntry(locale, translations)); } @@ -122,8 +122,8 @@ export class LocalesCache extends Cache< * @returns The translations */ public get( - key: Locale, - ): CacheEntry["translationsCache"] | undefined { + key: Locale + ): CacheEntry['translationsCache'] | undefined { // Get the cache entry const entry = this.getCache(key); if (!entry || (entry.expiresAt > 0 && entry.expiresAt < Date.now())) { @@ -150,8 +150,8 @@ export class LocalesCache extends Cache< * @returns The translations cache */ public async miss( - key: Locale, - ): Promise["translationsCache"]> { + key: Locale + ): Promise['translationsCache']> { // Miss the cache const cacheValue = await this.missCache(key); @@ -186,14 +186,14 @@ export class LocalesCache extends Cache< * @returns The cache entry */ protected async fallback( - locale: Locale, + locale: Locale ): Promise> { const translations = await this._translationLoader(locale); return this._createCacheEntry(locale, translations); } public update( - translationsObj: Record>, + translationsObj: Record> ): void { for (const [locale, translations] of Object.entries(translationsObj)) { const cacheEntry = this.getCache(this.genKey(locale)); @@ -219,7 +219,7 @@ export class LocalesCache extends Cache< */ private _createCacheEntry( locale: Locale, - initialTranslations: Record, + initialTranslations: Record ): CacheEntry { // Cache the promise and expiry timestamp const translationsCache = new TranslationsCache({ @@ -239,7 +239,7 @@ export class LocalesCache extends Cache< * @returns The translations cache lifecycle */ private _createTranslationsCacheLifecycle( - locale: Locale, + locale: Locale ): LifecycleParam< TranslationKey, Hash, diff --git a/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts b/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts index 484452199..7654f885e 100644 --- a/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts +++ b/packages/i18n/src/i18n-manager/translations-manager/TranslationsCache.ts @@ -1,14 +1,14 @@ -import { LookupOptions } from "../../translation-functions/types/options"; -import { Cache } from "./Cache"; -import type { LifecycleParam } from "../lifecycle-hooks/types"; -import { Translation } from "./utils/types/translation-data"; -import { hashMessage } from "../../utils/hashMessage"; -import type { Content } from "@generaltranslation/format/types"; +import { LookupOptions } from '../../translation-functions/types/options'; +import { Cache } from './Cache'; +import type { LifecycleParam } from '../lifecycle-hooks/types'; +import { Translation } from './utils/types/translation-data'; +import { hashMessage } from '../../utils/hashMessage'; +import type { Content } from '@generaltranslation/format/types'; import type { EntryMetadata, TranslateManyEntry, TranslationResult, -} from "generaltranslation/types"; +} from 'generaltranslation/types'; // See gt-next const MAX_BATCH_SIZE = 25; @@ -41,20 +41,20 @@ function getPositiveInteger(value: number | undefined, defaultValue: number) { } function normalizeBatchConfig( - batchConfig?: TranslationBatchConfig, + batchConfig?: TranslationBatchConfig ): Required { return { maxConcurrentRequests: getPositiveInteger( batchConfig?.maxConcurrentRequests, - DEFAULT_BATCH_CONFIG.maxConcurrentRequests, + DEFAULT_BATCH_CONFIG.maxConcurrentRequests ), maxBatchSize: getPositiveInteger( batchConfig?.maxBatchSize, - DEFAULT_BATCH_CONFIG.maxBatchSize, + DEFAULT_BATCH_CONFIG.maxBatchSize ), batchInterval: getPositiveValue( batchConfig?.batchInterval, - DEFAULT_BATCH_CONFIG.batchInterval, + DEFAULT_BATCH_CONFIG.batchInterval ), }; } @@ -90,7 +90,7 @@ type QueueEntry = { * TranslateMany call signature */ export type TranslateMany = ( - sources: Record, + sources: Record ) => Promise>; /** @@ -164,7 +164,7 @@ export class TranslationsCache< * @returns The translation value */ public get( - key: TranslationKey, + key: TranslationKey ): T | undefined { const value = this.getCache(key) as T | undefined; if (value != null && this.onHit) { @@ -184,7 +184,7 @@ export class TranslationsCache< * @returns The translation value */ public async miss( - key: TranslationKey, + key: TranslationKey ): Promise { const value = await this.missCache(key); if (value != null && this.onMiss) { @@ -213,7 +213,7 @@ export class TranslationsCache< * @returns The fallback value */ protected fallback( - key: TranslationKey, + key: TranslationKey ): Promise { // Add translation request to queue const translationPromise = this._enqueueTranslation(key); @@ -277,7 +277,7 @@ export class TranslationsCache< * @returns {Promise} The translation promise */ private _enqueueTranslation( - key: TranslationKey, + key: TranslationKey ): Promise { const hash = this.genKey(key); const options = key.options; @@ -314,14 +314,14 @@ export class TranslationsCache< * @param {QueueEntry[]} batch - The batch of requests to send */ private async _sendBatchRequest( - batch: QueueEntry[], + batch: QueueEntry[] ): Promise { this._activeRequests++; const requests = convertBatchToTranslateManyParams(batch); const response = await this._sendBatchRequestWithErrorHandling( batch, - requests, + requests ); if (response) { this._handleTranslationResponse(batch, response); @@ -335,7 +335,7 @@ export class TranslationsCache< */ private async _sendBatchRequestWithErrorHandling( batch: QueueEntry[], - requests: Record, + requests: Record ): Promise | undefined> { try { return await this._translateMany(requests); @@ -352,7 +352,7 @@ export class TranslationsCache< */ private _handleTranslationResponse( batch: QueueEntry[], - response: Awaited>, + response: Awaited> ): void { for (const entry of batch) { const { key } = entry; diff --git a/packages/i18n/src/i18n-manager/types.ts b/packages/i18n/src/i18n-manager/types.ts index 1664ce928..23b0f9fe7 100644 --- a/packages/i18n/src/i18n-manager/types.ts +++ b/packages/i18n/src/i18n-manager/types.ts @@ -1,16 +1,16 @@ -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 { Dictionary } from "./translations-manager/DictionaryCache"; -import type { DictionaryLoader } from "./translations-manager/LocalesDictionaryCache"; +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 { Dictionary } from './translations-manager/DictionaryCache'; +import type { DictionaryLoader } from './translations-manager/LocalesDictionaryCache'; import type { Hash, TranslationBatchConfig, -} from "./translations-manager/TranslationsCache"; -import { Locale } from "./translations-manager/LocalesCache"; +} from './translations-manager/TranslationsCache'; +import { Locale } from './translations-manager/LocalesCache'; export type DictionaryConfig = | { @@ -33,14 +33,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; initialTranslations?: Record>; @@ -53,7 +53,7 @@ export type I18nManagerConstructorParams< * I18nManager class configuration */ export type I18nManagerConfig = { - environment: "development" | "production"; + environment: 'development' | 'production'; defaultLocale: string; locales: string[]; customMapping: CustomMapping; diff --git a/packages/i18n/src/internal-types.ts b/packages/i18n/src/internal-types.ts index f43e1a662..45fa70228 100644 --- a/packages/i18n/src/internal-types.ts +++ b/packages/i18n/src/internal-types.ts @@ -11,8 +11,8 @@ export type { Dictionary, DictionaryLoader, DictionaryConfig, -} from "./i18n-manager/types"; -export type { LocaleCandidates } from "./i18n-manager/condition-store/localeResolver"; +} from './i18n-manager/types'; +export type { LocaleCandidates } from './i18n-manager/condition-store/localeResolver'; export type { DictionaryValue, DictionaryEntry, @@ -21,15 +21,15 @@ export type { DictionaryOptions, DictionaryPath, DictionaryKey, -} from "./i18n-manager/translations-manager/DictionaryCache"; +} from './i18n-manager/translations-manager/DictionaryCache'; // Translation Options (Function types exported by /types) -export type * from "./translation-functions/types/options"; -export type { InlineTranslationOptionsFields } 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"; +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 69089037f..d2297b655 100644 --- a/packages/i18n/src/internal.ts +++ b/packages/i18n/src/internal.ts @@ -1,21 +1,21 @@ -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 * 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"; +} from './utils/listenerKeys'; export type { DictionaryListenerLookup, TranslateListenerLookup, -} from "./utils/listenerKeys"; +} from './utils/listenerKeys'; export { getDictionaryEntry, isDictionaryValue, resolveDictionaryLookupOptions, -} from "./i18n-manager/translations-manager/utils/dictionary-helpers"; +} from './i18n-manager/translations-manager/utils/dictionary-helpers'; diff --git a/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts b/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts index f3e62b4aa..646053371 100644 --- a/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts +++ b/packages/i18n/src/translation-functions/internal/sync-translation-resolution.ts @@ -1,8 +1,8 @@ -import { InlineTranslationOptions } from "../types/options"; +import { InlineTranslationOptions } from '../types/options'; import { resolveStringContent, resolveStringContentWithFallback, -} from "./helpers"; +} from './helpers'; /** * Synchronously resolve a translation for a given message and options @@ -14,9 +14,9 @@ import { export function resolveTranslationSync( locale: string, message: string, - options: InlineTranslationOptions = {}, + options: InlineTranslationOptions = {} ) { - return resolveStringContent(locale, message, { $format: "ICU", ...options }); + return resolveStringContent(locale, message, { $format: 'ICU', ...options }); } /** @@ -29,10 +29,10 @@ export function resolveTranslationSync( export function resolveTranslationSyncWithFallback( locale: string, message: string, - options: InlineTranslationOptions = {}, + options: InlineTranslationOptions = {} ) { 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 fe5350c00..80e9b3173 100644 --- a/packages/i18n/src/translation-functions/types/options.ts +++ b/packages/i18n/src/translation-functions/types/options.ts @@ -1,7 +1,7 @@ import type { DataFormat, StringFormat, -} from "@generaltranslation/format/types"; +} from '@generaltranslation/format/types'; /** * Values that get interpolated @@ -76,7 +76,7 @@ export type EncodedTranslationOptions = BaseTranslationOptions & { export type RuntimeTranslationOptions = { $locale?: string; $format?: DataFormat; -} & Omit; +} & Omit; /** * Options for JSX translation @@ -84,7 +84,7 @@ export type RuntimeTranslationOptions = { */ export type JsxTranslationOptions = { // TODO: make this required, but internally, not user facing - $format?: "JSX"; + $format?: 'JSX'; $context?: string; $id?: string; $_hash?: string; @@ -95,18 +95,18 @@ export type JsxTranslationOptions = { */ export type LookupOptions = | (BaseTranslationOptions & - (Omit & { + (Omit & { $format: StringFormat; $locale?: string; })) | (JsxTranslationOptions & { - $format: "JSX"; + $format: 'JSX'; $locale?: string; }); export type DictionaryLookupOptions = Omit< InlineTranslationOptions, - "$format" + '$format' > & { $format: StringFormat; }; @@ -116,11 +116,11 @@ export type ResolutionOptions = { * The locale to use for formatting looking up and formatting the message. */ $locale?: string; -} & (T extends "JSX" +} & (T extends 'JSX' ? JsxTranslationOptions & { - $format?: "JSX"; + $format?: 'JSX'; } - : Omit & { + : Omit & { $format?: T; }); @@ -129,7 +129,7 @@ export type ResolutionOptions = { */ export type NormalizedLookupOptions = Omit< ResolutionOptions, - "$format" | "$locale" + '$format' | '$locale' > & { $format: T; $locale: string; diff --git a/packages/i18n/src/utils/extractVariables.ts b/packages/i18n/src/utils/extractVariables.ts index 6ba7e9ead..df39016e8 100644 --- a/packages/i18n/src/utils/extractVariables.ts +++ b/packages/i18n/src/utils/extractVariables.ts @@ -1,4 +1,4 @@ -import { BaseTranslationOptions } from "../translation-functions/types/options"; +import { BaseTranslationOptions } from '../translation-functions/types/options'; /** * Given an object of options, returns an object with no gt-related options @@ -7,21 +7,21 @@ import { BaseTranslationOptions } from "../translation-functions/types/options"; * TODO: next major version, options should be Record */ export function extractVariables( - options: T, + options: T ): BaseTranslationOptions { return Object.fromEntries( Object.entries(options).filter( ([key]) => - key !== "$id" && - key !== "$context" && - key !== "$maxChars" && - key !== "$hash" && // this is already being done in @gt/react-core - key !== "$_hash" && - key !== "$_source" && - key !== "$_fallback" && - key !== "$format" && - key !== "$_locales" && - key !== "$locale", - ), + key !== '$id' && + key !== '$context' && + key !== '$maxChars' && + key !== '$hash' && // this is already being done in @gt/react-core + key !== '$_hash' && + key !== '$_source' && + key !== '$_fallback' && + key !== '$format' && + key !== '$_locales' && + key !== '$locale' + ) ); } diff --git a/packages/i18n/src/utils/listenerKeys.ts b/packages/i18n/src/utils/listenerKeys.ts index 505e72e6f..572de3054 100644 --- a/packages/i18n/src/utils/listenerKeys.ts +++ b/packages/i18n/src/utils/listenerKeys.ts @@ -22,7 +22,9 @@ export function getTranslateListenerKey( lookup: TranslateListenerLookup ): string { const hash = - 'hash' in lookup ? lookup.hash : hashMessage(lookup.message, lookup.options); + 'hash' in lookup + ? lookup.hash + : hashMessage(lookup.message, lookup.options); return `${lookup.locale}:${hash}`; } diff --git a/packages/node/src/async-i18n-manager/AsyncConditionStore.ts b/packages/node/src/async-i18n-manager/AsyncConditionStore.ts index 1b4cd21c3..bd23d2ce8 100644 --- a/packages/node/src/async-i18n-manager/AsyncConditionStore.ts +++ b/packages/node/src/async-i18n-manager/AsyncConditionStore.ts @@ -1,17 +1,17 @@ -import { AsyncLocalStorage } from "node:async_hooks"; -import { createLocaleResolver } from "gt-i18n/internal"; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { createLocaleResolver } from 'gt-i18n/internal'; import type { LocaleCandidates, LocaleResolverConfig, ScopedConditionStore, -} from "gt-i18n/internal/types"; +} from 'gt-i18n/internal/types'; type Store = { locale: string; }; const OUTSIDE_SCOPE_MESSAGE = - "AsyncConditionStore: getLocale() called outside of a withGT() scope."; + 'AsyncConditionStore: getLocale() called outside of a withGT() scope.'; type AsyncConditionStoreConstructorParams = LocaleResolverConfig & { store?: AsyncLocalStorage; @@ -45,7 +45,7 @@ export class AsyncConditionStore implements ScopedConditionStore { 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(); } diff --git a/packages/react-core/src/branches/plurals/Plural.tsx b/packages/react-core/src/branches/plurals/Plural.tsx index 731bb84ec..3c9b5ec0a 100644 --- a/packages/react-core/src/branches/plurals/Plural.tsx +++ b/packages/react-core/src/branches/plurals/Plural.tsx @@ -1,8 +1,8 @@ -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"; +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`). @@ -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/branches/plurals/getPluralBranch.ts b/packages/react-core/src/branches/plurals/getPluralBranch.ts index 3b7bb0d7c..bc1c65b68 100644 --- a/packages/react-core/src/branches/plurals/getPluralBranch.ts +++ b/packages/react-core/src/branches/plurals/getPluralBranch.ts @@ -1,8 +1,8 @@ import { getPluralForm, isAcceptedPluralForm, -} from "generaltranslation/internal"; -import { ReactNode } from "react"; +} from 'generaltranslation/internal'; +import { ReactNode } from 'react'; /** * Main function to get the appropriate branch based on the provided number and branches. @@ -14,11 +14,11 @@ import { ReactNode } from "react"; export default function getPluralBranch( n: number, locales: string[], - branches: Record, + branches: Record ) { - let branchName = ""; + let branchName = ''; let branch = null; - if (typeof n === "number" && !branch && branches) { + if (typeof n === 'number' && !branch && branches) { const pluralForms = Object.keys(branches).filter(isAcceptedPluralForm); branchName = getPluralForm(n, pluralForms, locales); } diff --git a/packages/react-core/src/context.ts b/packages/react-core/src/context.ts index f4705fd88..fc850c603 100644 --- a/packages/react-core/src/context.ts +++ b/packages/react-core/src/context.ts @@ -1,14 +1,14 @@ // ===== Components ===== // -export { Branch } from "./refactor/components/branches/Branch"; -export { Plural } from "./refactor/components/branches/Plural"; -export { Derive } from "./refactor/components/derivation/Derive"; -export { LocaleSelector } from "./refactor/components/helpers/LocaleSelector"; -export { T } from "./refactor/components/translation/T"; -export { Currency } from "./refactor/components/variables/Currency"; -export { DateTime } from "./refactor/components/variables/DateTime"; -export { Num } from "./refactor/components/variables/Num"; -export { RelativeTime } from "./refactor/components/variables/RelativeTime"; -export { Var } from "./refactor/components/variables/Var"; +export { Branch } from './refactor/components/branches/Branch'; +export { Plural } from './refactor/components/branches/Plural'; +export { Derive } from './refactor/components/derivation/Derive'; +export { LocaleSelector } from './refactor/components/helpers/LocaleSelector'; +export { T } from './refactor/components/translation/T'; +export { Currency } from './refactor/components/variables/Currency'; +export { DateTime } from './refactor/components/variables/DateTime'; +export { Num } from './refactor/components/variables/Num'; +export { RelativeTime } from './refactor/components/variables/RelativeTime'; +export { Var } from './refactor/components/variables/Var'; // ===== Hooks ===== // export { @@ -16,47 +16,47 @@ export { useSetLocale, useEnableI18n, useSetEnableI18n, -} from "./refactor/hooks/context-hooks"; +} from './refactor/hooks/context-hooks'; export { useCustomMapping, useDefaultLocale, useLocales, -} from "./refactor/hooks/external-store-hooks"; -export { useGT } from "./refactor/hooks/useGT"; -export { useMessages } from "./refactor/hooks/useMessages"; -export { useTranslations } from "./refactor/hooks/useTranslations"; -export { useLocaleSelector } from "./refactor/hooks/useLocaleSelector"; -export { useFormatLocales } from "./refactor/hooks/utils"; +} from './refactor/hooks/external-store-hooks'; +export { useGT } from './refactor/hooks/useGT'; +export { useMessages } from './refactor/hooks/useMessages'; +export { useTranslations } from './refactor/hooks/useTranslations'; +export { useLocaleSelector } from './refactor/hooks/useLocaleSelector'; +export { useFormatLocales } from './refactor/hooks/utils'; // ===== Functions ===== // -export { getTranslationsSnapshot } from "./refactor/functions/helpers/getTranslationsSnapshot"; +export { getTranslationsSnapshot } from './refactor/functions/helpers/getTranslationsSnapshot'; // ===== Internal ===== // -export { InternalGTProvider } from "./refactor/context/provider/InternalGTProvider"; -export { internalInitializeGTSPA } from "./refactor/setup/initializeGTSPA"; -export { internalInitializeGTSSR } from "./refactor/setup/initializeGTSSR"; +export { InternalGTProvider } from './refactor/context/provider/InternalGTProvider'; +export { internalInitializeGTSPA } from './refactor/setup/initializeGTSPA'; +export { internalInitializeGTSSR } from './refactor/setup/initializeGTSSR'; export { getI18nStore, setI18nStore, -} from "./refactor/i18n-store/singleton-operations"; +} from './refactor/i18n-store/singleton-operations'; export { setRenderStrategy, getRenderStrategy, setStoresInitialized, -} from "./refactor/setup/globals"; +} from './refactor/setup/globals'; export { getConditionStore, setConditionStore, -} from "./refactor/condition-store/singleton-operations"; +} from './refactor/condition-store/singleton-operations'; export { getI18nManager, setI18nManager, -} from "./refactor/i18n-manager/singleton-operations"; -export { I18nStore } from "./refactor/i18n-store/I18nStore"; -export { ReactI18nManager } from "./refactor/i18n-manager/ReactI18nManager"; -export { ReactConditionStore } from "./refactor/condition-store/ReactConditionStore"; -export type { ReactConditionStoreParams } from "./refactor/condition-store/ReactConditionStore"; -export type { I18nStoreParams } from "./refactor/i18n-store/I18nStore"; -export type { InternalGTProviderProps } from "./refactor/context/provider/InternalGTProvider"; -export type { OverrideSetLocaleType } from "./refactor/i18n-store/storeTypes"; -export type { ReactI18nManagerParams } from "./refactor/i18n-manager/ReactI18nManager"; +} from './refactor/i18n-manager/singleton-operations'; +export { I18nStore } from './refactor/i18n-store/I18nStore'; +export { ReactI18nManager } from './refactor/i18n-manager/ReactI18nManager'; +export { ReactConditionStore } from './refactor/condition-store/ReactConditionStore'; +export type { ReactConditionStoreParams } from './refactor/condition-store/ReactConditionStore'; +export type { I18nStoreParams } from './refactor/i18n-store/I18nStore'; +export type { InternalGTProviderProps } from './refactor/context/provider/InternalGTProvider'; +export type { OverrideSetLocaleType } from './refactor/i18n-store/storeTypes'; +export type { ReactI18nManagerParams } from './refactor/i18n-manager/ReactI18nManager'; diff --git a/packages/react-core/src/internal.ts b/packages/react-core/src/internal.ts index bffcdea62..c0a4ea2b0 100644 --- a/packages/react-core/src/internal.ts +++ b/packages/react-core/src/internal.ts @@ -1,43 +1,43 @@ // 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 './internal/flattenDictionary'; +import addGTIdentifier from './internal/addGTIdentifier'; +import { removeInjectedT } from './internal/removeInjectedT'; +import writeChildrenAsObjects from './internal/writeChildrenAsObjects'; +import getPluralBranch from './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 './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'; 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 './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'; -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/provider/GTContext.ts b/packages/react-core/src/provider/GTContext.ts index 056a18a2e..ce3f260c2 100644 --- a/packages/react-core/src/provider/GTContext.ts +++ b/packages/react-core/src/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/__tests__/i18n-store.test.ts b/packages/react-core/src/provider/__tests__/i18n-store.test.ts index 56bd0c845..e80c2204f 100644 --- a/packages/react-core/src/provider/__tests__/i18n-store.test.ts +++ b/packages/react-core/src/provider/__tests__/i18n-store.test.ts @@ -41,8 +41,7 @@ describe('external store i18n wiring', () => { it('notifies only locale subscribers when locale changes', () => { const conditionStore = createConditionStore('en'); const localeListener = vi.fn(); - const unsubscribeLocale = - conditionStore.subscribeToLocale(localeListener); + const unsubscribeLocale = conditionStore.subscribeToLocale(localeListener); conditionStore.setLocale('fr'); @@ -55,8 +54,7 @@ describe('external store i18n wiring', () => { it('notifies region subscribers when region changes', () => { const conditionStore = createConditionStore('en'); const regionListener = vi.fn(); - const unsubscribeRegion = - conditionStore.subscribeToRegion(regionListener); + const unsubscribeRegion = conditionStore.subscribeToRegion(regionListener); conditionStore.setRegion('US'); @@ -159,16 +157,14 @@ describe('external store i18n wiring', () => { const matchingListener = vi.fn(); const otherListener = vi.fn(); - const unsubscribeMatching = - externalStore.subscribeToDictionaryEntry( - { locale: 'fr', id: 'greeting' }, - matchingListener - ); - const unsubscribeOther = - externalStore.subscribeToDictionaryEntry( - { locale: 'fr', id: 'other' }, - otherListener - ); + const unsubscribeMatching = externalStore.subscribeToDictionaryEntry( + { locale: 'fr', id: 'greeting' }, + matchingListener + ); + const unsubscribeOther = externalStore.subscribeToDictionaryEntry( + { locale: 'fr', id: 'other' }, + otherListener + ); await manager.lookupDictionaryWithFallback('fr', 'greeting'); @@ -185,16 +181,14 @@ describe('external store i18n wiring', () => { const matchingListener = vi.fn(); const otherListener = vi.fn(); - const unsubscribeMatching = - externalStore.subscribeToDictionaryObject( - { locale: 'fr', id: 'user.profile' }, - matchingListener - ); - const unsubscribeOther = - externalStore.subscribeToDictionaryObject( - { locale: 'fr', id: 'other' }, - otherListener - ); + const unsubscribeMatching = externalStore.subscribeToDictionaryObject( + { locale: 'fr', id: 'user.profile' }, + matchingListener + ); + const unsubscribeOther = externalStore.subscribeToDictionaryObject( + { locale: 'fr', id: 'other' }, + otherListener + ); await manager.lookupDictionaryObjWithFallback('fr', 'user.profile'); diff --git a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts b/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts index 5771c5202..7b4cad537 100644 --- a/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts +++ b/packages/react-core/src/provider/hooks/translation/useCreateInternalUseGTFunction.ts @@ -1,26 +1,26 @@ -import { hashSource } from "generaltranslation/id"; +import { hashSource } from 'generaltranslation/id'; import { InlineTranslationOptions, Translations, _Messages, _Message, -} from "../../../types-dir/types"; -import type { StringFormat } from "@generaltranslation/format/types"; -import { TranslateIcuCallback } from "../../../types-dir/runtime"; -import { GT } from "generaltranslation"; +} from '../../../types-dir/types'; +import type { StringFormat } from '@generaltranslation/format/types'; +import { TranslateIcuCallback } from '../../../types-dir/runtime'; +import { GT } from 'generaltranslation'; import { createStringRenderError, createStringRenderWarning, createStringTranslationError, -} from "../../../errors-dir/createErrors"; -import { decodeMsg, decodeOptions } from "../../../messages/messages"; +} from '../../../errors-dir/createErrors'; +import { decodeMsg, decodeOptions } from '../../../messages/messages'; import { extractVars, condenseVars, indexVars, VAR_IDENTIFIER, -} from "generaltranslation/internal"; -import { InlineResolveOptions } from "gt-i18n/types"; +} from 'generaltranslation/internal'; +import { InlineResolveOptions } from 'gt-i18n/types'; type MReturnType = T extends string ? string : T; @@ -51,17 +51,17 @@ export default function useCreateInternalUseGTFunction({ translationRequired: boolean; developmentApiEnabled: boolean; registerIcuForTranslation: TranslateIcuCallback; - environment: "development" | "production" | "test"; + environment: 'development' | 'production' | 'test'; }): { _gtFunction: ( message: string, options?: InlineTranslationOptions, - preloadedTranslations?: Translations, + preloadedTranslations?: Translations ) => string; _mFunction: ( message: T, options?: InlineResolveOptions, - preloadedTranslations?: Translations, + preloadedTranslations?: Translations ) => T extends string ? string : T; _filterMessagesForPreload: (_messages: _Messages) => _Messages; _preloadMessages: (_messages: _Messages) => Promise; @@ -87,7 +87,7 @@ export default function useCreateInternalUseGTFunction({ }: RenderMessageParams) { try { // (1) Try to format message - const declaredVars = extractVars(fallback || ""); + const declaredVars = extractVars(fallback || ''); const formattedMessage = gt.formatMessage( Object.keys(declaredVars).length ? condenseVars(message) : message, { @@ -95,16 +95,16 @@ export default function useCreateInternalUseGTFunction({ variables: { ...variables, ...declaredVars, - [VAR_IDENTIFIER]: "other", + [VAR_IDENTIFIER]: 'other', }, dataFormat: format, - }, + } ); // Apply cutoff formatting const cutoffMessage = gt.formatCutoff(formattedMessage, { maxChars }); return cutoffMessage; } catch (error) { - if (environment === "production") { + if (environment === 'production') { console.warn(createStringRenderWarning(message, id, error)); } else { // (3) If no fallback, throw error (non-prod) @@ -139,9 +139,9 @@ export default function useCreateInternalUseGTFunction({ $maxChars?: number; $id?: string; $_hash?: string; - } = {}, + } = {} ) { - if (!message || typeof message !== "string") return null; + if (!message || typeof message !== 'string') return null; const { $id: id, @@ -152,13 +152,13 @@ export default function useCreateInternalUseGTFunction({ ...variables } = options; const format = - typeof rawFormat === "string" ? (rawFormat as StringFormat) : undefined; + typeof rawFormat === 'string' ? (rawFormat as StringFormat) : undefined; // Update renderContent to use actual variables const renderMessage = ( msg: string, locales: string[], - fallback?: string, + fallback?: string ) => { return renderMessageHelper({ message: msg, @@ -178,7 +178,7 @@ export default function useCreateInternalUseGTFunction({ ...(context && { context }), ...(maxChars != null && { maxChars: Math.abs(maxChars) }), ...(id && { id }), - dataFormat: format || "ICU", + dataFormat: format || 'ICU', }); return { @@ -195,19 +195,19 @@ export default function useCreateInternalUseGTFunction({ function getTranslationData( calculateHash: () => string, id?: string, - _hash?: string, + _hash?: string ) { let translationEntry; - let hash = ""; // empty string because 1) it has to be a string but 2) we don't always need to calculate it + let hash = ''; // empty string because 1) it has to be a string but 2) we don't always need to calculate it if (id) { translationEntry = translations?.[id]; } - if (_hash && typeof translationEntry === "undefined") { + if (_hash && typeof translationEntry === 'undefined') { hash = _hash; translationEntry = translations?.[_hash]; } // Use calculated hash to index - if (typeof translationEntry === "undefined") { + if (typeof translationEntry === 'undefined') { hash = calculateHash(); translationEntry = translations?.[hash]; } @@ -226,7 +226,7 @@ export default function useCreateInternalUseGTFunction({ const { translationEntry, hash } = getTranslationData( calculateHash, id, - _hash, + _hash ); if (!translationEntry) { result.push({ message, ...options, $_hash: hash }); @@ -245,7 +245,7 @@ export default function useCreateInternalUseGTFunction({ const { translationEntry, hash } = getTranslationData( calculateHash, id, - _hash, + _hash ); // Return if no translation needed if (translationEntry) { @@ -271,11 +271,11 @@ export default function useCreateInternalUseGTFunction({ const _gtFunction = ( message: string, options: InlineTranslationOptions = {}, - preloadedTranslations: Translations | undefined, + preloadedTranslations: Translations | undefined ) => { // ----- SET UP ----- // const init = initializeGT(message, options); - if (!init) return ""; + if (!init) return ''; const { id, context, maxChars, _hash, calculateHash, renderMessage } = init; // ----- EARLY RETURN IF TRANSLATION NOT REQUIRED ----- // @@ -287,7 +287,7 @@ export default function useCreateInternalUseGTFunction({ const { translationEntry, hash } = getTranslationData( calculateHash, id, - _hash, + _hash ); // ----- RENDER TRANSLATION ----- // @@ -301,23 +301,23 @@ export default function useCreateInternalUseGTFunction({ return renderMessage( translationEntry as string, [locale, defaultLocale], - message, + message ); } - if (typeof preloadedTranslations?.[hash] !== "undefined") { + if (typeof preloadedTranslations?.[hash] !== 'undefined') { if (preloadedTranslations?.[hash]) { return renderMessage( preloadedTranslations?.[hash] as string, [locale, defaultLocale], - message, + message ); } return renderMessage(message, [defaultLocale]); } if (!developmentApiEnabled) { - console.warn(createStringTranslationError(message, id, "gt")); + console.warn(createStringTranslationError(message, id, 'gt')); return renderMessage(message, [defaultLocale]); } @@ -328,7 +328,7 @@ export default function useCreateInternalUseGTFunction({ ...(context && { context }), ...(id && { id }), ...(maxChars != null && { maxChars }), - hash: hash || "", + hash: hash || '', }, }); @@ -338,7 +338,7 @@ export default function useCreateInternalUseGTFunction({ const _mFunction = ( encodedMsg: T, options: InlineResolveOptions = {}, - preloadedTranslations: Translations | undefined, + preloadedTranslations: Translations | undefined ): T extends string ? string : T => { // Return if message is not a string if (!encodedMsg) return encodedMsg as MReturnType; @@ -348,7 +348,7 @@ export default function useCreateInternalUseGTFunction({ return _gtFunction( encodedMsg, options, - preloadedTranslations, + preloadedTranslations ) as MReturnType; } @@ -368,7 +368,7 @@ export default function useCreateInternalUseGTFunction({ const renderMessage = ( msg: string, locales: string[], - fallback?: string, + fallback?: string ) => { return renderMessageHelper({ message: msg, @@ -397,23 +397,23 @@ export default function useCreateInternalUseGTFunction({ return renderMessage( translationEntry as string, [locale, defaultLocale], - $_source, + $_source ) as MReturnType; } if (!developmentApiEnabled) { console.warn( - createStringTranslationError($_source, decodeMsg(encodedMsg), "m"), + createStringTranslationError($_source, decodeMsg(encodedMsg), 'm') ); return renderMessage($_source, [defaultLocale]) as MReturnType; } - if (typeof preloadedTranslations?.[$_hash] !== "undefined") { + if (typeof preloadedTranslations?.[$_hash] !== 'undefined') { if (preloadedTranslations?.[$_hash]) { return renderMessage( preloadedTranslations?.[$_hash] as string, [locale, defaultLocale], - $_source, + $_source ) as MReturnType; } return renderMessage($_source, [defaultLocale]) as MReturnType; diff --git a/packages/react-core/src/refactor/components/branches/Branch.tsx b/packages/react-core/src/refactor/components/branches/Branch.tsx index 54485b8cb..cf6e33f0d 100644 --- a/packages/react-core/src/refactor/components/branches/Branch.tsx +++ b/packages/react-core/src/refactor/components/branches/Branch.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ReactNode } from 'react'; // ===== Component ===== // @@ -15,10 +15,10 @@ function Branch({ [key: string]: ReactNode; }): ReactNode { let branchKey = branch?.toString(); - if (typeof branchKey === "string" && branchKey.startsWith("data-")) { + if (typeof branchKey === 'string' && branchKey.startsWith('data-')) { branchKey = undefined; } - return branchKey && typeof branches[branchKey] !== "undefined" + return branchKey && typeof branches[branchKey] !== 'undefined' ? branches[branchKey] : children; } @@ -32,8 +32,8 @@ function GtInternalBranch(props: { } /** @internal _gtt - The GT transformation for the component. */ -Branch._gtt = "branch"; -GtInternalBranch._gtt = "branch-automatic"; +Branch._gtt = 'branch'; +GtInternalBranch._gtt = 'branch-automatic'; // ===== Exports ===== // diff --git a/packages/react-core/src/refactor/components/branches/Plural.tsx b/packages/react-core/src/refactor/components/branches/Plural.tsx index 10d2e3057..48c6abefc 100644 --- a/packages/react-core/src/refactor/components/branches/Plural.tsx +++ b/packages/react-core/src/refactor/components/branches/Plural.tsx @@ -1,6 +1,6 @@ -import getPluralBranch from "../../../branches/plurals/getPluralBranch"; -import type { ReactNode } from "react"; -import { useFormatLocales } from "../../hooks/utils"; +import getPluralBranch from '../../../branches/plurals/getPluralBranch'; +import type { ReactNode } from 'react'; +import { useFormatLocales } from '../../hooks/utils'; // ===== Component ===== // @@ -16,7 +16,7 @@ function Plural({ [key: string]: ReactNode; }): ReactNode { const locales = useFormatLocales(localesProp); - if (typeof n !== "number") { + if (typeof n !== 'number') { return children; } return getPluralBranch(n, locales, branches) || children; @@ -32,8 +32,8 @@ function GtInternalPlural(props: { } /** @internal _gtt - The GT transformation for the component. */ -Plural._gtt = "plural"; -GtInternalPlural._gtt = "plural-automatic"; +Plural._gtt = 'plural'; +GtInternalPlural._gtt = 'plural-automatic'; // ===== Exports ===== // diff --git a/packages/react-core/src/refactor/components/helpers/InternalLocaleSelector.tsx b/packages/react-core/src/refactor/components/helpers/InternalLocaleSelector.tsx index 5621a153c..0ecfa8dc1 100644 --- a/packages/react-core/src/refactor/components/helpers/InternalLocaleSelector.tsx +++ b/packages/react-core/src/refactor/components/helpers/InternalLocaleSelector.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { CustomMapping, LocaleProperties } from "generaltranslation/types"; +import React from 'react'; +import { CustomMapping, LocaleProperties } from 'generaltranslation/types'; /** * Capitalizes the first letter of a string if applicable. @@ -8,8 +8,8 @@ import { CustomMapping, LocaleProperties } from "generaltranslation/types"; * @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) : ""); + if (!str) return ''; + return str.charAt(0).toUpperCase() + (str.length > 1 ? str.slice(1) : ''); } /** @@ -20,7 +20,7 @@ function capitalizeName(str: string): string { * @internal */ function _convertCustomNamesToMapping( - customNames?: { [key: string]: string } | undefined, + customNames?: { [key: string]: string } | undefined ): CustomMapping | undefined { if (!customNames) return undefined; const result: CustomMapping = {}; @@ -62,7 +62,7 @@ export function InternalLocaleSelector({ // Get display name const getDisplayName = (locale: string) => { if (customMapping && customMapping[locale]) { - if (typeof customMapping[locale] === "string") + if (typeof customMapping[locale] === 'string') return customMapping[locale]; if (customMapping[locale].name) return customMapping[locale].name; } @@ -78,11 +78,11 @@ export function InternalLocaleSelector({