@better-i18n/next 0.1.0
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/README.md +100 -0
- package/package.json +69 -0
- package/src/client.ts +153 -0
- package/src/config.ts +27 -0
- package/src/core.ts +147 -0
- package/src/index.ts +35 -0
- package/src/logger.ts +67 -0
- package/src/manifest.ts +28 -0
- package/src/middleware.ts +27 -0
- package/src/server.ts +23 -0
- package/src/types.ts +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @better-i18n/next
|
|
2
|
+
|
|
3
|
+
Headless Next.js integration for Better i18n. This package owns your `next-intl` wiring, middleware/proxy, and CDN fetching.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i @better-i18n/next next-intl
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// i18n.ts
|
|
15
|
+
import { createI18n } from "@better-i18n/next";
|
|
16
|
+
|
|
17
|
+
export const i18n = createI18n({
|
|
18
|
+
workspaceId: "ws_123",
|
|
19
|
+
projectSlug: "landing",
|
|
20
|
+
defaultLocale: "en",
|
|
21
|
+
debug: process.env.NODE_ENV !== "production",
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// app/i18n/request.ts
|
|
27
|
+
import { i18n } from "./i18n";
|
|
28
|
+
|
|
29
|
+
export default i18n.requestConfig;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// middleware.ts (Next 15.x)
|
|
34
|
+
import { i18n } from "./i18n";
|
|
35
|
+
|
|
36
|
+
export default i18n.middleware;
|
|
37
|
+
|
|
38
|
+
export const config = {
|
|
39
|
+
matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).*)"],
|
|
40
|
+
};
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// proxy.ts (Next 16+)
|
|
45
|
+
import { i18n } from "./i18n";
|
|
46
|
+
|
|
47
|
+
export default i18n.proxy;
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Client hook
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
"use client";
|
|
54
|
+
|
|
55
|
+
import { useManifestLanguages } from "@better-i18n/next/client";
|
|
56
|
+
|
|
57
|
+
export function LanguageSwitcher() {
|
|
58
|
+
const { languages, isLoading, error } = useManifestLanguages({
|
|
59
|
+
workspaceId: "ws_123",
|
|
60
|
+
projectSlug: "landing",
|
|
61
|
+
defaultLocale: "en",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (error) throw error;
|
|
65
|
+
if (isLoading) return null;
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ul>
|
|
69
|
+
{languages.map((lang) => (
|
|
70
|
+
<li key={lang.code}>{lang.nativeName ?? lang.code}</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Server helpers
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { getLocales, getMessages } from "@better-i18n/next/server";
|
|
81
|
+
|
|
82
|
+
const locales = await getLocales({ workspaceId, projectSlug, defaultLocale });
|
|
83
|
+
const messages = await getMessages({ workspaceId, projectSlug, defaultLocale }, "tr");
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Debug logging
|
|
87
|
+
|
|
88
|
+
- `BETTER_I18N_DEBUG=1`
|
|
89
|
+
- `BETTER_I18N_LOG_LEVEL=debug|info|warn|error|silent`
|
|
90
|
+
|
|
91
|
+
`debug` in config also enables verbose logs.
|
|
92
|
+
|
|
93
|
+
## Notes
|
|
94
|
+
|
|
95
|
+
- Fail-fast: CDN errors throw (no local fallback).
|
|
96
|
+
- For Next apps, ensure `transpilePackages: ["@better-i18n/next"]` if your bundler doesn’t compile TS in node_modules.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@better-i18n/next",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Better-i18n Next.js integration",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/better-i18n/better-i18n.git",
|
|
9
|
+
"directory": "packages/next"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/better-i18n/better-i18n/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/better-i18n/better-i18n/tree/main/packages/next",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./src/index.ts",
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"default": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./server": {
|
|
24
|
+
"types": "./src/server.ts",
|
|
25
|
+
"default": "./src/server.ts"
|
|
26
|
+
},
|
|
27
|
+
"./client": {
|
|
28
|
+
"types": "./src/client.ts",
|
|
29
|
+
"default": "./src/client.ts"
|
|
30
|
+
},
|
|
31
|
+
"./middleware": {
|
|
32
|
+
"types": "./src/middleware.ts",
|
|
33
|
+
"default": "./src/middleware.ts"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"package.json",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"i18n",
|
|
47
|
+
"localization",
|
|
48
|
+
"nextjs",
|
|
49
|
+
"next-intl",
|
|
50
|
+
"cdn"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"typecheck": "tsc --noEmit"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"next": ">=15.0.0",
|
|
57
|
+
"next-intl": ">=4.0.0",
|
|
58
|
+
"react": ">=18.0.0",
|
|
59
|
+
"react-dom": ">=18.0.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"next": "^15.4.4",
|
|
63
|
+
"next-intl": "^4.5.8",
|
|
64
|
+
"@repo/typescript-config": "workspace:*",
|
|
65
|
+
"@types/node": "^20.0.0",
|
|
66
|
+
"@types/react": "^19.0.0",
|
|
67
|
+
"typescript": "~5.9.2"
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import { getProjectBaseUrl, normalizeConfig } from "./config";
|
|
6
|
+
import { createLogger } from "./logger";
|
|
7
|
+
import { extractLanguages } from "./manifest";
|
|
8
|
+
import type {
|
|
9
|
+
I18nConfig,
|
|
10
|
+
LanguageOption,
|
|
11
|
+
ManifestResponse,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
export type UseManifestLanguagesResult = {
|
|
15
|
+
languages: LanguageOption[];
|
|
16
|
+
isLoading: boolean;
|
|
17
|
+
error: Error | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ClientCacheEntry = {
|
|
21
|
+
data?: LanguageOption[];
|
|
22
|
+
error?: Error;
|
|
23
|
+
promise?: Promise<LanguageOption[]>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const clientCache = new Map<string, ClientCacheEntry>();
|
|
27
|
+
|
|
28
|
+
const getCacheKey = (config: I18nConfig) =>
|
|
29
|
+
`${config.cdnBaseUrl || "https://cdn.better-i18n.com"}|${config.workspaceId}|${config.projectSlug}`;
|
|
30
|
+
|
|
31
|
+
const fetchManifestLanguages = async (
|
|
32
|
+
config: I18nConfig,
|
|
33
|
+
logger: ReturnType<typeof createLogger>,
|
|
34
|
+
signal?: AbortSignal,
|
|
35
|
+
): Promise<LanguageOption[]> => {
|
|
36
|
+
const normalized = normalizeConfig(config);
|
|
37
|
+
const url = `${getProjectBaseUrl(normalized)}/manifest.json`;
|
|
38
|
+
logger.debug("fetch", url);
|
|
39
|
+
|
|
40
|
+
const response = await (normalized.fetch ?? fetch)(url, {
|
|
41
|
+
cache: "no-store",
|
|
42
|
+
signal,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`[better-i18n] Manifest fetch failed (${response.status})`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const data = (await response.json()) as ManifestResponse;
|
|
50
|
+
const languages = extractLanguages(data);
|
|
51
|
+
|
|
52
|
+
if (languages.length === 0) {
|
|
53
|
+
throw new Error("[better-i18n] No languages found in manifest");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return languages;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const useManifestLanguages = (
|
|
60
|
+
config: I18nConfig,
|
|
61
|
+
): UseManifestLanguagesResult => {
|
|
62
|
+
const normalized = useMemo(
|
|
63
|
+
() => normalizeConfig(config),
|
|
64
|
+
[
|
|
65
|
+
config.workspaceId,
|
|
66
|
+
config.projectSlug,
|
|
67
|
+
config.defaultLocale,
|
|
68
|
+
config.cdnBaseUrl,
|
|
69
|
+
config.localePrefix,
|
|
70
|
+
config.debug,
|
|
71
|
+
config.logLevel,
|
|
72
|
+
config.manifestCacheTtlMs,
|
|
73
|
+
config.manifestRevalidateSeconds,
|
|
74
|
+
config.messagesRevalidateSeconds,
|
|
75
|
+
config.fetch,
|
|
76
|
+
],
|
|
77
|
+
);
|
|
78
|
+
const logger = useMemo(() => createLogger(normalized, "client"), [
|
|
79
|
+
normalized,
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const cacheKey = getCacheKey(normalized);
|
|
83
|
+
const cached = clientCache.get(cacheKey);
|
|
84
|
+
|
|
85
|
+
const [languages, setLanguages] = useState<LanguageOption[]>(
|
|
86
|
+
cached?.data ?? [],
|
|
87
|
+
);
|
|
88
|
+
const [isLoading, setIsLoading] = useState(!cached?.data);
|
|
89
|
+
const [error, setError] = useState<Error | null>(cached?.error ?? null);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
let isMounted = true;
|
|
93
|
+
const controller = new AbortController();
|
|
94
|
+
|
|
95
|
+
const run = async () => {
|
|
96
|
+
try {
|
|
97
|
+
const entry = clientCache.get(cacheKey) ?? {};
|
|
98
|
+
|
|
99
|
+
if (entry.data) {
|
|
100
|
+
if (isMounted) {
|
|
101
|
+
setLanguages(entry.data);
|
|
102
|
+
setError(entry.error ?? null);
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!entry.promise) {
|
|
109
|
+
entry.promise = fetchManifestLanguages(
|
|
110
|
+
normalized,
|
|
111
|
+
logger,
|
|
112
|
+
controller.signal,
|
|
113
|
+
)
|
|
114
|
+
.then((nextLanguages) => {
|
|
115
|
+
clientCache.set(cacheKey, { data: nextLanguages });
|
|
116
|
+
return nextLanguages;
|
|
117
|
+
})
|
|
118
|
+
.catch((err) => {
|
|
119
|
+
const nextError = err instanceof Error ? err : new Error(String(err));
|
|
120
|
+
clientCache.set(cacheKey, { error: nextError });
|
|
121
|
+
throw nextError;
|
|
122
|
+
});
|
|
123
|
+
clientCache.set(cacheKey, entry);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const nextLanguages = await entry.promise;
|
|
127
|
+
|
|
128
|
+
if (isMounted) {
|
|
129
|
+
setLanguages(nextLanguages);
|
|
130
|
+
setError(null);
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (!isMounted) return;
|
|
134
|
+
const nextError = err instanceof Error ? err : new Error(String(err));
|
|
135
|
+
logger.error(nextError);
|
|
136
|
+
setError(nextError);
|
|
137
|
+
} finally {
|
|
138
|
+
if (isMounted) {
|
|
139
|
+
setIsLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
run();
|
|
145
|
+
|
|
146
|
+
return () => {
|
|
147
|
+
isMounted = false;
|
|
148
|
+
controller.abort();
|
|
149
|
+
};
|
|
150
|
+
}, [cacheKey, logger, normalized]);
|
|
151
|
+
|
|
152
|
+
return { languages, isLoading, error };
|
|
153
|
+
};
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { I18nConfig, NormalizedConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CDN_BASE_URL = "https://cdn.better-i18n.com";
|
|
4
|
+
|
|
5
|
+
export const normalizeConfig = (config: I18nConfig): NormalizedConfig => {
|
|
6
|
+
if (!config.workspaceId?.trim()) {
|
|
7
|
+
throw new Error("[better-i18n] workspaceId is required");
|
|
8
|
+
}
|
|
9
|
+
if (!config.projectSlug?.trim()) {
|
|
10
|
+
throw new Error("[better-i18n] projectSlug is required");
|
|
11
|
+
}
|
|
12
|
+
if (!config.defaultLocale?.trim()) {
|
|
13
|
+
throw new Error("[better-i18n] defaultLocale is required");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
...config,
|
|
18
|
+
cdnBaseUrl: config.cdnBaseUrl?.replace(/\/$/, "") || DEFAULT_CDN_BASE_URL,
|
|
19
|
+
localePrefix: config.localePrefix ?? "as-needed",
|
|
20
|
+
manifestCacheTtlMs: config.manifestCacheTtlMs ?? 5 * 60 * 1000,
|
|
21
|
+
manifestRevalidateSeconds: config.manifestRevalidateSeconds ?? 3600,
|
|
22
|
+
messagesRevalidateSeconds: config.messagesRevalidateSeconds ?? 30,
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getProjectBaseUrl = (config: NormalizedConfig) =>
|
|
27
|
+
`${config.cdnBaseUrl}/${config.workspaceId}/${config.projectSlug}`;
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { normalizeConfig, getProjectBaseUrl } from "./config";
|
|
2
|
+
import { createLogger } from "./logger";
|
|
3
|
+
import { extractLanguages } from "./manifest";
|
|
4
|
+
import type {
|
|
5
|
+
I18nConfig,
|
|
6
|
+
LanguageOption,
|
|
7
|
+
ManifestResponse,
|
|
8
|
+
Messages,
|
|
9
|
+
NextFetchRequestInit,
|
|
10
|
+
NormalizedConfig,
|
|
11
|
+
} from "./types";
|
|
12
|
+
|
|
13
|
+
type ManifestCacheEntry = {
|
|
14
|
+
value: ManifestResponse;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const manifestCache = new Map<string, ManifestCacheEntry>();
|
|
19
|
+
|
|
20
|
+
const buildCacheKey = (config: NormalizedConfig) =>
|
|
21
|
+
`${config.cdnBaseUrl}|${config.workspaceId}|${config.projectSlug}`;
|
|
22
|
+
|
|
23
|
+
const withRevalidate = (
|
|
24
|
+
init: NextFetchRequestInit,
|
|
25
|
+
revalidateSeconds?: number,
|
|
26
|
+
): NextFetchRequestInit => {
|
|
27
|
+
if (!revalidateSeconds || revalidateSeconds <= 0) {
|
|
28
|
+
return init;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { cache, ...rest } = init;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...rest,
|
|
35
|
+
next: {
|
|
36
|
+
...init.next,
|
|
37
|
+
revalidate: revalidateSeconds,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const fetchManifest = async (
|
|
43
|
+
config: I18nConfig,
|
|
44
|
+
init: NextFetchRequestInit = {},
|
|
45
|
+
): Promise<ManifestResponse> => {
|
|
46
|
+
const normalized = normalizeConfig(config);
|
|
47
|
+
const logger = createLogger(normalized, "manifest");
|
|
48
|
+
const url = `${getProjectBaseUrl(normalized)}/manifest.json`;
|
|
49
|
+
const requestInit = withRevalidate(
|
|
50
|
+
{
|
|
51
|
+
...init,
|
|
52
|
+
},
|
|
53
|
+
normalized.manifestRevalidateSeconds,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
logger.debug("fetch", url);
|
|
57
|
+
|
|
58
|
+
const fetchFn = normalized.fetch ?? fetch;
|
|
59
|
+
const response = await fetchFn(url, requestInit);
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const message = `[better-i18n] Manifest fetch failed (${response.status})`;
|
|
63
|
+
logger.error(message);
|
|
64
|
+
throw new Error(message);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = (await response.json()) as ManifestResponse;
|
|
68
|
+
if (!Array.isArray(data.languages)) {
|
|
69
|
+
throw new Error("[better-i18n] Manifest payload missing languages array");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
logger.debug("fetched", { languages: data.languages.length });
|
|
73
|
+
return data;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const getManifest = async (
|
|
77
|
+
config: I18nConfig,
|
|
78
|
+
options: { forceRefresh?: boolean } = {},
|
|
79
|
+
): Promise<ManifestResponse> => {
|
|
80
|
+
const normalized = normalizeConfig(config);
|
|
81
|
+
const cacheKey = buildCacheKey(normalized);
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const cached = manifestCache.get(cacheKey);
|
|
84
|
+
|
|
85
|
+
if (!options.forceRefresh && cached && cached.expiresAt > now) {
|
|
86
|
+
return cached.value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const manifest = await fetchManifest(normalized);
|
|
90
|
+
manifestCache.set(cacheKey, {
|
|
91
|
+
value: manifest,
|
|
92
|
+
expiresAt: now + (normalized.manifestCacheTtlMs ?? 0),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return manifest;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const getLocales = async (config: I18nConfig): Promise<string[]> => {
|
|
99
|
+
const manifest = await getManifest(config);
|
|
100
|
+
const languages = extractLanguages(manifest);
|
|
101
|
+
|
|
102
|
+
if (languages.length === 0) {
|
|
103
|
+
throw new Error("[better-i18n] No locales found in manifest");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return languages.map((language) => language.code);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const getMessages = async (
|
|
110
|
+
config: I18nConfig,
|
|
111
|
+
locale: string,
|
|
112
|
+
): Promise<Messages> => {
|
|
113
|
+
const normalized = normalizeConfig(config);
|
|
114
|
+
const logger = createLogger(normalized, "messages");
|
|
115
|
+
const url = `${getProjectBaseUrl(normalized)}/${locale}/translations.json`;
|
|
116
|
+
|
|
117
|
+
const requestInit = withRevalidate(
|
|
118
|
+
{},
|
|
119
|
+
normalized.messagesRevalidateSeconds,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
logger.debug("fetch", url);
|
|
123
|
+
|
|
124
|
+
const fetchFn = normalized.fetch ?? fetch;
|
|
125
|
+
const response = await fetchFn(url, requestInit);
|
|
126
|
+
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
const message = `[better-i18n] Messages fetch failed (${response.status})`;
|
|
129
|
+
logger.error(message);
|
|
130
|
+
throw new Error(message);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (await response.json()) as Messages;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const getManifestLanguages = async (
|
|
137
|
+
config: I18nConfig,
|
|
138
|
+
): Promise<LanguageOption[]> => {
|
|
139
|
+
const manifest = await getManifest(config);
|
|
140
|
+
const languages = extractLanguages(manifest);
|
|
141
|
+
|
|
142
|
+
if (languages.length === 0) {
|
|
143
|
+
throw new Error("[better-i18n] No languages found in manifest");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return languages;
|
|
147
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { I18nConfig } from "./types";
|
|
2
|
+
import { normalizeConfig } from "./config";
|
|
3
|
+
import {
|
|
4
|
+
createNextIntlRequestConfig,
|
|
5
|
+
getLocales,
|
|
6
|
+
getManifest,
|
|
7
|
+
getMessages,
|
|
8
|
+
} from "./server";
|
|
9
|
+
import { createI18nMiddleware, createI18nProxy } from "./middleware";
|
|
10
|
+
|
|
11
|
+
export const createI18n = (config: I18nConfig) => {
|
|
12
|
+
const normalized = normalizeConfig(config);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
config: normalized,
|
|
16
|
+
requestConfig: createNextIntlRequestConfig(normalized),
|
|
17
|
+
middleware: createI18nMiddleware(normalized),
|
|
18
|
+
proxy: createI18nProxy(normalized),
|
|
19
|
+
getManifest: (options?: { forceRefresh?: boolean }) =>
|
|
20
|
+
getManifest(normalized, options),
|
|
21
|
+
getLocales: () => getLocales(normalized),
|
|
22
|
+
getMessages: (locale: string) => getMessages(normalized, locale),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type {
|
|
27
|
+
I18nConfig,
|
|
28
|
+
LanguageOption,
|
|
29
|
+
Locale,
|
|
30
|
+
LocalePrefix,
|
|
31
|
+
LogLevel,
|
|
32
|
+
ManifestLanguage,
|
|
33
|
+
ManifestResponse,
|
|
34
|
+
Messages,
|
|
35
|
+
} from "./types";
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { I18nConfig, Logger, LogLevel } from "./types";
|
|
2
|
+
|
|
3
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
4
|
+
debug: 0,
|
|
5
|
+
info: 1,
|
|
6
|
+
warn: 2,
|
|
7
|
+
error: 3,
|
|
8
|
+
silent: 4,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const getEnv = (key: string): string | undefined => {
|
|
12
|
+
if (typeof process === "undefined" || !process.env) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
return process.env[key];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const normalizeLevel = (level?: string): LogLevel | undefined => {
|
|
19
|
+
if (!level) return undefined;
|
|
20
|
+
const normalized = level.toLowerCase();
|
|
21
|
+
if (normalized in LEVEL_RANK) {
|
|
22
|
+
return normalized as LogLevel;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const resolveLevel = (config: I18nConfig): LogLevel => {
|
|
28
|
+
const envDebug = getEnv("BETTER_I18N_DEBUG");
|
|
29
|
+
if (envDebug && envDebug !== "0" && envDebug.toLowerCase() !== "false") {
|
|
30
|
+
return "debug";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const envLevel = normalizeLevel(getEnv("BETTER_I18N_LOG_LEVEL"));
|
|
34
|
+
if (envLevel) return envLevel;
|
|
35
|
+
|
|
36
|
+
if (config.logLevel) return config.logLevel;
|
|
37
|
+
if (config.debug) return "debug";
|
|
38
|
+
|
|
39
|
+
return "warn";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const shouldLog = (current: LogLevel, target: LogLevel) =>
|
|
43
|
+
LEVEL_RANK[target] >= LEVEL_RANK[current];
|
|
44
|
+
|
|
45
|
+
export const createLogger = (config: I18nConfig, scope = "core"): Logger => {
|
|
46
|
+
const level = resolveLevel(config);
|
|
47
|
+
const prefix = `[better-i18n:${scope}]`;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
debug: (...args: unknown[]) => {
|
|
51
|
+
if (!shouldLog(level, "debug")) return;
|
|
52
|
+
console.debug(prefix, ...args);
|
|
53
|
+
},
|
|
54
|
+
info: (...args: unknown[]) => {
|
|
55
|
+
if (!shouldLog(level, "info")) return;
|
|
56
|
+
console.info(prefix, ...args);
|
|
57
|
+
},
|
|
58
|
+
warn: (...args: unknown[]) => {
|
|
59
|
+
if (!shouldLog(level, "warn")) return;
|
|
60
|
+
console.warn(prefix, ...args);
|
|
61
|
+
},
|
|
62
|
+
error: (...args: unknown[]) => {
|
|
63
|
+
if (!shouldLog(level, "error")) return;
|
|
64
|
+
console.error(prefix, ...args);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LanguageOption,
|
|
3
|
+
ManifestLanguage,
|
|
4
|
+
ManifestResponse,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
const normalizeLanguage = (language: ManifestLanguage): LanguageOption => ({
|
|
8
|
+
code: language.code,
|
|
9
|
+
name: language.name,
|
|
10
|
+
nativeName:
|
|
11
|
+
language.nativeName || language.name || language.code.toUpperCase(),
|
|
12
|
+
flagUrl: language.flagUrl ?? null,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const extractLanguages = (
|
|
16
|
+
manifest: ManifestResponse,
|
|
17
|
+
): LanguageOption[] => {
|
|
18
|
+
const languages = Array.isArray(manifest.languages)
|
|
19
|
+
? manifest.languages
|
|
20
|
+
: [];
|
|
21
|
+
|
|
22
|
+
return languages
|
|
23
|
+
.filter(
|
|
24
|
+
(language): language is ManifestLanguage =>
|
|
25
|
+
!!language && typeof language.code === "string" && language.code.length > 0,
|
|
26
|
+
)
|
|
27
|
+
.map(normalizeLanguage);
|
|
28
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import createMiddleware from "next-intl/middleware";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
|
|
4
|
+
import { normalizeConfig } from "./config";
|
|
5
|
+
import { createLogger } from "./logger";
|
|
6
|
+
import { getLocales } from "./core";
|
|
7
|
+
import type { I18nConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
export const createI18nMiddleware = (config: I18nConfig) => {
|
|
10
|
+
const normalized = normalizeConfig(config);
|
|
11
|
+
const logger = createLogger(normalized, "middleware");
|
|
12
|
+
|
|
13
|
+
return async function middleware(request: NextRequest) {
|
|
14
|
+
const locales = await getLocales(normalized);
|
|
15
|
+
logger.debug("locales", locales);
|
|
16
|
+
|
|
17
|
+
const handleI18nRouting = createMiddleware({
|
|
18
|
+
locales,
|
|
19
|
+
defaultLocale: normalized.defaultLocale,
|
|
20
|
+
localePrefix: normalized.localePrefix,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return handleI18nRouting(request);
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const createI18nProxy = createI18nMiddleware;
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getRequestConfig } from "next-intl/server";
|
|
2
|
+
|
|
3
|
+
import { normalizeConfig } from "./config";
|
|
4
|
+
import { getLocales, getMessages } from "./core";
|
|
5
|
+
import type { I18nConfig } from "./types";
|
|
6
|
+
|
|
7
|
+
export const createNextIntlRequestConfig = (config: I18nConfig) =>
|
|
8
|
+
getRequestConfig(async ({ requestLocale }) => {
|
|
9
|
+
const locales = await getLocales(config);
|
|
10
|
+
let locale = await requestLocale;
|
|
11
|
+
|
|
12
|
+
if (!locale || !locales.includes(locale)) {
|
|
13
|
+
locale = normalizeConfig(config).defaultLocale;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
locale,
|
|
18
|
+
messages: await getMessages(config, locale),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export { fetchManifest, getLocales, getManifest, getManifestLanguages, getMessages } from "./core";
|
|
23
|
+
export type { LanguageOption, ManifestLanguage, ManifestResponse, Messages } from "./types";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type Locale = string;
|
|
2
|
+
|
|
3
|
+
export type LocalePrefix = "as-needed" | "always" | "never";
|
|
4
|
+
|
|
5
|
+
export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
|
6
|
+
|
|
7
|
+
export interface I18nConfig {
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
projectSlug: string;
|
|
10
|
+
defaultLocale: string;
|
|
11
|
+
cdnBaseUrl?: string;
|
|
12
|
+
localePrefix?: LocalePrefix;
|
|
13
|
+
debug?: boolean;
|
|
14
|
+
logLevel?: LogLevel;
|
|
15
|
+
manifestCacheTtlMs?: number;
|
|
16
|
+
manifestRevalidateSeconds?: number;
|
|
17
|
+
messagesRevalidateSeconds?: number;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NormalizedConfig extends I18nConfig {
|
|
22
|
+
cdnBaseUrl: string;
|
|
23
|
+
localePrefix: LocalePrefix;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ManifestLanguage {
|
|
27
|
+
code: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
nativeName?: string;
|
|
30
|
+
flagUrl?: string | null;
|
|
31
|
+
isSource?: boolean;
|
|
32
|
+
lastUpdated?: string | null;
|
|
33
|
+
keyCount?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ManifestFile {
|
|
37
|
+
url: string;
|
|
38
|
+
size: number;
|
|
39
|
+
lastModified: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ManifestResponse {
|
|
43
|
+
projectSlug?: string;
|
|
44
|
+
sourceLanguage?: string;
|
|
45
|
+
languages: ManifestLanguage[];
|
|
46
|
+
files?: Record<string, ManifestFile>;
|
|
47
|
+
updatedAt?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LanguageOption {
|
|
51
|
+
code: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
nativeName?: string;
|
|
54
|
+
flagUrl?: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Messages = Record<string, any>;
|
|
58
|
+
|
|
59
|
+
export interface Logger {
|
|
60
|
+
debug: (...args: unknown[]) => void;
|
|
61
|
+
info: (...args: unknown[]) => void;
|
|
62
|
+
warn: (...args: unknown[]) => void;
|
|
63
|
+
error: (...args: unknown[]) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type NextFetchRequestInit = RequestInit & {
|
|
67
|
+
next?: {
|
|
68
|
+
revalidate?: number;
|
|
69
|
+
};
|
|
70
|
+
};
|