@better-i18n/next 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/client.tsx +324 -0
- package/src/config.ts +1 -0
- package/src/index.ts +4 -2
- package/src/middleware.ts +36 -24
- package/src/server.ts +18 -0
- package/src/types.ts +7 -0
- package/src/client.ts +0 -142
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-i18n/next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Better-i18n Next.js integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"default": "./src/server.ts"
|
|
26
26
|
},
|
|
27
27
|
"./client": {
|
|
28
|
-
"types": "./src/client.
|
|
29
|
-
"default": "./src/client.
|
|
28
|
+
"types": "./src/client.tsx",
|
|
29
|
+
"default": "./src/client.tsx"
|
|
30
30
|
},
|
|
31
31
|
"./middleware": {
|
|
32
32
|
"types": "./src/middleware.ts",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"typecheck": "tsc --noEmit"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@better-i18n/core": "0.1.
|
|
60
|
+
"@better-i18n/core": "0.1.7"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"next": ">=15.0.0",
|
package/src/client.tsx
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { useRouter } from "next/navigation";
|
|
13
|
+
import { NextIntlClientProvider } from "next-intl";
|
|
14
|
+
import { createI18nCore, parseProject } from "@better-i18n/core";
|
|
15
|
+
import type { LanguageOption, Messages } from "@better-i18n/core";
|
|
16
|
+
|
|
17
|
+
import { normalizeConfig } from "./config";
|
|
18
|
+
import type { I18nConfig } from "./types";
|
|
19
|
+
|
|
20
|
+
// ─── BetterI18nProvider ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface BetterI18nContextValue {
|
|
23
|
+
setLocale: (locale: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const BetterI18nContext = createContext<BetterI18nContextValue | null>(null);
|
|
27
|
+
|
|
28
|
+
export interface BetterI18nProviderProps {
|
|
29
|
+
/** Initial locale from server (getLocale()) */
|
|
30
|
+
locale: string;
|
|
31
|
+
/** Initial messages from server (getMessages()) */
|
|
32
|
+
messages: Messages;
|
|
33
|
+
/** i18n config — only project and defaultLocale are required */
|
|
34
|
+
config: I18nConfig;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Provider that wraps `NextIntlClientProvider` and enables instant locale
|
|
40
|
+
* switching without a server round-trip.
|
|
41
|
+
*
|
|
42
|
+
* When `useSetLocale()` is called inside this provider, it:
|
|
43
|
+
* 1. Sets a cookie (for server-side persistence on next navigation)
|
|
44
|
+
* 2. Fetches new messages from CDN on the client
|
|
45
|
+
* 3. Re-renders the tree with new locale + messages instantly
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* // app/layout.tsx
|
|
50
|
+
* import { BetterI18nProvider } from '@better-i18n/next/client'
|
|
51
|
+
*
|
|
52
|
+
* export default async function RootLayout({ children }) {
|
|
53
|
+
* const locale = await getLocale()
|
|
54
|
+
* const messages = await getMessages()
|
|
55
|
+
*
|
|
56
|
+
* return (
|
|
57
|
+
* <BetterI18nProvider
|
|
58
|
+
* locale={locale}
|
|
59
|
+
* messages={messages}
|
|
60
|
+
* config={{ project: 'acme/dashboard', defaultLocale: 'en' }}
|
|
61
|
+
* >
|
|
62
|
+
* {children}
|
|
63
|
+
* </BetterI18nProvider>
|
|
64
|
+
* )
|
|
65
|
+
* }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function BetterI18nProvider({
|
|
69
|
+
locale: initialLocale,
|
|
70
|
+
messages: initialMessages,
|
|
71
|
+
config,
|
|
72
|
+
children,
|
|
73
|
+
}: BetterI18nProviderProps) {
|
|
74
|
+
const normalized = useMemo(() => normalizeConfig(config), [
|
|
75
|
+
config.project,
|
|
76
|
+
config.defaultLocale,
|
|
77
|
+
config.cookieName,
|
|
78
|
+
config.cdnBaseUrl,
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const [locale, setLocaleState] = useState(initialLocale);
|
|
82
|
+
const [messages, setMessages] = useState<Messages>(initialMessages);
|
|
83
|
+
|
|
84
|
+
// Sync with server-provided values when they change (e.g. navigation to a new page)
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
setLocaleState(initialLocale);
|
|
87
|
+
setMessages(initialMessages);
|
|
88
|
+
}, [initialLocale, initialMessages]);
|
|
89
|
+
|
|
90
|
+
const setLocale = useCallback(
|
|
91
|
+
async (newLocale: string) => {
|
|
92
|
+
if (newLocale === locale) return;
|
|
93
|
+
|
|
94
|
+
// 1. Set cookie for server-side persistence
|
|
95
|
+
document.cookie = `${normalized.cookieName}=${newLocale}; path=/; max-age=31536000; samesite=lax`;
|
|
96
|
+
|
|
97
|
+
// 2. Fetch new messages from CDN
|
|
98
|
+
const { workspaceId, projectSlug } = parseProject(normalized.project);
|
|
99
|
+
const cdnBase = normalized.cdnBaseUrl || "https://cdn.better-i18n.com";
|
|
100
|
+
const url = `${cdnBase}/${workspaceId}/${projectSlug}/${newLocale}/translations.json`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(url);
|
|
104
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
105
|
+
const newMessages: Messages = await res.json();
|
|
106
|
+
|
|
107
|
+
// 3. Instant client-side update — no server round-trip
|
|
108
|
+
setLocaleState(newLocale);
|
|
109
|
+
setMessages(newMessages);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error("[better-i18n] Client-side locale switch failed, falling back to refresh:", err);
|
|
112
|
+
// Fallback: let the server handle it
|
|
113
|
+
window.location.reload();
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[locale, normalized]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<BetterI18nContext.Provider value={{ setLocale }}>
|
|
121
|
+
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
122
|
+
{children}
|
|
123
|
+
</NextIntlClientProvider>
|
|
124
|
+
</BetterI18nContext.Provider>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── useManifestLanguages ────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export type UseManifestLanguagesResult = {
|
|
131
|
+
languages: LanguageOption[];
|
|
132
|
+
isLoading: boolean;
|
|
133
|
+
error: Error | null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
type ClientCacheEntry = {
|
|
137
|
+
data?: LanguageOption[];
|
|
138
|
+
error?: Error;
|
|
139
|
+
promise?: Promise<LanguageOption[]>;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Client-side request deduplication cache
|
|
143
|
+
const clientCache = new Map<string, ClientCacheEntry>();
|
|
144
|
+
|
|
145
|
+
const getCacheKey = (project: string, cdnBaseUrl?: string) =>
|
|
146
|
+
`${cdnBaseUrl || "https://cdn.better-i18n.com"}|${project}`;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* React hook to fetch manifest languages on the client
|
|
150
|
+
*
|
|
151
|
+
* Uses `createI18nCore` from `@better-i18n/core` internally with
|
|
152
|
+
* request deduplication to prevent duplicate fetches.
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```tsx
|
|
156
|
+
* const { languages, isLoading, error } = useManifestLanguages({
|
|
157
|
+
* project: 'acme/dashboard',
|
|
158
|
+
* defaultLocale: 'en',
|
|
159
|
+
* })
|
|
160
|
+
*
|
|
161
|
+
* if (isLoading) return <Spinner />
|
|
162
|
+
* if (error) return <Error message={error.message} />
|
|
163
|
+
*
|
|
164
|
+
* return (
|
|
165
|
+
* <select>
|
|
166
|
+
* {languages.map(lang => (
|
|
167
|
+
* <option key={lang.code} value={lang.code}>
|
|
168
|
+
* {lang.nativeName || lang.name || lang.code}
|
|
169
|
+
* </option>
|
|
170
|
+
* ))}
|
|
171
|
+
* </select>
|
|
172
|
+
* )
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export const useManifestLanguages = (config: I18nConfig): UseManifestLanguagesResult => {
|
|
176
|
+
const normalized = useMemo(
|
|
177
|
+
() => normalizeConfig(config),
|
|
178
|
+
[
|
|
179
|
+
config.project,
|
|
180
|
+
config.defaultLocale,
|
|
181
|
+
config.cdnBaseUrl,
|
|
182
|
+
config.debug,
|
|
183
|
+
config.logLevel,
|
|
184
|
+
],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const i18nCore = useMemo(
|
|
188
|
+
() =>
|
|
189
|
+
createI18nCore({
|
|
190
|
+
project: normalized.project,
|
|
191
|
+
defaultLocale: normalized.defaultLocale,
|
|
192
|
+
cdnBaseUrl: normalized.cdnBaseUrl,
|
|
193
|
+
debug: normalized.debug,
|
|
194
|
+
logLevel: normalized.logLevel,
|
|
195
|
+
}),
|
|
196
|
+
[normalized],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const cacheKey = getCacheKey(normalized.project, normalized.cdnBaseUrl);
|
|
200
|
+
const cached = clientCache.get(cacheKey);
|
|
201
|
+
|
|
202
|
+
const [languages, setLanguages] = useState<LanguageOption[]>(cached?.data ?? []);
|
|
203
|
+
const [isLoading, setIsLoading] = useState(!cached?.data);
|
|
204
|
+
const [error, setError] = useState<Error | null>(cached?.error ?? null);
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
let isMounted = true;
|
|
208
|
+
|
|
209
|
+
const run = async () => {
|
|
210
|
+
const entry = clientCache.get(cacheKey) ?? {};
|
|
211
|
+
|
|
212
|
+
// Return cached data if available
|
|
213
|
+
if (entry.data) {
|
|
214
|
+
if (isMounted) {
|
|
215
|
+
setLanguages(entry.data);
|
|
216
|
+
setIsLoading(false);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Deduplicate in-flight requests
|
|
222
|
+
if (!entry.promise) {
|
|
223
|
+
entry.promise = i18nCore
|
|
224
|
+
.getLanguages()
|
|
225
|
+
.then((langs) => {
|
|
226
|
+
clientCache.set(cacheKey, { data: langs });
|
|
227
|
+
return langs;
|
|
228
|
+
})
|
|
229
|
+
.catch((err) => {
|
|
230
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
231
|
+
clientCache.set(cacheKey, { error });
|
|
232
|
+
throw error;
|
|
233
|
+
});
|
|
234
|
+
clientCache.set(cacheKey, entry);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const langs = await entry.promise;
|
|
239
|
+
if (isMounted) {
|
|
240
|
+
setLanguages(langs);
|
|
241
|
+
setError(null);
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (isMounted) {
|
|
245
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
if (isMounted) {
|
|
249
|
+
setIsLoading(false);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
run();
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
isMounted = false;
|
|
258
|
+
};
|
|
259
|
+
}, [cacheKey, i18nCore]);
|
|
260
|
+
|
|
261
|
+
return { languages, isLoading, error };
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// ─── useSetLocale ────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* React hook that returns a function to switch the active locale.
|
|
268
|
+
*
|
|
269
|
+
* - **With `BetterI18nProvider`**: Fetches new messages client-side from CDN
|
|
270
|
+
* and updates the UI instantly — no page refresh needed.
|
|
271
|
+
* - **Without provider (standalone)**: Pass a config object. Sets a cookie and
|
|
272
|
+
* calls `router.refresh()` for a soft server re-render.
|
|
273
|
+
*
|
|
274
|
+
* @example With provider (recommended — instant switching)
|
|
275
|
+
* ```tsx
|
|
276
|
+
* // Wrap your layout with BetterI18nProvider, then:
|
|
277
|
+
* const setLocale = useSetLocale()
|
|
278
|
+
* <button onClick={() => setLocale('tr')}>Türkçe</button>
|
|
279
|
+
* ```
|
|
280
|
+
*
|
|
281
|
+
* @example Standalone (soft refresh)
|
|
282
|
+
* ```tsx
|
|
283
|
+
* const setLocale = useSetLocale({ project: 'acme/dashboard', defaultLocale: 'en' })
|
|
284
|
+
* <button onClick={() => setLocale('tr')}>Türkçe</button>
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
export function useSetLocale(config?: I18nConfig): (locale: string) => void {
|
|
288
|
+
const ctx = useContext(BetterI18nContext);
|
|
289
|
+
|
|
290
|
+
// If BetterI18nProvider is in the tree, use instant client-side switching
|
|
291
|
+
if (ctx) {
|
|
292
|
+
return ctx.setLocale;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Standalone fallback: cookie + router.refresh()
|
|
296
|
+
if (!config) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"[better-i18n] useSetLocale() requires either a <BetterI18nProvider> ancestor " +
|
|
299
|
+
"or a config argument. Use useSetLocale({ project, defaultLocale }) for standalone mode."
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
304
|
+
return useStandaloneSetLocale(config);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Internal standalone hook — cookie + router.refresh() */
|
|
308
|
+
function useStandaloneSetLocale(config: I18nConfig) {
|
|
309
|
+
const normalized = useMemo(() => normalizeConfig(config), [
|
|
310
|
+
config.project,
|
|
311
|
+
config.defaultLocale,
|
|
312
|
+
config.cookieName,
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const router = useRouter();
|
|
316
|
+
|
|
317
|
+
return useCallback(
|
|
318
|
+
(locale: string) => {
|
|
319
|
+
document.cookie = `${normalized.cookieName}=${locale}; path=/; max-age=31536000; samesite=lax`;
|
|
320
|
+
router.refresh();
|
|
321
|
+
},
|
|
322
|
+
[normalized.cookieName, router]
|
|
323
|
+
);
|
|
324
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const normalizeConfig = (config: I18nConfig): NormalizedConfig => {
|
|
|
27
27
|
return {
|
|
28
28
|
...coreConfig,
|
|
29
29
|
localePrefix: config.localePrefix ?? "as-needed",
|
|
30
|
+
cookieName: config.cookieName ?? "locale",
|
|
30
31
|
manifestRevalidateSeconds: config.manifestRevalidateSeconds ?? 3600,
|
|
31
32
|
messagesRevalidateSeconds: config.messagesRevalidateSeconds ?? 30,
|
|
32
33
|
};
|
package/src/index.ts
CHANGED
|
@@ -81,6 +81,7 @@ export const createI18n = (config: I18nConfig) => {
|
|
|
81
81
|
detection: {
|
|
82
82
|
cookie: true,
|
|
83
83
|
browserLanguage: true,
|
|
84
|
+
cookieName: normalized.cookieName,
|
|
84
85
|
},
|
|
85
86
|
},
|
|
86
87
|
callback
|
|
@@ -96,8 +97,9 @@ export type { MiddlewareContext, MiddlewareCallback } from "./middleware";
|
|
|
96
97
|
// Core instance factory
|
|
97
98
|
export { createNextI18nCore } from "./server";
|
|
98
99
|
|
|
99
|
-
// Client
|
|
100
|
-
export { useManifestLanguages } from "./client";
|
|
100
|
+
// Client hooks & provider
|
|
101
|
+
export { BetterI18nProvider, useManifestLanguages, useSetLocale } from "./client";
|
|
102
|
+
export type { BetterI18nProviderProps } from "./client";
|
|
101
103
|
|
|
102
104
|
// Re-export types
|
|
103
105
|
export type {
|
package/src/middleware.ts
CHANGED
|
@@ -107,19 +107,12 @@ export function createBetterI18nMiddleware(
|
|
|
107
107
|
// 1. Fetch available locales from CDN
|
|
108
108
|
const availableLocales = await i18nCore.getLocales();
|
|
109
109
|
|
|
110
|
-
// 2.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
localePrefix,
|
|
117
|
-
});
|
|
118
|
-
cachedLocalesKey = localesKey;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// 3. Our custom locale detection (for cookie logic)
|
|
122
|
-
const pathLocale = request.nextUrl.pathname.split("/")[1];
|
|
110
|
+
// 2. Our custom locale detection
|
|
111
|
+
// When localePrefix is "never", path has no locale segment — skip it
|
|
112
|
+
const pathLocale =
|
|
113
|
+
localePrefix === "never"
|
|
114
|
+
? null
|
|
115
|
+
: request.nextUrl.pathname.split("/")[1];
|
|
123
116
|
const cookieLocale = cookie ? request.cookies.get(cookieName)?.value : null;
|
|
124
117
|
const headerLocale = browserLanguage
|
|
125
118
|
? request.headers.get("accept-language")?.split(",")[0]?.split("-")[0]
|
|
@@ -134,18 +127,37 @@ export function createBetterI18nMiddleware(
|
|
|
134
127
|
availableLocales,
|
|
135
128
|
});
|
|
136
129
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
130
|
+
const detectedLocale = result.locale;
|
|
131
|
+
|
|
132
|
+
let response: NextResponse;
|
|
133
|
+
|
|
134
|
+
if (localePrefix === "never") {
|
|
135
|
+
// 3a. "never" mode: bypass next-intl middleware entirely.
|
|
136
|
+
// next-intl's createMiddleware tries URL rewriting which breaks cookie-only locale.
|
|
137
|
+
// We create a plain response and set the header that next-intl's getRequestConfig reads.
|
|
138
|
+
response = NextResponse.next();
|
|
139
|
+
response.headers.set(
|
|
140
|
+
"x-middleware-request-x-next-intl-locale",
|
|
141
|
+
detectedLocale
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
// 3b. URL-based modes: delegate to next-intl middleware as before
|
|
145
|
+
const localesKey = availableLocales.join(",");
|
|
146
|
+
if (!cachedMiddleware || cachedLocalesKey !== localesKey) {
|
|
147
|
+
cachedMiddleware = createMiddleware({
|
|
148
|
+
locales: availableLocales,
|
|
149
|
+
defaultLocale,
|
|
150
|
+
localePrefix,
|
|
151
|
+
});
|
|
152
|
+
cachedLocalesKey = localesKey;
|
|
153
|
+
}
|
|
154
|
+
response = cachedMiddleware(request);
|
|
155
|
+
}
|
|
144
156
|
|
|
145
|
-
//
|
|
157
|
+
// 4. Add x-locale header for backwards compatibility
|
|
146
158
|
response.headers.set("x-locale", detectedLocale);
|
|
147
159
|
|
|
148
|
-
//
|
|
160
|
+
// 5. Set our custom cookie if needed
|
|
149
161
|
if (cookie && result.shouldSetCookie) {
|
|
150
162
|
response.cookies.set(cookieName, detectedLocale, {
|
|
151
163
|
path: "/",
|
|
@@ -154,7 +166,7 @@ export function createBetterI18nMiddleware(
|
|
|
154
166
|
});
|
|
155
167
|
}
|
|
156
168
|
|
|
157
|
-
//
|
|
169
|
+
// 6. If callback provided, execute it (Clerk-style)
|
|
158
170
|
if (callback) {
|
|
159
171
|
const callbackResult = await callback(request, {
|
|
160
172
|
locale: detectedLocale,
|
|
@@ -167,7 +179,7 @@ export function createBetterI18nMiddleware(
|
|
|
167
179
|
}
|
|
168
180
|
}
|
|
169
181
|
|
|
170
|
-
//
|
|
182
|
+
// 7. Return the response
|
|
171
183
|
return response;
|
|
172
184
|
};
|
|
173
185
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRequestConfig } from "next-intl/server";
|
|
2
|
+
import { cookies } from "next/headers";
|
|
2
3
|
import { createI18nCore } from "@better-i18n/core";
|
|
3
4
|
import type { I18nCore, Messages } from "@better-i18n/core";
|
|
4
5
|
|
|
@@ -98,8 +99,25 @@ export const createNextIntlRequestConfig = (config: I18nConfig) =>
|
|
|
98
99
|
const i18n = createNextI18nCore(config);
|
|
99
100
|
const normalized = normalizeConfig(config);
|
|
100
101
|
const locales = await i18n.getLocales();
|
|
102
|
+
|
|
103
|
+
// 1. Middleware header (set by next-intl or our middleware)
|
|
101
104
|
let locale = await requestLocale;
|
|
102
105
|
|
|
106
|
+
// 2. Cookie fallback — critical for localePrefix: "never" where
|
|
107
|
+
// requestLocale may be undefined if middleware header isn't forwarded
|
|
108
|
+
if (!locale) {
|
|
109
|
+
try {
|
|
110
|
+
const cookieStore = await cookies();
|
|
111
|
+
const cookieLocale = cookieStore.get(normalized.cookieName)?.value;
|
|
112
|
+
if (cookieLocale && locales.includes(cookieLocale)) {
|
|
113
|
+
locale = cookieLocale;
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// cookies() throws in non-request contexts (e.g. build time)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3. Final fallback to defaultLocale
|
|
103
121
|
if (!locale || !locales.includes(locale)) {
|
|
104
122
|
locale = normalized.defaultLocale;
|
|
105
123
|
}
|
package/src/types.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface I18nConfig extends I18nCoreConfig {
|
|
|
27
27
|
*/
|
|
28
28
|
localePrefix?: LocalePrefix;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Cookie name used for locale persistence when localePrefix is "never"
|
|
32
|
+
* @default "locale"
|
|
33
|
+
*/
|
|
34
|
+
cookieName?: string;
|
|
35
|
+
|
|
30
36
|
/**
|
|
31
37
|
* Next.js ISR revalidation time for manifest (seconds)
|
|
32
38
|
* @default 3600
|
|
@@ -45,6 +51,7 @@ export interface I18nConfig extends I18nCoreConfig {
|
|
|
45
51
|
*/
|
|
46
52
|
export interface NormalizedConfig extends CoreNormalizedConfig, Omit<I18nConfig, keyof I18nCoreConfig> {
|
|
47
53
|
localePrefix: LocalePrefix;
|
|
54
|
+
cookieName: string;
|
|
48
55
|
manifestRevalidateSeconds: number;
|
|
49
56
|
messagesRevalidateSeconds: number;
|
|
50
57
|
}
|
package/src/client.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect, useMemo, useState } from "react";
|
|
4
|
-
import { createI18nCore } from "@better-i18n/core";
|
|
5
|
-
import type { LanguageOption } from "@better-i18n/core";
|
|
6
|
-
|
|
7
|
-
import { normalizeConfig } from "./config";
|
|
8
|
-
import type { I18nConfig } from "./types";
|
|
9
|
-
|
|
10
|
-
export type UseManifestLanguagesResult = {
|
|
11
|
-
languages: LanguageOption[];
|
|
12
|
-
isLoading: boolean;
|
|
13
|
-
error: Error | null;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
type ClientCacheEntry = {
|
|
17
|
-
data?: LanguageOption[];
|
|
18
|
-
error?: Error;
|
|
19
|
-
promise?: Promise<LanguageOption[]>;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// Client-side request deduplication cache
|
|
23
|
-
const clientCache = new Map<string, ClientCacheEntry>();
|
|
24
|
-
|
|
25
|
-
const getCacheKey = (project: string, cdnBaseUrl?: string) =>
|
|
26
|
-
`${cdnBaseUrl || "https://cdn.better-i18n.com"}|${project}`;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* React hook to fetch manifest languages on the client
|
|
30
|
-
*
|
|
31
|
-
* Uses `createI18nCore` from `@better-i18n/core` internally with
|
|
32
|
-
* request deduplication to prevent duplicate fetches.
|
|
33
|
-
*
|
|
34
|
-
* @example
|
|
35
|
-
* ```tsx
|
|
36
|
-
* const { languages, isLoading, error } = useManifestLanguages({
|
|
37
|
-
* project: 'acme/dashboard',
|
|
38
|
-
* defaultLocale: 'en',
|
|
39
|
-
* })
|
|
40
|
-
*
|
|
41
|
-
* if (isLoading) return <Spinner />
|
|
42
|
-
* if (error) return <Error message={error.message} />
|
|
43
|
-
*
|
|
44
|
-
* return (
|
|
45
|
-
* <select>
|
|
46
|
-
* {languages.map(lang => (
|
|
47
|
-
* <option key={lang.code} value={lang.code}>
|
|
48
|
-
* {lang.nativeName || lang.name || lang.code}
|
|
49
|
-
* </option>
|
|
50
|
-
* ))}
|
|
51
|
-
* </select>
|
|
52
|
-
* )
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
export const useManifestLanguages = (config: I18nConfig): UseManifestLanguagesResult => {
|
|
56
|
-
const normalized = useMemo(
|
|
57
|
-
() => normalizeConfig(config),
|
|
58
|
-
[
|
|
59
|
-
config.project,
|
|
60
|
-
config.defaultLocale,
|
|
61
|
-
config.cdnBaseUrl,
|
|
62
|
-
config.debug,
|
|
63
|
-
config.logLevel,
|
|
64
|
-
],
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const i18nCore = useMemo(
|
|
68
|
-
() =>
|
|
69
|
-
createI18nCore({
|
|
70
|
-
project: normalized.project,
|
|
71
|
-
defaultLocale: normalized.defaultLocale,
|
|
72
|
-
cdnBaseUrl: normalized.cdnBaseUrl,
|
|
73
|
-
debug: normalized.debug,
|
|
74
|
-
logLevel: normalized.logLevel,
|
|
75
|
-
}),
|
|
76
|
-
[normalized],
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const cacheKey = getCacheKey(normalized.project, normalized.cdnBaseUrl);
|
|
80
|
-
const cached = clientCache.get(cacheKey);
|
|
81
|
-
|
|
82
|
-
const [languages, setLanguages] = useState<LanguageOption[]>(cached?.data ?? []);
|
|
83
|
-
const [isLoading, setIsLoading] = useState(!cached?.data);
|
|
84
|
-
const [error, setError] = useState<Error | null>(cached?.error ?? null);
|
|
85
|
-
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
let isMounted = true;
|
|
88
|
-
|
|
89
|
-
const run = async () => {
|
|
90
|
-
const entry = clientCache.get(cacheKey) ?? {};
|
|
91
|
-
|
|
92
|
-
// Return cached data if available
|
|
93
|
-
if (entry.data) {
|
|
94
|
-
if (isMounted) {
|
|
95
|
-
setLanguages(entry.data);
|
|
96
|
-
setIsLoading(false);
|
|
97
|
-
}
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Deduplicate in-flight requests
|
|
102
|
-
if (!entry.promise) {
|
|
103
|
-
entry.promise = i18nCore
|
|
104
|
-
.getLanguages()
|
|
105
|
-
.then((langs) => {
|
|
106
|
-
clientCache.set(cacheKey, { data: langs });
|
|
107
|
-
return langs;
|
|
108
|
-
})
|
|
109
|
-
.catch((err) => {
|
|
110
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
111
|
-
clientCache.set(cacheKey, { error });
|
|
112
|
-
throw error;
|
|
113
|
-
});
|
|
114
|
-
clientCache.set(cacheKey, entry);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const langs = await entry.promise;
|
|
119
|
-
if (isMounted) {
|
|
120
|
-
setLanguages(langs);
|
|
121
|
-
setError(null);
|
|
122
|
-
}
|
|
123
|
-
} catch (err) {
|
|
124
|
-
if (isMounted) {
|
|
125
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
126
|
-
}
|
|
127
|
-
} finally {
|
|
128
|
-
if (isMounted) {
|
|
129
|
-
setIsLoading(false);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
run();
|
|
135
|
-
|
|
136
|
-
return () => {
|
|
137
|
-
isMounted = false;
|
|
138
|
-
};
|
|
139
|
-
}, [cacheKey, i18nCore]);
|
|
140
|
-
|
|
141
|
-
return { languages, isLoading, error };
|
|
142
|
-
};
|