Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/i18n/InternationalizationContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
*/

import {createContext} from 'react';
import {getResolve} from './resolve';
import type {Locale, MessagesByLocale, Overrides} from './types';

export interface InternationalizationContextValue {
locale: Locale;
direction: 'ltr' | 'rtl';
messages: MessagesByLocale;
overrides?: Overrides;
translate: ReturnType<typeof getResolve>;
}

/**
Expand All @@ -39,5 +41,6 @@ export const InternationalizationContext =
locale: 'en',
direction: 'ltr',
messages: {},
translate: getResolve('en', {}),
});
InternationalizationContext.displayName = 'InternationalizationContext';
9 changes: 8 additions & 1 deletion packages/core/src/i18n/InternationalizationProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {useMemo, type ReactNode} from 'react';
import {InternationalizationContext} from './InternationalizationContext';
import {getLocaleDirection} from './getLocaleDirection';
import type {Locale, MessagesByLocale, Overrides} from './types';
import {getResolve} from './resolve';

export interface InternationalizationProviderProps {
/**
Expand Down Expand Up @@ -79,7 +80,13 @@ export function InternationalizationProvider({
}: InternationalizationProviderProps) {
const direction = dir ?? getLocaleDirection(locale);
const value = useMemo(
() => ({locale, direction, messages: messages ?? {}, overrides}),
() => ({
locale,
direction,
messages: messages ?? {},
overrides,
translate: getResolve(locale, messages ?? {}, overrides),
}),
[locale, direction, messages, overrides],
);
return (
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/i18n/__tests__/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@
*/

import {describe, expect, test, beforeEach, vi} from 'vitest';
import {__resetForTests, resolve, resolveLocaleChain} from '../resolve';
import {__resetForTests, getResolve, resolveLocaleChain} from '../resolve';
import type {Catalog, MessagesByLocale, Overrides} from '../types';

const resolve = (
...[key, values, locale, messages, overrides]: [
...Parameters<ReturnType<typeof getResolve>>,
...Parameters<typeof getResolve>,
]
) => getResolve(locale, messages, overrides)(key, values);

// Reset caches between tests so warn-once and formatter cache don't bleed.
beforeEach(() => {
__resetForTests();
Expand Down
103 changes: 50 additions & 53 deletions packages/core/src/i18n/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,74 +73,71 @@ export function resolveLocaleChain(locale: Locale): Locale[] {
return chain;
}

function lookup(
key: string,
function getLookup(
locale: Locale,
messages: MessagesByLocale,
overrides: Overrides | undefined,
): string | null {
overrides?: Overrides,
): Record<string, string> {
const lookup = {} as Record<string, string>;
const chain = resolveLocaleChain(locale);

// 1 + 2. Overrides (most specific to least specific in the chain)
if (overrides !== undefined) {
for (const tag of chain) {
const value = overrides[tag]?.[key];
if (value !== undefined) {
return value;
for (const tag of chain) {
if (overrides?.[tag]) {
for (const [key, value] of Object.entries(overrides[tag])) {
if (lookup[key] === undefined && value !== null) {
lookup[key] = value;
}
}
}
}

// 3 + 4. Shipped catalogs (most specific to least specific)
for (const tag of chain) {
const entry = messages[tag]?.[key];
if (entry !== undefined) {
return entry.defaultMessage;
if (messages[tag]) {
for (const [key, value] of Object.entries(messages[tag])) {
if (lookup[key] === undefined && value?.defaultMessage !== null) {
lookup[key] = value?.defaultMessage;
}
}
}
}

// 5. Shipped en catalog — always present
const enEntry = EN_CATALOG[key];
if (enEntry !== undefined) {
return enEntry.defaultMessage;
for (const [key, value] of Object.entries(EN_CATALOG)) {
if (lookup[key] === undefined && value?.defaultMessage !== null) {
lookup[key] = value?.defaultMessage;
}
}

// 6. Nothing found
return null;
return lookup;
}

export function resolve(
key: string,
values: Record<string, unknown> | undefined,
export function getResolve(
locale: Locale,
messages: MessagesByLocale,
overrides: Overrides | undefined,
): string {
const result = lookup(key, locale, messages, overrides);

if (result === null) {
// Fires ONLY when a key is missing from every source including the
// shipped `en` catalog — a real bug (typo, stale catalog, deleted key).
// Fallback to `en` from a non-en locale is expected and stays silent,
// matching the FormatJS / i18next default.
warnOnce(
`astryx-i18n:${locale}::${key}`,
'astryx-i18n',
`missing key: ${key} (locale: ${locale})`,
);
return key;
}
overrides?: Overrides,
) {
const lookup = getLookup(locale, messages, overrides);

return (key: string, values: Record<string, unknown> | undefined) => {
const result = lookup[key];
if (result === undefined) {
// Fires ONLY when a key is missing from every source including the
// shipped `en` catalog — a real bug (typo, stale catalog, deleted key).
// Fallback to `en` from a non-en locale is expected and stays silent,
// matching the FormatJS / i18next default.
warnOnce(
`astryx-i18n:${locale}::${key}`,
'astryx-i18n',
`missing key: ${key} (locale: ${locale})`,
);
return key;
}

if (values === undefined) {
// Static string — skip the parser entirely for the common case
return result;
}
if (values === undefined) {
// Static string — skip the parser entirely for the common case
return result;
}

const formatted = getFormatter(result, locale).format(values);
// IntlMessageFormat.format returns string | (string | React elements) — we
// only ever pass string values so it will be a string; assert for the type
// system.
return formatted as string;
const formatted = getFormatter(result, locale).format(values);
// IntlMessageFormat.format returns string | (string | React elements) — we
// only ever pass string values so it will be a string; assert for the type
// system.
return formatted as string;
};
}

/**
Expand Down
9 changes: 2 additions & 7 deletions packages/core/src/i18n/useTranslator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@
* - /packages/core/src/i18n/t.client.ts
*/

import {use, useCallback} from 'react';
import {use} from 'react';
import {InternationalizationContext} from './InternationalizationContext';
import {resolve} from './resolve';

export type TranslatorFn = (
key: string,
Expand All @@ -42,9 +41,5 @@ export type TranslatorFn = (
*/
export function useTranslator(): TranslatorFn {
const ctx = use(InternationalizationContext);
return useCallback(
(key: string, values?: Record<string, unknown>) =>
resolve(key, values, ctx.locale, ctx.messages, ctx.overrides),
[ctx.locale, ctx.messages, ctx.overrides],
);
return ctx.translate;
}