From 3ca2285f4351e95f3396284b3d59f7d96fc70c69 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sat, 17 Feb 2024 10:59:32 +0000 Subject: [PATCH 01/10] feat: next-international V2 --- examples/app/README.md | 36 +++ examples/app/app/client-component.tsx | 26 +++ examples/app/app/layout.tsx | 13 ++ examples/app/app/page.tsx | 26 +++ examples/app/locales/en.ts | 4 + examples/app/locales/fr.ts | 4 + examples/app/locales/index.ts | 6 + examples/app/next-env.d.ts | 5 + examples/app/next.config.mjs | 4 + examples/app/package.json | 23 ++ examples/app/tsconfig.json | 26 +++ packages/international-types/index.ts | 221 +++--------------- packages/international-types/package.json | 3 +- packages/next-international/app.d.ts | 1 + packages/next-international/package.json | 10 +- .../next-international/src/app/app/client.ts | 23 ++ .../next-international/src/app/app/server.ts | 23 ++ .../next-international/src/app/app/types.ts | 15 ++ pnpm-lock.yaml | 156 +++++++++++++ 19 files changed, 438 insertions(+), 187 deletions(-) create mode 100644 examples/app/README.md create mode 100644 examples/app/app/client-component.tsx create mode 100644 examples/app/app/layout.tsx create mode 100644 examples/app/app/page.tsx create mode 100644 examples/app/locales/en.ts create mode 100644 examples/app/locales/fr.ts create mode 100644 examples/app/locales/index.ts create mode 100644 examples/app/next-env.d.ts create mode 100644 examples/app/next.config.mjs create mode 100644 examples/app/package.json create mode 100644 examples/app/tsconfig.json create mode 100644 packages/next-international/app.d.ts create mode 100644 packages/next-international/src/app/app/client.ts create mode 100644 packages/next-international/src/app/app/server.ts create mode 100644 packages/next-international/src/app/app/types.ts diff --git a/examples/app/README.md b/examples/app/README.md new file mode 100644 index 0000000..c403366 --- /dev/null +++ b/examples/app/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/examples/app/app/client-component.tsx b/examples/app/app/client-component.tsx new file mode 100644 index 0000000..108ddde --- /dev/null +++ b/examples/app/app/client-component.tsx @@ -0,0 +1,26 @@ +'use client'; + +import React from 'react'; +import { useI18n, useScopedI18n } from '@/locales'; + +export function ClientComponent() { + const t = useI18n(); + const scopedT = useScopedI18n('hello'); + + return ( +
+

{t('hello.world')}

+

{scopedT('world')}

+

+ {t('hello.param', { + name: 'John', + })} +

+

+ {scopedT('param', { + name: 'John', + })} +

+
+ ); +} diff --git a/examples/app/app/layout.tsx b/examples/app/app/layout.tsx new file mode 100644 index 0000000..668746b --- /dev/null +++ b/examples/app/app/layout.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/app/app/page.tsx b/examples/app/app/page.tsx new file mode 100644 index 0000000..a2697ac --- /dev/null +++ b/examples/app/app/page.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useI18n, useScopedI18n } from '@/locales'; +import { ClientComponent } from './client-component'; + +export default function Home() { + const t = useI18n(); + const scopedT = useScopedI18n('hello'); + + return ( +
+

{t('hello.world')}

+

{scopedT('world')}

+

+ {t('hello.param', { + name: 'John', + })} +

+

+ {scopedT('param', { + name: 'John', + })} +

+ +
+ ); +} diff --git a/examples/app/locales/en.ts b/examples/app/locales/en.ts new file mode 100644 index 0000000..4e48224 --- /dev/null +++ b/examples/app/locales/en.ts @@ -0,0 +1,4 @@ +export default { + 'hello.world': 'Hello, World!', + 'hello.param': 'Hello, {name}!', +} as const; diff --git a/examples/app/locales/fr.ts b/examples/app/locales/fr.ts new file mode 100644 index 0000000..a8e53b1 --- /dev/null +++ b/examples/app/locales/fr.ts @@ -0,0 +1,4 @@ +export default { + 'hello.world': 'Bonjour, le monde !', + 'a.param': 'Bonjour, {name} !', +} as const; diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts new file mode 100644 index 0000000..fa1ac66 --- /dev/null +++ b/examples/app/locales/index.ts @@ -0,0 +1,6 @@ +import { createI18n } from 'next-international/app'; + +export const { useI18n, useScopedI18n } = createI18n({ + en: () => import('./en'), + fr: () => import('./fr'), +}); diff --git a/examples/app/next-env.d.ts b/examples/app/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/examples/app/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/examples/app/next.config.mjs b/examples/app/next.config.mjs new file mode 100644 index 0000000..4678774 --- /dev/null +++ b/examples/app/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/examples/app/package.json b/examples/app/package.json new file mode 100644 index 0000000..dd7363e --- /dev/null +++ b/examples/app/package.json @@ -0,0 +1,23 @@ +{ + "name": "app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "react": "^18", + "react-dom": "^18", + "next": "14.1.0", + "next-international": "workspace:*" + }, + "devDependencies": { + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18" + } +} diff --git a/examples/app/tsconfig.json b/examples/app/tsconfig.json new file mode 100644 index 0000000..e7ff90f --- /dev/null +++ b/examples/app/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/international-types/index.ts b/packages/international-types/index.ts index 8fba9a1..95b076f 100644 --- a/packages/international-types/index.ts +++ b/packages/international-types/index.ts @@ -1,205 +1,58 @@ -export type LocaleValue = string | number | boolean | null | undefined | Date; -export type BaseLocale = Record; -export type ImportedLocales = Record Promise>; -export type ExplicitLocales = Record; - -export type LocaleKeys< - Locale extends BaseLocale, - Scope extends Scopes | undefined, - Key extends string = Extract, -> = Scope extends undefined ? RemovePlural : Key extends `${Scope}.${infer Test}` ? RemovePlural : never; - -type Delimiter = `=${number}` | 'other'; - -type ExtractParams = Value extends '' - ? [] - : Value extends `${string}{${infer Param}}${infer Tail}` - ? [Param, ...ExtractParams] - : []; - -export type Params = Value extends '' - ? [] - : // Plural with 3 cases - Value extends `{${infer Param}, plural, ${Delimiter} {${infer Content}} ${Delimiter} {${infer Content2}} ${Delimiter} {${infer Content3}}}` - ? [Param, ...ExtractParams, ...ExtractParams, ...ExtractParams] - : // Plural with 2 cases - Value extends `{${infer Param}, plural, ${Delimiter} {${infer Content}} ${Delimiter} {${infer Content2}}}` - ? [Param, ...ExtractParams, ...ExtractParams] - : // Simple cases (e.g `This is a {param}`) - Value extends `${string}{${infer Param}}${infer Tail}` - ? [Param, ...Params] - : []; - -export type GetParams = Value extends '' - ? [] - : Value extends `${string}{${infer Param}}${infer Tail}` - ? [Param, ...Params] - : []; - -export type ParamsObject = Record[number], LocaleValue>; +export type Param = string | number; +export type LocaleType = Record; +export type LocalesObject = { + [locale: string]: () => Promise<{ default: LocaleType }>; +}; type ExtractScopes< Value extends string, Prev extends string | undefined = undefined, > = Value extends `${infer Head}.${infer Tail}` ? [ - Prev extends string ? `${Prev}.${Head}` : Head, - ...ExtractScopes, - ] + Prev extends string ? `${Prev}.${Head}` : Head, + ...ExtractScopes, + ] : []; -export type Scopes = ExtractScopes>[number]; +export type Scopes< + Locale extends LocaleType, + TheKeys extends string = Extract, +> = ExtractScopes[number]; + +export type Keys< + Locale extends LocaleType, + Scope extends Scopes | undefined = undefined, + TheKeys extends string = Extract, +> = Scope extends undefined ? TheKeys : TheKeys extends `${Scope}.${infer Tail}` ? Tail : never; -export type ScopedValue< - Locale extends BaseLocale, +export type ExtractParams = Value extends '' + ? [] + : Value extends `${string}{${infer Param}}${infer Tail}` + ? [Param, ...ExtractParams] + : []; + +export type Params< + Locale extends LocaleType, Scope extends Scopes | undefined, - Key extends LocaleKeys, -> = Scope extends undefined - ? IsPlural extends true - ? Locale[`${Key}#${PluralSuffix}`] - : Locale[Key] - : IsPlural extends true - ? Locale[`${Scope}.${Key}#${PluralSuffix}`] - : Locale[`${Scope}.${Key}`]; + Key extends Keys, + Value extends string = Scope extends undefined ? Locale[Key] : Locale[`${Scope}.${Key}`], + TheParams extends string[] = ExtractParams, +> = TheParams['length'] extends 0 + ? [] + : [ + { + [K in TheParams[number]]: Param; + }, + ]; -// From https://github.com/microsoft/TypeScript/issues/13298#issuecomment-885980381 type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never; type UnionToTuple = UnionToIntersection T> extends (_: never) => infer W ? [...UnionToTuple>, W] : []; -// Given a object type with string keys, return the "first" key. -// Because the key ordering is not guaranteed, this type should be used -// only when the key order is not important. type SomeKey> = UnionToTuple[0] extends string ? UnionToTuple[0] : never; -// Gets a single locale type from an object of the shape of BaseLocales. -export type GetLocaleType = Locales extends ImportedLocales - ? Awaited]>>['default'] - : Locales[SomeKey]; - -type Join = K extends string | number - ? P extends string | number - ? `${K}${'' extends P ? '' : '.'}${P}` - : never - : never; - -type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]; - -type Leaves = [D] extends [never] - ? never - : T extends object - ? { [K in keyof T]-?: Join> }[keyof T] - : ''; - -type FollowPath = P extends `${infer U}.${infer R}` - ? U extends keyof T - ? FollowPath - : P extends keyof T - ? T[P] - : never - : P extends keyof T - ? T[P] - : never; - -export type FlattenLocale> = { - [K in Leaves]: FollowPath; -}; - -type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; - -type RemovePlural = Key extends `${infer Head}#${PluralSuffix}` ? Head : Key; - -type GetPlural< - Key extends string, - Scope extends Scopes | undefined, - Locale extends BaseLocale, -> = Scope extends undefined - ? `${Key}#${PluralSuffix}` & keyof Locale extends infer PluralKey - ? PluralKey extends `${string}#${infer Plural extends PluralSuffix}` - ? Plural - : never - : never - : `${Scope}.${Key}#${PluralSuffix}` & keyof Locale extends infer PluralKey - ? PluralKey extends `${string}#${infer Plural extends PluralSuffix}` - ? Plural - : never - : never; - -type IsPlural< - Key extends string, - Scope extends Scopes | undefined, - Locale extends BaseLocale, -> = Scope extends undefined - ? `${Key}#${PluralSuffix}` & keyof Locale extends never - ? false - : true - : `${Scope}.${Key}#${PluralSuffix}` & keyof Locale extends never - ? false - : true; - -type GetCountUnion< - Key extends string, - Scope extends Scopes | undefined, - Locale extends BaseLocale, - Plural extends PluralSuffix = GetPlural, -> = Plural extends 'zero' - ? 0 - : Plural extends 'one' - ? // eslint-disable-next-line @typescript-eslint/ban-types - 1 | 21 | 31 | 41 | 51 | 61 | 71 | 81 | 91 | 101 | (number & {}) - : Plural extends 'two' - ? // eslint-disable-next-line @typescript-eslint/ban-types - 2 | 22 | 32 | 42 | 52 | 62 | 72 | 82 | 92 | 102 | (number & {}) - : number; - -type AddCount | undefined, Locale extends BaseLocale> = T extends [] - ? [ - { - /** - * The `count` depends on the plural tags defined in your locale, - * and the current locale rules. - * - * - `zero` allows 0 - * - `one` autocompletes 1, 21, 31, 41... but allows any number - * - `two` autocompletes 2, 22, 32, 42... but allows any number - * - `few`, `many` and `other` allow any number - * - * @see https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html - */ - count: GetCountUnion; - }, - ] - : T extends [infer R] - ? [ - { - /** - * The `count` depends on the plural tags defined in your locale, - * and the current locale rules. - * - * - `zero` allows 0 - * - `one` autocompletes 1, 21, 31, 41... but allows any number - * - `two` autocompletes 2, 22, 32, 42... but allows any number - * - `few`, `many` and `other` allow any number - * - * @see https://www.unicode.org/cldr/charts/43/supplemental/language_plural_rules.html - */ - count: GetCountUnion; - } & R, - ] - : never; - -export type CreateParams< - T, - Locale extends BaseLocale, - Scope extends Scopes | undefined, - Key extends LocaleKeys, - Value extends LocaleValue = ScopedValue, -> = IsPlural extends true - ? AddCount['length'] extends 0 ? [] : [T], Key, Scope, Locale> - : GetParams['length'] extends 0 - ? [] - : [T]; +export type GetLocale = Awaited]>>['default']; diff --git a/packages/international-types/package.json b/packages/international-types/package.json index dcdae36..21a1e0b 100644 --- a/packages/international-types/package.json +++ b/packages/international-types/package.json @@ -23,6 +23,7 @@ "homepage": "https://next-international.vercel.app", "license": "MIT", "scripts": { - "build": "tsc --declaration --emitDeclarationOnly --outDir dist index.ts" + "build": "tsc --declaration --emitDeclarationOnly --outDir dist index.ts", + "watch": "pnpm build --watch" } } diff --git a/packages/next-international/app.d.ts b/packages/next-international/app.d.ts new file mode 100644 index 0000000..f819711 --- /dev/null +++ b/packages/next-international/app.d.ts @@ -0,0 +1 @@ +export * from './dist/app/app'; diff --git a/packages/next-international/package.json b/packages/next-international/package.json index 7949d9a..b5133ad 100644 --- a/packages/next-international/package.json +++ b/packages/next-international/package.json @@ -21,6 +21,11 @@ "./middleware": { "types": "./dist/app/middleware/index.d.ts", "default": "./dist/app/middleware/index.js" + }, + "./app": { + "types": "./dist/app/app/client.d.ts", + "react-server": "./dist/app/app/server.js", + "default": "./dist/app/app/client.js" } }, "keywords": [ @@ -33,7 +38,8 @@ "dist", "client.d.ts", "server.d.ts", - "middleware.d.ts" + "middleware.d.ts", + "app.d.ts" ], "repository": { "type": "git", @@ -45,7 +51,7 @@ "homepage": "https://next-international.vercel.app", "license": "MIT", "scripts": { - "build": "tsup src/index.ts src/app/client/index.ts src/app/server/index.ts src/app/middleware/index.ts --external next --external react --dts", + "build": "tsup src/app/app/client.ts src/app/app/server.ts --out-dir dist/app/app --external next --external react --dts", "watch": "pnpm build --watch" }, "devDependencies": { diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts new file mode 100644 index 0000000..b0c076a --- /dev/null +++ b/packages/next-international/src/app/app/client.ts @@ -0,0 +1,23 @@ +import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; +import type { CreateI18n, UseI18n, UseScopedI18n } from './types'; + +export function createI18n>( + locales: Locales, +): CreateI18n { + const useI18n: UseI18n = () => { + return (key, ...params) => { + return 'client'; + }; + }; + + const useScopedI18n: UseScopedI18n = scope => { + return (key, ...params) => { + return 'client'; + }; + }; + + return { + useI18n, + useScopedI18n, + }; +} diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts new file mode 100644 index 0000000..3ebab55 --- /dev/null +++ b/packages/next-international/src/app/app/server.ts @@ -0,0 +1,23 @@ +import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; +import type { CreateI18n, UseI18n, UseScopedI18n } from './types'; + +export function createI18n>( + locales: Locales, +): CreateI18n { + const useI18n: UseI18n = () => { + return (key, ...params) => { + return 'server'; + }; + }; + + const useScopedI18n: UseScopedI18n = scope => { + return (key, ...params) => { + return 'server'; + }; + }; + + return { + useI18n, + useScopedI18n, + }; +} diff --git a/packages/next-international/src/app/app/types.ts b/packages/next-international/src/app/app/types.ts new file mode 100644 index 0000000..e025bf5 --- /dev/null +++ b/packages/next-international/src/app/app/types.ts @@ -0,0 +1,15 @@ +import type { Keys, LocaleType, Params, Scopes } from 'international-types'; + +export type UseI18n = () => >( + key: Key, + ...params: Params +) => string; + +export type UseScopedI18n = >( + scope: Scope, +) => >(key: Key, ...params: Params) => string; + +export type CreateI18n = { + useI18n: UseI18n; + useScopedI18n: UseScopedI18n; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13d7d81..2f82dce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,34 @@ importers: specifier: ^5.2.2 version: 5.2.2 + examples/app: + dependencies: + next: + specifier: 14.1.0 + version: 14.1.0(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + next-international: + specifier: workspace:* + version: link:../../packages/next-international + react: + specifier: ^18 + version: 18.2.0 + react-dom: + specifier: ^18 + version: 18.2.0(react@18.2.0) + devDependencies: + '@types/node': + specifier: ^20 + version: 20.8.7 + '@types/react': + specifier: ^18 + version: 18.2.45 + '@types/react-dom': + specifier: ^18 + version: 18.0.6 + typescript: + specifier: ^5 + version: 5.2.2 + examples/next-app: dependencies: next: @@ -2374,6 +2402,10 @@ packages: /@next/env@14.0.4: resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} + /@next/env@14.1.0: + resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==} + dev: false + /@next/eslint-plugin-next@13.5.6: resolution: {integrity: sha512-ng7pU/DDsxPgT6ZPvuprxrkeew3XaRf4LAT4FabaEO/hAbvVx4P7wqnqdbTdDn1kgTvsI4tpIgT4Awn/m0bGbg==} dependencies: @@ -2388,6 +2420,15 @@ packages: requiresBuild: true optional: true + /@next/swc-darwin-arm64@14.1.0: + resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-darwin-x64@14.0.4: resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==} engines: {node: '>= 10'} @@ -2396,6 +2437,15 @@ packages: requiresBuild: true optional: true + /@next/swc-darwin-x64@14.1.0: + resolution: {integrity: sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-gnu@14.0.4: resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==} engines: {node: '>= 10'} @@ -2404,6 +2454,15 @@ packages: requiresBuild: true optional: true + /@next/swc-linux-arm64-gnu@14.1.0: + resolution: {integrity: sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-musl@14.0.4: resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} engines: {node: '>= 10'} @@ -2412,6 +2471,15 @@ packages: requiresBuild: true optional: true + /@next/swc-linux-arm64-musl@14.1.0: + resolution: {integrity: sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-gnu@14.0.4: resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} engines: {node: '>= 10'} @@ -2420,6 +2488,15 @@ packages: requiresBuild: true optional: true + /@next/swc-linux-x64-gnu@14.1.0: + resolution: {integrity: sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-musl@14.0.4: resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==} engines: {node: '>= 10'} @@ -2428,6 +2505,15 @@ packages: requiresBuild: true optional: true + /@next/swc-linux-x64-musl@14.1.0: + resolution: {integrity: sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-arm64-msvc@14.0.4: resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==} engines: {node: '>= 10'} @@ -2436,6 +2522,15 @@ packages: requiresBuild: true optional: true + /@next/swc-win32-arm64-msvc@14.1.0: + resolution: {integrity: sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-ia32-msvc@14.0.4: resolution: {integrity: sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==} engines: {node: '>= 10'} @@ -2444,6 +2539,15 @@ packages: requiresBuild: true optional: true + /@next/swc-win32-ia32-msvc@14.1.0: + resolution: {integrity: sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-x64-msvc@14.0.4: resolution: {integrity: sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==} engines: {node: '>= 10'} @@ -2452,6 +2556,15 @@ packages: requiresBuild: true optional: true + /@next/swc-win32-x64-msvc@14.1.0: + resolution: {integrity: sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -3621,6 +3734,10 @@ packages: /caniuse-lite@1.0.30001509: resolution: {integrity: sha512-2uDDk+TRiTX5hMcUYT/7CSyzMZxjfGu0vAUjS2g0LSD8UoXOv0LtpH4LxGMemsiPq6LCVIUjNwVM0erkOkGCDA==} + /caniuse-lite@1.0.30001587: + resolution: {integrity: sha512-HMFNotUmLXn71BQxg8cijvqxnIAofforZOwGsxyXJ0qugTdspUF4sPSJ2vhgprHCB996tIDzEq1ubumPDV8ULA==} + dev: false + /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} dev: false @@ -7254,6 +7371,45 @@ packages: - '@babel/core' - babel-plugin-macros + /next@14.1.0(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.1.0 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + caniuse-lite: 1.0.30001587 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(@babel/core@7.23.2)(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.1.0 + '@next/swc-darwin-x64': 14.1.0 + '@next/swc-linux-arm64-gnu': 14.1.0 + '@next/swc-linux-arm64-musl': 14.1.0 + '@next/swc-linux-x64-gnu': 14.1.0 + '@next/swc-linux-x64-musl': 14.1.0 + '@next/swc-win32-arm64-msvc': 14.1.0 + '@next/swc-win32-ia32-msvc': 14.1.0 + '@next/swc-win32-x64-msvc': 14.1.0 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /nextra-theme-docs@2.13.2(next@14.0.4)(nextra@2.13.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-yE4umXaImp1/kf/sFciPj2+EFrNSwd9Db26hi98sIIiujzGf3+9eUgAz45vF9CwBw50FSXxm1QGRcY+slQ4xQQ==} peerDependencies: From eb5155f109f6efa81837043efc93ccfdaeea8198 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sat, 17 Feb 2024 11:37:44 +0000 Subject: [PATCH 02/10] feat: provider logic --- examples/app/app/layout.tsx | 5 ++++- examples/app/locales/index.ts | 2 +- packages/next-international/src/app/app/client.ts | 2 ++ packages/next-international/src/app/app/provider.tsx | 12 ++++++++++++ packages/next-international/src/app/app/server.ts | 2 ++ packages/next-international/src/app/app/types.ts | 8 ++++++++ 6 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 packages/next-international/src/app/app/provider.tsx diff --git a/examples/app/app/layout.tsx b/examples/app/app/layout.tsx index 668746b..052d357 100644 --- a/examples/app/app/layout.tsx +++ b/examples/app/app/layout.tsx @@ -1,3 +1,4 @@ +import { I18nProvider } from '@/locales'; import React from 'react'; export default function RootLayout({ @@ -7,7 +8,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ); } diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts index fa1ac66..1a71c5d 100644 --- a/examples/app/locales/index.ts +++ b/examples/app/locales/index.ts @@ -1,6 +1,6 @@ import { createI18n } from 'next-international/app'; -export const { useI18n, useScopedI18n } = createI18n({ +export const { useI18n, useScopedI18n, I18nProvider } = createI18n({ en: () => import('./en'), fr: () => import('./fr'), }); diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index b0c076a..c8c972d 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -1,5 +1,6 @@ import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; import type { CreateI18n, UseI18n, UseScopedI18n } from './types'; +import { createI18nProvider } from './provider'; export function createI18n>( locales: Locales, @@ -19,5 +20,6 @@ export function createI18n(), }; } diff --git a/packages/next-international/src/app/app/provider.tsx b/packages/next-international/src/app/app/provider.tsx new file mode 100644 index 0000000..c52aa04 --- /dev/null +++ b/packages/next-international/src/app/app/provider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import type { LocaleType } from 'international-types'; +import type { I18nProvider } from './types'; + +export const createI18nProvider = () => { + const Provider: I18nProvider = ({ children }) => { + return children; + }; + + return Provider; +}; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 3ebab55..549d323 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -1,5 +1,6 @@ import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; import type { CreateI18n, UseI18n, UseScopedI18n } from './types'; +import { createI18nProvider } from './provider'; export function createI18n>( locales: Locales, @@ -19,5 +20,6 @@ export function createI18n(), }; } diff --git a/packages/next-international/src/app/app/types.ts b/packages/next-international/src/app/app/types.ts index e025bf5..21e91f1 100644 --- a/packages/next-international/src/app/app/types.ts +++ b/packages/next-international/src/app/app/types.ts @@ -1,4 +1,5 @@ import type { Keys, LocaleType, Params, Scopes } from 'international-types'; +import type { ReactNode } from 'react'; export type UseI18n = () => >( key: Key, @@ -9,7 +10,14 @@ export type UseScopedI18n = >(key: Key, ...params: Params) => string; +export type I18nProviderProps = { + children: ReactNode; +}; + +export type I18nProvider = (props: I18nProviderProps) => ReactNode; + export type CreateI18n = { useI18n: UseI18n; useScopedI18n: UseScopedI18n; + I18nProvider: I18nProvider; }; From 4273c79c5e0aa399571bed1941fb568e728e9f1b Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sat, 17 Feb 2024 12:09:08 +0000 Subject: [PATCH 03/10] feat: middleware logic --- examples/app/middleware.ts | 10 + packages/next-international/package.json | 6 +- .../src/app/middleware/index.ts | 232 +++++++++--------- 3 files changed, 133 insertions(+), 115 deletions(-) create mode 100644 examples/app/middleware.ts diff --git a/examples/app/middleware.ts b/examples/app/middleware.ts new file mode 100644 index 0000000..ebbf7ed --- /dev/null +++ b/examples/app/middleware.ts @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; +import { createI18nMiddleware } from 'next-international/middleware'; + +export const middleware = createI18nMiddleware(request => { + return NextResponse.next(); +}); + +export const config = { + matcher: ['/', '/:locale'], +}; diff --git a/packages/next-international/package.json b/packages/next-international/package.json index b5133ad..817c7c2 100644 --- a/packages/next-international/package.json +++ b/packages/next-international/package.json @@ -19,8 +19,8 @@ "default": "./dist/app/server/index.js" }, "./middleware": { - "types": "./dist/app/middleware/index.d.ts", - "default": "./dist/app/middleware/index.js" + "types": "./dist/app/app/middleware/index.d.ts", + "default": "./dist/app/app/middleware/index.js" }, "./app": { "types": "./dist/app/app/client.d.ts", @@ -51,7 +51,7 @@ "homepage": "https://next-international.vercel.app", "license": "MIT", "scripts": { - "build": "tsup src/app/app/client.ts src/app/app/server.ts --out-dir dist/app/app --external next --external react --dts", + "build": "tsup src/app/app/client.ts src/app/app/server.ts src/app/middleware/index.ts --out-dir dist/app/app --external next --external react --dts", "watch": "pnpm build --watch" }, "devDependencies": { diff --git a/packages/next-international/src/app/middleware/index.ts b/packages/next-international/src/app/middleware/index.ts index 427bad9..996ca69 100644 --- a/packages/next-international/src/app/middleware/index.ts +++ b/packages/next-international/src/app/middleware/index.ts @@ -1,114 +1,122 @@ -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; - -import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; -import { warn } from '../../helpers/log'; -import type { I18nMiddlewareConfig } from '../../types'; - -const DEFAULT_STRATEGY: NonNullable['urlMappingStrategy']> = 'redirect'; - -export function createI18nMiddleware(config: I18nMiddlewareConfig) { - return function I18nMiddleware(request: NextRequest) { - const locale = localeFromRequest(config.locales, request, config.resolveLocaleFromRequest) ?? config.defaultLocale; - const nextUrl = request.nextUrl; - - // If the locale from the request is not an handled locale, then redirect to the same URL with the default locale - if (noLocalePrefix(config.locales, nextUrl.pathname)) { - nextUrl.pathname = `/${locale}${nextUrl.pathname}`; - - const strategy = config.urlMappingStrategy ?? DEFAULT_STRATEGY; - if (strategy === 'rewrite' || (strategy === 'rewriteDefault' && locale === config.defaultLocale)) { - const response = NextResponse.rewrite(nextUrl); - return addLocaleToResponse(request, response, locale); - } else { - if (!['redirect', 'rewriteDefault'].includes(strategy)) { - warn(`Invalid urlMappingStrategy: ${strategy}. Defaulting to redirect.`); - } - - const response = NextResponse.redirect(nextUrl); - return addLocaleToResponse(request, response, locale); - } - } - - let response = NextResponse.next(); - const pathnameLocale = nextUrl.pathname.split('/', 2)?.[1]; - - if (!pathnameLocale || config.locales.includes(pathnameLocale)) { - // If the URL mapping strategy is set to 'rewrite' and the locale from the request doesn't match the locale in the pathname, - // or if the URL mapping strategy is set to 'rewriteDefault' and the locale from the request doesn't match the locale in the pathname - // or is the same as the default locale, then proceed with the following logic - if ( - (config.urlMappingStrategy === 'rewrite' && pathnameLocale !== locale) || - (config.urlMappingStrategy === 'rewriteDefault' && - (pathnameLocale !== locale || pathnameLocale === config.defaultLocale)) - ) { - // Remove the locale from the pathname - const pathnameWithoutLocale = nextUrl.pathname.slice(pathnameLocale.length + 1); - - // Create a new URL without the locale in the pathname - const newUrl = new URL(pathnameWithoutLocale || '/', request.url); - - // Preserve the original search parameters - newUrl.search = nextUrl.search; - response = NextResponse.redirect(newUrl); - } - - return addLocaleToResponse(request, response, pathnameLocale ?? config.defaultLocale); - } - - return response; +// import type { NextRequest } from 'next/server'; +// import { NextResponse } from 'next/server'; +// +// import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; +// import { warn } from '../../helpers/log'; +// import type { I18nMiddlewareConfig } from '../../types'; +// +// const DEFAULT_STRATEGY: NonNullable['urlMappingStrategy']> = 'redirect'; +// +// export function createI18nMiddleware(config: I18nMiddlewareConfig) { +// return function I18nMiddleware(request: NextRequest) { +// const locale = localeFromRequest(config.locales, request, config.resolveLocaleFromRequest) ?? config.defaultLocale; +// const nextUrl = request.nextUrl; +// +// // If the locale from the request is not an handled locale, then redirect to the same URL with the default locale +// if (noLocalePrefix(config.locales, nextUrl.pathname)) { +// nextUrl.pathname = `/${locale}${nextUrl.pathname}`; +// +// const strategy = config.urlMappingStrategy ?? DEFAULT_STRATEGY; +// if (strategy === 'rewrite' || (strategy === 'rewriteDefault' && locale === config.defaultLocale)) { +// const response = NextResponse.rewrite(nextUrl); +// return addLocaleToResponse(request, response, locale); +// } else { +// if (!['redirect', 'rewriteDefault'].includes(strategy)) { +// warn(`Invalid urlMappingStrategy: ${strategy}. Defaulting to redirect.`); +// } +// +// const response = NextResponse.redirect(nextUrl); +// return addLocaleToResponse(request, response, locale); +// } +// } +// +// let response = NextResponse.next(); +// const pathnameLocale = nextUrl.pathname.split('/', 2)?.[1]; +// +// if (!pathnameLocale || config.locales.includes(pathnameLocale)) { +// // If the URL mapping strategy is set to 'rewrite' and the locale from the request doesn't match the locale in the pathname, +// // or if the URL mapping strategy is set to 'rewriteDefault' and the locale from the request doesn't match the locale in the pathname +// // or is the same as the default locale, then proceed with the following logic +// if ( +// (config.urlMappingStrategy === 'rewrite' && pathnameLocale !== locale) || +// (config.urlMappingStrategy === 'rewriteDefault' && +// (pathnameLocale !== locale || pathnameLocale === config.defaultLocale)) +// ) { +// // Remove the locale from the pathname +// const pathnameWithoutLocale = nextUrl.pathname.slice(pathnameLocale.length + 1); +// +// // Create a new URL without the locale in the pathname +// const newUrl = new URL(pathnameWithoutLocale || '/', request.url); +// +// // Preserve the original search parameters +// newUrl.search = nextUrl.search; +// response = NextResponse.redirect(newUrl); +// } +// +// return addLocaleToResponse(request, response, pathnameLocale ?? config.defaultLocale); +// } +// +// return response; +// }; +// } +// +// /** +// * Retrieve `Next-Locale` header from request +// * and check if it is an handled locale. +// */ +// function localeFromRequest( +// locales: Locales, +// request: NextRequest, +// resolveLocaleFromRequest: NonNullable< +// I18nMiddlewareConfig['resolveLocaleFromRequest'] +// > = defaultResolveLocaleFromRequest, +// ) { +// const locale = request.cookies.get(LOCALE_COOKIE)?.value ?? resolveLocaleFromRequest(request); +// +// if (!locale || !locales.includes(locale)) { +// return null; +// } +// +// return locale; +// } +// +// /** +// * Default implementation of the `resolveLocaleFromRequest` function for the I18nMiddlewareConfig. +// * This function extracts the locale from the 'Accept-Language' header of the request. +// */ +// const defaultResolveLocaleFromRequest: NonNullable['resolveLocaleFromRequest']> = request => { +// const header = request.headers.get('Accept-Language'); +// const locale = header?.split(',', 1)?.[0]?.split('-', 1)?.[0]; +// return locale ?? null; +// }; +// +// /** +// * Returns `true` if the pathname does not start with an handled locale +// */ +// function noLocalePrefix(locales: readonly string[], pathname: string) { +// return locales.every(locale => { +// return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); +// }); +// } +// +// /** +// * Add `X-Next-Locale` header and `Next-Locale` cookie to response +// * +// * **NOTE:** The cookie is only set if the locale is different from the one in the cookie +// */ +// function addLocaleToResponse(request: NextRequest, response: NextResponse, locale: string) { +// response.headers.set(LOCALE_HEADER, locale); +// +// if (request.cookies.get(LOCALE_COOKIE)?.value !== locale) { +// response.cookies.set(LOCALE_COOKIE, locale, { sameSite: 'strict' }); +// } +// return response; +// } + +import type { NextMiddleware } from 'next/server'; + +export function createI18nMiddleware(middleware: NextMiddleware): NextMiddleware { + return (request, event) => { + return middleware(request, event); }; } - -/** - * Retrieve `Next-Locale` header from request - * and check if it is an handled locale. - */ -function localeFromRequest( - locales: Locales, - request: NextRequest, - resolveLocaleFromRequest: NonNullable< - I18nMiddlewareConfig['resolveLocaleFromRequest'] - > = defaultResolveLocaleFromRequest, -) { - const locale = request.cookies.get(LOCALE_COOKIE)?.value ?? resolveLocaleFromRequest(request); - - if (!locale || !locales.includes(locale)) { - return null; - } - - return locale; -} - -/** - * Default implementation of the `resolveLocaleFromRequest` function for the I18nMiddlewareConfig. - * This function extracts the locale from the 'Accept-Language' header of the request. - */ -const defaultResolveLocaleFromRequest: NonNullable['resolveLocaleFromRequest']> = request => { - const header = request.headers.get('Accept-Language'); - const locale = header?.split(',', 1)?.[0]?.split('-', 1)?.[0]; - return locale ?? null; -}; - -/** - * Returns `true` if the pathname does not start with an handled locale - */ -function noLocalePrefix(locales: readonly string[], pathname: string) { - return locales.every(locale => { - return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); - }); -} - -/** - * Add `X-Next-Locale` header and `Next-Locale` cookie to response - * - * **NOTE:** The cookie is only set if the locale is different from the one in the cookie - */ -function addLocaleToResponse(request: NextRequest, response: NextResponse, locale: string) { - response.headers.set(LOCALE_HEADER, locale); - - if (request.cookies.get(LOCALE_COOKIE)?.value !== locale) { - response.cookies.set(LOCALE_COOKIE, locale, { sameSite: 'strict' }); - } - return response; -} From 7de56595aa91d214513c882b3de4655ae6968014 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sun, 18 Feb 2024 08:48:41 +0000 Subject: [PATCH 04/10] feat: next-international V2 (#362) --- .../app/app/{ => [locale]}/client-component.tsx | 0 examples/app/app/{ => [locale]}/layout.tsx | 4 +++- examples/app/app/{ => [locale]}/page.tsx | 0 examples/app/locales/index.ts | 2 +- packages/next-international/app.d.ts | 2 +- packages/next-international/package.json | 6 +++--- packages/next-international/src/app/app/client.ts | 13 +++++++++++-- .../next-international/src/app/app/constants.ts | 1 + packages/next-international/src/app/app/server.ts | 13 +++++++++++-- packages/next-international/src/app/app/types.ts | 7 +++++++ 10 files changed, 38 insertions(+), 10 deletions(-) rename examples/app/app/{ => [locale]}/client-component.tsx (100%) rename examples/app/app/{ => [locale]}/layout.tsx (65%) rename examples/app/app/{ => [locale]}/page.tsx (100%) create mode 100644 packages/next-international/src/app/app/constants.ts diff --git a/examples/app/app/client-component.tsx b/examples/app/app/[locale]/client-component.tsx similarity index 100% rename from examples/app/app/client-component.tsx rename to examples/app/app/[locale]/client-component.tsx diff --git a/examples/app/app/layout.tsx b/examples/app/app/[locale]/layout.tsx similarity index 65% rename from examples/app/app/layout.tsx rename to examples/app/app/[locale]/layout.tsx index 052d357..8ac1059 100644 --- a/examples/app/app/layout.tsx +++ b/examples/app/app/[locale]/layout.tsx @@ -1,6 +1,8 @@ -import { I18nProvider } from '@/locales'; +import { I18nProvider, generateI18nStaticParams } from '@/locales'; import React from 'react'; +export const generateStaticParams = generateI18nStaticParams(); + export default function RootLayout({ children, }: Readonly<{ diff --git a/examples/app/app/page.tsx b/examples/app/app/[locale]/page.tsx similarity index 100% rename from examples/app/app/page.tsx rename to examples/app/app/[locale]/page.tsx diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts index 1a71c5d..ab44dd2 100644 --- a/examples/app/locales/index.ts +++ b/examples/app/locales/index.ts @@ -1,6 +1,6 @@ import { createI18n } from 'next-international/app'; -export const { useI18n, useScopedI18n, I18nProvider } = createI18n({ +export const { useI18n, useScopedI18n, I18nProvider, generateI18nStaticParams } = createI18n({ en: () => import('./en'), fr: () => import('./fr'), }); diff --git a/packages/next-international/app.d.ts b/packages/next-international/app.d.ts index f819711..0bcbf3f 100644 --- a/packages/next-international/app.d.ts +++ b/packages/next-international/app.d.ts @@ -1 +1 @@ -export * from './dist/app/app'; +export * from './dist/app/app/app/client'; diff --git a/packages/next-international/package.json b/packages/next-international/package.json index 817c7c2..32b1e5e 100644 --- a/packages/next-international/package.json +++ b/packages/next-international/package.json @@ -23,9 +23,9 @@ "default": "./dist/app/app/middleware/index.js" }, "./app": { - "types": "./dist/app/app/client.d.ts", - "react-server": "./dist/app/app/server.js", - "default": "./dist/app/app/client.js" + "types": "./dist/app/app/app/client.d.ts", + "react-server": "./dist/app/app/app/server.js", + "default": "./dist/app/app/app/client.js" } }, "keywords": [ diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index c8c972d..027de91 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -1,9 +1,11 @@ import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; -import type { CreateI18n, UseI18n, UseScopedI18n } from './types'; +import type { CreateI18n, GenerateI18nStaticParams, I18nConfig, UseI18n, UseScopedI18n } from './types'; import { createI18nProvider } from './provider'; +import { SEGMENT_NAME } from './constants'; export function createI18n>( locales: Locales, + config: I18nConfig = {}, ): CreateI18n { const useI18n: UseI18n = () => { return (key, ...params) => { @@ -17,9 +19,16 @@ export function createI18n(); + + const generateI18nStaticParams: GenerateI18nStaticParams = () => { + return Object.keys(locales).map(locale => ({ [config.segmentName ?? SEGMENT_NAME]: locale })); + }; + return { useI18n, useScopedI18n, - I18nProvider: createI18nProvider(), + I18nProvider, + generateI18nStaticParams, }; } diff --git a/packages/next-international/src/app/app/constants.ts b/packages/next-international/src/app/app/constants.ts new file mode 100644 index 0000000..b6bfe90 --- /dev/null +++ b/packages/next-international/src/app/app/constants.ts @@ -0,0 +1 @@ +export const SEGMENT_NAME = 'locale'; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 549d323..a32a50d 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -1,9 +1,11 @@ import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; -import type { CreateI18n, UseI18n, UseScopedI18n } from './types'; +import type { CreateI18n, GenerateI18nStaticParams, I18nConfig, UseI18n, UseScopedI18n } from './types'; import { createI18nProvider } from './provider'; +import { SEGMENT_NAME } from './constants'; export function createI18n>( locales: Locales, + config: I18nConfig = {}, ): CreateI18n { const useI18n: UseI18n = () => { return (key, ...params) => { @@ -17,9 +19,16 @@ export function createI18n(); + + const generateI18nStaticParams: GenerateI18nStaticParams = () => { + return Object.keys(locales).map(locale => ({ [config.segmentName ?? SEGMENT_NAME]: locale })); + }; + return { useI18n, useScopedI18n, - I18nProvider: createI18nProvider(), + I18nProvider, + generateI18nStaticParams, }; } diff --git a/packages/next-international/src/app/app/types.ts b/packages/next-international/src/app/app/types.ts index 21e91f1..fb28915 100644 --- a/packages/next-international/src/app/app/types.ts +++ b/packages/next-international/src/app/app/types.ts @@ -16,8 +16,15 @@ export type I18nProviderProps = { export type I18nProvider = (props: I18nProviderProps) => ReactNode; +export type GenerateI18nStaticParams = () => Array>; + export type CreateI18n = { useI18n: UseI18n; useScopedI18n: UseScopedI18n; I18nProvider: I18nProvider; + generateI18nStaticParams: GenerateI18nStaticParams; +}; + +export type I18nConfig = { + segmentName?: string; }; From 637c2f95012ba614d2c0807ab059937eaed18371 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sun, 18 Feb 2024 09:15:01 +0000 Subject: [PATCH 05/10] feat(v2): not found catch-all (#363) --- examples/app/app/[locale]/[...notFound]/page.tsx | 5 +++++ examples/app/app/[locale]/layout.tsx | 13 +++++-------- examples/app/app/layout.tsx | 13 +++++++++++++ examples/app/app/not-found.tsx | 10 ++++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 examples/app/app/[locale]/[...notFound]/page.tsx create mode 100644 examples/app/app/layout.tsx create mode 100644 examples/app/app/not-found.tsx diff --git a/examples/app/app/[locale]/[...notFound]/page.tsx b/examples/app/app/[locale]/[...notFound]/page.tsx new file mode 100644 index 0000000..5cc2a45 --- /dev/null +++ b/examples/app/app/[locale]/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation'; + +export default function NotFound() { + notFound(); +} diff --git a/examples/app/app/[locale]/layout.tsx b/examples/app/app/[locale]/layout.tsx index 8ac1059..4077a33 100644 --- a/examples/app/app/[locale]/layout.tsx +++ b/examples/app/app/[locale]/layout.tsx @@ -1,18 +1,15 @@ import { I18nProvider, generateI18nStaticParams } from '@/locales'; import React from 'react'; -export const generateStaticParams = generateI18nStaticParams(); +export const dynamicParams = false; +export function generateStaticParams() { + return generateI18nStaticParams(); +} export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - return ( - - - {children} - - - ); + return {children}; } diff --git a/examples/app/app/layout.tsx b/examples/app/app/layout.tsx new file mode 100644 index 0000000..668746b --- /dev/null +++ b/examples/app/app/layout.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/app/app/not-found.tsx b/examples/app/app/not-found.tsx new file mode 100644 index 0000000..2c4bcd6 --- /dev/null +++ b/examples/app/app/not-found.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +export default function NotFound() { + return ( +
+

