@better-i18n/next 0.1.1 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @better-i18n/next
2
2
 
3
- Headless Next.js integration for Better i18n. This package owns your `next-intl` wiring, middleware/proxy, and CDN fetching.
3
+ Next.js integration for [Better i18n](https://better-i18n.com). Handles `next-intl` wiring, middleware, and CDN-based translation fetching.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,20 +8,23 @@ Headless Next.js integration for Better i18n. This package owns your `next-intl`
8
8
  npm i @better-i18n/next next-intl
9
9
  ```
10
10
 
11
- ## Quick start
11
+ ## Quick Start
12
+
13
+ ### 1. Create i18n config
12
14
 
13
15
  ```ts
14
16
  // i18n.ts
15
17
  import { createI18n } from "@better-i18n/next";
16
18
 
17
19
  export const i18n = createI18n({
18
- workspaceId: "ws_123",
19
- projectSlug: "landing",
20
+ project: "your-org/your-project", // Format: "org-slug/project-slug"
20
21
  defaultLocale: "en",
21
22
  debug: process.env.NODE_ENV !== "production",
22
23
  });
23
24
  ```
24
25
 
26
+ ### 2. Setup request config
27
+
25
28
  ```ts
26
29
  // app/i18n/request.ts
27
30
  import { i18n } from "./i18n";
@@ -29,25 +32,20 @@ import { i18n } from "./i18n";
29
32
  export default i18n.requestConfig;
30
33
  ```
31
34
 
35
+ ### 3. Add middleware
36
+
32
37
  ```ts
33
- // middleware.ts (Next 15.x)
38
+ // middleware.ts
34
39
  import { i18n } from "./i18n";
35
40
 
36
41
  export default i18n.middleware;
37
42
 
38
43
  export const config = {
39
- matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).*)"],
44
+ matcher: ["/", "/((?!api|_next|_vercel|.*\\..*).*)" ],
40
45
  };
41
46
  ```
42
47
 
43
- ```ts
44
- // proxy.ts (Next 16+)
45
- import { i18n } from "./i18n";
46
-
47
- export default i18n.proxy;
48
- ```
49
-
50
- ## Client hook
48
+ ## Client Hook
51
49
 
52
50
  ```tsx
53
51
  "use client";
@@ -56,8 +54,7 @@ import { useManifestLanguages } from "@better-i18n/next/client";
56
54
 
