Skip to content

Commit baadfbf

Browse files
committed
add server export
1 parent 19a0551 commit baadfbf

6 files changed

Lines changed: 147 additions & 4 deletions

File tree

packages/i18n/src/create-i18n/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,13 @@ export function createI18n($locale: ReadableStore<string>, options: CreateI18nOp
160160
): Messages<B> {
161161
const out: Record<string, unknown> = {};
162162
for (const key in base) {
163-
out[key] = buildEntry(locale, base[key], overridesForNamespace?.[key]);
163+
const baseVal = base[key];
164+
const override = overridesForNamespace?.[key];
165+
if (typeof baseVal === 'object' && baseVal !== null && !asMarker(baseVal)) {
166+
out[key] = buildMessages(locale, baseVal as Record<string, unknown>, override as Overrides | undefined);
167+
} else {
168+
out[key] = buildEntry(locale, baseVal, override);
169+
}
164170
}
165171
return out as Messages<B>;
166172
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getLocalization } from '../i18n/server';
4+
5+
describe('getLocalization', () => {
6+
it('returns the default locale with no messages when acceptLanguage is null', async () => {
7+
const result = await getLocalization(null);
8+
expect(result).toEqual({ locale: 'en', initialMessages: {} });
9+
});
10+
11+
it('matches the default locale via base tag (en-US → en)', async () => {
12+
const result = await getLocalization('en-US');
13+
expect(result).toEqual({ locale: 'en', initialMessages: {} });
14+
});
15+
16+
it('picks the highest-priority match (en-US,fr → en, not fr)', async () => {
17+
const result = await getLocalization('en-US,fr;q=0.9');
18+
expect(result.locale).toBe('en');
19+
});
20+
21+
it('loads french messages when locale is fr', async () => {
22+
const result = await getLocalization('fr');
23+
expect(result.locale).toBe('fr');
24+
expect(result.initialMessages).toHaveProperty('fr');
25+
expect(result.initialMessages.fr).toBeTruthy();
26+
});
27+
28+
it('matches a supported locale via base tag (fr-FR → fr)', async () => {
29+
const result = await getLocalization('fr-FR');
30+
expect(result.locale).toBe('fr');
31+
});
32+
33+
it('falls back to the default locale for an unsupported language', async () => {
34+
const result = await getLocalization('de');
35+
expect(result).toEqual({ locale: 'en', initialMessages: {} });
36+
});
37+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { renderHook } from '@testing-library/react';
2+
import React from 'react';
3+
import { describe, expect, it } from 'vitest';
4+
5+
import frMessages from '../locales/fr.json';
6+
import { MosaicProvider } from '../providers/mosaic-provider';
7+
import { useMessages } from '../providers/localization-provider';
8+
9+
const base = { title: 'Organization Profile', tab: { general: 'General', members: 'Members' } };
10+
11+
describe('LocalizationProvider overrides', () => {
12+
it('override wins over French translation when both locale and overrides are set', () => {
13+
const { result } = renderHook(() => useMessages('organizationProfile', base), {
14+
wrapper: ({ children }) =>
15+
React.createElement(
16+
MosaicProvider,
17+
{
18+
localization: {
19+
locale: 'fr',
20+
initialMessages: { fr: frMessages },
21+
overrides: { 'organizationProfile.title': 'test' },
22+
},
23+
},
24+
children,
25+
),
26+
});
27+
expect(result.current.title).toBe('test');
28+
// Non-overridden key still resolves from the French bundle
29+
expect((result.current.tab as Record<string, unknown>).general).toBe('Général');
30+
});
31+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Locale bundles shipped by @clerk/ui. Add an entry here as each locale lands.
2+
const LOADERS: Record<string, () => Promise<{ default: Record<string, Record<string, unknown>> }>> = {
3+
fr: () => import('../locales/fr.json'),
4+
};
5+
6+
const DEFAULT_LOCALE = 'en';
7+
8+
function negotiate(acceptLanguage: string | null): string {
9+
if (!acceptLanguage) return DEFAULT_LOCALE;
10+
const available = new Set([...Object.keys(LOADERS), DEFAULT_LOCALE]);
11+
for (const entry of acceptLanguage.split(',')) {
12+
const lang = entry.split(';')[0].trim();
13+
for (const candidate of [lang, lang.split('-')[0]]) {
14+
if (available.has(candidate)) return candidate;
15+
}
16+
}
17+
return DEFAULT_LOCALE;
18+
}
19+
20+
export type Localization = {
21+
locale: string;
22+
initialMessages: Record<string, Record<string, Record<string, unknown>>>;
23+
};
24+
25+
/**
26+
* Resolve a locale and pre-load its message bundle from the Accept-Language header.
27+
* Drop the result straight into <MosaicProvider localization={...} />.
28+
*
29+
* Works in any server environment — pass whatever string your framework exposes:
30+
* Next.js: getLocalization((await headers()).get('accept-language'))
31+
* Remix: getLocalization(request.headers.get('accept-language'))
32+
* Express: getLocalization(req.get('accept-language') ?? null)
33+
*/
34+
export async function getLocalization(acceptLanguage: string | null): Promise<Localization> {
35+
const locale = negotiate(acceptLanguage);
36+
const loader = LOADERS[locale];
37+
if (!loader) return { locale, initialMessages: {} };
38+
const messages = (await loader()).default;
39+
return { locale, initialMessages: { [locale]: messages } };
40+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"organizationProfile": {
3+
"title": "Profil de l'organisation",
4+
"tab": {
5+
"general": "Général",
6+
"members": "Membres"
7+
},
8+
"membersContent": "Contenu des membres"
9+
}
10+
}

packages/ui/src/mosaic/providers/localization-provider.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { atom, browser, createI18n, defineLocalization, localeFrom } from '@clerk/i18n';
22
import { useStore } from '@clerk/i18n/react';
33
import { createContextAndHook } from '@clerk/shared/react';
4-
import React, { useMemo } from 'react';
4+
import React, { useMemo, useRef } from 'react';
55

66
import type { FlatOverrides, I18n, NestedOverrides, Registry, ResolvedOverrides, WritableStore } from '@clerk/i18n';
77

88
interface LocalizationValue {
99
i18n: I18n;
1010
$locale: WritableStore<string | null>;
11+
$overrides: WritableStore<ResolvedOverrides>;
1112
}
1213

1314
const [LocalizationCtx, useLocalization] = createContextAndHook<LocalizationValue>('LocalizationContext');
@@ -38,16 +39,34 @@ export function LocalizationProvider<R extends Registry = Registry>({
3839
// Seed $explicit so the first render is correct; no useEffect needed.
3940
const value = useMemo(() => {
4041
const $explicit = atom<string | null>(locale ?? null);
42+
const $overrides = atom<ResolvedOverrides>(
43+
overrides ? defineLocalization(overrides as NestedOverrides<Registry>) : {},
44+
);
4145
const $locale = localeFrom($explicit, browser({ available: locales, fallback: defaultLocale }));
4246
const i18n = createI18n($locale, {
4347
get: () => Promise.resolve({}),
44-
overrides: atom<ResolvedOverrides>(overrides ? defineLocalization(overrides as NestedOverrides<Registry>) : {}),
48+
overrides: $overrides,
4549
cache: initialMessages,
4650
});
47-
return { i18n, $locale: $explicit };
51+
return { i18n, $locale: $explicit, $overrides };
4852
// eslint-disable-next-line react-hooks/exhaustive-deps
4953
}, []);
5054

55+
// Sync controlled props → atoms during render (derived-state pattern, no effect needed).
56+
// locale: primitive comparison against atom's current value is sufficient.
57+
const nextLocale = locale ?? null;
58+
if (value.$locale.get() !== nextLocale) {
59+
value.$locale.set(nextLocale);
60+
}
61+
62+
// overrides: defineLocalization() returns a new object every call, so compare
63+
// the prop reference — not the resolved value — to avoid spurious updates.
64+
const prevOverridesRef = useRef(overrides);
65+
if (prevOverridesRef.current !== overrides) {
66+
prevOverridesRef.current = overrides;
67+
value.$overrides.set(overrides ? defineLocalization(overrides as NestedOverrides<Registry>) : {});
68+
}
69+
5170
return <LocalizationCtx.Provider value={{ value }}>{children}</LocalizationCtx.Provider>;
5271
}
5372

0 commit comments

Comments
 (0)