Custom Not Found page

+

Could not find requested resource

+
+ ); +} From 63618f1edc71bab06942e57b3e2fa0d6b475fa96 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sun, 3 Mar 2024 10:50:19 +0000 Subject: [PATCH 06/10] feat(v2): `useLocale`, `useChangeLocale` support (#370) --- .../app/app/[locale]/client-component.tsx | 21 ++++++++- examples/app/app/[locale]/layout.tsx | 4 +- examples/app/app/[locale]/page.tsx | 5 +- examples/app/locales/index.ts | 9 ++-- .../next-international/src/app/app/client.ts | 47 +++++++++++++++++-- .../src/app/app/provider.tsx | 2 +- .../next-international/src/app/app/server.ts | 46 ++++++++++++++++-- .../next-international/src/app/app/types.ts | 11 ++++- 8 files changed, 127 insertions(+), 18 deletions(-) diff --git a/examples/app/app/[locale]/client-component.tsx b/examples/app/app/[locale]/client-component.tsx index 108ddde..2a2078c 100644 --- a/examples/app/app/[locale]/client-component.tsx +++ b/examples/app/app/[locale]/client-component.tsx @@ -1,11 +1,13 @@ 'use client'; import React from 'react'; -import { useI18n, useScopedI18n } from '@/locales'; +import { useChangeLocale, useI18n, useLocale, useScopedI18n } from '@/locales'; export function ClientComponent() { const t = useI18n(); const scopedT = useScopedI18n('hello'); + const locale = useLocale(); + const changeLocale = useChangeLocale(); return (
@@ -21,6 +23,23 @@ export function ClientComponent() { name: 'John', })}