57
55
  export function LanguageSwitcher() {
58
56
  const { languages, isLoading, error } = useManifestLanguages({
59
- workspaceId: "ws_123",
60
- projectSlug: "landing",
57
+ project: "your-org/your-project",
61
58
  defaultLocale: "en",
62
59
  });
63
60
 
@@ -74,27 +71,33 @@ export function LanguageSwitcher() {
74
71
  }
75
72
  ```
76
73
 
77
- ## Server helpers
74
+ ## Server Helpers
78
75
 
79
76
  ```ts
80
77
  import { getLocales, getMessages } from "@better-i18n/next/server";
81
78
 
82
- const locales = await getLocales({ workspaceId, projectSlug, defaultLocale });
83
- const messages = await getMessages({ workspaceId, projectSlug, defaultLocale }, "tr");
79
+ const config = { project: "your-org/your-project", defaultLocale: "en" };
80
+
81
+ const locales = await getLocales(config);
82
+ const messages = await getMessages(config, "tr");
84
83
  ```
85
84
 
86
- ## Debug logging
85
+ ## Configuration Options
87
86
 
88
- - `BETTER_I18N_DEBUG=1`
89
- - `BETTER_I18N_LOG_LEVEL=debug|info|warn|error|silent`
87
+ | Option | Type | Description |
88
+ |--------|------|-------------|
89
+ | `project` | `string` | **Required.** Format: `"org-slug/project-slug"` |
90
+ | `defaultLocale` | `string` | **Required.** Default locale code (e.g., `"en"`) |
91
+ | `cdnBaseUrl` | `string` | CDN URL (default: `https://cdn.better-i18n.com`) |
92
+ | `debug` | `boolean` | Enable verbose logging |
93
+ | `localePrefix` | `"as-needed" \| "always" \| "never"` | URL prefix strategy |
90
94
 
91
- `debug` in config also enables verbose logs.
95
+ ## Debug Logging
92
96
 
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
+ Enable with environment variables:
98
+ - `BETTER_I18N_DEBUG=1`
99
+ - `BETTER_I18N_LOG_LEVEL=debug|info|warn|error|silent`
97
100
 
98
101
  ## License
99
102
 
100
- MIT
103
+ MIT © [Better i18n](https://better-i18n.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-i18n/next",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Better-i18n Next.js integration",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -56,6 +56,9 @@
56
56
  "scripts": {
57
57
  "typecheck": "tsc --noEmit"
58
58
  },
59
+ "dependencies": {
60
+ "@better-i18n/core": "workspace:*"
61
+ },
59
62
  "peerDependencies": {
60
63
  "next": ">=15.0.0",
61
64
  "next-intl": ">=4.0.0",
@@ -65,7 +68,7 @@
65
68
  "devDependencies": {
66
69
  "next": "^15.4.4",
67
70
  "next-intl": "^4.5.8",
68
- "@repo/typescript-config": "workspace:*",
71
+ "@better-i18n/typescript-config": "workspace:*",
69
72
  "@types/node": "^20.0.0",
70
73
  "@types/react": "^19.0.0",
71
74
  "typescript": "~5.9.2"
package/src/client.ts CHANGED
@@ -1,15 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useMemo, useState } from "react";
4
+ import { createI18nCore } from "@better-i18n/core";
5
+ import type { LanguageOption } from "@better-i18n/core";
4
6
 
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";
7
+ import { normalizeConfig } from "./config";
8
+ import type { I18nConfig } from "./types";
13
9
 
14
10
  export type UseManifestLanguagesResult = {
15
11
  languages: LanguageOption[];
@@ -23,116 +19,111 @@ type ClientCacheEntry = {
23
19
  promise?: Promise<LanguageOption[]>;
24
20
  };
25
21
 
22
+ // Client-side request deduplication cache
26
23
  const clientCache = new Map<string, ClientCacheEntry>();
27
24
 
28
- const getCacheKey = (config: I18nConfig) =>
29
- `${config.cdnBaseUrl || "https://cdn.better-i18n.com"}|${config.project}`;
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 => {
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 => {
62
56
  const normalized = useMemo(
63
57
  () => normalizeConfig(config),
64
58
  [
65
59
  config.project,
66
60
  config.defaultLocale,
67
61
  config.cdnBaseUrl,
68
- config.localePrefix,
69
62
  config.debug,
70
63
  config.logLevel,
71
- config.manifestCacheTtlMs,
72
- config.manifestRevalidateSeconds,
73
- config.messagesRevalidateSeconds,
74
- config.fetch,
75
64
  ],
76
65
  );
77
- const logger = useMemo(() => createLogger(normalized, "client"), [
78
- normalized,
79
- ]);
80
66
 
81
- const cacheKey = getCacheKey(normalized);
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);
82
80
  const cached = clientCache.get(cacheKey);
83
81
 
84
- const [languages, setLanguages] = useState<LanguageOption[]>(
85
- cached?.data ?? [],
86
- );
82
+ const [languages, setLanguages] = useState<LanguageOption[]>(cached?.data ?? []);
87
83
  const [isLoading, setIsLoading] = useState(!cached?.data);
88
84
  const [error, setError] = useState<Error | null>(cached?.error ?? null);
89
85
 
90
86
  useEffect(() => {
91
87
  let isMounted = true;
92
- const controller = new AbortController();
93
88
 
94
89
  const run = async () => {
95
- try {
96
- const entry = clientCache.get(cacheKey) ?? {};
97
-
98
- if (entry.data) {
99
- if (isMounted) {
100
- setLanguages(entry.data);
101
- setError(entry.error ?? null);
102
- setIsLoading(false);
103
- }
104
- return;
105
- }
90
+ const entry = clientCache.get(cacheKey) ?? {};
106
91
 
107
- if (!entry.promise) {
108
- entry.promise = fetchManifestLanguages(
109
- normalized,
110
- logger,
111
- controller.signal,
112
- )
113
- .then((nextLanguages) => {
114
- clientCache.set(cacheKey, { data: nextLanguages });
115
- return nextLanguages;
116
- })
117
- .catch((err) => {
118
- const nextError = err instanceof Error ? err : new Error(String(err));
119
- clientCache.set(cacheKey, { error: nextError });
120
- throw nextError;
121
- });
122
- clientCache.set(cacheKey, entry);
92
+ // Return cached data if available
93
+ if (entry.data) {
94
+ if (isMounted) {
95
+ setLanguages(entry.data);
96
+ setIsLoading(false);
123
97
  }
98
+ return;
99
+ }
124
100
 
125
- const nextLanguages = await entry.promise;
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
+ }
126
116
 
117
+ try {
118
+ const langs = await entry.promise;
127
119
  if (isMounted) {
128
- setLanguages(nextLanguages);
120
+ setLanguages(langs);
129
121
  setError(null);
130
122
  }
131
123
  } catch (err) {
132
- if (!isMounted) return;
133
- const nextError = err instanceof Error ? err : new Error(String(err));
134
- logger.error(nextError);
135
- setError(nextError);
124
+ if (isMounted) {
125
+ setError(err instanceof Error ? err : new Error(String(err)));
126
+ }
136
127
  } finally {
137
128
  if (isMounted) {
138
129
  setIsLoading(false);
@@ -144,9 +135,8 @@ export const useManifestLanguages = (
144
135
 
145
136
  return () => {
146
137
  isMounted = false;
147
- controller.abort();
148
138
  };
149
- }, [cacheKey, logger, normalized]);
139
+ }, [cacheKey, i18nCore]);
150
140
 
151
141
  return { languages, isLoading, error };
152
142
  };
package/src/config.ts CHANGED
@@ -1,44 +1,39 @@
1
- import type { I18nConfig, NormalizedConfig, ParsedProject } from "./types";
1
+ import {
2
+ parseProject,
3
+ getProjectBaseUrl as coreGetProjectBaseUrl,
4
+ normalizeConfig as coreNormalizeConfig,
5
+ } from "@better-i18n/core";
6
+ import type { I18nConfig, NormalizedConfig } from "./types";
2
7
 
3
- const DEFAULT_CDN_BASE_URL = "https://cdn.better-i18n.com";
8
+ // Re-export parseProject from core
9
+ export { parseProject };
4
10
 
5
11
  /**
6
- * Parse project string "org/slug" into workspaceId and projectSlug
12
+ * Normalize Next.js i18n config with defaults
7
13
  */
8
- export const parseProject = (project: string): ParsedProject => {
9
- const parts = project.split("/");
10
- if (parts.length !== 2 || !parts[0] || !parts[1]) {
11
- throw new Error(
12
- `[better-i18n] Invalid project format "${project}". Expected "org/project" (e.g., "carna/landing")`
13
- );
14
- }
15
- return {
16
- workspaceId: parts[0],
17
- projectSlug: parts[1],
18
- };
19
- };
20
-
21
14
  export const normalizeConfig = (config: I18nConfig): NormalizedConfig => {
22
- if (!config.project?.trim()) {
23
- throw new Error("[better-i18n] project is required");
24
- }
25
- if (!config.defaultLocale?.trim()) {
26
- throw new Error("[better-i18n] defaultLocale is required");
27
- }
28
-
29
- const { workspaceId, projectSlug } = parseProject(config.project);
15
+ // Use core normalization for base config
16
+ const coreConfig = coreNormalizeConfig({
17
+ project: config.project,
18
+ defaultLocale: config.defaultLocale,
19
+ cdnBaseUrl: config.cdnBaseUrl,
20
+ manifestCacheTtlMs: config.manifestCacheTtlMs,
21
+ debug: config.debug,
22
+ logLevel: config.logLevel,
23
+ fetch: config.fetch,
24
+ });
30
25
 
26
+ // Add Next.js-specific defaults
31
27
  return {
32
- ...config,
33
- workspaceId,
34
- projectSlug,
35
- cdnBaseUrl: config.cdnBaseUrl?.replace(/\/$/, "") || DEFAULT_CDN_BASE_URL,
28
+ ...coreConfig,
36
29
  localePrefix: config.localePrefix ?? "as-needed",
37
- manifestCacheTtlMs: config.manifestCacheTtlMs ?? 5 * 60 * 1000,
38
30
  manifestRevalidateSeconds: config.manifestRevalidateSeconds ?? 3600,
39
31
  messagesRevalidateSeconds: config.messagesRevalidateSeconds ?? 30,
40
32
  };
41
33
  };
42
34
 
43
- export const getProjectBaseUrl = (config: NormalizedConfig) =>
44
- `${config.cdnBaseUrl}/${config.workspaceId}/${config.projectSlug}`;
35
+ /**
36
+ * Get the project base URL on CDN
37
+ */
38
+ export const getProjectBaseUrl = (config: NormalizedConfig): string =>
39
+ coreGetProjectBaseUrl(config);
package/src/index.ts CHANGED
@@ -1,28 +1,61 @@
1
1
  import type { I18nConfig } from "./types";
2
2
  import { normalizeConfig } from "./config";
3
3
  import {
4
+ createNextI18nCore,
4
5
  createNextIntlRequestConfig,
5
- getLocales,
6
- getManifest,
7
- getMessages,
8
6
  } from "./server";
9
- import { createI18nMiddleware, createI18nProxy } from "./middleware";
7
+ import {
8
+ createI18nMiddleware,
9
+ createI18nProxy,
10
+ createBetterI18nMiddleware,
11
+ composeMiddleware,
12
+ } from "./middleware";
10
13
 
14
+ /**
15
+ * Create a complete i18n setup for Next.js
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * // i18n/config.ts
20
+ * import { createI18n } from '@better-i18n/next'
21
+ *
22
+ * export const i18n = createI18n({
23
+ * project: 'acme/dashboard',
24
+ * defaultLocale: 'en',
25
+ * })
26
+ *
27
+ * // i18n/request.ts
28
+ * export default i18n.requestConfig
29
+ *
30
+ * // middleware.ts
31
+ * export default i18n.middleware
32
+ * ```
33
+ */
11
34
  export const createI18n = (config: I18nConfig) => {
12
35
  const normalized = normalizeConfig(config);
36
+ const i18n = createNextI18nCore(normalized);
13
37
 
14
38
  return {
15
39
  config: normalized,
16
40
  requestConfig: createNextIntlRequestConfig(normalized),
17
41
  middleware: createI18nMiddleware(normalized),
18
42
  proxy: createI18nProxy(normalized),
19
- getManifest: (options?: { forceRefresh?: boolean }) =>
20
- getManifest(normalized, options),
21
- getLocales: () => getLocales(normalized),
22
- getMessages: (locale: string) => getMessages(normalized, locale),
43
+ getManifest: i18n.getManifest,
44
+ getLocales: i18n.getLocales,
45
+ getMessages: i18n.getMessages,
23
46
  };
24
47
  };
25
48
 
49
+ // Modern standalone middleware exports
50
+ export { createBetterI18nMiddleware, composeMiddleware };
51
+
52
+ // Core instance factory
53
+ export { createNextI18nCore } from "./server";
54
+
55
+ // Client hook
56
+ export { useManifestLanguages } from "./client";
57
+
58
+ // Re-export types
26
59
  export type {
27
60
  I18nConfig,
28
61
  LanguageOption,
@@ -33,3 +66,5 @@ export type {
33
66
  ManifestResponse,
34
67
  Messages,
35
68
  } from "./types";
69
+
70
+ export type { I18nMiddlewareConfig } from "@better-i18n/core";
package/src/middleware.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import createMiddleware from "next-intl/middleware";
2
- import type { NextRequest } from "next/server";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { createI18nCore, createLogger, detectLocale } from "@better-i18n/core";
4
+ import type { I18nMiddlewareConfig } from "@better-i18n/core";
3
5
 
4
6
  import { normalizeConfig } from "./config";
5
- import { createLogger } from "./logger";
6
- import { getLocales } from "./core";
7
+ import { getLocales } from "./server";
7
8
  import type { I18nConfig } from "./types";
8
9
 
10
+ /**
11
+ * Legacy Next-intl based middleware
12
+ */
9
13
  export const createI18nMiddleware = (config: I18nConfig) => {
10
14
  const normalized = normalizeConfig(config);
11
15
  const logger = createLogger(normalized, "middleware");
@@ -25,3 +29,82 @@ export const createI18nMiddleware = (config: I18nConfig) => {
25
29
  };
26
30
 
27
31
  export const createI18nProxy = createI18nMiddleware;
32
+
33
+ /**
34
+ * Modern composable middleware for Better i18n
35
+ */
36
+ export function createBetterI18nMiddleware(config: I18nMiddlewareConfig) {
37
+ const { project, defaultLocale, detection = {} } = config;
38
+
39
+ const {
40
+ cookie = true,
41
+ browserLanguage = true,
42
+ cookieName = "locale",
43
+ cookieMaxAge = 31536000,
44
+ } = detection;
45
+
46
+ // Create i18n core instance for CDN operations
47
+ const i18nCore = createI18nCore({ project, defaultLocale });
48
+
49
+ return async (request: NextRequest): Promise<NextResponse> => {
50
+ // 1. Fetch available locales from CDN
51
+ const availableLocales = await i18nCore.getLocales();
52
+
53
+ // 2. Extract locale indicators
54
+ const pathLocale = request.nextUrl.pathname.split("/")[1];
55
+ const cookieLocale = cookie ? request.cookies.get(cookieName)?.value : null;
56
+ const headerLocale = browserLanguage
57
+ ? request.headers.get("accept-language")?.split(",")[0]?.split("-")[0]
58
+ : null;
59
+
60
+ // 3. Detect locale using core logic
61
+ const result = detectLocale({
62
+ project,
63
+ defaultLocale,
64
+ pathLocale,
65
+ cookieLocale: cookieLocale || null,
66
+ headerLocale,
67
+ availableLocales,
68
+ });
69
+
70
+ // 4. Create response with locale header for Server Components
71
+ const response = NextResponse.next({
72
+ headers: {
73
+ "x-locale": result.locale,
74
+ },
75
+ });
76
+
77
+ // 5. Set cookie if needed
78
+ if (cookie && result.shouldSetCookie) {
79
+ response.cookies.set(cookieName, result.locale, {
80
+ path: "/",
81
+ maxAge: cookieMaxAge,
82
+ sameSite: "lax",
83
+ });
84
+ }
85
+
86
+ return response;
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Helper to compose multiple Next.js middleware
92
+ */
93
+ export function composeMiddleware(
94
+ ...middlewares: Array<(req: NextRequest) => Promise<NextResponse>>
95
+ ) {
96
+ return async (request: NextRequest): Promise<NextResponse> => {
97
+ let response = NextResponse.next();
98
+
99
+ for (const middleware of middlewares) {
100
+ response = await middleware(request);
101
+
102
+ // Short-circuit on redirect/rewrite (status >= 300)
103
+ if (response.status >= 300) {
104
+ break;
105
+ }
106
+ }
107
+
108
+ return response;
109
+ };
110
+ }
package/src/server.ts CHANGED
@@ -1,23 +1,146 @@
1
1
  import { getRequestConfig } from "next-intl/server";
2
+ import { createI18nCore } from "@better-i18n/core";
3
+ import type { I18nCore, Messages } from "@better-i18n/core";
2
4
 
3
- import { normalizeConfig } from "./config";
4
- import { getLocales, getMessages } from "./core";
5
- import type { I18nConfig } from "./types";
5
+ import { normalizeConfig, getProjectBaseUrl } from "./config";
6
+ import type { I18nConfig, NextFetchRequestInit } from "./types";
6
7
 
8
+ /**
9
+ * Next.js i18n core instance with ISR support
10
+ */
11
+ export interface NextI18nCore extends I18nCore {
12
+ /**
13
+ * Get messages for a locale with Next.js ISR revalidation
14
+ */
15
+ getMessages: (locale: string) => Promise<Messages>;
16
+ }
17
+
18
+ /**
19
+ * Create a fetch function with Next.js ISR revalidation
20
+ */
21
+ const createIsrFetch = (revalidateSeconds: number): typeof fetch => {
22
+ return (input: RequestInfo | URL, init?: RequestInit) => {
23
+ const nextInit: NextFetchRequestInit = {
24
+ ...init,
25
+ next: { revalidate: revalidateSeconds },
26
+ };
27
+ return fetch(input, nextInit);
28
+ };
29
+ };
30
+
31
+ /**
32
+ * Create a Next.js i18n core instance with ISR support
33
+ *
34
+ * This wraps the framework-agnostic `createI18nCore` from `@better-i18n/core`
35
+ * and adds Next.js-specific ISR revalidation for optimal caching.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * const i18n = createNextI18nCore({
40
+ * project: 'acme/dashboard',
41
+ * defaultLocale: 'en',
42
+ * })
43
+ *
44
+ * // Manifest cached for 1 hour (ISR)
45
+ * const locales = await i18n.getLocales()
46
+ *
47
+ * // Messages revalidated every 30s (ISR)
48
+ * const messages = await i18n.getMessages('en')
49
+ * ```
50
+ */
51
+ export const createNextI18nCore = (config: I18nConfig): NextI18nCore => {
52
+ const normalized = normalizeConfig(config);
53
+
54
+ // Core instance uses ISR fetch for manifest (default 3600s)
55
+ const manifestFetch = createIsrFetch(normalized.manifestRevalidateSeconds);
56
+ const i18nCore = createI18nCore({
57
+ project: normalized.project,
58
+ defaultLocale: normalized.defaultLocale,
59
+ cdnBaseUrl: normalized.cdnBaseUrl,
60
+ manifestCacheTtlMs: normalized.manifestCacheTtlMs,
61
+ debug: normalized.debug,
62
+ logLevel: normalized.logLevel,
63
+ fetch: manifestFetch,
64
+ });
65
+
66
+ // Messages use separate ISR fetch with shorter revalidation (default 30s)
67
+ const messagesFetch = createIsrFetch(normalized.messagesRevalidateSeconds);
68
+
69
+ return {
70
+ ...i18nCore,
71
+ getMessages: async (locale: string): Promise<Messages> => {
72
+ const url = `${getProjectBaseUrl(normalized)}/${locale}/translations.json`;
73
+ const response = await messagesFetch(url);
74
+ if (!response.ok) {
75
+ throw new Error(`[better-i18n] Messages fetch failed for locale "${locale}" (${response.status})`);
76
+ }
77
+ return response.json();
78
+ },
79
+ };
80
+ };
81
+
82
+ /**
83
+ * Create next-intl request config for App Router
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * // i18n/request.ts
88
+ * import { createNextIntlRequestConfig } from '@better-i18n/next/server'
89
+ *
90
+ * export default createNextIntlRequestConfig({
91
+ * project: 'acme/dashboard',
92
+ * defaultLocale: 'en',
93
+ * })
94
+ * ```
95
+ */
7
96
  export const createNextIntlRequestConfig = (config: I18nConfig) =>
8
97
  getRequestConfig(async ({ requestLocale }) => {
9
- const locales = await getLocales(config);
98
+ const i18n = createNextI18nCore(config);
99
+ const normalized = normalizeConfig(config);
100
+ const locales = await i18n.getLocales();
10
101
  let locale = await requestLocale;
11
102
 
12
103
  if (!locale || !locales.includes(locale)) {
13
- locale = normalizeConfig(config).defaultLocale;
104
+ locale = normalized.defaultLocale;
14
105
  }
15
106
 
16
107
  return {
17
108
  locale,
18
- messages: await getMessages(config, locale),
109
+ messages: await i18n.getMessages(locale),
19
110
  };
20
111
  });
21
112
 
22
- export { fetchManifest, getLocales, getManifest, getManifestLanguages, getMessages } from "./core";
23
- export type { LanguageOption, ManifestLanguage, ManifestResponse, Messages } from "./types";
113
+ // Convenience exports for backwards compatibility
114
+
115
+ /**
116
+ * Fetch manifest from CDN
117
+ */
118
+ export const fetchManifest = (config: I18nConfig) =>
119
+ createNextI18nCore(config).getManifest();
120
+
121
+ /**
122
+ * Get manifest with caching
123
+ */
124
+ export const getManifest = (config: I18nConfig, options?: { forceRefresh?: boolean }) =>
125
+ createNextI18nCore(config).getManifest(options);
126
+
127
+ /**
128
+ * Get available locale codes
129
+ */
130
+ export const getLocales = (config: I18nConfig) =>
131
+ createNextI18nCore(config).getLocales();
132
+
133
+ /**
134
+ * Get messages for a locale
135
+ */
136
+ export const getMessages = (config: I18nConfig, locale: string) =>
137
+ createNextI18nCore(config).getMessages(locale);
138
+
139
+ /**
140
+ * Get language options with metadata
141
+ */
142
+ export const getManifestLanguages = (config: I18nConfig) =>
143
+ createNextI18nCore(config).getLanguages();
144
+
145
+ // Re-export types from core
146
+ export type { LanguageOption, ManifestLanguage, ManifestResponse, Messages } from "@better-i18n/core";
package/src/types.ts CHANGED
@@ -1,74 +1,57 @@
1
- export type Locale = string;
1
+ // Re-export common types from i18n-core
2
+ export type {
3
+ Locale,
4
+ LogLevel,
5
+ ManifestLanguage,
6
+ ManifestFile,
7
+ ManifestResponse,
8
+ LanguageOption,
9
+ Messages,
10
+ Logger,
11
+ ParsedProject,
12
+ } from "@better-i18n/core";
13
+
14
+ import type { I18nCoreConfig, NormalizedConfig as CoreNormalizedConfig } from "@better-i18n/core";
15
+
16
+ // Next.js-specific types
2
17
 
3
18
  export type LocalePrefix = "as-needed" | "always" | "never";
4
19
 
5
- export type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
6
-
7
- export interface I18nConfig {
8
- /** Project identifier in format "org/project" (e.g., "carna/landing") */
9
- project: string;
10
- defaultLocale: string;
11
- cdnBaseUrl?: string;
20
+ /**
21
+ * Next.js i18n configuration (extends core config)
22
+ */
23
+ export interface I18nConfig extends I18nCoreConfig {
24
+ /**
25
+ * URL locale prefix behavior
26
+ * @default "as-needed"
27
+ */
12
28
  localePrefix?: LocalePrefix;
13
- debug?: boolean;
14
- logLevel?: LogLevel;
15
- manifestCacheTtlMs?: number;
29
+
30
+ /**
31
+ * Next.js ISR revalidation time for manifest (seconds)
32
+ * @default 3600
33
+ */
16
34
  manifestRevalidateSeconds?: number;
17
- messagesRevalidateSeconds?: number;
18
- fetch?: typeof fetch;
19
- }
20
35
 
21
- /** Parsed project identifier */
22
- export interface ParsedProject {
23
- workspaceId: string;
24
- projectSlug: string;
36
+ /**
37
+ * Next.js ISR revalidation time for messages (seconds)
38
+ * @default 30
39
+ */
40
+ messagesRevalidateSeconds?: number;
25
41
  }
26
42
 
27
- export interface NormalizedConfig extends I18nConfig, ParsedProject {
28
- cdnBaseUrl: string;
43
+ /**
44
+ * Normalized Next.js config with defaults
45
+ */
46
+ export interface NormalizedConfig extends CoreNormalizedConfig, Omit<I18nConfig, keyof I18nCoreConfig> {
29
47
  localePrefix: LocalePrefix;
48
+ manifestRevalidateSeconds: number;
49
+ messagesRevalidateSeconds: number;
30
50
  }
31
51
 
32
- export interface ManifestLanguage {
33
- code: string;
34
- name?: string;
35
- nativeName?: string;
36
- flagUrl?: string | null;
37
- isSource?: boolean;
38
- lastUpdated?: string | null;
39
- keyCount?: number;
40
- }
41
-
42
- export interface ManifestFile {
43
- url: string;
44
- size: number;
45
- lastModified: string | null;
46
- }
47
-
48
- export interface ManifestResponse {
49
- projectSlug?: string;
50
- sourceLanguage?: string;
51
- languages: ManifestLanguage[];
52
- files?: Record<string, ManifestFile>;
53
- updatedAt?: string;
54
- }
55
-
56
- export interface LanguageOption {
57
- code: string;
58
- name?: string;
59
- nativeName?: string;
60
- flagUrl?: string | null;
61
- }
62
-
63
- export type Messages = Record<string, any>;
64
-
65
- export interface Logger {
66
- debug: (...args: unknown[]) => void;
67
- info: (...args: unknown[]) => void;
68
- warn: (...args: unknown[]) => void;
69
- error: (...args: unknown[]) => void;
70
- }
71
-
52
+ /**
53
+ * Next.js fetch request init with ISR options
54
+ */
72
55
  export type NextFetchRequestInit = RequestInit & {
73
56
  next?: {
74
57
  revalidate?: number;
package/src/core.ts DELETED
@@ -1,147 +0,0 @@
1
- import { getProjectBaseUrl, normalizeConfig } 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.project}`;
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/logger.ts DELETED
@@ -1,67 +0,0 @@
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 DELETED
@@ -1,28 +0,0 @@
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
- };