+

Current locale: {locale}

+ +
); } diff --git a/examples/app/app/[locale]/layout.tsx b/examples/app/app/[locale]/layout.tsx index 4077a33..5e20df1 100644 --- a/examples/app/app/[locale]/layout.tsx +++ b/examples/app/app/[locale]/layout.tsx @@ -8,8 +8,10 @@ export function generateStaticParams() { export default function RootLayout({ children, + params: { locale }, }: Readonly<{ children: React.ReactNode; + params: { locale: string }; }>) { - return {children}; + return {children}; } diff --git a/examples/app/app/[locale]/page.tsx b/examples/app/app/[locale]/page.tsx index a2697ac..911eadb 100644 --- a/examples/app/app/[locale]/page.tsx +++ b/examples/app/app/[locale]/page.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { useI18n, useScopedI18n } from '@/locales'; +import { useI18n, useLocale, useScopedI18n } from '@/locales'; import { ClientComponent } from './client-component'; export default function Home() { const t = useI18n(); const scopedT = useScopedI18n('hello'); + const locale = useLocale(); return (
@@ -20,6 +21,8 @@ export default function Home() { name: 'John', })}

+

Current locale: {locale}

+
); diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts index ab44dd2..213ef20 100644 --- a/examples/app/locales/index.ts +++ b/examples/app/locales/index.ts @@ -1,6 +1,7 @@ import { createI18n } from 'next-international/app'; -export const { useI18n, useScopedI18n, I18nProvider, generateI18nStaticParams } = createI18n({ - en: () => import('./en'), - fr: () => import('./fr'), -}); +export const { useI18n, useScopedI18n, I18nProvider, generateI18nStaticParams, useLocale, useChangeLocale } = + createI18n({ + en: () => import('./en'), + fr: () => import('./fr'), + }); diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index 027de91..ff28b8a 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -1,21 +1,45 @@ import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; -import type { CreateI18n, GenerateI18nStaticParams, I18nConfig, UseI18n, UseScopedI18n } from './types'; +import type { + CreateI18n, + GenerateI18nStaticParams, + I18nConfig, + UseChangeLocale, + UseI18n, + UseLocale, + UseScopedI18n, +} from './types'; import { createI18nProvider } from './provider'; import { SEGMENT_NAME } from './constants'; +import { useParams, useRouter } from 'next/navigation'; + +function useLocaleCache() { + const params = useParams(); + const locale = params.locale; + + if (typeof locale !== 'string') { + throw new Error('Invariant: locale params is not a string: ' + JSON.stringify(params, null, 2)); + } + + return locale; +} export function createI18n>( locales: Locales, config: I18nConfig = {}, -): CreateI18n { +): CreateI18n { const useI18n: UseI18n = () => { + const locale = useLocaleCache(); + return (key, ...params) => { - return 'client'; + return 'client: ' + locale; }; }; const useScopedI18n: UseScopedI18n = scope => { + const locale = useLocaleCache(); + return (key, ...params) => { - return 'client'; + return 'client: ' + locale; }; }; @@ -25,10 +49,25 @@ export function createI18n ({ [config.segmentName ?? SEGMENT_NAME]: locale })); }; + const useLocale: UseLocale = () => { + return useLocaleCache(); + }; + + const useChangeLocale: UseChangeLocale = () => { + const router = useRouter(); + + return locale => { + // TODO: preserve URL & search params + router.push(`/${locale as string}`); + }; + }; + return { useI18n, useScopedI18n, I18nProvider, generateI18nStaticParams, + useLocale, + useChangeLocale, }; } diff --git a/packages/next-international/src/app/app/provider.tsx b/packages/next-international/src/app/app/provider.tsx index c52aa04..d4c5af4 100644 --- a/packages/next-international/src/app/app/provider.tsx +++ b/packages/next-international/src/app/app/provider.tsx @@ -4,7 +4,7 @@ import type { LocaleType } from 'international-types'; import type { I18nProvider } from './types'; export const createI18nProvider = () => { - const Provider: I18nProvider = ({ children }) => { + const Provider: I18nProvider = ({ locale, children }) => { return children; }; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index a32a50d..7bba45c 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -1,21 +1,47 @@ import type { GetLocale, LocaleType, LocalesObject } from 'international-types'; -import type { CreateI18n, GenerateI18nStaticParams, I18nConfig, UseI18n, UseScopedI18n } from './types'; +import type { + CreateI18n, + GenerateI18nStaticParams, + I18nConfig, + UseChangeLocale, + UseI18n, + UseLocale, + UseScopedI18n, +} from './types'; import { createI18nProvider } from './provider'; import { SEGMENT_NAME } from './constants'; +// @ts-expect-error - no types +import { cache } from 'react'; +import { staticGenerationAsyncStorage } from 'next/dist/client/components/static-generation-async-storage.external'; + +const useLocaleCache = cache(() => { + const store = staticGenerationAsyncStorage.getStore(); + const url = store?.urlPathname; + + if (typeof url !== 'string') { + throw new Error('Invariant: urlPathname is not a string: ' + JSON.stringify(store, null, 2)); + } + + return url.split('/')[1].split('?')[0]; +}); export function createI18n>( locales: Locales, config: I18nConfig = {}, -): CreateI18n { +): CreateI18n { const useI18n: UseI18n = () => { + const locale = useLocaleCache(); + return (key, ...params) => { - return 'server'; + return 'server: ' + locale; }; }; const useScopedI18n: UseScopedI18n = scope => { + const locale = useLocaleCache(); + return (key, ...params) => { - return 'server'; + return 'server: ' + locale; }; }; @@ -25,10 +51,22 @@ export function createI18n ({ [config.segmentName ?? SEGMENT_NAME]: locale })); }; + const useLocale: UseLocale = () => { + return useLocaleCache(); + }; + + const useChangeLocale: UseChangeLocale = () => { + return () => { + throw new Error('Invariant: useChangeLocale only works in Client Components'); + }; + }; + return { useI18n, useScopedI18n, I18nProvider, generateI18nStaticParams, + useLocale, + useChangeLocale, }; } diff --git a/packages/next-international/src/app/app/types.ts b/packages/next-international/src/app/app/types.ts index fb28915..1fff464 100644 --- a/packages/next-international/src/app/app/types.ts +++ b/packages/next-international/src/app/app/types.ts @@ -1,4 +1,4 @@ -import type { Keys, LocaleType, Params, Scopes } from 'international-types'; +import type { Keys, LocaleType, LocalesObject, Params, Scopes } from 'international-types'; import type { ReactNode } from 'react'; export type UseI18n = () => >( @@ -11,6 +11,7 @@ export type UseScopedI18n = >(key: Key, ...params: Params) => string; export type I18nProviderProps = { + locale: string; children: ReactNode; }; @@ -18,11 +19,17 @@ export type I18nProvider = (props: I18nProviderProps< export type GenerateI18nStaticParams = () => Array>; -export type CreateI18n = { +export type UseLocale = () => keyof Locales; + +export type UseChangeLocale = () => (locale: keyof Locales) => void; + +export type CreateI18n = { useI18n: UseI18n; useScopedI18n: UseScopedI18n; I18nProvider: I18nProvider; generateI18nStaticParams: GenerateI18nStaticParams; + useLocale: UseLocale; + useChangeLocale: UseChangeLocale; }; export type I18nConfig = { From 1c69c87a6e2094e727ffd49b10e204b4e8ce1cf5 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sun, 3 Mar 2024 11:42:01 +0000 Subject: [PATCH 07/10] feat(v2): improved middleware (#372) --- .../app/app/[locale]/[...notFound]/page.tsx | 9 +- examples/app/app/[locale]/layout.tsx | 8 +- examples/app/app/layout.tsx | 13 -- examples/app/app/not-found.tsx | 10 - examples/app/middleware.ts | 16 +- .../next-international/src/app/app/client.ts | 5 +- .../next-international/src/app/app/server.ts | 17 +- .../src/app/middleware/index.ts | 204 ++++++++---------- 8 files changed, 131 insertions(+), 151 deletions(-) delete mode 100644 examples/app/app/layout.tsx delete mode 100644 examples/app/app/not-found.tsx diff --git a/examples/app/app/[locale]/[...notFound]/page.tsx b/examples/app/app/[locale]/[...notFound]/page.tsx index 5cc2a45..2c4bcd6 100644 --- a/examples/app/app/[locale]/[...notFound]/page.tsx +++ b/examples/app/app/[locale]/[...notFound]/page.tsx @@ -1,5 +1,10 @@ -import { notFound } from 'next/navigation'; +import React from 'react'; export default function NotFound() { - notFound(); + return ( +
+

Custom Not Found page

+

Could not find requested resource

+
+ ); } diff --git a/examples/app/app/[locale]/layout.tsx b/examples/app/app/[locale]/layout.tsx index 5e20df1..d2bdb57 100644 --- a/examples/app/app/[locale]/layout.tsx +++ b/examples/app/app/[locale]/layout.tsx @@ -13,5 +13,11 @@ export default function RootLayout({ children: React.ReactNode; params: { locale: string }; }>) { - return {children}; + return ( + + + {children} + + + ); } diff --git a/examples/app/app/layout.tsx b/examples/app/app/layout.tsx deleted file mode 100644 index 668746b..0000000 --- a/examples/app/app/layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - {children} - - ); -} diff --git a/examples/app/app/not-found.tsx b/examples/app/app/not-found.tsx deleted file mode 100644 index 2c4bcd6..0000000 --- a/examples/app/app/not-found.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -export default function NotFound() { - return ( -
-

Custom Not Found page

-

Could not find requested resource

-
- ); -} diff --git a/examples/app/middleware.ts b/examples/app/middleware.ts index ebbf7ed..4be91f1 100644 --- a/examples/app/middleware.ts +++ b/examples/app/middleware.ts @@ -1,10 +1,18 @@ import { NextResponse } from 'next/server'; import { createI18nMiddleware } from 'next-international/middleware'; -export const middleware = createI18nMiddleware(request => { - return NextResponse.next(); -}); +export const middleware = createI18nMiddleware( + request => { + console.log('User middleware:', request.url); + return NextResponse.next(); + }, + { + locales: ['en', 'fr'], + defaultLocale: 'en', + // urlMappingStrategy: 'rewrite', + }, +); export const config = { - matcher: ['/', '/:locale'], + matcher: ['/((?!api|static|.*\\..*|_next|favicon.ico|robots.txt).*)'], }; diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index ff28b8a..c469955 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -57,8 +57,11 @@ export function createI18n { + const finalLocale = locale as string; + document.cookie = `locale=${finalLocale};`; + // TODO: preserve URL & search params - router.push(`/${locale as string}`); + router.push(`/${finalLocale}`); }; }; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 7bba45c..8ab9895 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -22,7 +22,22 @@ const useLocaleCache = cache(() => { throw new Error('Invariant: urlPathname is not a string: ' + JSON.stringify(store, null, 2)); } - return url.split('/')[1].split('?')[0]; + let locale = url.split('/')[1].split('?')[0]; + + if (locale === '') { + const cookie = (store?.incrementalCache?.requestHeaders?.['cookie'] as string) + ?.split(';') + .find(c => c.trim().startsWith('locale=')) + ?.split('=')[1]; + + if (!cookie) { + throw new Error('Invariant: locale cookie not found'); + } + + locale = cookie; + } + + return locale; }); export function createI18n>( diff --git a/packages/next-international/src/app/middleware/index.ts b/packages/next-international/src/app/middleware/index.ts index 996ca69..7990032 100644 --- a/packages/next-international/src/app/middleware/index.ts +++ b/packages/next-international/src/app/middleware/index.ts @@ -1,122 +1,88 @@ -// import type { NextRequest } from 'next/server'; -// import { NextResponse } from 'next/server'; -// -// import { LOCALE_COOKIE, LOCALE_HEADER } from '../../common/constants'; -// import { warn } from '../../helpers/log'; -// import type { I18nMiddlewareConfig } from '../../types'; -// -// const DEFAULT_STRATEGY: NonNullable['urlMappingStrategy']> = 'redirect'; -// -// export function createI18nMiddleware(config: I18nMiddlewareConfig) { -// return function I18nMiddleware(request: NextRequest) { -// const locale = localeFromRequest(config.locales, request, config.resolveLocaleFromRequest) ?? config.defaultLocale; -// const nextUrl = request.nextUrl; -// -// // If the locale from the request is not an handled locale, then redirect to the same URL with the default locale -// if (noLocalePrefix(config.locales, nextUrl.pathname)) { -// nextUrl.pathname = `/${locale}${nextUrl.pathname}`; -// -// const strategy = config.urlMappingStrategy ?? DEFAULT_STRATEGY; -// if (strategy === 'rewrite' || (strategy === 'rewriteDefault' && locale === config.defaultLocale)) { -// const response = NextResponse.rewrite(nextUrl); -// return addLocaleToResponse(request, response, locale); -// } else { -// if (!['redirect', 'rewriteDefault'].includes(strategy)) { -// warn(`Invalid urlMappingStrategy: ${strategy}. Defaulting to redirect.`); -// } -// -// const response = NextResponse.redirect(nextUrl); -// return addLocaleToResponse(request, response, locale); -// } -// } -// -// let response = NextResponse.next(); -// const pathnameLocale = nextUrl.pathname.split('/', 2)?.[1]; -// -// if (!pathnameLocale || config.locales.includes(pathnameLocale)) { -// // If the URL mapping strategy is set to 'rewrite' and the locale from the request doesn't match the locale in the pathname, -// // or if the URL mapping strategy is set to 'rewriteDefault' and the locale from the request doesn't match the locale in the pathname -// // or is the same as the default locale, then proceed with the following logic -// if ( -// (config.urlMappingStrategy === 'rewrite' && pathnameLocale !== locale) || -// (config.urlMappingStrategy === 'rewriteDefault' && -// (pathnameLocale !== locale || pathnameLocale === config.defaultLocale)) -// ) { -// // Remove the locale from the pathname -// const pathnameWithoutLocale = nextUrl.pathname.slice(pathnameLocale.length + 1); -// -// // Create a new URL without the locale in the pathname -// const newUrl = new URL(pathnameWithoutLocale || '/', request.url); -// -// // Preserve the original search parameters -// newUrl.search = nextUrl.search; -// response = NextResponse.redirect(newUrl); -// } -// -// return addLocaleToResponse(request, response, pathnameLocale ?? config.defaultLocale); -// } -// -// return response; -// }; -// } -// -// /** -// * Retrieve `Next-Locale` header from request -// * and check if it is an handled locale. -// */ -// function localeFromRequest( -// locales: Locales, -// request: NextRequest, -// resolveLocaleFromRequest: NonNullable< -// I18nMiddlewareConfig['resolveLocaleFromRequest'] -// > = defaultResolveLocaleFromRequest, -// ) { -// const locale = request.cookies.get(LOCALE_COOKIE)?.value ?? resolveLocaleFromRequest(request); -// -// if (!locale || !locales.includes(locale)) { -// return null; -// } -// -// return locale; -// } -// -// /** -// * Default implementation of the `resolveLocaleFromRequest` function for the I18nMiddlewareConfig. -// * This function extracts the locale from the 'Accept-Language' header of the request. -// */ -// const defaultResolveLocaleFromRequest: NonNullable['resolveLocaleFromRequest']> = request => { -// const header = request.headers.get('Accept-Language'); -// const locale = header?.split(',', 1)?.[0]?.split('-', 1)?.[0]; -// return locale ?? null; -// }; -// -// /** -// * Returns `true` if the pathname does not start with an handled locale -// */ -// function noLocalePrefix(locales: readonly string[], pathname: string) { -// return locales.every(locale => { -// return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); -// }); -// } -// -// /** -// * Add `X-Next-Locale` header and `Next-Locale` cookie to response -// * -// * **NOTE:** The cookie is only set if the locale is different from the one in the cookie -// */ -// function addLocaleToResponse(request: NextRequest, response: NextResponse, locale: string) { -// response.headers.set(LOCALE_HEADER, locale); -// -// if (request.cookies.get(LOCALE_COOKIE)?.value !== locale) { -// response.cookies.set(LOCALE_COOKIE, locale, { sameSite: 'strict' }); -// } -// return response; -// } - -import type { NextMiddleware } from 'next/server'; - -export function createI18nMiddleware(middleware: NextMiddleware): NextMiddleware { - return (request, event) => { +import { NextResponse } from 'next/server'; +import type { NextMiddleware, NextRequest } from 'next/server'; + +type I18nMiddlewareConfig = { + locales: Locales; + defaultLocale: Locales[number]; + resolveLocaleFromRequest?: (request: NextRequest) => string | null; + urlMappingStrategy?: 'redirect' | 'rewrite' | 'rewriteDefault'; +}; + +function getLocaleFromRequest( + request: NextRequest, + config: I18nMiddlewareConfig, +) { + if (config.resolveLocaleFromRequest) { + return config.resolveLocaleFromRequest(request); + } + + const locale = request.cookies.get('locale')?.value; + + if (!locale || !config.locales.includes(locale)) { + return null; + } + + return locale; +} + +function noLocalePrefix(locales: readonly string[], pathname: string) { + return locales.every(locale => { + return !(pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)); + }); +} + +export function createI18nMiddleware( + middleware: NextMiddleware, + config: I18nMiddlewareConfig, +): NextMiddleware { + return async (request, event) => { + let currentLocale = getLocaleFromRequest(request, config); + + if (currentLocale === null) { + currentLocale = config.defaultLocale; + const response = await middleware(request, event); + + if (response instanceof NextResponse) { + response.cookies.set('locale', currentLocale, { sameSite: 'strict' }); + } else if (response instanceof Response) { + const cookies = response.headers.get('set-cookie') ?? ''; + response.headers.set('set-cookie', `${cookies}; locale=${currentLocale}; SameSite=Strict`); + } + + return response; + } + + if (!config.urlMappingStrategy || config.urlMappingStrategy === 'redirect') { + const nextUrl = request.nextUrl; + const pathname = new URL(request.url).pathname; + + if (noLocalePrefix(config.locales, pathname)) { + nextUrl.pathname = `/${currentLocale}${pathname}`; + const response = NextResponse.redirect(nextUrl); + return response; + } + } + + if ( + (config.urlMappingStrategy === 'rewriteDefault' && currentLocale === config.defaultLocale) || + config.urlMappingStrategy === 'rewrite' + ) { + const nextUrl = request.nextUrl; + + if (noLocalePrefix(config.locales, nextUrl.pathname)) { + nextUrl.pathname = `/${currentLocale}${nextUrl.pathname}`; + const response = NextResponse.rewrite(nextUrl); + return response; + } + + const urlWithoutLocale = nextUrl.pathname.slice(currentLocale.length + 1); + const newUrl = new URL(urlWithoutLocale || '/', request.url); + newUrl.search = nextUrl.search; + + const response = NextResponse.redirect(newUrl); + return response; + } + return middleware(request, event); }; } From 78ff1ae68d891ff19b9421abf7c55e8fa69939e6 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sun, 3 Mar 2024 11:46:47 +0000 Subject: [PATCH 08/10] feat(v2): remove I18nProvider (#371) --- examples/app/app/[locale]/layout.tsx | 6 ++---- packages/next-international/src/app/app/client.ts | 4 ---- packages/next-international/src/app/app/provider.tsx | 12 ------------ packages/next-international/src/app/app/server.ts | 4 ---- packages/next-international/src/app/app/types.ts | 9 --------- 5 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 packages/next-international/src/app/app/provider.tsx diff --git a/examples/app/app/[locale]/layout.tsx b/examples/app/app/[locale]/layout.tsx index d2bdb57..8a7456d 100644 --- a/examples/app/app/[locale]/layout.tsx +++ b/examples/app/app/[locale]/layout.tsx @@ -1,4 +1,4 @@ -import { I18nProvider, generateI18nStaticParams } from '@/locales'; +import { generateI18nStaticParams } from '@/locales'; import React from 'react'; export const dynamicParams = false; @@ -15,9 +15,7 @@ export default function RootLayout({ }>) { return ( - - {children} - + {children} ); } diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index c469955..fa459ad 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -8,7 +8,6 @@ import type { UseLocale, UseScopedI18n, } from './types'; -import { createI18nProvider } from './provider'; import { SEGMENT_NAME } from './constants'; import { useParams, useRouter } from 'next/navigation'; @@ -43,8 +42,6 @@ export function createI18n(); - const generateI18nStaticParams: GenerateI18nStaticParams = () => { return Object.keys(locales).map(locale => ({ [config.segmentName ?? SEGMENT_NAME]: locale })); }; @@ -68,7 +65,6 @@ export function createI18n() => { - const Provider: I18nProvider = ({ locale, children }) => { - return children; - }; - - return Provider; -}; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 8ab9895..74f49b6 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -8,7 +8,6 @@ import type { UseLocale, UseScopedI18n, } from './types'; -import { createI18nProvider } from './provider'; import { SEGMENT_NAME } from './constants'; // @ts-expect-error - no types import { cache } from 'react'; @@ -60,8 +59,6 @@ export function createI18n(); - const generateI18nStaticParams: GenerateI18nStaticParams = () => { return Object.keys(locales).map(locale => ({ [config.segmentName ?? SEGMENT_NAME]: locale })); }; @@ -79,7 +76,6 @@ export function createI18n = () => >( key: Key, @@ -10,13 +9,6 @@ export type UseScopedI18n = >(key: Key, ...params: Params) => string; -export type I18nProviderProps = { - locale: string; - children: ReactNode; -}; - -export type I18nProvider = (props: I18nProviderProps) => ReactNode; - export type GenerateI18nStaticParams = () => Array>; export type UseLocale = () => keyof Locales; @@ -26,7 +18,6 @@ export type UseChangeLocale = () => (locale: keyo export type CreateI18n = { useI18n: UseI18n; useScopedI18n: UseScopedI18n; - I18nProvider: I18nProvider; generateI18nStaticParams: GenerateI18nStaticParams; useLocale: UseLocale; useChangeLocale: UseChangeLocale; From 0fb093876b8f4b1d25ff7b55099299cf8254f4b0 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Sun, 3 Mar 2024 15:00:49 +0000 Subject: [PATCH 09/10] feat: add plural --- examples/app/.gitignore | 1 + examples/app/locales/fr.ts | 2 +- examples/app/locales/index.ts | 13 ++- examples/app/package.json | 10 +- packages/international-types/index.ts | 58 ++++++++-- packages/next-international/package.json | 2 +- .../next-international/src/app/app/client.ts | 75 +++++++++---- .../next-international/src/app/app/server.ts | 14 +-- .../next-international/src/app/app/types.ts | 39 ++++++- .../next-international/src/app/app/utils.ts | 106 ++++++++++++++++++ .../src/app/middleware/index.ts | 18 ++- pnpm-lock.yaml | 100 +++++++++-------- 12 files changed, 337 insertions(+), 101 deletions(-) create mode 100644 examples/app/.gitignore create mode 100644 packages/next-international/src/app/app/utils.ts diff --git a/examples/app/.gitignore b/examples/app/.gitignore new file mode 100644 index 0000000..e985853 --- /dev/null +++ b/examples/app/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/examples/app/locales/fr.ts b/examples/app/locales/fr.ts index a8e53b1..5728a7e 100644 --- a/examples/app/locales/fr.ts +++ b/examples/app/locales/fr.ts @@ -1,4 +1,4 @@ export default { 'hello.world': 'Bonjour, le monde !', - 'a.param': 'Bonjour, {name} !', + 'hello.param': 'Bonjour, {name} !', } as const; diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts index 213ef20..91b7a20 100644 --- a/examples/app/locales/index.ts +++ b/examples/app/locales/index.ts @@ -1,7 +1,14 @@ import { createI18n } from 'next-international/app'; +// import en from './en'; -export const { useI18n, useScopedI18n, I18nProvider, generateI18nStaticParams, useLocale, useChangeLocale } = - createI18n({ +export const { useI18n, useScopedI18n, generateI18nStaticParams, useLocale, useChangeLocale } = createI18n( + { en: () => import('./en'), fr: () => import('./fr'), - }); + }, + { + // segmentName: 'locale', + // basePath: '/base', + // fallbackLocale: en, + }, +); diff --git a/examples/app/package.json b/examples/app/package.json index dd7363e..4ec9f72 100644 --- a/examples/app/package.json +++ b/examples/app/package.json @@ -9,15 +9,15 @@ "lint": "next lint" }, "dependencies": { + "next": "14.1.1", + "next-international": "workspace:*", "react": "^18", - "react-dom": "^18", - "next": "14.1.0", - "next-international": "workspace:*" + "react-dom": "^18" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", - "@types/react-dom": "^18" + "@types/react-dom": "^18", + "typescript": "^5" } } diff --git a/packages/international-types/index.ts b/packages/international-types/index.ts index 95b076f..e396bff 100644 --- a/packages/international-types/index.ts +++ b/packages/international-types/index.ts @@ -9,9 +9,9 @@ type ExtractScopes< Prev extends string | undefined = undefined, > = Value extends `${infer Head}.${infer Tail}` ? [ - Prev extends string ? `${Prev}.${Head}` : Head, - ...ExtractScopes, - ] + Prev extends string ? `${Prev}.${Head}` : Head, + ...ExtractScopes, + ] : []; export type Scopes< @@ -21,9 +21,13 @@ export type Scopes< export type Keys< Locale extends LocaleType, - Scope extends Scopes | undefined = undefined, + Scope extends Scopes | undefined, TheKeys extends string = Extract, -> = Scope extends undefined ? TheKeys : TheKeys extends `${Scope}.${infer Tail}` ? Tail : never; +> = Scope extends undefined + ? RemovePlural + : TheKeys extends `${Scope}.${infer Tail}` + ? RemovePlural + : never; export type ExtractParams = Value extends '' ? [] @@ -31,19 +35,51 @@ export type ExtractParams = Value extends '' ? [Param, ...ExtractParams] : []; +export type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'; +export type RemovePlural = Key extends `${infer Head}#${PluralSuffix}` ? Head : Key; + +export type IsPlural< + Locale extends LocaleType, + Scope extends Scopes | undefined, + Key extends string, +> = Scope extends undefined + ? `${Key}#${PluralSuffix}` & keyof Locale extends never + ? false + : true + : `${Scope}.${Key}#${PluralSuffix}` & keyof Locale extends never + ? false + : true; + +export type GetPluralCount = number; + export type Params< Locale extends LocaleType, Scope extends Scopes | undefined, Key extends Keys, - Value extends string = Scope extends undefined ? Locale[Key] : Locale[`${Scope}.${Key}`], + Plural extends boolean = IsPlural, + Value extends string = Scope extends undefined + ? Plural extends true + ? Locale[`${Key}#${PluralSuffix}`] + : Locale[Key] + : Plural extends true + ? Locale[`${Scope}.${Key}#${PluralSuffix}`] + : Locale[`${Scope}.${Key}`], TheParams extends string[] = ExtractParams, -> = TheParams['length'] extends 0 +> = Plural extends true + ? TheParams['length'] extends 0 + ? [{ count: GetPluralCount }] + : [ + { count: GetPluralCount } & { + [K in TheParams[number]]: Param; + }, + ] + : TheParams['length'] extends 0 ? [] : [ - { - [K in TheParams[number]]: Param; - }, - ]; + { + [K in TheParams[number]]: Param; + }, + ]; type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never; diff --git a/packages/next-international/package.json b/packages/next-international/package.json index 32b1e5e..1bcd6ab 100644 --- a/packages/next-international/package.json +++ b/packages/next-international/package.json @@ -56,7 +56,7 @@ }, "devDependencies": { "@types/react": "^18.2.45", - "next": "^14.0.4", + "next": "^14.1.1", "react": "^18.2.0", "tsup": "^8.0.1" }, diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index fa459ad..71461d2 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -9,14 +9,17 @@ import type { UseScopedI18n, } from './types'; import { SEGMENT_NAME } from './constants'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams, useRouter, usePathname, useSearchParams } from 'next/navigation'; +// @ts-expect-error - no types +import { use } from 'react'; +import { createT } from './utils'; -function useLocaleCache() { +function useLocaleCache(config: I18nConfig) { const params = useParams(); - const locale = params.locale; + const locale = params[config.segmentName ?? SEGMENT_NAME]; if (typeof locale !== 'string') { - throw new Error('Invariant: locale params is not a string: ' + JSON.stringify(params, null, 2)); + throw new Error(`Invariant: locale not found in params: ${JSON.stringify(params, null, 2)}`); } return locale; @@ -26,20 +29,29 @@ export function createI18n { + const localesCache = new Map>(); + const useI18n: UseI18n = () => { - const locale = useLocaleCache(); + const locale = useLocaleCache(config); + const data = localesCache.get(locale) ?? use(locales[locale]()).default; - return (key, ...params) => { - return 'client: ' + locale; - }; + if (!localesCache.has(locale)) { + localesCache.set(locale, data); + } + + return (key, ...params) => createT(locale, data, undefined, key, ...params); }; const useScopedI18n: UseScopedI18n = scope => { - const locale = useLocaleCache(); + const locale = useLocaleCache(config); + const data = localesCache.get(locale) ?? use(locales[locale]()).default; - return (key, ...params) => { - return 'client: ' + locale; - }; + if (!localesCache.has(locale)) { + localesCache.set(locale, data); + } + + // @ts-expect-error - no types + return (key, ...params) => createT(locale, data, scope, key, ...params); }; const generateI18nStaticParams: GenerateI18nStaticParams = () => { @@ -47,18 +59,43 @@ export function createI18n = () => { - return useLocaleCache(); + return useLocaleCache(config); }; - const useChangeLocale: UseChangeLocale = () => { + const useChangeLocale: UseChangeLocale = changeLocaleConfig => { const router = useRouter(); + const currentLocale = useLocaleCache(config); + const path = usePathname(); + // We call the hook conditionally to avoid always opting out of Static Rendering. + // eslint-disable-next-line react-hooks/rules-of-hooks + const searchParams = changeLocaleConfig?.preserveSearchParams ? useSearchParams().toString() : undefined; + const finalSearchParams = searchParams ? `?${searchParams}` : ''; + + let pathWithoutLocale = path; + + if (config.basePath) { + pathWithoutLocale = pathWithoutLocale.replace(config.basePath, ''); + } + + if (pathWithoutLocale.startsWith(`/${currentLocale}/`)) { + pathWithoutLocale = pathWithoutLocale.replace(`/${currentLocale}/`, '/'); + } else if (pathWithoutLocale === `/${currentLocale}`) { + pathWithoutLocale = '/'; + } + + return newLocale => { + if (newLocale === currentLocale) return; + + locales[newLocale]().then(module => { + localesCache.set(newLocale as string, module.default); + + const finalLocale = newLocale as string; + localesCache.set(finalLocale, module.default); - return locale => { - const finalLocale = locale as string; - document.cookie = `locale=${finalLocale};`; + document.cookie = `locale=${finalLocale};`; - // TODO: preserve URL & search params - router.push(`/${finalLocale}`); + router.push(`/${newLocale as string}${pathWithoutLocale}${finalSearchParams}`); + }); }; }; diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 74f49b6..5780da8 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -10,8 +10,9 @@ import type { } from './types'; import { SEGMENT_NAME } from './constants'; // @ts-expect-error - no types -import { cache } from 'react'; +import { cache, use } from 'react'; import { staticGenerationAsyncStorage } from 'next/dist/client/components/static-generation-async-storage.external'; +import { createT } from './utils'; const useLocaleCache = cache(() => { const store = staticGenerationAsyncStorage.getStore(); @@ -45,18 +46,17 @@ export function createI18n { const useI18n: UseI18n = () => { const locale = useLocaleCache(); + const data = use(locales[locale]()).default; - return (key, ...params) => { - return 'server: ' + locale; - }; + return (key, ...params) => createT(locale, data, undefined, key, ...params); }; const useScopedI18n: UseScopedI18n = scope => { const locale = useLocaleCache(); + const data = use(locales[locale]()).default; - return (key, ...params) => { - return 'server: ' + locale; - }; + // @ts-expect-error - no types + return (key, ...params) => createT(locale, data, scope, key, ...params); }; const generateI18nStaticParams: GenerateI18nStaticParams = () => { diff --git a/packages/next-international/src/app/app/types.ts b/packages/next-international/src/app/app/types.ts index bb1d516..ff7ed6f 100644 --- a/packages/next-international/src/app/app/types.ts +++ b/packages/next-international/src/app/app/types.ts @@ -1,19 +1,33 @@ import type { Keys, LocaleType, LocalesObject, Params, Scopes } from 'international-types'; +import type { ReactNode } from 'react'; -export type UseI18n = () => >( +export type UseI18n = () => >( key: Key, ...params: Params -) => string; +) => string | ReactNode[]; export type UseScopedI18n = >( scope: Scope, -) => >(key: Key, ...params: Params) => string; +) => >(key: Key, ...params: Params) => string | ReactNode[]; export type GenerateI18nStaticParams = () => Array>; export type UseLocale = () => keyof Locales; -export type UseChangeLocale = () => (locale: keyof Locales) => void; +type UseChangeLocaleConfig = { + /** + * If `true`, the search params will be preserved when changing the locale. + * Don't forget to **wrap the component in a `Suspense` boundary to avoid opting out the page from Static Rendering**. + * + * @see https://nextjs.org/docs/app/api-reference/functions/use-search-params#static-rendering + * @default false + */ + preserveSearchParams?: boolean; +}; + +export type UseChangeLocale = ( + config?: UseChangeLocaleConfig, +) => (locale: keyof Locales) => void; export type CreateI18n = { useI18n: UseI18n; @@ -24,5 +38,22 @@ export type CreateI18n }; export type I18nConfig = { + /** + * The name of the Next.js layout segment param that will be used to determine the locale in a client component. + * + * An app directory folder hierarchy that looks like `app/[locale]/products/[category]/[subCategory]/page.tsx` would be `locale`. + * + * @default locale + */ segmentName?: string; + /** + * If you are using a custom basePath inside `next.config.js`, you must also specify it here. + * + * @see https://nextjs.org/docs/app/api-reference/next-config-js/basePath + */ + basePath?: string; + /** + * A locale to use if some keys aren't translated, to fallback to this locale instead of showing the translation key. + */ + fallbackLocale?: Record; }; diff --git a/packages/next-international/src/app/app/utils.ts b/packages/next-international/src/app/app/utils.ts new file mode 100644 index 0000000..f739da5 --- /dev/null +++ b/packages/next-international/src/app/app/utils.ts @@ -0,0 +1,106 @@ +import type { Keys, LocaleType, Params, Scopes } from 'international-types'; +import type { ReactNode } from 'react'; +import { cloneElement, isValidElement } from 'react'; + +function flattenLocale(locale: Record, prefix = ''): Locale { + return Object.entries(locale).reduce( + (prev, [name, value]) => ({ + ...prev, + ...(typeof value === 'string' + ? { [prefix + name]: value } + : flattenLocale(value as unknown as Locale, `${prefix}${name}.`)), + }), + {} as Locale, + ); +} + +type Cache = { + content: LocaleType; + pluralKeys: Set; +}; + +const LOCALE_CACHE = new Map(); + +export function createT< + Locale extends LocaleType, + Scope extends Scopes | undefined, + Key extends Keys, + Param extends Params, +>(locale: string, data: Locale, scope: Scope, key: Key, ...params: Param) { + let cache = LOCALE_CACHE.get(locale); + + if (!cache) { + const content = flattenLocale(data); + const pluralKeys = new Set( + Object.keys(content) + .filter(key => key.includes('#')) + .map(key => key.split('#', 1)[0]), + ); + + const newCache = { + content, + pluralKeys, + }; + + cache = newCache; + LOCALE_CACHE.set(locale, newCache); + } + + const { content, pluralKeys } = cache; + const pluralRules = new Intl.PluralRules(locale); + + function getPluralKey(count: number) { + if (count === 0) return 'zero'; + return pluralRules.select(count); + } + + const paramObject = params[0]; + let isPlural = false; + + if (paramObject && 'count' in paramObject) { + const isPluralKey = scope ? pluralKeys.has(`${scope}.${key}`) : pluralKeys.has(key); + + if (isPluralKey) { + // @ts-expect-error - no types + key = `${key}#${getPluralKey(paramObject.count)}` as Key; + isPlural = true; + } + } + + let value = scope ? content[`${scope}.${key}`] : content[key]; + + if (!value && isPlural) { + const baseKey = key.split('#', 1)[0] as Key; + value = (content[`${baseKey}#other`] || key)?.toString(); + } else { + value = (value || key)?.toString(); + } + + if (!paramObject) { + return value; + } + + let isString = true; + + const result = value?.split(/({[^}]*})/).map((part, index) => { + const match = part.match(/{(.*)}/); + + if (match) { + const param = match[1] as keyof Locale; + // @ts-expect-error - no types + const paramValue = paramObject[param]; + + if (isValidElement(paramValue)) { + isString = false; + return cloneElement(paramValue, { key: `${String(param)}-${index}` }); + } + + return paramValue as ReactNode; + } + + // if there's no match - it's not a variable and just a normal string + return part; + }); + + return isString ? result?.join('') : result; +} diff --git a/packages/next-international/src/app/middleware/index.ts b/packages/next-international/src/app/middleware/index.ts index 7990032..7d27853 100644 --- a/packages/next-international/src/app/middleware/index.ts +++ b/packages/next-international/src/app/middleware/index.ts @@ -4,8 +4,24 @@ import type { NextMiddleware, NextRequest } from 'next/server'; type I18nMiddlewareConfig = { locales: Locales; defaultLocale: Locales[number]; - resolveLocaleFromRequest?: (request: NextRequest) => string | null; + /** + * When a url is not prefixed with a locale, this setting determines whether the middleware should perform a *redirect* or *rewrite* to the default locale. + * + * **redirect**: `https://example.com/products` -> *redirect* to `https://example.com/en/products` -> client sees the locale in the url + * + * **rewrite**: `https://example.com/products` -> *rewrite* to `https://example.com/en/products` -> client doesn't see the locale in the url + * + * **rewriteDefault**: `https://example.com/products` -> use *rewrite* for the default locale, *redirect* for all other locales + * + * @default redirect + */ urlMappingStrategy?: 'redirect' | 'rewrite' | 'rewriteDefault'; + /** + * Override the resolution of a locale from a `Request`, which by default will try to extract it from the `Accept-Language` header. This can be useful to force the use of a specific locale regardless of the `Accept-Language` header. + * + * @description This function will only be called if the user doesn't already have a `Next-Locale` cookie. + */ + resolveLocaleFromRequest?: (request: NextRequest) => string | null; }; function getLocaleFromRequest( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f82dce..0fd7c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,8 +130,8 @@ importers: examples/app: dependencies: next: - specifier: 14.1.0 - version: 14.1.0(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + specifier: 14.1.1 + version: 14.1.1(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) next-international: specifier: workspace:* version: link:../../packages/next-international @@ -235,8 +235,8 @@ importers: specifier: ^18.2.45 version: 18.2.45 next: - specifier: ^14.0.4 - version: 14.0.4(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) + specifier: ^14.1.1 + version: 14.1.1(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -2401,11 +2401,11 @@ packages: /@next/env@14.0.4: resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} - - /@next/env@14.1.0: - resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==} dev: false + /@next/env@14.1.1: + resolution: {integrity: sha512-7CnQyD5G8shHxQIIg3c7/pSeYFeMhsNbpU/bmvH7ZnDql7mNRgg8O2JZrhrc/soFnfBnKP4/xXNiiSIPn2w8gA==} + /@next/eslint-plugin-next@13.5.6: resolution: {integrity: sha512-ng7pU/DDsxPgT6ZPvuprxrkeew3XaRf4LAT4FabaEO/hAbvVx4P7wqnqdbTdDn1kgTvsI4tpIgT4Awn/m0bGbg==} dependencies: @@ -2418,15 +2418,15 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: false optional: true - /@next/swc-darwin-arm64@14.1.0: - resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==} + /@next/swc-darwin-arm64@14.1.1: + resolution: {integrity: sha512-yDjSFKQKTIjyT7cFv+DqQfW5jsD+tVxXTckSe1KIouKk75t1qZmj/mV3wzdmFb0XHVGtyRjDMulfVG8uCKemOQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] requiresBuild: true - dev: false optional: true /@next/swc-darwin-x64@14.0.4: @@ -2435,15 +2435,15 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: false optional: true - /@next/swc-darwin-x64@14.1.0: - resolution: {integrity: sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==} + /@next/swc-darwin-x64@14.1.1: + resolution: {integrity: sha512-KCQmBL0CmFmN8D64FHIZVD9I4ugQsDBBEJKiblXGgwn7wBCSe8N4Dx47sdzl4JAg39IkSN5NNrr8AniXLMb3aw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] requiresBuild: true - dev: false optional: true /@next/swc-linux-arm64-gnu@14.0.4: @@ -2452,15 +2452,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true - /@next/swc-linux-arm64-gnu@14.1.0: - resolution: {integrity: sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==} + /@next/swc-linux-arm64-gnu@14.1.1: + resolution: {integrity: sha512-YDQfbWyW0JMKhJf/T4eyFr4b3tceTorQ5w2n7I0mNVTFOvu6CGEzfwT3RSAQGTi/FFMTFcuspPec/7dFHuP7Eg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@next/swc-linux-arm64-musl@14.0.4: @@ -2469,15 +2469,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true - /@next/swc-linux-arm64-musl@14.1.0: - resolution: {integrity: sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==} + /@next/swc-linux-arm64-musl@14.1.1: + resolution: {integrity: sha512-fiuN/OG6sNGRN/bRFxRvV5LyzLB8gaL8cbDH5o3mEiVwfcMzyE5T//ilMmaTrnA8HLMS6hoz4cHOu6Qcp9vxgQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] requiresBuild: true - dev: false optional: true /@next/swc-linux-x64-gnu@14.0.4: @@ -2486,15 +2486,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true - /@next/swc-linux-x64-gnu@14.1.0: - resolution: {integrity: sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==} + /@next/swc-linux-x64-gnu@14.1.1: + resolution: {integrity: sha512-rv6AAdEXoezjbdfp3ouMuVqeLjE1Bin0AuE6qxE6V9g3Giz5/R3xpocHoAi7CufRR+lnkuUjRBn05SYJ83oKNQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@next/swc-linux-x64-musl@14.0.4: @@ -2503,15 +2503,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true - /@next/swc-linux-x64-musl@14.1.0: - resolution: {integrity: sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==} + /@next/swc-linux-x64-musl@14.1.1: + resolution: {integrity: sha512-YAZLGsaNeChSrpz/G7MxO3TIBLaMN8QWMr3X8bt6rCvKovwU7GqQlDu99WdvF33kI8ZahvcdbFsy4jAFzFX7og==} engines: {node: '>= 10'} cpu: [x64] os: [linux] requiresBuild: true - dev: false optional: true /@next/swc-win32-arm64-msvc@14.0.4: @@ -2520,15 +2520,15 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: false optional: true - /@next/swc-win32-arm64-msvc@14.1.0: - resolution: {integrity: sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==} + /@next/swc-win32-arm64-msvc@14.1.1: + resolution: {integrity: sha512-1L4mUYPBMvVDMZg1inUYyPvFSduot0g73hgfD9CODgbr4xiTYe0VOMTZzaRqYJYBA9mana0x4eaAaypmWo1r5A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] requiresBuild: true - dev: false optional: true /@next/swc-win32-ia32-msvc@14.0.4: @@ -2537,15 +2537,15 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: false optional: true - /@next/swc-win32-ia32-msvc@14.1.0: - resolution: {integrity: sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==} + /@next/swc-win32-ia32-msvc@14.1.1: + resolution: {integrity: sha512-jvIE9tsuj9vpbbXlR5YxrghRfMuG0Qm/nZ/1KDHc+y6FpnZ/apsgh+G6t15vefU0zp3WSpTMIdXRUsNl/7RSuw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] requiresBuild: true - dev: false optional: true /@next/swc-win32-x64-msvc@14.0.4: @@ -2554,15 +2554,15 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: false optional: true - /@next/swc-win32-x64-msvc@14.1.0: - resolution: {integrity: sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==} + /@next/swc-win32-x64-msvc@14.1.1: + resolution: {integrity: sha512-S6K6EHDU5+1KrBDLko7/c1MNy/Ya73pIAmvKeFwsF4RmBFJSO7/7YeD4FnZ4iBdzE69PpQ4sOMU9ORKeNuxe8A==} engines: {node: '>= 10'} cpu: [x64] os: [win32] requiresBuild: true - dev: false optional: true /@nodelib/fs.scandir@2.1.5: @@ -3681,7 +3681,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001509 + caniuse-lite: 1.0.30001587 electron-to-chromium: 1.4.447 node-releases: 2.0.12 update-browserslist-db: 1.0.11(browserslist@4.21.9) @@ -3733,10 +3733,10 @@ packages: /caniuse-lite@1.0.30001509: resolution: {integrity: sha512-2uDDk+TRiTX5hMcUYT/7CSyzMZxjfGu0vAUjS2g0LSD8UoXOv0LtpH4LxGMemsiPq6LCVIUjNwVM0erkOkGCDA==} + dev: false /caniuse-lite@1.0.30001587: resolution: {integrity: sha512-HMFNotUmLXn71BQxg8cijvqxnIAofforZOwGsxyXJ0qugTdspUF4sPSJ2vhgprHCB996tIDzEq1ubumPDV8ULA==} - dev: false /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -5476,6 +5476,7 @@ packages: /glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: false /glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} @@ -7370,9 +7371,10 @@ packages: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros + dev: false - /next@14.1.0(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==} + /next@14.1.1(@babel/core@7.23.2)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-McrGJqlGSHeaz2yTRPkEucxQKe5Zq7uPwyeHNmJaZNY4wx9E9QdxmTp310agFRoMuIYgQrCrT3petg13fSVOww==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -7386,7 +7388,7 @@ packages: sass: optional: true dependencies: - '@next/env': 14.1.0 + '@next/env': 14.1.1 '@swc/helpers': 0.5.2 busboy: 1.6.0 caniuse-lite: 1.0.30001587 @@ -7396,19 +7398,18 @@ packages: react-dom: 18.2.0(react@18.2.0) styled-jsx: 5.1.1(@babel/core@7.23.2)(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 14.1.0 - '@next/swc-darwin-x64': 14.1.0 - '@next/swc-linux-arm64-gnu': 14.1.0 - '@next/swc-linux-arm64-musl': 14.1.0 - '@next/swc-linux-x64-gnu': 14.1.0 - '@next/swc-linux-x64-musl': 14.1.0 - '@next/swc-win32-arm64-msvc': 14.1.0 - '@next/swc-win32-ia32-msvc': 14.1.0 - '@next/swc-win32-x64-msvc': 14.1.0 + '@next/swc-darwin-arm64': 14.1.1 + '@next/swc-darwin-x64': 14.1.1 + '@next/swc-linux-arm64-gnu': 14.1.1 + '@next/swc-linux-arm64-musl': 14.1.1 + '@next/swc-linux-x64-gnu': 14.1.1 + '@next/swc-linux-x64-musl': 14.1.1 + '@next/swc-win32-arm64-msvc': 14.1.1 + '@next/swc-win32-ia32-msvc': 14.1.1 + '@next/swc-win32-x64-msvc': 14.1.1 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - dev: false /nextra-theme-docs@2.13.2(next@14.0.4)(nextra@2.13.2)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-yE4umXaImp1/kf/sFciPj2+EFrNSwd9Db26hi98sIIiujzGf3+9eUgAz45vF9CwBw50FSXxm1QGRcY+slQ4xQQ==} @@ -9378,6 +9379,7 @@ packages: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + dev: false /web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} From 63737f44996092fd38674a44b7b60fdaa1502951 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Mon, 4 Mar 2024 11:49:28 +0000 Subject: [PATCH 10/10] refactor: i18n APIs return promises --- .../app/app/[locale]/client-component.tsx | 11 +++-- examples/app/app/[locale]/page.tsx | 15 +++--- examples/app/locales/index.ts | 2 +- .../next-international/src/app/app/client.ts | 47 +++++++------------ .../next-international/src/app/app/server.ts | 30 ++++++------ .../next-international/src/app/app/types.ts | 19 ++++---- 6 files changed, 59 insertions(+), 65 deletions(-) diff --git a/examples/app/app/[locale]/client-component.tsx b/examples/app/app/[locale]/client-component.tsx index 2a2078c..490f656 100644 --- a/examples/app/app/[locale]/client-component.tsx +++ b/examples/app/app/[locale]/client-component.tsx @@ -1,12 +1,13 @@ 'use client'; -import React from 'react'; -import { useChangeLocale, useI18n, useLocale, useScopedI18n } from '@/locales'; +// @ts-expect-error - missing import +import React, { use } from 'react'; +import { useChangeLocale, getI18n, getLocale, getScopedI18n } from '@/locales'; export function ClientComponent() { - const t = useI18n(); - const scopedT = useScopedI18n('hello'); - const locale = useLocale(); + const t = use(getI18n()); + const scopedT = use(getScopedI18n('hello')); + const locale = getLocale(); const changeLocale = useChangeLocale(); return ( diff --git a/examples/app/app/[locale]/page.tsx b/examples/app/app/[locale]/page.tsx index 911eadb..55eeeb3 100644 --- a/examples/app/app/[locale]/page.tsx +++ b/examples/app/app/[locale]/page.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { useI18n, useLocale, useScopedI18n } from '@/locales'; +import { getI18n, getLocale, getScopedI18n } from '@/locales'; import { ClientComponent } from './client-component'; +import { Suspense } from 'react'; -export default function Home() { - const t = useI18n(); - const scopedT = useScopedI18n('hello'); - const locale = useLocale(); +export default async function Home() { + const t = await getI18n(); + const scopedT = await getScopedI18n('hello'); + const locale = getLocale(); return (
@@ -23,7 +24,9 @@ export default function Home() {

Current locale: {locale}


- + + +
); } diff --git a/examples/app/locales/index.ts b/examples/app/locales/index.ts index 91b7a20..d50d6cb 100644 --- a/examples/app/locales/index.ts +++ b/examples/app/locales/index.ts @@ -1,7 +1,7 @@ import { createI18n } from 'next-international/app'; // import en from './en'; -export const { useI18n, useScopedI18n, generateI18nStaticParams, useLocale, useChangeLocale } = createI18n( +export const { getI18n, getScopedI18n, generateI18nStaticParams, getLocale, useChangeLocale } = createI18n( { en: () => import('./en'), fr: () => import('./fr'), diff --git a/packages/next-international/src/app/app/client.ts b/packages/next-international/src/app/app/client.ts index 71461d2..aa05eec 100644 --- a/packages/next-international/src/app/app/client.ts +++ b/packages/next-international/src/app/app/client.ts @@ -4,17 +4,16 @@ import type { GenerateI18nStaticParams, I18nConfig, UseChangeLocale, - UseI18n, + GetI18n, UseLocale, - UseScopedI18n, + GetScopedI18n, } from './types'; import { SEGMENT_NAME } from './constants'; import { useParams, useRouter, usePathname, useSearchParams } from 'next/navigation'; -// @ts-expect-error - no types -import { use } from 'react'; import { createT } from './utils'; -function useLocaleCache(config: I18nConfig) { +function getLocaleCache(config: I18nConfig) { + // eslint-disable-next-line react-hooks/rules-of-hooks const params = useParams(); const locale = params[config.segmentName ?? SEGMENT_NAME]; @@ -31,24 +30,17 @@ export function createI18n { const localesCache = new Map>(); - const useI18n: UseI18n = () => { - const locale = useLocaleCache(config); - const data = localesCache.get(locale) ?? use(locales[locale]()).default; - - if (!localesCache.has(locale)) { - localesCache.set(locale, data); - } + const getI18n: GetI18n = async () => { + const locale = getLocaleCache(config); + const data = localesCache.get(locale) ?? (await locales[locale]()).default; + // @ts-expect-error - no types return (key, ...params) => createT(locale, data, undefined, key, ...params); }; - const useScopedI18n: UseScopedI18n = scope => { - const locale = useLocaleCache(config); - const data = localesCache.get(locale) ?? use(locales[locale]()).default; - - if (!localesCache.has(locale)) { - localesCache.set(locale, data); - } + const getScopedI18n: GetScopedI18n = async scope => { + const locale = getLocaleCache(config); + const data = localesCache.get(locale) ?? (await locales[locale]()).default; // @ts-expect-error - no types return (key, ...params) => createT(locale, data, scope, key, ...params); @@ -58,13 +50,13 @@ export function createI18n ({ [config.segmentName ?? SEGMENT_NAME]: locale })); }; - const useLocale: UseLocale = () => { - return useLocaleCache(config); + const getLocale: UseLocale = () => { + return getLocaleCache(config); }; const useChangeLocale: UseChangeLocale = changeLocaleConfig => { const router = useRouter(); - const currentLocale = useLocaleCache(config); + const currentLocale = getLocaleCache(config); const path = usePathname(); // We call the hook conditionally to avoid always opting out of Static Rendering. // eslint-disable-next-line react-hooks/rules-of-hooks @@ -87,23 +79,20 @@ export function createI18n { - localesCache.set(newLocale as string, module.default); - const finalLocale = newLocale as string; - localesCache.set(finalLocale, module.default); + localesCache.set(finalLocale, module.default); document.cookie = `locale=${finalLocale};`; - router.push(`/${newLocale as string}${pathWithoutLocale}${finalSearchParams}`); }); }; }; return { - useI18n, - useScopedI18n, + getI18n, + getScopedI18n, generateI18nStaticParams, - useLocale, + getLocale, useChangeLocale, }; } diff --git a/packages/next-international/src/app/app/server.ts b/packages/next-international/src/app/app/server.ts index 5780da8..0264853 100644 --- a/packages/next-international/src/app/app/server.ts +++ b/packages/next-international/src/app/app/server.ts @@ -4,17 +4,17 @@ import type { GenerateI18nStaticParams, I18nConfig, UseChangeLocale, - UseI18n, + GetI18n, UseLocale, - UseScopedI18n, + GetScopedI18n, } from './types'; import { SEGMENT_NAME } from './constants'; // @ts-expect-error - no types -import { cache, use } from 'react'; +import { cache } from 'react'; import { staticGenerationAsyncStorage } from 'next/dist/client/components/static-generation-async-storage.external'; import { createT } from './utils'; -const useLocaleCache = cache(() => { +const getLocaleCache = cache(() => { const store = staticGenerationAsyncStorage.getStore(); const url = store?.urlPathname; @@ -44,16 +44,16 @@ export function createI18n { - const useI18n: UseI18n = () => { - const locale = useLocaleCache(); - const data = use(locales[locale]()).default; + const getI18n: GetI18n = async () => { + const locale = getLocaleCache(); + const data = (await locales[locale]()).default; return (key, ...params) => createT(locale, data, undefined, key, ...params); }; - const useScopedI18n: UseScopedI18n = scope => { - const locale = useLocaleCache(); - const data = use(locales[locale]()).default; + const getScopedI18n: GetScopedI18n = async scope => { + const locale = getLocaleCache(); + const data = (await locales[locale]()).default; // @ts-expect-error - no types return (key, ...params) => createT(locale, data, scope, key, ...params); @@ -63,8 +63,8 @@ export function createI18n ({ [config.segmentName ?? SEGMENT_NAME]: locale })); }; - const useLocale: UseLocale = () => { - return useLocaleCache(); + const getLocale: UseLocale = () => { + return getLocaleCache(); }; const useChangeLocale: UseChangeLocale = () => { @@ -74,10 +74,10 @@ export function createI18n = () => >( - key: Key, - ...params: Params -) => string | ReactNode[]; +export type GetI18n = () => Promise< + >(key: Key, ...params: Params) => string | ReactNode[] +>; -export type UseScopedI18n = >( +export type GetScopedI18n = >( scope: Scope, -) => >(key: Key, ...params: Params) => string | ReactNode[]; +) => Promise< + >(key: Key, ...params: Params) => string | ReactNode[] +>; export type GenerateI18nStaticParams = () => Array>; @@ -30,10 +31,10 @@ export type UseChangeLocale = ( ) => (locale: keyof Locales) => void; export type CreateI18n = { - useI18n: UseI18n; - useScopedI18n: UseScopedI18n; + getI18n: GetI18n; + getScopedI18n: GetScopedI18n; generateI18nStaticParams: GenerateI18nStaticParams; - useLocale: UseLocale; + getLocale: UseLocale; useChangeLocale: UseChangeLocale; };