diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 09e61d078..8f1cdb6ff 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ on: jobs: build: name: Build, lint, and test - runs-on: ubuntu-latest + runs-on: macos-15 env: TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease-canary.yml similarity index 87% rename from .github/workflows/prerelease.yml rename to .github/workflows/prerelease-canary.yml index 19a49df2a..5468e5145 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease-canary.yml @@ -1,4 +1,4 @@ -name: prerelease +name: prerelease (canary) on: push: @@ -26,7 +26,7 @@ jobs: - run: | sed -i 's/"use-intl": "workspace:\^"/"use-intl": "workspace:"/' ./packages/next-intl/package.json && git commit -am "use fixed version" - run: pnpm lerna publish 0.0.0-canary-${GITHUB_SHA::7} --no-git-reset --dist-tag canary --no-push --yes - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6473ff40..9e251c2d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: git config --global user.name "${{ github.actor }}" git config --global user.email "${{ github.actor }}@users.noreply.github.com" - run: pnpm run publish - if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ')}}" + if: "${{startsWith(github.event.head_commit.message, 'fix: ') || startsWith(github.event.head_commit.message, 'feat: ') || startsWith(github.event.head_commit.message, 'feat!: ')}}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8a14edf8d..924523876 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -86,7 +86,7 @@ Note that the exclamation mark syntax (`!`) for indicating breaking changes is c Other prefixes that are allowed and will _not_ create a release are the following: -1. `docs`: Documentation-only changes +1. `docs`: Documentation-only changes and updated examples 2. `test`: Missing tests were added or existing ones corrected 3. `ci`: Changes to CI configuration files and scripts 4. `build`: Changes that affect the build system or external dependencies diff --git a/docs/src/components/Footer.tsx b/docs/src/components/Footer.tsx index 81b28f852..8e51c832a 100644 --- a/docs/src/components/Footer.tsx +++ b/docs/src/components/Footer.tsx @@ -2,6 +2,7 @@ import {useRouter} from 'next/router'; import config from '@/config'; import FooterLink from './FooterLink'; import FooterSeparator from './FooterSeparator'; +import FooterVersionSelector from './FooterVersionSelector'; export default function Footer() { const router = useRouter(); @@ -19,6 +20,8 @@ export default function Footer() { Examples Blog + +
diff --git a/docs/src/components/FooterVersionSelector.tsx b/docs/src/components/FooterVersionSelector.tsx new file mode 100644 index 000000000..ee21de8c8 --- /dev/null +++ b/docs/src/components/FooterVersionSelector.tsx @@ -0,0 +1,19 @@ +import {ChangeEvent} from 'react'; + +export default function FooterVersionSelector() { + function onChange(event: ChangeEvent) { + const version = event.target.value; + window.location.href = `https://${version}.next-intl.dev`; + } + + return ( + + ); +} diff --git a/docs/src/pages/blog/_meta.tsx b/docs/src/pages/blog/_meta.tsx index d2289e251..badbdc76f 100644 --- a/docs/src/pages/blog/_meta.tsx +++ b/docs/src/pages/blog/_meta.tsx @@ -3,7 +3,7 @@ export default { title: 'Overview' }, 'next-intl-4-0': { - title: 'next-intl 4.0 beta', + title: 'next-intl 4.0', display: 'hidden' }, 'next-intl-3-22': { diff --git a/docs/src/pages/blog/index.mdx b/docs/src/pages/blog/index.mdx index 5bbe075db..736da7080 100644 --- a/docs/src/pages/blog/index.mdx +++ b/docs/src/pages/blog/index.mdx @@ -6,8 +6,8 @@ import StayUpdated from '@/components/StayUpdated.mdx';
Dec 23, 2024 · by Jan Amann +Mar 12, 2025 · by Jan Amann After a year of feature development, this release focuses on streamlining the API surface while maintaining the core architecture of `next-intl`. With many improvements already released in [previous minor versions](/blog/next-intl-3-22), this update introduces several enhancements that will improve your development experience and make working with internationalization even more seamless. @@ -44,7 +44,7 @@ declare module 'next-intl' { } ``` -See the updated [TypeScript augmentation](https://v4.next-intl.dev/docs/workflows/typescript) guide. +See the updated [TypeScript augmentation](/docs/workflows/typescript) guide. ## Strictly-typed locale @@ -65,7 +65,7 @@ declare module 'next-intl' { By doing so, APIs like `useLocale()` or `` that either return or receive a `locale` will now pick up your app-specific `Locale` type, improving type safety across your app. -To simplify narrowing of `string`-based locales, a `hasLocale` function has been added. This can for example be used in [`i18n/request.ts`](https://v4.next-intl.dev/docs/getting-started/app-router/with-i18n-routing#i18n-request) to return a valid locale: +To simplify narrowing of `string`-based locales, a `hasLocale` function has been added. This can for example be used in [`i18n/request.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-request) to return a valid locale: ```tsx import {getRequestConfig} from 'next-intl/server'; @@ -148,13 +148,13 @@ t('followers', {count: 30000}); "{count, number} followers" ``` -Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](https://v4.next-intl.dev/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. +Due to a current limitation in TypeScript, this feature is opt-in for now. Please refer to the [strict arguments](/docs/workflows/typescript#messages-arguments) docs to learn how to enable it. ## GDPR compliance [#gdpr-compliance] In order to comply with the current GDPR regulations, the following changes have been made and are relevant to you if you're using the `next-intl` middleware for i18n routing: -1. The locale cookie now defaults to a session cookie that expires when a browser is closed. +1. The locale cookie now defaults to a session cookie that expires when the browser is closed. 2. The locale cookie is now only set when a user switches to a locale that doesn't match the `accept-language` header. If you want to increase the cookie expiration, e.g. because you're informing users about the usage of cookies or if GDPR doesn't apply to your app, you can use the `maxAge` attribute to do so: @@ -174,11 +174,11 @@ export const routing = defineRouting({ }); ``` -Since the cookie is now only available after a locale switch, make sure to not rely on it always being present. E.g. if you need access to the user's locale in a [Route Handler](https://v4.next-intl.dev/docs/environments/actions-metadata-route-handlers#route-handlers), a reliable option is to provide the locale as a search param (e.g. `/api/posts/12?locale=en`). +Since the cookie is now only available after a locale switch, make sure to not rely on it always being present. E.g. if you need access to the user's locale in a [Route Handler](/docs/environments/actions-metadata-route-handlers#route-handlers), a reliable option is to provide the locale as a search param (e.g. `/api/posts/12?locale=en`). -As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](https://v4.next-intl.dev/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. +As part of this change, disabling a cookie now requires you to set [`localeCookie: false`](/docs/routing#locale-cookie) in your routing configuration. Previously, `localeDetection: false` ambiguously also disabled the cookie from being set, but since a separate `localeCookie` option was introduced recently, this should now be used instead. -Learn more in the [locale cookie](https://v4.next-intl.dev/docs/routing#locale-cookie) docs. +Learn more in the [locale cookie](/docs/routing#locale-cookie) docs. ## Modernized build output @@ -266,7 +266,7 @@ This will create the following structure: - `example.no`: `no-NO` - `example.no/en`: `en-NO` -Learn more in the updated docs for [`domains`](https://v4.next-intl.dev/docs/routing#domains). +Learn more in the updated docs for [`domains`](/docs/routing#domains). ## Preparation for upcoming Next.js features [#nextjs-future] @@ -300,11 +300,9 @@ For a smooth upgrade, please initially upgrade to the latest v3.x version and ch Afterwards, you can upgrade by running: ``` -npm install next-intl@v4-beta +npm install next-intl@4 ``` -The beta docs are available here: [v4.next-intl.dev](https://v4.next-intl.dev) - I'd love to hear about your experiences with `next-intl@4.0`! Join the conversation in the [discussions](https://github.com/amannn/next-intl/discussions/1631). ## Thank you! @@ -315,6 +313,8 @@ A special thank you goes to Crow —Jan +(this post has been updated from an initial announcement for the 3.0 release candidate) + PS: Have you heard that [learn.next-intl.dev](https://learn.next-intl.dev) is coming? diff --git a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx index 1111998f5..1a7cd17bf 100644 --- a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx +++ b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx @@ -173,8 +173,9 @@ Note that by default, `next-intl` returns [the `link` response header](/docs/rou Next.js supports providing alternate URLs per language via the [`alternates` entry](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generate-a-localized-sitemap). You can construct a list of entries for each pathname and locale as follows: -```tsx filename="app/sitemap.ts" {4-5,8-9} +```tsx filename="app/sitemap.ts" {5-6,9-10} import {MetadataRoute} from 'next'; +import {Locale} from 'next-intl'; import {routing, getPathname} from '@/i18n/routing'; // Adapt this as necessary @@ -198,7 +199,7 @@ function getEntries(href: Href) { })); } -function getUrl(href: Href, locale: (typeof routing.locales)[number]) { +function getUrl(href: Href, locale: Locale) { const pathname = getPathname({locale, href}); return host + pathname; } @@ -230,12 +231,17 @@ You can use `next-intl` in [Route Handlers](https://nextjs.org/docs/app/building ```tsx filename="app/api/hello/route.tsx" import {NextResponse} from 'next/server'; +import {hasLocale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; +import {routing} from '@/i18n/routing'; export async function GET(request) { // Example: Receive the `locale` via a search param const {searchParams} = new URL(request.url); const locale = searchParams.get('locale'); + if (!hasLocale(routing.locales, locale)) { + return NextResponse.json({error: 'Invalid locale'}, {status: 400}); + } const t = await getTranslations({locale, namespace: 'Hello'}); return NextResponse.json({title: t('title')}); diff --git a/docs/src/pages/docs/environments/error-files.mdx b/docs/src/pages/docs/environments/error-files.mdx index 1d6c60190..c8f4001c6 100644 --- a/docs/src/pages/docs/environments/error-files.mdx +++ b/docs/src/pages/docs/environments/error-files.mdx @@ -77,16 +77,16 @@ export default function RootLayout({children}) { } ``` -For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't a valid locale. +For the 404 page to render, we need to call the `notFound` function in the root layout when we detect an incoming `locale` param that isn't valid. ```tsx filename="app/[locale]/layout.tsx" +import {hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; +import {routing} from '@/i18n/routing'; -export default async function LocaleLayout({children, params}) { +export default function LocaleLayout({children, params}) { const {locale} = await params; - - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/docs/src/pages/docs/environments/server-client-components.mdx b/docs/src/pages/docs/environments/server-client-components.mdx index 0c8a1dbca..865e5201b 100644 --- a/docs/src/pages/docs/environments/server-client-components.mdx +++ b/docs/src/pages/docs/environments/server-client-components.mdx @@ -69,7 +69,7 @@ These functions are available: Components that aren't declared with the `async` keyword and don't use interactive features like `useState`, are referred to as [shared components](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#sharing-code-between-server-and-client). These can render either as a Server or Client Component, depending on where they are imported from. -In Next.js, Server Components are the default, and therefore shared components will typically execute as Server Components. +In Next.js, Server Components are the default, and therefore shared components will typically execute as Server Components: ```tsx filename="UserDetails.tsx" import {useTranslations} from 'next-intl'; @@ -77,6 +77,9 @@ import {useTranslations} from 'next-intl'; export default function UserDetails({user}) { const t = useTranslations('UserProfile'); + // This component will execute as a Server Component by default. + // However, if it is imported from a Client Component, it will + // execute as a Client Component. return (

{t('title')}

@@ -112,11 +115,13 @@ Regarding performance, async functions and hooks can be used interchangeably. Th ## Using internationalization in Client Components -Depending on your situation, you may need to handle internationalization in Client Components. While providing all messages to the client side is typically the easiest way to [get started](/docs/getting-started/app-router#layout) and a reasonable approach for many apps, you can be more selective about which messages are passed to the client side if you're interested in optimizing the performance of your app. +Depending on your situation, you may need to handle internationalization in Client Components. Providing all messages to the client side is the easiest way to get started, therefore `next-intl` automatically does this when you render [`NextIntlClientProvider`](/docs/usage/configuration#nextintlclientprovider). This is a reasonable approach for many apps. + +However, you can be more selective about which messages are passed to the client side if you're interested in optimizing the performance of your app. There are several options for using translations from `next-intl` in Client Components, listed here in order of enabling the best performance: -### Option 1: Passing translations to Client Components +### Option 1: Passing translated labels to Client Components The preferred approach is to pass the processed labels as props or `children` from a Server Component. @@ -275,8 +280,6 @@ In particular, page and search params are often a great option because they offe ### Option 3: Providing individual messages -To reduce bundle size, `next-intl` doesn't automatically provide [messages](/docs/usage/configuration#messages) or [formats](/docs/usage/configuration#formats) to Client Components. - If you need to incorporate dynamic state into components that can not be moved to the server side, you can wrap these components with `NextIntlClientProvider` and provide the relevant messages. ```tsx filename="Counter.tsx" @@ -312,22 +315,16 @@ An automatic, compiler-driven approach is being evaluated in [`next-intl#1`](htt ### Option 4: Providing all messages -If you're building a highly dynamic app where most components use React's interactive features, you may prefer to make all messages available to Client Components. +If you're building a highly dynamic app where most components use React's interactive features, you may prefer to make all messages available to Client Components—this is the default behavior of `next-intl`. ```tsx filename="layout.tsx" /NextIntlClientProvider/ import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; export default async function RootLayout(/* ... */) { - // Receive messages provided in `i18n/request.ts` - const messages = await getMessages(); - return ( - - {children} - + {children} ); @@ -366,7 +363,6 @@ The component accepts the following props that are not serializable: 1. [`onError`](/docs/usage/configuration#error-handling) 2. [`getMessageFallback`](/docs/usage/configuration#error-handling) -3. Rich text elements for [`defaultTranslationValues`](/docs/usage/configuration#default-translation-values) To configure these, you can wrap `NextIntlClientProvider` with another component that is marked with `'use client'` and defines the relevant props. diff --git a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx index e1cc96fe4..45adc4f02 100644 --- a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -62,7 +62,7 @@ The simplest option is to add JSON files in your local project folder: ### `next.config.ts` [#next-config] -Now, set up the plugin which creates an alias to provide a request-specific i18n configuration to Server Components—more on this in the following steps. +Now, set up the plugin which creates an alias to provide a request-specific i18n configuration like your messages to Server Components—more on this in the following steps. @@ -128,6 +128,8 @@ Once we have our routing configuration in place, we can use it to set up the nav import {createNavigation} from 'next-intl/navigation'; import {routing} from './routing'; +// Lightweight wrappers around Next.js' navigation +// APIs that consider the routing configuration export const {Link, redirect, usePathname, useRouter, getPathname} = createNavigation(routing); ``` @@ -143,27 +145,53 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; ``` +
+ How can I match pathnames that contain dots like `/users/jane.doe`? + +If you have pathnames where dots are expected, you can match them with explicit entries: + +```tsx filename="src/middleware.ts" {10,11} +// ... + +export const config = { + matcher: [ + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + '/((?!api|trpc|_next|_vercel|.*\\..*).*)' + + // Match all pathnames within `{/:locale}/users` + '/([\\w-]+)?/users/(.+)' + ]; +} +``` + +This will match e.g. `/users/jane.doe`, also optionally with a locale prefix. + +
+ ### `src/i18n/request.ts` [#i18n-request] When using features from `next-intl` in Server Components, the relevant configuration is read from a central module that is located at `i18n/request.ts` by convention. This configuration is scoped to the current request and can be used to provide messages and other options based on the user's locale. ```tsx filename="src/i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that a valid locale is used - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, @@ -193,8 +221,7 @@ const withNextIntl = createNextIntlPlugin( The `locale` that was matched by the middleware is available via the `locale` param and can be used to configure the document language. Additionally, we can use this place to pass configuration from `i18n/request.ts` to Client Components via `NextIntlClientProvider`. ```tsx filename="app/[locale]/layout.tsx" -import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; +import {NextIntlClientProvider, Locale, hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; @@ -207,28 +234,20 @@ export default async function LocaleLayout({ }) { // Ensure that the incoming `locale` is valid const {locale} = await params; - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - - {children} - + {children} ); } ``` -Note that `NextIntlClientProvider` automatically inherits configuration from `i18n/request.ts` here, but `messages` need to be passed explicitly. - ### `src/app/[locale]/page.tsx` [#page] And that's it! @@ -305,14 +324,13 @@ export function generateStaticParams() { ```tsx filename="app/[locale]/layout.tsx" import {setRequestLocale} from 'next-intl/server'; +import {hasLocale} from 'next-intl'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; export default async function LocaleLayout({children, params}) { const {locale} = await params; - - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx index 890ccad34..af25be7c0 100644 --- a/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx @@ -128,7 +128,7 @@ The `locale` that was provided in `i18n/request.ts` is available via `getLocale` ```tsx filename="app/layout.tsx" import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; export default async function RootLayout({ children @@ -137,24 +137,16 @@ export default async function RootLayout({ }) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - - {children} - + {children} ); } ``` -Note that `NextIntlClientProvider` automatically inherits configuration from `i18n/request.ts` here, but `messages` need to be passed explicitly. - ### `app/page.tsx` [#page] Use translations in your page components or anywhere else! diff --git a/docs/src/pages/docs/getting-started/pages-router.mdx b/docs/src/pages/docs/getting-started/pages-router.mdx index 4c7a5a056..9261760b2 100644 --- a/docs/src/pages/docs/getting-started/pages-router.mdx +++ b/docs/src/pages/docs/getting-started/pages-router.mdx @@ -109,9 +109,3 @@ export async function getStaticProps() { - -## Support for legacy Next.js versions - -Next.js version 10, 11 and 12 are still supported. Note however that instead of installing `next-intl`, you'll have to import functionality like `useTranslations` from [`use-intl`](/docs/environments/core-library#react-apps). - -See the [legacy example](https://github.com/amannn/next-intl/tree/main/examples/example-pages-router-legacy). diff --git a/docs/src/pages/docs/routing.mdx b/docs/src/pages/docs/routing.mdx index 4f295f42c..f22f60b55 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -63,13 +63,6 @@ export const routing = defineRouting({ }); ``` -
-How can I redirect unprefixed pathnames? - -If you want to redirect unprefixed pathnames like `/about` to a prefixed alternative like `/en/about`, you can adjust your middleware matcher to [match unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix) too. - -
- #### Don't use a locale prefix for the default locale [#locale-prefix-as-needed] If you want to use no prefix for the default locale (e.g. `/about`), you can configure your routing accordingly: @@ -83,9 +76,10 @@ export const routing = defineRouting({ }); ``` -**Important**: For this routing strategy to work as expected, you should additionally adapt your middleware matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). +**Note that:** -Note that if a superfluous locale prefix like `/en/about` is requested, the middleware will automatically redirect to the unprefixed version `/about`. This can be helpful in case you're redirecting from another locale and you want to update a potential cookie value first (e.g. [``](/docs/routing/navigation#link) relies on this mechanism). +1. If you use this routing strategy, make sure that your [middleware matcher](/docs/routing/middleware#matcher-config) detects unprefixed pathnames. +2. If a superfluous locale prefix like `/en/about` is requested, the middleware will automatically redirect to the unprefixed version `/about`. This can be helpful in case you're redirecting from another locale and you want to update a potential cookie value first (e.g. [``](/docs/routing/navigation#link) relies on this mechanism). #### Never use a locale prefix [#locale-prefix-never] @@ -109,9 +103,9 @@ In this case, requests for all locales will be rewritten to have the locale only **Note that:** -1. If you use this strategy, you should adapt your matcher to detect [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). -2. If you don't use domain-based routing, the cookie is now the source of truth for determining the locale. Make sure that your hosting solution reliably returns the `set-cookie` header from the middleware (e.g. Vercel and Cloudflare are known to potentially [strip this header](https://developers.cloudflare.com/cache/concepts/cache-behavior/#interaction-of-set-cookie-response-header-with-cache) for cacheable requests). -3. [Alternate links](#alternate-links) are disabled in this mode since URLs might not be unique per locale. Due to this, consider including these yourself, or set up a [sitemap](/docs/environments/actions-metadata-route-handlers#sitemap) that links localized pages via `alternates`. +1. If you use this routing strategy, make sure that your [middleware matcher](/docs/routing/middleware#matcher-config) detects unprefixed pathnames. +2. [Alternate links](#alternate-links) are disabled in this mode since URLs might not be unique per locale. Due to this, consider including these yourself, or set up a [sitemap](/docs/environments/actions-metadata-route-handlers#sitemap) that links localized pages via `alternates`. +3. You can consider increasing the [`maxAge`](#locale-cookie) attribute of the locale cookie to a longer duration to remember the user's preference across sessions. #### Custom prefixes [#locale-prefix-custom] @@ -177,8 +171,8 @@ Since you typically want to define these routes only once internally, you can us import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ - locales: ['en', 'de'], - defaultLocale: 'en', + locales: ['en-US', 'en-UK', 'de'], + defaultLocale: 'en-US', // The `pathnames` object holds pairs of internal and // external paths. Based on the locale, the external @@ -189,29 +183,25 @@ export const routing = defineRouting({ '/': '/', '/blog': '/blog', - // If locales use different paths, you can - // specify each external path per locale + // If some locales use different paths, you can + // specify the relevant external pathnames '/about': { - en: '/about', de: '/ueber-uns' }, // Dynamic params are supported via square brackets - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]' + '/news/[articleSlug]': { + de: '/neuigkeiten/[articleSlug]' }, // Static pathnames that overlap with dynamic segments // will be prioritized over the dynamic segment '/news/just-in': { - en: '/news/just-in', de: '/neuigkeiten/aktuell' }, // Also (optional) catch-all segments are supported '/categories/[...slug]': { - en: '/categories/[...slug]', de: '/kategorien/[...slug]' } } @@ -297,11 +287,12 @@ In case you're using a system like a CMS to configure localized pathnames, you'l ```tsx filename="page.tsx" import {notFound} from 'next'; +import {Locale} from 'next-intl'; import {fetchContent} from './cms'; type Props = { params: Promise<{ - locale: string; + locale: Locale; slug: Array; }>; }; @@ -327,37 +318,49 @@ If you want to serve your localized content based on different domains, you can **Examples:** -- `us.example.com/en` -- `ca.example.com/en` -- `ca.example.com/fr` +- `us.example.com`: `en-US` +- `ca.example.com`: `en-CA` +- `ca.example.com/fr`: `fr-CA` +- `fr.example.com`: `fr-FR` + +In many cases, `domains` are combined with a [`localePrefix`](#locale-prefix) setting to achieve results as shown above. Also [custom prefixes](#locale-prefix-custom) can be used to customize the user-facing prefix per locale. ```tsx filename="routing.ts" import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ - locales: ['en', 'fr'], - defaultLocale: 'en', + locales: ['en-US', 'en-CA', 'fr-CA', 'fr-FR'], + defaultLocale: 'en-US', domains: [ { domain: 'us.example.com', - defaultLocale: 'en', - // Optionally restrict the locales available on this domain - locales: ['en'] + defaultLocale: 'en-US', + locales: ['en-US'] }, { domain: 'ca.example.com', - defaultLocale: 'en' - // If there are no `locales` specified on a domain, - // all available locales will be supported here + defaultLocale: 'en-CA', + locales: ['en-CA', 'fr-CA'] + }, + { + domain: 'fr.example.com', + defaultLocale: 'fr-FR', + locales: ['fr-FR'] } - ] + ], + localePrefix: { + mode: 'as-needed', + prefixes: { + // Cleaner prefix for `ca.example.com/fr` + 'fr-CA': '/fr' + } + } }); ``` -**Note that:** +Locales are required to be unique across domains, therefore regional variants are typically used to avoid conflicts. Note however that you don't necessarily need to [provide messages for each locale](/docs/usage/configuration#messages-per-locale) if the overall language is sufficient for your use case. -1. You can optionally remove the locale prefix in pathnames by changing the [`localePrefix`](#locale-prefix) setting. E.g. [`localePrefix: 'never'`](#locale-prefix-never) can be helpful in case you have unique domains per locale. -2. If no domain matches, the middleware will fall back to the [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`). +If no domain matches, the middleware will fall back to the general [`defaultLocale`](/docs/routing/middleware#default-locale) (e.g. on `localhost`).
How can I locally test if my setup is working? @@ -399,9 +402,7 @@ PORT=3001 npm run dev
Can I use a different `localePrefix` setting per domain? -Since such a configuration would require reading the domain at runtime, this would prevent the ability to render pages statically. Due to this, `next-intl` doesn't support this configuration out of the box. - -However, you can still achieve this by building the app for each domain separately, while injecting diverging routing configuration via an environment variable. +While this is currently not supported out of the box, you can still achieve this by building the app for each domain separately while injecting diverging routing configuration via an environment variable. **Example:** @@ -412,48 +413,14 @@ const isUsDomain = process.env.VERCEL_PROJECT_PRODUCTION_URL === 'us.example.com'; export const routing = defineRouting({ - locales: isUsDomain ? ['en'] : ['en', 'fr'], - defaultLocale: 'en', + locales: isUsDomain ? ['en-US'] : ['en-CA', 'fr-CA'], + defaultLocale: isUsDomain ? 'en-US' : 'en-CA', localePrefix: isUsDomain ? 'never' : 'always' }); ```
-
-Special case: Using `domains` with `localePrefix: 'as-needed'` - -Since domains can have different default locales, this combination requires some tradeoffs that apply to the [navigation APIs](/docs/routing/navigation) in order for `next-intl` to avoid reading the current host on the server side (which would prevent the usage of static rendering). - -1. [``](/docs/routing/navigation#link): This component will always render a locale prefix on the server side, even for the default locale of a given domain. However, during hydration on the client side, the prefix is potentially removed, if the default locale of the current domain is used. Note that the temporarily prefixed pathname will always be valid, however the middleware will potentially clean up a superfluous prefix via a redirect if the user clicks on a link before hydration. -2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the provided locale. However, similar to the handling with ``, the middleware will potentially clean up a superfluous prefix. -3. [`getPathname`](/docs/routing/navigation#getpathname): This function requires that a `domain` is passed as part of the arguments in order to avoid ambiguity. This can either be provided statically (e.g. when used in a sitemap), or read from a header like [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host). - -```tsx -import {getPathname} from '@/i18n/routing'; -import {headers} from 'next/headers'; - -// Case 1: Statically known domain -const domain = 'ca.example.com'; - -// Case 2: Read at runtime (dynamic rendering) -const domain = headers().get('x-forwarded-host'); - -// Assuming the current domain is `ca.example.com`, -// the returned pathname will be `/about` -const pathname = getPathname({ - href: '/about', - locale: 'en', - domain -}); -``` - -A `domain` can optionally also be passed to `redirect` in the same manner to ensure that a prefix is only added when necessary. Alternatively, you can also consider redirecting in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side. - -If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an [environment variable](#domains-localeprefix-individual). - -
- ### Turning off locale detection [#locale-detection] The middleware will [detect a matching locale](/docs/routing/middleware#locale-detection) based on your routing configuration & the incoming request and will either pass the request through for a matching locale or redirect to one that matches. @@ -473,11 +440,10 @@ In this case, only the locale prefix and a potentially [matching domain](#domain ### Locale cookie [#locale-cookie] -By default, the middleware will set a cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for future requests. +If a user changes the locale to a value that doesn't match the `accept-language` header, `next-intl` will set a session cookie called `NEXT_LOCALE` that contains the most recently detected locale. This is used to [remember the user's locale](/docs/routing/middleware#locale-detection) preference for subsequent requests. By default, the cookie will be configured with the following attributes: -1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 1 year so that the preference of the user is kept as long as possible. 2. [**`sameSite`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value): This value is set to `lax` so that the cookie can be set when coming from an external site. 3. [**`path`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#pathpath-value): This value is not set by default, but will use the value of your [`basePath`](#base-path) if configured. @@ -493,8 +459,8 @@ export const routing = defineRouting({ localeCookie: { // Custom cookie name name: 'USER_LOCALE', - // Expire in one day - maxAge: 60 * 60 * 24 + // Expire in one year + maxAge: 60 * 60 * 24 * 365 } }); ``` @@ -511,7 +477,20 @@ export const routing = defineRouting({ }); ``` -Note that the cookie is only set when the user switches the locale and is not updated on every request. + ### Alternate links [#alternate-links] @@ -549,12 +528,7 @@ link: ; rel="alternate"; hreflang="en", The [`x-default`](https://developers.google.com/search/docs/specialty/international/localized-versions#xdefault) entry is included to point to a variant that can be used if no other language matches the user's browser setting. This special entry is reserved for language selection & detection, in our case issuing a 307 redirect to the best matching locale. -Note that middleware configuration is automatically incorporated with the following special cases: - -1. **`localePrefix: 'always'` (default)**: The `x-default` entry is only included for `/`, not for nested pathnames like `/about`. The reason is that the default [matcher](#matcher-config) doesn't handle unprefixed pathnames apart from `/`, therefore these URLs could be 404s. Note that this only applies to the optional `x-default` entry, locale-specific URLs are always included. -2. **`localePrefix: 'never'`**: Alternate links are entirely turned off since there might not be unique URLs per locale. - -Other configuration options like `domains`, `pathnames` and `basePath` are automatically considered. +Your middleware configuration, including options like `domains`, `pathnames` and `basePath`, is automatically incorporated.
diff --git a/docs/src/pages/docs/routing/middleware.mdx b/docs/src/pages/docs/routing/middleware.mdx index caffb9c98..8bfec5d32 100644 --- a/docs/src/pages/docs/routing/middleware.mdx +++ b/docs/src/pages/docs/routing/middleware.mdx @@ -23,14 +23,16 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; ``` ## Locale detection [#locale-detection] -The locale is negotiated based on your [`localePrefix`](/docs/routing#locale-prefix) and [`domains`](/docs/routing#domains) setting. Once a locale is detected, it will be remembered for future requests by being stored in the `NEXT_LOCALE` cookie. +The locale is negotiated based on your routing configuration, taking into account your settings for [`localePrefix`](/docs/routing#locale-prefix), [`domains`](/docs/routing#domains), [`localeDetection`](/docs/routing#locale-detection), and [`localeCookie`](/docs/routing#locale-cookie). ### Prefix-based routing (default) [#location-detection-prefix] @@ -48,10 +50,11 @@ To change the locale, users can visit a prefixed route. This will take precedenc **Example workflow:** 1. A user requests `/` and based on the `accept-language` header, the `en` locale is matched. -2. The `en` locale is saved in a cookie and the user is redirected to `/en`. +2. The user is redirected to `/en`. 3. The app renders `Switch to German` to allow the user to change the locale to `de`. 4. When the user clicks on the link, a request to `/de` is initiated. -5. The middleware will update the cookie value to `de`. +5. The middleware will add a cookie to remember the preference for the `de` locale. +6. The user later requests `/` again and the middleware will redirect to `/de` based on the cookie.
Which algorithm is used to match the accept-language header against the available locales? @@ -90,14 +93,13 @@ Since the middleware is aware of all your domains, if a domain receives a reques 4. The middleware recognizes that the user wants to switch to another domain and responds with a redirect to `ca.example.com/fr`.
-How is the best matching domain for a given locale detected? +How is the best-matching domain for a given locale detected? -The bestmatching domain is detected based on these priorities: +The best-matching domain is detected based on these priorities: 1. Stay on the current domain if the locale is supported here 2. Use an alternative domain where the locale is configured as the `defaultLocale` -3. Use an alternative domain where the available `locales` are restricted and the locale is supported -4. Use an alternative domain that supports all locales +3. Use an alternative domain that supports the locale
@@ -105,58 +107,6 @@ The bestmatching domain is detected based on these priorities: The middleware is intended to only run on pages, not on arbitrary files that you serve independently of the user locale (e.g. `/favicon.ico`). -Because of this, the following config is generally recommended: - -```tsx filename="middleware.ts" -export const config = { - // Match only internationalized pathnames - matcher: ['/', '/(de|en)/:path*'] -}; -``` - -This enables: - -1. A redirect at `/` to a suitable locale -2. Internationalization of all pathnames starting with a locale (e.g. `/en/about`) - -
-Can I avoid hardcoding the locales in the `matcher` config? - -A [Next.js `matcher`](https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher) needs to be statically analyzable, therefore you can't use variables to generate this value. However, you can alternatively implement a programmatic condition in the middleware: - -```tsx filename="middleware.ts" -import {NextRequest} from 'next/server'; -import createMiddleware from 'next-intl/middleware'; -import {routing} from './i18n/routing'; - -const handleI18nRouting = createMiddleware(routing); - -export default function middleware(request: NextRequest) { - const {pathname} = request.nextUrl; - - // Matches '/', as well as all paths that start with a locale like '/en' - const shouldHandle = - pathname === '/' || - new RegExp(`^/(${locales.join('|')})(/.*)?$`).test( - request.nextUrl.pathname - ); - if (!shouldHandle) return; - - return handleI18nRouting(request); -} -``` - -
- -### Pathnames without a locale prefix [#matcher-no-prefix] - -There are two use cases where you might want to match pathnames without a locale prefix: - -1. You're using a config for [`localePrefix`](/docs/routing#locale-prefix) other than [`always`](/docs/routing#locale-prefix-always) -2. You want to enable redirects that add a locale for unprefixed pathnames (e.g. `/about` → `/en/about`) - -For these cases, the middleware should run on requests for pathnames without a locale prefix as well. - A popular strategy is to match all routes that don't start with certain segments (e.g. `/_next`) and also none that include a dot (`.`) since these typically indicate static files. However, if you have some routes where a dot is expected (e.g. `/users/jane.doe`), you should explicitly provide a matcher for these. ```tsx filename="middleware.ts" @@ -168,6 +118,7 @@ export const config = { // - … if they start with `/api`, `/_next` or `/_vercel` // - … the ones containing a dot (e.g. `favicon.ico`) '/((?!api|_next|_vercel|.*\\..*).*)', + // However, match all pathnames within `/users`, optionally with a locale prefix '/([\\w-]+)?/users/(.+)' ] diff --git a/docs/src/pages/docs/routing/navigation.mdx b/docs/src/pages/docs/routing/navigation.mdx index e23f7f150..8b8973fb0 100644 --- a/docs/src/pages/docs/routing/navigation.mdx +++ b/docs/src/pages/docs/routing/navigation.mdx @@ -260,8 +260,8 @@ Note that if you're using the [`pathnames`](/docs/routing#pathnames) setting, th // When the user is on `/de/ueber-uns`, this will be `/about` const pathname = usePathname(); -// When the user is on `/de/neuigkeiten/produktneuheit-94812`, -// this will be `/news/[articleSlug]-[articleId]` +// When the user is on `/de/neuigkeiten/produktneuheit`, +// this will be `/news/[articleSlug]` const pathname = usePathname(); ``` @@ -372,14 +372,3 @@ const pathname = getPathname({ } }); ``` - -## Legacy APIs - -`next-intl@3.0.0` brought the first release of the navigation APIs with these functions: - -- `createSharedPathnamesNavigation` -- `createLocalizedPathnamesNavigation` - -As part of `next-intl@3.22.0`, these functions have been replaced by a single `createNavigation` function, which unifies the API for both use cases and also fixes a few quirks in the previous APIs. Going forward, `createNavigation` is recommended and the previous functions are marked as deprecated. - -While `createNavigation` is mostly API-compatible, there are some minor differences that should be noted. Please refer to the [3.22 announcement post](/blog/next-intl-3-22#create-navigation) for full details. diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 523969519..8dfb5a2e5 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -15,51 +15,21 @@ Depending on if you handle [internationalization in Server- or Client Components `i18n/request.ts` can be used to provide configuration for **server-only** code, i.e. Server Components, Server Actions & friends. The configuration is provided via the `getRequestConfig` function and needs to be set up based on whether you're using [i18n routing](/docs/getting-started/app-router) or not. - - - - ```tsx filename="i18n/request.ts" import {getRequestConfig} from 'next-intl/server'; import {routing} from '@/i18n/routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment. - let locale = await requestLocale; - - // Ensure that a valid locale is used - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // ... return { locale, - messages: (await import(`../../messages/${locale}.json`)).default - }; -}); -``` - - - - -```tsx filename="i18n/request.ts" -import {getRequestConfig} from 'next-intl/server'; - -export default getRequestConfig(async () => { - // Provide a static locale, fetch a user setting, - // read from `cookies()`, `headers()`, etc. - const locale = 'en'; - - return { - locale, - messages: (await import(`../../messages/${locale}.json`)).default + messages + // ... }; }); ``` - - - The configuration object is created once for each request by internally using React's [`cache`](https://react.dev/reference/react/cache). The first component to use internationalization will call the function defined with `getRequestConfig`. Since this function is executed during the Server Components render pass, you can call functions like [`cookies()`](https://nextjs.org/docs/app/api-reference/functions/cookies) and [`headers()`](https://nextjs.org/docs/app/api-reference/functions/headers) to return configuration that is request-specific. @@ -80,17 +50,6 @@ const withNextIntl = createNextIntlPlugin(
-
-Which values can the `requestLocale` parameter hold? - -While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: - -1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. -1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). -1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case. - -
- ### `NextIntlClientProvider` `NextIntlClientProvider` can be used to provide configuration for **Client Components**. @@ -100,16 +59,12 @@ import {NextIntlClientProvider} from 'next-intl'; import {getMessages} from 'next-intl/server'; export default async function RootLayout(/* ... */) { - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); + // ... return ( - - {children} - + {children} ); @@ -119,51 +74,38 @@ export default async function RootLayout(/* ... */) { These props are inherited if you're rendering `NextIntlClientProvider` from a Server Component: 1. `locale` -2. `now` -3. `timeZone` +2. `messages` +3. `now` +4. `timeZone` +5. `formats` In contrast, these props can be provided as necessary: -1. `messages` (see [Internationalization in Client Components](/docs/environments/server-client-components#using-internationalization-in-client-components)) -2. `formats` -3. `defaultTranslationValues` -4. `onError` and `getMessageFallback` +1. `onError` +2. `getMessageFallback` + +Additionally, nested instances of `NextIntlClientProvider` will inherit configuration from their respective ancestors. Note however that individual props are treated as atomic, therefore e.g. `messages` need to be merged manually—if necessary.
How can I provide non-serializable props like `onError` to `NextIntlClientProvider`? -React limits the types of props that can be passed to Client Components to the ones that are [serializable](https://react.dev/reference/rsc/use-client#serializable-types). Since `onError`, `getMessageFallback` and `defaultTranslationValues` can receive functions, these configuration options can't be automatically inherited by the client side. +React limits the types of props that can be passed to Client Components to the ones that are [serializable](https://react.dev/reference/rsc/use-client#serializable-types). Since `onError` and `getMessageFallback` can receive functions, these configuration options can't be automatically inherited by the client side. -In order to define these values, you can wrap `NextIntlClientProvider` with another component that is marked with `'use client'` and defines the relevant props: +In order to define these values on the client side, you can add a provider that defines these props: -```tsx filename="IntlProvider.tsx" +```tsx filename="IntlErrorHandlingProvider.tsx" 'use client'; import {NextIntlClientProvider} from 'next-intl'; -export default function IntlProvider({ - locale, - now, - timeZone, - messages, - formats -}) { +export default function IntlErrorHandlingProvider({children}) { return ( {text} - }} onError={(error) => console.error(error)} getMessageFallback={({namespace, key}) => `${namespace}.${key}`} - // Make sure to forward these props to avoid markup mismatches - locale={locale} - now={now} - timeZone={timeZone} - // Provide as necessary - messages={messages} - formats={formats} - /> + > + {children} + ); } ``` @@ -171,26 +113,19 @@ export default function IntlProvider({ Once you have defined your client-side provider component, you can use it in a Server Component: ```tsx filename="layout.tsx" -import IntlProvider from './IntlProvider'; -import {getLocale, getNow, getTimeZone, getMessages} from 'next-intl/server'; +import {NextIntlClientProvider} from 'next-intl'; +import {getLocale} from 'next-intl/server'; +import IntlErrorHandlingProvider from './IntlErrorHandlingProvider'; export default async function RootLayout({children}) { const locale = await getLocale(); - const now = await getNow(); - const timeZone = await getTimeZone(); - const messages = await getMessages(); return ( - - {children} - + + {children} + ); @@ -199,10 +134,149 @@ export default async function RootLayout({children}) { By doing this, your provider component will already be part of the client-side bundle and can therefore define and pass functions as props. -**Important:** Be sure to pass explicit `locale`, `timeZone` and `now` props to `NextIntlClientProvider` in this case, since these aren't automatically inherited from a Server Component when you import `NextIntlClientProvider` from a Client Component. +Note that the inner `NextIntlClientProvider` inherits the configuration from the outer one, only the `onError` and `getMessageFallback` functions are added. + +
+ +## Locale + +The `locale` represents an identifier that contains the language and formatting preferences of users, optionally including regional information (e.g. `en-US`). Locales are specified as [IETF BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag). + + + + +Depending on if you're using [i18n routing](/docs/getting-started/app-router), you can read the locale from the `requestLocale` parameter or provide a value on your own: + +**With i18n routing:** + +```tsx filename="i18n/request.ts" +export default getRequestConfig(async ({requestLocale}) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; + + return { + locale + // ... + }; +}); +``` + +**Without i18n routing:** + +```tsx filename="i18n/request.ts" +export default getRequestConfig(async () => { + // Provide a static locale, fetch a user setting, + // read from `cookies()`, `headers()`, etc. + const locale = 'en'; + + return { + locale + // ... + }; +}); +``` + +
+Which values can the `requestLocale` parameter hold? + +While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider: + +1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment. +1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`). +1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case.
+
+ + +```tsx +... +``` + + +
+ +
+How can I change the locale? + +Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: + +1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). +2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. + +
+ +### `useLocale` & `getLocale` [#use-locale] + +The current locale of your app is automatically incorporated into hooks like `useTranslations` & `useFormatter` and will affect the rendered output. + +In case you need to use this value in other places of your app, e.g. to implement a locale switcher or to pass it to API calls, you can read it via `useLocale` or `getLocale`: + +```tsx +// Regular components +import {useLocale} from 'next-intl'; +const locale = useLocale(); + +// Async Server Components +import {getLocale} from 'next-intl/server'; +const locale = await getLocale(); +``` + +
+Which value is returned from `useLocale`? + +Depending on how a component renders, the returned locale corresponds to: + +1. **Server Components**: The locale represents the value returned in [`i18n/request.ts`](#i18n-request). +2. **Client Components**: The locale is received from [`NextIntlClientProvider`](#nextintlclientprovider). + +Note that `NextIntlClientProvider` automatically inherits the locale if it is rendered by a Server Component, therefore you rarely need to pass a locale to `NextIntlClientProvider` yourself. + +
+ +
+I'm using the Pages Router, how can I access the locale? + +If you use [internationalized routing with the Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), you can receive the locale from the router in order to pass it to `NextIntlClientProvider`: + +```tsx filename="_app.tsx" +import {useRouter} from 'next/router'; + +// ... + +const router = useRouter(); + +return ( + + ... + ; +); +``` + +
+ +### `Locale` type [#locale-type] + +When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: + +```tsx +import {Locale} from 'next-intl'; + +async function getPosts(locale: Locale) { + // ... +} +``` + + + By default, `Locale` is typed as `string`. However, you can optionally provide + a strict union based on your supported locales for this type by [augmenting + the `Locale` type](/docs/workflows/typescript#locale). + + ## Messages The most crucial aspect of internationalization is providing labels based on the user's language. The recommended workflow is to store your messages in your repository along with the code. @@ -235,40 +309,6 @@ export default getRequestConfig(async () => { After messages are configured, they can be used via [`useTranslations`](/docs/usage/messages#rendering-messages-with-usetranslations). -In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: - -```tsx -// Regular components -import {useMessages} from 'next-intl'; -const messages = useMessages(); - -// Async Server Components -import {getMessages} from 'next-intl/server'; -const messages = await getMessages(); -``` - -
- - -```tsx -import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; - -async function Component({children}) { - // Read messages configured via `i18n/request.ts` - const messages = await getMessages(); - - return ( - - {children} - - ); -} -``` - - -
-
How can I load messages from remote sources? @@ -318,6 +358,67 @@ Note that [the VSCode integration for `next-intl`](/docs/workflows/vscode-integr
+
+Do I need separate messages for each locale that my app supports? + +Since you have full control over how messages are loaded, you can choose to load messages for example merely based on the overall language, ignoring any regional variants: + +```tsx +import {getRequestConfig} from 'next-intl/server'; + +export default getRequestConfig(async () => { + // E.g. "en-US", "en-CA", … + const locale = 'en-US'; + + // E.g. "en" + const language = new Intl.Locale(locale).language; + + // Load messages based on the language + const messages = (await import(`../../messages/${language}.json`)).default; + + // ... +}); +``` + +
+ +### `useMessages` & `getMessages` [#use-messages] + +In case you require access to messages in a component, you can read them via `useMessages()` or `getMessages()` from your configuration: + +```tsx +// Regular components +import {useMessages} from 'next-intl'; +const messages = useMessages(); + +// Async Server Components +import {getMessages} from 'next-intl/server'; +const messages = await getMessages(); +``` + + + + +```tsx +import {NextIntlClientProvider} from 'next-intl'; +import {getMessages} from 'next-intl/server'; +import pick from 'lodash/pick'; + +async function Component({children}) { + // Read messages configured via `i18n/request.ts` + const messages = await getMessages(); + + return ( + + {children} + + ); +} +``` + + + + ## Time zone Specifying a time zone affects the rendering of dates and times. By default, the time zone of the server runtime will be used, but can be customized as necessary. @@ -357,6 +458,10 @@ const timeZone = 'Europe/Vienna'; The available time zone names can be looked up in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). +The time zone in Client Components is automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. For all other cases, you can specify the value explicitly on a wrapping `NextIntlClientProvider`. + +### `useTimeZone` & `getTimeZone` [#use-time-zone] + The configured time zone can be read via `useTimeZone` or `getTimeZone` in components: ```tsx @@ -369,16 +474,9 @@ import {getTimeZone} from 'next-intl/server'; const timeZone = await getTimeZone(); ``` -The time zone in Client Components is automatically inherited from the server -side if you wrap the relevant components in a `NextIntlClientProvider` that is -rendered by a Server Component. For all other cases, you can specify the value -explicitly on a wrapping `NextIntlClientProvider`. - ## Now value [#now] -When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". By default, this is the time a component renders. - -If you prefer to override the default, you can provide an explicit value for `now`: +When formatting [relative dates and times](/docs/usage/dates-times#relative-times), `next-intl` will format times in relation to a reference point in time that is referred to as "now". While it can be beneficial in terms of caching to [provide this value](/docs/usage/dates-times#relative-times-usenow) where necessary, you can provide a global value for `now`, e.g. to ensure consistency when running tests. @@ -388,11 +486,7 @@ import {getRequestConfig} from 'next-intl/server'; export default getRequestConfig(async () => { return { - // This is the default, a single date instance will be - // used by all Server Components to ensure consistency. - // Tip: This value can be mocked to a constant value - // for consistent results in end-to-end-tests. - now: new Date() + now: new Date('2024-11-14T10:36:01.516Z') // ... }; @@ -403,7 +497,7 @@ export default getRequestConfig(async () => { ```tsx -const now = new Date('2020-11-20T10:36:01.516Z'); +const now = new Date('2024-11-14T10:36:01.516Z'); ...; ``` @@ -411,6 +505,10 @@ const now = new Date('2020-11-20T10:36:01.516Z'); +If a `now` value is provided in `i18n/request.ts`, this will automatically be inherited by Client Components if you wrap them in a `NextIntlClientProvider` that is rendered by a Server Component. + +### `useNow` & `getNow` [#use-now] + The configured `now` value can be read in components via `useNow` or `getNow`: ```tsx @@ -423,10 +521,7 @@ import {getNow} from 'next-intl/server'; const now = await getNow(); ``` -Similarly to the `timeZone`, the `now` value in Client Components is -automatically inherited from the server side if you wrap the relevant -components in a `NextIntlClientProvider` that is rendered by a Server -Component. +Note that the returned value defaults to the current date and time, therefore making this hook useful when [providing `now`](/docs/usage/dates-times#relative-times-usenow) for `format.relativeTime` even when you haven't configured a global `now` value. ## Formats @@ -466,8 +561,6 @@ export default getRequestConfig(async () => { }); ``` -Note that `formats` are not automatically inherited by Client Components. If you want to make this available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). - @@ -516,9 +609,9 @@ function Component() { ``` - You can optionally [specify a global type for - `formats`](/docs/workflows/typescript#formats) to get autocompletion and type - safety. + By default, format names are loosely typed as `string`. However, you can + optionally use strict types by [augmenting the `Formats` + type](/docs/workflows/typescript#formats). Global formats for numbers, dates and times can be referenced in messages too: @@ -541,53 +634,7 @@ function Component() { } ``` -## Default translation values (deprecated) [#default-translation-values] - - - This feature is deprecated and will be removed in the next major version of `next-intl` ([alternative](/docs/usage/messages#rich-text-reuse-tags)). - - - -To achieve consistent usage of translation values and reduce redundancy, you can define a set of global default values. This configuration can also be used to apply consistent styling of commonly used rich text elements. - - - - -```tsx filename="i18n/request.tsx" -import {getRequestConfig} from 'next-intl/server'; - -export default getRequestConfig(async () => { - return { - defaultTranslationValues: { - important: (chunks) => {chunks}, - value: 123 - } - - // ... - }; -}); -``` - -Note that `defaultTranslationValues` are not automatically inherited by Client Components. If you want to make this available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). - - - - -```tsx - {chunks}, - value: 123 - }} -> - ... - -``` - -Note that `NextIntlClientProvider` is a Client Component, therefore if you render it from a Server Component, the props need to be serializable across the server/client boundary (see: [How can I provide non-serializable props to `NextIntlClientProvider`](#nextintlclientprovider-non-serializable-props)). - - - +Formats are automatically inherited from the server side if you wrap the relevant components in a `NextIntlClientProvider` that is rendered by a Server Component. ## Error handling (`onError` & `getMessageFallback`) [#error-handling] @@ -629,7 +676,7 @@ export default getRequestConfig(async () => { }); ``` -Note that `onError` and `getMessageFallback` are not automatically inherited by Client Components. If you want to make this functionality available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). +Note that `onError` and `getMessageFallback` are not automatically inherited by Client Components. If you want to make this functionality available in Client Components too, you can however create a [client-side provider](#nextintlclientprovider-non-serializable-props) that defines these props. @@ -667,61 +714,3 @@ function getMessageFallback({namespace, key, error}) { - -## Locale - -The current locale of your app is automatically incorporated into hooks like `useTranslations` & `useFormatter` and will affect the rendered output. - -In case you need to use this value in other places of your app, e.g. to implement a locale switcher or to pass it to API calls, you can read it via `useLocale` or `getLocale`: - -```tsx -// Regular components -import {useLocale} from 'next-intl'; -const locale = useLocale(); - -// Async Server Components -import {getLocale} from 'next-intl/server'; -const locale = await getLocale(); -``` - -
-How can I change the locale? - -Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows: - -1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter). -2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie. - -
- -
-Which value is returned from `useLocale`? - -The returned value is resolved based on these priorities: - -1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`. -2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component. For all other cases, you can specify the value - explicitly. - -
- -
-I'm using the Pages Router, how can I access the locale? - -If you use [internationalized routing with the Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/internationalization), you can receive the locale from the router in order to pass it to `NextIntlClientProvider`: - -```tsx filename="_app.tsx" -import {useRouter} from 'next/router'; - -// ... - -const router = useRouter(); - -return ( - - ... - ; -); -``` - -
diff --git a/docs/src/pages/docs/usage/dates-times.mdx b/docs/src/pages/docs/usage/dates-times.mdx index a8627c853..62f8c5fe8 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -34,7 +34,11 @@ See [the MDN docs about `DateTimeFormat`](https://developer.mozilla.org/en-US/do If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the second argument: ```js +// Use a global format format.dateTime(dateTime, 'short'); + +// Optionally override some options +format.dateTime(dateTime, 'short', {year: 'numeric'}); ```
@@ -67,32 +71,84 @@ function Component() { const format = useFormatter(); const dateTime = new Date('2020-11-20T08:30:00.000Z'); - // At 2020-11-20T10:36:00.000Z, - // this will render "2 hours ago" - format.relativeTime(dateTime); + // A reference point in time + const now = new Date('2020-11-20T10:36:00.000Z'); + + // This will render "2 hours ago" + format.relativeTime(dateTime, now); } ``` Note that values are rounded, so e.g. if 126 minutes have passed, "2 hours ago" will be returned. -### Supplying `now` +### `useNow` [#relative-times-usenow] -By default, `relativeTime` will use [the global value for `now`](/docs/usage/configuration#now). If you want to use a different value, you can explicitly pass this as the second parameter. +Since providing `now` is a common pattern, `next-intl` provides a convenience hook that can be used to retrieve the current date and time: -```js -import {useFormatter} from 'next-intl'; +```tsx {4} +import {useNow, useFormatter} from 'next-intl'; -function Component() { +function FormattedDate({date}) { + const now = useNow(); const format = useFormatter(); - const dateTime = new Date('2020-11-20T08:30:00.000Z'); - const now = new Date('2020-11-20T10:36:00.000Z'); - // Renders "2 hours ago" - format.relativeTime(dateTime, now); + format.relativeTime(date, now); +} +``` + +In contrast to simply calling `new Date()` in your component, `useNow` has some benefits: + +1. The returned value is consistent across re-renders on the client side. +2. The value can optionally be [updated continuously](#relative-times-update) based on an interval. +3. The value can optionally be initialized from a [global value](/docs/usage/configuration#now), e.g. allowing you to use a static `now` value to ensure consistency when running tests. If a global value is not provided, `useNow` will use the current time. + +
+How can I avoid hydration errors with `useNow`? + +If you're using `useNow` in a component that renders both on the server as well as the client and you're not using a global `now` value, you can consider using [`suppressHydrationWarning`](https://react.dev/reference/react-dom/client/hydrateRoot#suppressing-unavoidable-hydration-mismatch-errors) to tell React that this particular text is expected to potentially be updated on the client side: + +```tsx {7} +import {useNow, useFormatter} from 'next-intl'; + +function FormattedDate({date}) { + const now = useNow(); + const format = useFormatter(); + + return {format.relativeTime(date, now)}; +} +``` + +While this prop has a somewhat intimidating name, it's an escape hatch that was purposefully designed for cases like this. + +
+ +
+How can I use `now` in Server Components with `dynamicIO`? + +If you're using [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO), Next.js may prompt you to specify a cache expiration in case you're using `useNow` in a Server Component. + +You can do so by annotating your component with the `'use cache'` directive, while converting it to an async function: + +```tsx +import {getNow, getFormatter} from 'next-intl/server'; + +async function FormattedDate({date}) { + 'use cache'; + + const now = await getNow(); + const format = await getFormatter(); + + return format.relativeTime(date, now); } ``` -If you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#now): +Alternatively, if you don't want to use any caching, you can mark the component with [`await connection()`](https://nextjs.org/docs/app/api-reference/functions/connection) instead to render at request time. + +
+ +### `updateInterval` [#relative-times-update] + +In case you want a relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#use-now): ```js import {useNow, useFormatter} from 'next-intl'; @@ -112,9 +168,9 @@ function Component() { } ``` -### Customizing the unit +### Customizing the unit [#relative-times-unit] -By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` (e.g. 3 seconds, 40 minutes, 4 days, etc.). +By default, `relativeTime` will pick a unit based on the difference between the passed date and `now` like "3 seconds" or "5 days". If you want to use a specific unit, you can provide options via the second argument: @@ -152,10 +208,14 @@ function Component() { } ``` -If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the trailing argument: +If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the third argument: ```js +// Use a global format format.dateTimeRange(dateTimeA, dateTimeB, 'short'); + +// Optionally override some options +format.dateTimeRange(dateTimeA, dateTimeB, 'short', {year: 'numeric'}); ``` ## Dates and times within messages diff --git a/docs/src/pages/docs/usage/messages.mdx b/docs/src/pages/docs/usage/messages.mdx index 34dc62614..27f2e9907 100644 --- a/docs/src/pages/docs/usage/messages.mdx +++ b/docs/src/pages/docs/usage/messages.mdx @@ -8,7 +8,7 @@ The main part of handling internationalization (typically referred to as _i18n_) ## Terminology -- **Locale**: We use this term to describe an identifier that contains the language and formatting preferences of users. Apart from the language, a locale can include optional regional information (e.g. `en-US`). Locales are specified as [IETF BCP 47 language tags](https://en.wikipedia.org/wiki/IETF_language_tag). +- **Locale**: We use this term to describe an identifier that contains the language and formatting preferences of users. Apart from the language, a locale can include optional regional information (e.g. `en-US`). - **Messages**: These are collections of namespace-label pairs that are grouped by locale (e.g. `en-US.json`). ## Structuring messages diff --git a/docs/src/pages/docs/usage/numbers.mdx b/docs/src/pages/docs/usage/numbers.mdx index fb8dbb287..26c2e4370 100644 --- a/docs/src/pages/docs/usage/numbers.mdx +++ b/docs/src/pages/docs/usage/numbers.mdx @@ -31,7 +31,11 @@ See [the MDN docs about `NumberFormat`](https://developer.mozilla.org/en-US/docs If you have [global formats](/docs/usage/configuration#formats) configured, you can reference them by passing a name as the second argument: ```js +// Use a global format format.number(499.9, 'precise'); + +// Optionally override some options +format.number(499.9, 'price', {currency: 'USD'}); ``` ## Numbers within messages diff --git a/docs/src/pages/docs/workflows.mdx b/docs/src/pages/docs/workflows.mdx index 746ad13d5..89b597c1c 100644 --- a/docs/src/pages/docs/workflows.mdx +++ b/docs/src/pages/docs/workflows.mdx @@ -3,10 +3,10 @@ import Cards from '@/components/Cards'; # Workflows & integrations -To get the most out of `next-intl`, you can choose from these integrations to improve your workflow when developing and collaborating with translators. +To get the most out of `next-intl`, you can choose from these integrations to improve your workflow. - + - The [TypeScript integration of `next-intl`](/docs/workflows/typescript) can + The [TypeScript augmentation of `next-intl`](/docs/workflows/typescript) can help you to validate at compile time that your app is in sync with your translation bundles. diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 1597b4b8e..e7626f25e 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -1,12 +1,88 @@ +import Details from '@/components/Details'; +import {Tabs} from 'nextra/components'; import Callout from '@/components/Callout'; -# TypeScript integration +# TypeScript augmentation `next-intl` integrates seamlessly with TypeScript right out of the box, requiring no additional setup. -However, you can optionally provide supplemental type definitions for your messages and formats to enable autocompletion and improve type safety. +However, you can optionally provide supplemental definitions to augment the types that `next-intl` works with, enabling improved autocompletion and type safety across your app. -## Messages +```tsx filename="global.d.ts" +import {routing} from '@/i18n/routing'; +import {formats} from '@/i18n/request'; +import messages from './messages/en.json'; + +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof messages; + Formats: typeof formats; + } +} +``` + +Type augmentation is available for: + +- [`Locale`](#locale) +- [`Messages`](#messages) +- [`Formats`](#formats) + +## `Locale` + +Augmenting the `Locale` type will affect all APIs from `next-intl` that either return or receive a locale: + +```tsx +import {useLocale} from 'next-intl'; + +// ✅ 'en' | 'de' +const locale = useLocale(); +``` + +```tsx +import {Link} from '@/i18n/routing'; + +// ✅ Passes the validation +; +``` + +Additionally, `next-intl` provides a [`Locale`](/docs/usage/configuration#locale-type) type that can be used when passing the locale as an argument. + +To enable this validation, you can adapt `AppConfig` as follows: + + + + +```tsx filename="global.d.ts" +import {routing} from '@/i18n/routing'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof routing.locales)[number]; + } +} +``` + + + + +```tsx filename="global.d.ts" +// Potentially imported from a shared config +const locales = ['en', 'de'] as const; + +declare module 'next-intl' { + interface AppConfig { + // ... + Locale: (typeof locales)[number]; + } +} +``` + + + + +## `Messages` Messages can be strictly typed to ensure you're using valid keys. @@ -31,44 +107,158 @@ function About() { } ``` -To enable this validation, add a global type definition file in your project root (e.g. `global.d.ts`): +To enable this validation, you can adapt `AppConfig` as follows: ```ts filename="global.d.ts" -import en from './messages/en.json'; +import messages from './messages/en.json'; + +declare module 'next-intl' { + interface AppConfig { + // ... + Messages: typeof messages; + } +} +``` + +You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the type based on the messages from your default locale. + +
+Does this affect the performance of type checking? + +While the size of your messages file can have an effect on the time it takes to run the TypeScript compiler on your project, the overhead of augmenting `Messages` should be reasonably fast. -type Messages = typeof en; +Here's a benchmark from a sample project with 340 messages: + +- No type augmentation for messages: ~2.20s +- Type-safe keys: ~2.82s +- Type-safe arguments: ~2.85s + +This was observed on a MacBook Pro 2019 (Intel). + +--- + +If you experience performance issues on larger projects, you can consider: + +1. Using type augmentation of messages only on your continuous integration pipeline as a safety net +2. Splitting your project into multiple packages in a monorepo, allowing you to work with separate messages per package + +
+ +
+Does this affect the performance of my editor? + +Generally, type augmentation for `Messages` should be [reasonably fast](#messages-performance-tsc). + +In case you notice your editor performance related to saving files to be impacted, it might be caused by running ESLint on save when using [type-aware](https://typescript-eslint.io/troubleshooting/typed-linting/performance/) rules from `@typescript-eslint`. + +To ensure your editor performance is optimal, you can consider running expensive, type-aware rules only on your continuous integration pipeline: + +```tsx filename="eslint.config.js" +// ... -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} + // Run expensive, type-aware linting only on CI + '@typescript-eslint/no-misused-promises': process.env.CI + ? 'error' + : 'off' +``` + +
+ +### Type-safe arguments [#messages-arguments] + +Apart from strictly typing message keys, you can also ensure type safety for message arguments: + +```json filename="messages/en.json" +{ + "UserProfile": { + "title": "Hello {firstName}" + } } ``` -You can freely define the interface, but if you have your messages available locally, it can be helpful to automatically create the interface based on the messages from your default locale by importing it. +```tsx +function UserProfile({user}) { + const t = useTranslations('UserProfile'); + + // ✖️ Missing argument + t('title'); + + // ✅ Argument is provided + t('title', {firstName: user.firstName}); +} +``` + +TypeScript currently has a [limitation](https://github.com/microsoft/TypeScript/issues/32063) where it infers values of imported JSON modules as loose types like `string` instead of the actual value. To bridge this gap for the time being, `next-intl` can generate an accompanying `.d.json.ts` file for the messages that you're assigning to your `AppConfig`. + +**Usage:** + +1. Add support for JSON type declarations in your `tsconfig.json`: + +```json filename="tsconfig.json" +{ + "compilerOptions": { + // ... + "allowArbitraryExtensions": true + } +} +``` + +2. Configure the `createMessagesDeclaration` setting in your Next.js config: + +```tsx filename="next.config.mjs" +import {createNextIntlPlugin} from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin({ + experimental: { + // Provide the path to the messages that you're using in `AppConfig` + createMessagesDeclaration: './messages/en.json' + } + // ... +}); + +// ... +``` + +With this setup in place, you'll see a new declaration file generated in your `messages` directory once you run `next dev` or `next build`: -## Formats +```diff + messages/en.json ++ messages/en.d.json.ts +``` + +This declaration file will provide the exact types for the JSON messages that you're importing and assigning to `AppConfig`, enabling type safety for message arguments. -[Global formats](/docs/usage/configuration#formats) that are referenced in calls like `format.dateTime` can be strictly typed to ensure you're using valid format names across your app. +To keep your code base tidy, you can ignore this file in Git: + +```text filename=".gitignore" +messages/*.d.json.ts +``` + +Please consider upvoting [`TypeScript#32063`](https://github.com/microsoft/TypeScript/issues/32063) to potentially remove this workaround in the future. + +## `Formats` + +If you're using [global formats](/docs/usage/configuration#formats), you can strictly type the format names that are referenced in calls to `format.dateTime`, `format.number` and `format.list`. ```tsx function Component() { const format = useFormatter(); - // ✅ Valid format - format.number(2, 'precise'); - - // ✅ Valid format - format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); - // ✖️ Unknown format string format.dateTime(new Date(), 'unknown'); // ✅ Valid format format.dateTime(new Date(), 'short'); + + // ✅ Valid format + format.number(2, 'precise'); + + // ✅ Valid format + format.list(['HTML', 'CSS', 'JavaScript'], 'enumeration'); } ``` -To enable this validation, export the formats that you're using in your request configuration: +To enable this validation, export the formats that you're using e.g. from your request configuration: ```ts filename="i18n/request.ts" import {Formats} from 'next-intl'; @@ -97,16 +287,16 @@ export const formats = { // ... ``` -Now, a global type definition file in the root of your project can pick up the shape of your formats and use them for declaring the `IntlFormats` interface: +Now, you can include the `formats` in your `AppConfig`: ```ts filename="global.d.ts" -import {formats} from './src/i18n/request'; - -type Formats = typeof formats; +import {formats} from '@/i18n/request'; -declare global { - // Use type safe formats with `next-intl` - interface IntlFormats extends Formats {} +declare module 'next-intl' { + interface AppConfig { + // ... + Formats: typeof formats; + } } ``` @@ -114,8 +304,7 @@ declare global { If you're encountering problems, double check that: -1. Your interface uses the correct name. -2. You're using TypeScript version 5 or later. -3. You're using correct paths for all modules you're importing into your global declaration file. -4. Your type declaration file is included in `tsconfig.json`. -5. Your editor has loaded the most recent type declarations. When in doubt, you can restart. +1. The interface uses the correct name `AppConfig`. +2. You're using correct paths for all modules you're importing into your global declaration file. +3. Your type declaration file is included in `tsconfig.json`. +4. Your editor has loaded the latest types. When in doubt, restart your editor. diff --git a/docs/src/pages/index.mdx b/docs/src/pages/index.mdx index d219d0aaf..9aee3b5d6 100644 --- a/docs/src/pages/index.mdx +++ b/docs/src/pages/index.mdx @@ -20,6 +20,10 @@ import GetStartedBackground from '@/components/GetStartedBackground'; description="Support multiple languages, with your app code becoming simpler instead of more complex." getStarted="Get started" viewExample="View an example" + announcement={{ + href: '/blog/next-intl-4-0', + label: 'next-intl 4.0 is out now!' + }} /> diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index 3ef5664b2..482775518 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import {notFound} from 'next/navigation'; +import {NextIntlClientProvider, hasLocale} from 'next-intl'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -8,10 +9,9 @@ type Props = { }; export default async function LocaleLayout({children, params}: Props) { - const {locale} = await params; - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + const {locale} = await params; + if (!hasLocale(routing.locales, locale)) { notFound(); } @@ -20,7 +20,9 @@ export default async function LocaleLayout({children, params}: Props) { next-intl - {children} + + {children} + ); } diff --git a/examples/example-app-router-migration/src/i18n/request.ts b/examples/example-app-router-migration/src/i18n/request.ts index 70066e964..370fc6d0c 100644 --- a/examples/example-app-router-migration/src/i18n/request.ts +++ b/examples/example-app-router-migration/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router-migration/src/middleware.ts b/examples/example-app-router-migration/src/middleware.ts index 5cc4efb87..9fce69220 100644 --- a/examples/example-app-router-migration/src/middleware.ts +++ b/examples/example-app-router-migration/src/middleware.ts @@ -4,6 +4,8 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Skip all paths that should not be internationalized - matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; diff --git a/examples/example-app-router-mixed-routing/global.d.ts b/examples/example-app-router-mixed-routing/global.d.ts index b749518b9..98a911d1a 100644 --- a/examples/example-app-router-mixed-routing/global.d.ts +++ b/examples/example-app-router-mixed-routing/global.d.ts @@ -1,8 +1,9 @@ -import en from './messages/en.json'; +import {locales} from '@/config'; +import messages from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof locales)[number]; + Messages: typeof messages; + } } diff --git a/examples/example-app-router-mixed-routing/package.json b/examples/example-app-router-mixed-routing/package.json index ecc977914..a35ee9b49 100644 --- a/examples/example-app-router-mixed-routing/package.json +++ b/examples/example-app-router-mixed-routing/package.json @@ -2,7 +2,7 @@ "name": "example-app-router-mixed-routing", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "playwright test", "build": "next build", diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx index b3d69830f..8e2122f3b 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/PublicNavigationLocaleSwitcher.tsx @@ -1,7 +1,6 @@ 'use client'; -import {useLocale} from 'next-intl'; -import {Locale} from '@/config'; +import {Locale, useLocale} from 'next-intl'; import {Link, usePathname} from '@/i18n/navigation.public'; export default function PublicNavigationLocaleSwitcher() { diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx index f00f9e7c4..81f48f831 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/about/page.tsx @@ -1,10 +1,10 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import {use} from 'react'; import PageTitle from '@/components/PageTitle'; type Props = { - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; export default function About({params}: Props) { diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx index e589d9ca9..3061b50e6 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx @@ -1,7 +1,7 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; -import {getMessages, setRequestLocale} from 'next-intl/server'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; +import {setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; import {locales} from '@/config'; @@ -10,7 +10,7 @@ import PublicNavigationLocaleSwitcher from './PublicNavigationLocaleSwitcher'; type Props = { children: ReactNode; - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; export function generateStaticParams() { @@ -22,23 +22,18 @@ export const metadata: Metadata = { }; export default async function LocaleLayout({children, params}: Props) { - const {locale} = await params; - - // Enable static rendering - setRequestLocale(locale); - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { + const {locale} = await params; + if (!hasLocale(locales, locale)) { notFound(); } - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); + // Enable static rendering + setRequestLocale(locale); return ( - +
{children}
diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx index d92c5f734..3698d4f80 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/page.tsx @@ -1,10 +1,10 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import {use} from 'react'; import PageTitle from '@/components/PageTitle'; type Props = { - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; export default function Index({params}: Props) { diff --git a/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx b/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx index 9101443dc..f85bc3c10 100644 --- a/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx +++ b/examples/example-app-router-mixed-routing/src/app/app/AppNavigationLocaleSwitcher.tsx @@ -1,8 +1,7 @@ 'use client'; import {useRouter} from 'next/navigation'; -import {useLocale} from 'next-intl'; -import {Locale} from '@/config'; +import {Locale, useLocale} from 'next-intl'; import updateLocale from './updateLocale'; export default function AppNavigationLocaleSwitcher() { diff --git a/examples/example-app-router-mixed-routing/src/app/app/layout.tsx b/examples/example-app-router-mixed-routing/src/app/app/layout.tsx index cbd693a89..48ece5398 100644 --- a/examples/example-app-router-mixed-routing/src/app/app/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/app/layout.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; import AppNavigation from './AppNavigation'; @@ -18,13 +18,9 @@ export const metadata: Metadata = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - +
diff --git a/examples/example-app-router-mixed-routing/src/config.ts b/examples/example-app-router-mixed-routing/src/config.ts index d71700814..e7b729aa0 100644 --- a/examples/example-app-router-mixed-routing/src/config.ts +++ b/examples/example-app-router-mixed-routing/src/config.ts @@ -1,5 +1,5 @@ +import {Locale} from 'next-intl'; + export const locales = ['en', 'de'] as const; export const defaultLocale: Locale = 'en'; - -export type Locale = (typeof locales)[number]; diff --git a/examples/example-app-router-mixed-routing/src/db.ts b/examples/example-app-router-mixed-routing/src/db.ts index 0c6b1c91b..4a7d1a2d6 100644 --- a/examples/example-app-router-mixed-routing/src/db.ts +++ b/examples/example-app-router-mixed-routing/src/db.ts @@ -1,5 +1,6 @@ import {cookies} from 'next/headers'; -import {defaultLocale} from './config'; +import {Locale, hasLocale} from 'next-intl'; +import {defaultLocale, locales} from './config'; // This cookie name is used by `next-intl` on the public pages too. By // reading/writing to this locale, we can ensure that the user's locale @@ -8,8 +9,9 @@ import {defaultLocale} from './config'; // that instead when the user is logged in. const COOKIE_NAME = 'NEXT_LOCALE'; -export async function getUserLocale() { - return (await cookies()).get(COOKIE_NAME)?.value || defaultLocale; +export async function getUserLocale(): Promise { + const candidate = (await cookies()).get(COOKIE_NAME)?.value; + return hasLocale(locales, candidate) ? candidate : defaultLocale; } export async function setUserLocale(locale: string) { diff --git a/examples/example-app-router-mixed-routing/src/i18n/request.ts b/examples/example-app-router-mixed-routing/src/i18n/request.ts index ff3845b6c..15748b733 100644 --- a/examples/example-app-router-mixed-routing/src/i18n/request.ts +++ b/examples/example-app-router-mixed-routing/src/i18n/request.ts @@ -1,20 +1,17 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {defaultLocale, locales} from '../config'; import {getUserLocale} from '../db'; export default getRequestConfig(async ({requestLocale}) => { // Read from potential `[locale]` segment - let locale = await requestLocale; + let candidate = await requestLocale; - if (!locale) { + if (!candidate) { // The user is logged in - locale = await getUserLocale(); - } - - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { - locale = defaultLocale; + candidate = await getUserLocale(); } + const locale = hasLocale(locales, candidate) ? candidate : defaultLocale; return { locale, diff --git a/examples/example-app-router-mixed-routing/src/middleware.ts b/examples/example-app-router-mixed-routing/src/middleware.ts index f413e76bc..53a13aea5 100644 --- a/examples/example-app-router-mixed-routing/src/middleware.ts +++ b/examples/example-app-router-mixed-routing/src/middleware.ts @@ -4,6 +4,8 @@ import {routing} from './i18n/routing.public'; export default createMiddleware(routing); export const config = { - // Match only public pathnames - matcher: ['/', '/(de|en)/:path*'] + // Match all pathnames except for + // - … if they start with `/app`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!app|_next|_vercel|.*\\..*).*)' }; diff --git a/examples/example-app-router-mixed-routing/tests/main.spec.ts b/examples/example-app-router-mixed-routing/tests/main.spec.ts index d3e719d97..b5e48d974 100644 --- a/examples/example-app-router-mixed-routing/tests/main.spec.ts +++ b/examples/example-app-router-mixed-routing/tests/main.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; it('syncs the locale across the public and private pages', async ({page}) => { await page.goto('/'); diff --git a/examples/example-app-router-next-auth/global.d.ts b/examples/example-app-router-next-auth/global.d.ts index b749518b9..6cb8e005a 100644 --- a/examples/example-app-router-next-auth/global.d.ts +++ b/examples/example-app-router-next-auth/global.d.ts @@ -1,8 +1,9 @@ -import en from './messages/en.json'; +import {routing} from '@/i18n/routing'; +import messages from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof messages; + } } diff --git a/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx b/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx index f7c6d90c2..324a5cecd 100644 --- a/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx +++ b/examples/example-app-router-next-auth/src/app/[locale]/Index.tsx @@ -20,9 +20,9 @@ export default function Index({session}: Props) { return ( - {session ? ( + {session?.user?.name ? ( <> -

{t('loggedIn', {username: session.user?.name})}

+

{t('loggedIn', {username: session.user.name})}

{t('secret')}

diff --git a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx index 7e5b062dc..eb8821de6 100644 --- a/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-next-auth/src/app/[locale]/layout.tsx @@ -1,19 +1,17 @@ import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; export default async function LocaleLayout({children, params}: Props) { const {locale} = await params; - - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } diff --git a/examples/example-app-router-next-auth/src/i18n/request.ts b/examples/example-app-router-next-auth/src/i18n/request.ts index 70066e964..370fc6d0c 100644 --- a/examples/example-app-router-next-auth/src/i18n/request.ts +++ b/examples/example-app-router-next-auth/src/i18n/request.ts @@ -1,14 +1,13 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, diff --git a/examples/example-app-router-next-auth/src/middleware.ts b/examples/example-app-router-next-auth/src/middleware.ts index 500a469ee..a45f9fbab 100644 --- a/examples/example-app-router-next-auth/src/middleware.ts +++ b/examples/example-app-router-next-auth/src/middleware.ts @@ -43,6 +43,8 @@ export default function middleware(req: NextRequest) { } export const config = { - // Skip all paths that should not be internationalized - matcher: ['/((?!api|_next|.*\\..*).*)'] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)'] }; diff --git a/examples/example-app-router-playground/.gitignore b/examples/example-app-router-playground/.gitignore index d61873784..080da4308 100644 --- a/examples/example-app-router-playground/.gitignore +++ b/examples/example-app-router-playground/.gitignore @@ -5,3 +5,4 @@ tsconfig.tsbuildinfo *storybook.log storybook-static test-results +messages/*.d.json.ts diff --git a/examples/example-app-router-playground/global.d.ts b/examples/example-app-router-playground/global.d.ts index 15004afe0..85c56e020 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,13 +1,11 @@ -import en from './messages/en.json'; -import {formats} from './src/i18n/request'; +import {formats} from '@/i18n/request'; +import {routing} from '@/i18n/routing'; +import messages from './messages/en.json'; -type Messages = typeof en; -type Formats = typeof formats; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} - - // Use type safe formats with `next-intl` - interface IntlFormats extends Formats {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Formats: typeof formats; + Messages: typeof messages; + } } diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index 47d672723..b5b245ab7 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -2,7 +2,12 @@ import createMDX from '@next/mdx'; import createNextIntlPlugin from 'next-intl/plugin'; import createBundleAnalyzer from '@next/bundle-analyzer'; -const withNextIntl = createNextIntlPlugin('./src/i18n/request.tsx'); +const withNextIntl = createNextIntlPlugin({ + requestConfig: './src/i18n/request.tsx', + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); const withMdx = createMDX({}); const withBundleAnalyzer = createBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' @@ -10,10 +15,13 @@ const withBundleAnalyzer = createBundleAnalyzer({ /** @type {import('next').NextConfig} */ const nextConfig = { - pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], + eslint: { + ignoreDuringBuilds: true + }, trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash', basePath: - process.env.NEXT_PUBLIC_USE_CASE === 'base-path' ? '/base/path' : undefined + process.env.NEXT_PUBLIC_USE_CASE === 'base-path' ? '/base/path' : undefined, + pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'] }; export default withNextIntl(withMdx(withBundleAnalyzer(nextConfig))); diff --git a/examples/example-app-router-playground/src/app/[locale]/about/page.tsx b/examples/example-app-router-playground/src/app/[locale]/about/page.tsx index 02908bce4..dd743536c 100644 --- a/examples/example-app-router-playground/src/app/[locale]/about/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/about/page.tsx @@ -1,11 +1,13 @@ +import {Locale} from 'next-intl'; + type Props = { params: Promise<{ - locale: string; + locale: Locale; }>; }; -export default async function AboutPage(props: Props) { - const params = await props.params; - const Content = (await import(`./${params.locale}.mdx`)).default; +export default async function AboutPage({params}: Props) { + const {locale} = await params; + const Content = (await import(`./${locale}.mdx`)).default; return ; } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx index df1a9e5b7..ece94e2a3 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItem.tsx @@ -2,5 +2,5 @@ import {useTranslations} from 'next-intl'; export default function ListItem({id}: {id: number}) { const t = useTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx index 801919ba3..b25696f1b 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemAsync.tsx @@ -2,5 +2,5 @@ import {getTranslations} from 'next-intl/server'; export default async function ListItemAsync({id}: {id: number}) { const t = await getTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx index b94cf8749..62d4796e1 100644 --- a/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/actions/ListItemClient.tsx @@ -4,5 +4,5 @@ import {useTranslations} from 'next-intl'; export default function ListItemClient({id}: {id: number}) { const t = useTranslations('ServerActions'); - return t('item', {id}); + return t('item', {id: String(id)}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/api/route.ts b/examples/example-app-router-playground/src/app/[locale]/api/route.ts index e587fc21c..9ea968a94 100644 --- a/examples/example-app-router-playground/src/app/[locale]/api/route.ts +++ b/examples/example-app-router-playground/src/app/[locale]/api/route.ts @@ -1,9 +1,10 @@ import {NextRequest, NextResponse} from 'next/server'; +import {Locale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; type Props = { params: Promise<{ - locale: string; + locale: Locale; }>; }; diff --git a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx index a6f3582eb..c3b2a26c8 100644 --- a/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/client/ClientContent.tsx @@ -1,6 +1,6 @@ 'use client'; -import {useFormatter, useLocale, useNow, useTimeZone} from 'next-intl'; +import {useLocale, useNow, useTimeZone} from 'next-intl'; import {Link, usePathname} from '@/i18n/navigation'; export default function ClientContent() { @@ -18,23 +18,3 @@ export default function ClientContent() { ); } - -export function TypeTest() { - const format = useFormatter(); - - format.dateTime(new Date(), 'medium'); - // @ts-expect-error - format.dateTime(new Date(), 'unknown'); - - format.dateTimeRange(new Date(), new Date(), 'medium'); - // @ts-expect-error - format.dateTimeRange(new Date(), new Date(), 'unknown'); - - format.number(420, 'precise'); - // @ts-expect-error - format.number(420, 'unknown'); - - format.list(['this', 'is', 'a', 'list'], 'enumeration'); - // @ts-expect-error - format.list(['this', 'is', 'a', 'list'], 'unknown'); -} diff --git a/examples/example-app-router-playground/src/app/[locale]/layout.tsx b/examples/example-app-router-playground/src/app/[locale]/layout.tsx index 4044195d5..4f64aadfa 100644 --- a/examples/example-app-router-playground/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/layout.tsx @@ -1,6 +1,7 @@ import {Metadata} from 'next'; import {Inter} from 'next/font/google'; import {notFound} from 'next/navigation'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import { getFormatter, getNow, @@ -13,7 +14,7 @@ import Navigation from '../../components/Navigation'; type Props = { children: ReactNode; - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; const inter = Inter({subsets: ['latin']}); @@ -35,7 +36,7 @@ export async function generateMetadata( description: t('description'), other: { currentYear: formatter.dateTime(now, {year: 'numeric'}), - timeZone: timeZone || 'N/A' + timeZone } }; } @@ -44,7 +45,7 @@ export default async function LocaleLayout({params, children}: Props) { const {locale} = await params; // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } @@ -58,8 +59,10 @@ export default async function LocaleLayout({params, children}: Props) { lineHeight: 1.5 }} > - - {children} + + + {children} +
diff --git a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx index 55696500a..409b0bd1d 100644 --- a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx @@ -1,7 +1,6 @@ import {Metadata} from 'next'; -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {use} from 'react'; -import {Locale} from '@/i18n/routing'; import {getPathname} from '@/i18n/navigation'; type Props = { @@ -11,24 +10,24 @@ type Props = { }>; }; -export async function generateMetadata(props: Props): Promise { - const params = await props.params; +export async function generateMetadata({params}: Props): Promise { + const {locale, articleId} = await params; return { alternates: { canonical: getPathname({ href: { pathname: '/news/[articleId]', - params: {articleId: params.articleId} + params: {articleId} }, - locale: params.locale + locale }) } }; } export default function NewsArticle(props: Props) { - const params = use(props.params); + const {articleId} = use(props.params); const t = useTranslations('NewsArticle'); - return

{t('title', {articleId: params.articleId})}

; + return

{t('title', {articleId})}

; } diff --git a/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx b/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx index 763972462..ffa13ed2e 100644 --- a/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/opengraph-image.tsx @@ -1,9 +1,10 @@ import {ImageResponse} from 'next/og'; +import {Locale} from 'next-intl'; import {getTranslations} from 'next-intl/server'; type Props = { params: { - locale: string; + locale: Locale; }; }; diff --git a/examples/example-app-router-playground/src/components/AsyncComponent.tsx b/examples/example-app-router-playground/src/components/AsyncComponent.tsx index 94a624238..aa4b17029 100644 --- a/examples/example-app-router-playground/src/components/AsyncComponent.tsx +++ b/examples/example-app-router-playground/src/components/AsyncComponent.tsx @@ -1,4 +1,4 @@ -import {getFormatter, getTranslations} from 'next-intl/server'; +import {getTranslations} from 'next-intl/server'; export default async function AsyncComponent() { const t = await getTranslations('AsyncComponent'); @@ -20,8 +20,6 @@ export default async function AsyncComponent() { export async function TypeTest() { const t = await getTranslations('AsyncComponent'); - const format = await getFormatter(); - // @ts-expect-error await getTranslations('Unknown'); @@ -36,20 +34,4 @@ export async function TypeTest() { // @ts-expect-error t.has('unknown'); - - format.dateTime(new Date(), 'medium'); - // @ts-expect-error - format.dateTime(new Date(), 'unknown'); - - format.dateTimeRange(new Date(), new Date(), 'medium'); - // @ts-expect-error - format.dateTimeRange(new Date(), new Date(), 'unknown'); - - format.number(420, 'precise'); - // @ts-expect-error - format.number(420, 'unknown'); - - format.list(['this', 'is', 'a', 'list'], 'enumeration'); - // @ts-expect-error - format.list(['this', 'is', 'a', 'list'], 'unknown'); } diff --git a/examples/example-app-router-playground/src/components/Navigation.tsx b/examples/example-app-router-playground/src/components/Navigation.tsx index b4daa3660..6ad41a1ab 100644 --- a/examples/example-app-router-playground/src/components/Navigation.tsx +++ b/examples/example-app-router-playground/src/components/Navigation.tsx @@ -13,7 +13,7 @@ export default function Navigation() { - {t('newsArticle', {articleId: 3})} + {t('newsArticle', {articleId: String(3)})} ); diff --git a/examples/example-app-router-playground/src/type-portability-test.ts b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts similarity index 91% rename from examples/example-app-router-playground/src/type-portability-test.ts rename to examples/example-app-router-playground/src/components/TypePortabilityTest.ts index 18facf87d..f1e5df1ab 100644 --- a/examples/example-app-router-playground/src/type-portability-test.ts +++ b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts @@ -6,7 +6,6 @@ import { createFormatter, createTranslator, - initializeConfig, useFormatter, useLocale, useMessages, @@ -61,9 +60,12 @@ export async function asyncApis() { } export const withNextIntl = createNextIntlPlugin(); -export const config = initializeConfig({locale: 'en'}); -export const translator = createTranslator({locale: 'en'}); + export const formatter = createFormatter({ locale: 'en', now: new Date(2022, 10, 6, 20, 20, 0, 0) }); + +export const translator = createTranslator({ + locale: 'en' +}); diff --git a/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx b/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx new file mode 100644 index 000000000..c81ebfb43 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseFormatterTypeTests.tsx @@ -0,0 +1,44 @@ +import {useFormatter} from 'next-intl'; +import {getFormatter} from 'next-intl/server'; + +export function RegularComponent() { + const format = useFormatter(); + + format.dateTime(new Date(), 'medium'); + format.dateTime(new Date(), 'long'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} + +export async function AsyncComponent() { + const format = await getFormatter(); + + format.dateTime(new Date(), 'medium'); + format.dateTime(new Date(), 'long'); + // @ts-expect-error + format.dateTime(new Date(), 'unknown'); + + format.dateTimeRange(new Date(), new Date(), 'medium'); + // @ts-expect-error + format.dateTimeRange(new Date(), new Date(), 'unknown'); + + format.number(420, 'precise'); + // @ts-expect-error + format.number(420, 'unknown'); + + format.list(['this', 'is', 'a', 'list'], 'enumeration'); + // @ts-expect-error + format.list(['this', 'is', 'a', 'list'], 'unknown'); +} diff --git a/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx b/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx new file mode 100644 index 000000000..e3a529351 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseLocaleTypeTests.tsx @@ -0,0 +1,62 @@ +import {Locale, useLocale} from 'next-intl'; +import {getLocale} from 'next-intl/server'; +import {Link, getPathname, redirect, useRouter} from '@/i18n/navigation'; + +export function RegularComponent() { + const locale = useLocale(); + + locale satisfies Locale; + 'en' satisfies typeof locale; + + // @ts-expect-error + 'fr' satisfies typeof locale; + // @ts-expect-error + 2 satisfies typeof locale; + + const router = useRouter(); + router.push('/', {locale}); + + return ( + <> + + Home + + {getPathname({ + href: '/', + locale + })} + {redirect({ + href: '/', + locale + })} + + ); +} + +export async function AsyncComponent() { + const locale = await getLocale(); + + locale satisfies Locale; + 'en' satisfies typeof locale; + + // @ts-expect-error + 'fr' satisfies typeof locale; + // @ts-expect-error + 2 satisfies typeof locale; + + return ( + <> + + Home + + {getPathname({ + href: '/', + locale + })} + {redirect({ + href: '/', + locale + })} + + ); +} diff --git a/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx b/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx new file mode 100644 index 000000000..fb9d850fa --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseMessagesTypeTests.tsx @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import {useMessages} from 'next-intl'; +import {getMessages} from 'next-intl/server'; + +export async function AsyncComponent() { + const messages = await getMessages(); + + // Valid + messages.Index; + messages.Index.title; + + // Invalid + // @ts-expect-error + messages.Unknown; + // @ts-expect-error + messages.Index.unknown; +} + +export function RegularComponent() { + const messages = useMessages(); + + // Valid + messages.Index; + messages.Index.title; + + // Invalid + // @ts-expect-error + messages.Unknown; + // @ts-expect-error + messages.Index.unknown; +} diff --git a/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx new file mode 100644 index 000000000..e9d0bc574 --- /dev/null +++ b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx @@ -0,0 +1,44 @@ +import { + createTranslator, + useLocale, + useMessages, + useTranslations +} from 'next-intl'; +import {getTranslations} from 'next-intl/server'; + +export function RegularComponent() { + const t = useTranslations('ClientCounter'); + t('count', {count: String(1)}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: String(1)}); +} + +export function CreateTranslator() { + const messages = useMessages(); + const locale = useLocale(); + const t = createTranslator({ + locale, + messages, + namespace: 'ClientCounter' + }); + + t('count', {count: String(1)}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: String(1)}); +} + +export async function AsyncComponent() { + const t = await getTranslations('ClientCounter'); + t('count', {count: String(1)}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: String(1)}); +} diff --git a/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx b/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx index e7a013de6..a0a75f327 100644 --- a/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx +++ b/examples/example-app-router-playground/src/components/client/02-MessagesOnClientCounter/ClientCounter.tsx @@ -13,7 +13,7 @@ export default function ClientCounter() { return (
-

{t('count', {count})}

+

{t('count', {count: String(count)})}

diff --git a/examples/example-app-router-playground/src/i18n/request.tsx b/examples/example-app-router-playground/src/i18n/request.tsx index 25b48590d..ee1fa2264 100644 --- a/examples/example-app-router-playground/src/i18n/request.tsx +++ b/examples/example-app-router-playground/src/i18n/request.tsx @@ -1,5 +1,5 @@ import {headers} from 'next/headers'; -import {Formats} from 'next-intl'; +import {Formats, hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import defaultMessages from '../../messages/en.json'; import {routing} from './routing'; @@ -10,6 +10,11 @@ export const formats = { dateStyle: 'medium', timeStyle: 'short', hour12: false + }, + long: { + dateStyle: 'full', + timeStyle: 'long', + hour12: false } }, number: { @@ -26,13 +31,11 @@ export const formats = { } satisfies Formats; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming locale is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; const now = (await headers()).get('x-now'); const timeZone = (await headers()).get('x-time-zone') ?? 'Europe/Vienna'; @@ -42,7 +45,10 @@ export default getRequestConfig(async ({requestLocale}) => { return { locale, - now: now ? new Date(now) : undefined, + now: now + ? new Date(now) + : // Ensure a consistent value for a render + new Date(), timeZone, messages, formats, diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index 5ee31f247..3725901a0 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -17,11 +17,13 @@ export const routing = defineRouting({ ? [ { domain: 'example.com', - defaultLocale: 'en' + defaultLocale: 'en', + locales: ['en', 'es', 'ja'] }, { domain: 'example.de', - defaultLocale: 'de' + defaultLocale: 'de', + locales: ['de'] } ] : undefined, @@ -58,6 +60,3 @@ export const routing = defineRouting({ maxAge: 200 * 24 * 60 * 60 } }); - -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; diff --git a/examples/example-app-router-playground/tests/base-path.spec.ts b/examples/example-app-router-playground/tests/base-path.spec.ts index 77e2fda9e..cc9c49a51 100644 --- a/examples/example-app-router-playground/tests/base-path.spec.ts +++ b/examples/example-app-router-playground/tests/base-path.spec.ts @@ -1,9 +1,9 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; import {assertLocaleCookieValue} from './utils'; it('updates the cookie correctly', async ({page}) => { await page.goto('/base/path'); - await assertLocaleCookieValue(page, 'en', {path: '/base/path'}); + await assertLocaleCookieValue(page, undefined); await page.getByRole('button', {name: 'Go to nested page'}).click(); await expect(page).toHaveURL('/base/path/nested'); diff --git a/examples/example-app-router-playground/tests/domains.spec.ts b/examples/example-app-router-playground/tests/domains.spec.ts index f1aac49dd..557e46dfd 100644 --- a/examples/example-app-router-playground/tests/domains.spec.ts +++ b/examples/example-app-router-playground/tests/domains.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect, chromium} from '@playwright/test'; +import {chromium, expect, test as it} from '@playwright/test'; it('can use config based on the default locale on an unknown domain', async ({ page @@ -37,6 +37,5 @@ it('can use a secondary locale unprefixed if the domain has specified it as the await page.getByRole('link', {name: 'Start'}).click(); await expect(page).toHaveURL('http://example.de'); await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click(); - await expect(page).toHaveURL('http://example.de/en'); - await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible(); + await expect(page).toHaveURL('http://example.com/en'); }); diff --git a/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts b/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts index 37ef14c27..cff9b8782 100644 --- a/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts +++ b/examples/example-app-router-playground/tests/locale-cookie-false.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; it('never sets a cookie', async ({page}) => { async function expectNoCookie() { diff --git a/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts b/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts index bb9fcea8a..48b66239a 100644 --- a/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts +++ b/examples/example-app-router-playground/tests/locale-prefix-never.spec.ts @@ -1,4 +1,5 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; +import {assertLocaleCookieValue} from './utils'; it('clears the router cache when changing the locale', async ({page}) => { await page.goto('/'); @@ -7,13 +8,6 @@ it('clears the router cache when changing the locale', async ({page}) => { await page.locator(`html[lang="${lang}"]`).waitFor(); } - async function assertCookie(locale: string) { - const cookies = await page.context().cookies(); - expect(cookies.find((cookie) => cookie.name === 'NEXT_LOCALE')?.value).toBe( - locale - ); - } - await expectDocumentLang('en'); await page.getByRole('link', {name: 'Client page'}).click(); @@ -22,16 +16,16 @@ it('clears the router cache when changing the locale', async ({page}) => { await expect( page.getByText('This page hydrates on the client side.') ).toBeAttached(); - await assertCookie('en'); + await assertLocaleCookieValue(page, undefined); await page.getByRole('link', {name: 'Go to home'}).click(); await expectDocumentLang('en'); await expect(page).toHaveURL('/'); - await assertCookie('en'); + await assertLocaleCookieValue(page, undefined); await page.getByRole('link', {name: 'Switch to German'}).click(); await expectDocumentLang('de'); - await assertCookie('de'); + await assertLocaleCookieValue(page, 'de'); await page.getByRole('link', {name: 'Client-Seite'}).click(); await expectDocumentLang('de'); @@ -39,5 +33,5 @@ it('clears the router cache when changing the locale', async ({page}) => { await expect( page.getByText('Dise Seite wird auf der Client-Seite initialisiert.') ).toBeAttached(); - await assertCookie('de'); + await assertLocaleCookieValue(page, 'de'); }); diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index 05e763648..61d522dd3 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -300,17 +300,25 @@ it('keeps the locale cookie updated when changing the locale and uses soft navig const tracker = getPageLoadTracker(context); await page.goto('/'); - await assertLocaleCookieValue(page, 'en'); + await assertLocaleCookieValue(page, undefined); expect(tracker.numPageLoads).toBe(1); - const link = page.getByRole('link', {name: 'Switch to German'}); - await link.hover(); - await assertLocaleCookieValue(page, 'en'); - await link.click(); + const linkDe = page.getByRole('link', {name: 'Switch to German'}); + await linkDe.hover(); + await assertLocaleCookieValue(page, undefined); + await linkDe.click(); await expect(page).toHaveURL('/de'); await assertLocaleCookieValue(page, 'de'); + const linkEn = page.getByRole('link', {name: 'Zu Englisch wechseln'}); + await linkEn.hover(); + await assertLocaleCookieValue(page, 'de'); + await linkEn.click(); + + await expect(page).toHaveURL('/'); + await assertLocaleCookieValue(page, 'en'); + // Currently, a root layout outside of the `[locale]` // folder is required for this to work. expect(tracker.numPageLoads).toBe(1); diff --git a/examples/example-app-router-playground/tests/utils.ts b/examples/example-app-router-playground/tests/utils.ts index 259b175bf..c065d1f29 100644 --- a/examples/example-app-router-playground/tests/utils.ts +++ b/examples/example-app-router-playground/tests/utils.ts @@ -1,4 +1,4 @@ -import {APIResponse, expect, Page} from '@playwright/test'; +import {APIResponse, Page, expect} from '@playwright/test'; export async function getAlternateLinks(response: APIResponse) { return ( @@ -14,15 +14,19 @@ export async function getAlternateLinks(response: APIResponse) { export async function assertLocaleCookieValue( page: Page, - value: string, + value?: string, otherProps?: Record ) { const cookie = (await page.context().cookies()).find( (cur) => cur.name === 'NEXT_LOCALE' ); - expect(cookie).toMatchObject({ - name: 'NEXT_LOCALE', - value, - ...otherProps - }); + if (value) { + expect(cookie).toMatchObject({ + name: 'NEXT_LOCALE', + value, + ...otherProps + }); + } else { + expect(cookie).toBeUndefined(); + } } diff --git a/examples/example-app-router-playground/tsconfig.json b/examples/example-app-router-playground/tsconfig.json index 710349cbe..1e1c7d72e 100644 --- a/examples/example-app-router-playground/tsconfig.json +++ b/examples/example-app-router-playground/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "eslint-config-molindo/tsconfig.json", "compilerOptions": { + "allowArbitraryExtensions": true, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/examples/example-app-router-single-locale/next.config.mjs b/examples/example-app-router-single-locale/next.config.mjs new file mode 100644 index 000000000..00dc8ae23 --- /dev/null +++ b/examples/example-app-router-single-locale/next.config.mjs @@ -0,0 +1,8 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default withNextIntl(nextConfig); diff --git a/examples/example-app-router-single-locale/package.json b/examples/example-app-router-single-locale/package.json index ea75a0501..0263e16f7 100644 --- a/examples/example-app-router-single-locale/package.json +++ b/examples/example-app-router-single-locale/package.json @@ -2,7 +2,7 @@ "name": "example-app-router-single-locale", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "playwright test", "build": "next build", diff --git a/examples/example-app-router-single-locale/src/app/layout.tsx b/examples/example-app-router-single-locale/src/app/layout.tsx index dd542a179..99c0dd7d7 100644 --- a/examples/example-app-router-single-locale/src/app/layout.tsx +++ b/examples/example-app-router-single-locale/src/app/layout.tsx @@ -1,5 +1,5 @@ import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; type Props = { @@ -9,19 +9,13 @@ type Props = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( next-intl - - {children} - + {children} ); diff --git a/examples/example-app-router-without-i18n-routing/next.config.mjs b/examples/example-app-router-without-i18n-routing/next.config.mjs new file mode 100644 index 000000000..00dc8ae23 --- /dev/null +++ b/examples/example-app-router-without-i18n-routing/next.config.mjs @@ -0,0 +1,8 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default withNextIntl(nextConfig); diff --git a/examples/example-app-router-without-i18n-routing/package.json b/examples/example-app-router-without-i18n-routing/package.json index 27bb9ddab..ec3686b6c 100644 --- a/examples/example-app-router-without-i18n-routing/package.json +++ b/examples/example-app-router-without-i18n-routing/package.json @@ -2,7 +2,7 @@ "name": "example-app-router-without-i18n-routing", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "playwright test", "build": "next build", diff --git a/examples/example-app-router-without-i18n-routing/src/app/layout.tsx b/examples/example-app-router-without-i18n-routing/src/app/layout.tsx index c9efefe2f..587b115f9 100644 --- a/examples/example-app-router-without-i18n-routing/src/app/layout.tsx +++ b/examples/example-app-router-without-i18n-routing/src/app/layout.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import {Inter} from 'next/font/google'; import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import './globals.css'; @@ -14,10 +14,6 @@ type Props = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( @@ -29,9 +25,7 @@ export default async function LocaleLayout({children}: Props) { inter.className )} > - - {children} - + {children} ); diff --git a/examples/example-app-router/.gitignore b/examples/example-app-router/.gitignore index 85549a55b..8b567be68 100644 --- a/examples/example-app-router/.gitignore +++ b/examples/example-app-router/.gitignore @@ -6,3 +6,4 @@ tsconfig.tsbuildinfo /playwright-report/ /playwright/.cache/ out +messages/en.d.json.ts diff --git a/examples/example-app-router/global.d.ts b/examples/example-app-router/global.d.ts index b749518b9..6cb8e005a 100644 --- a/examples/example-app-router/global.d.ts +++ b/examples/example-app-router/global.d.ts @@ -1,8 +1,9 @@ -import en from './messages/en.json'; +import {routing} from '@/i18n/routing'; +import messages from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Locale: (typeof routing.locales)[number]; + Messages: typeof messages; + } } diff --git a/examples/example-app-router/next.config.ts b/examples/example-app-router/next.config.ts index a33fd0c8f..3a75d5282 100644 --- a/examples/example-app-router/next.config.ts +++ b/examples/example-app-router/next.config.ts @@ -1,7 +1,11 @@ import {NextConfig} from 'next'; import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin(); +const withNextIntl = createNextIntlPlugin({ + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); const config: NextConfig = {}; diff --git a/examples/example-app-router/package.json b/examples/example-app-router/package.json index 8ebcf1b3e..442a4e51a 100644 --- a/examples/example-app-router/package.json +++ b/examples/example-app-router/package.json @@ -2,7 +2,7 @@ "name": "example-app-router", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbo", "lint": "eslint src && tsc && prettier src --check", "test": "pnpm run test:playwright && pnpm run test:jest", "test:playwright": "playwright test", diff --git a/examples/example-app-router/src/app/[locale]/layout.tsx b/examples/example-app-router/src/app/[locale]/layout.tsx index 77fab0507..4bf03a100 100644 --- a/examples/example-app-router/src/app/[locale]/layout.tsx +++ b/examples/example-app-router/src/app/[locale]/layout.tsx @@ -1,6 +1,6 @@ import {notFound} from 'next/navigation'; -import {getMessages, getTranslations, setRequestLocale} from 'next-intl/server'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, hasLocale, NextIntlClientProvider} from 'next-intl'; +import {getTranslations, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import {clsx} from 'clsx'; import {Inter} from 'next/font/google'; @@ -10,7 +10,7 @@ import './styles.css'; type Props = { children: ReactNode; - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; const inter = Inter({subsets: ['latin']}); @@ -30,24 +30,19 @@ export async function generateMetadata(props: Omit) { } export default async function LocaleLayout({children, params}: Props) { - const {locale} = await params; - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + const {locale} = await params; + if (!hasLocale(routing.locales, locale)) { notFound(); } // Enable static rendering setRequestLocale(locale); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - + {children} diff --git a/examples/example-app-router/src/app/[locale]/page.tsx b/examples/example-app-router/src/app/[locale]/page.tsx index d5c8dd100..f640ce5ad 100644 --- a/examples/example-app-router/src/app/[locale]/page.tsx +++ b/examples/example-app-router/src/app/[locale]/page.tsx @@ -1,10 +1,10 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import {use} from 'react'; import PageLayout from '@/components/PageLayout'; type Props = { - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; export default function IndexPage({params}: Props) { diff --git a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx index 48c65aadb..fad686586 100644 --- a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx +++ b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx @@ -1,10 +1,10 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import {use} from 'react'; import PageLayout from '@/components/PageLayout'; type Props = { - params: Promise<{locale: string}>; + params: Promise<{locale: Locale}>; }; export default function PathnamesPage({params}: Props) { diff --git a/examples/example-app-router/src/app/sitemap.ts b/examples/example-app-router/src/app/sitemap.ts index 257be4590..eb55e022d 100644 --- a/examples/example-app-router/src/app/sitemap.ts +++ b/examples/example-app-router/src/app/sitemap.ts @@ -1,6 +1,7 @@ import {MetadataRoute} from 'next'; +import {Locale} from 'next-intl'; import {host} from '@/config'; -import {routing, Locale} from '@/i18n/routing'; +import {routing} from '@/i18n/routing'; import {getPathname} from '@/i18n/navigation'; export default function sitemap(): MetadataRoute.Sitemap { diff --git a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx index bdeb8d49f..4c432d986 100644 --- a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx +++ b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx @@ -2,8 +2,8 @@ import clsx from 'clsx'; import {useParams} from 'next/navigation'; +import {Locale} from 'next-intl'; import {ChangeEvent, ReactNode, useTransition} from 'react'; -import {Locale} from '@/i18n/routing'; import {usePathname, useRouter} from '@/i18n/navigation'; type Props = { diff --git a/examples/example-app-router/src/i18n/request.ts b/examples/example-app-router/src/i18n/request.ts index df242f13d..370fc6d0c 100644 --- a/examples/example-app-router/src/i18n/request.ts +++ b/examples/example-app-router/src/i18n/request.ts @@ -1,22 +1,16 @@ +import {hasLocale} from 'next-intl'; import {getRequestConfig} from 'next-intl/server'; import {routing} from './routing'; export default getRequestConfig(async ({requestLocale}) => { - // This typically corresponds to the `[locale]` segment - let locale = await requestLocale; - - // Ensure that the incoming `locale` is valid - if (!locale || !routing.locales.includes(locale as any)) { - locale = routing.defaultLocale; - } + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; return { locale, - messages: ( - await (locale === 'en' - ? // When using Turbopack, this will enable HMR for `en` - import('../../messages/en.json') - : import(`../../messages/${locale}.json`)) - ).default + messages: (await import(`../../messages/${locale}.json`)).default }; }); diff --git a/examples/example-app-router/src/i18n/routing.ts b/examples/example-app-router/src/i18n/routing.ts index bfec3d988..56be693a6 100644 --- a/examples/example-app-router/src/i18n/routing.ts +++ b/examples/example-app-router/src/i18n/routing.ts @@ -6,11 +6,7 @@ export const routing = defineRouting({ pathnames: { '/': '/', '/pathnames': { - en: '/pathnames', de: '/pfadnamen' } } }); - -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; diff --git a/examples/example-app-router/src/middleware.ts b/examples/example-app-router/src/middleware.ts index b25094067..9fce69220 100644 --- a/examples/example-app-router/src/middleware.ts +++ b/examples/example-app-router/src/middleware.ts @@ -4,16 +4,8 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - matcher: [ - // Enable a redirect to a matching locale at the root - '/', - - // Set a cookie to remember the previous locale for - // all requests that have a locale prefix - '/(de|en)/:path*', - - // Enable redirects that add missing locales - // (e.g. `/pathnames` -> `/en/pathnames`) - '/((?!_next|_vercel|.*\\..*).*)' - ] + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' }; diff --git a/examples/example-app-router/tests/main.spec.ts b/examples/example-app-router/tests/main.spec.ts index d708e7f7c..78b1b3581 100644 --- a/examples/example-app-router/tests/main.spec.ts +++ b/examples/example-app-router/tests/main.spec.ts @@ -58,19 +58,13 @@ it('can be used to localize the page', async ({page}) => { page.getByRole('heading', {name: 'next-intl Beispiel'}); }); -it('sets a cookie', async ({page}) => { +it('sets a cookie when necessary', async ({page}) => { function getCookieValue() { return page.evaluate(() => document.cookie); } const response = await page.goto('/en'); - const value = await response?.headerValue('set-cookie'); - expect(value).toContain('NEXT_LOCALE=en;'); - expect(value).toContain('Path=/;'); - expect(value).toContain('SameSite=lax'); - expect(value).toContain('Max-Age=31536000;'); - expect(value).toContain('Expires='); - expect(await getCookieValue()).toBe('NEXT_LOCALE=en'); + expect(await response?.headerValue('set-cookie')).toBe(null); await page .getByRole('combobox', {name: 'Change language'}) @@ -93,6 +87,16 @@ it('sets a cookie', async ({page}) => { expect(await getCookieValue()).toBe('NEXT_LOCALE=de'); }); +it("sets a cookie when requesting a locale that doesn't match the `accept-language` header", async ({ + page +}) => { + const response = await page.goto('/de'); + const value = await response?.headerValue('set-cookie'); + expect(value).toContain('NEXT_LOCALE=de;'); + expect(value).toContain('Path=/;'); + expect(value).toContain('SameSite=lax'); +}); + it('serves a robots.txt', async ({page}) => { const response = await page.goto('/robots.txt'); const body = await response?.body(); diff --git a/examples/example-app-router/tsconfig.json b/examples/example-app-router/tsconfig.json index 49aa1ee30..a4ea571af 100644 --- a/examples/example-app-router/tsconfig.json +++ b/examples/example-app-router/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "eslint-config-molindo/tsconfig.json", "compilerOptions": { + "allowArbitraryExtensions": true, "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/examples/example-pages-router-advanced/config/babel.config.js b/examples/example-pages-router-advanced/config/babel.config.js deleted file mode 100644 index 1066d32f7..000000000 --- a/examples/example-pages-router-advanced/config/babel.config.js +++ /dev/null @@ -1,5 +0,0 @@ -// Used by Jest - -module.exports = { - presets: ['next/babel'] -}; diff --git a/examples/example-pages-router-advanced/config/jest.json b/examples/example-pages-router-advanced/config/jest.json deleted file mode 100644 index 54ba3e92b..000000000 --- a/examples/example-pages-router-advanced/config/jest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "testEnvironment": "jsdom", - "rootDir": "../", - "transform": { - "\\.tsx$": ["babel-jest", {"configFile": "./config/babel.config.js"}] - } -} diff --git a/examples/example-pages-router-advanced/global.d.ts b/examples/example-pages-router-advanced/global.d.ts index b749518b9..bc828b1cf 100644 --- a/examples/example-pages-router-advanced/global.d.ts +++ b/examples/example-pages-router-advanced/global.d.ts @@ -1,8 +1,7 @@ -import en from './messages/en.json'; +import messages from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Messages: typeof messages; + } } diff --git a/examples/example-pages-router-advanced/jest.config.js b/examples/example-pages-router-advanced/jest.config.js new file mode 100644 index 000000000..d9f09280e --- /dev/null +++ b/examples/example-pages-router-advanced/jest.config.js @@ -0,0 +1,9 @@ +/* eslint-env node */ +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({dir: './'}); + +module.exports = createJestConfig({ + testEnvironment: 'jsdom', + rootDir: 'src' +}); diff --git a/examples/example-pages-router-advanced/package.json b/examples/example-pages-router-advanced/package.json index 17146e380..d048bcd4f 100644 --- a/examples/example-pages-router-advanced/package.json +++ b/examples/example-pages-router-advanced/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc && prettier src --check", - "test": "jest --config config/jest.json", + "test": "jest", "build": "next build", "start": "next start" }, diff --git a/examples/example-pages-router-advanced/src/components/Navigation.tsx b/examples/example-pages-router-advanced/src/components/Navigation.tsx index b09a77870..664f501ac 100644 --- a/examples/example-pages-router-advanced/src/components/Navigation.tsx +++ b/examples/example-pages-router-advanced/src/components/Navigation.tsx @@ -6,7 +6,7 @@ export default function Navigation() { const t = useTranslations('Navigation'); const {locale, locales, route} = useRouter(); - const otherLocale = locales?.find((cur) => cur !== locale); + const otherLocale = locales?.find((cur) => cur !== locale) as string; return (
diff --git a/examples/example-pages-router-advanced/src/pages/_app.tsx b/examples/example-pages-router-advanced/src/pages/_app.tsx index 14f75d12d..165d5fcbb 100644 --- a/examples/example-pages-router-advanced/src/pages/_app.tsx +++ b/examples/example-pages-router-advanced/src/pages/_app.tsx @@ -1,9 +1,9 @@ import {AppProps} from 'next/app'; import {useRouter} from 'next/router'; -import {NextIntlClientProvider} from 'next-intl'; +import {Messages, NextIntlClientProvider} from 'next-intl'; type PageProps = { - messages: IntlMessages; + messages: Messages; now: number; }; diff --git a/examples/example-pages-router-advanced/src/pages/index.tsx b/examples/example-pages-router-advanced/src/pages/index.tsx index 51505127f..2f5ddd32e 100644 --- a/examples/example-pages-router-advanced/src/pages/index.tsx +++ b/examples/example-pages-router-advanced/src/pages/index.tsx @@ -13,7 +13,7 @@ export default function Index() {
{t.rich('description', { - locale, + locale: locale!, p: (children) =>

{children}

, code: (children) => {children} })} diff --git a/examples/example-pages-router-legacy/package.json b/examples/example-pages-router-legacy/package.json index da7d4bf97..791af01fe 100644 --- a/examples/example-pages-router-legacy/package.json +++ b/examples/example-pages-router-legacy/package.json @@ -11,7 +11,7 @@ "next": "^12.0.0", "react": "^17.0.0", "react-dom": "^17.0.0", - "use-intl": "^3.0.0" + "next-intl": "^3.0.0" }, "devDependencies": { "prettier": "^3.3.3" diff --git a/examples/example-pages-router-legacy/src/pages/_app.js b/examples/example-pages-router-legacy/src/pages/_app.js index f5cb63e91..d9a176e0c 100644 --- a/examples/example-pages-router-legacy/src/pages/_app.js +++ b/examples/example-pages-router-legacy/src/pages/_app.js @@ -1,13 +1,13 @@ import Head from 'next/head'; import {useRouter} from 'next/router'; -import {IntlProvider} from 'use-intl'; +import {NextIntlClientProvider} from 'next-intl'; export default function App({Component, pageProps}) { const router = useRouter(); const {messages, now, ...rest} = pageProps; return ( - example-pages-router-legacy - + ); } diff --git a/examples/example-pages-router-legacy/src/pages/index.js b/examples/example-pages-router-legacy/src/pages/index.js index db4395258..2b33e42ba 100644 --- a/examples/example-pages-router-legacy/src/pages/index.js +++ b/examples/example-pages-router-legacy/src/pages/index.js @@ -1,4 +1,4 @@ -import {useFormatter, useNow, useTranslations} from 'use-intl'; +import {useFormatter, useNow, useTranslations} from 'next-intl'; import PageLayout from '../components/PageLayout'; export default function Index() { diff --git a/examples/example-pages-router/global.d.ts b/examples/example-pages-router/global.d.ts index b749518b9..bc828b1cf 100644 --- a/examples/example-pages-router/global.d.ts +++ b/examples/example-pages-router/global.d.ts @@ -1,8 +1,7 @@ -import en from './messages/en.json'; +import messages from './messages/en.json'; -type Messages = typeof en; - -declare global { - // Use type safe message keys with `next-intl` - interface IntlMessages extends Messages {} +declare module 'next-intl' { + interface AppConfig { + Messages: typeof messages; + } } diff --git a/examples/example-pages-router/src/components/LocaleSwitcher.tsx b/examples/example-pages-router/src/components/LocaleSwitcher.tsx index b76d34fc1..565931671 100644 --- a/examples/example-pages-router/src/components/LocaleSwitcher.tsx +++ b/examples/example-pages-router/src/components/LocaleSwitcher.tsx @@ -6,7 +6,7 @@ export default function LocaleSwitcher() { const t = useTranslations('LocaleSwitcher'); const {locale, locales, route} = useRouter(); - const otherLocale = locales?.find((cur) => cur !== locale); + const otherLocale = locales?.find((cur) => cur !== locale) as string; return ( diff --git a/examples/example-use-intl/global.d.ts b/examples/example-use-intl/global.d.ts new file mode 100644 index 000000000..9db98bbdf --- /dev/null +++ b/examples/example-use-intl/global.d.ts @@ -0,0 +1,10 @@ +import 'use-intl'; +import messages from './messages/en.json'; +import {locales} from './src/config'; + +declare module 'use-intl' { + interface AppConfig { + Locale: (typeof locales)[number]; + Messages: typeof messages; + } +} diff --git a/examples/example-use-intl/messages/en.json b/examples/example-use-intl/messages/en.json new file mode 100644 index 000000000..51f26812a --- /dev/null +++ b/examples/example-use-intl/messages/en.json @@ -0,0 +1,5 @@ +{ + "App": { + "hello": "Hello {username}!" + } +} diff --git a/examples/example-use-intl/src/config.tsx b/examples/example-use-intl/src/config.tsx new file mode 100644 index 000000000..a8a68c781 --- /dev/null +++ b/examples/example-use-intl/src/config.tsx @@ -0,0 +1 @@ +export const locales = ['en'] as const; diff --git a/examples/example-use-intl/src/main.tsx b/examples/example-use-intl/src/main.tsx index dc772d8ab..d48504350 100644 --- a/examples/example-use-intl/src/main.tsx +++ b/examples/example-use-intl/src/main.tsx @@ -1,16 +1,13 @@ import {StrictMode} from 'react'; import ReactDOM from 'react-dom/client'; import {IntlProvider} from 'use-intl'; +import en from '../messages/en.json'; import App from './App.tsx'; // You can get the messages from anywhere you like. You can also // fetch them from within a component and then render the provider // along with your app once you have the messages. -const messages = { - App: { - hello: 'Hello {username}!' - } -}; +const messages = en; const node = document.getElementById('root'); diff --git a/examples/example-use-intl/tsconfig.json b/examples/example-use-intl/tsconfig.json index ba939e9e6..309ae7f00 100644 --- a/examples/example-use-intl/tsconfig.json +++ b/examples/example-use-intl/tsconfig.json @@ -13,6 +13,6 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"], + "include": ["src", "global.d.ts"], "references": [{"path": "./tsconfig.node.json"}] } diff --git a/examples/example-use-intl/vite.config.ts b/examples/example-use-intl/vite.config.ts index 0d4024d8f..d2e8b6716 100644 --- a/examples/example-use-intl/vite.config.ts +++ b/examples/example-use-intl/vite.config.ts @@ -1,11 +1,6 @@ -import {defineConfig} from 'vite'; import react from '@vitejs/plugin-react'; +import {defineConfig} from 'vite'; export default defineConfig({ - plugins: [react()], - - // TODO: Remove after use-intl has full ESM support - // https://vitejs.dev/guide/dep-pre-bundling#monorepos-and-linked-dependencies - optimizeDeps: {include: ['use-intl']}, - build: {commonjsOptions: {include: [/use-intl/, /node_modules/]}} + plugins: [react()] }); diff --git a/lerna.json b/lerna.json index 7f5c34fbd..a78603321 100644 --- a/lerna.json +++ b/lerna.json @@ -8,10 +8,7 @@ "changelogPreset": "conventional-changelog-conventionalcommits", "command": { "publish": { - "removePackageFields": [ - "devDependencies", - "prettier" - ], + "removePackageFields": ["devDependencies", "prettier"], "yes": true }, "version": { diff --git a/package.json b/package.json index ca8821447..86029cae1 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,9 @@ "publish": "lerna publish" }, "devDependencies": { - "@babel/core": "^7.24.7", - "@babel/preset-env": "^7.24.7", - "@babel/preset-react": "^7.24.7", - "@babel/preset-typescript": "^7.24.7", "@lerna-lite/cli": "^3.9.0", "@lerna-lite/publish": "^3.9.0", - "@rollup/plugin-babel": "^6.0.3", - "@rollup/plugin-commonjs": "^26.0.1", - "@rollup/plugin-node-resolve": "^15.2.1", - "@rollup/plugin-replace": "^5.0.7", - "@rollup/plugin-terser": "^0.4.3", "conventional-changelog-conventionalcommits": "^7.0.0", - "execa": "^9.2.0", - "rollup": "^4.18.0", "turbo": "^2.2.3" }, "pnpm": { diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 501127992..e29f38792 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -3,81 +3,51 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { name: "import * from 'next-intl' (react-client)", - path: 'dist/production/index.react-client.js', - limit: '14.095 KB' - }, - { - name: "import * from 'next-intl' (react-server)", - path: 'dist/production/index.react-server.js', - limit: '15.385 KB' + path: 'dist/esm/production/index.react-client.js', + limit: '13.125 KB' }, { - name: "import {createSharedPathnamesNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', - import: '{createSharedPathnamesNavigation}', - limit: '4.145 KB' + name: "import {NextIntlClientProvider} from 'next-intl' (react-client)", + import: '{NextIntlClientProvider}', + path: 'dist/esm/production/index.react-client.js', + limit: '1.005 KB' }, { - name: "import {createLocalizedPathnamesNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', - import: '{createLocalizedPathnamesNavigation}', - limit: '4.145 KB' + name: "import * from 'next-intl' (react-server)", + path: 'dist/esm/production/index.react-server.js', + limit: '14.095 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', + path: 'dist/esm/production/navigation.react-client.js', import: '{createNavigation}', - limit: '4.145 KB' - }, - { - name: "import {createSharedPathnamesNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', - import: '{createSharedPathnamesNavigation}', - limit: '16.835 KB' - }, - { - name: "import {createLocalizedPathnamesNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', - import: '{createLocalizedPathnamesNavigation}', - limit: '16.875 KB' + limit: '2.305 KB' }, { name: "import {createNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', + path: 'dist/esm/production/navigation.react-server.js', import: '{createNavigation}', - limit: '16.855 KB' + limit: '3.075 KB' }, { name: "import * from 'next-intl/server' (react-client)", - path: 'dist/production/server.react-client.js', + path: 'dist/esm/production/server.react-client.js', limit: '1 KB' }, { name: "import * from 'next-intl/server' (react-server)", - path: 'dist/production/server.react-server.js', - limit: '14.635 KB' + path: 'dist/esm/production/server.react-server.js', + limit: '13.395 KB' }, { - name: "import createMiddleware from 'next-intl/middleware'", - path: 'dist/production/middleware.js', - limit: '9.725 KB' + name: "import * from 'next-intl/middleware'", + path: 'dist/esm/production/middleware.js', + limit: '9.505 KB' }, { name: "import * from 'next-intl/routing'", - path: 'dist/production/routing.js', + path: 'dist/esm/production/routing.js', limit: '1 KB' - }, - { - name: "import * from 'next-intl' (react-client, ESM)", - path: 'dist/esm/index.react-client.js', - import: '*', - limit: '14.375 kB' - }, - { - name: "import {NextIntlProvider} from 'next-intl' (react-client, ESM)", - path: 'dist/esm/index.react-client.js', - import: '{NextIntlClientProvider}', - limit: '1.425 kB' } ]; diff --git a/packages/next-intl/__mocks__/react.tsx b/packages/next-intl/__mocks__/react.tsx index c70f628f1..906d0ead2 100644 --- a/packages/next-intl/__mocks__/react.tsx +++ b/packages/next-intl/__mocks__/react.tsx @@ -1,4 +1,4 @@ -import {isPromise} from '../src/shared/utils'; +import {isPromise} from '../src/shared/utils.js'; // @ts-expect-error -- React uses CJS export * from 'react'; diff --git a/packages/next-intl/config.d.ts b/packages/next-intl/config.d.ts index 86c346051..fd48ba85d 100644 --- a/packages/next-intl/config.d.ts +++ b/packages/next-intl/config.d.ts @@ -1,3 +1,4 @@ -import config from './dist/types/src/config'; +// Needed for projects with `moduleResolution: 'node'` +import config from './dist/types/config'; export = config; diff --git a/packages/next-intl/eslint.config.mjs b/packages/next-intl/eslint.config.mjs index 92ba53b1f..9308ce875 100644 --- a/packages/next-intl/eslint.config.mjs +++ b/packages/next-intl/eslint.config.mjs @@ -6,6 +6,28 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ 'react-compiler': reactCompilerPlugin }, rules: { - 'react-compiler/react-compiler': 'error' + 'react-compiler/react-compiler': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + // Because: + // - Avoid hardcoding the `locale` param + // - Prepare for a new API in Next.js to read params deeply + // - Avoid issues with `dynamicIO` + name: 'next/navigation.js', + importNames: ['useParams'] + } + ] + } + ], + + // Strict type imports to avoid side effects + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', + 'import/no-duplicates': ['error', {'prefer-inline': true}], + 'import/extensions': 'error' } }); diff --git a/packages/next-intl/middleware.d.ts b/packages/next-intl/middleware.d.ts index 41dddf9a1..2222782a3 100644 --- a/packages/next-intl/middleware.d.ts +++ b/packages/next-intl/middleware.d.ts @@ -1,3 +1,4 @@ -import createMiddleware from './dist/types/src/middleware'; +// Needed for projects with `moduleResolution: 'node'` +import createMiddleware from './dist/types/middleware'; export = createMiddleware; diff --git a/packages/next-intl/navigation.d.ts b/packages/next-intl/navigation.d.ts index 81ded918e..ea19b24e0 100644 --- a/packages/next-intl/navigation.d.ts +++ b/packages/next-intl/navigation.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/navigation.react-client'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/navigation.react-client'; diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 195b97db9..03e4e875d 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -21,46 +21,69 @@ "test": "TZ=Europe/Berlin vitest", "lint": "pnpm run lint:source && pnpm run lint:package", "lint:source": "eslint src test && tsc --noEmit && pnpm run lint:prettier", - "lint:package": "publint && attw --pack", + "lint:package": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm", "lint:prettier": "prettier src --check", "prepublishOnly": "turbo build && cp ../../README.md .", "postpublish": "git checkout . && rm ./README.md", "size": "size-limit" }, - "main": "./dist/index.react-client.js", - "module": "./dist/esm/index.react-client.js", - "typings": "./dist/types/src/index.react-client.d.ts", + "type": "module", + "main": "./dist/esm/production/index.react-client.js", + "typings": "./dist/types/index.react-client.d.ts", "exports": { ".": { - "types": "./dist/types/src/index.react-client.d.ts", - "react-server": "./dist/esm/index.react-server.js", - "default": "./dist/index.react-client.js" + "types": "./dist/types/index.react-client.d.ts", + "react-server": { + "development": "./dist/esm/development/index.react-server.js", + "default": "./dist/esm/production/index.react-server.js" + }, + "development": "./dist/esm/development/index.react-client.js", + "default": "./dist/esm/production/index.react-client.js" }, "./server": { - "types": "./server.d.ts", - "react-server": "./dist/esm/server.react-server.js", - "default": "./dist/server.react-client.js" + "types": "./dist/types/server.react-server.d.ts", + "react-server": { + "development": "./dist/esm/development/server.react-server.js", + "default": "./dist/esm/production/server.react-server.js" + }, + "development": "./dist/esm/development/server.react-client.js", + "default": "./dist/esm/production/server.react-client.js" }, "./config": { - "types": "./config.d.ts", - "default": "./dist/config.js" + "types": "./dist/types/config.d.ts", + "development": "./dist/esm/development/config.js", + "default": "./dist/esm/production/config.js" }, "./middleware": { - "types": "./middleware.d.ts", - "default": "./dist/middleware.js" + "types": "./dist/types/middleware.d.ts", + "development": "./dist/esm/development/middleware.js", + "default": "./dist/esm/production/middleware.js" }, "./navigation": { - "types": "./navigation.d.ts", - "react-server": "./dist/esm/navigation.react-server.js", - "default": "./dist/navigation.react-client.js" + "types": "./dist/types/navigation.react-client.d.ts", + "react-server": { + "development": "./dist/esm/development/navigation.react-server.js", + "default": "./dist/esm/production/navigation.react-server.js" + }, + "development": "./dist/esm/development/navigation.react-client.js", + "default": "./dist/esm/production/navigation.react-client.js" }, "./routing": { - "types": "./routing.d.ts", - "default": "./dist/routing.js" + "types": "./dist/types/routing.d.ts", + "development": "./dist/esm/development/routing.js", + "default": "./dist/esm/production/routing.js" }, "./plugin": { - "types": "./plugin.d.ts", - "default": "./dist/plugin.js" + "import": { + "types": "./dist/types/plugin.d.ts", + "development": "./dist/esm/development/plugin.js", + "default": "./dist/esm/production/plugin.js" + }, + "require": { + "types": "./plugin.d.cts", + "default": "./dist/cjs/development/plugin.cjs" + }, + "default": "./dist/esm/production/plugin.js" } }, "files": [ @@ -69,6 +92,7 @@ "navigation.d.ts", "middleware.d.ts", "plugin.d.ts", + "plugin.d.cts", "routing.d.ts", "config.d.ts" ], @@ -91,13 +115,19 @@ "use-intl": "workspace:^" }, "peerDependencies": { - "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } }, "devDependencies": { - "@arethetypeswrong/cli": "^0.15.3", + "@arethetypeswrong/cli": "^0.16.4", "@edge-runtime/vm": "^3.2.0", - "@size-limit/preset-big-lib": "^11.1.4", + "@size-limit/preset-small-lib": "^11.1.4", "@testing-library/react": "^16.0.0", "@types/negotiator": "^0.6.3", "@types/node": "^20.14.5", @@ -115,6 +145,7 @@ "rollup": "^4.18.0", "rollup-plugin-preserve-directives": "0.4.0", "size-limit": "^11.1.4", + "tools": "workspace:^", "typescript": "^5.5.3", "vitest": "^2.0.2" }, diff --git a/packages/next-intl/plugin.d.cts b/packages/next-intl/plugin.d.cts new file mode 100644 index 000000000..266baeabc --- /dev/null +++ b/packages/next-intl/plugin.d.cts @@ -0,0 +1,3 @@ +import createNextIntlPlugin from './dist/types/plugin.ts'; + +export = createNextIntlPlugin; diff --git a/packages/next-intl/plugin.d.ts b/packages/next-intl/plugin.d.ts index 476ab78b5..8683332fa 100644 --- a/packages/next-intl/plugin.d.ts +++ b/packages/next-intl/plugin.d.ts @@ -1,8 +1,4 @@ -import {NextConfig} from 'next'; +// Needed for projects with `moduleResolution: 'node'` +import plugin from './dist/types/plugin'; -function createNextIntlPlugin( - i18nPath?: string -): (config?: NextConfig) => NextConfig; - -// Currently only available via CJS -export = createNextIntlPlugin; +export = plugin; diff --git a/packages/next-intl/rollup.config.js b/packages/next-intl/rollup.config.js new file mode 100644 index 000000000..7a2543390 --- /dev/null +++ b/packages/next-intl/rollup.config.js @@ -0,0 +1,76 @@ +import preserveDirectives from 'rollup-plugin-preserve-directives'; +import {getBuildConfig} from 'tools'; +import pkg from './package.json' with {type: 'json'}; + +function rewriteBundle(regex, replaceFn) { + return { + name: 'rewrite-bundle', + generateBundle(options, bundle) { + for (const fileName of Object.keys(bundle)) { + const chunk = bundle[fileName]; + const updatedCode = chunk.code.replace(regex, replaceFn); + chunk.code = updatedCode; + } + } + }; +} + +export default [ + ...getBuildConfig({ + input: { + 'index.react-client': 'src/index.react-client.tsx', + 'index.react-server': 'src/index.react-server.tsx', + + 'navigation.react-client': 'src/navigation.react-client.tsx', + 'navigation.react-server': 'src/navigation.react-server.tsx', + + 'server.react-client': 'src/server.react-client.tsx', + 'server.react-server': 'src/server.react-server.tsx', + + middleware: 'src/middleware.tsx', + routing: 'src/routing.tsx', + plugin: 'src/plugin.tsx', + config: 'src/config.tsx' + }, + external: [ + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.peerDependencies), + 'react/jsx-runtime', + 'next-intl/config', + 'use-intl/core', + 'use-intl/react' + ], + output: { + preserveModules: true + }, + onwarn(warning, warn) { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; + warn(warning); + }, + plugins: [ + preserveDirectives(), + + // Since we're writing our code with ESM, we have to import e.g. from + // `next/link.js`. While this can be used in production, since Next.js 15 + // this somehow causes hard reloads when `next/link.js` is imported and + // used to link to another page. There might be some optimizations + // happening in the background that we can't control. Due to this, it + // seems safer to update imports to a version that doesn't have `.js` + // suffix and let the bundler optimize them. + rewriteBundle(/['"]next\/(\w+)\.js['"]/g, (match, p1) => + match.replace(`next/${p1}.js`, `next/${p1}`) + ) + ] + }), + ...getBuildConfig({ + env: ['development'], + input: { + plugin: 'src/plugin.tsx' + }, + output: { + dir: 'dist/cjs/development', + format: 'cjs', + entryFileNames: '[name].cjs' + } + }) +]; diff --git a/packages/next-intl/rollup.config.mjs b/packages/next-intl/rollup.config.mjs deleted file mode 100644 index bb8702657..000000000 --- a/packages/next-intl/rollup.config.mjs +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-env node */ -import preserveDirectives from 'rollup-plugin-preserve-directives'; -import getBuildConfig from '../../scripts/getBuildConfig.mjs'; - -const config = { - input: { - 'index.react-client': 'src/index.react-client.tsx', - 'index.react-server': 'src/index.react-server.tsx', - - 'navigation.react-client': 'src/navigation.react-client.tsx', - 'navigation.react-server': 'src/navigation.react-server.tsx', - - 'server.react-client': 'src/server.react-client.tsx', - 'server.react-server': 'src/server.react-server.tsx', - - middleware: 'src/middleware.tsx', - routing: 'src/routing.tsx', - plugin: 'src/plugin.tsx', - config: 'src/config.tsx' - }, - external: ['next-intl/config', /use-intl/], - output: { - preserveModules: true - }, - onwarn(warning, warn) { - if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; - warn(warning); - }, - plugins: [preserveDirectives()] -}; - -export default [ - getBuildConfig({ - ...config, - env: 'development' - }), - getBuildConfig({ - ...config, - output: { - ...config.output, - format: 'es' - }, - env: 'esm' - }), - getBuildConfig({ - ...config, - env: 'production' - }) -]; diff --git a/packages/next-intl/routing.d.ts b/packages/next-intl/routing.d.ts index 13ee0d973..51815e313 100644 --- a/packages/next-intl/routing.d.ts +++ b/packages/next-intl/routing.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/routing'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/routing'; diff --git a/packages/next-intl/server.d.ts b/packages/next-intl/server.d.ts index e53f54959..c0a7dc884 100644 --- a/packages/next-intl/server.d.ts +++ b/packages/next-intl/server.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/server/react-server'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/server/react-server'; diff --git a/packages/next-intl/src/index.react-client.tsx b/packages/next-intl/src/index.react-client.tsx index 429480f8a..ccc872cea 100644 --- a/packages/next-intl/src/index.react-client.tsx +++ b/packages/next-intl/src/index.react-client.tsx @@ -6,4 +6,4 @@ * from `./react-server` instead. */ -export * from './react-client'; +export * from './react-client/index.js'; diff --git a/packages/next-intl/src/index.react-server.tsx b/packages/next-intl/src/index.react-server.tsx index 812d94f58..b80740326 100644 --- a/packages/next-intl/src/index.react-server.tsx +++ b/packages/next-intl/src/index.react-server.tsx @@ -1 +1 @@ -export * from './react-server'; +export * from './react-server/index.js'; diff --git a/packages/next-intl/src/middleware.tsx b/packages/next-intl/src/middleware.tsx index 0b94d4d5a..d02f285ee 100644 --- a/packages/next-intl/src/middleware.tsx +++ b/packages/next-intl/src/middleware.tsx @@ -1 +1 @@ -export {default} from './middleware/index'; +export {default} from './middleware/index.js'; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 2a0be8b3e..80fbaf1d0 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment edge-runtime -import {NextRequest} from 'next/server'; +import {NextRequest} from 'next/server.js'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; -import {Pathnames} from '../routing'; -import {receiveRoutingConfig} from '../routing/config'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; +import {receiveRoutingConfig} from '../routing/config.js'; +import type {Pathnames} from '../routing.js'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.js'; describe.each([{basePath: undefined}, {basePath: '/base'}])( 'basePath: $basePath', @@ -103,7 +103,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://example.com/'), resolvedLocale: 'en', - localizedPathnames: pathnames['/'] + localizedPathnames: pathnames['/'], + internalTemplateName: '/' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -133,7 +135,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://example.com/de/ueber'), resolvedLocale: 'de', - localizedPathnames: pathnames['/about'] + localizedPathnames: pathnames['/about'], + internalTemplateName: '/about' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -146,7 +149,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://example.com/users/2'), resolvedLocale: 'en', - localizedPathnames: pathnames['/users/[userId]'] + localizedPathnames: pathnames['/users/[userId]'], + internalTemplateName: '/users/[userId]' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -155,6 +159,35 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( ]); }); + it('works for partial pathnames with undefined entries', () => { + const routing = receiveRoutingConfig({ + defaultLocale: 'en', + locales: ['en', 'de', 'ja'], + localePrefix: 'as-needed' + }); + const pathnames = { + '/': '/', + '/about': { + de: '/ueber' + } + }; + + expect( + getAlternateLinksHeaderValue({ + routing, + request: getMockRequest('https://example.com/about'), + resolvedLocale: 'en', + localizedPathnames: pathnames['/about'], + internalTemplateName: '/about' + }).split(', ') + ).toEqual([ + `; rel="alternate"; hreflang="en"`, + `; rel="alternate"; hreflang="de"`, + `; rel="alternate"; hreflang="ja"`, + `; rel="alternate"; hreflang="x-default"` + ]); + }); + it('works for prefixed routing (always)', () => { const routing = receiveRoutingConfig({ defaultLocale: 'en', @@ -184,7 +217,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, - `; rel="alternate"; hreflang="es"` + `; rel="alternate"; hreflang="es"`, + `; rel="alternate"; hreflang="x-default"` ]); }); @@ -196,8 +230,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( domains: [ { domain: 'example.com', - defaultLocale: 'en' - // (supports all locales) + defaultLocale: 'en', + locales: ['en', 'es', 'fr'] }, { domain: 'example.es', @@ -263,8 +297,8 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( domains: [ { domain: 'example.com', - defaultLocale: 'en' - // (supports all locales) + defaultLocale: 'en', + locales: ['en', 'es', 'fr'] }, { domain: 'example.es', @@ -403,25 +437,29 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://en.example.com/about'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/about'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/fr/a-propos'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://fr.example.com/a-propos'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/about'] + localizedPathnames: routing.pathnames!['/about'], + internalTemplateName: '/about' }) ] .map((links) => links.split(', ')) @@ -439,25 +477,29 @@ describe.each([{basePath: undefined}, {basePath: '/base'}])( routing, request: getMockRequest('https://en.example.com/users/42'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/users/42'), resolvedLocale: 'en', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://ca.example.com/fr/utilisateurs/42'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }), getAlternateLinksHeaderValue({ routing, request: getMockRequest('https://fr.example.com/utilisateurs/42'), resolvedLocale: 'fr', - localizedPathnames: routing.pathnames!['/users/[userId]'] + localizedPathnames: routing.pathnames!['/users/[userId]'], + internalTemplateName: '/users/[userId]' }) ] .map((links) => links.split(', ')) @@ -600,7 +642,8 @@ describe('trailingSlash: true', () => { routing, request: new NextRequest(new URL('https://example.com' + pathname)), resolvedLocale: 'en', - localizedPathnames: pathnames['/about'] + localizedPathnames: pathnames['/about'], + internalTemplateName: '/about' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, @@ -617,7 +660,8 @@ describe('trailingSlash: true', () => { routing, request: new NextRequest(new URL('https://example.com' + pathname)), resolvedLocale: 'en', - localizedPathnames: pathnames['/'] + localizedPathnames: pathnames['/'], + internalTemplateName: '/' }).split(', ') ).toEqual([ `; rel="alternate"; hreflang="en"`, diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 4ce604ee2..94b259ba6 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -1,12 +1,12 @@ -import {NextRequest} from 'next/server'; -import {ResolvedRoutingConfig} from '../routing/config'; -import { +import type {NextRequest} from 'next/server.js'; +import type {ResolvedRoutingConfig} from '../routing/config.js'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {normalizeTrailingSlash} from '../shared/utils'; +} from '../routing/types.js'; +import {normalizeTrailingSlash} from '../shared/utils.js'; import { applyBasePath, formatTemplatePathname, @@ -14,7 +14,7 @@ import { getLocalePrefixes, getNormalizedPathname, isLocaleSupportedOnDomain -} from './utils'; +} from './utils.js'; /** * See https://developers.google.com/search/docs/specialty/international/localized-versions @@ -25,6 +25,7 @@ export default function getAlternateLinksHeaderValue< AppPathnames extends Pathnames | undefined, AppDomains extends DomainsConfig | undefined >({ + internalTemplateName, localizedPathnames, request, resolvedLocale, @@ -42,6 +43,7 @@ export default function getAlternateLinksHeaderValue< request: NextRequest; resolvedLocale: AppLocales[number]; localizedPathnames?: Pathnames[string]; + internalTemplateName?: string; }) { const normalizedUrl = request.nextUrl.clone(); @@ -59,7 +61,7 @@ export default function getAlternateLinksHeaderValue< routing.localePrefix ); - function getAlternateEntry(url: URL, locale: string) { + function getAlternateEntry(url: URL, locale: AppLocales[number]) { url.pathname = normalizeTrailingSlash(url.pathname); if (request.nextUrl.basePath) { @@ -72,10 +74,12 @@ export default function getAlternateLinksHeaderValue< function getLocalizedPathname(pathname: string, locale: AppLocales[number]) { if (localizedPathnames && typeof localizedPathnames === 'object') { + const sourceTemplate = localizedPathnames[resolvedLocale]; + return formatTemplatePathname( pathname, - localizedPathnames[resolvedLocale], - localizedPathnames[locale] + sourceTemplate ?? internalTemplateName!, + localizedPathnames[locale] ?? internalTemplateName! ); } else { return pathname; @@ -143,14 +147,16 @@ export default function getAlternateLinksHeaderValue< // Add x-default entry const shouldAddXDefault = // For domain-based routing there is no reasonable x-default - !routing.domains && - (routing.localePrefix.mode !== 'always' || normalizedUrl.pathname === '/'); + !routing.domains || routing.domains.length === 0; if (shouldAddXDefault) { - const url = new URL( - getLocalizedPathname(normalizedUrl.pathname, routing.defaultLocale), - normalizedUrl + const localizedPathname = getLocalizedPathname( + normalizedUrl.pathname, + routing.defaultLocale ); - links.push(getAlternateEntry(url, 'x-default')); + if (localizedPathname) { + const url = new URL(localizedPathname, normalizedUrl); + links.push(getAlternateEntry(url, 'x-default')); + } } return links.join(', '); diff --git a/packages/next-intl/src/middleware/index.tsx b/packages/next-intl/src/middleware/index.tsx index ccc76b9f6..b5a6b6397 100644 --- a/packages/next-intl/src/middleware/index.tsx +++ b/packages/next-intl/src/middleware/index.tsx @@ -2,4 +2,4 @@ * The middleware, available as `next-intl/middleware`. */ -export {default} from './middleware'; +export {default} from './middleware.js'; diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 7449040eb..de123481d 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -1,15 +1,23 @@ // @vitest-environment edge-runtime import {RequestCookies} from 'next/dist/compiled/@edge-runtime/cookies'; -import {NextRequest, NextResponse} from 'next/server'; +import {NextRequest, NextResponse} from 'next/server.js'; import {pathToRegexp} from 'path-to-regexp'; -import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import createMiddleware from '../middleware'; -import {Pathnames, defineRouting} from '../routing'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; +import createMiddleware from '../middleware.js'; +import {type Pathnames, defineRouting} from '../routing.js'; const COOKIE_LOCALE_NAME = 'NEXT_LOCALE'; -vi.mock('next/server', async (importActual) => { +vi.mock('next/server.js', async (importActual) => { const ActualNextServer = (await importActual()) as any; type MiddlewareResponseInit = Parameters<(typeof NextResponse)['next']>[0]; @@ -287,14 +295,6 @@ describe('prefix-based routing', () => { ); }); - it('sets a cookie', () => { - const response = middleware(createMockRequest('/')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'en' - }); - }); - it('can turn off the cookie', () => { const response = createMiddleware({...routing, localeCookie: false})( createMockRequest('/') @@ -354,6 +354,21 @@ describe('prefix-based routing', () => { ); }); + it('does not return alternate links when redirecting', () => { + const response = middleware( + createMockRequest('/en', 'en', 'http://localhost:3000', 'de') + ); + expect(MockedNextResponse.redirect).toHaveBeenCalled(); + expect(response.headers.get('link')).toBe(null); + }); + + it('sets a cookie when changing to the default locale', () => { + const response = middleware( + createMockRequest('/en', 'en', undefined, 'de') + ); + expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en'); + }); + it('always provides the locale via a request header, even if a cookie exists with the correct value (see https://github.com/amannn/next-intl/discussions/446)', () => { middleware(createMockRequest('/', 'en', 'http://localhost:3000', 'en')); expect(MockedNextResponse.rewrite).toHaveBeenCalled(); @@ -411,74 +426,92 @@ describe('prefix-based routing', () => { pathnames: { '/': '/', '/about': { - en: '/about', de: '/ueber', 'de-AT': '/ueber', ja: '/約' }, '/users': { - en: '/users', de: '/benutzer', 'de-AT': '/benutzer', ja: '/ユーザー' }, '/users/[userId]': { - en: '/users/[userId]', de: '/benutzer/[userId]', 'de-AT': '/benutzer/[userId]', ja: '/ユーザー/[userId]' }, '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', de: '/neuigkeiten/[articleSlug]-[articleId]', 'de-AT': '/neuigkeiten/[articleSlug]-[articleId]', ja: '/ニュース/[articleSlug]-[articleId]' }, '/articles/[category]/[articleSlug]': { - en: '/articles/[category]/[articleSlug]', de: '/artikel/[category]/[articleSlug]', 'de-AT': '/artikel/[category]/[articleSlug]', ja: '/記事/[category]/[articleSlug]' }, '/articles/[category]/just-in': { - en: '/articles/[category]/just-in', de: '/artikel/[category]/aktuell', 'de-AT': '/artikel/[category]/aktuell', ja: '/記事/[category]/最新' }, '/products/[...slug]': { - en: '/products/[...slug]', de: '/produkte/[...slug]', 'de-AT': '/produkte/[...slug]', ja: '/製品/[...slug]' }, '/products/[slug]': { - en: '/products/[slug]', de: '/produkte/[slug]', 'de-AT': '/produkte/[slug]', ja: '/製品/[slug]' }, '/products/add': { - en: '/products/add', de: '/produkte/hinzufuegen', 'de-AT': '/produkte/hinzufuegen', ja: '/製品/追加' }, '/categories/[[...slug]]': { - en: '/categories/[[...slug]]', de: '/kategorien/[[...slug]]', 'de-AT': '/kategorien/[[...slug]]', ja: '/カテゴリー/[[...slug]]' }, '/categories/new': { - en: '/categories/new', de: '/kategorien/neu', 'de-AT': '/kategorien/neu', ja: '/カテゴリー/新着' + }, + '/partially-available': { + de: '/teilweise-verfuegbar', + 'de-AT': '/teilweise-verfuegbar' + // (ja inherits en) } } satisfies Pathnames> }); + describe('partially available locales', () => { + it('serves requests for available locales', () => { + middlewareWithPathnames( + createMockRequest('/de/teilweise-verfuegbar', 'de') + ); + expect(MockedNextResponse.rewrite).toHaveBeenCalled(); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/de/partially-available' + ); + }); + + it('uses the internal default for undefined entries', () => { + middlewareWithPathnames(createMockRequest('/partially-available')); + middlewareWithPathnames(createMockRequest('/ja/partially-available')); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en/partially-available' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://localhost:3000/ja/partially-available' + ); + }); + }); + it('serves requests for the default locale at the root', () => { middlewareWithPathnames(createMockRequest('/', 'en')); expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); @@ -1022,13 +1055,6 @@ describe('prefix-based routing', () => { 'http://localhost:3000/en' ); }); - - it("doesn't set a cookie", () => { - const response = middleware( - createMockRequest('/', 'de', 'http://localhost:3000', undefined) - ); - expect(response.cookies.getAll()).toEqual([]); - }); }); describe('localePrefix: always', () => { @@ -1102,6 +1128,24 @@ describe('prefix-based routing', () => { expect(MockedNextResponse.next).toHaveBeenCalledTimes(3); }); + it("does not set a cookie when the user's locale matches the prefix as well as the default locale", () => { + const response = middleware(createMockRequest('/en', 'en')); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it("does not set a cookie when the user's locale matches the prefix as well as a non-default locale", () => { + const response = middleware(createMockRequest('/de', 'de')); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it('sets a cookie when the user locale does not match the prefix', () => { + const response = middleware(createMockRequest('/en', 'de')); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'en' + }); + }); + describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { middleware(withBasePath(createMockRequest('/'))); @@ -1382,35 +1426,42 @@ describe('prefix-based routing', () => { ]); expect(getLinks(createMockRequest('/en/about', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/ueber', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/en/users/1', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/de/benutzer/1', 'de'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect( getLinks(createMockRequest('/en/products/apparel/t-shirts', 'en')) ).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect( getLinks(createMockRequest('/de/produkte/apparel/t-shirts', 'de')) ).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); expect(getLinks(createMockRequest('/en/unknown', 'en'))).toEqual([ '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="de"' + '; rel="alternate"; hreflang="de"', + '; rel="alternate"; hreflang="x-default"' ]); }); @@ -1729,7 +1780,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); @@ -1737,7 +1789,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); }); @@ -1862,7 +1915,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); @@ -1870,7 +1924,8 @@ describe('prefix-based routing', () => { '; rel="alternate"; hreflang="en"', '; rel="alternate"; hreflang="en-gb"', '; rel="alternate"; hreflang="de-at"', - '; rel="alternate"; hreflang="pt"' + '; rel="alternate"; hreflang="pt"', + '; rel="alternate"; hreflang="x-default"' ]); }); }); @@ -2025,22 +2080,26 @@ describe('prefix-based routing', () => { ); }); - it('sets a cookie', () => { + it('does not set a cookie by default', () => { const response = middleware(createMockRequest('/')); - expect(response.cookies.get('NEXT_LOCALE')).toEqual({ - name: 'NEXT_LOCALE', - value: 'en' - }); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); }); - it('sets a cookie based on accept-language header', () => { - const response = middleware(createMockRequest('/', 'de')); + it('sets a cookie if the user requests a different locale than what is configured in accept-language', () => { + const response = middleware(createMockRequest('/de', 'en')); expect(response.cookies.get('NEXT_LOCALE')).toEqual({ name: 'NEXT_LOCALE', value: 'de' }); }); + it('does not set a cookie if it is already set', () => { + const response = middleware( + createMockRequest('/de', 'en', undefined, 'de') + ); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + it('keeps a cookie if already set', () => { const response = middleware( createMockRequest('/', 'en', 'http://localhost:3000', 'de') @@ -2439,7 +2498,8 @@ describe('domain-based routing', () => { domains: [ { defaultLocale: 'fr', - domain: 'ca.example.com' + domain: 'ca.example.com', + locales: ['en', 'fr'] } ] }); @@ -2449,6 +2509,30 @@ describe('domain-based routing', () => { ); }); + it("doesn't set a cookie when on a domain that doesn't support the user's locale", () => { + const response = middleware( + createMockRequest('/', 'fr', 'http://en.example.com') + ); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it("doesn't set a cookie when on a domain that supports the user's locale", () => { + const response = middleware( + createMockRequest('/', 'fr', 'http://en.example.com') + ); + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined(); + }); + + it("sets a cookie when on a domain that supports the user's locale and a different locale is requested", () => { + const response = middleware( + createMockRequest('/en', 'fr', 'http://ca.example.com') + ); + expect(response.cookies.get('NEXT_LOCALE')).toEqual({ + name: 'NEXT_LOCALE', + value: 'en' + }); + }); + describe('unknown hosts', () => { it('serves requests for unknown hosts at the root', () => { middleware(createMockRequest('/', 'en', 'http://localhost')); @@ -2945,7 +3029,7 @@ describe('domain-based routing', () => { ]); expect( getLinks( - createMockRequest('/a-propos', 'fr', 'http://ca.example.com') + createMockRequest('/fr/a-propos', 'fr', 'http://ca.example.com') ) ).toEqual([ '; rel="alternate"; hreflang="en"', @@ -2988,7 +3072,7 @@ describe('domain-based routing', () => { expect( getLinks( createMockRequest( - '/fr/produits/apparel/t-shirts', + '/produits/apparel/t-shirts', 'fr', 'http://fr.example.com' ) @@ -3128,20 +3212,73 @@ describe('domain-based routing', () => { describe('custom prefixes with pathnames', () => { const middlewareWithPrefixes = createMiddleware({ defaultLocale: 'en', - locales: ['en', 'en-gb'], + locales: ['en', 'en-gb', 'sv-SE', 'en-SE', 'no-NO', 'en-NO'], localePrefix: { mode: 'as-needed', prefixes: { - 'en-gb': '/uk' + 'en-gb': '/uk', + 'en-SE': '/en', + 'en-NO': '/en' } }, pathnames: { '/': '/', '/about': { en: '/about', - 'en-gb': '/about' + 'en-gb': '/about', + 'en-SE': '/about', + 'en-NO': '/about', + 'sv-SE': '/about', + 'no-NO': '/about' + } + } satisfies Pathnames< + ReadonlyArray<'en' | 'en-gb' | 'sv-SE' | 'en-SE' | 'no-NO' | 'en-NO'> + >, + domains: [ + { + defaultLocale: 'en-gb', + domain: 'example.co.uk', + locales: ['en-gb'] + }, + { + defaultLocale: 'sv-SE', + domain: 'example.se', + locales: ['sv-SE', 'en-SE'] + }, + { + defaultLocale: 'no-NO', + domain: 'example.no', + locales: ['no-NO', 'en-NO'] + }, + { + defaultLocale: 'en', + domain: 'example.com', + locales: ['en'] } - } satisfies Pathnames> + ] + }); + + it('serves requests for overlapping prefixes', () => { + middlewareWithPrefixes( + createMockRequest('/', undefined, 'http://example.com') + ); + middlewareWithPrefixes( + createMockRequest('/en', undefined, 'http://example.no') + ); + middlewareWithPrefixes( + createMockRequest('/en', undefined, 'http://example.se') + ); + expect(MockedNextResponse.redirect).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(3); + expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe( + 'http://example.com/en' + ); + expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe( + 'http://example.no/en-NO' + ); + expect(MockedNextResponse.rewrite.mock.calls[2][0].toString()).toBe( + 'http://example.se/en-SE' + ); }); it('serves requests for the default locale at the root', () => { @@ -3189,26 +3326,35 @@ describe('domain-based routing', () => { ?.split(', '); } - ['/en', '/uk'].forEach((pathname) => { + ['/', '/uk'].forEach((pathname) => { expect(getLinks(createMockRequest(pathname))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en-gb"', - '; rel="alternate"; hreflang="x-default"' + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="sv-SE"', + '; rel="alternate"; hreflang="en-SE"', + '; rel="alternate"; hreflang="no-NO"', + '; rel="alternate"; hreflang="en-NO"' ]); }); - ['/en/about', '/uk/about'].forEach((pathname) => { + ['/about', '/uk/about'].forEach((pathname) => { expect(getLinks(createMockRequest(pathname))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en-gb"', - '; rel="alternate"; hreflang="x-default"' + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="sv-SE"', + '; rel="alternate"; hreflang="en-SE"', + '; rel="alternate"; hreflang="no-NO"', + '; rel="alternate"; hreflang="en-NO"' ]); }); - expect(getLinks(createMockRequest('/en/unknown'))).toEqual([ - '; rel="alternate"; hreflang="en"', - '; rel="alternate"; hreflang="en-gb"', - '; rel="alternate"; hreflang="x-default"' + expect(getLinks(createMockRequest('/unknown'))).toEqual([ + '; rel="alternate"; hreflang="en"', + '; rel="alternate"; hreflang="en-gb"', + '; rel="alternate"; hreflang="sv-SE"', + '; rel="alternate"; hreflang="en-SE"', + '; rel="alternate"; hreflang="no-NO"', + '; rel="alternate"; hreflang="en-NO"' ]); }); }); @@ -3317,6 +3463,39 @@ describe('domain-based routing', () => { ); }); + it('keeps the port when there is a x-forwarded-host', () => { + createMiddleware({ + defaultLocale: 'en', + locales: ['en', 'es'], + domains: [ + { + domain: 'localhost:3000', + defaultLocale: 'en', + locales: ['en'] + }, + { + domain: 'localhost:3001', + defaultLocale: 'es', + locales: ['es'] + } + ] + })( + createMockRequest( + '/en', + undefined, + 'http://localhost:3001', + undefined, + { + 'x-forwarded-host': 'localhost:3001' + } + ) + ); + + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/en' + ); + }); + describe('base path', () => { it('redirects non-prefixed requests for the default locale', () => { middleware( @@ -3329,16 +3508,3 @@ describe('domain-based routing', () => { }); }); }); - -describe('deprecated middleware options', () => { - it('still accepts them', () => { - createMiddleware( - {locales: ['en'], defaultLocale: 'en'}, - { - localeDetection: false, - alternateLinks: false, - localeCookie: false - } - ); - }); -}); diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 620bee462..cbb0608ae 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,20 +1,21 @@ -import {NextRequest, NextResponse} from 'next/server'; -import {RoutingConfig, receiveRoutingConfig} from '../routing/config'; -import { +import {type NextRequest, NextResponse} from 'next/server.js'; +import {type RoutingConfig, receiveRoutingConfig} from '../routing/config.js'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {HEADER_LOCALE_NAME} from '../shared/constants'; +} from '../routing/types.js'; +import {HEADER_LOCALE_NAME} from '../shared/constants.js'; import { getLocalePrefix, + getLocalizedTemplate, matchesPathname, normalizeTrailingSlash -} from '../shared/utils'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; -import resolveLocale from './resolveLocale'; -import syncCookie from './syncCookie'; +} from '../shared/utils.js'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.js'; +import resolveLocale from './resolveLocale.js'; +import syncCookie from './syncCookie.js'; import { applyBasePath, formatPathname, @@ -26,51 +27,22 @@ import { getPathnameMatch, isLocaleSupportedOnDomain, sanitizePathname -} from './utils'; +} from './utils.js'; export default function createMiddleware< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode = 'always', - AppPathnames extends Pathnames = never, - AppDomains extends DomainsConfig = never + const AppLocales extends Locales, + const AppLocalePrefixMode extends LocalePrefixMode = 'always', + const AppPathnames extends Pathnames = never, + const AppDomains extends DomainsConfig = never >( routing: RoutingConfig< AppLocales, AppLocalePrefixMode, AppPathnames, AppDomains - >, - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - options?: { - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - localeCookie?: RoutingConfig< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - >['localeCookie']; - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - localeDetection?: RoutingConfig< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - >['localeDetection']; - /** @deprecated Should be passed via the first parameter `routing` instead (ideally defined with `defineRouting`) */ - alternateLinks?: RoutingConfig< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - >['alternateLinks']; - } + > ) { - const resolvedRouting = receiveRoutingConfig({ - ...routing, - alternateLinks: options?.alternateLinks ?? routing.alternateLinks, - localeDetection: options?.localeDetection ?? routing.localeDetection, - localeCookie: options?.localeCookie ?? routing.localeCookie - }); + const resolvedRouting = receiveRoutingConfig(routing); return function middleware(request: NextRequest) { let unsafeExternalPathname: string; @@ -153,7 +125,11 @@ export default function createMiddleware< request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol; - urlObj.port = request.headers.get('x-forwarded-port') ?? ''; + const redirectDomainPort = redirectDomain.split(':')[1] as + | string + | undefined; + urlObj.port = + redirectDomainPort ?? request.headers.get('x-forwarded-port') ?? ''; } } @@ -164,6 +140,7 @@ export default function createMiddleware< ); } + hasRedirected = true; return NextResponse.redirect(urlObj.toString()); } @@ -176,7 +153,8 @@ export default function createMiddleware< const pathnameMatch = getPathnameMatch( externalPathname, resolvedRouting.locales, - resolvedRouting.localePrefix + resolvedRouting.localePrefix, + domain ); const hasLocalePrefix = pathnameMatch != null; @@ -187,6 +165,7 @@ export default function createMiddleware< let response; let internalTemplateName: string | undefined; + let hasRedirected: boolean | undefined; let unprefixedInternalPathname = unprefixedExternalPathname; const pathnames = (resolvedRouting as any).pathnames as @@ -202,10 +181,11 @@ export default function createMiddleware< if (internalTemplateName) { const pathnameConfig = pathnames[internalTemplateName]; - const localeTemplate: string = - typeof pathnameConfig === 'string' - ? pathnameConfig - : pathnameConfig[locale]; + const localeTemplate: string = getLocalizedTemplate( + pathnameConfig, + locale, + internalTemplateName + ); if (matchesPathname(localeTemplate, unprefixedExternalPathname)) { unprefixedInternalPathname = formatTemplatePathname( @@ -217,10 +197,11 @@ export default function createMiddleware< let sourceTemplate: string; if (resolvedTemplateLocale) { // A localized pathname from another locale has matched - sourceTemplate = - typeof pathnameConfig === 'string' - ? pathnameConfig - : pathnameConfig[resolvedTemplateLocale]; + sourceTemplate = getLocalizedTemplate( + pathnameConfig, + resolvedTemplateLocale, + internalTemplateName + ); } else { // An internal pathname has matched that // doesn't have a localized pathname @@ -330,11 +311,10 @@ export default function createMiddleware< } } - if (resolvedRouting.localeDetection && resolvedRouting.localeCookie) { - syncCookie(request, response, locale, resolvedRouting.localeCookie); - } + syncCookie(request, response, locale, resolvedRouting, domain); if ( + !hasRedirected && resolvedRouting.localePrefix.mode !== 'never' && resolvedRouting.alternateLinks && resolvedRouting.locales.length > 1 @@ -343,6 +323,7 @@ export default function createMiddleware< 'Link', getAlternateLinksHeaderValue({ routing: resolvedRouting, + internalTemplateName, localizedPathnames: internalTemplateName != null && pathnames ? pathnames[internalTemplateName] diff --git a/packages/next-intl/src/middleware/resolveLocale.test.tsx b/packages/next-intl/src/middleware/resolveLocale.test.tsx index c8117757c..338536132 100644 --- a/packages/next-intl/src/middleware/resolveLocale.test.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {getAcceptLanguageLocale} from './resolveLocale'; +import {getAcceptLanguageLocale} from './resolveLocale.js'; describe('getAcceptLanguageLocale', () => { it('resolves a more specific locale to a generic one', () => { diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 17939c6c4..05f51eb17 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,15 +1,16 @@ import {match} from '@formatjs/intl-localematcher'; import Negotiator from 'negotiator'; -import {RequestCookies} from 'next/dist/server/web/spec-extension/cookies'; -import {ResolvedRoutingConfig} from '../routing/config'; -import { +import type {RequestCookies} from 'next/dist/server/web/spec-extension/cookies.js'; +import type {Locale} from 'use-intl'; +import type {ResolvedRoutingConfig} from '../routing/config.js'; +import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; +} from '../routing/types.js'; +import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils.js'; function findDomainFromHost( requestHeaders: Headers, @@ -32,7 +33,7 @@ function orderLocales(locales: AppLocales) { export function getAcceptLanguageLocale( requestHeaders: Headers, locales: AppLocales, - defaultLocale: string + defaultLocale: Locale ) { let locale; @@ -43,12 +44,7 @@ export function getAcceptLanguageLocale( }).languages(); try { const orderedLocales = orderLocales(locales); - - locale = match( - languages, - orderedLocales as unknown as Array, - defaultLocale - ); + locale = match(languages, orderedLocales, defaultLocale); } catch { // Invalid language } @@ -173,7 +169,8 @@ function resolveLocaleFromDomain< const prefixLocale = getPathnameMatch( pathname, routing.locales, - routing.localePrefix + routing.localePrefix, + domain )?.locale; if (prefixLocale) { if (isLocaleSupportedOnDomain(prefixLocale, domain)) { @@ -201,7 +198,7 @@ function resolveLocaleFromDomain< if (!locale && routing.localeDetection) { const headerLocale = getAcceptLanguageLocale( requestHeaders, - domain.locales || routing.locales, + domain.locales, domain.defaultLocale ); diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index c924b104b..42619882c 100644 --- a/packages/next-intl/src/middleware/syncCookie.tsx +++ b/packages/next-intl/src/middleware/syncCookie.tsx @@ -1,16 +1,53 @@ -import {NextRequest, NextResponse} from 'next/server'; -import {LocaleCookieConfig} from '../routing/config'; +import type {NextRequest, NextResponse} from 'next/server.js'; +import type {Locale} from 'use-intl'; +import type { + InitializedLocaleCookieConfig, + ResolvedRoutingConfig +} from '../routing/config.js'; +import type { + DomainConfig, + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../routing/types.js'; +import {getAcceptLanguageLocale} from './resolveLocale.js'; -export default function syncCookie( +export default function syncCookie< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined +>( request: NextRequest, response: NextResponse, - locale: string, - localeCookie: LocaleCookieConfig + locale: Locale, + routing: Pick< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'locales' | 'defaultLocale' + > & { + localeCookie: InitializedLocaleCookieConfig; + }, + domain?: DomainConfig ) { - const {name, ...rest} = localeCookie; - const hasOutdatedCookie = request.cookies.get(name)?.value !== locale; + if (!routing.localeCookie) return; - if (hasOutdatedCookie) { + const {name, ...rest} = routing.localeCookie; + const acceptLanguageLocale = getAcceptLanguageLocale( + request.headers, + domain?.locales || routing.locales, + routing.defaultLocale + ); + const hasLocaleCookie = request.cookies.has(name); + const hasOutdatedCookie = + hasLocaleCookie && request.cookies.get(name)?.value !== locale; + + if (hasLocaleCookie ? hasOutdatedCookie : acceptLanguageLocale !== locale) { response.cookies.set(name, locale, { path: request.nextUrl.basePath || undefined, ...rest diff --git a/packages/next-intl/src/middleware/utils.test.tsx b/packages/next-intl/src/middleware/utils.test.tsx index e322b5538..44e554566 100644 --- a/packages/next-intl/src/middleware/utils.test.tsx +++ b/packages/next-intl/src/middleware/utils.test.tsx @@ -5,7 +5,7 @@ import { getNormalizedPathname, getPathnameMatch, getRouteParams -} from './utils'; +} from './utils.js'; describe('getNormalizedPathname', () => { it('should return the normalized pathname', () => { diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index c5dfdaadc..26650dd06 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,19 +1,21 @@ -import { +import type {Locale} from 'use-intl'; +import type { DomainConfig, DomainsConfig, LocalePrefixConfigVerbose, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; +} from '../routing/types.js'; import { getLocalePrefix, + getLocalizedTemplate, getSortedPathnames, matchesPathname, normalizeTrailingSlash, prefixPathname, templateToRegex -} from '../shared/utils'; +} from '../shared/utils.js'; export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; @@ -48,8 +50,13 @@ export function getInternalTemplate< sortedEntries.unshift(sortedEntries.splice(curLocaleIndex, 1)[0]); } - for (const [entryLocale, entryPathname] of sortedEntries) { - if (matchesPathname(entryPathname as string, pathname)) { + for (const [entryLocale] of sortedEntries) { + const localizedTemplate = getLocalizedTemplate( + pathnames[internalPathname], + entryLocale, + internalPathname + ); + if (matchesPathname(localizedTemplate, pathname)) { return [entryLocale, internalPathname]; } } @@ -158,7 +165,8 @@ export function getPathnameMatch< >( pathname: string, locales: AppLocales, - localePrefix: LocalePrefixConfigVerbose + localePrefix: LocalePrefixConfigVerbose, + domain?: DomainConfig ): | { locale: AppLocales[number]; @@ -169,6 +177,21 @@ export function getPathnameMatch< | undefined { const localePrefixes = getLocalePrefixes(locales, localePrefix); + // Sort to prioritize domain locales + if (domain) { + localePrefixes.sort(([localeA], [localeB]) => { + if (localeA === domain.defaultLocale) return -1; + if (localeB === domain.defaultLocale) return 1; + + const isLocaleAInDomain = domain.locales.includes(localeA); + const isLocaleBInDomain = domain.locales.includes(localeB); + if (isLocaleAInDomain && !isLocaleBInDomain) return -1; + if (!isLocaleAInDomain && isLocaleBInDomain) return 1; + + return 0; + }); + } + for (const [locale, prefix] of localePrefixes) { let exact, matches; if (pathname === prefix || pathname.startsWith(prefix + '/')) { @@ -254,19 +277,15 @@ export function getHost(requestHeaders: Headers) { } export function isLocaleSupportedOnDomain( - locale: string, + locale: Locale, domain: DomainConfig ) { - return ( - domain.defaultLocale === locale || - !domain.locales || - domain.locales.includes(locale) - ); + return domain.defaultLocale === locale || domain.locales.includes(locale); } export function getBestMatchingDomain( curHostDomain: DomainConfig | undefined, - locale: string, + locale: Locale, domainsConfig: DomainsConfig ) { let domainConfig; @@ -281,19 +300,9 @@ export function getBestMatchingDomain( domainConfig = domainsConfig.find((cur) => cur.defaultLocale === locale); } - // Prio 3: Use alternative domain with restricted matching locale - if (!domainConfig) { - domainConfig = domainsConfig.find((cur) => cur.locales?.includes(locale)); - } - - // Prio 4: Stay on the current domain if it supports all locales - if (!domainConfig && curHostDomain?.locales == null) { - domainConfig = curHostDomain; - } - - // Prio 5: Use alternative domain that supports all locales + // Prio 3: Use alternative domain that supports the locale if (!domainConfig) { - domainConfig = domainsConfig.find((cur) => !cur.locales); + domainConfig = domainsConfig.find((cur) => cur.locales.includes(locale)); } return domainConfig; diff --git a/packages/next-intl/src/navigation.react-client.tsx b/packages/next-intl/src/navigation.react-client.tsx index 773e086d1..690c40fe3 100644 --- a/packages/next-intl/src/navigation.react-client.tsx +++ b/packages/next-intl/src/navigation.react-client.tsx @@ -1 +1 @@ -export * from './navigation/react-client/index'; +export * from './navigation/react-client/index.js'; diff --git a/packages/next-intl/src/navigation.react-server.tsx b/packages/next-intl/src/navigation.react-server.tsx index c207e942e..62fcb6ec3 100644 --- a/packages/next-intl/src/navigation.react-server.tsx +++ b/packages/next-intl/src/navigation.react-server.tsx @@ -1 +1 @@ -export * from './navigation/react-server'; +export * from './navigation/react-server/index.js'; diff --git a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx deleted file mode 100644 index 1ac6359d8..000000000 --- a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,759 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - RedirectType, - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect, - usePathname as useNextPathname, - useParams -} from 'next/navigation'; -import React from 'react'; -import {renderToString} from 'react-dom/server'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {Pathnames, defineRouting} from '../routing'; -import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy'; -import {getLocalePrefix} from '../shared/utils'; -import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation'; -import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation'; -import LegacyBaseLink from './shared/LegacyBaseLink'; - -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); - return { - ...actual, - usePathname: vi.fn(), - useParams: vi.fn(), - redirect: vi.fn(), - permanentRedirect: vi.fn() - }; -}); -vi.mock('next-intl/config', () => ({ - default: async () => - ((await vi.importActual('../../src/server')) as any).getRequestConfig({ - locale: 'en' - }) -})); -vi.mock('react'); -// Avoids handling an async component (not supported by renderToString) -vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, localePrefix, ...rest}: any) { - const finalLocale = locale || 'en'; - const prefix = getLocalePrefix(finalLocale, localePrefix); - return ( - - ); - } -})); -vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); - -beforeEach(() => { - // usePathname from Next.js returns the pathname the user sees - // (i.e. the external one that might be localized) - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - vi.mocked(getRequestLocale).mockImplementation(() => 'en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -const locales = ['en', 'de', 'ja'] as const; - -const pathnames = { - '/': '/', - '/about': { - en: '/about', - de: '/ueber-uns', - ja: '/約' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]', - ja: '/ニュース/[articleSlug]-[articleId]' - }, - '/categories/[...parts]': { - en: '/categories/[...parts]', - de: '/kategorien/[...parts]', - ja: '/カテゴリ/[...parts]' - }, - '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' -} satisfies Pathnames; - -describe.each([ - { - env: 'react-client', - implementation: createLocalizedPathnamesNavigationClient - }, - { - env: 'react-server', - implementation: createLocalizedPathnamesNavigationServer - } -])( - 'createLocalizedPathnamesNavigation ($env)', - ({implementation: createLocalizedPathnamesNavigation}) => { - describe("localePrefix: 'always'", () => { - const routing = defineRouting({ - locales, - defaultLocale: 'en', - pathnames, - localePrefix: 'always' - }); - const {Link} = createLocalizedPathnamesNavigation(routing); - describe('Link', () => { - it('renders a prefix for the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/ueber-uns"'); - }); - - it('renders an object href', () => { - render( - About - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); - }); - }); - }); - - describe("localePrefix: 'always', custom prefixes", () => { - const pathnamesCustomPrefixes = { - '/': '/', - '/about': { - en: '/about', - 'de-at': '/ueber-uns' - } - } as const; - const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({ - locales: ['en', 'de-at'] as const, - pathnames: pathnamesCustomPrefixes, - localePrefix: { - mode: 'always', - prefixes: { - 'de-at': '/de' - } - } as const - }); - - describe('Link', () => { - it('handles a locale without a custom prefix', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('handles a locale with a custom prefix', () => { - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/de/ueber-uns"'); - }); - - it('handles a locale with a custom prefix on an object href', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/ueber-uns?foo=bar'); - }); - }); - - describe('getPathname', () => { - it('resolves to the correct path', () => { - expect( - getPathname({ - locale: 'de-at', - href: '/about' - }) - ).toBe('/ueber-uns'); - }); - }); - - describe('redirect', () => { - function Component< - Pathname extends keyof typeof pathnamesCustomPrefixes - >({href}: {href: Parameters>[0]}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'de-at' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'de-at'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - }); - }); - }); - - describe("localePrefix: 'as-needed'", () => { - const {Link, getPathname, permanentRedirect, redirect} = - createLocalizedPathnamesNavigation({ - locales, - pathnames, - localePrefix: 'as-needed' - }); - - describe('Link', () => { - it('renders a prefix for the default locale initially', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it("doesn't render a prefix for the default locale eventually", () => { - render(Über uns); - expect(screen.getByText('Über uns').getAttribute('href')).toBe( - '/about' - ); - }); - - it('adds a prefix when linking to a non-default locale', () => { - render( - - Über uns - - ); - expect( - screen.getByRole('link', {name: 'Über uns'}).getAttribute('href') - ).toBe('/de/ueber-uns'); - }); - - it('handles params', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/neuigkeiten/launch-party-3'); - }); - - it('handles catch-all segments', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/categories/clothing/t-shirts'); - }); - - it('handles optional catch-all segments', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/catch-all/one/two'); - }); - - it('supports optional search params', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/about?foo=bar&bar=1&bar=2'); - }); - - it('handles unknown routes', () => { - // @ts-expect-error -- Unknown route - const {rerender} = render(Unknown); - expect( - screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') - ).toBe('/unknown'); - - rerender( - // @ts-expect-error -- Unknown route - - Unknown - - ); - expect( - screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') - ).toBe('/de/unknown'); - }); - }); - - describe('redirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de/ueber-uns'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/de/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/unknown'); - }); - - it('can supply a type', () => { - function Test() { - redirect('/', RedirectType.push); - return null; - } - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - - describe('permanentRedirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/de/ueber-uns' - ); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/de/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/unknown'); - }); - - it('can supply a type', () => { - function Test() { - permanentRedirect('/', RedirectType.push); - return null; - } - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - - describe('getPathname', () => { - it('resolves to the correct path', () => { - expect( - getPathname({ - locale: 'en', - href: { - pathname: '/categories/[...parts]', - params: {parts: ['clothing', 't-shirts']}, - query: {sort: 'price'} - } - }) - ).toBe('/categories/clothing/t-shirts?sort=price'); - }); - - it('handles foreign symbols', () => { - expect( - getPathname({ - locale: 'ja', - href: { - pathname: '/about', - query: {foo: 'bar'} - } - }) - ).toBe('/約?foo=bar'); - }); - }); - }); - - describe("localePrefix: 'never'", () => { - const {Link, permanentRedirect, redirect} = - createLocalizedPathnamesNavigation({ - pathnames, - locales, - localePrefix: 'never' - }); - - describe('Link', () => { - it("doesn't render a prefix for the default locale", () => { - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/ueber-uns"'); - }); - }); - - describe('redirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/about'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/ueber-uns'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/unknown'); - }); - }); - - describe('permanentRedirect', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/ueber-uns'); - - rerender( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/?foo=bar&bar=1&bar=2' - ); - }); - - it('handles unknown routes', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/unknown'); - }); - }); - }); - - describe('type tests', () => { - it('requires `pathnames`', () => { - // @ts-expect-error -- Missing pathnames - createLocalizedPathnamesNavigation({locales}); - }); - }); - } -); diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 69b9f1587..35a8d9da1 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -2,30 +2,29 @@ import {render, screen} from '@testing-library/react'; import { RedirectType, permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect, - useParams as nextUseParams -} from 'next/navigation'; -import React from 'react'; + redirect as nextRedirect +} from 'next/navigation.js'; import {renderToString} from 'react-dom/server'; +import {type Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {DomainsConfig, Pathnames, defineRouting} from '../routing'; -import createNavigationClient from './react-client/createNavigation'; -import createNavigationServer from './react-server/createNavigation'; -import getServerLocale from './react-server/getServerLocale'; +import {type DomainsConfig, type Pathnames, defineRouting} from '../routing.js'; +import createNavigationClient from './react-client/createNavigation.js'; +import createNavigationServer from './react-server/createNavigation.js'; +import getServerLocale from './react-server/getServerLocale.js'; vi.mock('react'); -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); - return { - ...actual, - useParams: vi.fn(() => ({locale: 'en'})), - redirect: vi.fn(), - permanentRedirect: vi.fn() - }; -}); +vi.mock('next/navigation.js', async () => ({ + ...(await vi.importActual('next/navigation.js')), + redirect: vi.fn(), + permanentRedirect: vi.fn() +})); vi.mock('./react-server/getServerLocale'); +vi.mock('use-intl', async () => ({ + ...(await vi.importActual('use-intl')), + useLocale: vi.fn(() => 'en') +})); -function mockCurrentLocale(locale: string) { +function mockCurrentLocale(locale: Locale) { // Enable synchronous rendering without having to suspend const value = locale; const promise = Promise.resolve(value); @@ -34,9 +33,7 @@ function mockCurrentLocale(locale: string) { vi.mocked(getServerLocale).mockImplementation(() => promise); - vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({ - locale - })); + vi.mocked(useLocale).mockImplementation(() => locale); } function mockLocation(location: Partial) { @@ -56,12 +53,13 @@ const defaultLocale = 'en' as const; const domains = [ { defaultLocale: 'en', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] }, { defaultLocale: 'de', domain: 'example.de', - locales: ['de', 'en'] + locales: ['de', 'ja'] } ] satisfies DomainsConfig; @@ -110,6 +108,19 @@ describe.each([ localePrefix: 'always' }); + describe('createNavigation', () => { + it('ensures `defaultLocale` is in `locales`', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => + createNavigation({ + locales, + // @ts-expect-error + defaultLocale: 'zh', + localePrefix: 'always' + }); + }); + }); + describe('Link', () => { it('renders a prefix when currently on the default locale', () => { const markup = renderToString(About); @@ -215,6 +226,14 @@ describe.each([ expect(markup).toContain('href="/en/about"'); expect(consoleSpy).not.toHaveBeenCalled(); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return ; + } + render(); + }); }); describe('getPathname', () => { @@ -306,6 +325,17 @@ describe.each([ true ); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return getPathname({ + locale, + href: '/about' + }); + } + render(); + }); }); describe.each([ @@ -354,6 +384,14 @@ describe.each([ // @ts-expect-error -- Missing locale redirectFn({pathname: '/about'}); }); + + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + return redirectFn({href: '/about', locale}); + } + render(); + }); }); }); @@ -870,10 +908,9 @@ describe.each([ }); describe('Link', () => { - it('renders a prefix during SSR even for the default locale', () => { - // (see comment in source for reasoning) + it('renders no prefix during SSR for the default locale', () => { const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); + expect(markup).toContain('href="/about"'); }); it('does not render a prefix eventually on the client side for the default locale of the given domain', () => { @@ -898,11 +935,11 @@ describe.each([ it('renders a prefix when currently on a secondary locale', () => { mockLocation({host: 'example.de'}); - mockCurrentLocale('en'); + mockCurrentLocale('ja'); render(About); expect( screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/en/about'); + ).toBe('/ja/about'); }); it('does not render a prefix when currently on a domain with a different default locale', () => { @@ -936,30 +973,9 @@ describe.each([ }); describe('getPathname', () => { - it('does not add a prefix for the default locale', () => { - expect( - getPathname({locale: 'en', href: '/about', domain: 'example.com'}) - ).toBe('/about'); - expect( - getPathname({locale: 'de', href: '/about', domain: 'example.de'}) - ).toBe('/about'); - }); - - it('adds a prefix for a secondary locale', () => { - expect( - getPathname({locale: 'de', href: '/about', domain: 'example.com'}) - ).toBe('/de/about'); - expect( - getPathname({locale: 'en', href: '/about', domain: 'example.de'}) - ).toBe('/en/about'); - }); - - it('prints a warning when no domain is provided', () => { - const consoleSpy = vi.spyOn(console, 'error'); - // @ts-expect-error -- Domain is not provided - getPathname({locale: 'de', href: '/about'}); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); + it('does not add a prefix for a default locale', () => { + expect(getPathname({locale: 'en', href: '/about'})).toBe('/about'); + expect(getPathname({locale: 'de', href: '/about'})).toBe('/about'); }); }); @@ -968,21 +984,19 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('adds a prefix even for the default locale', () => { - // (see comment in source for reasoning) + // There's one edge case that is not handled here: If `localePrefix: + // 'as-needed'` is used and the user redirects from a non-default locale + // to the default locale, no cookie will be updated and therefore the user + // redirected back to the original locale. Typically, redirect is not used + // for language switching though and uses the current locale of the user, + // therefore this is currently neglected. runInRender(() => redirectFn({href: '/', locale: 'en'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); - }); - - it('does not add a prefix when domain is provided for the default locale', () => { - runInRender(() => - redirectFn({href: '/', locale: 'en', domain: 'example.com'}) - ); expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); it('adds a prefix for a secondary locale', () => { - runInRender(() => redirectFn({href: '/', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); + runInRender(() => redirectFn({href: '/', locale: 'ja'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/ja'); }); }); }); @@ -1015,7 +1029,7 @@ describe.each([ runInRender(() => redirectFn({href: {pathname: '/', query: {foo: 'bar'}}, locale: 'en'}) ); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en?foo=bar'); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/?foo=bar'); }); }); }); diff --git a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx deleted file mode 100644 index 57c9e74db..000000000 --- a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,532 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - RedirectType, - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect, - usePathname as useNextPathname, - useParams -} from 'next/navigation'; -import React from 'react'; -import {renderToString} from 'react-dom/server'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {defineRouting} from '../routing'; -import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy'; -import {getLocalePrefix} from '../shared/utils'; -import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation'; -import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation'; -import LegacyBaseLink from './shared/LegacyBaseLink'; - -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); - return { - ...actual, - useParams: vi.fn(() => ({locale: 'en'})), - usePathname: vi.fn(() => '/'), - redirect: vi.fn(), - permanentRedirect: vi.fn() - }; -}); -vi.mock('next-intl/config', () => ({ - default: async () => - ((await vi.importActual('../../src/server')) as any).getRequestConfig({ - locale: 'en' - }) -})); -vi.mock('react'); -// Avoids handling an async component (not supported by renderToString) -vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, localePrefix, ...rest}: any) { - const finalLocale = locale || 'en'; - const prefix = getLocalePrefix(finalLocale, localePrefix); - return ( - - ); - } -})); -vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); - -beforeEach(() => { - vi.mocked(getRequestLocale).mockImplementation(() => 'en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -const locales = ['en', 'de'] as const; -const localesWithCustomPrefixes = ['en', 'en-gb'] as const; -const customizedPrefixes = { - 'en-gb': '/uk' -}; - -describe.each([ - {env: 'react-client', implementation: createSharedPathnamesNavigationClient}, - {env: 'react-server', implementation: createSharedPathnamesNavigationServer} -])( - 'createSharedPathnamesNavigation ($env)', - ({implementation: createSharedPathnamesNavigation}) => { - describe("localePrefix: 'always'", () => { - const routing = defineRouting({ - locales, - defaultLocale: 'en', - localePrefix: 'always' - }); - const {Link} = createSharedPathnamesNavigation(routing); - - describe('Link', () => { - it('renders a prefix for the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); - - it('renders an object href', () => { - render( - About - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); - }); - - it('handles params', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/news/launch-party-3'); - }); - - it('handles relative links correctly on the initial render', () => { - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); - }); - }); - - describe("localePrefix: 'always', custom prefixes", () => { - const {Link, redirect} = createSharedPathnamesNavigation({ - locales: localesWithCustomPrefixes, - localePrefix: { - mode: 'always', - prefixes: customizedPrefixes - } - }); - - describe('Link', () => { - it('handles a locale without a custom prefix', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it('handles a locale with a custom prefix', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/uk/about"'); - }); - - it('handles a locale with a custom prefix on an object href', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/uk/about?foo=bar'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - }); - - it('can redirect to a relative pathname', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/en/about'); - render(); - expect(nextRedirect).toHaveBeenCalledWith('test'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'en-gb' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'en-gb'); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/uk'); - }); - }); - }); - - describe("localePrefix: 'as-needed'", () => { - const {Link, permanentRedirect, redirect} = - createSharedPathnamesNavigation({ - locales, - localePrefix: 'as-needed' - }); - - describe('Link', () => { - it('renders a prefix for the default locale initially', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); - - it("doesn't render a prefix for the default locale eventually", () => { - render(Über uns); - expect(screen.getByText('Über uns').getAttribute('href')).toBe( - '/about' - ); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/de/news/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('can supply a type', () => { - function Test() { - redirect('/', RedirectType.push); - return null; - } - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - - describe('permanentRedirect', () => { - function Component({href}: {href: string}) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/de/news/launch-party-3' - ); - }); - - it('supports optional search params', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/en?foo=bar&bar=1&bar=2' - ); - }); - - it('can supply a type', () => { - function Test() { - permanentRedirect('/', RedirectType.push); - return null; - } - render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en', 'push'); - }); - }); - }); - - describe("localePrefix: 'as-needed', custom prefixes", () => { - const {Link, permanentRedirect, redirect} = - createSharedPathnamesNavigation({ - locales: localesWithCustomPrefixes, - localePrefix: {mode: 'as-needed', prefixes: customizedPrefixes} - }); - - describe('Link', () => { - it('renders a prefix for a locale with a custom prefix', () => { - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/uk/about"'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for a locale with a custom prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'en-gb' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'en-gb'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/uk'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/uk/about'); - }); - }); - - describe('permanentRedirect', () => { - function Component({href}: {href: string}) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for a locale with a custom prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({ - locale: 'en-gb' - })); - vi.mocked(getRequestLocale).mockImplementation(() => 'en-gb'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/uk'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/uk/about'); - }); - }); - }); - - describe("localePrefix: 'never'", () => { - const {Link, permanentRedirect, redirect} = - createSharedPathnamesNavigation({ - locales, - localePrefix: 'never' - }); - - describe('Link', () => { - it("doesn't render a prefix for the default locale", () => { - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); - - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); - }); - - describe('redirect', () => { - function Component({href}: {href: string}) { - redirect(href); - return null; - } - - it('can redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); - }); - }); - - describe('permanentRedirect', () => { - function Component({href}: {href: string}) { - permanentRedirect(href); - return null; - } - - it('can permanently redirect for the default locale', () => { - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/news/launch-party-3' - ); - }); - - it('can permanently redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); - - rerender(); - expect(nextPermanentRedirect).toHaveBeenLastCalledWith( - '/news/launch-party-3' - ); - }); - }); - }); - - describe('usage without statically known locales', () => { - const {Link} = createSharedPathnamesNavigation(); - - describe('Link', () => { - it('uses the default locale', () => { - expect(renderToString(About)).toContain( - 'href="/en/about"' - ); - }); - - it('can use a non-default locale', () => { - expect( - renderToString( - - About - - ) - ).toContain('href="/de/about"'); - expect( - renderToString( - - About - - ) - ).toContain('href="/en/about"'); - }); - }); - }); - - describe('type tests', () => { - it("doesn't accept `pathnames`", () => { - createSharedPathnamesNavigation({ - locales: ['en'], - defaultLocale: 'en', - // @ts-expect-error - pathnames: { - '/': '/' - } - }); - }); - }); - } -); diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx deleted file mode 100644 index 8510508e0..000000000 --- a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react'; -import {useParams, usePathname} from 'next/navigation'; -import React, {ComponentProps, LegacyRef, forwardRef} from 'react'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client'; -import {LocalePrefixConfigVerbose} from '../../routing/types'; -import ClientLink from './ClientLink'; - -// Note: Once we remove the legacy navigation APIs, this test suite can be -// removed too. All relevant tests have been moved to the new navigation API. - -vi.mock('next/navigation'); - -function mockLocation(pathname: string, basePath = '') { - vi.mocked(usePathname).mockReturnValue(pathname); - - delete (global.window as any).location; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - global.window ??= Object.create(window); - (global.window as any).location = {pathname: basePath + pathname}; -} - -const MockClientLink = forwardRef( - ( - { - localePrefix = {mode: 'always'}, - ...rest - }: Omit< - ComponentProps, - 'localePrefix' | 'localeCookie' - > & { - localePrefix?: LocalePrefixConfigVerbose; - }, - ref - ) => ( - } - localeCookie={{ - name: 'NEXT_LOCALE', - maxAge: 31536000, - sameSite: 'lax' - }} - localePrefix={localePrefix} - {...rest} - /> - ) -); -MockClientLink.displayName = 'MockClientLink'; - -describe('unprefixed routing', () => { - beforeEach(() => { - vi.mocked(usePathname).mockImplementation(() => '/'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - }); - - it('renders an href without a locale if the locale matches', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/test' - ); - }); - - it('renders an href without a locale if the locale matches for an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/test?foo=bar' - ); - }); - - it('renders an href with a locale if the locale changes', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('renders an href with a locale if the locale changes for an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('works for external urls', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com' - ); - }); - - it('handles relative links', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'test' - ); - }); - - it('works for external urls with an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com/test' - ); - }); - - it('can receive a ref', () => { - let ref; - - render( - { - ref = node; - }} - href="/test" - > - Test - - ); - - expect(ref).toBeDefined(); - }); - - it('sets an hreflang when changing the locale', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('hreflang') - ).toBe('de'); - }); - - it('updates the href when the query changes for localePrefix=never', () => { - const {rerender} = render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/' - ); - rerender( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/?foo=bar' - ); - }); - - describe('base path', () => { - beforeEach(() => { - mockLocation('/', '/base/path'); - }); - - it('renders an unprefixed href when staying on the same locale', () => { - render(Test); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/test'); - }); - - it('renders a prefixed href when switching the locale', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/de/test'); - }); - }); -}); - -describe('prefixed routing', () => { - beforeEach(() => { - vi.mocked(usePathname).mockImplementation(() => '/en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - }); - - it('renders an href with a locale if the locale matches', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/en/test' - ); - }); - - it('renders an href without a locale if the locale matches for an object href', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/en/test' - ); - }); - - it('renders an href with a locale if the locale changes', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('renders an href with a locale if the locale changes for an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/de/test' - ); - }); - - it('works for external urls', () => { - render(Test); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com' - ); - }); - - it('works for external urls with an object href', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - 'https://example.com/test' - ); - }); - - describe('base path', () => { - beforeEach(() => { - mockLocation('/en', '/base/path'); - }); - - it('renders an unprefixed href when staying on the same locale', () => { - render(Test); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/en/test'); - }); - - it('renders a prefixed href when switching the locale', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/de/test'); - }); - }); -}); - -describe('usage outside of Next.js', () => { - beforeEach(() => { - vi.mocked(useParams).mockImplementation((() => null) as any); - }); - - it('works with a provider', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/en/test' - ); - }); - - it('throws without a provider', () => { - expect(() => - render(Test) - ).toThrow('No intl context found. Have you configured the provider?'); - }); -}); - -describe('cookie sync', () => { - beforeEach(() => { - vi.mocked(usePathname).mockImplementation(() => '/en'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - - mockLocation('/'); - - global.document.cookie = 'NEXT_LOCALE=en'; - }); - - it('keeps the cookie value in sync', () => { - render( - - Test - - ); - expect(document.cookie).toContain('NEXT_LOCALE=en'); - fireEvent.click(screen.getByRole('link', {name: 'Test'})); - expect(document.cookie).toContain('NEXT_LOCALE=de'); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx deleted file mode 100644 index 607e89866..000000000 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, {ComponentProps, ReactElement, forwardRef} from 'react'; -import useLocale from '../../react-client/useLocale'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types'; -import {getLocalePrefix} from '../../shared/utils'; -import LegacyBaseLink from '../shared/LegacyBaseLink'; - -type Props< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode -> = Omit< - ComponentProps, - 'locale' | 'prefix' | 'localePrefixMode' -> & { - locale?: AppLocales[number]; - localePrefix: LocalePrefixConfigVerbose; -}; - -function ClientLink< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->( - {locale, localePrefix, ...rest}: Props, - ref: Props['ref'] -) { - const defaultLocale = useLocale(); - const finalLocale = locale || defaultLocale; - const prefix = getLocalePrefix(finalLocale, localePrefix); - - return ( - - ); -} - -/** - * Wraps `next/link` and prefixes the `href` with the current locale if - * necessary. - * - * @example - * ```tsx - * import {Link} from 'next-intl'; - * - * // When the user is on `/en`, the link will point to `/en/about` - * About - * - * // You can override the `locale` to switch to another language - * Switch to German - * ``` - * - * Note that when a `locale` prop is passed to switch the locale, the `prefetch` - * prop is not supported. This is because Next.js would prefetch the page and - * the `set-cookie` response header would cause the locale cookie on the current - * page to be overwritten before the user even decides to change the locale. - */ -const ClientLinkWithRef = forwardRef(ClientLink) as < - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->( - props: Props & { - ref?: Props['ref']; - } -) => ReactElement; -(ClientLinkWithRef as any).displayName = 'ClientLink'; -export default ClientLinkWithRef; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx deleted file mode 100644 index cd80c08bb..000000000 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - usePathname as useNextPathname, - useRouter as useNextRouter, - useParams -} from 'next/navigation'; -import React, {ComponentProps, useRef} from 'react'; -import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import {Pathnames} from '../../routing'; -import createLocalizedPathnamesNavigation from './createLocalizedPathnamesNavigation'; - -vi.mock('next/navigation'); - -const locales = ['en', 'de', 'ja'] as const; -const pathnames = { - '/': '/', - '/about': { - en: '/about', - de: '/ueber-uns', - ja: '/約' - }, - '/news/[articleSlug]-[articleId]': { - en: '/news/[articleSlug]-[articleId]', - de: '/neuigkeiten/[articleSlug]-[articleId]', - ja: '/ニュース/[articleSlug]-[articleId]' - }, - '/categories/[...parts]': { - en: '/categories/[...parts]', - de: '/kategorien/[...parts]', - ja: '/カテゴリ/[...parts]' - }, - '/categories/new': { - en: '/categories/new', - de: '/kategorien/neu', - ja: '/カテゴリ/新規' - }, - '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' -} satisfies Pathnames; - -beforeEach(() => { - const router = { - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn() - }; - vi.mocked(useNextRouter).mockImplementation(() => router); - - // usePathname from Next.js returns the pathname the user sees - // (i.e. the external one that might be localized) - vi.mocked(useNextPathname).mockImplementation(() => '/'); - - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -describe("localePrefix: 'as-needed'", () => { - const {Link, redirect, usePathname, useRouter} = - createLocalizedPathnamesNavigation({ - locales, - pathnames - }); - - describe('Link', () => { - it('supports receiving a ref', () => { - const ref = React.createRef(); - render(); - expect(ref.current).not.toBe(null); - }); - }); - - describe('usePathname', () => { - it('returns the internal pathname for the default locale', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/about'); - rerender(); - screen.getByText('/about'); - - vi.mocked(useNextPathname).mockImplementation( - () => '/news/launch-party-3' - ); - rerender(); - screen.getByText('/news/[articleSlug]-[articleId]'); - }); - - it('returns the internal pathname for a more specific pathname that overlaps with another pathname', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - - vi.mocked(useNextPathname).mockImplementation(() => '/en/categories/new'); - render(); - screen.getByText('/categories/new'); - }); - - it('returns an encoded pathname correctly', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useParams).mockImplementation(() => ({locale: 'ja'})); - vi.mocked(useNextPathname).mockImplementation(() => '/ja/%E7%B4%84'); - render(); - screen.getByText('/about'); - }); - - it('returns the internal pathname a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/de'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns'); - rerender(); - screen.getByText('/about'); - - vi.mocked(useNextPathname).mockImplementation( - () => '/de/neuigkeiten/launch-party-3' - ); - rerender(); - screen.getByText('/news/[articleSlug]-[articleId]'); - }); - - it('handles unknown routes', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/en/unknown'); - const {rerender} = render(); - screen.getByText('/unknown'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/unknown'); - rerender(); - screen.getByText('/de/unknown'); - }); - - describe('trailingSlash: true', () => { - beforeEach(() => { - process.env._next_intl_trailing_slash = 'true'; - }); - afterEach(() => { - delete process.env._next_intl_trailing_slash; - }); - - function Component() { - // eslint-disable-next-line react-compiler/react-compiler - const pathname = createLocalizedPathnamesNavigation({ - locales, - pathnames: { - '/': '/', - // (w) - '/about/': { - en: '/about/', // (w) - de: '/ueber-uns', // (wo) - ja: '/約/' // (w) - }, - // (wo) - '/news': { - en: '/news', // (wo) - de: '/neuigkeiten/', // (w) - ja: '/ニュース' // (wo) - } - } - }).usePathname(); - return <>{pathname}; - } - - it('returns the root', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render(); - screen.getByText('/'); - }); - - it.each(['/news', '/news/'])( - 'can return an internal pathname without a trailing slash for the default locale (%s)', - (pathname) => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - vi.mocked(useNextPathname).mockImplementation(() => pathname); - render(); - screen.getByText('/news'); - } - ); - - it.each(['/de/neuigkeiten/', '/de/neuigkeiten'])( - 'can return an internal pathname without a trailing slash for a secondary locale (%s)', - (pathname) => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(useNextPathname).mockImplementation(() => pathname); - render(); - screen.getByText('/news'); - } - ); - - it.each(['/about', '/about/'])( - 'can return an internal pathname with a trailing slash for the default locale (%s)', - (pathname) => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); - vi.mocked(useNextPathname).mockImplementation(() => pathname); - render(); - screen.getByText('/about/'); - } - ); - - it.each(['/de/ueber-uns/', '/de/ueber-uns'])( - 'can return an internal pathname with a trailing slash for a secondary locale (%s)', - (pathname) => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - vi.mocked(useNextPathname).mockImplementation(() => pathname); - render(); - screen.getByText('/about/'); - } - ); - }); - }); - - describe('useRouter', () => { - it('keeps a stable identity when possible', () => { - function Component() { - const router = useRouter(); - const initialRouter = useRef(router); - // eslint-disable-next-line react-compiler/react-compiler - return String(router === initialRouter.current); - } - const {rerender} = render(); - screen.getByText('true'); - - rerender(); - screen.getByText('true'); - }); - - describe('push', () => { - it('resolves to the correct path when passing another locale', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); - }); - - it('supports optional search params', () => { - function Component() { - const router = useRouter(); - router.push( - { - pathname: '/about', - query: { - foo: 'bar', - bar: [1, 2] - } - }, - {locale: 'de'} - ); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns?foo=bar&bar=1&bar=2'); - }); - - it('passes through unknown options to the Next.js router', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de', scroll: false}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns', {scroll: false}); - }); - }); - - it('handles unknown routes', () => { - function Component() { - const router = useRouter(); - // @ts-expect-error -- Unknown route - router.push('/unknown'); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/unknown'); - }); - }); - - /** - * Type tests - */ - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function TypeTests() { - const router = useRouter(); - - // @ts-expect-error -- Unknown route - router.push('/unknown'); - - // Valid - router.push('/about'); - router.push('/about', {locale: 'de'}); - router.push({pathname: '/about'}); - router.push('/catch-all/[[...parts]]'); - - // @ts-expect-error -- Requires params - router.push({pathname: '/news/[articleSlug]-[articleId]'}); - - router.push({ - pathname: '/news/[articleSlug]-[articleId]', - // @ts-expect-error -- Missing param - params: { - articleId: 3 - } - }); - - // Valid - router.push({ - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' - } - }); - - // @ts-expect-error -- Doesn't accept params - router.push({pathname: '/about', params: {foo: 'bar'}}); - - // @ts-expect-error -- Unknown locale - - Über uns - ; - - // @ts-expect-error -- Unknown route - About; - - // @ts-expect-error -- Requires params - About; - // @ts-expect-error -- Requires params - About; - - // @ts-expect-error -- Params for different route - About; - - // @ts-expect-error -- Doesn't accept params - About; - - // @ts-expect-error -- Missing params - Über uns; - - // Valid - Über uns; - Über uns; - - Über uns - ; - Optional catch-all; - - // Link composition - function WrappedLink( - props: ComponentProps> - ) { - return ; - } - About; - - News - ; - - // @ts-expect-error -- Requires params - News; - - // Valid - redirect({pathname: '/about'}); - redirect('/catch-all/[[...parts]]'); - redirect({ - pathname: '/catch-all/[[...parts]]', - params: {parts: ['one', 'two']} - }); - - // @ts-expect-error -- Unknown route - redirect('/unknown'); - // @ts-expect-error -- Localized alternative - redirect('/ueber-uns'); - // @ts-expect-error -- Requires params - redirect('/news/[articleSlug]-[articleId]'); - redirect({ - pathname: '/news/[articleSlug]-[articleId]', - // @ts-expect-error -- Missing param - params: { - articleId: 3 - } - }); - - // Allow unknown routes - const { - Link: LinkWithUnknown, - redirect: redirectWithUnknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - usePathname: usePathnameWithUnkown, - useRouter: useRouterWithUnknown - } = createLocalizedPathnamesNavigation({ - locales, - pathnames: pathnames as typeof pathnames & Record - }); - Unknown; - redirectWithUnknown('/unknown'); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const pathnameWithUnknown: ReturnType = - '/unknown'; - useRouterWithUnknown().push('/unknown'); - } -}); - -describe("localePrefix: 'as-needed', custom prefix", () => { - const {usePathname, useRouter} = createLocalizedPathnamesNavigation({ - locales: ['en', 'de-at'] as const, - pathnames: { - '/': '/', - '/about': { - en: '/about', - 'de-at': '/ueber-uns' - } - }, - localePrefix: { - mode: 'always', - prefixes: { - 'de-at': '/de' - } - } - }); - - describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing a locale with a custom prefix', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de-at'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); - }); - }); - }); - - describe('usePathname', () => { - it('returns the internal pathname for a locale with a custom prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de-at'})); - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/de'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns'); - rerender(); - screen.getByText('/about'); - }); - }); -}); - -describe("localePrefix: 'never'", () => { - const {useRouter} = createLocalizedPathnamesNavigation({ - pathnames, - locales, - localePrefix: 'never' - }); - - describe('useRouter', () => { - function Component({locale}: {locale?: (typeof locales)[number]}) { - const router = useRouter(); - router.push('/about', {locale}); - return null; - } - - describe('push', () => { - it('can push a pathname for the default locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/about'); - }); - - it('can push a pathname for a secondary locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/ueber-uns'); - }); - - it('resolves to the correct path when passing another locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); - }); - }); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx deleted file mode 100644 index 52babd5b4..000000000 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, {ComponentProps, ReactElement, forwardRef, useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; -import { - RoutingConfigLocalizedNavigation, - receiveLocaleCookie, - receiveRoutingConfig -} from '../../routing/config'; -import { - DomainsConfig, - LocalePrefixMode, - Locales, - Pathnames -} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; -import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - compileLocalizedPathname, - getRoute, - normalizeNameOrNameWithParams -} from '../shared/utils'; -import ClientLink from './ClientLink'; -import {clientPermanentRedirect, clientRedirect} from './redirects'; -import useBasePathname from './useBasePathname'; -import useBaseRouter from './useBaseRouter'; - -/** - * @deprecated Consider switching to `createNavigation` (see https://next-intl.dev/blog/next-intl-3-22#create-navigation) - **/ -export default function createLocalizedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode = 'always', - AppPathnames extends Pathnames = never, - AppDomains extends DomainsConfig = never ->( - routing: RoutingConfigLocalizedNavigation< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - > -) { - const config = receiveRoutingConfig(routing); - const localeCookie = receiveLocaleCookie(routing.localeCookie); - - function useTypedLocale(): AppLocales[number] { - const locale = useLocale(); - const isValid = config.locales.includes(locale as any); - if (!isValid) { - throw new Error( - process.env.NODE_ENV !== 'production' - ? `Unknown locale encountered: "${locale}". Make sure to validate the locale in \`i18n.ts\`.` - : undefined - ); - } - return locale; - } - - type LinkProps = Omit< - ComponentProps, - 'href' | 'name' | 'localePrefix' | 'localeCookie' - > & { - href: HrefOrUrlObjectWithParams; - locale?: AppLocales[number]; - }; - function Link( - {href, locale, ...rest}: LinkProps, - ref?: ComponentProps['ref'] - ) { - const defaultLocale = useTypedLocale(); - const finalLocale = locale || defaultLocale; - - return ( - ({ - locale: finalLocale, - // @ts-expect-error -- This is ok - pathname: href, - // @ts-expect-error -- This is ok - params: typeof href === 'object' ? href.params : undefined, - pathnames: config.pathnames - })} - locale={locale} - localeCookie={localeCookie} - localePrefix={config.localePrefix} - {...rest} - /> - ); - } - const LinkWithRef = forwardRef(Link) as unknown as < - Pathname extends keyof AppPathnames - >( - props: LinkProps & { - ref?: ComponentProps['ref']; - } - ) => ReactElement; - (LinkWithRef as any).displayName = 'Link'; - - function redirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render - const locale = useTypedLocale(); - const resolvedHref = getPathname({href, locale}); - return clientRedirect( - {pathname: resolvedHref, localePrefix: config.localePrefix}, - ...args - ); - } - - function permanentRedirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render - const locale = useTypedLocale(); - const resolvedHref = getPathname({href, locale}); - return clientPermanentRedirect( - {pathname: resolvedHref, localePrefix: config.localePrefix}, - ...args - ); - } - - function useRouter() { - const baseRouter = useBaseRouter(config.localePrefix, localeCookie); - const defaultLocale = useTypedLocale(); - - return useMemo( - () => ({ - ...baseRouter, - push( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const resolvedHref = getPathname({ - href, - locale: args[0]?.locale || defaultLocale - }); - return baseRouter.push(resolvedHref, ...args); - }, - - replace( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const resolvedHref = getPathname({ - href, - locale: args[0]?.locale || defaultLocale - }); - return baseRouter.replace(resolvedHref, ...args); - }, - - prefetch( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const resolvedHref = getPathname({ - href, - locale: args[0]?.locale || defaultLocale - }); - return baseRouter.prefetch(resolvedHref, ...args); - } - }), - [baseRouter, defaultLocale] - ); - } - - function usePathname(): keyof AppPathnames { - const pathname = useBasePathname(config); - const locale = useTypedLocale(); - - // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. - return useMemo( - () => - pathname ? getRoute(locale, pathname, config.pathnames) : pathname, - [locale, pathname] - ); - } - - function getPathname({ - href, - locale - }: { - locale: AppLocales[number]; - href: HrefOrHrefWithParams; - }) { - return compileLocalizedPathname({ - ...normalizeNameOrNameWithParams(href), - locale, - pathnames: config.pathnames - }); - } - - return { - Link: LinkWithRef, - redirect, - permanentRedirect, - usePathname, - useRouter, - getPathname - }; -} diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index e8aa6a9a3..d88bb4a1d 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,22 +1,21 @@ import {fireEvent, render, screen} from '@testing-library/react'; -import {PrefetchKind} from 'next/dist/client/components/router-reducer/router-reducer-types'; import { usePathname as useNextPathname, - useRouter as useNextRouter, - useParams -} from 'next/navigation'; -import React from 'react'; + useRouter as useNextRouter +} from 'next/navigation.js'; +import {type Locale, useLocale} from 'use-intl'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../react-client'; -import {DomainsConfig, Pathnames} from '../../routing'; -import createNavigation from './createNavigation'; +import type {DomainsConfig, Pathnames} from '../../routing.js'; +import createNavigation from './createNavigation.js'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); +vi.mock('use-intl', async () => ({ + ...(await vi.importActual('use-intl')), + useLocale: vi.fn(() => 'en') +})); -function mockCurrentLocale(locale: string) { - vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({ - locale - })); +function mockCurrentLocale(locale: Locale) { + vi.mocked(useLocale).mockImplementation(() => locale); } function mockLocation( @@ -56,12 +55,13 @@ const defaultLocale = 'en' as const; const domains: DomainsConfig = [ { defaultLocale: 'en', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] }, { defaultLocale: 'de', domain: 'example.de', - locales: ['de', 'en'] + locales: ['de', 'ja'] } ]; @@ -113,29 +113,6 @@ describe("localePrefix: 'always'", () => { }); describe('Link', () => { - describe('usage outside of Next.js', () => { - beforeEach(() => { - vi.mocked(useParams).mockImplementation((() => null) as any); - }); - - it('works with a provider', () => { - render( - - Test - - ); - expect( - screen.getByRole('link', {name: 'Test'}).getAttribute('href') - ).toBe('/en/test'); - }); - - it('throws without a provider', () => { - expect(() => render(Test)).toThrow( - 'No intl context found. Have you configured the provider?' - ); - }); - }); - it('can receive a ref', () => { let ref; @@ -171,16 +148,20 @@ describe("localePrefix: 'always'", () => { }); it('prefixes with a secondary locale', () => { - // Being able to accept a string and not only a strictly typed locale is - // important in order to be able to use a result from `useLocale()`. - // This is less relevant for `Link`, but this should be in sync across - // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377) - const locale = 'de' as string; - - invokeRouter((router) => router[method]('/about', {locale})); + invokeRouter((router) => router[method]('/about', {locale: 'de'})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about'); }); + it('can use a locale from `useLocale`', () => { + function Component() { + const locale = useLocale(); + const router = useRouter(); + router.push('/about', {locale}); + return null; + } + render(); + }); + it('passes through unknown options to the Next.js router', () => { invokeRouter((router) => router[method]('/about', {scroll: true})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about', { @@ -228,7 +209,11 @@ describe("localePrefix: 'always'", () => { it('prefixes with a secondary locale', () => { invokeRouter((router) => - router.prefetch('/about', {locale: 'de', kind: PrefetchKind.FULL}) + router.prefetch('/about', { + locale: 'de', + // @ts-expect-error -- Somehow only works via the enum (which is not exported) + kind: 'full' + }) ); expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about', { kind: 'full' @@ -289,8 +274,8 @@ describe("localePrefix: 'always', with `localeCookie`", () => { expect(cookieSpy).toHaveBeenCalledWith( [ 'NEXT_LOCALE=de', - 'max-age=60', 'sameSite=strict', + 'max-age=60', 'domain=example.com', 'partitioned', 'path=/nested', @@ -313,8 +298,8 @@ describe("localePrefix: 'always', with `localeCookie`", () => { expect(cookieSpy).toHaveBeenCalledWith( [ 'NEXT_LOCALE=de', - 'max-age=60', 'sameSite=strict', + 'max-age=60', 'domain=example.com', 'partitioned', 'path=/nested', @@ -361,12 +346,7 @@ describe("localePrefix: 'always', with `basePath`", () => { invokeRouter((router) => router.push('/about', {locale: 'de'})); expect(cookieSpy).toHaveBeenCalledWith( - [ - 'NEXT_LOCALE=de', - 'max-age=31536000', - 'sameSite=lax', - 'path=/base/path' - ].join(';') + ';' + ['NEXT_LOCALE=de', 'sameSite=lax', 'path=/base/path'].join(';') + ';' ); cookieSpy.mockRestore(); }); @@ -584,43 +564,20 @@ describe("localePrefix: 'as-needed', with `basePath` and `domains`", () => { describe('useRouter', () => { const invokeRouter = getInvokeRouter(useRouter); - describe('example.com, defaultLocale: "en"', () => { - beforeEach(() => { - mockLocation( - {pathname: '/base/path/about', host: 'example.com'}, - '/base/path' - ); - }); - - it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => { - invokeRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); - - it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => { - invokeRouter((router) => router.push('/test', {locale: 'de'})); - expect(useNextRouter().push).toHaveBeenCalledWith('/de/test'); - }); + it('can compute the correct pathname when on the default locale and not supplying a locale', () => { + invokeRouter((router) => router.push('/test')); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); }); - describe('example.de, defaultLocale: "de"', () => { - beforeEach(() => { - mockCurrentLocale('de'); - mockLocation( - {pathname: '/base/path/about', host: 'example.de'}, - '/base/path' - ); - }); - - it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => { - invokeRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); + it('can compute the correct pathname when on the default locale and supplying a secondary locale', () => { + invokeRouter((router) => router.push('/test', {locale: 'ja'})); + expect(useNextRouter().push).toHaveBeenCalledWith('/ja/test'); + }); - it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => { - invokeRouter((router) => router.push('/test', {locale: 'en'})); - expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); - }); + it('can compute the correct pathname when on a secondary locale and navigating to the default locale', () => { + mockCurrentLocale('ja'); + invokeRouter((router) => router.push('/test', {locale: 'en'})); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); }); }); }); @@ -660,13 +617,6 @@ describe("localePrefix: 'as-needed', with `domains`", () => { expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); - - it('prefixes the default locale when on a domain with a different defaultLocale', () => { - mockCurrentLocale('de'); - mockLocation({pathname: '/about', host: 'example.de'}); - invokeRouter((router) => router[method]('/about', {locale: 'en'})); - expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about'); - }); }); }); @@ -777,7 +727,11 @@ describe("localePrefix: 'never'", () => { expect(document.cookie).toContain('NEXT_LOCALE=de'); invokeRouter((router) => - router.prefetch('/about', {locale: 'ja', kind: PrefetchKind.AUTO}) + router.prefetch('/about', { + locale: 'ja', + // @ts-expect-error -- Somehow only works via the enum (which is not exported) + kind: 'auto' + }) ); expect(document.cookie).toContain('NEXT_LOCALE=ja'); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 7f3dd7698..6dc99839f 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1,23 +1,23 @@ import { usePathname as useNextPathname, useRouter as useNextRouter -} from 'next/navigation'; +} from 'next/navigation.js'; import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; -import { +import {type Locale, useLocale} from 'use-intl'; +import type { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation -} from '../../routing/config'; -import { +} from '../../routing/config.js'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns'; -import syncLocaleCookie from '../shared/syncLocaleCookie'; -import {getRoute} from '../shared/utils'; -import useBasePathname from './useBasePathname'; +} from '../../routing/types.js'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.js'; +import syncLocaleCookie from '../shared/syncLocaleCookie.js'; +import {getRoute} from '../shared/utils.js'; +import useBasePathname from './useBasePathname.js'; export default function createNavigation< const AppLocales extends Locales, @@ -40,14 +40,8 @@ export default function createNavigation< AppDomains > ) { - type Locale = AppLocales extends never ? string : AppLocales[number]; - - function useTypedLocale() { - return useLocale() as Locale; - } - const {Link, config, getPathname, ...redirects} = createSharedNavigationFns( - useTypedLocale, + useLocale, routing ); @@ -56,7 +50,7 @@ export default function createNavigation< ? string : keyof AppPathnames { const pathname = useBasePathname(config); - const locale = useTypedLocale(); + const locale = useLocale(); // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. return useMemo( @@ -77,7 +71,7 @@ export default function createNavigation< function useRouter() { const router = useNextRouter(); - const curLocale = useTypedLocale(); + const curLocale = useLocale(); const nextPathname = useNextPathname(); return useMemo(() => { @@ -87,15 +81,13 @@ export default function createNavigation< >(fn: Fn) { return function handler( href: Parameters[0]['href'], - options?: Partial & {locale?: string} + options?: Partial & {locale?: Locale} ): void { const {locale: nextLocale, ...rest} = options || {}; - // @ts-expect-error -- We're passing a domain here just in case const pathname = getPathname({ href, - locale: nextLocale || curLocale, - domain: window.location.host + locale: nextLocale || curLocale }); const args: [href: string, options?: Options] = [pathname]; diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx deleted file mode 100644 index 2de0bd005..000000000 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import { - usePathname as useNextPathname, - useRouter as useNextRouter, - useParams -} from 'next/navigation'; -import React from 'react'; -import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; -import createSharedPathnamesNavigation from './createSharedPathnamesNavigation'; - -vi.mock('next/navigation'); - -const locales = ['en', 'de'] as const; - -beforeEach(() => { - const router = { - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn() - }; - vi.mocked(useNextRouter).mockImplementation(() => router); - vi.mocked(useNextPathname).mockImplementation(() => '/'); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); -}); - -describe("localePrefix: 'as-needed'", () => { - const {Link, useRouter} = createSharedPathnamesNavigation({ - locales, - localePrefix: 'as-needed' - }); - - describe('Link', () => { - it('supports receiving a ref', () => { - const ref = React.createRef(); - render(); - expect(ref.current).not.toBe(null); - }); - }); - - describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing another locale', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about'); - }); - - it('passes through unknown options to the Next.js router', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de', scroll: false}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about', {scroll: false}); - }); - }); - }); - - /** - * Type tests - */ - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - function TypeTests() { - const router = useRouter(); - - // @ts-expect-error -- Only supports string paths - router.push({pathname: '/about'}); - - // Valid - router.push('/about'); - router.push('/about', {locale: 'de'}); - router.push('/unknown'); // No error since routes are unknown - - // @ts-expect-error -- Unknown locale - router.push('/about', {locale: 'unknown'}); - - // @ts-expect-error -- No params supported - - User - ; - - // @ts-expect-error -- Unknown locale - - User - ; - - // Valid - Über uns; - About; // No error since routes are unknown - } -}); - -describe("localePrefix: 'as-needed', custom prefix", () => { - const {usePathname, useRouter} = createSharedPathnamesNavigation({ - locales: ['en', 'en-gb'], - localePrefix: { - mode: 'as-needed', - prefixes: { - 'en-gb': '/uk' - } - } - }); - - describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing a locale with a custom prefix', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'en-gb'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/uk/about'); - }); - }); - }); - - describe('usePathname', () => { - it('returns the correct pathname for a custom locale prefix', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'en-gb'})); - vi.mocked(useNextPathname).mockImplementation(() => '/uk/about'); - function Component() { - return usePathname(); - } - render(); - screen.getByText('/about'); - }); - }); -}); - -describe("localePrefix: 'never'", () => { - const {useRouter} = createSharedPathnamesNavigation({ - locales, - localePrefix: 'never' - }); - - describe('useRouter', () => { - function Component({locale}: {locale?: (typeof locales)[number]}) { - const router = useRouter(); - router.push('/about', {locale}); - return null; - } - - describe('push', () => { - it('can push a pathname for the default locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/about'); - }); - - it('can push a pathname for a secondary locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/about'); - }); - - it('resolves to the correct path when passing another locale', () => { - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about'); - }); - }); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx deleted file mode 100644 index d72e875cf..000000000 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, {ComponentProps, ReactElement, forwardRef} from 'react'; -import { - RoutingConfigSharedNavigation, - receiveLocaleCookie, - receiveLocalePrefixConfig -} from '../../routing/config'; -import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; -import ClientLink from './ClientLink'; -import {clientPermanentRedirect, clientRedirect} from './redirects'; -import useBasePathname from './useBasePathname'; -import useBaseRouter from './useBaseRouter'; - -/** - * @deprecated Consider switching to `createNavigation` (see https://next-intl.dev/blog/next-intl-3-22#create-navigation) - **/ -export default function createSharedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode, - AppDomains extends DomainsConfig = never ->( - routing?: RoutingConfigSharedNavigation< - AppLocales, - AppLocalePrefixMode, - AppDomains - > -) { - const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); - const localeCookie = receiveLocaleCookie(routing?.localeCookie); - - type LinkProps = Omit< - ComponentProps>, - 'localePrefix' | 'localeCookie' - >; - function Link(props: LinkProps, ref: LinkProps['ref']) { - return ( - - ref={ref} - localeCookie={localeCookie} - localePrefix={localePrefix} - {...props} - /> - ); - } - const LinkWithRef = forwardRef(Link) as unknown as ( - props: LinkProps & {ref?: LinkProps['ref']} - ) => ReactElement; - (LinkWithRef as any).displayName = 'Link'; - - function redirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return clientRedirect({pathname, localePrefix}, ...args); - } - - function permanentRedirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return clientPermanentRedirect({pathname, localePrefix}, ...args); - } - - function usePathname(): string { - const result = useBasePathname({ - localePrefix, - defaultLocale: routing?.defaultLocale - }); - // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. - return result; - } - - function useRouter() { - return useBaseRouter( - localePrefix, - localeCookie - ); - } - - return { - Link: LinkWithRef, - redirect, - permanentRedirect, - usePathname, - useRouter - }; -} diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index 7d0372b79..c2f39bbc0 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,14 +1,2 @@ -export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; -export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export {default as createNavigation} from './createNavigation'; - -export type {QueryParams} from '../shared/utils'; - -import type { - Locales, - Pathnames as PathnamesDeprecatedExport -} from '../../routing/types'; - -/** @deprecated Please import from `next-intl/routing` instead. */ -export type Pathnames = - PathnamesDeprecatedExport; +export {default as createNavigation} from './createNavigation.js'; +export type {QueryParams} from '../shared/utils.js'; diff --git a/packages/next-intl/src/navigation/react-client/redirects.tsx b/packages/next-intl/src/navigation/react-client/redirects.tsx deleted file mode 100644 index a9d118ba1..000000000 --- a/packages/next-intl/src/navigation/react-client/redirects.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import useLocale from '../../react-client/useLocale'; -import {ParametersExceptFirst} from '../../shared/types'; -import {basePermanentRedirect, baseRedirect} from '../shared/redirects'; - -function createRedirectFn(redirectFn: typeof baseRedirect) { - return function clientRedirect( - params: Omit[0], 'locale'>, - ...args: ParametersExceptFirst - ) { - let locale; - try { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render - locale = useLocale(); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - '`redirect()` and `permanentRedirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' - ); - } - throw e; - } - - return redirectFn({...params, locale}, ...args); - }; -} - -export const clientRedirect = createRedirectFn(baseRedirect); -export const clientPermanentRedirect = createRedirectFn(basePermanentRedirect); diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx index 9a4c94d32..f12a60977 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx @@ -1,15 +1,18 @@ import {render, screen} from '@testing-library/react'; -import {usePathname as useNextPathname, useParams} from 'next/navigation'; -import React from 'react'; +import {usePathname as useNextPathname} from 'next/navigation.js'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {NextIntlClientProvider} from '../../index.react-client'; -import useBasePathname from './useBasePathname'; +import {NextIntlClientProvider, useLocale} from '../../index.react-client.js'; +import useBasePathname from './useBasePathname.js'; -vi.mock('next/navigation'); +vi.mock('next/navigation.js'); +vi.mock('use-intl', async () => ({ + ...(await vi.importActual('use-intl')), + useLocale: vi.fn(() => 'en') +})); function mockPathname(pathname: string) { vi.mocked(useNextPathname).mockImplementation(() => pathname); - vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); + vi.mocked(useLocale).mockImplementation(() => 'en'); } function Component() { @@ -53,7 +56,6 @@ describe('prefixed routing', () => { describe('usage outside of Next.js', () => { beforeEach(() => { vi.mocked(useNextPathname).mockImplementation((() => null) as any); - vi.mocked(useParams).mockImplementation((() => null) as any); }); it('returns `null` when used within a provider', () => { @@ -64,10 +66,4 @@ describe('usage outside of Next.js', () => { ); expect(container.innerHTML).toBe(''); }); - - it('throws without a provider', () => { - expect(() => render()).toThrow( - 'No intl context found. Have you configured the provider?' - ); - }); }); diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index bd52dffc8..e7ced2828 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -1,17 +1,17 @@ -import {usePathname as useNextPathname} from 'next/navigation'; +import {usePathname as useNextPathname} from 'next/navigation.js'; import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; -import { +import {useLocale} from 'use-intl'; +import type { LocalePrefixConfigVerbose, LocalePrefixMode, Locales -} from '../../routing/types'; +} from '../../routing/types.js'; import { getLocaleAsPrefix, getLocalePrefix, hasPathnamePrefixed, unprefixPathname -} from '../../shared/utils'; +} from '../../shared/utils.js'; export default function useBasePathname< AppLocales extends Locales, diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx deleted file mode 100644 index da01c455e..000000000 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import {render} from '@testing-library/react'; -import {PrefetchKind} from 'next/dist/client/components/router-reducer/router-reducer-types'; -import {AppRouterInstance} from 'next/dist/shared/lib/app-router-context.shared-runtime'; -import { - usePathname as useNextPathname, - useRouter as useNextRouter -} from 'next/navigation'; -import React, {useEffect} from 'react'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import useBaseRouter from './useBaseRouter'; - -vi.mock('next/navigation', () => { - const router: AppRouterInstance = { - push: vi.fn(), - replace: vi.fn(), - prefetch: vi.fn(), - back: vi.fn(), - forward: vi.fn(), - refresh: vi.fn() - }; - return { - useRouter: vi.fn(() => router), - useParams: vi.fn(() => ({locale: 'en'})), - usePathname: vi.fn(() => '/') - }; -}); - -function callRouter(cb: (router: ReturnType) => void) { - function Component() { - const router = useBaseRouter( - { - // The mode is not used, only the absence of - // `prefixes` is relevant for this test suite - mode: 'as-needed' - }, - { - name: 'NEXT_LOCALE', - maxAge: 31536000, - sameSite: 'lax' - } - ); - useEffect(() => { - cb(router); - }, [router]); - return null; - } - - render(); -} - -function mockLocation(pathname: string, basePath = '') { - vi.mocked(useNextPathname).mockReturnValue(pathname); - - delete (global.window as any).location; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - global.window ??= Object.create(window); - (global.window as any).location = {pathname: basePath + pathname}; -} - -function clearNextRouterMocks() { - ['push', 'replace', 'prefetch', 'back', 'forward', 'refresh'].forEach( - (fnName) => { - vi.mocked((useNextRouter() as any)[fnName]).mockClear(); - } - ); -} - -describe('unprefixed routing', () => { - beforeEach(() => { - mockLocation('/'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); - - it('can change the locale with `push`', () => { - callRouter((router) => router.push('/about', {locale: 'de'})); - expect(useNextRouter().push).toHaveBeenCalledWith('/de/about'); - }); - - it('can change the locale with `replace`', () => { - callRouter((router) => router.replace('/about', {locale: 'es'})); - expect(useNextRouter().replace).toHaveBeenCalledWith('/es/about'); - }); - - it('can prefetch a new locale', () => { - callRouter((router) => - router.prefetch('/about', {locale: 'es', kind: PrefetchKind.AUTO}) - ); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/es/about', { - kind: PrefetchKind.AUTO - }); - }); - - it('keeps the cookie value in sync', () => { - document.cookie = 'NEXT_LOCALE=en'; - - callRouter((router) => router.push('/about', {locale: 'de'})); - expect(document.cookie).toContain('NEXT_LOCALE=de'); - - callRouter((router) => router.push('/test')); - expect(document.cookie).toContain('NEXT_LOCALE=de'); - - callRouter((router) => router.replace('/about', {locale: 'es'})); - expect(document.cookie).toContain('NEXT_LOCALE=es'); - - callRouter((router) => - router.prefetch('/about', {locale: 'it', kind: PrefetchKind.AUTO}) - ); - expect(document.cookie).toContain('NEXT_LOCALE=it'); - }); -}); - -describe('prefixed routing', () => { - beforeEach(() => { - mockLocation('/en'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/en/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); -}); - -describe('basePath unprefixed routing', () => { - beforeEach(() => { - mockLocation('/', '/base/path'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); -}); - -describe('basePath prefixed routing', () => { - beforeEach(() => { - mockLocation('/en', '/base/path'); - clearNextRouterMocks(); - }); - - it('can push', () => { - callRouter((router) => router.push('/test')); - expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); - }); - - it('can replace', () => { - callRouter((router) => router.replace('/test')); - expect(useNextRouter().replace).toHaveBeenCalledWith('/en/test'); - }); - - it('can prefetch', () => { - callRouter((router) => router.prefetch('/test')); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/test'); - }); - - it('passes through absolute urls', () => { - callRouter((router) => router.push('https://example.com')); - expect(useNextRouter().push).toHaveBeenCalledWith('https://example.com'); - }); - - it('passes through relative urls', () => { - callRouter((router) => router.push('about')); - expect(useNextRouter().push).toHaveBeenCalledWith('about'); - }); -}); diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx deleted file mode 100644 index 0cf263195..000000000 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import {useRouter as useNextRouter, usePathname} from 'next/navigation'; -import {useMemo} from 'react'; -import useLocale from '../../react-client/useLocale'; -import {InitializedLocaleCookieConfig} from '../../routing/config'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types'; -import {getLocalePrefix, localizeHref} from '../../shared/utils'; -import syncLocaleCookie from '../shared/syncLocaleCookie'; -import {getBasePath} from '../shared/utils'; - -type IntlNavigateOptions = { - locale?: AppLocales[number]; -}; - -/** - * Returns a wrapped instance of `useRouter` from `next/navigation` that - * will automatically localize the `href` parameters it receives. - * - * @example - * ```tsx - * 'use client'; - * - * import {useRouter} from 'next-intl/client'; - * - * const router = useRouter(); - * - * // When the user is on `/en`, the router will navigate to `/en/about` - * router.push('/about'); - * - * // Optionally, you can switch the locale by passing the second argument - * router.push('/about', {locale: 'de'}); - * ``` - */ -export default function useBaseRouter< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->( - localePrefix: LocalePrefixConfigVerbose, - localeCookie: InitializedLocaleCookieConfig -) { - const router = useNextRouter(); - const locale = useLocale(); - const pathname = usePathname(); - - return useMemo(() => { - function localize(href: string, nextLocale?: AppLocales[number]) { - let curPathname = window.location.pathname; - - const basePath = getBasePath(pathname); - if (basePath) curPathname = curPathname.replace(basePath, ''); - - const targetLocale = nextLocale || locale; - - // We generate a prefix in any case, but decide - // in `localizeHref` if we apply it or not - const prefix = getLocalePrefix(targetLocale, localePrefix); - - return localizeHref(href, targetLocale, locale, curPathname, prefix); - } - - function createHandler< - Options, - Fn extends (href: string, options?: Options) => void - >(fn: Fn) { - return function handler( - href: string, - options?: Options & IntlNavigateOptions - ): void { - const {locale: nextLocale, ...rest} = options || {}; - - syncLocaleCookie(localeCookie, pathname, locale, nextLocale); - - const args: [ - href: string, - options?: Parameters[1] - ] = [localize(href, nextLocale)]; - if (Object.keys(rest).length > 0) { - args.push(rest); - } - - // @ts-expect-error -- This is ok - return fn(...args); - }; - } - - return { - ...router, - push: createHandler< - Parameters[1], - typeof router.push - >(router.push), - replace: createHandler< - Parameters[1], - typeof router.replace - >(router.replace), - prefetch: createHandler< - Parameters[1], - typeof router.prefetch - >(router.prefetch) - }; - }, [locale, localeCookie, localePrefix, pathname, router]); -} diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx deleted file mode 100644 index 852ef9309..000000000 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, {ComponentProps} from 'react'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types'; -import {getLocale} from '../../server.react-server'; -import {getLocalePrefix} from '../../shared/utils'; -import LegacyBaseLink from '../shared/LegacyBaseLink'; - -// Only used by legacy navigation APIs, can be removed when they are removed - -type Props< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode -> = Omit< - ComponentProps, - 'locale' | 'prefix' | 'localePrefixMode' -> & { - locale?: AppLocales[number]; - localePrefix: LocalePrefixConfigVerbose; -}; - -export default async function ServerLink< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode ->({locale, localePrefix, ...rest}: Props) { - const finalLocale = locale || (await getLocale()); - const prefix = getLocalePrefix(finalLocale, localePrefix); - - return ( - - ); -} diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx deleted file mode 100644 index 117648daf..000000000 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, {ComponentProps} from 'react'; -import { - RoutingConfigLocalizedNavigation, - receiveRoutingConfig -} from '../../routing/config'; -import { - DomainsConfig, - LocalePrefixMode, - Locales, - Pathnames -} from '../../routing/types'; -import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy'; -import {ParametersExceptFirst} from '../../shared/types'; -import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - compileLocalizedPathname, - normalizeNameOrNameWithParams -} from '../shared/utils'; -import ServerLink from './ServerLink'; -import {serverPermanentRedirect, serverRedirect} from './redirects'; - -export default function createLocalizedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode = 'always', - AppPathnames extends Pathnames = never, - AppDomains extends DomainsConfig = never ->( - routing: RoutingConfigLocalizedNavigation< - AppLocales, - AppLocalePrefixMode, - AppPathnames, - AppDomains - > -) { - const config = receiveRoutingConfig(routing); - - type LinkProps = Omit< - ComponentProps, - 'href' | 'name' | 'localePrefix' | 'localeCookie' - > & { - href: HrefOrUrlObjectWithParams; - locale?: AppLocales[number]; - }; - function Link({ - href, - locale, - ...rest - }: LinkProps) { - const defaultLocale = getRequestLocale() as (typeof config.locales)[number]; - const finalLocale = locale || defaultLocale; - - return ( - ({ - locale: finalLocale, - // @ts-expect-error -- This is ok - pathname: href, - // @ts-expect-error -- This is ok - params: typeof href === 'object' ? href.params : undefined, - pathnames: config.pathnames - })} - locale={locale} - localeCookie={config.localeCookie} - localePrefix={config.localePrefix} - {...rest} - /> - ); - } - - function redirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const locale = getRequestLocale(); - const pathname = getPathname({href, locale}); - return serverRedirect( - {localePrefix: config.localePrefix, pathname}, - ...args - ); - } - - function permanentRedirect( - href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst - ) { - const locale = getRequestLocale(); - const pathname = getPathname({href, locale}); - return serverPermanentRedirect( - {localePrefix: config.localePrefix, pathname}, - ...args - ); - } - - function getPathname({ - href, - locale - }: { - locale: AppLocales[number]; - href: HrefOrHrefWithParams; - }) { - return compileLocalizedPathname({ - ...normalizeNameOrNameWithParams(href), - locale, - pathnames: config.pathnames - }); - } - - function notSupported(hookName: string) { - return () => { - throw new Error( - `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.` - ); - }; - } - - return { - Link, - redirect, - permanentRedirect, - getPathname, - usePathname: notSupported('usePathname'), - useRouter: notSupported('useRouter') - }; -} diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx index dc491b08b..18f48d8b3 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from 'vitest'; -import createNavigation from './createNavigation'; +import createNavigation from './createNavigation.js'; vi.mock('react'); diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index ba09f314c..4ccccc12e 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -1,15 +1,15 @@ -import { +import type { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation -} from '../../routing/config'; -import { +} from '../../routing/config.js'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns'; -import getServerLocale from './getServerLocale'; +} from '../../routing/types.js'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.js'; +import getServerLocale from './getServerLocale.js'; export default function createNavigation< const AppLocales extends Locales, @@ -32,14 +32,8 @@ export default function createNavigation< AppDomains > ) { - type Locale = AppLocales extends never ? string : AppLocales[number]; - - function getLocale() { - return getServerLocale() as Promise; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const {config, ...fns} = createSharedNavigationFns(getLocale, routing); + const {config, ...fns} = createSharedNavigationFns(getServerLocale, routing); function notSupported(hookName: string) { return () => { diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx deleted file mode 100644 index 2b7aa6f8a..000000000 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, {ComponentProps} from 'react'; -import { - RoutingConfigSharedNavigation, - receiveLocaleCookie, - receiveLocalePrefixConfig -} from '../../routing/config'; -import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; -import ServerLink from './ServerLink'; -import {serverPermanentRedirect, serverRedirect} from './redirects'; - -export default function createSharedPathnamesNavigation< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode, - AppDomains extends DomainsConfig = never ->( - routing?: RoutingConfigSharedNavigation< - AppLocales, - AppLocalePrefixMode, - AppDomains - > -) { - const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); - const localeCookie = receiveLocaleCookie(routing?.localeCookie); - - function notSupported(hookName: string) { - return () => { - throw new Error( - `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.` - ); - }; - } - - function Link( - props: Omit< - ComponentProps>, - 'localePrefix' | 'localeCookie' - > - ) { - return ( - - localeCookie={localeCookie} - localePrefix={localePrefix} - {...props} - /> - ); - } - - function redirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return serverRedirect({pathname, localePrefix}, ...args); - } - - function permanentRedirect( - pathname: string, - ...args: ParametersExceptFirst - ) { - return serverPermanentRedirect({pathname, localePrefix}, ...args); - } - - return { - Link, - redirect, - permanentRedirect, - usePathname: notSupported('usePathname'), - useRouter: notSupported('useRouter') - }; -} diff --git a/packages/next-intl/src/navigation/react-server/getServerLocale.tsx b/packages/next-intl/src/navigation/react-server/getServerLocale.tsx index b153bdf4c..ba8529277 100644 --- a/packages/next-intl/src/navigation/react-server/getServerLocale.tsx +++ b/packages/next-intl/src/navigation/react-server/getServerLocale.tsx @@ -1,4 +1,4 @@ -import getConfig from '../../server/react-server/getConfig'; +import getConfig from '../../server/react-server/getConfig.js'; /** * This is only moved to a separate module for easier mocking in diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx index 88636a437..e1f566961 100644 --- a/packages/next-intl/src/navigation/react-server/index.tsx +++ b/packages/next-intl/src/navigation/react-server/index.tsx @@ -1,4 +1,2 @@ -export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; -export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; -export {default as createNavigation} from './createNavigation'; -export type {Pathnames} from '../../routing/types'; +export {default as createNavigation} from './createNavigation.js'; +export type {Pathnames} from '../../routing/types.js'; diff --git a/packages/next-intl/src/navigation/react-server/redirects.tsx b/packages/next-intl/src/navigation/react-server/redirects.tsx deleted file mode 100644 index c0370282e..000000000 --- a/packages/next-intl/src/navigation/react-server/redirects.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy'; -import {ParametersExceptFirst} from '../../shared/types'; -import {basePermanentRedirect, baseRedirect} from '../shared/redirects'; - -function createRedirectFn(redirectFn: typeof baseRedirect) { - return function serverRedirect( - params: Omit[0], 'locale'>, - ...args: ParametersExceptFirst - ) { - const locale = getRequestLocale(); - return redirectFn({...params, locale}, ...args); - }; -} - -export const serverRedirect = createRedirectFn(baseRedirect); -export const serverPermanentRedirect = createRedirectFn(basePermanentRedirect); diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index fe595688a..a1e0fb781 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -1,62 +1,31 @@ 'use client'; -import NextLink from 'next/link'; -import {usePathname} from 'next/navigation'; -import React, { - ComponentProps, - MouseEvent, - forwardRef, - useEffect, - useState +import NextLink, {type LinkProps} from 'next/link.js'; +import {usePathname} from 'next/navigation.js'; +import { + type ComponentProps, + type MouseEvent, + type Ref, + forwardRef } from 'react'; -import useLocale from '../../react-client/useLocale'; -import {InitializedLocaleCookieConfig} from '../../routing/config'; -import syncLocaleCookie from './syncLocaleCookie'; +import {type Locale, useLocale} from 'use-intl'; +import type {InitializedLocaleCookieConfig} from '../../routing/config.js'; +import syncLocaleCookie from './syncLocaleCookie.js'; -type Props = Omit, 'locale'> & { - locale?: string; - defaultLocale?: string; +type NextLinkProps = Omit, keyof LinkProps> & + Omit; + +type Props = NextLinkProps & { + locale?: Locale; localeCookie: InitializedLocaleCookieConfig; - /** Special case for `localePrefix: 'as-needed'` and `domains`. */ - unprefixed?: { - domains: {[domain: string]: string}; - pathname: string; - }; }; function BaseLink( - { - defaultLocale, - href, - locale, - localeCookie, - onClick, - prefetch, - unprefixed, - ...rest - }: Props, - ref: ComponentProps['ref'] + {href, locale, localeCookie, onClick, prefetch, ...rest}: Props, + ref: Ref ) { const curLocale = useLocale(); const isChangingLocale = locale != null && locale !== curLocale; - const linkLocale = locale || curLocale; - const host = useHost(); - - const finalHref = - // Only after hydration (to avoid mismatches) - host && - // If there is an `unprefixed` prop, the - // `defaultLocale` might differ by domain - unprefixed && - // Unprefix the pathname if a domain matches - (unprefixed.domains[host] === linkLocale || - // … and handle unknown domains by applying the - // global `defaultLocale` (e.g. on localhost) - (!Object.keys(unprefixed.domains).includes(host) && - curLocale === defaultLocale && - !locale)) - ? unprefixed.pathname - : href; // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. @@ -76,10 +45,14 @@ function BaseLink( prefetch = false; } + // Somehow the types for `next/link` don't work as expected + // when `moduleResolution: "nodenext"` is used. + const Link = NextLink as unknown as (props: NextLinkProps) => JSX.Element; + return ( - (); - - useEffect(() => { - setHost(window.location.host); - }, []); - - return host; -} - export default forwardRef(BaseLink); diff --git a/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx b/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx deleted file mode 100644 index 52471fc75..000000000 --- a/packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client'; - -import NextLink from 'next/link'; -import {usePathname} from 'next/navigation'; -import React, {ComponentProps, forwardRef, useEffect, useState} from 'react'; -import useLocale from '../../react-client/useLocale'; -import {InitializedLocaleCookieConfig} from '../../routing/config'; -import {LocalePrefixMode} from '../../routing/types'; -import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils'; -import BaseLink from './BaseLink'; - -type Props = Omit, 'locale'> & { - locale: string; - prefix: string; - localePrefixMode: LocalePrefixMode; - localeCookie: InitializedLocaleCookieConfig; -}; - -function LegacyBaseLink( - {href, locale, localeCookie, localePrefixMode, prefix, ...rest}: Props, - ref: Props['ref'] -) { - // The types aren't entirely correct here. Outside of Next.js - // `useParams` can be called, but the return type is `null`. - const pathname = usePathname() as ReturnType | null; - - const curLocale = useLocale(); - const isChangingLocale = locale !== curLocale; - - const [localizedHref, setLocalizedHref] = useState(() => - isLocalizableHref(href) && - (localePrefixMode !== 'never' || isChangingLocale) - ? // For the `localePrefix: 'as-needed' strategy, the href shouldn't - // be prefixed if the locale is the default locale. To determine this, we - // need a) the default locale and b) the information if we use prefixed - // routing. The default locale can vary by domain, therefore during the - // RSC as well as the SSR render, we can't determine the default locale - // statically. Therefore we always prefix the href since this will - // always result in a valid URL, even if it might cause a redirect. This - // is better than pointing to a non-localized href during the server - // render, which would potentially be wrong. The final href is - // determined in the effect below. - prefixHref(href, prefix) - : href - ); - - useEffect(() => { - if (!pathname) return; - - setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix)); - }, [curLocale, href, locale, pathname, prefix]); - - return ( - - ); -} - -const LegacyBaseLinkWithRef = forwardRef(LegacyBaseLink); -(LegacyBaseLinkWithRef as any).displayName = 'ClientLink'; -export default LegacyBaseLinkWithRef; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index fe3155005..44b3e39c2 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -1,37 +1,36 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect -} from 'next/navigation'; -import React, {ComponentProps, forwardRef, use} from 'react'; +} from 'next/navigation.js'; +import {type ComponentProps, forwardRef} from 'react'; +import type {Locale} from 'use-intl'; import { - RoutingConfigLocalizedNavigation, - RoutingConfigSharedNavigation, + type RoutingConfigLocalizedNavigation, + type RoutingConfigSharedNavigation, receiveRoutingConfig -} from '../../routing/config'; -import { - DomainConfig, +} from '../../routing/config.js'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import {ParametersExceptFirst, Prettify} from '../../shared/types'; -import {isLocalizableHref, isPromise} from '../../shared/utils'; -import BaseLink from './BaseLink'; +} from '../../routing/types.js'; +import type {ParametersExceptFirst, Prettify} from '../../shared/types.js'; +import use from '../../shared/use.js'; +import {isLocalizableHref, isPromise} from '../../shared/utils.js'; +import BaseLink from './BaseLink.js'; import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - QueryParams, + type HrefOrHrefWithParams, + type HrefOrUrlObjectWithParams, + type QueryParams, applyPathnamePrefix, compileLocalizedPathname, normalizeNameOrNameWithParams, serializeSearchParams, validateReceivedConfig -} from './utils'; +} from './utils.js'; type PromiseOrValue = Type | Promise; -type UnwrapPromiseOrValue = - Type extends Promise ? Value : Type; /** * Shared implementations for `react-server` and `react-client` @@ -42,9 +41,7 @@ export default function createSharedNavigationFns< const AppLocalePrefixMode extends LocalePrefixMode = 'always', const AppDomains extends DomainsConfig = never >( - getLocale: () => PromiseOrValue< - AppLocales extends never ? string : AppLocales[number] - >, + getLocale: () => PromiseOrValue, routing?: [AppPathnames] extends [never] ? | RoutingConfigSharedNavigation< @@ -60,8 +57,6 @@ export default function createSharedNavigationFns< AppDomains > ) { - type Locale = UnwrapPromiseOrValue>; - const config = receiveRoutingConfig(routing || {}); if (process.env.NODE_ENV !== 'production') { validateReceivedConfig(config); @@ -71,16 +66,6 @@ export default function createSharedNavigationFns< ? undefined : AppPathnames; - // This combination requires that the current host is known in order to - // compute a correct pathname. Since that can only be achieved by reading from - // headers, this would break static rendering. Therefore, as a workaround we - // always add a prefix in this case to be on the safe side. The downside is - // that the user might get redirected again if the middleware detects that the - // prefix is not needed. - const forcePrefixSsr = - (config.localePrefix.mode === 'as-needed' && (config as any).domains) || - undefined; - type LinkProps = Prettify< Omit< ComponentProps, @@ -91,17 +76,16 @@ export default function createSharedNavigationFns< ? ComponentProps['href'] : HrefOrUrlObjectWithParams; /** @see https://next-intl.dev/docs/routing/navigation#link */ - locale?: string; + locale?: Locale; } >; function Link( {href, locale, ...rest}: LinkProps, ref: ComponentProps['ref'] ) { - let pathname, params, query; + let pathname, params; if (typeof href === 'object') { pathname = href.pathname; - query = href.query; // @ts-expect-error -- This is ok params = href.params; } else { @@ -118,20 +102,18 @@ export default function createSharedNavigationFns< const finalPathname = isLocalizable ? getPathname( - // @ts-expect-error -- This is ok { locale: locale || curLocale, + // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, - locale != null || forcePrefixSsr || undefined + locale != null || undefined ) : pathname; return ( , - domain: DomainConfig - ) => { - // @ts-expect-error -- This is ok - acc[domain.domain] = domain.defaultLocale; - return acc; - }, - {} - ), - pathname: getPathname( - // @ts-expect-error -- This is ok - { - locale: curLocale, - href: - pathnames == null - ? {pathname, query} - : {pathname, query, params} - }, - false - ) - } - : undefined - } {...rest} /> ); } const LinkWithRef = forwardRef(Link); - type DomainConfigForAsNeeded = typeof routing extends undefined - ? {} - : AppLocalePrefixMode extends 'as-needed' - ? [AppDomains] extends [never] - ? {} - : { - /** - * In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. - * @see https://next-intl.dev/docs/routing/navigation#getpathname - */ - domain: AppDomains[number]['domain']; - } - : {}; - function getPathname( args: { /** @see https://next-intl.dev/docs/routing/navigation#getpathname */ href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - locale: string; - } & DomainConfigForAsNeeded, + locale: Locale; + }, /** @private Removed in types returned below */ _forcePrefix?: boolean ) { @@ -223,14 +161,7 @@ export default function createSharedNavigationFns< }); } - return applyPathnamePrefix( - pathname, - locale, - config, - // @ts-expect-error -- This is ok - args.domain, - _forcePrefix - ); + return applyPathnamePrefix(pathname, locale, config, _forcePrefix); } function getRedirectFn( @@ -238,15 +169,10 @@ export default function createSharedNavigationFns< ) { /** @see https://next-intl.dev/docs/routing/navigation#redirect */ return function redirectFn( - args: Omit[0], 'domain'> & - Partial, + args: Omit[0], 'domain'>, ...rest: ParametersExceptFirst ) { - return fn( - // @ts-expect-error -- We're forcing the prefix when no domain is provided - getPathname(args, args.domain ? undefined : forcePrefixSsr), - ...rest - ); + return fn(getPathname(args), ...rest); }; } diff --git a/packages/next-intl/src/navigation/shared/redirects.test.tsx b/packages/next-intl/src/navigation/shared/redirects.test.tsx deleted file mode 100644 index e9a331ca2..000000000 --- a/packages/next-intl/src/navigation/shared/redirects.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect -} from 'next/navigation'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {basePermanentRedirect, baseRedirect} from './redirects'; - -vi.mock('next/navigation'); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe.each([ - [baseRedirect, nextRedirect], - [basePermanentRedirect, nextPermanentRedirect] -])('baseRedirect', (redirectFn, nextFn) => { - describe("localePrefix: 'always'", () => { - it('handles internal paths', () => { - redirectFn({ - pathname: '/test/path', - locale: 'en', - localePrefix: {mode: 'always'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - redirectFn({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: {mode: 'always'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('https://example.com'); - }); - }); - - describe("localePrefix: 'as-needed'", () => { - it('handles internal paths', () => { - redirectFn({ - pathname: '/test/path', - locale: 'en', - localePrefix: {mode: 'as-needed'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('/en/test/path'); - }); - - it('handles external paths', () => { - redirectFn({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: {mode: 'as-needed'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('https://example.com'); - }); - }); - - describe("localePrefix: 'never'", () => { - it('handles internal paths', () => { - redirectFn({ - pathname: '/test/path', - locale: 'en', - localePrefix: {mode: 'never'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('/test/path'); - }); - - it('handles external paths', () => { - redirectFn({ - pathname: 'https://example.com', - locale: 'en', - localePrefix: {mode: 'never'} - }); - expect(nextFn).toHaveBeenCalledTimes(1); - expect(nextFn).toHaveBeenCalledWith('https://example.com'); - }); - }); -}); diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx deleted file mode 100644 index cc6bd04db..000000000 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect -} from 'next/navigation'; -import { - LocalePrefixConfigVerbose, - LocalePrefixMode, - Locales -} from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; -import { - getLocalePrefix, - isLocalizableHref, - prefixPathname -} from '../../shared/utils'; - -function createRedirectFn(redirectFn: typeof nextRedirect) { - return function baseRedirect< - AppLocales extends Locales, - AppLocalePrefixMode extends LocalePrefixMode - >( - params: { - pathname: string; - locale: Locales[number]; - localePrefix: LocalePrefixConfigVerbose; - }, - ...args: ParametersExceptFirst - ) { - const prefix = getLocalePrefix(params.locale, params.localePrefix); - - // This logic is considered legacy and is replaced by `applyPathnamePrefix`. - // We keep it this way for now for backwards compatibility. - const localizedPathname = - params.localePrefix.mode === 'never' || - !isLocalizableHref(params.pathname) - ? params.pathname - : prefixPathname(prefix, params.pathname); - - return redirectFn(localizedPathname, ...args); - }; -} - -export const baseRedirect = createRedirectFn(nextRedirect); -export const basePermanentRedirect = createRedirectFn(nextPermanentRedirect); diff --git a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx index 6ebeb99dd..af826e352 100644 --- a/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx +++ b/packages/next-intl/src/navigation/shared/syncLocaleCookie.tsx @@ -1,5 +1,6 @@ -import {InitializedLocaleCookieConfig} from '../../routing/config'; -import {getBasePath} from './utils'; +import type {Locale} from 'use-intl'; +import type {InitializedLocaleCookieConfig} from '../../routing/config.js'; +import {getBasePath} from './utils.js'; /** * We have to keep the cookie value in sync as Next.js might @@ -9,8 +10,8 @@ import {getBasePath} from './utils'; export default function syncLocaleCookie( localeCookie: InitializedLocaleCookieConfig, pathname: string | null, - locale: string, - nextLocale?: string + locale: Locale, + nextLocale?: Locale ) { const isSwitchingLocale = nextLocale !== locale && nextLocale != null; diff --git a/packages/next-intl/src/navigation/shared/utils.test.tsx b/packages/next-intl/src/navigation/shared/utils.test.tsx index d20e951aa..1e59939e8 100644 --- a/packages/next-intl/src/navigation/shared/utils.test.tsx +++ b/packages/next-intl/src/navigation/shared/utils.test.tsx @@ -3,7 +3,7 @@ import { compileLocalizedPathname, getBasePath, serializeSearchParams -} from './utils'; +} from './utils.js'; describe('serializeSearchParams', () => { it('handles strings', () => { diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 2dd158146..6ff74f470 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,21 +1,23 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; -import {ResolvedRoutingConfig} from '../../routing/config'; -import { +import type {Locale} from 'use-intl'; +import type {ResolvedRoutingConfig} from '../../routing/config.js'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; +} from '../../routing/types.js'; import { getLocalePrefix, + getLocalizedTemplate, getSortedPathnames, isLocalizableHref, matchesPathname, normalizeTrailingSlash, prefixPathname -} from '../../shared/utils'; -import StrictParams from './StrictParams'; +} from '../../shared/utils.js'; +import type StrictParams from './StrictParams.js'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -49,7 +51,7 @@ export function normalizeNameOrNameWithParams( href: | HrefOrHrefWithParams | { - locale: string; + locale: Locale; href: HrefOrHrefWithParams; } ): { @@ -131,10 +133,10 @@ export function compileLocalizedPathname({ } function compilePath( - namedPath: Pathnames[keyof Pathnames] + namedPath: Pathnames[keyof Pathnames], + internalPathname: string ) { - const template = - typeof namedPath === 'string' ? namedPath : namedPath[locale]; + const template = getLocalizedTemplate(namedPath, locale, internalPathname); let compiled = template; if (params) { @@ -175,12 +177,12 @@ export function compileLocalizedPathname({ if (typeof pathname === 'string') { const namedPath = getNamedPath(pathname); - const compiled = compilePath(namedPath); + const compiled = compilePath(namedPath, pathname); return compiled; } else { - const {pathname: href, ...rest} = pathname; - const namedPath = getNamedPath(href); - const compiled = compilePath(namedPath); + const {pathname: internalPathname, ...rest} = pathname; + const namedPath = getNamedPath(internalPathname); + const compiled = compilePath(namedPath, internalPathname); const result: UrlObject = {...rest, pathname: compiled}; return result; } @@ -202,7 +204,16 @@ export function getRoute( return internalPathname; } } else { - if (matchesPathname(localizedPathnamesOrPathname[locale], decoded)) { + if ( + matchesPathname( + getLocalizedTemplate( + localizedPathnamesOrPathname, + locale, + internalPathname + ), + decoded + ) + ) { return internalPathname; } } @@ -250,7 +261,6 @@ export function applyPathnamePrefix< 'defaultLocale' > >, - domain?: string, force?: boolean ): string { const {mode} = routing.localePrefix; @@ -262,29 +272,11 @@ export function applyPathnamePrefix< if (mode === 'always') { shouldPrefix = true; } else if (mode === 'as-needed') { - let defaultLocale: AppLocales[number] | undefined = routing.defaultLocale; - - if (routing.domains) { - const domainConfig = routing.domains.find( - (cur) => cur.domain === domain - ); - if (domainConfig) { - defaultLocale = domainConfig.defaultLocale; - } else if (process.env.NODE_ENV !== 'production') { - if (!domain) { - console.error( - "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter.\n\nSee: https://next-intl.dev/docs/routing#domains-localeprefix-asneeded" - ); - } else { - // If a domain was provided, but it wasn't found in the routing - // configuration, this can be an indicator that the user is on - // localhost. In this case, we can simply use the domain-agnostic - // default locale. - } - } - } - - shouldPrefix = defaultLocale !== locale; + shouldPrefix = routing.domains + ? // Since locales are unique per domain, any locale that is a + // default locale of a domain doesn't require a prefix + !routing.domains.some((cur) => cur.defaultLocale === locale) + : locale !== routing.defaultLocale; } } diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index 96a2ec8a8..3963d42d9 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -1,139 +1 @@ -/* eslint-env node */ - -import fs from 'fs'; -import path from 'path'; -import type {NextConfig} from 'next'; - -function withExtensions(localPath: string) { - return [ - `${localPath}.ts`, - `${localPath}.tsx`, - `${localPath}.js`, - `${localPath}.jsx` - ]; -} - -let hasWarnedForDeprecatedI18nConfig = false; - -function resolveI18nPath(providedPath?: string, cwd?: string) { - function resolvePath(pathname: string) { - const parts = []; - if (cwd) parts.push(cwd); - parts.push(pathname); - return path.resolve(...parts); - } - - function pathExists(pathname: string) { - return fs.existsSync(resolvePath(pathname)); - } - - if (providedPath) { - if (!pathExists(providedPath)) { - throw new Error( - `[next-intl] Could not find i18n config at ${providedPath}, please provide a valid path.` - ); - } - return providedPath; - } else { - for (const candidate of [ - ...withExtensions('./i18n/request'), - ...withExtensions('./src/i18n/request') - ]) { - if (pathExists(candidate)) { - return candidate; - } - } - - for (const candidate of [ - ...withExtensions('./i18n'), - ...withExtensions('./src/i18n') - ]) { - if (pathExists(candidate)) { - if (!hasWarnedForDeprecatedI18nConfig) { - console.warn( - `\n[next-intl] Reading request configuration from ${candidate} is deprecated, please see https://next-intl.dev/blog/next-intl-3-22#i18n-request — you can either move your configuration to ./i18n/request.ts or provide a custom path in your Next.js config: - -const withNextIntl = createNextIntlPlugin( - './path/to/i18n/request.tsx' -);\n` - ); - hasWarnedForDeprecatedI18nConfig = true; - } - return candidate; - } - } - - throw new Error(`\n[next-intl] Could not locate request configuration module. - -This path is supported by default: ./(src/)i18n/request.{js,jsx,ts,tsx} - -Alternatively, you can specify a custom location in your Next.js config: - -const withNextIntl = createNextIntlPlugin( - './path/to/i18n/request.tsx' -);\n`); - } -} - -function initPlugin(i18nPath?: string, nextConfig?: NextConfig): NextConfig { - if (nextConfig?.i18n != null) { - console.warn( - "\n[next-intl] An `i18n` property was found in your Next.js config. This likely causes conflicts and should therefore be removed if you use the App Router.\n\nIf you're in progress of migrating from the Pages Router, you can refer to this example: https://next-intl.dev/examples#app-router-migration\n" - ); - } - - const useTurbo = process.env.TURBOPACK != null; - - const nextIntlConfig: Partial = {}; - - // Assign alias for `next-intl/config` - if (useTurbo) { - if (i18nPath?.startsWith('/')) { - throw new Error( - "[next-intl] Turbopack support for next-intl currently does not support absolute paths, please provide a relative one (e.g. './src/i18n/config.ts').\n\nFound: " + - i18nPath + - '\n' - ); - } - nextIntlConfig.experimental = { - ...nextConfig?.experimental, - turbo: { - ...nextConfig?.experimental?.turbo, - resolveAlias: { - ...nextConfig?.experimental?.turbo?.resolveAlias, - // Turbo aliases don't work with absolute - // paths (see error handling above) - 'next-intl/config': resolveI18nPath(i18nPath) - } - } - }; - } else { - nextIntlConfig.webpack = function webpack( - ...[config, options]: Parameters> - ) { - // Webpack requires absolute paths - config.resolve.alias['next-intl/config'] = path.resolve( - config.context, - resolveI18nPath(i18nPath, config.context) - ); - if (typeof nextConfig?.webpack === 'function') { - return nextConfig.webpack(config, options); - } - return config; - }; - } - - // Forward config - nextIntlConfig.env = { - ...nextConfig?.env, - _next_intl_trailing_slash: nextConfig?.trailingSlash ? 'true' : undefined - }; - - return Object.assign({}, nextConfig, nextIntlConfig); -} - -module.exports = function createNextIntlPlugin(i18nPath?: string) { - return function withNextIntl(nextConfig?: NextConfig) { - return initPlugin(i18nPath, nextConfig); - }; -}; +export {default} from './plugin/index.js'; diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx new file mode 100644 index 000000000..386db0b35 --- /dev/null +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -0,0 +1,83 @@ +import fs from 'fs'; +import path from 'path'; +import {throwError} from './utils.js'; +import watchFile from './watchFile.js'; + +function runOnce(fn: () => void) { + if (process.env._NEXT_INTL_COMPILE_MESSAGES === '1') { + return; + } + process.env._NEXT_INTL_COMPILE_MESSAGES = '1'; + fn(); +} + +export default function createMessagesDeclaration( + messagesPaths: Array +) { + // Next.js can call the Next.js config multiple + // times - ensure we only run once. + runOnce(() => { + for (const messagesPath of messagesPaths) { + const fullPath = path.resolve(messagesPath); + + if (!fs.existsSync(fullPath)) { + throwError( + `\`createMessagesDeclaration\` points to a non-existent file: ${fullPath}` + ); + } + if (!fullPath.endsWith('.json')) { + throwError( + `\`createMessagesDeclaration\` needs to point to a JSON file. Received: ${fullPath}` + ); + } + + // Keep this as a runtime check and don't replace + // this with a constant during the build process + const env = process.env['NODE_ENV'.trim()]; + + compileDeclaration(messagesPath); + + if (env === 'development') { + startWatching(messagesPath); + } + } + }); +} + +function startWatching(messagesPath: string) { + const watcher = watchFile(messagesPath, () => { + compileDeclaration(messagesPath, true); + }); + + process.on('exit', () => { + watcher.close(); + }); +} + +function compileDeclaration(messagesPath: string, async: true): Promise; +function compileDeclaration(messagesPath: string, async?: false): void; +function compileDeclaration( + messagesPath: string, + async = false +): void | Promise { + const declarationPath = messagesPath.replace(/\.json$/, '.d.json.ts'); + + function createDeclaration(content: string) { + return `// This file is auto-generated by next-intl, do not edit directly. +// See: https://next-intl.dev/docs/workflows/typescript#messages-arguments + +declare const messages: ${content.trim()}; +export default messages;`; + } + + if (async) { + return fs.promises + .readFile(messagesPath, 'utf-8') + .then((content) => + fs.promises.writeFile(declarationPath, createDeclaration(content)) + ); + } + + const content = fs.readFileSync(messagesPath, 'utf-8'); + fs.writeFileSync(declarationPath, createDeclaration(content)); +} diff --git a/packages/next-intl/src/plugin/createNextIntlPlugin.tsx b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx new file mode 100644 index 000000000..62a6af878 --- /dev/null +++ b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx @@ -0,0 +1,40 @@ +import type {NextConfig} from 'next'; +import createMessagesDeclaration from './createMessagesDeclaration.js'; +import getNextConfig from './getNextConfig.js'; +import type {PluginConfig} from './types.js'; +import {warn} from './utils.js'; + +function initPlugin( + pluginConfig: PluginConfig, + nextConfig?: NextConfig +): NextConfig { + if (nextConfig?.i18n != null) { + warn( + "\n[next-intl] An `i18n` property was found in your Next.js config. This likely causes conflicts and should therefore be removed if you use the App Router.\n\nIf you're in progress of migrating from the Pages Router, you can refer to this example: https://next-intl.dev/examples#app-router-migration\n" + ); + } + + const messagesPathOrPaths = + pluginConfig.experimental?.createMessagesDeclaration; + if (messagesPathOrPaths) { + createMessagesDeclaration( + typeof messagesPathOrPaths === 'string' + ? [messagesPathOrPaths] + : messagesPathOrPaths + ); + } + + return getNextConfig(pluginConfig, nextConfig); +} + +export default function createNextIntlPlugin( + i18nPathOrConfig: string | PluginConfig = {} +) { + const config = + typeof i18nPathOrConfig === 'string' + ? {requestConfig: i18nPathOrConfig} + : i18nPathOrConfig; + return function withNextIntl(nextConfig?: NextConfig) { + return initPlugin(config, nextConfig); + }; +} diff --git a/packages/next-intl/src/plugin/getNextConfig.tsx b/packages/next-intl/src/plugin/getNextConfig.tsx new file mode 100644 index 000000000..5b7f8d277 --- /dev/null +++ b/packages/next-intl/src/plugin/getNextConfig.tsx @@ -0,0 +1,110 @@ +import fs from 'fs'; +import path from 'path'; +import type {NextConfig} from 'next'; +import type {PluginConfig} from './types.js'; +import {throwError} from './utils.js'; + +function withExtensions(localPath: string) { + return [ + `${localPath}.ts`, + `${localPath}.tsx`, + `${localPath}.js`, + `${localPath}.jsx` + ]; +} + +function resolveI18nPath(providedPath?: string, cwd?: string) { + function resolvePath(pathname: string) { + const parts = []; + if (cwd) parts.push(cwd); + parts.push(pathname); + return path.resolve(...parts); + } + + function pathExists(pathname: string) { + return fs.existsSync(resolvePath(pathname)); + } + + if (providedPath) { + if (!pathExists(providedPath)) { + throwError( + `Could not find i18n config at ${providedPath}, please provide a valid path.` + ); + } + return providedPath; + } else { + for (const candidate of [ + ...withExtensions('./i18n/request'), + ...withExtensions('./src/i18n/request') + ]) { + if (pathExists(candidate)) { + return candidate; + } + } + + throwError( + `Could not locate request configuration module.\n\nThis path is supported by default: ./(src/)i18n/request.{js,jsx,ts,tsx}\n\nAlternatively, you can specify a custom location in your Next.js config:\n\nconst withNextIntl = createNextIntlPlugin( + +Alternatively, you can specify a custom location in your Next.js config: + +const withNextIntl = createNextIntlPlugin( + './path/to/i18n/request.tsx' +);` + ); + } +} +export default function getNextConfig( + pluginConfig: PluginConfig, + nextConfig?: NextConfig +) { + const useTurbo = process.env.TURBOPACK != null; + const nextIntlConfig: Partial = {}; + + // Assign alias for `next-intl/config` + if (useTurbo) { + if (pluginConfig.requestConfig?.startsWith('/')) { + throwError( + "Turbopack support for next-intl currently does not support absolute paths, please provide a relative one (e.g. './src/i18n/config.ts').\n\nFound: " + + pluginConfig.requestConfig + ); + } + + // `NextConfig['turbo']` is stable in Next.js 15. In case the + // experimental feature is removed in the future, we should + // replace this accordingly in a future major version. + nextIntlConfig.experimental = { + ...nextConfig?.experimental, + turbo: { + ...nextConfig?.experimental?.turbo, + resolveAlias: { + ...nextConfig?.experimental?.turbo?.resolveAlias, + // Turbo aliases don't work with absolute + // paths (see error handling above) + 'next-intl/config': resolveI18nPath(pluginConfig.requestConfig) + } + } + }; + } else { + nextIntlConfig.webpack = function webpack( + ...[config, options]: Parameters> + ) { + // Webpack requires absolute paths + config.resolve.alias['next-intl/config'] = path.resolve( + config.context, + resolveI18nPath(pluginConfig.requestConfig, config.context) + ); + if (typeof nextConfig?.webpack === 'function') { + return nextConfig.webpack(config, options); + } + return config; + }; + } + + // Forward config + nextIntlConfig.env = { + ...nextConfig?.env, + _next_intl_trailing_slash: nextConfig?.trailingSlash ? 'true' : undefined + }; + + return Object.assign({}, nextConfig, nextIntlConfig); +} diff --git a/packages/next-intl/src/plugin/index.tsx b/packages/next-intl/src/plugin/index.tsx new file mode 100644 index 000000000..06c26f724 --- /dev/null +++ b/packages/next-intl/src/plugin/index.tsx @@ -0,0 +1 @@ +export {default} from './createNextIntlPlugin.js'; diff --git a/packages/next-intl/src/plugin/types.tsx b/packages/next-intl/src/plugin/types.tsx new file mode 100644 index 000000000..24e5cd7c8 --- /dev/null +++ b/packages/next-intl/src/plugin/types.tsx @@ -0,0 +1,7 @@ +export type PluginConfig = { + requestConfig?: string; + experimental?: { + /** A path to the messages file that you'd like to create a declaration for. In case you want to consider multiple files, you can pass an array of paths. */ + createMessagesDeclaration?: string | Array; + }; +}; diff --git a/packages/next-intl/src/plugin/utils.tsx b/packages/next-intl/src/plugin/utils.tsx new file mode 100644 index 000000000..c4906c141 --- /dev/null +++ b/packages/next-intl/src/plugin/utils.tsx @@ -0,0 +1,11 @@ +function formatMessage(message: string) { + return `\n[next-intl] ${message}\n`; +} + +export function throwError(message: string): never { + throw new Error(formatMessage(message)); +} + +export function warn(message: string) { + console.warn(formatMessage(message)); +} diff --git a/packages/next-intl/src/plugin/watchFile.tsx b/packages/next-intl/src/plugin/watchFile.tsx new file mode 100644 index 000000000..744430f89 --- /dev/null +++ b/packages/next-intl/src/plugin/watchFile.tsx @@ -0,0 +1,21 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Wrapper around `fs.watch` that provides a workaround + * for https://github.com/nodejs/node/issues/5039. + */ +export default function watchFile(filepath: string, callback: () => void) { + const directory = path.dirname(filepath); + const filename = path.basename(filepath); + + return fs.watch( + directory, + {persistent: false, recursive: false}, + (event, changedFilename) => { + if (changedFilename === filename) { + callback(); + } + } + ); +} diff --git a/packages/next-intl/src/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx index 335f33d66..0e7fe3d0e 100644 --- a/packages/next-intl/src/react-client/index.tsx +++ b/packages/next-intl/src/react-client/index.tsx @@ -46,7 +46,4 @@ export const useFormatter = callHook( base_useFormatter ) as typeof base_useFormatter; -// Replace `useLocale` export from `use-intl` -export {default as useLocale} from './useLocale'; - -export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider'; +export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.js'; diff --git a/packages/next-intl/src/react-client/useFormatter.test.tsx b/packages/next-intl/src/react-client/useFormatter.test.tsx index a1ec1bff2..123f39fc5 100644 --- a/packages/next-intl/src/react-client/useFormatter.test.tsx +++ b/packages/next-intl/src/react-client/useFormatter.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it} from 'vitest'; -import {NextIntlClientProvider, useFormatter} from '.'; +import {NextIntlClientProvider, useFormatter} from './index.js'; function Component() { const format = useFormatter(); diff --git a/packages/next-intl/src/react-client/useLocale.test.tsx b/packages/next-intl/src/react-client/useLocale.test.tsx deleted file mode 100644 index 9093c0619..000000000 --- a/packages/next-intl/src/react-client/useLocale.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import {useParams} from 'next/navigation'; -import React from 'react'; -import {expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useLocale} from '.'; - -vi.mock('next/navigation', () => ({ - useParams: vi.fn(() => ({locale: 'en'})) -})); - -function Component() { - return <>{useLocale()}; -} - -it('returns a locale from `useParams` without a provider', () => { - render(); - screen.getByText('en'); -}); - -it('prioritizes the locale from the provider', () => { - render( - - - - ); - screen.getByText('de'); -}); - -it('throws if neither a locale from the provider or useParams is available', () => { - vi.mocked(useParams).mockImplementation(() => ({})); - expect(() => render()).toThrow( - 'No intl context found. Have you configured the provider?' - ); -}); diff --git a/packages/next-intl/src/react-client/useLocale.tsx b/packages/next-intl/src/react-client/useLocale.tsx deleted file mode 100644 index 2c21738a7..000000000 --- a/packages/next-intl/src/react-client/useLocale.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {useParams} from 'next/navigation'; -// Workaround for some bundle splitting until we have ESM -import {useLocale as useBaseLocale} from 'use-intl/_useLocale'; -import {LOCALE_SEGMENT_NAME} from '../shared/constants'; - -let hasWarnedForParams = false; - -export default function useLocale(): string { - // The types aren't entirely correct here. Outside of Next.js - // `useParams` can be called, but the return type is `null`. - const params = useParams() as ReturnType | null; - - let locale; - - try { - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/rules-of-hooks, react-compiler/react-compiler -- False positive - locale = useBaseLocale(); - } catch (error) { - if (typeof params?.[LOCALE_SEGMENT_NAME] === 'string') { - if (process.env.NODE_ENV !== 'production' && !hasWarnedForParams) { - console.warn( - 'Deprecation warning: `useLocale` has returned a default from `useParams().locale` since no `NextIntlClientProvider` ancestor was found for the calling component. This behavior will be removed in the next major version. Please ensure all Client Components that use `next-intl` are wrapped in a `NextIntlClientProvider`.' - ); - hasWarnedForParams = true; - } - locale = params[LOCALE_SEGMENT_NAME]; - } else { - throw error; - } - } - - return locale; -} diff --git a/packages/next-intl/src/react-client/useNow.test.tsx b/packages/next-intl/src/react-client/useNow.test.tsx index d00490d69..6c2e783b7 100644 --- a/packages/next-intl/src/react-client/useNow.test.tsx +++ b/packages/next-intl/src/react-client/useNow.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useNow} from '.'; +import {NextIntlClientProvider, useNow} from './index.js'; function Component() { const now = useNow(); diff --git a/packages/next-intl/src/react-client/useTimeZone.test.tsx b/packages/next-intl/src/react-client/useTimeZone.test.tsx index e8fe3a792..3e2b6709f 100644 --- a/packages/next-intl/src/react-client/useTimeZone.test.tsx +++ b/packages/next-intl/src/react-client/useTimeZone.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import {NextIntlClientProvider, useTimeZone} from '.'; +import {NextIntlClientProvider, useTimeZone} from './index.js'; function Component() { const timeZone = useTimeZone(); diff --git a/packages/next-intl/src/react-client/useTranslations.test.tsx b/packages/next-intl/src/react-client/useTranslations.test.tsx index f9b7a2390..e74e9fc56 100644 --- a/packages/next-intl/src/react-client/useTranslations.test.tsx +++ b/packages/next-intl/src/react-client/useTranslations.test.tsx @@ -1,7 +1,6 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it, vi} from 'vitest'; -import {NextIntlClientProvider, useTranslations} from '.'; +import {NextIntlClientProvider, useTranslations} from './index.js'; function Component() { const t = useTranslations('Component'); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index 6ce77d6b4..2e5cb37e1 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,14 +1,30 @@ import {expect, it, vi} from 'vitest'; -import {getLocale, getNow, getTimeZone} from '../server.react-server'; -import NextIntlClientProvider from '../shared/NextIntlClientProvider'; -import NextIntlClientProviderServer from './NextIntlClientProviderServer'; +import getConfigNow from '../server/react-server/getConfigNow.js'; +import getFormats from '../server/react-server/getFormats.js'; +import {getLocale, getMessages, getTimeZone} from '../server.react-server.js'; +import NextIntlClientProvider from '../shared/NextIntlClientProvider.js'; +import NextIntlClientProviderServer from './NextIntlClientProviderServer.js'; vi.mock('../../src/server/react-server', async () => ({ getLocale: vi.fn(async () => 'en-US'), - getNow: vi.fn(async () => new Date('2020-01-01T00:00:00.000Z')), + getMessages: vi.fn(async () => ({})), getTimeZone: vi.fn(async () => 'America/New_York') })); +vi.mock('../../src/server/react-server/getFormats', () => ({ + default: vi.fn(async () => ({ + dateTime: { + short: { + day: 'numeric' + } + } + })) +})); + +vi.mock('../../src/server/react-server/getConfigNow', () => ({ + default: vi.fn(async () => new Date('2020-01-01T00:00:00.000Z')) +})); + vi.mock('../../src/shared/NextIntlClientProvider', async () => ({ default: vi.fn(() => 'NextIntlClientProvider') })); @@ -18,7 +34,9 @@ it("doesn't read from headers if all relevant configuration is passed", async () children: null, locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), - timeZone: 'Europe/London' + timeZone: 'Europe/London', + formats: {}, + messages: {} }); expect(result.type).toBe(NextIntlClientProvider); @@ -26,12 +44,16 @@ it("doesn't read from headers if all relevant configuration is passed", async () children: null, locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), - timeZone: 'Europe/London' + timeZone: 'Europe/London', + formats: {}, + messages: {} }); expect(getLocale).not.toHaveBeenCalled(); - expect(getNow).not.toHaveBeenCalled(); + expect(getConfigNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); + expect(getFormats).not.toHaveBeenCalled(); + expect(getMessages).not.toHaveBeenCalled(); }); it('reads missing configuration from getter functions', async () => { @@ -44,10 +66,20 @@ it('reads missing configuration from getter functions', async () => { children: null, locale: 'en-US', now: new Date('2020-01-01T00:00:00.000Z'), - timeZone: 'America/New_York' + timeZone: 'America/New_York', + messages: {}, + formats: { + dateTime: { + short: { + day: 'numeric' + } + } + } }); expect(getLocale).toHaveBeenCalled(); - expect(getNow).toHaveBeenCalled(); + expect(getConfigNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); + expect(getFormats).toHaveBeenCalled(); + expect(getMessages).toHaveBeenCalled(); }); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index 941187dcc..f468f1149 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,11 +1,15 @@ -import React, {ComponentProps} from 'react'; -import {getLocale, getNow, getTimeZone} from '../server.react-server'; -import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider'; +import type {ComponentProps} from 'react'; +import getConfigNow from '../server/react-server/getConfigNow.js'; +import getFormats from '../server/react-server/getFormats.js'; +import {getLocale, getMessages, getTimeZone} from '../server.react-server.js'; +import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.js'; type Props = ComponentProps; export default async function NextIntlClientProviderServer({ + formats, locale, + messages, now, timeZone, ...rest @@ -14,8 +18,13 @@ export default async function NextIntlClientProviderServer({ diff --git a/packages/next-intl/src/react-server/getTranslator.tsx b/packages/next-intl/src/react-server/getTranslator.tsx deleted file mode 100644 index 640170d09..000000000 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import {ReactNode, cache} from 'react'; -import { - Formats, - MarkupTranslationValues, - MessageKeys, - NamespaceKeys, - NestedKeyOf, - NestedValueOf, - RichTranslationValues, - TranslationValues, - createTranslator -} from 'use-intl/core'; - -function getTranslatorImpl< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never ->( - config: Parameters[0], - namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): boolean; -} { - return createTranslator({ - ...config, - namespace - }); -} - -export default cache(getTranslatorImpl); diff --git a/packages/next-intl/src/react-server/index.test.tsx b/packages/next-intl/src/react-server/index.test.tsx index 36c74a3df..d3ea6a1aa 100644 --- a/packages/next-intl/src/react-server/index.test.tsx +++ b/packages/next-intl/src/react-server/index.test.tsx @@ -1,8 +1,7 @@ -import React from 'react'; import {describe, expect, it, vi} from 'vitest'; -import {getTranslations} from '../server.react-server'; -import {isPromise} from '../shared/utils'; -import {renderToStream} from './testUtils'; +import {getTranslations} from '../server.react-server.js'; +import {isPromise} from '../shared/utils.js'; +import {renderToStream} from './testUtils.js'; import { _createCache, useFormatter, @@ -10,7 +9,7 @@ import { useMessages, useNow, useTranslations -} from '.'; +} from './index.js'; vi.mock('react'); diff --git a/packages/next-intl/src/react-server/index.tsx b/packages/next-intl/src/react-server/index.tsx index 08d767c66..52c25e38d 100644 --- a/packages/next-intl/src/react-server/index.tsx +++ b/packages/next-intl/src/react-server/index.tsx @@ -7,13 +7,13 @@ */ // Replaced exports from the `react` package -export {default as useLocale} from './useLocale'; -export {default as useTranslations} from './useTranslations'; -export {default as useFormatter} from './useFormatter'; -export {default as useNow} from './useNow'; -export {default as useTimeZone} from './useTimeZone'; -export {default as useMessages} from './useMessages'; -export {default as NextIntlClientProvider} from './NextIntlClientProviderServer'; +export {default as useLocale} from './useLocale.js'; +export {default as useTranslations} from './useTranslations.js'; +export {default as useFormatter} from './useFormatter.js'; +export {default as useNow} from './useNow.js'; +export {default as useTimeZone} from './useTimeZone.js'; +export {default as useMessages} from './useMessages.js'; +export {default as NextIntlClientProvider} from './NextIntlClientProviderServer.js'; // Everything from `core` export * from 'use-intl/core'; diff --git a/packages/next-intl/src/react-server/testUtils.tsx b/packages/next-intl/src/react-server/testUtils.tsx index b6eca87a7..09ea87257 100644 --- a/packages/next-intl/src/react-server/testUtils.tsx +++ b/packages/next-intl/src/react-server/testUtils.tsx @@ -1,8 +1,9 @@ -import React, {ReactNode, Suspense} from 'react'; -import {ReactDOMServerReadableStream} from 'react-dom/server'; +import {type ReactNode, Suspense} from 'react'; +import type {ReactDOMServerReadableStream} from 'react-dom/server'; // @ts-expect-error -- Not available in types import {renderToReadableStream as _renderToReadableStream} from 'react-dom/server.browser'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports const renderToReadableStream: typeof import('react-dom/server').renderToReadableStream = _renderToReadableStream; diff --git a/packages/next-intl/src/react-server/useConfig.tsx b/packages/next-intl/src/react-server/useConfig.tsx index 83b7227a6..75d099e80 100644 --- a/packages/next-intl/src/react-server/useConfig.tsx +++ b/packages/next-intl/src/react-server/useConfig.tsx @@ -1,5 +1,5 @@ -import {use} from 'react'; -import getConfig from '../server/react-server/getConfig'; +import getConfig from '../server/react-server/getConfig.js'; +import use from '../shared/use.js'; function useHook(hookName: string, promise: Promise) { try { diff --git a/packages/next-intl/src/react-server/useFormatter.test.tsx b/packages/next-intl/src/react-server/useFormatter.test.tsx new file mode 100644 index 000000000..b371c2ddc --- /dev/null +++ b/packages/next-intl/src/react-server/useFormatter.test.tsx @@ -0,0 +1,43 @@ +import {describe, expect, it, vi} from 'vitest'; +import getDefaultNow from '../server/react-server/getDefaultNow.js'; +import {renderToStream} from './testUtils.js'; +import useFormatter from './useFormatter.js'; + +vi.mock('react'); +vi.mock('../server/react-server/getDefaultNow.tsx', () => ({ + default: vi.fn(() => new Date()) +})); + +vi.mock('../../src/server/react-server/createRequestConfig', () => ({ + default: async () => ({ + locale: 'en' + }) +})); + +describe('dynamicIO', () => { + it('should not include `now` in the translator config', async () => { + function TestComponent() { + const format = useFormatter(); + format.dateTime(new Date()); + format.number(1); + format.dateTimeRange(new Date(), new Date()); + format.list(['a', 'b']); + format.relativeTime(new Date(), new Date()); + return null; + } + + await renderToStream(); + expect(getDefaultNow).not.toHaveBeenCalled(); + }); + + it('should read `now` for `relativeTime` if relying on a global `now`', async () => { + function TestComponent() { + const format = useFormatter(); + format.relativeTime(new Date()); + return null; + } + + await renderToStream(); + expect(getDefaultNow).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 7aac95999..b5f03ed1c 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,14 +1,8 @@ -import {cache} from 'react'; -import {type useFormatter as useFormatterType} from 'use-intl'; -import {createFormatter} from 'use-intl/core'; -import useConfig from './useConfig'; +import type {useFormatter as useFormatterType} from 'use-intl'; +import getServerFormatter from '../server/react-server/getServerFormatter.js'; +import useConfig from './useConfig.js'; -const createFormatterCached = cache(createFormatter); - -export default function useFormatter( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useFormatter(): ReturnType { const config = useConfig('useFormatter'); - return createFormatterCached(config); + return getServerFormatter(config); } diff --git a/packages/next-intl/src/react-server/useLocale.tsx b/packages/next-intl/src/react-server/useLocale.tsx index 3a4d281b7..88b638dc1 100644 --- a/packages/next-intl/src/react-server/useLocale.tsx +++ b/packages/next-intl/src/react-server/useLocale.tsx @@ -1,10 +1,7 @@ import type {useLocale as useLocaleType} from 'use-intl'; -import useConfig from './useConfig'; +import useConfig from './useConfig.js'; -export default function useLocale( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useLocale(): ReturnType { const config = useConfig('useLocale'); return config.locale; } diff --git a/packages/next-intl/src/react-server/useMessages.tsx b/packages/next-intl/src/react-server/useMessages.tsx index 8dff17598..00016d160 100644 --- a/packages/next-intl/src/react-server/useMessages.tsx +++ b/packages/next-intl/src/react-server/useMessages.tsx @@ -1,11 +1,8 @@ import type {useMessages as useMessagesType} from 'use-intl'; -import {getMessagesFromConfig} from '../server/react-server/getMessages'; -import useConfig from './useConfig'; +import {getMessagesFromConfig} from '../server/react-server/getMessages.js'; +import useConfig from './useConfig.js'; -export default function useMessages( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useMessages(): ReturnType { const config = useConfig('useMessages'); return getMessagesFromConfig(config); } diff --git a/packages/next-intl/src/react-server/useNow.tsx b/packages/next-intl/src/react-server/useNow.tsx index 3b4c2411c..6580e6de4 100644 --- a/packages/next-intl/src/react-server/useNow.tsx +++ b/packages/next-intl/src/react-server/useNow.tsx @@ -1,8 +1,9 @@ import type {useNow as useNowType} from 'use-intl'; -import useConfig from './useConfig'; +import getDefaultNow from '../server/react-server/getDefaultNow.js'; +import useConfig from './useConfig.js'; export default function useNow( - ...[options]: Parameters + options?: Parameters[0] ): ReturnType { if (options?.updateInterval != null) { console.error( @@ -11,5 +12,5 @@ export default function useNow( } const config = useConfig('useNow'); - return config.now; + return config.now ?? getDefaultNow(); } diff --git a/packages/next-intl/src/react-server/useTimeZone.tsx b/packages/next-intl/src/react-server/useTimeZone.tsx index 6b47cfe36..a1184a29a 100644 --- a/packages/next-intl/src/react-server/useTimeZone.tsx +++ b/packages/next-intl/src/react-server/useTimeZone.tsx @@ -1,10 +1,7 @@ import type {useTimeZone as useTimeZoneType} from 'use-intl'; -import useConfig from './useConfig'; +import useConfig from './useConfig.js'; -export default function useTimeZone( - // eslint-disable-next-line no-empty-pattern - ...[]: Parameters -): ReturnType { +export default function useTimeZone(): ReturnType { const config = useConfig('useTimeZone'); return config.timeZone; } diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index b9f2af5ef..df0a36ab6 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -1,7 +1,7 @@ -import React, {cache} from 'react'; +import {cache} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {renderToStream} from './testUtils'; -import {createTranslator, useTranslations} from '.'; +import {renderToStream} from './testUtils.js'; +import {createTranslator, useTranslations} from './index.js'; vi.mock('../../src/server/react-server/createRequestConfig', () => ({ default: async () => ({ @@ -31,6 +31,20 @@ vi.mock('use-intl/core', async (importActual) => { }; }); +describe('dynamicIO', () => { + it('should not include `now` in the translator config', async () => { + function TestComponent() { + useTranslations('A'); + return null; + } + + await renderToStream(); + expect(createTranslator).toHaveBeenCalledWith( + expect.not.objectContaining({now: expect.anything()}) + ); + }); +}); + describe('performance', () => { let attemptedRenders: Record; let finishedRenders: Record; diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index ebf7b613c..cbb6293cc 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,10 +1,10 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getBaseTranslator from './getTranslator'; -import useConfig from './useConfig'; +import getServerTranslator from '../server/react-server/getServerTranslator.js'; +import useConfig from './useConfig.js'; export default function useTranslations( ...[namespace]: Parameters ): ReturnType { const config = useConfig('useTranslations'); - return getBaseTranslator(config, namespace); + return getServerTranslator(config, namespace); } diff --git a/packages/next-intl/src/routing.tsx b/packages/next-intl/src/routing.tsx index 445db87c8..692879791 100644 --- a/packages/next-intl/src/routing.tsx +++ b/packages/next-intl/src/routing.tsx @@ -1 +1 @@ -export * from './routing/index'; +export * from './routing/index.js'; diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 46d357d85..3fc6320dc 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -1,12 +1,12 @@ -import type {NextResponse} from 'next/server'; -import { +import type {NextResponse} from 'next/server.js'; +import type { DomainsConfig, LocalePrefix, LocalePrefixConfigVerbose, LocalePrefixMode, Locales, Pathnames -} from './types'; +} from './types.js'; type CookieAttributes = Pick< NonNullable['2']>, @@ -151,13 +151,12 @@ export function receiveRoutingConfig< }; } -export function receiveLocaleCookie( +function receiveLocaleCookie( localeCookie?: boolean | CookieAttributes ): InitializedLocaleCookieConfig { return (localeCookie ?? true) ? { name: 'NEXT_LOCALE', - maxAge: 31536000, // 1 year sameSite: 'lax', ...(typeof localeCookie === 'object' && localeCookie) @@ -173,9 +172,9 @@ export type LocaleCookieConfig = Omit< CookieAttributes, 'name' | 'maxAge' | 'sameSite' > & - Required>; + Required>; -export function receiveLocalePrefixConfig< +function receiveLocalePrefixConfig< AppLocales extends Locales, AppLocalePrefixMode extends LocalePrefixMode >(localePrefix?: LocalePrefix) { diff --git a/packages/next-intl/src/routing/defineRouting.test.tsx b/packages/next-intl/src/routing/defineRouting.test.tsx index 673ce77b8..1ad7c541d 100644 --- a/packages/next-intl/src/routing/defineRouting.test.tsx +++ b/packages/next-intl/src/routing/defineRouting.test.tsx @@ -1,5 +1,5 @@ -import {describe, it} from 'vitest'; -import defineRouting from './defineRouting'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import defineRouting from './defineRouting.js'; describe('defaultLocale', () => { it('ensures the `defaultLocale` is within `locales`', () => { @@ -35,14 +35,13 @@ describe('pathnames', () => { routing.pathnames['/about'].en; }); - it('ensures all locales have a value', () => { + it('accepts a partial config for only some locales', () => { defineRouting({ locales: ['en', 'de'], defaultLocale: 'en', pathnames: { - // @ts-expect-error -- Missing de '/about': { - en: '/about' + de: '/ueber-uns' } } }); @@ -57,7 +56,8 @@ describe('domains', () => { domains: [ { defaultLocale: 'en', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] } ] }); @@ -71,7 +71,8 @@ describe('domains', () => { { // @ts-expect-error defaultLocale: 'es', - domain: 'example.com' + domain: 'example.com', + locales: ['en'] } ] }); @@ -103,6 +104,73 @@ describe('domains', () => { ] }); }); + + describe('validation', () => { + beforeEach(() => { + const originalConsoleWarn = console.warn; + console.warn = vi.fn(); + return () => { + console.warn = originalConsoleWarn; + }; + }); + + it('does not warn if locales are unique per domain', () => { + defineRouting({ + locales: ['en-US', 'en-CA', 'fr-CA', 'fr-FR'], + defaultLocale: 'en-US', + domains: [ + { + domain: 'us.example.com', + defaultLocale: 'en-US', + locales: ['en-US'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en-CA', + locales: ['en-CA', 'fr-CA'] + }, + { + domain: 'fr.example.com', + defaultLocale: 'fr-FR', + locales: ['fr-FR'] + } + ] + }); + + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should warn if locales are not unique per domain', () => { + defineRouting({ + locales: ['en', 'fr'], + defaultLocale: 'en', + domains: [ + { + domain: 'us.example.com', + defaultLocale: 'en', + locales: ['en'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en', + locales: ['en', 'fr'] + }, + { + domain: 'fr.example.com', + defaultLocale: 'fr', + locales: ['fr'] + } + ] + }); + + expect(console.warn).toHaveBeenCalledWith( + 'Locales are expected to be unique per domain, but found overlap:\n' + + '- "en" is used by: us.example.com, ca.example.com\n' + + '- "fr" is used by: ca.example.com, fr.example.com\n' + + 'Please see https://next-intl.dev/docs/routing#domains' + ); + }); + }); }); describe('localePrefix', () => { diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index d470db867..181d921f1 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -1,5 +1,10 @@ -import {RoutingConfig} from './config'; -import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types'; +import type {RoutingConfig} from './config.js'; +import type { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from './types.js'; export default function defineRouting< const AppLocales extends Locales, @@ -14,5 +19,38 @@ export default function defineRouting< AppDomains > ) { + if (process.env.NODE_ENV !== 'production' && config.domains) { + validateUniqueLocalesPerDomain(config.domains); + } return config; } + +function validateUniqueLocalesPerDomain< + AppLocales extends Locales, + AppDomains extends DomainsConfig +>(domains: AppDomains) { + const domainsByLocale = new Map>(); + + for (const {domain, locales} of domains) { + for (const locale of locales) { + const localeDomains = domainsByLocale.get(locale) || new Set(); + localeDomains.add(domain); + domainsByLocale.set(locale, localeDomains); + } + } + + const duplicateLocaleMessages = Array.from(domainsByLocale.entries()) + .filter(([, localeDomains]) => localeDomains.size > 1) + .map( + ([locale, localeDomains]) => + `- "${locale}" is used by: ${Array.from(localeDomains).join(', ')}` + ); + + if (duplicateLocaleMessages.length > 0) { + console.warn( + 'Locales are expected to be unique per domain, but found overlap:\n' + + duplicateLocaleMessages.join('\n') + + '\nPlease see https://next-intl.dev/docs/routing#domains' + ); + } +} diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx index ac10d4159..db9f16bb7 100644 --- a/packages/next-intl/src/routing/index.tsx +++ b/packages/next-intl/src/routing/index.tsx @@ -1,8 +1,8 @@ export type { Pathnames, - DomainsConfig, LocalePrefix, + DomainsConfig, LocalePrefixMode -} from './types'; -export type {RoutingConfig} from './config'; -export {default as defineRouting} from './defineRouting'; +} from './types.js'; +export {default as defineRouting} from './defineRouting.js'; +export type {RoutingConfig} from './config.js'; diff --git a/packages/next-intl/src/routing/types.test.tsx b/packages/next-intl/src/routing/types.test.tsx index 0cd4bc418..c3ac97bc9 100644 --- a/packages/next-intl/src/routing/types.test.tsx +++ b/packages/next-intl/src/routing/types.test.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import {describe, it} from 'vitest'; -import {DomainConfig, LocalePrefix} from './types'; +import type {DomainConfig, LocalePrefix} from './types.js'; describe('LocalePrefix', () => { it('does not require a type param for simple values', () => { @@ -42,14 +42,7 @@ describe('LocalePrefix', () => { }); describe('DomainConfig', () => { - it('allows to handle all locales', () => { - const config: DomainConfig<['en', 'de']> = { - defaultLocale: 'en', - domain: 'example.com' - }; - }); - - it('allows to restrict locales', () => { + it('allows to define locales', () => { const config: DomainConfig<['en', 'de']> = { defaultLocale: 'en', domain: 'example.com', diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index d47c5ee2e..9250db844 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -1,3 +1,5 @@ +// We intentionally don't use `Locale` here to avoid a circular reference +// when `routing` is used to initialize the `Locale` type. export type Locales = ReadonlyArray; export type LocalePrefixMode = 'always' | 'as-needed' | 'never'; @@ -34,7 +36,7 @@ export type LocalePrefix< export type Pathnames = Record< Pathname, - Record | Pathname + Partial> | Pathname >; export type DomainConfig = { @@ -44,8 +46,8 @@ export type DomainConfig = { /** The domain name (e.g. "example.com", "www.example.com" or "fr.example.com"). Note that the `x-forwarded-host` or alternatively the `host` header will be used to determine the requested domain. */ domain: string; - /** Optionally restrict which locales are available on this domain. */ - locales?: Array; + /** The locales that are available on this domain. */ + locales: Array; }; export type DomainsConfig = Array< diff --git a/packages/next-intl/src/server.react-client.tsx b/packages/next-intl/src/server.react-client.tsx index 5d866c5b5..cf320e6f1 100644 --- a/packages/next-intl/src/server.react-client.tsx +++ b/packages/next-intl/src/server.react-client.tsx @@ -1 +1 @@ -export * from './server/react-client/index'; +export * from './server/react-client/index.js'; diff --git a/packages/next-intl/src/server.react-server.tsx b/packages/next-intl/src/server.react-server.tsx index 4e88250c9..9324dd03a 100644 --- a/packages/next-intl/src/server.react-server.tsx +++ b/packages/next-intl/src/server.react-server.tsx @@ -1 +1 @@ -export * from './server/react-server/index'; +export * from './server/react-server/index.js'; diff --git a/packages/next-intl/src/server/react-client/index.test.tsx b/packages/next-intl/src/server/react-client/index.test.tsx index 786560db4..74eab6d42 100644 --- a/packages/next-intl/src/server/react-client/index.test.tsx +++ b/packages/next-intl/src/server/react-client/index.test.tsx @@ -1,21 +1,23 @@ import {describe, expect, it} from 'vitest'; -import {getRequestConfig} from '../../server.react-client'; +import {getRequestConfig} from '../../server.react-client.js'; describe('getRequestConfig', () => { it('can be called in the outer module closure', () => { expect( - getRequestConfig(({locale}) => ({ - messages: {hello: 'Hello ' + locale} + getRequestConfig(async ({requestLocale}) => ({ + locale: (await requestLocale) || 'en', + messages: {hello: 'Hello'} })) ); }); it('can not call the returned function', () => { - const getConfig = getRequestConfig(({locale}) => ({ - messages: {hello: 'Hello ' + locale} + const getConfig = getRequestConfig(async ({requestLocale}) => ({ + locale: (await requestLocale) || 'en', + messages: {hello: 'Hello '} })); - expect(() => - getConfig({requestLocale: Promise.resolve('en'), locale: 'en'}) - ).toThrow('`getRequestConfig` is not supported in Client Components.'); + expect(() => getConfig({requestLocale: Promise.resolve('en')})).toThrow( + '`getRequestConfig` is not supported in Client Components.' + ); }); }); diff --git a/packages/next-intl/src/server/react-client/index.tsx b/packages/next-intl/src/server/react-client/index.tsx index 4023f975c..f5bc34a81 100644 --- a/packages/next-intl/src/server/react-client/index.tsx +++ b/packages/next-intl/src/server/react-client/index.tsx @@ -5,9 +5,8 @@ import type { getNow as getNow_type, getRequestConfig as getRequestConfig_type, getTimeZone as getTimeZone_type, - setRequestLocale as setRequestLocale_type, - unstable_setRequestLocale as unstable_setRequestLocale_type -} from '../react-server'; + setRequestLocale as setRequestLocale_type +} from '../react-server/index.js'; /** * Allows to import `next-intl/server` in non-RSC environments. @@ -46,10 +45,6 @@ export const getLocale = notSupported('getLocale') as typeof getLocale_type; // anyway, therefore this is irrelevant. export const getTranslations = notSupported('getTranslations'); -export const unstable_setRequestLocale = notSupported( - 'unstable_setRequestLocale' -) as typeof unstable_setRequestLocale_type; - export const setRequestLocale = notSupported( 'setRequestLocale' ) as typeof setRequestLocale_type; diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index e0cd06085..9a3ac4675 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,8 +1,9 @@ -import {headers} from 'next/headers'; +import {headers} from 'next/headers.js'; import {cache} from 'react'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; -import {isPromise} from '../../shared/utils'; -import {getCachedRequestLocale} from './RequestLocaleCache'; +import type {Locale} from 'use-intl'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.js'; +import {isPromise} from '../../shared/utils.js'; +import {getCachedRequestLocale} from './RequestLocaleCache.js'; async function getHeadersImpl(): Promise { const promiseOrValue = headers(); @@ -12,7 +13,7 @@ async function getHeadersImpl(): Promise { } const getHeaders = cache(getHeadersImpl); -async function getLocaleFromHeaderImpl(): Promise { +async function getLocaleFromHeaderImpl(): Promise { let locale; try { diff --git a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx index a8bc80194..98219f2b2 100644 --- a/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocaleCache.tsx @@ -1,8 +1,9 @@ import {cache} from 'react'; +import type {Locale} from 'use-intl'; // See https://github.com/vercel/next.js/discussions/58862 function getCacheImpl() { - const value: {locale?: string} = {locale: undefined}; + const value: {locale?: Locale} = {locale: undefined}; return value; } @@ -12,6 +13,6 @@ export function getCachedRequestLocale() { return getCache().locale; } -export function setCachedRequestLocale(locale: string) { +export function setCachedRequestLocale(locale: Locale) { getCache().locale = locale; } diff --git a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx b/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx deleted file mode 100644 index 64904e170..000000000 --- a/packages/next-intl/src/server/react-server/RequestLocaleLegacy.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {headers} from 'next/headers'; -import {notFound} from 'next/navigation'; -import {cache} from 'react'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; -import {getCachedRequestLocale} from './RequestLocaleCache'; - -// This was originally built for Next.js <14, where `headers()` was not async. -// With https://github.com/vercel/next.js/pull/68812, the API became async. -// This file can be removed once we remove the legacy navigation APIs. -function getHeaders() { - return headers() as unknown as Awaited>; -} - -function getLocaleFromHeaderImpl() { - let locale; - - try { - locale = getHeaders().get(HEADER_LOCALE_NAME); - } catch (error) { - if ( - error instanceof Error && - (error as any).digest === 'DYNAMIC_SERVER_USAGE' - ) { - throw new Error( - 'Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `setRequestLocale` API to enable static rendering, see https://next-intl.dev/docs/getting-started/app-router/with-i18n-routing#static-rendering', - {cause: error} - ); - } else { - throw error; - } - } - - if (!locale) { - if (process.env.NODE_ENV !== 'production') { - console.error( - `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request. See https://next-intl.dev/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` - ); - } - notFound(); - } - - return locale; -} -const getLocaleFromHeader = cache(getLocaleFromHeaderImpl); - -export function getRequestLocale(): string { - return getCachedRequestLocale() || getLocaleFromHeader(); -} diff --git a/packages/next-intl/src/server/react-server/createRequestConfig.tsx b/packages/next-intl/src/server/react-server/createRequestConfig.tsx index 2614208d8..fbe1831ee 100644 --- a/packages/next-intl/src/server/react-server/createRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/createRequestConfig.tsx @@ -1,5 +1,8 @@ import getRuntimeConfig from 'next-intl/config'; -import type {GetRequestConfigParams, RequestConfig} from './getRequestConfig'; +import type { + GetRequestConfigParams, + RequestConfig +} from './getRequestConfig.js'; export default getRuntimeConfig as unknown as ( params: GetRequestConfigParams diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 482302b53..3972c8c5d 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,25 +1,16 @@ -import {notFound} from 'next/navigation'; import {cache} from 'react'; import { - IntlConfig, + type IntlConfig, + type Locale, _createCache, _createIntlFormatters, initializeConfig } from 'use-intl/core'; -import {isPromise} from '../../shared/utils'; -import {getRequestLocale} from './RequestLocale'; -import {getRequestLocale as getRequestLocaleLegacy} from './RequestLocaleLegacy'; -import createRequestConfig from './createRequestConfig'; -import {GetRequestConfigParams} from './getRequestConfig'; - -let hasWarnedForMissingReturnedLocale = false; -let hasWarnedForAccessedLocaleParam = false; - -// Make sure `now` is consistent across the request in case none was configured -function getDefaultNowImpl() { - return new Date(); -} -const getDefaultNow = cache(getDefaultNowImpl); +import {isPromise} from '../../shared/utils.js'; +import {getRequestLocale} from './RequestLocale.js'; +import createRequestConfig from './createRequestConfig.js'; +import type {GetRequestConfigParams} from './getRequestConfig.js'; +import validateLocale from './validateLocale.js'; // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component @@ -30,7 +21,7 @@ const getDefaultTimeZone = cache(getDefaultTimeZoneImpl); async function receiveRuntimeConfigImpl( getConfig: typeof createRequestConfig, - localeOverride?: string + localeOverride?: Locale ) { if ( process.env.NODE_ENV !== 'production' && @@ -49,22 +40,11 @@ See also: https://next-intl.dev/docs/usage/configuration#i18n-request } const params: GetRequestConfigParams = { + locale: localeOverride, + // In case the consumer doesn't read `params.locale` and instead provides the // `locale` (either in a single-language workflow or because the locale is // read from the user settings), don't attempt to read the request locale. - get locale() { - if ( - process.env.NODE_ENV !== 'production' && - !hasWarnedForAccessedLocaleParam - ) { - console.warn( - `\nThe \`locale\` parameter in \`getRequestConfig\` is deprecated, please switch to \`await requestLocale\`. See https://next-intl.dev/blog/next-intl-3-22#await-request-locale\n` - ); - hasWarnedForAccessedLocaleParam = true; - } - return localeOverride || getRequestLocaleLegacy(); - }, - get requestLocale() { return localeOverride ? Promise.resolve(localeOverride) @@ -77,58 +57,40 @@ See also: https://next-intl.dev/docs/usage/configuration#i18n-request result = await result; } - let locale = result.locale; - - if (!locale) { - if ( - process.env.NODE_ENV !== 'production' && - !hasWarnedForMissingReturnedLocale - ) { - console.error( - `\nA \`locale\` is expected to be returned from \`getRequestConfig\`, but none was returned. This will be an error in the next major version of next-intl.\n\nSee: https://next-intl.dev/blog/next-intl-3-22#await-request-locale\n` - ); - hasWarnedForMissingReturnedLocale = true; - } - - locale = await params.requestLocale; - if (!locale) { - if (process.env.NODE_ENV !== 'production') { - console.error( - `\nUnable to find \`next-intl\` locale because the middleware didn't run on this request and no \`locale\` was returned in \`getRequestConfig\`. See https://next-intl.dev/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n` - ); - } - notFound(); - } + if (!result.locale) { + throw new Error( + 'No locale was returned from `getRequestConfig`.\n\nSee https://next-intl.dev/docs/usage/configuration#i18n-request' + ); + } + if (process.env.NODE_ENV !== 'production') { + validateLocale(result.locale); } - return { - ...result, - locale, - now: result.now || getDefaultNow(), - timeZone: result.timeZone || getDefaultTimeZone() - }; + return result; } const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); const getFormatters = cache(_createIntlFormatters); const getCache = cache(_createCache); -async function getConfigImpl(localeOverride?: string): Promise< - IntlConfig & { - getMessageFallback: NonNullable; - now: NonNullable; - onError: NonNullable; - timeZone: NonNullable; - _formatters: ReturnType; - } -> { +async function getConfigImpl(localeOverride?: Locale): Promise<{ + locale: IntlConfig['locale']; + formats?: NonNullable; + timeZone: NonNullable; + onError: NonNullable; + getMessageFallback: NonNullable; + messages?: NonNullable; + now?: NonNullable; + _formatters: ReturnType; +}> { const runtimeConfig = await receiveRuntimeConfig( createRequestConfig, localeOverride ); return { ...initializeConfig(runtimeConfig), - _formatters: getFormatters(getCache()) + _formatters: getFormatters(getCache()), + timeZone: runtimeConfig.timeZone || getDefaultTimeZone() }; } const getConfig = cache(getConfigImpl); diff --git a/packages/next-intl/src/server/react-server/getConfigNow.tsx b/packages/next-intl/src/server/react-server/getConfigNow.tsx new file mode 100644 index 000000000..70088b327 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getConfigNow.tsx @@ -0,0 +1,11 @@ +import {cache} from 'react'; +import type {Locale} from 'use-intl'; +import getConfig from './getConfig.js'; + +async function getConfigNowImpl(locale?: Locale) { + const config = await getConfig(locale); + return config.now; +} +const getConfigNow = cache(getConfigNowImpl); + +export default getConfigNow; diff --git a/packages/next-intl/src/server/react-server/getDefaultNow.tsx b/packages/next-intl/src/server/react-server/getDefaultNow.tsx new file mode 100644 index 000000000..17c17c431 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getDefaultNow.tsx @@ -0,0 +1,10 @@ +import {cache} from 'react'; + +function defaultNow() { + // See https://next-intl.dev/docs/usage/dates-times#relative-times-server + return new Date(); +} + +const getDefaultNow = cache(defaultNow); + +export default getDefaultNow; diff --git a/packages/next-intl/src/server/react-server/getFormats.tsx b/packages/next-intl/src/server/react-server/getFormats.tsx new file mode 100644 index 000000000..363a25efe --- /dev/null +++ b/packages/next-intl/src/server/react-server/getFormats.tsx @@ -0,0 +1,10 @@ +import {cache} from 'react'; +import getConfig from './getConfig.js'; + +async function getFormatsCachedImpl() { + const config = await getConfig(); + return config.formats; +} +const getFormats = cache(getFormatsCachedImpl); + +export default getFormats; diff --git a/packages/next-intl/src/server/react-server/getFormatter.test.tsx b/packages/next-intl/src/server/react-server/getFormatter.test.tsx new file mode 100644 index 000000000..b9a505dcb --- /dev/null +++ b/packages/next-intl/src/server/react-server/getFormatter.test.tsx @@ -0,0 +1,35 @@ +import {describe, expect, it, vi} from 'vitest'; +import getDefaultNow from './getDefaultNow.js'; +import getFormatter from './getFormatter.js'; + +vi.mock('react'); +vi.mock('./getDefaultNow.tsx', () => ({ + default: vi.fn(() => new Date()) +})); + +vi.mock('next-intl/config', () => ({ + default: async () => + ( + (await vi.importActual('../../../src/server/react-server')) as any + ).getRequestConfig({ + locale: 'en' + }) +})); + +describe('dynamicIO', () => { + it('should not read `now` unnecessarily', async () => { + const format = await getFormatter(); + format.dateTime(new Date()); + format.number(1); + format.dateTimeRange(new Date(), new Date()); + format.list(['a', 'b']); + format.relativeTime(new Date(), new Date()); + expect(getDefaultNow).not.toHaveBeenCalled(); + }); + + it('should read `now` for `relativeTime` if relying on a global `now`', async () => { + const format = await getFormatter(); + format.relativeTime(new Date()); + expect(getDefaultNow).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 6199ca85a..55591a5c3 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -1,10 +1,11 @@ import {cache} from 'react'; -import {createFormatter} from 'use-intl/core'; -import getConfig from './getConfig'; +import type {Locale, createFormatter} from 'use-intl/core'; +import getConfig from './getConfig.js'; +import getServerFormatter from './getServerFormatter.js'; -async function getFormatterCachedImpl(locale?: string) { +async function getFormatterCachedImpl(locale?: Locale) { const config = await getConfig(locale); - return createFormatter(config); + return getServerFormatter(config); } const getFormatterCached = cache(getFormatterCachedImpl); @@ -15,7 +16,7 @@ const getFormatterCached = cache(getFormatterCachedImpl); * you can override it by passing in additional options. */ export default async function getFormatter(opts?: { - locale?: string; + locale?: Locale; }): Promise> { return getFormatterCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getLocale.tsx b/packages/next-intl/src/server/react-server/getLocale.tsx index 797a76cb9..61a8deb31 100644 --- a/packages/next-intl/src/server/react-server/getLocale.tsx +++ b/packages/next-intl/src/server/react-server/getLocale.tsx @@ -1,9 +1,10 @@ import {cache} from 'react'; -import getConfig from './getConfig'; +import type {Locale} from 'use-intl'; +import getConfig from './getConfig.js'; -async function getLocaleCachedImpl() { +async function getLocaleCachedImpl(): Promise { const config = await getConfig(); - return Promise.resolve(config.locale); + return config.locale; } const getLocaleCached = cache(getLocaleCachedImpl); diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index 0fc92bcbe..90495438d 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -1,10 +1,10 @@ import {cache} from 'react'; -import type {AbstractIntlMessages} from 'use-intl'; -import getConfig from './getConfig'; +import type {Locale, useMessages as useMessagesType} from 'use-intl'; +import getConfig from './getConfig.js'; export function getMessagesFromConfig( config: Awaited> -): AbstractIntlMessages { +): ReturnType { if (!config.messages) { throw new Error( 'No messages found. Have you configured them correctly? See https://next-intl.dev/docs/configuration#messages' @@ -13,14 +13,14 @@ export function getMessagesFromConfig( return config.messages; } -async function getMessagesCachedImpl(locale?: string) { +async function getMessagesCachedImpl(locale?: Locale) { const config = await getConfig(locale); return getMessagesFromConfig(config); } const getMessagesCached = cache(getMessagesCachedImpl); export default async function getMessages(opts?: { - locale?: string; -}): Promise { + locale?: Locale; +}): Promise> { return getMessagesCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index 40373b1fe..767338158 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -1,12 +1,7 @@ -import {cache} from 'react'; -import getConfig from './getConfig'; +import type {Locale} from 'use-intl'; +import getConfigNow from './getConfigNow.js'; +import getDefaultNow from './getDefaultNow.js'; -async function getNowCachedImpl(locale?: string) { - const config = await getConfig(locale); - return config.now; -} -const getNowCached = cache(getNowCachedImpl); - -export default async function getNow(opts?: {locale?: string}): Promise { - return getNowCached(opts?.locale); +export default async function getNow(opts?: {locale?: Locale}): Promise { + return (await getConfigNow(opts?.locale)) ?? getDefaultNow(); } diff --git a/packages/next-intl/src/server/react-server/getRequestConfig.tsx b/packages/next-intl/src/server/react-server/getRequestConfig.tsx index b16b196e4..d96097c18 100644 --- a/packages/next-intl/src/server/react-server/getRequestConfig.tsx +++ b/packages/next-intl/src/server/react-server/getRequestConfig.tsx @@ -1,23 +1,19 @@ -import type {IntlConfig} from 'use-intl/core'; +import type {IntlConfig, Locale} from 'use-intl/core'; export type RequestConfig = Omit & { /** * @see https://next-intl.dev/docs/usage/configuration#i18n-request **/ - locale?: IntlConfig['locale']; + locale: IntlConfig['locale']; }; export type GetRequestConfigParams = { /** - * Deprecated in favor of `requestLocale` (see https://next-intl.dev/blog/next-intl-3-22#await-request-locale). - * - * The locale that was matched by the `[locale]` path segment. Note however - * that this can be overridden in async APIs when the `locale` is explicitly - * passed (e.g. `getTranslations({locale: 'en'})`). - * - * @deprecated + * If you provide an explicit locale to an async server-side function like + * `getTranslations({locale: 'en'})`, it will be passed via `locale` to + * `getRequestConfig` so you can use it instead of the segment value. */ - locale: string; + locale?: Locale; /** * Typically corresponds to the `[locale]` segment that was matched by the middleware. diff --git a/packages/next-intl/src/server/react-server/getServerFormatter.tsx b/packages/next-intl/src/server/react-server/getServerFormatter.tsx new file mode 100644 index 000000000..d1b6d282f --- /dev/null +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -0,0 +1,17 @@ +import {cache} from 'react'; +import {createFormatter} from 'use-intl/core'; +import getDefaultNow from './getDefaultNow.js'; + +function getFormatterCachedImpl(config: Parameters[0]) { + return createFormatter({ + ...config, + // Only init when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + get now() { + return config.now ?? getDefaultNow(); + } + }); +} +const getFormatterCached = cache(getFormatterCachedImpl); + +export default getFormatterCached; diff --git a/packages/next-intl/src/server/react-server/getServerTranslator.tsx b/packages/next-intl/src/server/react-server/getServerTranslator.tsx new file mode 100644 index 000000000..2c72d673f --- /dev/null +++ b/packages/next-intl/src/server/react-server/getServerTranslator.tsx @@ -0,0 +1,21 @@ +import {cache} from 'react'; +import { + type Messages, + type NamespaceKeys, + type NestedKeyOf, + createTranslator +} from 'use-intl/core'; + +function getServerTranslatorImpl< + NestedKey extends NamespaceKeys> = never +>( + config: Parameters[0], + namespace?: NestedKey +): ReturnType> { + return createTranslator({ + ...config, + namespace + }); +} + +export default cache(getServerTranslatorImpl); diff --git a/packages/next-intl/src/server/react-server/getTimeZone.tsx b/packages/next-intl/src/server/react-server/getTimeZone.tsx index a897137e0..feba75f95 100644 --- a/packages/next-intl/src/server/react-server/getTimeZone.tsx +++ b/packages/next-intl/src/server/react-server/getTimeZone.tsx @@ -1,14 +1,15 @@ import {cache} from 'react'; -import getConfig from './getConfig'; +import type {Locale} from 'use-intl'; +import getConfig from './getConfig.js'; -async function getTimeZoneCachedImpl(locale?: string) { +async function getTimeZoneCachedImpl(locale?: Locale) { const config = await getConfig(locale); return config.timeZone; } const getTimeZoneCached = cache(getTimeZoneCachedImpl); export default async function getTimeZone(opts?: { - locale?: string; -}): Promise { + locale?: Locale; +}): Promise { return getTimeZoneCached(opts?.locale); } diff --git a/packages/next-intl/src/server/react-server/getTranslations.test.tsx b/packages/next-intl/src/server/react-server/getTranslations.test.tsx new file mode 100644 index 000000000..e2b9c2350 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getTranslations.test.tsx @@ -0,0 +1,29 @@ +import {createTranslator} from 'use-intl/core'; +import {expect, it, vi} from 'vitest'; +import getTranslations from './getTranslations.js'; + +vi.mock('react'); +vi.mock('use-intl/core'); + +vi.mock('next-intl/config', () => ({ + default: async () => + ( + (await vi.importActual('../../../src/server/react-server')) as any + ).getRequestConfig({ + locale: 'en', + timeZone: 'Europe/London', + messages: { + title: 'Hello' + } + }) +})); + +it('should not include `now` in the translator config', async () => { + await getTranslations(); + + expect(createTranslator).toHaveBeenCalledWith( + expect.not.objectContaining({ + now: expect.anything() + }) + ); +}); diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index c3068e908..2cd909052 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -1,243 +1,37 @@ -import {ReactNode, cache} from 'react'; -import { - Formats, - MarkupTranslationValues, - MessageKeys, +import {cache} from 'react'; +import type { + Locale, + Messages, NamespaceKeys, NestedKeyOf, - NestedValueOf, - RichTranslationValues, - TranslationValues, createTranslator } from 'use-intl/core'; -import getConfig from './getConfig'; +import getConfig from './getConfig.js'; +import getServerTranslator from './getServerTranslator.js'; // Maintainer note: `getTranslations` has two different call signatures. // We need to define these with function overloads, otherwise TypeScript // messes up the return type. -// CALL SIGNATURE 1: `getTranslations(namespace)` +// Call signature 1: `getTranslations(namespace)` function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -Promise<{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): boolean; -}>; -// CALL SIGNATURE 2: `getTranslations({locale, namespace})` +): Promise>>; +// Call signature 2: `getTranslations({locale, namespace})` function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >(opts?: { - locale: string; + locale: Locale; namespace?: NestedKey; -}): // Explicitly defining the return type is necessary as TypeScript would get it wrong -Promise<{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): boolean; -}>; -// IMPLEMENTATION +}): Promise>>; +// Implementation async function getTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never ->(namespaceOrOpts?: NestedKey | {locale: string; namespace?: NestedKey}) { + NestedKey extends NamespaceKeys> = never +>(namespaceOrOpts?: NestedKey | {locale: Locale; namespace?: NestedKey}) { let namespace: NestedKey | undefined; - let locale: string | undefined; + let locale: Locale | undefined; if (typeof namespaceOrOpts === 'string') { namespace = namespaceOrOpts; @@ -247,12 +41,7 @@ async function getTranslations< } const config = await getConfig(locale); - - return createTranslator({ - ...config, - namespace, - messages: config.messages - }); + return getServerTranslator(config, namespace); } export default cache(getTranslations); diff --git a/packages/next-intl/src/server/react-server/index.test.tsx b/packages/next-intl/src/server/react-server/index.test.tsx index 9edb4d554..77878e1bc 100644 --- a/packages/next-intl/src/server/react-server/index.test.tsx +++ b/packages/next-intl/src/server/react-server/index.test.tsx @@ -1,14 +1,14 @@ // @vitest-environment edge-runtime import {describe, expect, it, vi} from 'vitest'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.js'; import { getFormatter, getMessages, getNow, getTimeZone, getTranslations -} from '.'; +} from './index.js'; vi.mock('next-intl/config', () => ({ default: async () => @@ -28,7 +28,7 @@ vi.mock('next-intl/config', () => ({ }) })); -vi.mock('next/headers', () => ({ +vi.mock('next/headers.js', () => ({ headers: () => ({ get(name: string) { if (name === HEADER_LOCALE_NAME) { @@ -150,7 +150,7 @@ describe('getMessages', () => { const messages = await getMessages(); // @ts-expect-error - messages.about(); + messages(); // Valid return messages.about; diff --git a/packages/next-intl/src/server/react-server/index.tsx b/packages/next-intl/src/server/react-server/index.tsx index 3b368f52e..846ff4599 100644 --- a/packages/next-intl/src/server/react-server/index.tsx +++ b/packages/next-intl/src/server/react-server/index.tsx @@ -6,17 +6,12 @@ export { default as getRequestConfig, type GetRequestConfigParams, type RequestConfig -} from './getRequestConfig'; -export {default as getFormatter} from './getFormatter'; -export {default as getNow} from './getNow'; -export {default as getTimeZone} from './getTimeZone'; -export {default as getTranslations} from './getTranslations'; -export {default as getMessages} from './getMessages'; -export {default as getLocale} from './getLocale'; +} from './getRequestConfig.js'; +export {default as getFormatter} from './getFormatter.js'; +export {default as getNow} from './getNow.js'; +export {default as getTimeZone} from './getTimeZone.js'; +export {default as getTranslations} from './getTranslations.js'; +export {default as getMessages} from './getMessages.js'; +export {default as getLocale} from './getLocale.js'; -export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache'; - -export { - /** @deprecated Deprecated in favor of `setRequestLocale`. */ - setCachedRequestLocale as unstable_setRequestLocale -} from './RequestLocaleCache'; +export {setCachedRequestLocale as setRequestLocale} from './RequestLocaleCache.js'; diff --git a/packages/next-intl/src/server/react-server/validateLocale.test.tsx b/packages/next-intl/src/server/react-server/validateLocale.test.tsx new file mode 100644 index 000000000..4a4ba3b0b --- /dev/null +++ b/packages/next-intl/src/server/react-server/validateLocale.test.tsx @@ -0,0 +1,71 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import validateLocale from './validateLocale.js'; + +describe('accepts valid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en', + 'en-US', + 'EN-US', + 'en-us', + 'en-GB', + 'zh-Hans-CN', + 'es-419', + 'en-Latn', + 'zh-Hans', + 'en-US-u-ca-buddhist', + 'en-x-private1', + 'en-US-u-nu-thai', + 'ar-u-nu-arab', + 'en-t-m0-true', + 'zh-Hans-CN-x-private1-private2', + 'en-US-u-ca-gregory-nu-latn', + 'en-US-x-usd', + + // Somehow tolerated by Intl.Locale + 'english' + ])('accepts: %s', (locale) => { + validateLocale(locale); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); + +describe('warns for invalid formats', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it.each([ + 'en_US', + 'en-', + 'e-US', + 'en-USA', + 'und', + '123', + '-en', + 'en--US', + 'toolongstring', + 'en-US-', + '@#$', + 'en US', + 'en.US' + ])('rejects: %s', (locale) => { + validateLocale(locale); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/server/react-server/validateLocale.tsx b/packages/next-intl/src/server/react-server/validateLocale.tsx new file mode 100644 index 000000000..25bb4f7b9 --- /dev/null +++ b/packages/next-intl/src/server/react-server/validateLocale.tsx @@ -0,0 +1,12 @@ +export default function validateLocale(locale: string) { + try { + const constructed = new Intl.Locale(locale); + if (!constructed.language) { + throw new Error('Language is required'); + } + } catch { + console.error( + `An invalid locale was provided: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` + ); + } +} diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx deleted file mode 100644 index f24b0c579..000000000 --- a/packages/next-intl/src/shared/NextIntlClientProvider.test.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import React from 'react'; -import {it} from 'vitest'; -import {NextIntlClientProvider, useTranslations} from '../index.react-client'; - -it('can use messages from the provider', () => { - function Component() { - const t = useTranslations(); - return <>{t('message')}; - } - - render( - - - - ); - - screen.getByText('Hello'); -}); - -it('can override the locale from Next.js', () => { - function Component() { - const t = useTranslations(); - return <>{t('message', {price: 29000.5})}; - } - - render( - - - - ); - - screen.getByText('29.000,50 €'); -}); diff --git a/packages/next-intl/src/shared/NextIntlClientProvider.tsx b/packages/next-intl/src/shared/NextIntlClientProvider.tsx index 9f9cdce55..22189f468 100644 --- a/packages/next-intl/src/shared/NextIntlClientProvider.tsx +++ b/packages/next-intl/src/shared/NextIntlClientProvider.tsx @@ -1,22 +1,19 @@ 'use client'; -import React, {ComponentProps} from 'react'; -// Workaround for some bundle splitting until we have ESM -import {IntlProvider} from 'use-intl/_IntlProvider'; +import type {ComponentProps} from 'react'; +import type {Locale} from 'use-intl'; +import {IntlProvider} from 'use-intl/react'; type Props = Omit, 'locale'> & { /** This is automatically received when being rendered from a Server Component. In all other cases, e.g. when rendered from a Client Component, a unit test or with the Pages Router, you can pass this prop explicitly. */ - locale?: string; + locale?: Locale; }; export default function NextIntlClientProvider({locale, ...rest}: Props) { - // TODO: We could call `useParams` here to receive a default value - // for `locale`, but this would require dropping Next.js <13. - if (!locale) { throw new Error( process.env.NODE_ENV !== 'production' - ? 'Failed to determine locale in `NextIntlClientProvider`, please provide the `locale` prop explicitly.\n\nSee https://next-intl.dev/docs/configuration#locale' + ? "Couldn't infer the `locale` prop in `NextIntlClientProvider`, please provide it explicitly.\n\nSee https://next-intl.dev/docs/configuration#locale" : undefined ); } diff --git a/packages/next-intl/src/shared/constants.tsx b/packages/next-intl/src/shared/constants.tsx index a87f8d203..d86ae9878 100644 --- a/packages/next-intl/src/shared/constants.tsx +++ b/packages/next-intl/src/shared/constants.tsx @@ -1,7 +1,2 @@ -export const COOKIE_BASE_PATH_NAME = 'NEXT_INTL_BASE_PATH'; - -// Should take precedence over the cookie +// Used to read the locale from the middleware export const HEADER_LOCALE_NAME = 'X-NEXT-INTL-LOCALE'; - -// In a URL like "/en-US/about", the locale segment is "en-US" -export const LOCALE_SEGMENT_NAME = 'locale'; diff --git a/packages/next-intl/src/shared/use.tsx b/packages/next-intl/src/shared/use.tsx new file mode 100644 index 000000000..e0f4be59d --- /dev/null +++ b/packages/next-intl/src/shared/use.tsx @@ -0,0 +1,11 @@ +import * as react from 'react'; + +// @ts-expect-error -- Ooof, Next.js doesn't make this easy. +// `use` is only available in React 19 canary, but we can +// use it in Next.js already as Next.js "vendors" a fixed +// version of React. However, if we'd simply put `use` in +// ESM code, then the build doesn't work since React does +// not export `use` officially. Therefore, we have to use +// something that is not statically analyzable. Once React +// 19 is out, we can remove this in the next major version. +export default react['use'.trim()] as typeof react.use; diff --git a/packages/next-intl/src/shared/utils.test.tsx b/packages/next-intl/src/shared/utils.test.tsx index 8f39a1ddc..c9456ac70 100644 --- a/packages/next-intl/src/shared/utils.test.tsx +++ b/packages/next-intl/src/shared/utils.test.tsx @@ -5,7 +5,7 @@ import { matchesPathname, prefixPathname, unprefixPathname -} from './utils'; +} from './utils.js'; describe('prefixPathname', () => { it("doesn't add trailing slashes for the root", () => { diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 82ba81343..082fd32a0 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,13 +1,12 @@ -import {UrlObject} from 'url'; -import NextLink from 'next/link'; -import {ComponentProps} from 'react'; -import { +import type {LinkProps} from 'next/link.js'; +import type { LocalePrefixConfigVerbose, LocalePrefixMode, - Locales -} from '../routing/types'; + Locales, + Pathnames +} from '../routing/types.js'; -type Href = ComponentProps['href']; +type Href = LinkProps['href']; function isRelativeHref(href: Href) { const pathname = typeof href === 'object' ? href.pathname : href; @@ -27,61 +26,6 @@ export function isLocalizableHref(href: Href) { return isLocalHref(href) && !isRelativeHref(href); } -export function localizeHref( - href: string, - locale: string, - curLocale: string, - curPathname: string, - prefix?: string -): string; -export function localizeHref( - href: UrlObject | string, - locale: string, - curLocale: string, - curPathname: string, - prefix?: string -): UrlObject | string; -export function localizeHref( - href: UrlObject | string, - locale: string, - curLocale: string = locale, - curPathname: string, - prefix?: string -) { - if (!isLocalizableHref(href)) { - return href; - } - - const isSwitchingLocale = locale !== curLocale; - const isPathnamePrefixed = hasPathnamePrefixed(prefix, curPathname); - const shouldPrefix = isSwitchingLocale || isPathnamePrefixed; - - if (shouldPrefix && prefix != null) { - return prefixHref(href, prefix); - } - - return href; -} - -export function prefixHref(href: string, prefix: string): string; -export function prefixHref( - href: UrlObject | string, - prefix: string -): UrlObject | string; -export function prefixHref(href: UrlObject | string, prefix: string) { - let prefixedHref; - if (typeof href === 'string') { - prefixedHref = prefixPathname(prefix, href); - } else { - prefixedHref = {...href}; - if (href.pathname) { - prefixedHref.pathname = prefixPathname(prefix, href.pathname); - } - } - - return prefixedHref; -} - export function unprefixPathname(pathname: string, prefix: string) { return pathname.replace(new RegExp(`^${prefix}`), '') || '/'; } @@ -115,6 +59,16 @@ function hasTrailingSlash() { } } +export function getLocalizedTemplate( + pathnameConfig: Pathnames[keyof Pathnames], + locale: AppLocales[number], + internalTemplate: string +) { + return typeof pathnameConfig === 'string' + ? pathnameConfig + : pathnameConfig[locale] || internalTemplate; +} + export function normalizeTrailingSlash(pathname: string) { const trailingSlash = hasTrailingSlash(); diff --git a/packages/next-intl/tsconfig.build.json b/packages/next-intl/tsconfig.build.json new file mode 100644 index 000000000..8e330c446 --- /dev/null +++ b/packages/next-intl/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.tsx", "test", "__mocks__"], + "compilerOptions": { + "rootDir": "src", + "noEmit": false, + "outDir": "dist/types", + "emitDeclarationOnly": true + } +} diff --git a/packages/next-intl/tsconfig.json b/packages/next-intl/tsconfig.json index a8c3d4ed6..9dcc99414 100644 --- a/packages/next-intl/tsconfig.json +++ b/packages/next-intl/tsconfig.json @@ -2,20 +2,14 @@ "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "__mocks__", "types", "next-env.d.ts"], "compilerOptions": { - "moduleDetection": "force", - "isolatedModules": true, - "module": "esnext", "lib": ["dom", "esnext"], "target": "ES2020", - "importHelpers": false, "declaration": true, - "sourceMap": true, "rootDir": ".", - "moduleResolution": "Bundler", - "jsx": "react", - "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", "skipLibCheck": true, - "noEmit": false, - "outDir": "dist" + "noEmit": true } } diff --git a/packages/next-intl/types/index.d.ts b/packages/next-intl/types/index.d.ts index f94a7983c..a6c395566 100644 --- a/packages/next-intl/types/index.d.ts +++ b/packages/next-intl/types/index.d.ts @@ -1,4 +1,8 @@ -declare interface IntlMessages extends Record {} +declare namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production'; + } +} // Temporarly copied here until the "es2020.intl" lib is published. declare namespace Intl { diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 9f93114d7..a0b6ed26f 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -2,22 +2,17 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { - name: "import * from 'use-intl' (ESM)", + name: "import * from 'use-intl' (production)", import: '*', - path: 'dist/esm/index.js', - limit: '14.125 kB' + path: 'dist/esm/production/index.js', + limit: '13.015 kB' }, { - name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (ESM)", - path: 'dist/esm/index.js', + name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", + path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '2.865 kB' - }, - { - name: "import * from 'use-intl' (CJS)", - path: 'dist/production/index.js', - limit: '15.65 kB' + limit: '2.005 kB' } ]; diff --git a/packages/use-intl/_IntlProvider.d.ts b/packages/use-intl/_IntlProvider.d.ts deleted file mode 100644 index 638757cd7..000000000 --- a/packages/use-intl/_IntlProvider.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/types/src/_IntlProvider'; diff --git a/packages/use-intl/_useLocale.d.ts b/packages/use-intl/_useLocale.d.ts deleted file mode 100644 index 6eefcd371..000000000 --- a/packages/use-intl/_useLocale.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dist/types/src/_useLocale'; diff --git a/packages/use-intl/core.d.ts b/packages/use-intl/core.d.ts index 5df015235..b65fd68a9 100644 --- a/packages/use-intl/core.d.ts +++ b/packages/use-intl/core.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/core'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/core.d.ts'; diff --git a/packages/use-intl/eslint.config.mjs b/packages/use-intl/eslint.config.mjs index 92ba53b1f..e92ad614a 100644 --- a/packages/use-intl/eslint.config.mjs +++ b/packages/use-intl/eslint.config.mjs @@ -6,6 +6,13 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({ 'react-compiler': reactCompilerPlugin }, rules: { - 'react-compiler/react-compiler': 'error' + 'react-compiler/react-compiler': 'error', + + // Strict type imports to avoid side effects + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', + 'import/no-duplicates': ['error', {'prefer-inline': true}], + 'import/extensions': 'error' } }); diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index b6be86654..ecccc6233 100644 --- a/packages/use-intl/package.json +++ b/packages/use-intl/package.json @@ -15,42 +15,35 @@ "test": "TZ=Europe/Berlin vitest", "lint": "pnpm run lint:source && pnpm run lint:package", "lint:source": "eslint src test && tsc --noEmit && pnpm run lint:prettier", - "lint:package": "publint && attw --pack", + "lint:package": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm", "lint:prettier": "prettier src --check", "prepublishOnly": "turbo build", "size": "size-limit" }, - "main": "./dist/index.js", - "module": "dist/esm/index.js", - "typings": "./dist/types/src/index.d.ts", + "type": "module", + "main": "./dist/esm/production/index.js", + "typings": "./dist/types/index.d.ts", "exports": { ".": { - "types": "./dist/types/src/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/types/index.d.ts", + "development": "./dist/esm/development/index.js", + "default": "./dist/esm/production/index.js" }, "./core": { - "types": "./core.d.ts", - "default": "./dist/core.js" + "types": "./dist/types/core.d.ts", + "development": "./dist/esm/development/core.js", + "default": "./dist/esm/production/core.js" }, "./react": { - "types": "./react.d.ts", - "default": "./dist/react.js" - }, - "./_useLocale": { - "types": "./_useLocale.d.ts", - "default": "./dist/_useLocale.js" - }, - "./_IntlProvider": { - "types": "./_IntlProvider.d.ts", - "default": "./dist/_IntlProvider.js" + "types": "./dist/types/react.d.ts", + "development": "./dist/esm/development/react.js", + "default": "./dist/esm/production/react.js" } }, "files": [ "dist", "core.d.ts", - "react.d.ts", - "_useLocale.d.ts", - "_IntlProvider.d.ts" + "react.d.ts" ], "keywords": [ "react", @@ -65,14 +58,15 @@ ], "dependencies": { "@formatjs/fast-memoize": "^2.2.0", + "@schummar/icu-type-parser": "1.21.5", "intl-messageformat": "^10.5.14" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" }, "devDependencies": { - "@arethetypeswrong/cli": "^0.15.3", - "@size-limit/preset-big-lib": "^11.1.4", + "@arethetypeswrong/cli": "^0.16.4", + "@size-limit/preset-small-lib": "^11.1.4", "@testing-library/react": "^16.0.0", "@types/node": "^20.14.5", "@types/react": "^18.3.3", @@ -88,6 +82,7 @@ "rollup": "^4.18.0", "size-limit": "^11.1.4", "tinyspy": "^3.0.0", + "tools": "workspace:^", "typescript": "^5.5.3", "vitest": "^2.0.2" }, diff --git a/packages/use-intl/react.d.ts b/packages/use-intl/react.d.ts index db798174f..c861b28fb 100644 --- a/packages/use-intl/react.d.ts +++ b/packages/use-intl/react.d.ts @@ -1 +1,2 @@ -export * from './dist/types/src/react'; +// Needed for projects with `moduleResolution: 'node'` +export * from './dist/types/react.d.ts'; diff --git a/packages/use-intl/rollup.config.js b/packages/use-intl/rollup.config.js new file mode 100644 index 000000000..897a780d3 --- /dev/null +++ b/packages/use-intl/rollup.config.js @@ -0,0 +1,15 @@ +import {getBuildConfig} from 'tools'; +import pkg from './package.json' with {type: 'json'}; + +export default getBuildConfig({ + input: { + index: 'src/index.tsx', + core: 'src/core.tsx', + react: 'src/react.tsx' + }, + external: [ + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.peerDependencies), + 'react/jsx-runtime' + ] +}); diff --git a/packages/use-intl/rollup.config.mjs b/packages/use-intl/rollup.config.mjs deleted file mode 100644 index 0413155db..000000000 --- a/packages/use-intl/rollup.config.mjs +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-env node */ -import getBuildConfig from '../../scripts/getBuildConfig.mjs'; - -const input = { - index: 'src/index.tsx', - core: 'src/core.tsx', - react: 'src/react.tsx', - _useLocale: 'src/_useLocale.tsx', - _IntlProvider: 'src/_IntlProvider.tsx' -}; - -export default [ - getBuildConfig({input, env: 'development'}), - getBuildConfig({ - input, - env: 'esm', - output: {format: 'es'} - }), - getBuildConfig({input, env: 'production'}) -]; diff --git a/packages/use-intl/src/_IntlProvider.tsx b/packages/use-intl/src/_IntlProvider.tsx deleted file mode 100644 index c02594cc2..000000000 --- a/packages/use-intl/src/_IntlProvider.tsx +++ /dev/null @@ -1 +0,0 @@ -export {default as IntlProvider} from './react/IntlProvider'; diff --git a/packages/use-intl/src/_useLocale.tsx b/packages/use-intl/src/_useLocale.tsx deleted file mode 100644 index 162f5d506..000000000 --- a/packages/use-intl/src/_useLocale.tsx +++ /dev/null @@ -1 +0,0 @@ -export {default as useLocale} from './react/useLocale'; diff --git a/packages/use-intl/src/core.tsx b/packages/use-intl/src/core.tsx index 65c514e92..17f45946d 100644 --- a/packages/use-intl/src/core.tsx +++ b/packages/use-intl/src/core.tsx @@ -1 +1 @@ -export * from './core/index'; +export * from './core/index.js'; diff --git a/packages/use-intl/src/core/AbstractIntlMessages.tsx b/packages/use-intl/src/core/AbstractIntlMessages.tsx index dd496ea98..fed266fda 100644 --- a/packages/use-intl/src/core/AbstractIntlMessages.tsx +++ b/packages/use-intl/src/core/AbstractIntlMessages.tsx @@ -1,6 +1,7 @@ -/** A generic type that describes the shape of messages. +/** + * A generic type that describes the shape of messages. * - * Optionally `IntlMessages` can be provided to get type safety for message + * Optionally, messages can be strictly-typed in order to get type safety for message * namespaces and keys. See https://next-intl.dev/docs/usage/typescript */ type AbstractIntlMessages = { diff --git a/packages/use-intl/src/core/AppConfig.tsx b/packages/use-intl/src/core/AppConfig.tsx new file mode 100644 index 000000000..1bd0dec03 --- /dev/null +++ b/packages/use-intl/src/core/AppConfig.tsx @@ -0,0 +1,37 @@ +export default interface AppConfig { + // Locale + // Formats + // Messages +} + +export type Locale = AppConfig extends { + Locale: infer AppLocale; +} + ? AppLocale + : string; + +export type FormatNames = AppConfig extends { + Formats: infer AppFormats; +} + ? { + dateTime: AppFormats extends {dateTime: infer AppDateTimeFormats} + ? keyof AppDateTimeFormats + : string; + number: AppFormats extends {number: infer AppNumberFormats} + ? keyof AppNumberFormats + : string; + list: AppFormats extends {list: infer AppListFormats} + ? keyof AppListFormats + : string; + } + : { + dateTime: string; + number: string; + list: string; + }; + +export type Messages = AppConfig extends { + Messages: infer AppMessages; +} + ? AppMessages + : Record; diff --git a/packages/use-intl/src/core/DateTimeFormatOptions.tsx b/packages/use-intl/src/core/DateTimeFormatOptions.tsx index 5d279bea8..0cd45ee08 100644 --- a/packages/use-intl/src/core/DateTimeFormatOptions.tsx +++ b/packages/use-intl/src/core/DateTimeFormatOptions.tsx @@ -1,6 +1,6 @@ // https://github.com/microsoft/TypeScript/issues/35865 -import TimeZone from './TimeZone'; +import type TimeZone from './TimeZone.js'; /** * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat diff --git a/packages/use-intl/src/core/Formats.tsx b/packages/use-intl/src/core/Formats.tsx index 108da3103..05702cc82 100644 --- a/packages/use-intl/src/core/Formats.tsx +++ b/packages/use-intl/src/core/Formats.tsx @@ -1,5 +1,5 @@ -import DateTimeFormatOptions from './DateTimeFormatOptions'; -import NumberFormatOptions from './NumberFormatOptions'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.js'; +import type NumberFormatOptions from './NumberFormatOptions.js'; type Formats = { number?: Record; diff --git a/packages/use-intl/src/core/ICUArgs.tsx b/packages/use-intl/src/core/ICUArgs.tsx new file mode 100644 index 000000000..336806a23 --- /dev/null +++ b/packages/use-intl/src/core/ICUArgs.tsx @@ -0,0 +1,10 @@ +// schummar is the best, he published his ICU type parser for next-intl: +// https://github.com/schummar/schummar-translate/issues/28 +import type {GetICUArgs, GetICUArgsOptions} from '@schummar/icu-type-parser'; + +type ICUArgs = + // This is important when `t` is returned from a function and there's no + // known `Message` yet. Otherwise, we'd run into an infinite loop. + string extends Message ? {} : GetICUArgs; + +export default ICUArgs; diff --git a/packages/use-intl/src/core/ICUTags.tsx b/packages/use-intl/src/core/ICUTags.tsx new file mode 100644 index 000000000..a4531331e --- /dev/null +++ b/packages/use-intl/src/core/ICUTags.tsx @@ -0,0 +1,8 @@ +type ICUTags< + MessageString extends string, + TagsFn +> = MessageString extends `${infer Prefix}<${infer TagName}>${infer Content}${infer Tail}` + ? Record & ICUTags<`${Prefix}${Content}${Tail}`, TagsFn> + : {}; + +export default ICUTags; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index b7d51ea44..68da27a1e 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,19 +1,19 @@ -import type AbstractIntlMessages from './AbstractIntlMessages'; -import type Formats from './Formats'; -import type IntlError from './IntlError'; -import type TimeZone from './TimeZone'; -import type {RichTranslationValues} from './TranslationValues'; +import type {Locale, Messages} from './AppConfig.js'; +import type Formats from './Formats.js'; +import type IntlError from './IntlError.js'; +import type TimeZone from './TimeZone.js'; +import type {DeepPartial} from './types.js'; /** * Should be used for entry points that configure the library. */ -type IntlConfig = { +type IntlConfig = { /** A valid Unicode locale tag (e.g. "en" or "en-GB"). */ - locale: string; + locale: Locale; /** Global formats can be provided to achieve consistent * formatting across components. */ - formats?: Formats; + formats?: Formats | null; /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ timeZone?: TimeZone; /** This callback will be invoked when an error is encountered during @@ -40,24 +40,22 @@ type IntlConfig = { */ now?: Date; /** All messages that will be available. */ - messages?: Messages; - /** Global default values for translation values and rich text elements. - * Can be used for consistent usage or styling of rich text elements. - * Defaults will be overidden by locally provided values. - * - * @deprecated See https://next-intl.dev/docs/usage/messages#rich-text-reuse-tags - **/ - defaultTranslationValues?: RichTranslationValues; + messages?: DeepPartial | null; }; +/** /** * A stricter set of the configuration that should be used internally * once defaults are assigned to `IntlConfiguration`. */ -export type InitializedIntlConfig = - IntlConfig & { - onError: NonNullable['onError']>; - getMessageFallback: NonNullable['getMessageFallback']>; - }; +export type InitializedIntlConfig = Omit< + IntlConfig, + 'formats' | 'messages' | 'onError' | 'getMessageFallback' +> & { + formats?: NonNullable; + messages?: NonNullable; + onError: NonNullable; + getMessageFallback: NonNullable; +}; export default IntlConfig; diff --git a/packages/use-intl/src/core/IntlError.tsx b/packages/use-intl/src/core/IntlError.tsx index cfce8f64b..25177b820 100644 --- a/packages/use-intl/src/core/IntlError.tsx +++ b/packages/use-intl/src/core/IntlError.tsx @@ -1,12 +1,4 @@ -export enum IntlErrorCode { - MISSING_MESSAGE = 'MISSING_MESSAGE', - MISSING_FORMAT = 'MISSING_FORMAT', - ENVIRONMENT_FALLBACK = 'ENVIRONMENT_FALLBACK', - INSUFFICIENT_PATH = 'INSUFFICIENT_PATH', - INVALID_MESSAGE = 'INVALID_MESSAGE', - INVALID_KEY = 'INVALID_KEY', - FORMATTING_ERROR = 'FORMATTING_ERROR' -} +import type IntlErrorCode from './IntlErrorCode.js'; export default class IntlError extends Error { public readonly code: IntlErrorCode; diff --git a/packages/use-intl/src/core/IntlErrorCode.tsx b/packages/use-intl/src/core/IntlErrorCode.tsx new file mode 100644 index 000000000..a3f23a5b3 --- /dev/null +++ b/packages/use-intl/src/core/IntlErrorCode.tsx @@ -0,0 +1,11 @@ +const enum IntlErrorCode { + MISSING_MESSAGE = 'MISSING_MESSAGE', + MISSING_FORMAT = 'MISSING_FORMAT', + ENVIRONMENT_FALLBACK = 'ENVIRONMENT_FALLBACK', + INSUFFICIENT_PATH = 'INSUFFICIENT_PATH', + INVALID_MESSAGE = 'INVALID_MESSAGE', + INVALID_KEY = 'INVALID_KEY', + FORMATTING_ERROR = 'FORMATTING_ERROR' +} + +export default IntlErrorCode; diff --git a/packages/use-intl/src/core/MessageKeys.tsx b/packages/use-intl/src/core/MessageKeys.tsx new file mode 100644 index 000000000..40667bbab --- /dev/null +++ b/packages/use-intl/src/core/MessageKeys.tsx @@ -0,0 +1,36 @@ +export type NestedKeyOf = ObjectType extends object + ? { + [Property in keyof ObjectType]: + | `${Property & string}` + | `${Property & string}.${NestedKeyOf}`; + }[keyof ObjectType] + : never; + +export type NestedValueOf< + ObjectType, + Path extends string +> = Path extends `${infer Cur}.${infer Rest}` + ? Cur extends keyof ObjectType + ? NestedValueOf + : never + : Path extends keyof ObjectType + ? ObjectType[Path] + : never; + +export type NamespaceKeys = { + [PropertyPath in AllKeys]: NestedValueOf< + ObjectType, + PropertyPath + > extends string + ? never + : PropertyPath; +}[AllKeys]; + +export type MessageKeys = { + [PropertyPath in AllKeys]: NestedValueOf< + ObjectType, + PropertyPath + > extends string + ? PropertyPath + : never; +}[AllKeys]; diff --git a/packages/use-intl/src/core/TranslationValues.tsx b/packages/use-intl/src/core/TranslationValues.tsx index 3d4106f67..063d3374e 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,27 +1,25 @@ -import {ReactNode} from 'react'; +import type {ReactNode} from 'react'; -// From IntlMessageFormat#format -export type TranslationValue = - | string - | number - | boolean - | Date - | null - | undefined; +export type TranslationValues = Record< + string, + // All params that are allowed for basic params as well as operators like + // `plural`, `select`, `number` and `date`. Note that `Date` is not supported + // for plain params, but this requires type information from the ICU parser. + string | number | Date +>; -type TranslationValues = Record; +export type RichTagsFunction = (chunks: ReactNode) => ReactNode; +export type MarkupTagsFunction = (chunks: string) => string; // We could consider renaming this to `ReactRichTranslationValues` and defining // it in the `react` namespace if the core becomes useful to other frameworks. // It would be a breaking change though, so let's wait for now. export type RichTranslationValues = Record< string, - TranslationValue | ((chunks: ReactNode) => ReactNode) + TranslationValues[string] | RichTagsFunction >; export type MarkupTranslationValues = Record< string, - TranslationValue | ((chunks: string) => string) + TranslationValues[string] | MarkupTagsFunction >; - -export default TranslationValues; diff --git a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx index 90e22f6f0..ca5d32dd4 100644 --- a/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx +++ b/packages/use-intl/src/core/convertFormatsToIntlMessageFormat.tsx @@ -1,27 +1,9 @@ -import IntlMessageFormat, {Formats as IntlFormats} from 'intl-messageformat'; -import DateTimeFormatOptions from './DateTimeFormatOptions'; -import Formats from './Formats'; -import TimeZone from './TimeZone'; - -function setTimeZoneInFormats( - formats: Record | undefined, - timeZone: TimeZone -) { - if (!formats) return formats; - - // The only way to set a time zone with `intl-messageformat` is to merge it into the formats - // https://github.com/formatjs/formatjs/blob/8256c5271505cf2606e48e3c97ecdd16ede4f1b5/packages/intl/src/message.ts#L15 - return Object.keys(formats).reduce( - (acc: Record, key) => { - acc[key] = { - timeZone, - ...formats[key] - }; - return acc; - }, - {} - ); -} +import { + type Formats as IntlFormats, + IntlMessageFormat +} from 'intl-messageformat'; +import type Formats from './Formats.js'; +import type TimeZone from './TimeZone.js'; /** * `intl-messageformat` uses separate keys for `date` and `time`, but there's @@ -31,32 +13,51 @@ function setTimeZoneInFormats( * to convert the format before `intl-messageformat` can be used. */ export default function convertFormatsToIntlMessageFormat( - formats: Formats, + globalFormats?: Formats, + inlineFormats?: Formats, timeZone?: TimeZone ): Partial { - const formatsWithTimeZone = timeZone - ? {...formats, dateTime: setTimeZoneInFormats(formats.dateTime, timeZone)} - : formats; - - const mfDateDefaults = IntlMessageFormat.formats.date as Formats['dateTime']; - const defaultDateFormats = timeZone - ? setTimeZoneInFormats(mfDateDefaults, timeZone) - : mfDateDefaults; + const mfDateDefaults = IntlMessageFormat.formats.date as NonNullable< + Formats['dateTime'] + >; + const mfTimeDefaults = IntlMessageFormat.formats.time as NonNullable< + Formats['dateTime'] + >; - const mfTimeDefaults = IntlMessageFormat.formats.time as Formats['dateTime']; - const defaultTimeFormats = timeZone - ? setTimeZoneInFormats(mfTimeDefaults, timeZone) - : mfTimeDefaults; + const dateTimeFormats = { + ...globalFormats?.dateTime, + ...inlineFormats?.dateTime + }; - return { - ...formatsWithTimeZone, + const allFormats = { date: { - ...defaultDateFormats, - ...formatsWithTimeZone.dateTime + ...mfDateDefaults, + ...dateTimeFormats }, time: { - ...defaultTimeFormats, - ...formatsWithTimeZone.dateTime + ...mfTimeDefaults, + ...dateTimeFormats + }, + number: { + ...globalFormats?.number, + ...inlineFormats?.number } + // (list is not supported in ICU messages) }; + + if (timeZone) { + // The only way to set a time zone with `intl-messageformat` is to merge it into the formats + // https://github.com/formatjs/formatjs/blob/8256c5271505cf2606e48e3c97ecdd16ede4f1b5/packages/intl/src/message.ts#L15 + ['date', 'time'].forEach((property) => { + const formats = allFormats[property as keyof typeof allFormats]; + for (const [key, value] of Object.entries(formats)) { + formats[key] = { + timeZone, + ...value + }; + } + }); + } + + return allFormats; } diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index f1e90eb74..d961fec63 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -1,26 +1,27 @@ -import IntlMessageFormat from 'intl-messageformat'; -import {ReactNode, cloneElement, isValidElement} from 'react'; -import AbstractIntlMessages from './AbstractIntlMessages'; -import Formats from './Formats'; -import {InitializedIntlConfig} from './IntlConfig'; -import IntlError, {IntlErrorCode} from './IntlError'; -import TranslationValues, { +import {IntlMessageFormat} from 'intl-messageformat'; +import {type ReactNode, cloneElement, isValidElement} from 'react'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import type {Locale} from './AppConfig.js'; +import type Formats from './Formats.js'; +import type {InitializedIntlConfig} from './IntlConfig.js'; +import IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import type {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.js'; +import type { MarkupTranslationValues, - RichTranslationValues -} from './TranslationValues'; -import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; + RichTranslationValues, + TranslationValues +} from './TranslationValues.js'; +import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.js'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.js'; import { - Formatters, - IntlCache, - IntlFormatters, - MessageFormatter, + type Formatters, + type IntlCache, + type IntlFormatters, + type MessageFormatter, memoFn -} from './formatters'; -import joinPath from './joinPath'; -import MessageKeys from './utils/MessageKeys'; -import NestedKeyOf from './utils/NestedKeyOf'; -import NestedValueOf from './utils/NestedValueOf'; +} from './formatters.js'; +import joinPath from './joinPath.js'; // Placed here for improved tree shaking. Somehow when this is placed in // `formatters.tsx`, then it can't be shaken off from `next-intl`. @@ -41,7 +42,7 @@ function createMessageFormatter( } function resolvePath( - locale: string, + locale: Locale, messages: AbstractIntlMessages | undefined, key: string, namespace?: string @@ -77,8 +78,6 @@ function resolvePath( } function prepareTranslationValues(values: RichTranslationValues) { - if (Object.keys(values).length === 0) return undefined; - // Workaround for https://github.com/formatjs/formatjs/issues/1467 const transformedValues: RichTranslationValues = {}; Object.keys(values).forEach((key) => { @@ -105,7 +104,7 @@ function prepareTranslationValues(values: RichTranslationValues) { } function getMessagesOrError( - locale: string, + locale: Locale, messages?: Messages, namespace?: string, onError: (error: IntlError) => void = defaultOnError @@ -114,7 +113,7 @@ function getMessagesOrError( if (!messages) { throw new Error( process.env.NODE_ENV !== 'production' - ? `No messages were configured on the provider.` + ? `No messages were configured.` : undefined ); } @@ -146,26 +145,27 @@ function getMessagesOrError( export type CreateBaseTranslatorProps = InitializedIntlConfig & { cache: IntlCache; formatters: Formatters; - defaultTranslationValues?: RichTranslationValues; namespace?: string; messagesOrError: Messages | IntlError; }; function getPlainMessage(candidate: string, values?: unknown) { - if (values) return undefined; - - const unescapedMessage = candidate.replace(/'([{}])/gi, '$1'); - - // Placeholders can be in the message if there are default values, - // or if the user has forgotten to provide values. In the latter - // case we need to compile the message to receive an error. - const hasPlaceholders = /<|{/.test(unescapedMessage); - - if (!hasPlaceholders) { - return unescapedMessage; + if (process.env.NODE_ENV !== 'production') { + // Keep fast path in development + if (values) return undefined; + + // Despite potentially no values being available, there can still be + // placeholders in the message if the user has forgotten to provide + // values. In this case we compile the message to receive an error. + const unescapedMessage = candidate.replace(/'([{}])/gi, '$1'); + const hasPlaceholders = /<|{/.test(unescapedMessage); + + if (!hasPlaceholders) { + return unescapedMessage; + } + } else { + return values ? undefined : candidate; } - - return undefined; } export default function createBaseTranslator< @@ -190,7 +190,6 @@ function createBaseTranslatorImpl< NestedKey extends NestedKeyOf >({ cache, - defaultTranslationValues, formats: globalFormats, formatters, getMessageFallback = defaultGetMessageFallback, @@ -280,10 +279,7 @@ function createBaseTranslatorImpl< messageFormat = formatters.getMessageFormat( message, locale, - convertFormatsToIntlMessageFormat( - {...globalFormats, ...formats}, - timeZone - ), + convertFormatsToIntlMessageFormat(globalFormats, formats, timeZone), { formatters: { ...formatters, @@ -317,7 +313,7 @@ function createBaseTranslatorImpl< // for rich text elements since a recent minor update. This // needs to be evaluated in detail, possibly also in regards // to be able to format to parts. - prepareTranslationValues({...defaultTranslationValues, ...values}) + values ? prepareTranslationValues(values) : values ); if (formattedMessage == null) { @@ -393,23 +389,17 @@ function createBaseTranslatorImpl< formats ); - // When only string chunks are provided to the parser, only - // strings should be returned here. Note that we need a runtime - // check for this since rich text values could be accidentally - // inherited from `defaultTranslationValues`. - if (typeof result !== 'string') { + if (process.env.NODE_ENV !== 'production' && typeof result !== 'string') { const error = new IntlError( IntlErrorCode.FORMATTING_ERROR, - process.env.NODE_ENV !== 'production' - ? "`t.markup` only accepts functions for formatting that receive and return strings.\n\nE.g. t.markup('markup', {b: (chunks) => `${chunks}`})" - : undefined + "`t.markup` only accepts functions for formatting that receive and return strings.\n\nE.g. t.markup('markup', {b: (chunks) => `${chunks}`})" ); onError(error); return getMessageFallback({error, key, namespace}); } - return result; + return result as string; }; translateFn.raw = ( @@ -437,7 +427,7 @@ function createBaseTranslatorImpl< } }; - translateFn.has = (key: Parameters[0]): boolean => { + translateFn.has = (key: string): boolean => { if (hasMessagesError) { return false; } diff --git a/packages/use-intl/src/core/createFormatter.test.tsx b/packages/use-intl/src/core/createFormatter.test.tsx index 4bcfb696c..647d247dc 100644 --- a/packages/use-intl/src/core/createFormatter.test.tsx +++ b/packages/use-intl/src/core/createFormatter.test.tsx @@ -1,6 +1,6 @@ import {parseISO} from 'date-fns'; import {describe, expect, it} from 'vitest'; -import createFormatter from './createFormatter'; +import createFormatter from './createFormatter.js'; describe('dateTime', () => { it('formats a date and time', () => { @@ -28,6 +28,26 @@ describe('dateTime', () => { }) ).toBe('Nov 20, 2020, 5:36:01 AM'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin', + formats: { + dateTime: { + short: { + dateStyle: 'short', + timeStyle: 'short' + } + } + } + }); + expect( + formatter.dateTime(parseISO('2020-11-20T10:36:01.516Z'), 'short', { + timeZone: 'America/New_York' + }) + ).toBe('11/20/20, 5:36 AM'); + }); }); describe('number', () => { @@ -71,6 +91,25 @@ describe('number', () => { }) ).toBe('$123,456,789,123,456,789.00'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin', + formats: { + number: { + price: { + style: 'currency', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + } + } + } + }); + expect(formatter.number(123456.789, 'price', {currency: 'EUR'})).toBe( + '€123,456.79' + ); + }); }); describe('relativeTime', () => { @@ -349,6 +388,31 @@ describe('dateTimeRange', () => { ) ).toBe('Jan 10, 2007, 4:00:00 AM – Jan 10, 2008, 5:00:00 AM'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + timeZone: 'Europe/Berlin', + formats: { + dateTime: { + short: { + dateStyle: 'short', + timeStyle: 'short' + } + } + } + }); + expect( + formatter.dateTimeRange( + new Date(2007, 0, 10, 10, 0, 0), + new Date(2008, 0, 10, 11, 0, 0), + 'short', + { + timeZone: 'America/New_York' + } + ) + ).toBe('1/10/07, 4:00 AM – 1/10/08, 5:00 AM'); + }); }); describe('list', () => { @@ -373,4 +437,22 @@ describe('list', () => { }) ).toBe('apple, banana, or orange'); }); + + it('can combine a global format with an override', () => { + const formatter = createFormatter({ + locale: 'en', + formats: { + list: { + short: { + type: 'disjunction' + } + } + } + }); + expect( + formatter.list(['apple', 'banana', 'orange'], 'short', { + type: 'conjunction' + }) + ).toBe('apple, banana, and orange'); + }); }); diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index f4064fb68..a14ecaa55 100644 --- a/packages/use-intl/src/core/createFormatter.tsx +++ b/packages/use-intl/src/core/createFormatter.tsx @@ -1,17 +1,19 @@ -import {ReactElement} from 'react'; -import DateTimeFormatOptions from './DateTimeFormatOptions'; -import Formats from './Formats'; -import IntlError, {IntlErrorCode} from './IntlError'; -import NumberFormatOptions from './NumberFormatOptions'; -import RelativeTimeFormatOptions from './RelativeTimeFormatOptions'; -import TimeZone from './TimeZone'; -import {defaultOnError} from './defaults'; +import type {ReactElement} from 'react'; +import type {FormatNames, Locale} from './AppConfig.js'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.js'; +import type Formats from './Formats.js'; +import IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import type NumberFormatOptions from './NumberFormatOptions.js'; +import type RelativeTimeFormatOptions from './RelativeTimeFormatOptions.js'; +import type TimeZone from './TimeZone.js'; +import {defaultOnError} from './defaults.js'; import { - Formatters, - IntlCache, + type Formatters, + type IntlCache, createCache, createIntlFormatters -} from './formatters'; +} from './formatters.js'; const SECOND = 1; const MINUTE = SECOND * 60; @@ -70,7 +72,7 @@ function calculateRelativeTimeValue( } type Props = { - locale: string; + locale: Locale; timeZone?: TimeZone; onError?(error: IntlError): void; formats?: Formats; @@ -81,15 +83,16 @@ type Props = { _cache?: IntlCache; }; -export default function createFormatter({ - _cache: cache = createCache(), - _formatters: formatters = createIntlFormatters(cache), - formats, - locale, - now: globalNow, - onError = defaultOnError, - timeZone: globalTimeZone -}: Props) { +export default function createFormatter(props: Props) { + const { + _cache: cache = createCache(), + _formatters: formatters = createIntlFormatters(cache), + formats, + locale, + onError = defaultOnError, + timeZone: globalTimeZone + } = props; + function applyTimeZone(options?: DateTimeFormatOptions) { if (!options?.timeZone) { if (globalTimeZone) { @@ -111,7 +114,8 @@ export default function createFormatter({ function resolveFormatOrOptions( typeFormats: Record | undefined, - formatOrOptions?: string | Options + formatOrOptions?: string | Options, + overrides?: Options ) { let options; if (typeof formatOrOptions === 'string') { @@ -122,7 +126,7 @@ export default function createFormatter({ const error = new IntlError( IntlErrorCode.MISSING_FORMAT, process.env.NODE_ENV !== 'production' - ? `Format \`${formatName}\` is not available. You can configure it on the provider or provide custom options.` + ? `Format \`${formatName}\` is not available.` : undefined ); onError(error); @@ -132,18 +136,23 @@ export default function createFormatter({ options = formatOrOptions; } + if (overrides) { + options = {...options, ...overrides}; + } + return options; } function getFormattedValue( formatOrOptions: string | Options | undefined, + overrides: Options | undefined, typeFormats: Record | undefined, formatter: (options?: Options) => Output, getFallback: () => Output ) { let options; try { - options = resolveFormatOrOptions(typeFormats, formatOrOptions); + options = resolveFormatOrOptions(typeFormats, formatOrOptions, overrides); } catch { return getFallback(); } @@ -161,14 +170,22 @@ export default function createFormatter({ function dateTime( /** If a number is supplied, this is interpreted as a UTC timestamp. */ value: Date | number, - /** If a time zone is supplied, the `value` is converted to that time zone. - * Otherwise the user time zone will be used. */ - formatOrOptions?: - | Extract - | DateTimeFormatOptions + options?: DateTimeFormatOptions + ): string; + function dateTime( + /** If a number is supplied, this is interpreted as a UTC timestamp. */ + value: Date | number, + format?: FormatNames['dateTime'], + options?: DateTimeFormatOptions + ): string; + function dateTime( + value: Date | number, + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions, + overrides?: DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, + overrides, formats?.dateTime, (options) => { options = applyTimeZone(options); @@ -183,14 +200,25 @@ export default function createFormatter({ start: Date | number, /** If a number is supplied, this is interpreted as a UTC timestamp. */ end: Date | number, - /** If a time zone is supplied, the values are converted to that time zone. - * Otherwise the user time zone will be used. */ - formatOrOptions?: - | Extract - | DateTimeFormatOptions + options?: DateTimeFormatOptions + ): string; + function dateTimeRange( + /** If a number is supplied, this is interpreted as a UTC timestamp. */ + start: Date | number, + /** If a number is supplied, this is interpreted as a UTC timestamp. */ + end: Date | number, + format?: FormatNames['dateTime'], + options?: DateTimeFormatOptions + ): string; + function dateTimeRange( + start: Date | number, + end: Date | number, + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions, + overrides?: DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, + overrides, formats?.dateTime, (options) => { options = applyTimeZone(options); @@ -204,12 +232,21 @@ export default function createFormatter({ function number( value: number | bigint, - formatOrOptions?: - | Extract - | NumberFormatOptions + options?: NumberFormatOptions + ): string; + function number( + value: number | bigint, + format?: FormatNames['number'], + options?: NumberFormatOptions + ): string; + function number( + value: number | bigint, + formatOrOptions?: FormatNames['number'] | NumberFormatOptions, + overrides?: NumberFormatOptions ) { return getFormattedValue( formatOrOptions, + overrides, formats?.number, (options) => formatters.getNumberFormat(locale, options).format(value), () => String(value) @@ -217,14 +254,16 @@ export default function createFormatter({ } function getGlobalNow() { - if (globalNow) { - return globalNow; + // Only read when necessary to avoid triggering a `dynamicIO` error + // unnecessarily (`now` is only needed for `format.relativeTime`) + if (props.now) { + return props.now; } else { onError( new IntlError( IntlErrorCode.ENVIRONMENT_FALLBACK, process.env.NODE_ENV !== 'production' - ? `The \`now\` parameter wasn't provided and there is no global default configured. Consider adding a global default to avoid markup mismatches caused by environment differences. Learn more: https://next-intl.dev/docs/configuration#now` + ? `The \`now\` parameter wasn't provided to \`relativeTime\` and there is no global default configured, therefore the current time will be used as a fallback. See https://next-intl.dev/docs/usage/dates-times#relative-times-usenow` : undefined ) ); @@ -235,7 +274,16 @@ export default function createFormatter({ function relativeTime( /** The date time that needs to be formatted. */ date: number | Date, - /** The reference point in time to which `date` will be formatted in relation to. */ + /** The reference point in time to which `date` will be formatted in relation to. If this value is absent, a globally configured `now` value or alternatively the current time will be used. */ + now?: RelativeTimeFormatOptions['now'] + ): string; + function relativeTime( + /** The date time that needs to be formatted. */ + date: number | Date, + options?: RelativeTimeFormatOptions + ): string; + function relativeTime( + date: number | Date, nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions ) { try { @@ -290,9 +338,17 @@ export default function createFormatter({ type FormattableListValue = string | ReactElement; function list( value: Iterable, - formatOrOptions?: - | Extract - | Intl.ListFormatOptions + options?: Intl.ListFormatOptions + ): Value extends string ? string : Iterable; + function list( + value: Iterable, + format?: FormatNames['list'], + options?: Intl.ListFormatOptions + ): Value extends string ? string : Iterable; + function list( + value: Iterable, + formatOrOptions?: FormatNames['list'] | Intl.ListFormatOptions, + overrides?: Intl.ListFormatOptions ): Value extends string ? string : Iterable { const serializedValue: Array = []; const richValues = new Map(); @@ -318,6 +374,7 @@ export default function createFormatter({ Value extends string ? string : Iterable >( formatOrOptions, + overrides, formats?.list, // @ts-expect-error -- `richValues.size` is used to determine the return type, but TypeScript can't infer the meaning of this correctly (options) => { diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 159715758..ea4d4e7c2 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -1,16 +1,19 @@ -import React, {isValidElement} from 'react'; +import {isValidElement} from 'react'; import {renderToString} from 'react-dom/server'; import {describe, expect, it, vi} from 'vitest'; -import IntlError, {IntlErrorCode} from './IntlError'; -import createTranslator from './createTranslator'; +import type {Messages} from './AppConfig.js'; +import type IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import createTranslator from './createTranslator.js'; const messages = { Home: { title: 'Hello world!', + param: 'Hello {param}', rich: 'Hello {name}!', markup: 'Hello {name}!' } -}; +} as const; it('can translate a message within a namespace', () => { const t = createTranslator({ @@ -39,6 +42,7 @@ it('handles formatting errors', () => { onError }); + // @ts-expect-error const result = t('price'); const error: IntlError = onError.mock.calls[0][0]; @@ -50,6 +54,21 @@ it('handles formatting errors', () => { expect(result).toBe('price'); }); +it('restricts boolean and date values as plain params', () => { + const onError = vi.fn(); + const t = createTranslator({ + locale: 'en', + namespace: 'Home', + messages: messages as any, + onError + }); + + t('param', {param: new Date()}); + // @ts-expect-error + t('param', {param: true}); + expect(onError.mock.calls.length).toBe(2); +}); + it('supports alphanumeric value names', () => { const t = createTranslator({ locale: 'en', @@ -74,6 +93,564 @@ it('throws an error for non-alphanumeric value names', () => { expect(error.code).toBe('INVALID_MESSAGE'); }); +it('can handle nested blocks in selects', () => { + const t = createTranslator({ + locale: 'en', + messages: { + label: + '{foo, select, one {One: {one}} two {Two: {two}} other {Other: {other}}}' + } + }); + expect( + t('label', { + foo: 'one', + one: 'One', + two: 'Two', + other: 'Other' + }) + ).toBe('One: One'); +}); + +it('can handle nested blocks in plurals', () => { + const t = createTranslator({ + locale: 'en', + messages: { + label: '{count, plural, one {One: {one}} other {Other: {other}}}' + } + }); + expect(t('label', {count: 1, one: 'One', other: 'Other'})).toBe('One: One'); +}); + +describe('type safety', () => { + describe('keys, strictly-typed', () => { + it('allows valid namespaces', () => { + createTranslator({ + locale: 'en', + messages, + namespace: 'Home' + }); + }); + + it('allows valid keys', () => { + const t = createTranslator({ + locale: 'en', + messages, + namespace: 'Home' + }); + + t('title'); + t.has('title'); + t.markup('title'); + t.rich('title'); + }); + + it('allows an undefined namespace with a valid key', () => { + const t = createTranslator({ + locale: 'en', + messages + }); + t('Home.title'); + }); + + it('disallows an undefined namespace with an invalid key', () => { + const t = createTranslator({ + locale: 'en', + messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('unknown'); + // @ts-expect-error + t.has('unknown'); + // @ts-expect-error + t.markup('unknown'); + // @ts-expect-error + t.rich('unknown'); + }; + }); + + it('disallows invalid namespaces', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + createTranslator({ + locale: 'en', + messages, + // @ts-expect-error + namespace: 'unknown' + }); + }; + }); + + it('disallows invalid keys', () => { + const t = createTranslator({ + locale: 'en', + messages, + namespace: 'Home' + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('unknown'); + // @ts-expect-error + t.has('unknown'); + // @ts-expect-error + t.markup('unknown'); + // @ts-expect-error + t.rich('unknown'); + }; + }); + }); + + describe('keys, untyped', () => { + it('allows any namespace', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + createTranslator({ + locale: 'en', + messages: messages as Messages, + namespace: 'unknown' + }); + }; + }); + + it('allows any key', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + t('unknown'); + }; + }); + }); + + describe('params, strictly-typed', () => { + function translateMessage(msg: T) { + return createTranslator({ + locale: 'en', + messages: {msg} + }); + } + + it('validates plain params', () => { + const t = translateMessage('Hello {name}'); + + t('msg', {name: 'Jane'}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 'Jane'}); + // @ts-expect-error + t('msg'); + }; + }); + + it('restricts non-string values', () => { + const t = translateMessage('{param}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error -- should use {param, number} instead + t('msg', {param: 1.5}); + + // @ts-expect-error + t('msg', {param: new Date()}); + + // @ts-expect-error + t('msg', {param: true}); + }; + }); + + it('can handle undefined values', () => { + const t = translateMessage('Hello {name}'); + + const obj = { + name: 'Jane', + age: undefined + }; + t('msg', obj); + }); + + it('validates numbers', () => { + const t = translateMessage('Percentage: {value, number, percent}'); + t('msg', {value: 1.5}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {value: 'test'}); + }; + }); + + it('validates dates', () => { + const t = translateMessage('Date: {date, date, full}'); + t('msg', {date: new Date('2024-07-09T07:06:03.320Z')}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {date: '2024-07-09T07:06:03.320Z'}); + }; + }); + + it('restricts numbers in dates', () => { + const t = translateMessage('Date: {date, date, full}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {date: 1.5}); + }; + }); + + it('validates cardinal plurals', () => { + const t = translateMessage( + 'You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}.' + ); + + t('msg', {count: 0}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 1.5}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates ordinal plurals', () => { + const t = translateMessage( + "It's your {year, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday!" + ); + + t('msg', {year: 1}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 1}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates selects', () => { + const t = translateMessage( + '{gender, select, female {She} male {He} other {They}} is online.' + ); + + t('msg', {gender: 'female'}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {unknown: 'female'}); + // @ts-expect-error + t('msg'); + }; + }); + + it('validates nested selects', () => { + const t = translateMessage( + '{foo, select, one {One: {one}} two {Two: {two}} other {Other: {other}}}' + ); + + t('msg', { + foo: 'one', + one: 'One', + two: 'Two', + other: 'Other' + }); + t('msg', {foo: 'one', one: 'One'}); // Only `one` is required + t('msg', {foo: 'one', one: 'One', two: 'Two'}); // …but `two` is also allowed + t('msg', {foo: 'two', two: 'Two'}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {foo: 'unknown' as string, other: 'Other'}); + // @ts-expect-error + t('msg', {unknown: 'one'}); + // @ts-expect-error + t('msg'); + }; + }); + + it('restricts numbers in selects', () => { + const t = translateMessage( + '{count, select, 0 {zero} 1 {one} other {other}}' + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {count: 1.5}); + }; + }); + + it('restricts booleans in selects', () => { + const t = translateMessage('{bool, select, true {true} false {false}}'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {bool: true}); + }; + }); + + it('validates escaped', () => { + const t = translateMessage( + "Escape curly braces with single quotes (e.g. '{name')" + ); + + t('msg'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('msg', {name: 'Jane'}); + }; + }); + + it('validates simple rich text', () => { + const t = translateMessage( + 'Please refer to the guidelines.' + ); + + t.rich('msg', {guidelines: (chunks) =>

{chunks}

}); + t.markup('msg', {guidelines: (chunks) => `

${chunks}

`}); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.rich('msg', {guidelines: 'test'}); + // @ts-expect-error + t.rich('msg', {unknown: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.rich('msg', {unknown: 'test'}); + // @ts-expect-error + t.rich('msg'); + }; + }); + + it('validates nested rich text', () => { + const t = translateMessage( + 'This is very important' + ); + + t.rich('msg', { + important: (chunks) => {chunks}, + very: (chunks) => {chunks} + }); + t.markup('msg', { + important: (chunks) => `${chunks}`, + very: (chunks) => `${chunks}` + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.rich('msg', {important: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.rich('msg', {important: 'test', very: 'test'}); + // @ts-expect-error + t.rich('msg', {unknown: 'test'}); + // @ts-expect-error + t.rich('msg'); + }; + }); + + it('validates a complex message', () => { + const t = translateMessage( + 'Hello {name}, you have {count, plural, =0 {no followers} =1 {one follower} other {# followers ({count, number})}}.' + ); + + t.rich('msg', { + name: 'Jane', + count: 2, + user: (chunks) =>

{chunks}

+ }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.rich('msg', { + name: 'Jane', + user: (chunks) =>

{chunks}

+ }); + t.rich('msg', { + // @ts-expect-error + user: 'Jane', + // @ts-expect-error + name: (chunks) =>

{chunks}

, + count: 2 + }); + }; + }); + + describe('disallowed params', () => { + const t = createTranslator({ + locale: 'en', + messages: { + simpleParam: 'Hello {name}', + pluralMessage: + 'You have {count, plural, =0 {no followers} =1 {one follower} other {# followers}}.', + ordinalMessage: + "It's your {year, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday!", + selectMessage: + '{gender, select, female {She} male {He} other {They}} is online.', + escapedParam: + "Escape curly braces with single quotes (e.g. '{name'})", + simpleRichText: + 'Please refer to the guidelines.', + nestedRichText: + 'This is very important' + } + }); + + it("doesn't allow params for `has`", () => { + t.has('simpleParam'); + t.has('pluralMessage'); + t.has('ordinalMessage'); + t.has('selectMessage'); + t.has('escapedParam'); + t.has('simpleRichText'); + t.has('nestedRichText'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.has('simpleParam', {name: 'Jane'}); + // @ts-expect-error + t.has('pluralMessage', {count: 0}); + // @ts-expect-error + t.has('ordinalMessage', {year: 1}); + // @ts-expect-error + t.has('selectMessage', {gender: 'female'}); + // @ts-expect-error + t.has('simpleRichText', {guidelines: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.has('nestedRichText', { + important: (chunks: any) => {chunks} + }); + }; + }); + + it("doesn't allow params for `raw`", () => { + t.raw('simpleParam'); + t.raw('pluralMessage'); + t.raw('ordinalMessage'); + t.raw('selectMessage'); + t.raw('escapedParam'); + t.raw('simpleRichText'); + t.raw('nestedRichText'); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t.raw('simpleParam', {name: 'Jane'}); + // @ts-expect-error + t.raw('pluralMessage', {count: 0}); + // @ts-expect-error + t.raw('ordinalMessage', {year: 1}); + // @ts-expect-error + t.raw('selectMessage', {gender: 'female'}); + // @ts-expect-error + t.raw('simpleRichText', {guidelines: (chunks) =>

{chunks}

}); + // @ts-expect-error + t.raw('nestedRichText', { + important: (chunks: any) => {chunks} + }); + }; + }); + }); + }); + + describe('params, untyped', () => { + it('allows passing no values', () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + t('param'); + t.rich('param'); + t.markup('param'); + t.raw('param'); + t.has('param'); + }; + }); + + it('allows passing any values', () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + t('param', {unknown: 'Jane'}); + t.rich('param', {unknown: 'Jane', p: (chunks) =>

{chunks}

}); + t.markup('param', {unknown: 'Jane', p: (chunks) => `

${chunks}

`}); + }; + }); + + it('limits values where relevant', () => { + const t = createTranslator({ + locale: 'en', + messages: messages as Messages + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + () => { + // @ts-expect-error + t('param', {p: (chunks) =>

{chunks}

}); + // @ts-expect-error + t('param', {p: (chunks) => `

${chunks}

`}); + + // @ts-expect-error + t.markup('param', {unknown: 'Jane', p: (chunks) =>

{chunks}

}); + + // @ts-expect-error + t.raw('param', {unknown: 'Jane'}); + // @ts-expect-error + t.has('param', {unknown: 'Jane'}); + }; + }); + }); +}); + +describe('numbers in messages', () => { + it('can pass an inline format', () => { + const t = createTranslator({ + locale: 'en', + messages: {label: '{count, number, precise}'} + }); + expect( + t('label', {count: 1.5}, {number: {precise: {minimumFractionDigits: 5}}}) + ).toBe('1.50000'); + }); + + it('can merge an inline format with global formats', () => { + const t = createTranslator({ + locale: 'en', + messages: {label: '{count, number, precise} {count, number, integer}'}, + formats: {number: {precise: {minimumFractionDigits: 5}}} + }); + expect( + t('label', {count: 1.5}, {number: {integer: {minimumFractionDigits: 0}}}) + ).toBe('1.50000 2'); + }); +}); + describe('dates in messages', () => { it.each([ ['G', '7/9/2024 AD'], // 🤔 Includes date @@ -171,6 +748,17 @@ describe('dates in messages', () => { }); expect(t('date', {date})).toBe(expected); }); + + it('can set a time zone in a built-in default format', () => { + const t = createTranslator({ + locale: 'en', + messages: {date: `{date, time, full}`}, + timeZone: 'Asia/Kolkata' + }); + expect(t('date', {date: new Date('2023-12-31T18:30:00.000Z')})).toBe( + '12:00:00 AM GMT+5:30' + ); + }); }); describe('t.rich', () => { diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 92a25e9da..905fd2341 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,22 +1,100 @@ -import {ReactNode} from 'react'; -import Formats from './Formats'; -import IntlConfig from './IntlConfig'; -import TranslationValues, { - MarkupTranslationValues, - RichTranslationValues -} from './TranslationValues'; -import createTranslatorImpl from './createTranslatorImpl'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; +import type {ReactNode} from 'react'; +import type Formats from './Formats.js'; +import type ICUArgs from './ICUArgs.js'; +import type ICUTags from './ICUTags.js'; +import type IntlConfig from './IntlConfig.js'; +import type { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.js'; +import type { + MarkupTagsFunction, + RichTagsFunction, + TranslationValues +} from './TranslationValues.js'; +import createTranslatorImpl from './createTranslatorImpl.js'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.js'; import { - Formatters, - IntlCache, + type Formatters, + type IntlCache, createCache, createIntlFormatters -} from './formatters'; -import MessageKeys from './utils/MessageKeys'; -import NamespaceKeys from './utils/NamespaceKeys'; -import NestedKeyOf from './utils/NestedKeyOf'; -import NestedValueOf from './utils/NestedValueOf'; +} from './formatters.js'; +import type {Prettify} from './types.js'; + +type ICUArgsWithTags< + MessageString extends string, + TagsFn extends RichTagsFunction | MarkupTagsFunction = never +> = ICUArgs< + MessageString, + { + // Numbers and dates should use the corresponding operators + ICUArgument: string; + + ICUNumberArgument: number; + ICUDateArgument: Date; + } +> & + ([TagsFn] extends [never] ? {} : ICUTags); + +type OnlyOptional = Partial extends T ? true : false; + +type TranslateArgs< + Value extends string, + TagsFn extends RichTagsFunction | MarkupTagsFunction = never +> = + // If an unknown string is passed, allow any values + string extends Value + ? [ + values?: Record, + formats?: Formats + ] + : ( + Value extends any + ? (key: ICUArgsWithTags) => void + : never + ) extends (key: infer Args) => void + ? OnlyOptional extends true + ? [values?: undefined, formats?: Formats] + : [values: Prettify, formats?: Formats] + : never; + +// This type is slightly more loose than `AbstractIntlMessages` +// in order to avoid a type error. +type IntlMessages = Record; + +type NamespacedMessageKeys< + TranslatorMessages extends IntlMessages, + Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + > = never +> = MessageKeys< + NestedValueOf< + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` + >, + NestedKeyOf< + NestedValueOf< + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` + > + > +>; + +type NamespacedValue< + TranslatorMessages extends IntlMessages, + Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + >, + TargetKey extends NamespacedMessageKeys +> = NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` +>; /** * Translates messages from the given namespace by using the ICU syntax. @@ -27,9 +105,10 @@ import NestedValueOf from './utils/NestedValueOf'; * (e.g. `namespace.Component`). */ export default function createTranslator< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf + const TranslatorMessages extends IntlMessages, + const Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf > = never >({ _cache = createCache(), @@ -39,9 +118,9 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'defaultTranslationValues' | 'messages'> & { - messages?: IntlConfig['messages']; - namespace?: NestedKey; +}: Omit & { + messages?: TranslatorMessages; + namespace?: Namespace; /** @private */ _formatters?: Formatters; /** @private */ @@ -49,107 +128,50 @@ export default function createTranslator< }): // Explicitly defining the return type is necessary as TypeScript would get it wrong { // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( + >( key: TargetKey, - values?: TranslationValues, - formats?: Formats + ...args: TranslateArgs< + NamespacedValue + > ): string; // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( + rich>( key: TargetKey, - values?: RichTranslationValues, - formats?: Formats + ...args: TranslateArgs< + NamespacedValue, + RichTagsFunction + > ): ReactNode; // `markup` markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > + TargetKey extends NamespacedMessageKeys >( key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats + ...args: TranslateArgs< + NamespacedValue, + MarkupTagsFunction + > ): string; // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( + raw>( key: TargetKey ): any; // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( + has>( key: TargetKey ): boolean; } { // We have to wrap the actual function so the type inference for the optional // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. + // @ts-expect-error Use the explicit annotation instead return createTranslatorImpl< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >( { ...rest, diff --git a/packages/use-intl/src/core/createTranslatorImpl.tsx b/packages/use-intl/src/core/createTranslatorImpl.tsx index 64dd9f2dd..3f3fdb614 100644 --- a/packages/use-intl/src/core/createTranslatorImpl.tsx +++ b/packages/use-intl/src/core/createTranslatorImpl.tsx @@ -1,9 +1,9 @@ -import AbstractIntlMessages from './AbstractIntlMessages'; -import {InitializedIntlConfig} from './IntlConfig'; -import createBaseTranslator from './createBaseTranslator'; -import {Formatters, IntlCache} from './formatters'; -import resolveNamespace from './resolveNamespace'; -import NestedKeyOf from './utils/NestedKeyOf'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import type {InitializedIntlConfig} from './IntlConfig.js'; +import type {NestedKeyOf} from './MessageKeys.js'; +import createBaseTranslator from './createBaseTranslator.js'; +import type {Formatters, IntlCache} from './formatters.js'; +import resolveNamespace from './resolveNamespace.js'; export type CreateTranslatorImplProps = Omit< InitializedIntlConfig, diff --git a/packages/use-intl/src/core/defaults.tsx b/packages/use-intl/src/core/defaults.tsx index f957e40f6..a35403f0f 100644 --- a/packages/use-intl/src/core/defaults.tsx +++ b/packages/use-intl/src/core/defaults.tsx @@ -1,5 +1,5 @@ -import IntlError from './IntlError'; -import joinPath from './joinPath'; +import type IntlError from './IntlError.js'; +import joinPath from './joinPath.js'; /** * Contains defaults that are used for all entry points into the core. diff --git a/packages/use-intl/src/core/formatters.tsx b/packages/use-intl/src/core/formatters.tsx index 449809c64..2072d612c 100644 --- a/packages/use-intl/src/core/formatters.tsx +++ b/packages/use-intl/src/core/formatters.tsx @@ -1,5 +1,5 @@ -import {Cache, memoize, strategies} from '@formatjs/fast-memoize'; -import type IntlMessageFormat from 'intl-messageformat'; +import {type Cache, memoize, strategies} from '@formatjs/fast-memoize'; +import type {IntlMessageFormat} from 'intl-messageformat'; export type IntlCache = { dateTime: Record; diff --git a/packages/use-intl/src/core/hasLocale.test.tsx b/packages/use-intl/src/core/hasLocale.test.tsx new file mode 100644 index 000000000..b77d4b5de --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -0,0 +1,35 @@ +import {expect, it} from 'vitest'; +import hasLocale from './hasLocale.js'; + +it('narrows down the type', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'en-US' as string; + if (hasLocale(locales, candidate)) { + candidate satisfies (typeof locales)[number]; + } +}); + +it('can be called with a matching narrow candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'en-US' as const; + if (hasLocale(locales, candidate)) { + candidate satisfies (typeof locales)[number]; + } +}); + +it('can be called with a non-matching narrow candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + const candidate = 'de' as const; + if (hasLocale(locales, candidate)) { + candidate satisfies never; + } +}); + +it('can be called with any candidate', () => { + const locales = ['en-US', 'en-GB'] as const; + expect(hasLocale(locales, 'unknown')).toBe(false); + expect(hasLocale(locales, undefined)).toBe(false); + + // Relevant since `ParamValue` in Next.js includes `string[]` + expect(hasLocale(locales, ['de'])).toBe(false); +}); diff --git a/packages/use-intl/src/core/hasLocale.tsx b/packages/use-intl/src/core/hasLocale.tsx new file mode 100644 index 000000000..e8177f1db --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -0,0 +1,13 @@ +import type {Locale} from './AppConfig.js'; + +/** + * Checks if a locale exists in a list of locales. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale + */ +export default function hasLocale( + locales: ReadonlyArray, + candidate: unknown +): candidate is LocaleType { + return locales.includes(candidate as LocaleType); +} diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index a298caf02..a80ad92b7 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -1,22 +1,31 @@ -export type {default as AbstractIntlMessages} from './AbstractIntlMessages'; +export type {default as AbstractIntlMessages} from './AbstractIntlMessages.js'; export type { - default as TranslationValues, + TranslationValues, RichTranslationValues, - MarkupTranslationValues -} from './TranslationValues'; -export type {default as Formats} from './Formats'; -export type {default as IntlConfig} from './IntlConfig'; -export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions'; -export type {default as NumberFormatOptions} from './NumberFormatOptions'; -export type {default as RelativeTimeFormatOptions} from './RelativeTimeFormatOptions'; -export type {default as Timezone} from './TimeZone'; -export {default as IntlError, IntlErrorCode} from './IntlError'; -export {default as createTranslator} from './createTranslator'; -export {default as createFormatter} from './createFormatter'; -export {default as initializeConfig} from './initializeConfig'; -export type {default as MessageKeys} from './utils/MessageKeys'; -export type {default as NamespaceKeys} from './utils/NamespaceKeys'; -export type {default as NestedKeyOf} from './utils/NestedKeyOf'; -export type {default as NestedValueOf} from './utils/NestedValueOf'; -export {createIntlFormatters as _createIntlFormatters} from './formatters'; -export {createCache as _createCache} from './formatters'; + MarkupTranslationValues, + RichTagsFunction, + MarkupTagsFunction +} from './TranslationValues.js'; +export type {default as Formats} from './Formats.js'; +export type {default as IntlConfig} from './IntlConfig.js'; +export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions.js'; +export type {default as NumberFormatOptions} from './NumberFormatOptions.js'; +export {default as IntlError} from './IntlError.js'; +export {default as IntlErrorCode} from './IntlErrorCode.js'; +export {default as createTranslator} from './createTranslator.js'; +export {default as createFormatter} from './createFormatter.js'; +export {default as initializeConfig} from './initializeConfig.js'; +export type { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.js'; +export {createIntlFormatters as _createIntlFormatters} from './formatters.js'; +export {createCache as _createCache} from './formatters.js'; +export type {default as AppConfig, Locale, Messages} from './AppConfig.js'; +export {default as hasLocale} from './hasLocale.js'; +export type {default as RelativeTimeFormatOptions} from './RelativeTimeFormatOptions.js'; +export type {default as Timezone} from './TimeZone.js'; +export type {default as ICUArgs} from './ICUArgs.js'; +export type {default as ICUTags} from './ICUTags.js'; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index a784b2ce2..8bace8296 100644 --- a/packages/use-intl/src/core/initializeConfig.tsx +++ b/packages/use-intl/src/core/initializeConfig.tsx @@ -1,6 +1,6 @@ -import IntlConfig from './IntlConfig'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; -import validateMessages from './validateMessages'; +import type IntlConfig from './IntlConfig.js'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.js'; +import validateMessages from './validateMessages.js'; /** * Enhances the incoming props with defaults. @@ -9,7 +9,7 @@ export default function initializeConfig< // This is a generic to allow for stricter typing. E.g. // the RSC integration always provides a `now` value. Props extends IntlConfig ->({getMessageFallback, messages, onError, ...rest}: Props) { +>({formats, getMessageFallback, messages, onError, ...rest}: Props) { const finalOnError = onError || defaultOnError; const finalGetMessageFallback = getMessageFallback || defaultGetMessageFallback; @@ -22,7 +22,12 @@ export default function initializeConfig< return { ...rest, - messages, + formats: (formats || undefined) as + | NonNullable + | undefined, + messages: (messages || undefined) as + | NonNullable + | undefined, onError: finalOnError, getMessageFallback: finalGetMessageFallback }; diff --git a/packages/use-intl/src/core/types.tsx b/packages/use-intl/src/core/types.tsx new file mode 100644 index 000000000..140ae5c1b --- /dev/null +++ b/packages/use-intl/src/core/types.tsx @@ -0,0 +1,10 @@ +// https://www.totaltypescript.com/concepts/the-prettify-helper +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +export type DeepPartial = { + [Key in keyof Type]?: Type[Key] extends object + ? DeepPartial + : Type[Key]; +}; diff --git a/packages/use-intl/src/core/utils/MessageKeys.tsx b/packages/use-intl/src/core/utils/MessageKeys.tsx deleted file mode 100644 index f2190a87d..000000000 --- a/packages/use-intl/src/core/utils/MessageKeys.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import NestedValueOf from './NestedValueOf'; - -type MessageKeys = { - [Property in Keys]: NestedValueOf extends string - ? Property - : never; -}[Keys]; - -export default MessageKeys; diff --git a/packages/use-intl/src/core/utils/NamespaceKeys.tsx b/packages/use-intl/src/core/utils/NamespaceKeys.tsx deleted file mode 100644 index e2ea489e1..000000000 --- a/packages/use-intl/src/core/utils/NamespaceKeys.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import NestedValueOf from './NestedValueOf'; - -type NamespaceKeys = { - [Property in Keys]: NestedValueOf extends string - ? never - : Property; -}[Keys]; - -export default NamespaceKeys; diff --git a/packages/use-intl/src/core/utils/NestedKeyOf.tsx b/packages/use-intl/src/core/utils/NestedKeyOf.tsx deleted file mode 100644 index 3735df10e..000000000 --- a/packages/use-intl/src/core/utils/NestedKeyOf.tsx +++ /dev/null @@ -1,9 +0,0 @@ -type NestedKeyOf = ObjectType extends object - ? { - [Key in keyof ObjectType]: - | `${Key & string}` - | `${Key & string}.${NestedKeyOf}`; - }[keyof ObjectType] - : never; - -export default NestedKeyOf; diff --git a/packages/use-intl/src/core/utils/NestedValueOf.tsx b/packages/use-intl/src/core/utils/NestedValueOf.tsx deleted file mode 100644 index 4d396f4a3..000000000 --- a/packages/use-intl/src/core/utils/NestedValueOf.tsx +++ /dev/null @@ -1,12 +0,0 @@ -type NestedValueOf< - ObjectType, - Property extends string -> = Property extends `${infer Key}.${infer Rest}` - ? Key extends keyof ObjectType - ? NestedValueOf - : never - : Property extends keyof ObjectType - ? ObjectType[Property] - : never; - -export default NestedValueOf; diff --git a/packages/use-intl/src/core/validateMessages.tsx b/packages/use-intl/src/core/validateMessages.tsx index ac3b651f2..f5b2fd7e5 100644 --- a/packages/use-intl/src/core/validateMessages.tsx +++ b/packages/use-intl/src/core/validateMessages.tsx @@ -1,6 +1,7 @@ -import AbstractIntlMessages from './AbstractIntlMessages'; -import IntlError, {IntlErrorCode} from './IntlError'; -import joinPath from './joinPath'; +import type AbstractIntlMessages from './AbstractIntlMessages.js'; +import IntlError from './IntlError.js'; +import IntlErrorCode from './IntlErrorCode.js'; +import joinPath from './joinPath.js'; function validateMessagesSegment( messages: AbstractIntlMessages, diff --git a/packages/use-intl/src/index.tsx b/packages/use-intl/src/index.tsx index 78a29b8b5..03330e7ba 100644 --- a/packages/use-intl/src/index.tsx +++ b/packages/use-intl/src/index.tsx @@ -1,2 +1,2 @@ -export * from './core'; -export * from './react'; +export * from './core.js'; +export * from './react.js'; diff --git a/packages/use-intl/src/react.tsx b/packages/use-intl/src/react.tsx index f97dcae79..a6189cdec 100644 --- a/packages/use-intl/src/react.tsx +++ b/packages/use-intl/src/react.tsx @@ -1 +1 @@ -export * from './react/index'; +export * from './react/index.js'; diff --git a/packages/use-intl/src/react/IntlContext.tsx b/packages/use-intl/src/react/IntlContext.tsx index 3606b83d6..dbe190bcd 100644 --- a/packages/use-intl/src/react/IntlContext.tsx +++ b/packages/use-intl/src/react/IntlContext.tsx @@ -1,6 +1,6 @@ import {createContext} from 'react'; -import type {InitializedIntlConfig} from '../core/IntlConfig'; -import type {Formatters, IntlCache} from '../core/formatters'; +import type {InitializedIntlConfig} from '../core/IntlConfig.js'; +import type {Formatters, IntlCache} from '../core/formatters.js'; export type IntlContextValue = InitializedIntlConfig & { formatters: Formatters; diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index 5bfc0cd14..fbe8604a1 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -1,8 +1,9 @@ import {fireEvent, render, screen} from '@testing-library/react'; -import React, {memo, useState} from 'react'; -import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useTranslations from './useTranslations'; +import {memo, useState} from 'react'; +import {expect, it, vi} from 'vitest'; +import IntlProvider from './IntlProvider.js'; +import useNow from './useNow.js'; +import useTranslations from './useTranslations.js'; it("doesn't re-render context consumers unnecessarily", () => { const messages = {StaticText: {hello: 'Hello!'}}; @@ -44,3 +45,129 @@ it("doesn't re-render context consumers unnecessarily", () => { expect(numCounterRenders).toBe(2); expect(numStaticTextRenders).toBe(1); }); + +it('keeps a consistent context value that does not trigger unnecessary re-renders', () => { + const messages = {StaticText: {hello: 'Hello!'}}; + + let numCounterRenders = 0; + function Counter() { + const [count, setCount] = useState(0); + numCounterRenders++; + + return ( + <> + +

Count: {count}

+ + + + + ); + } + + let numStaticTextRenders = 0; + const StaticText = memo(() => { + const t = useTranslations('StaticText'); + numStaticTextRenders++; + return t('hello'); + }); + StaticText.displayName = 'StaticText'; + + render(); + screen.getByText('Count: 0'); + expect(numCounterRenders).toBe(1); + expect(numStaticTextRenders).toBe(1); + fireEvent.click(screen.getByText('Increment')); + screen.getByText('Count: 1'); + expect(numCounterRenders).toBe(2); + expect(numStaticTextRenders).toBe(1); +}); + +it('passes on configuration in nested providers', () => { + const onError = vi.fn(); + + function Component() { + const now = useNow(); + const t = useTranslations(); + t('unknown'); + return t('now', {now}); + } + + render( + + + + + + ); + + screen.getByText('Now: Jan 1, 2021, 1:00 AM'); + expect(onError.mock.calls.length).toBe(1); +}); + +it('does not merge messages in nested providers', () => { + // This is important because the locale can change + // and the messages from a previous locale should + // not leak into the new locale. + + const onError = vi.fn(); + + function Component() { + const t = useTranslations(); + return t('hello'); + } + + render( + + + + + + ); + + expect(onError.mock.calls.length).toBe(1); +}); + +it('can opt-out of messages inheritance', () => { + const onError = vi.fn(); + + function Component() { + const t = useTranslations(); + return {t('hello')}; + } + + render( + + + + + + + ); + + screen.getByText('Hey!'); + screen.getByText('hello'); + expect(onError.mock.calls.length).toBe(1); +}); diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index f62da4c8e..6a67d24c1 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -1,12 +1,12 @@ -import React, {ReactNode, useMemo} from 'react'; -import IntlConfig from '../core/IntlConfig'; +import {type ReactNode, useContext, useMemo} from 'react'; +import type IntlConfig from '../core/IntlConfig.js'; import { - Formatters, + type Formatters, createCache, createIntlFormatters -} from '../core/formatters'; -import initializeConfig from '../core/initializeConfig'; -import IntlContext from './IntlContext'; +} from '../core/formatters.js'; +import initializeConfig from '../core/initializeConfig.js'; +import IntlContext from './IntlContext.js'; type Props = IntlConfig & { children: ReactNode; @@ -14,7 +14,6 @@ type Props = IntlConfig & { export default function IntlProvider({ children, - defaultTranslationValues, formats, getMessageFallback, locale, @@ -23,17 +22,19 @@ export default function IntlProvider({ onError, timeZone }: Props) { + const prevContext = useContext(IntlContext); + // The formatter cache is released when the locale changes. For // long-running apps with a persistent `IntlProvider` at the root, // this can reduce the memory footprint (e.g. in React Native). const cache = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions locale; - return createCache(); - }, [locale]); + return prevContext?.cache || createCache(); + }, [locale, prevContext?.cache]); const formatters: Formatters = useMemo( - () => createIntlFormatters(cache), - [cache] + () => prevContext?.formatters || createIntlFormatters(cache), + [cache, prevContext?.formatters] ); // Memoizing this value helps to avoid triggering a re-render of all @@ -47,21 +48,20 @@ export default function IntlProvider({ const value = useMemo( () => ({ ...initializeConfig({ - locale, - defaultTranslationValues, - formats, - getMessageFallback, - messages, - now, - onError, - timeZone + locale, // (required by provider) + formats: formats === undefined ? prevContext?.formats : formats, + getMessageFallback: + getMessageFallback || prevContext?.getMessageFallback, + messages: messages === undefined ? prevContext?.messages : messages, + now: now || prevContext?.now, + onError: onError || prevContext?.onError, + timeZone: timeZone || prevContext?.timeZone }), formatters, cache }), [ cache, - defaultTranslationValues, formats, formatters, getMessageFallback, @@ -69,6 +69,7 @@ export default function IntlProvider({ messages, now, onError, + prevContext, timeZone ] ); diff --git a/packages/use-intl/src/react/index.test.tsx b/packages/use-intl/src/react/index.test.tsx index 164018819..8c211de88 100644 --- a/packages/use-intl/src/react/index.test.tsx +++ b/packages/use-intl/src/react/index.test.tsx @@ -1,11 +1,11 @@ import {render, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import React from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useFormatter from './useFormatter'; -import useNow from './useNow'; -import useTranslations from './useTranslations'; +import type {Locale} from '../core.js'; +import IntlProvider from './IntlProvider.js'; +import useFormatter from './useFormatter.js'; +import useNow from './useNow.js'; +import useTranslations from './useTranslations.js'; describe('performance', () => { beforeEach(() => { @@ -25,7 +25,7 @@ describe('performance', () => { ); } - function App({locale}: {locale: string}) { + function App({locale}: {locale: Locale}) { return ( > & {children: ReactNode} @@ -25,7 +31,7 @@ describe('dateTime', () => { function renderDateTime( value: Date | number, - options?: Parameters['dateTime']>['1'] + options?: DateTimeFormatOptions ) { function Component() { const format = useFormatter(); @@ -207,7 +213,7 @@ describe('dateTime', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.message).toBe( - 'MISSING_FORMAT: Format `onlyYear` is not available. You can configure it on the provider or provide custom options.' + 'MISSING_FORMAT: Format `onlyYear` is not available.' ); expect(error.code).toBe(IntlErrorCode.MISSING_FORMAT); expect(container.textContent).toMatch(/Nov 20 2020/); @@ -234,7 +240,7 @@ describe('dateTime', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.message).toBe( - 'MISSING_FORMAT: Format `medium` is not available. You can configure it on the provider or provide custom options.' + 'MISSING_FORMAT: Format `medium` is not available.' ); expect(error.code).toBe(IntlErrorCode.MISSING_FORMAT); expect(container.textContent).toMatch(/Nov 20 2020/); @@ -279,9 +285,7 @@ describe('dateTime', () => { ); const error: IntlError = onError.mock.calls[0][0]; - expect(error.message).toMatch( - "ENVIRONMENT_FALLBACK: The `timeZone` parameter wasn't provided and there is no global default configured." - ); + expect(error.message).toMatch(/^ENVIRONMENT_FALLBACK/); expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK); expect(container.textContent).toBe('11/20/2020'); }); @@ -289,10 +293,7 @@ describe('dateTime', () => { }); describe('number', () => { - function renderNumber( - value: number | bigint, - options?: Parameters['number']>['1'] - ) { + function renderNumber(value: number | bigint, options?: NumberFormatOptions) { function Component() { const format = useFormatter(); return <>{format.number(value, options)}; @@ -399,7 +400,7 @@ describe('number', () => { const error: IntlError = onError.mock.calls[0][0]; expect(error.message).toBe( - 'MISSING_FORMAT: Format `missing` is not available. You can configure it on the provider or provide custom options.' + 'MISSING_FORMAT: Format `missing` is not available.' ); expect(error.code).toBe(IntlErrorCode.MISSING_FORMAT); expect(container.textContent).toBe('10000'); @@ -432,13 +433,15 @@ describe('number', () => { describe('relativeTime', () => { function renderRelativeTime( date: Date | number, - nowOrOptions: Parameters< - ReturnType['relativeTime'] - >['1'] + nowOrOptions: Date | number | RelativeTimeFormatOptions ) { function Component() { const format = useFormatter(); - return <>{format.relativeTime(date, nowOrOptions)}; + if (nowOrOptions instanceof Date || typeof nowOrOptions === 'number') { + return format.relativeTime(date, nowOrOptions); + } else { + return format.relativeTime(date, nowOrOptions); + } } render( @@ -622,9 +625,7 @@ describe('relativeTime', () => { ); const error: IntlError = onError.mock.calls[0][0]; - expect(error.message).toMatch( - "ENVIRONMENT_FALLBACK: The `now` parameter wasn't provided and there is no global default configured." - ); + expect(error.message).toMatch(/^ENVIRONMENT_FALLBACK/); expect(error.code).toBe(IntlErrorCode.ENVIRONMENT_FALLBACK); }); }); @@ -633,7 +634,7 @@ describe('relativeTime', () => { describe('list', () => { function renderList( value: Iterable, - options?: Parameters['list']>['1'] + options?: Intl.ListFormatOptions ) { function Component() { const format = useFormatter(); diff --git a/packages/use-intl/src/react/useFormatter.tsx b/packages/use-intl/src/react/useFormatter.tsx index abfa58e8e..8a2221b6e 100644 --- a/packages/use-intl/src/react/useFormatter.tsx +++ b/packages/use-intl/src/react/useFormatter.tsx @@ -1,6 +1,6 @@ import {useMemo} from 'react'; -import createFormatter from '../core/createFormatter'; -import useIntlContext from './useIntlContext'; +import createFormatter from '../core/createFormatter.js'; +import useIntlContext from './useIntlContext.js'; export default function useFormatter(): ReturnType { const { diff --git a/packages/use-intl/src/react/useIntlContext.tsx b/packages/use-intl/src/react/useIntlContext.tsx index e8d3a5f2c..d588105db 100644 --- a/packages/use-intl/src/react/useIntlContext.tsx +++ b/packages/use-intl/src/react/useIntlContext.tsx @@ -1,5 +1,5 @@ import {useContext} from 'react'; -import IntlContext, {IntlContextValue} from './IntlContext'; +import IntlContext, {type IntlContextValue} from './IntlContext.js'; export default function useIntlContext(): IntlContextValue { const context = useContext(IntlContext); @@ -7,7 +7,7 @@ export default function useIntlContext(): IntlContextValue { if (!context) { throw new Error( process.env.NODE_ENV !== 'production' - ? 'No intl context found. Have you configured the provider? See https://next-intl.dev/docs/usage/configuration#client-server-components' + ? 'No intl context found. Have you configured the provider? See https://next-intl.dev/docs/usage/configuration#server-client-components' : undefined ); } diff --git a/packages/use-intl/src/react/useLocale.test.tsx b/packages/use-intl/src/react/useLocale.test.tsx index bd37a96b1..3f644b5c1 100644 --- a/packages/use-intl/src/react/useLocale.test.tsx +++ b/packages/use-intl/src/react/useLocale.test.tsx @@ -1,8 +1,7 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useLocale from './useLocale'; +import IntlProvider from './IntlProvider.js'; +import useLocale from './useLocale.js'; it('returns the current locale', () => { function Component() { diff --git a/packages/use-intl/src/react/useLocale.tsx b/packages/use-intl/src/react/useLocale.tsx index dc859f51a..bcac7e82f 100644 --- a/packages/use-intl/src/react/useLocale.tsx +++ b/packages/use-intl/src/react/useLocale.tsx @@ -1,5 +1,6 @@ -import useIntlContext from './useIntlContext'; +import type {Locale} from '../core.js'; +import useIntlContext from './useIntlContext.js'; -export default function useLocale() { +export default function useLocale(): Locale { return useIntlContext().locale; } diff --git a/packages/use-intl/src/react/useMessages.test.tsx b/packages/use-intl/src/react/useMessages.test.tsx index cab686764..9e6ac6c54 100644 --- a/packages/use-intl/src/react/useMessages.test.tsx +++ b/packages/use-intl/src/react/useMessages.test.tsx @@ -1,8 +1,7 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useMessages from './useMessages'; +import IntlProvider from './IntlProvider.js'; +import useMessages from './useMessages.js'; function Component() { const messages = useMessages(); diff --git a/packages/use-intl/src/react/useMessages.tsx b/packages/use-intl/src/react/useMessages.tsx index 315583eeb..c67f3d106 100644 --- a/packages/use-intl/src/react/useMessages.tsx +++ b/packages/use-intl/src/react/useMessages.tsx @@ -1,7 +1,7 @@ -import {AbstractIntlMessages} from '../core'; -import useIntlContext from './useIntlContext'; +import type {Messages} from '../core/AppConfig.js'; +import useIntlContext from './useIntlContext.js'; -export default function useMessages(): AbstractIntlMessages { +export default function useMessages(): Messages { const context = useIntlContext(); if (!context.messages) { diff --git a/packages/use-intl/src/react/useNow.test.tsx b/packages/use-intl/src/react/useNow.test.tsx index 87995b2ff..fa88bc8df 100644 --- a/packages/use-intl/src/react/useNow.test.tsx +++ b/packages/use-intl/src/react/useNow.test.tsx @@ -1,9 +1,8 @@ import {render, waitFor} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import React from 'react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useNow from './useNow'; +import IntlProvider from './IntlProvider.js'; +import useNow from './useNow.js'; it('returns the current time', () => { function Component() { diff --git a/packages/use-intl/src/react/useNow.tsx b/packages/use-intl/src/react/useNow.tsx index 3b4929f4c..efa17a754 100644 --- a/packages/use-intl/src/react/useNow.tsx +++ b/packages/use-intl/src/react/useNow.tsx @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import useIntlContext from './useIntlContext'; +import useIntlContext from './useIntlContext.js'; type Options = { updateInterval?: number; @@ -10,22 +10,7 @@ function getNow() { } /** - * Reading the current date via `new Date()` in components should be avoided, as - * it causes components to be impure and can lead to flaky tests. Instead, this - * hook can be used. - * - * By default, it returns the time when the component mounts. If `updateInterval` - * is specified, the value will be updated based on the interval. - * - * You can however also return a static value from this hook, if you - * configure the `now` parameter on the context provider. Note however, - * that if `updateInterval` is configured in this case, the component - * will initialize with the global value, but will afterwards update - * continuously based on the interval. - * - * For unit tests, this can be mocked to a constant value. For end-to-end - * testing, an environment parameter can be passed to the `now` parameter - * of the provider to mock this to a static value. + * @see https://next-intl.dev/docs/usage/dates-times#relative-times-usenow */ export default function useNow(options?: Options) { const updateInterval = options?.updateInterval; diff --git a/packages/use-intl/src/react/useTimeZone.test.tsx b/packages/use-intl/src/react/useTimeZone.test.tsx index b2ed1f4dc..8a7319dff 100644 --- a/packages/use-intl/src/react/useTimeZone.test.tsx +++ b/packages/use-intl/src/react/useTimeZone.test.tsx @@ -1,8 +1,7 @@ import {render, screen} from '@testing-library/react'; -import React from 'react'; import {expect, it} from 'vitest'; -import IntlProvider from './IntlProvider'; -import useTimeZone from './useTimeZone'; +import IntlProvider from './IntlProvider.js'; +import useTimeZone from './useTimeZone.js'; it('returns the time zone when it is configured', () => { function Component() { diff --git a/packages/use-intl/src/react/useTimeZone.tsx b/packages/use-intl/src/react/useTimeZone.tsx index 180901f64..6709deee5 100644 --- a/packages/use-intl/src/react/useTimeZone.tsx +++ b/packages/use-intl/src/react/useTimeZone.tsx @@ -1,4 +1,4 @@ -import useIntlContext from './useIntlContext'; +import useIntlContext from './useIntlContext.js'; export default function useTimeZone() { return useIntlContext().timeZone; diff --git a/packages/use-intl/src/react/useTranslations.test.tsx b/packages/use-intl/src/react/useTranslations.test.tsx index ded90ee04..ab1a0b65b 100644 --- a/packages/use-intl/src/react/useTranslations.test.tsx +++ b/packages/use-intl/src/react/useTranslations.test.tsx @@ -1,27 +1,27 @@ import {render, renderHook, screen} from '@testing-library/react'; import {parseISO} from 'date-fns'; -import IntlMessageFormat from 'intl-messageformat'; -import React, {ComponentProps, PropsWithChildren, ReactNode} from 'react'; +import {IntlMessageFormat} from 'intl-messageformat'; +import type {ComponentProps, PropsWithChildren, ReactNode} from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import { - Formats, - IntlError, + type Formats, + type IntlError, IntlErrorCode, - RichTranslationValues, - TranslationValues -} from '../core'; -import IntlProvider from './IntlProvider'; -import useTranslations from './useTranslations'; + type RichTranslationValues, + type TranslationValues +} from '../core.js'; +import IntlProvider from './IntlProvider.js'; +import useTranslations from './useTranslations.js'; // Wrap the library to include a counter for parse // invocations for the cache test below. vi.mock('intl-messageformat', async (importOriginal) => { const ActualIntlMessageFormat: typeof IntlMessageFormat = ( (await importOriginal()) as any - ).default; + ).IntlMessageFormat; return { - default: class MockIntlMessageFormat extends ActualIntlMessageFormat { + IntlMessageFormat: class MockIntlMessageFormat extends ActualIntlMessageFormat { public static invocationsByMessage: Record = {}; constructor( @@ -745,13 +745,7 @@ describe('error handling', () => { const onError = vi.fn(); render( - + ); @@ -776,9 +770,7 @@ describe('error handling', () => { expect(onError).toHaveBeenCalledTimes(1); const error: IntlError = onError.mock.calls[0][0]; expect(error.code).toBe(IntlErrorCode.MISSING_MESSAGE); - expect(error.message).toBe( - 'MISSING_MESSAGE: No messages were configured on the provider.' - ); + expect(error.message).toBe('MISSING_MESSAGE: No messages were configured.'); screen.getByText('Component.test'); }); @@ -880,7 +872,6 @@ describe('error handling', () => { render( @@ -906,7 +897,6 @@ describe('error handling', () => { render( @@ -989,89 +979,6 @@ describe('global formats', () => { }); }); -describe('default translation values', () => { - function renderRichTextMessageWithDefault( - message: string, - values?: RichTranslationValues, - formats?: Formats - ) { - function Component() { - const t = useTranslations(); - return <>{t.rich('message', values, formats)}; - } - - return render( - {children} - }} - formats={{dateTime: {time: {hour: 'numeric', minute: '2-digit'}}}} - locale="en" - messages={{message}} - timeZone="Europe/London" - > - - - ); - } - - function renderMessageWithDefault( - message: string, - values?: TranslationValues, - formats?: Formats - ) { - function Component() { - const t = useTranslations(); - return <>{t('message', values, formats)}; - } - - return render( - - - - ); - } - - it('uses default rich text element', () => { - const {container} = renderRichTextMessageWithDefault( - 'This is important and this as well' - ); - expect(container.innerHTML).toBe( - 'This is important and this as well' - ); - }); - - it('overrides default rich text element', () => { - const {container} = renderRichTextMessageWithDefault( - 'This is important and this as well', - { - important: (children) => {children} - } - ); - expect(container.innerHTML).toBe( - 'This is important and this as well' - ); - }); - - it('uses default translation values', () => { - renderMessageWithDefault('Hello {value}'); - screen.getByText('Hello 123'); - }); - - it('overrides default translation values', () => { - renderMessageWithDefault('Hello {value}', {value: 234}); - screen.getByText('Hello 234'); - }); -}); - describe('performance', () => { const MockIntlMessageFormat: typeof IntlMessageFormat & { invocationsByMessage: Record; @@ -1081,10 +988,10 @@ describe('performance', () => { vi.mock('intl-messageformat', async (original) => { const ActualIntlMessageFormat: typeof IntlMessageFormat = ( (await original()) as any - ).default; + ).IntlMessageFormat; return { - default: class MockIntlMessageFormatImpl extends ActualIntlMessageFormat { + IntlMessageFormat: class MockIntlMessageFormatImpl extends ActualIntlMessageFormat { public static invocationsByMessage: Record = {}; constructor( diff --git a/packages/use-intl/src/react/useTranslations.tsx b/packages/use-intl/src/react/useTranslations.tsx index 8bcc71f18..b1f9860c0 100644 --- a/packages/use-intl/src/react/useTranslations.tsx +++ b/packages/use-intl/src/react/useTranslations.tsx @@ -1,15 +1,8 @@ -import {ReactNode} from 'react'; -import Formats from '../core/Formats'; -import TranslationValues, { - MarkupTranslationValues, - RichTranslationValues -} from '../core/TranslationValues'; -import MessageKeys from '../core/utils/MessageKeys'; -import NamespaceKeys from '../core/utils/NamespaceKeys'; -import NestedKeyOf from '../core/utils/NestedKeyOf'; -import NestedValueOf from '../core/utils/NestedValueOf'; -import useIntlContext from './useIntlContext'; -import useTranslationsImpl from './useTranslationsImpl'; +import type {Messages} from '../core/AppConfig.js'; +import type {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.js'; +import type createTranslator from '../core/createTranslator.js'; +import useIntlContext from './useIntlContext.js'; +import useTranslationsImpl from './useTranslationsImpl.js'; /** * Translates messages from the given namespace by using the ICU syntax. @@ -20,118 +13,19 @@ import useTranslationsImpl from './useTranslationsImpl'; * (e.g. `namespace.Component`). */ export default function useTranslations< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never + NestedKey extends NamespaceKeys> = never >( namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': IntlMessages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): boolean; -} { +): ReturnType> { const context = useIntlContext(); - const messages = context.messages as IntlMessages; + const messages = context.messages as Messages; // We have to wrap the actual hook so the type inference for the optional // namespace works correctly. See https://stackoverflow.com/a/71529575/343045 // The prefix ("!") is arbitrary. + // @ts-expect-error Use the explicit annotation instead return useTranslationsImpl< - {'!': IntlMessages}, + {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` >( {'!': messages}, diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index d79fc8302..bd190d0f0 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -1,10 +1,10 @@ import {useMemo} from 'react'; -import {IntlError, IntlErrorCode} from '../core'; -import AbstractIntlMessages from '../core/AbstractIntlMessages'; -import createBaseTranslator from '../core/createBaseTranslator'; -import resolveNamespace from '../core/resolveNamespace'; -import NestedKeyOf from '../core/utils/NestedKeyOf'; -import useIntlContext from './useIntlContext'; +import type AbstractIntlMessages from '../core/AbstractIntlMessages.js'; +import type {NestedKeyOf} from '../core/MessageKeys.js'; +import createBaseTranslator from '../core/createBaseTranslator.js'; +import resolveNamespace from '../core/resolveNamespace.js'; +import {IntlError, IntlErrorCode} from '../core.js'; +import useIntlContext from './useIntlContext.js'; let hasWarnedForMissingTimezone = false; const isServer = typeof window === 'undefined'; @@ -19,7 +19,6 @@ export default function useTranslationsImpl< ) { const { cache, - defaultTranslationValues, formats: globalFormats, formatters, getMessageFallback, @@ -56,7 +55,6 @@ export default function useTranslationsImpl< formatters, getMessageFallback, messages: allMessages, - defaultTranslationValues, namespace, onError, formats: globalFormats, @@ -68,7 +66,6 @@ export default function useTranslationsImpl< formatters, getMessageFallback, allMessages, - defaultTranslationValues, namespace, onError, globalFormats, diff --git a/packages/use-intl/tsconfig.build.json b/packages/use-intl/tsconfig.build.json new file mode 100644 index 000000000..407c2ebcb --- /dev/null +++ b/packages/use-intl/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.tsx", "test"], + "compilerOptions": { + "rootDir": "src", + "noEmit": false, + "outDir": "dist/types", + "emitDeclarationOnly": true + } +} diff --git a/packages/use-intl/tsconfig.json b/packages/use-intl/tsconfig.json index 218e76ddf..29d340f67 100644 --- a/packages/use-intl/tsconfig.json +++ b/packages/use-intl/tsconfig.json @@ -1,24 +1,15 @@ { + "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "types"], "compilerOptions": { - "module": "esnext", "lib": ["dom", "esnext"], "target": "ES2020", - "importHelpers": false, "declaration": true, - "sourceMap": true, "rootDir": ".", - "strict": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "moduleResolution": "node", - "jsx": "react", - "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noEmit": false, - "outDir": "dist" + "noEmit": true } } diff --git a/packages/use-intl/types/index.d.ts b/packages/use-intl/types/index.d.ts index d21023b4e..a6c395566 100644 --- a/packages/use-intl/types/index.d.ts +++ b/packages/use-intl/types/index.d.ts @@ -1,17 +1,10 @@ -// This type is intended to be overridden -// by the consumer for optional type safety of messages -declare interface IntlMessages extends Record {} - -// This type is intended to be overridden -// by the consumer for optional type safety of formats -declare interface IntlFormats { - dateTime: any; - number: any; - list: any; +declare namespace NodeJS { + interface ProcessEnv { + NODE_ENV: 'development' | 'production'; + } } // Temporarly copied here until the "es2020.intl" lib is published. - declare namespace Intl { /** * [BCP 47 language tag](http://tools.ietf.org/html/rfc5646) definition. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07ecfa6f5..c0dcff6dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,48 +11,15 @@ importers: .: devDependencies: - '@babel/core': - specifier: ^7.24.7 - version: 7.25.9 - '@babel/preset-env': - specifier: ^7.24.7 - version: 7.25.9(@babel/core@7.25.9) - '@babel/preset-react': - specifier: ^7.24.7 - version: 7.25.9(@babel/core@7.25.9) - '@babel/preset-typescript': - specifier: ^7.24.7 - version: 7.25.9(@babel/core@7.25.9) '@lerna-lite/cli': specifier: ^3.9.0 version: 3.10.0(@lerna-lite/publish@3.10.0(@types/node@22.10.9)(typescript@5.6.3))(@lerna-lite/version@3.10.0(@lerna-lite/publish@3.10.0(@types/node@22.10.9)(typescript@5.6.3))(@types/node@22.10.9)(typescript@5.6.3))(@types/node@22.10.9)(typescript@5.6.3) '@lerna-lite/publish': specifier: ^3.9.0 version: 3.10.0(@types/node@22.10.9)(typescript@5.6.3) - '@rollup/plugin-babel': - specifier: ^6.0.3 - version: 6.0.4(@babel/core@7.25.9)(@types/babel__core@7.20.5)(rollup@4.24.0) - '@rollup/plugin-commonjs': - specifier: ^26.0.1 - version: 26.0.3(rollup@4.24.0) - '@rollup/plugin-node-resolve': - specifier: ^15.2.1 - version: 15.3.0(rollup@4.24.0) - '@rollup/plugin-replace': - specifier: ^5.0.7 - version: 5.0.7(rollup@4.24.0) - '@rollup/plugin-terser': - specifier: ^0.4.3 - version: 0.4.4(rollup@4.24.0) conventional-changelog-conventionalcommits: specifier: ^7.0.0 version: 7.0.2 - execa: - specifier: ^9.2.0 - version: 9.4.1 - rollup: - specifier: ^4.18.0 - version: 4.24.0 turbo: specifier: ^2.2.3 version: 2.2.3 @@ -73,10 +40,10 @@ importers: version: 2.1.5(react@18.3.1) '@vercel/analytics': specifier: 1.3.1 - version: 1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@vercel/speed-insights': specifier: ^1.0.12 - version: 1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -85,13 +52,13 @@ importers: version: 2.3.0 next: specifier: ^14.2.4 - version: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nextra: specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + version: 3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) nextra-theme-docs: specifier: ^3.1.0 - version: 3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -131,7 +98,7 @@ importers: version: 15.11.0 next-sitemap: specifier: ^4.2.3 - version: 4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) next-validate-link: specifier: ^1.3.0 version: 1.3.0 @@ -308,7 +275,7 @@ importers: version: 15.2.2(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^4.24.11 - version: 4.24.11(next@15.2.2(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.11(next@15.2.2(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -677,16 +644,16 @@ importers: dependencies: next: specifier: ^12.0.0 - version: 12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + next-intl: + specifier: ^3.0.0 + version: link:../../packages/next-intl react: specifier: ^17.0.0 version: 17.0.2 react-dom: specifier: ^17.0.0 version: 17.0.2(react@17.0.2) - use-intl: - specifier: ^3.0.0 - version: link:../../packages/use-intl devDependencies: prettier: specifier: ^3.3.3 @@ -813,12 +780,12 @@ importers: version: link:../use-intl devDependencies: '@arethetypeswrong/cli': - specifier: ^0.15.3 - version: 0.15.4 + specifier: ^0.16.4 + version: 0.16.4 '@edge-runtime/vm': specifier: ^3.2.0 version: 3.2.0 - '@size-limit/preset-big-lib': + '@size-limit/preset-small-lib': specifier: ^11.1.4 version: 11.1.6(size-limit@11.1.6) '@testing-library/react': @@ -872,6 +839,9 @@ importers: size-limit: specifier: ^11.1.4 version: 11.1.6 + tools: + specifier: workspace:^ + version: link:../../tools typescript: specifier: ^5.5.3 version: 5.6.3 @@ -884,14 +854,17 @@ importers: '@formatjs/fast-memoize': specifier: ^2.2.0 version: 2.2.1 + '@schummar/icu-type-parser': + specifier: 1.21.5 + version: 1.21.5 intl-messageformat: specifier: ^10.5.14 version: 10.7.1 devDependencies: '@arethetypeswrong/cli': - specifier: ^0.15.3 - version: 0.15.4 - '@size-limit/preset-big-lib': + specifier: ^0.16.4 + version: 0.16.4 + '@size-limit/preset-small-lib': specifier: ^11.1.4 version: 11.1.6(size-limit@11.1.6) '@testing-library/react': @@ -939,6 +912,9 @@ importers: tinyspy: specifier: ^3.0.0 version: 3.0.2 + tools: + specifier: workspace:^ + version: link:../../tools typescript: specifier: ^5.5.3 version: 5.6.3 @@ -946,6 +922,48 @@ importers: specifier: ^2.0.2 version: 2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.37.0) + tools: + devDependencies: + '@babel/core': + specifier: ^7.24.7 + version: 7.25.9 + '@babel/preset-env': + specifier: ^7.24.7 + version: 7.25.9(@babel/core@7.25.9) + '@babel/preset-react': + specifier: ^7.24.7 + version: 7.25.9(@babel/core@7.25.9) + '@babel/preset-typescript': + specifier: ^7.24.7 + version: 7.25.9(@babel/core@7.25.9) + '@rollup/plugin-babel': + specifier: ^6.0.3 + version: 6.0.4(@babel/core@7.25.9)(@types/babel__core@7.20.5)(rollup@4.24.0) + '@rollup/plugin-node-resolve': + specifier: ^15.2.1 + version: 15.3.0(rollup@4.24.0) + '@rollup/plugin-replace': + specifier: ^5.0.7 + version: 5.0.7(rollup@4.24.0) + '@rollup/plugin-terser': + specifier: ^0.4.3 + version: 0.4.4(rollup@4.24.0) + eslint: + specifier: ^9.11.1 + version: 9.13.0(jiti@2.3.3) + eslint-config-molindo: + specifier: ^8.0.0 + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@22.10.9))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.10.9)(jsdom@25.0.1)(terser@5.37.0)) + execa: + specifier: ^9.2.0 + version: 9.4.1 + globals: + specifier: ^15.11.0 + version: 15.11.0 + rollup: + specifier: ^4.18.0 + version: 4.24.0 + packages: '@aashutoshrathi/word-wrap@1.2.6': @@ -1057,13 +1075,13 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - '@arethetypeswrong/cli@0.15.4': - resolution: {integrity: sha512-YDbImAi1MGkouT7f2yAECpUMFhhA1J0EaXzIqoC5GGtK0xDgauLtcsZezm8tNq7d3wOFXH7OnY+IORYcG212rw==} + '@arethetypeswrong/cli@0.16.4': + resolution: {integrity: sha512-qMmdVlJon5FtA+ahn0c1oAVNxiq4xW5lqFiTZ21XHIeVwAVIQ+uRz4UEivqRMsjVV1grzRgJSKqaOrq1MvlVyQ==} engines: {node: '>=18'} hasBin: true - '@arethetypeswrong/core@0.15.1': - resolution: {integrity: sha512-FYp6GBAgsNz81BkfItRz8RLZO03w5+BaeiPma1uCfmxTnxbtuMrI/dbzGiOk8VghO108uFI0oJo0OkewdSHw7g==} + '@arethetypeswrong/core@0.16.4': + resolution: {integrity: sha512-RI3HXgSuKTfcBf1hSEg1P9/cOvmI0flsMm6/QL3L3wju4AlHDqd55JFPfXs4pzgEAgy5L9pul4/HPPz99x2GvA==} engines: {node: '>=18'} '@asamuzakjp/css-color@2.8.3': @@ -1104,10 +1122,6 @@ packages: resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.24.7': - resolution: {integrity: sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==} - engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.25.9': resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} @@ -1126,12 +1140,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.24.7': - resolution: {integrity: sha512-03TCmXy2FtXJEZfbXDTSqq1fRJArk7lX9DOFC/47VthYcxyIOx+eXQmdo6DOQvrbpIix+KfXwvuXdFDZHxt+rA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-create-regexp-features-plugin@7.25.9': resolution: {integrity: sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==} engines: {node: '>=6.9.0'} @@ -1159,10 +1167,6 @@ packages: resolution: {integrity: sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.22.5': - resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.25.9': resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} engines: {node: '>=6.9.0'} @@ -1923,9 +1927,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/regjsgen@0.8.0': - resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==} - '@babel/runtime@7.24.7': resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} engines: {node: '>=6.9.0'} @@ -2074,6 +2075,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.0': + resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.24.2': resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==} engines: {node: '>=18'} @@ -2098,6 +2105,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.24.0': + resolution: {integrity: sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.24.2': resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==} engines: {node: '>=18'} @@ -2122,6 +2135,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.24.0': + resolution: {integrity: sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.24.2': resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==} engines: {node: '>=18'} @@ -2146,6 +2165,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.24.0': + resolution: {integrity: sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.24.2': resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==} engines: {node: '>=18'} @@ -2170,6 +2195,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.24.0': + resolution: {integrity: sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.24.2': resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==} engines: {node: '>=18'} @@ -2194,6 +2225,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.24.0': + resolution: {integrity: sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.24.2': resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==} engines: {node: '>=18'} @@ -2218,6 +2255,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.24.0': + resolution: {integrity: sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.24.2': resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==} engines: {node: '>=18'} @@ -2242,6 +2285,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.24.0': + resolution: {integrity: sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.24.2': resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==} engines: {node: '>=18'} @@ -2266,6 +2315,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.24.0': + resolution: {integrity: sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.24.2': resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==} engines: {node: '>=18'} @@ -2290,6 +2345,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.24.0': + resolution: {integrity: sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.24.2': resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==} engines: {node: '>=18'} @@ -2314,6 +2375,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.24.0': + resolution: {integrity: sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.24.2': resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==} engines: {node: '>=18'} @@ -2338,6 +2405,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.24.0': + resolution: {integrity: sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.24.2': resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==} engines: {node: '>=18'} @@ -2362,6 +2435,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.24.0': + resolution: {integrity: sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.24.2': resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==} engines: {node: '>=18'} @@ -2386,6 +2465,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.24.0': + resolution: {integrity: sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.24.2': resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==} engines: {node: '>=18'} @@ -2410,6 +2495,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.24.0': + resolution: {integrity: sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.24.2': resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==} engines: {node: '>=18'} @@ -2434,6 +2525,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.24.0': + resolution: {integrity: sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.24.2': resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==} engines: {node: '>=18'} @@ -2458,6 +2555,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.24.0': + resolution: {integrity: sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.24.2': resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==} engines: {node: '>=18'} @@ -2488,12 +2591,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.24.0': + resolution: {integrity: sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.24.2': resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.24.0': + resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.24.2': resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==} engines: {node: '>=18'} @@ -2518,6 +2633,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.24.0': + resolution: {integrity: sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.24.2': resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==} engines: {node: '>=18'} @@ -2542,6 +2663,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.24.0': + resolution: {integrity: sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.24.2': resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==} engines: {node: '>=18'} @@ -2566,6 +2693,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.24.0': + resolution: {integrity: sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.24.2': resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==} engines: {node: '>=18'} @@ -2590,6 +2723,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.24.0': + resolution: {integrity: sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.24.2': resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==} engines: {node: '>=18'} @@ -2614,6 +2753,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.0': + resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.24.2': resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==} engines: {node: '>=18'} @@ -3113,9 +3258,6 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.5': - resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} - '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} @@ -3710,16 +3852,6 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} - '@puppeteer/browsers@2.2.2': - resolution: {integrity: sha512-hZ/JhxPIceWaGSEzUZp83/8M49CoxlkuThfTR7t4AoCu5+ZvJ3vktLm60Otww2TXeROB5igiZ8D9oPQh6ckBVg==} - engines: {node: '>=18'} - hasBin: true - - '@puppeteer/browsers@2.4.0': - resolution: {integrity: sha512-x8J1csfIygOwf6D6qUAZ0ASk3z63zPb7wkNeHRerCMh82qWKUrOgkuP005AJC8lDL6/evtXETGEJVcwykKT4/g==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/number@1.1.0': resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} @@ -4202,15 +4334,6 @@ packages: rollup: optional: true - '@rollup/plugin-commonjs@26.0.3': - resolution: {integrity: sha512-2BJcolt43MY+y5Tz47djHkodCC3c1VKVrBDKpVqHKpQ9z9S158kCCqB8NF6/gzxLdNlYW9abB3Ibh+kOWLp8KQ==} - engines: {node: '>=16.0.0 || 14 >= 14.17'} - peerDependencies: - rollup: ^2.68.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/plugin-node-resolve@15.3.0': resolution: {integrity: sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==} engines: {node: '>=14.0.0'} @@ -4238,15 +4361,6 @@ packages: rollup: optional: true - '@rollup/pluginutils@5.0.5': - resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/pluginutils@5.1.0': resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} engines: {node: '>=14.0.0'} @@ -4342,6 +4456,9 @@ packages: '@rushstack/eslint-patch@1.10.5': resolution: {integrity: sha512-kkKUDVlII2DQiKy7UstOR1ErJP8kUKAQ4oa+SQtM0K+lPdmmjj0YnnxBgtTVYH7mUKtbsxeFC9y0AmK7Yb78/A==} + '@schummar/icu-type-parser@1.21.5': + resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -4416,30 +4533,20 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@sitespeed.io/tracium@0.3.3': - resolution: {integrity: sha512-dNZafjM93Y+F+sfwTO5gTpsGXlnc/0Q+c2+62ViqP3gkMWvHEMSKkaEHgVJLcLg3i/g19GSIPziiKpgyne07Bw==} - engines: {node: '>=8'} - - '@size-limit/file@11.1.6': - resolution: {integrity: sha512-ojzzJMrTfcSECRnaTjGy0wNIolTCRdyqZTSWG9sG5XEoXG6PNgHXDDS6gf6YNxnqb+rWfCfVe93u6aKi3wEocQ==} + '@size-limit/esbuild@11.1.6': + resolution: {integrity: sha512-0nBKYSxeRjUVCVoCkWZbmGkGBwpm0HdwHedWgxksBGxTKU0PjOMSHc3XTjKOrXBKXQzw90Ue0mgOd4n6zct9SA==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: size-limit: 11.1.6 - '@size-limit/preset-big-lib@11.1.6': - resolution: {integrity: sha512-GE93qIW9C3+8MXOsYgV0QcLfKv6B+Q8u/Jjb5rLfetDHBKoZV7HmedM/bv0vrbdcZlT8elk5P18Jo6L6yeV/8Q==} - peerDependencies: - size-limit: 11.1.6 - - '@size-limit/time@11.1.6': - resolution: {integrity: sha512-NIlJEPvUIxw87gHjriHpPhvd9fIC94S9wq7OW25K7Ctn14FZ2NlOTezPCfVViPmdlXjBYdi8vjsbc7kLCF1EpA==} + '@size-limit/file@11.1.6': + resolution: {integrity: sha512-ojzzJMrTfcSECRnaTjGy0wNIolTCRdyqZTSWG9sG5XEoXG6PNgHXDDS6gf6YNxnqb+rWfCfVe93u6aKi3wEocQ==} engines: {node: ^18.0.0 || >=20.0.0} peerDependencies: size-limit: 11.1.6 - '@size-limit/webpack@11.1.6': - resolution: {integrity: sha512-PTZCgwJsgdzdEj2wPFuLm0cCge8N2WbswMcKWNwMJibxQxPAmiF+sZ2F6GYBS7G7K3Fb4ovCliuN+wnnRACPNg==} - engines: {node: ^18.0.0 || >=20.0.0} + '@size-limit/preset-small-lib@11.1.6': + resolution: {integrity: sha512-hlmkBlOryJIsKlGpS61Ti7/EEZomygAzOabpo2htdxUbkCkvtVoUQpGWHUfWuxdhheDVF6rtZZ6lPGftMKlaQg==} peerDependencies: size-limit: 11.1.6 @@ -4622,9 +4729,6 @@ packages: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@tufjs/canonical-json@2.0.0': resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==} engines: {node: ^16.14.0 || >=18.0.0} @@ -4882,9 +4986,6 @@ packages: '@types/yargs@17.0.32': resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.11.0': resolution: {integrity: sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5133,36 +5234,24 @@ packages: '@web3-storage/multipart-parser@1.0.0': resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} - '@webassemblyjs/ast@1.12.1': - resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} - '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} '@webassemblyjs/ast@1.9.0': resolution: {integrity: sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==} - '@webassemblyjs/floating-point-hex-parser@1.11.6': - resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} - '@webassemblyjs/floating-point-hex-parser@1.13.2': resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} '@webassemblyjs/floating-point-hex-parser@1.9.0': resolution: {integrity: sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==} - '@webassemblyjs/helper-api-error@1.11.6': - resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} - '@webassemblyjs/helper-api-error@1.13.2': resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} '@webassemblyjs/helper-api-error@1.9.0': resolution: {integrity: sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==} - '@webassemblyjs/helper-buffer@1.12.1': - resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} - '@webassemblyjs/helper-buffer@1.14.1': resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} @@ -5178,87 +5267,57 @@ packages: '@webassemblyjs/helper-module-context@1.9.0': resolution: {integrity: sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==} - '@webassemblyjs/helper-numbers@1.11.6': - resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} - '@webassemblyjs/helper-numbers@1.13.2': resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - '@webassemblyjs/helper-wasm-bytecode@1.11.6': - resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} - '@webassemblyjs/helper-wasm-bytecode@1.13.2': resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} '@webassemblyjs/helper-wasm-bytecode@1.9.0': resolution: {integrity: sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==} - '@webassemblyjs/helper-wasm-section@1.12.1': - resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} - '@webassemblyjs/helper-wasm-section@1.14.1': resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} '@webassemblyjs/helper-wasm-section@1.9.0': resolution: {integrity: sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==} - '@webassemblyjs/ieee754@1.11.6': - resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} - '@webassemblyjs/ieee754@1.13.2': resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} '@webassemblyjs/ieee754@1.9.0': resolution: {integrity: sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==} - '@webassemblyjs/leb128@1.11.6': - resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} - '@webassemblyjs/leb128@1.13.2': resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} '@webassemblyjs/leb128@1.9.0': resolution: {integrity: sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==} - '@webassemblyjs/utf8@1.11.6': - resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} - '@webassemblyjs/utf8@1.13.2': resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} '@webassemblyjs/utf8@1.9.0': resolution: {integrity: sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==} - '@webassemblyjs/wasm-edit@1.12.1': - resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} - '@webassemblyjs/wasm-edit@1.14.1': resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} '@webassemblyjs/wasm-edit@1.9.0': resolution: {integrity: sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==} - '@webassemblyjs/wasm-gen@1.12.1': - resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} - '@webassemblyjs/wasm-gen@1.14.1': resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} '@webassemblyjs/wasm-gen@1.9.0': resolution: {integrity: sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==} - '@webassemblyjs/wasm-opt@1.12.1': - resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} - '@webassemblyjs/wasm-opt@1.14.1': resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} '@webassemblyjs/wasm-opt@1.9.0': resolution: {integrity: sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==} - '@webassemblyjs/wasm-parser@1.12.1': - resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} - '@webassemblyjs/wasm-parser@1.14.1': resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} @@ -5268,9 +5327,6 @@ packages: '@webassemblyjs/wast-parser@1.9.0': resolution: {integrity: sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==} - '@webassemblyjs/wast-printer@1.12.1': - resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} - '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} @@ -5324,11 +5380,6 @@ packages: acorn-globals@7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} - acorn-import-attributes@1.9.5: - resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} - peerDependencies: - acorn: ^8 - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -5649,10 +5700,6 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-types@0.15.2: resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} engines: {node: '>=4'} @@ -5712,9 +5759,6 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - b4a@1.6.7: - resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} - babel-core@7.0.0-bridge.0: resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} peerDependencies: @@ -5803,21 +5847,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bare-events@2.5.0: - resolution: {integrity: sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==} - - bare-fs@2.3.5: - resolution: {integrity: sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==} - - bare-os@2.4.4: - resolution: {integrity: sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==} - - bare-path@2.1.3: - resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} - - bare-stream@2.3.0: - resolution: {integrity: sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -5829,10 +5858,6 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} - basic-ftp@5.0.5: - resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} - engines: {node: '>=10.0.0'} - batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -5919,10 +5944,6 @@ packages: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} engines: {node: '>=0.10.0'} - braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -5983,9 +6004,6 @@ packages: buffer-alloc@1.2.0: resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-fill@1.0.0: resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} @@ -6197,10 +6215,6 @@ packages: chokidar@2.1.8: resolution: {integrity: sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==} - chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -6220,11 +6234,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - chromium-bidi@0.5.17: - resolution: {integrity: sha512-BqOuIWUgTPj8ayuBFJUYCCuwIcwjBsb3/614P7tt1bEPJ4i1M0kCdIl0Wi9xhtswBXnfO2bTpTMkHD71H8rJMg==} - peerDependencies: - devtools-protocol: '*' - ci-info@2.0.0: resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} @@ -6406,10 +6415,6 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@2.13.0: resolution: {integrity: sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA==} @@ -6961,10 +6966,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} @@ -7010,15 +7011,6 @@ packages: supports-color: optional: true - debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.5: resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} engines: {node: '>=6.0'} @@ -7152,10 +7144,6 @@ packages: resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==} engines: {node: '>=0.10.0'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - del@4.1.1: resolution: {integrity: sha512-QwGuEUouP2kVwQenAsOof5Fv8K9t3D8Ca8NxcXKrIpEHjTXK5J2nXLdP+ALI1cgv8wj7KuwBhTwBkOZSJKM5XQ==} engines: {node: '>=6'} @@ -7219,9 +7207,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1262051: - resolution: {integrity: sha512-YJe4CT5SA8on3Spa+UDtNhEqtuV6Epwz3OZ4HQVLhlRccpZ9/PAYk0/cy/oKxFKRrZPBUPyxympQci4yWNWZ9g==} - didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -7483,9 +7468,6 @@ packages: es-module-lexer@1.5.3: resolution: {integrity: sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg==} - es-module-lexer@1.5.4: - resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} - es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} @@ -7540,6 +7522,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.24.0: + resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.24.2: resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==} engines: {node: '>=18'} @@ -7759,11 +7746,6 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estimo@3.0.3: - resolution: {integrity: sha512-qSibrDHo82yvmgeOW7onGgeOzS/nnqa8r2exQ8LyTSH8rAma10VBJE+hPSdukV1nQrqFvEz7BVe5puUK2LZJXg==} - engines: {node: '>=18'} - hasBin: true - estraverse@4.3.0: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} @@ -7976,17 +7958,9 @@ packages: resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} engines: {node: '>=0.10.0'} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -8040,9 +8014,6 @@ packages: fbjs@3.0.4: resolution: {integrity: sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.4.0: resolution: {integrity: sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==} peerDependencies: @@ -8090,10 +8061,6 @@ packages: resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} engines: {node: '>=0.10.0'} - fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -8126,10 +8093,6 @@ packages: resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} engines: {node: '>=14.16'} - find-chrome-bin@2.0.2: - resolution: {integrity: sha512-KlggCilbbvgETk/WEq9NG894U8yu4erIW0SjMm1sMPm2xihCHeNoybpzGoxEzHRthwF3XrKOgHYtfqgJzpCH2w==} - engines: {node: '>=18.0.0'} - find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} @@ -8277,10 +8240,6 @@ packages: resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} engines: {node: '>=14.14'} - fs-extra@11.3.0: - resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} - engines: {node: '>=14.14'} - fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -8401,10 +8360,6 @@ packages: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -8424,10 +8379,6 @@ packages: get-tsconfig@4.7.5: resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} - get-uri@6.0.3: - resolution: {integrity: sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==} - engines: {node: '>= 14'} - get-value@2.0.6: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} engines: {node: '>=0.10.0'} @@ -9156,9 +9107,6 @@ packages: is-color-stop@1.1.0: resolution: {integrity: sha512-H1U8Vz0cfXNujrJzEcvvwMDW9Ra+biSYA3ThdQvAnMLJkEHQXn6bWzLkxHtVYJ+Sdbx0b6finn3jZiaVe7MAHA==} - is-core-module@2.12.0: - resolution: {integrity: sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==} - is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} @@ -9349,9 +9297,6 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-reference@1.2.1: - resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} - is-reference@3.0.2: resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} @@ -10692,9 +10637,6 @@ packages: resolution: {integrity: sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==} engines: {node: '>=4.0.0'} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mixin-deep@1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} @@ -10823,10 +10765,6 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - new-github-release-url@2.0.0: resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -11390,14 +11328,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pac-proxy-agent@7.0.2: - resolution: {integrity: sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} @@ -11587,9 +11517,6 @@ packages: peek-stream@1.1.3: resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} @@ -12102,13 +12029,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -12148,10 +12068,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@22.6.5: - resolution: {integrity: sha512-s0/5XkAWe0/dWISiljdrybjwDCHhgN31Nu/wznOZPKeikgcJtZtbvPKBz0t802XWqfSQnQDt3L6xiAE5JLlfuw==} - engines: {node: '>=18'} - pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -12160,7 +12076,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: @@ -12186,9 +12101,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - queue-tick@1.0.1: - resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} - queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} @@ -12444,10 +12356,6 @@ packages: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} - regenerate-unicode-properties@10.1.0: - resolution: {integrity: sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==} - engines: {node: '>=4'} - regenerate-unicode-properties@10.2.0: resolution: {integrity: sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==} engines: {node: '>=4'} @@ -12482,10 +12390,6 @@ packages: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} - regexpu-core@5.3.2: - resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} - engines: {node: '>=4'} - regexpu-core@6.1.1: resolution: {integrity: sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==} engines: {node: '>=4'} @@ -12508,10 +12412,6 @@ packages: resolution: {integrity: sha512-1DHODs4B8p/mQHU9kr+jv8+wIC9mtG4eBHxWxIq5mhjE3D5oORhCc6deRKzTjs9DcfRFmj9BHSDguZklqCGFWQ==} hasBin: true - regjsparser@0.9.1: - resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} - hasBin: true - rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} @@ -12668,10 +12568,6 @@ packages: engines: {node: '>= 0.4'} hasBin: true - resolve@1.22.2: - resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} - hasBin: true - resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -12917,11 +12813,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.6.0: - resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} - engines: {node: '>=10'} - hasBin: true - semver@7.6.2: resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} engines: {node: '>=10'} @@ -12951,9 +12842,6 @@ packages: serialize-javascript@4.0.0: resolution: {integrity: sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==} - serialize-javascript@6.0.1: - resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} - serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -13314,9 +13202,6 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - streamx@2.20.1: - resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==} - string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -13592,19 +13477,10 @@ packages: tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} - tar-fs@3.0.5: - resolution: {integrity: sha512-JOgGAmZyMgbqpLwct7ZV8VzkEB6pxXFBVErLtb+XCOqzc6w1xiWKI9GVd6bwk68EX7eJ4DWmfXVmq8K2ziZTGg==} - - tar-fs@3.0.6: - resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} - tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -13653,22 +13529,6 @@ packages: peerDependencies: webpack: ^4.0.0 || ^5.0.0 - terser-webpack-plugin@5.3.10: - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - terser-webpack-plugin@5.3.11: resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} engines: {node: '>= 10.13.0'} @@ -13690,11 +13550,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - terser@5.18.2: - resolution: {integrity: sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w==} - engines: {node: '>=10'} - hasBin: true - terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} engines: {node: '>=10'} @@ -13709,9 +13564,6 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - text-decoder@1.2.0: - resolution: {integrity: sha512-n1yg1mOj9DNpk3NeZOx7T6jchTbyJS3i3cucbNN6FcdPriMZx7NsgrGpWWdWZZGxD7ES1XB+3uoqHMgOKaN+fg==} - text-extensions@2.4.0: resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} engines: {node: '>=8'} @@ -13890,9 +13742,6 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} - ts-expose-internals-conditionally@1.0.0-empty.0: - resolution: {integrity: sha512-F8m9NOF6ZhdOClDVdlM8gj3fDCav4ZIFSs/EI3ksQbAAXVSCN/Jh5OCJDDZWBuBy9psFc6jULGDlPwjMYMhJDw==} - ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -14065,8 +13914,8 @@ packages: typescript: optional: true - typescript@5.3.3: - resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} engines: {node: '>=14.17'} hasBin: true @@ -14098,9 +13947,6 @@ packages: unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} - unbzip2-stream@1.4.3: - resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -14123,10 +13969,6 @@ packages: resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} engines: {node: '>=4'} - unicode-match-property-value-ecmascript@2.1.0: - resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==} - engines: {node: '>=4'} - unicode-match-property-value-ecmascript@2.2.0: resolution: {integrity: sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==} engines: {node: '>=4'} @@ -14327,9 +14169,6 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} - urlpattern-polyfill@10.0.0: - resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} - use-callback-ref@1.3.2: resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} engines: {node: '>=10'} @@ -14739,16 +14578,6 @@ packages: webpack-command: optional: true - webpack@5.95.0: - resolution: {integrity: sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - webpack@5.97.1: resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} engines: {node: '>=10.13.0'} @@ -14925,18 +14754,6 @@ packages: utf-8-validate: optional: true - ws@8.16.0: - resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.17.1: resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} engines: {node: '>=10.0.0'} @@ -15060,9 +14877,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -15085,9 +14899,6 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} - zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -15241,9 +15052,9 @@ snapshots: '@antfu/utils@0.7.10': {} - '@arethetypeswrong/cli@0.15.4': + '@arethetypeswrong/cli@0.16.4': dependencies: - '@arethetypeswrong/core': 0.15.1 + '@arethetypeswrong/core': 0.16.4 chalk: 4.1.2 cli-table3: 0.6.5 commander: 10.0.1 @@ -15251,13 +15062,14 @@ snapshots: marked-terminal: 7.1.0(marked@9.1.6) semver: 7.6.3 - '@arethetypeswrong/core@0.15.1': + '@arethetypeswrong/core@0.16.4': dependencies: '@andrewbranch/untar.js': 1.0.3 + cjs-module-lexer: 1.4.1 fflate: 0.8.2 + lru-cache: 10.4.3 semver: 7.6.3 - ts-expose-internals-conditionally: 1.0.0-empty.0 - typescript: 5.3.3 + typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 '@asamuzakjp/css-color@2.8.3': @@ -15349,10 +15161,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.24.7': - dependencies: - '@babel/types': 7.25.9 - '@babel/helper-annotate-as-pure@7.25.9': dependencies: '@babel/types': 7.25.9 @@ -15385,13 +15193,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.24.7(@babel/core@7.25.9)': - dependencies: - '@babel/core': 7.25.9 - '@babel/helper-annotate-as-pure': 7.24.7 - regexpu-core: 5.3.2 - semver: 6.3.1 - '@babel/helper-create-regexp-features-plugin@7.25.9(@babel/core@7.25.9)': dependencies: '@babel/core': 7.25.9 @@ -15430,10 +15231,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.22.5': - dependencies: - '@babel/types': 7.25.9 - '@babel/helper-module-imports@7.25.9': dependencies: '@babel/traverse': 7.25.9 @@ -15786,7 +15583,7 @@ snapshots: '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.25.9)': dependencies: '@babel/core': 7.25.9 - '@babel/helper-create-regexp-features-plugin': 7.24.7(@babel/core@7.25.9) + '@babel/helper-create-regexp-features-plugin': 7.25.9(@babel/core@7.25.9) '@babel/helper-plugin-utils': 7.25.9 '@babel/plugin-transform-arrow-functions@7.25.9(@babel/core@7.25.9)': @@ -16344,8 +16141,6 @@ snapshots: pirates: 4.0.6 source-map-support: 0.5.21 - '@babel/regjsgen@0.8.0': {} - '@babel/runtime@7.24.7': dependencies: regenerator-runtime: 0.14.1 @@ -16512,6 +16307,9 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.24.0': + optional: true + '@esbuild/aix-ppc64@0.24.2': optional: true @@ -16524,6 +16322,9 @@ snapshots: '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.24.0': + optional: true + '@esbuild/android-arm64@0.24.2': optional: true @@ -16536,6 +16337,9 @@ snapshots: '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.24.0': + optional: true + '@esbuild/android-arm@0.24.2': optional: true @@ -16548,6 +16352,9 @@ snapshots: '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.24.0': + optional: true + '@esbuild/android-x64@0.24.2': optional: true @@ -16560,6 +16367,9 @@ snapshots: '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.24.0': + optional: true + '@esbuild/darwin-arm64@0.24.2': optional: true @@ -16572,6 +16382,9 @@ snapshots: '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.24.0': + optional: true + '@esbuild/darwin-x64@0.24.2': optional: true @@ -16584,6 +16397,9 @@ snapshots: '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.24.0': + optional: true + '@esbuild/freebsd-arm64@0.24.2': optional: true @@ -16596,6 +16412,9 @@ snapshots: '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.24.0': + optional: true + '@esbuild/freebsd-x64@0.24.2': optional: true @@ -16608,6 +16427,9 @@ snapshots: '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.24.0': + optional: true + '@esbuild/linux-arm64@0.24.2': optional: true @@ -16620,6 +16442,9 @@ snapshots: '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.24.0': + optional: true + '@esbuild/linux-arm@0.24.2': optional: true @@ -16632,6 +16457,9 @@ snapshots: '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.24.0': + optional: true + '@esbuild/linux-ia32@0.24.2': optional: true @@ -16644,6 +16472,9 @@ snapshots: '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.24.0': + optional: true + '@esbuild/linux-loong64@0.24.2': optional: true @@ -16656,6 +16487,9 @@ snapshots: '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.24.0': + optional: true + '@esbuild/linux-mips64el@0.24.2': optional: true @@ -16668,6 +16502,9 @@ snapshots: '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.24.0': + optional: true + '@esbuild/linux-ppc64@0.24.2': optional: true @@ -16680,6 +16517,9 @@ snapshots: '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.24.0': + optional: true + '@esbuild/linux-riscv64@0.24.2': optional: true @@ -16692,6 +16532,9 @@ snapshots: '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.24.0': + optional: true + '@esbuild/linux-s390x@0.24.2': optional: true @@ -16704,6 +16547,9 @@ snapshots: '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.24.0': + optional: true + '@esbuild/linux-x64@0.24.2': optional: true @@ -16719,9 +16565,15 @@ snapshots: '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.24.0': + optional: true + '@esbuild/netbsd-x64@0.24.2': optional: true + '@esbuild/openbsd-arm64@0.24.0': + optional: true + '@esbuild/openbsd-arm64@0.24.2': optional: true @@ -16734,6 +16586,9 @@ snapshots: '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.24.0': + optional: true + '@esbuild/openbsd-x64@0.24.2': optional: true @@ -16746,6 +16601,9 @@ snapshots: '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.24.0': + optional: true + '@esbuild/sunos-x64@0.24.2': optional: true @@ -16758,6 +16616,9 @@ snapshots: '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.24.0': + optional: true + '@esbuild/win32-arm64@0.24.2': optional: true @@ -16770,6 +16631,9 @@ snapshots: '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.24.0': + optional: true + '@esbuild/win32-ia32@0.24.2': optional: true @@ -16782,6 +16646,9 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.24.0': + optional: true + '@esbuild/win32-x64@0.24.2': optional: true @@ -17653,11 +17520,6 @@ snapshots: '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/source-map@0.3.6': dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -18413,32 +18275,6 @@ snapshots: '@polka/url@1.0.0-next.28': {} - '@puppeteer/browsers@2.2.2': - dependencies: - debug: 4.3.4 - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.4.0 - semver: 7.6.0 - tar-fs: 3.0.5 - unbzip2-stream: 1.4.3 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - - '@puppeteer/browsers@2.4.0': - dependencies: - debug: 4.4.0(supports-color@6.1.0) - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.4.0 - semver: 7.6.3 - tar-fs: 3.0.6 - unbzip2-stream: 1.4.3 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - '@radix-ui/number@1.1.0': {} '@radix-ui/primitive@1.1.0': {} @@ -19093,30 +18929,21 @@ snapshots: '@rollup/plugin-babel@6.0.4(@babel/core@7.25.9)(@types/babel__core@7.20.5)(rollup@4.24.0)': dependencies: '@babel/core': 7.25.9 - '@babel/helper-module-imports': 7.22.5 - '@rollup/pluginutils': 5.0.5(rollup@4.24.0) - optionalDependencies: - '@types/babel__core': 7.20.5 - rollup: 4.24.0 - - '@rollup/plugin-commonjs@26.0.3(rollup@4.24.0)': - dependencies: + '@babel/helper-module-imports': 7.25.9 '@rollup/pluginutils': 5.1.0(rollup@4.24.0) - commondir: 1.0.1 - estree-walker: 2.0.2 - glob: 10.4.5 - is-reference: 1.2.1 - magic-string: 0.30.12 optionalDependencies: + '@types/babel__core': 7.20.5 rollup: 4.24.0 + transitivePeerDependencies: + - supports-color '@rollup/plugin-node-resolve@15.3.0(rollup@4.24.0)': dependencies: - '@rollup/pluginutils': 5.0.5(rollup@4.24.0) + '@rollup/pluginutils': 5.1.0(rollup@4.24.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.8 optionalDependencies: rollup: 4.24.0 @@ -19129,17 +18956,9 @@ snapshots: '@rollup/plugin-terser@0.4.4(rollup@4.24.0)': dependencies: - serialize-javascript: 6.0.1 + serialize-javascript: 6.0.2 smob: 1.4.1 - terser: 5.18.2 - optionalDependencies: - rollup: 4.24.0 - - '@rollup/pluginutils@5.0.5(rollup@4.24.0)': - dependencies: - '@types/estree': 1.0.6 - estree-walker: 2.0.2 - picomatch: 2.3.1 + terser: 5.36.0 optionalDependencies: rollup: 4.24.0 @@ -19203,6 +19022,8 @@ snapshots: '@rushstack/eslint-patch@1.10.5': {} + '@schummar/icu-type-parser@1.21.5': {} + '@sec-ant/readable-stream@0.4.1': {} '@segment/loosely-validate-event@2.0.0': @@ -19300,50 +19121,21 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@sitespeed.io/tracium@0.3.3': + '@size-limit/esbuild@11.1.6(size-limit@11.1.6)': dependencies: - debug: 4.4.0(supports-color@6.1.0) - transitivePeerDependencies: - - supports-color + esbuild: 0.24.0 + nanoid: 5.0.7 + size-limit: 11.1.6 '@size-limit/file@11.1.6(size-limit@11.1.6)': dependencies: size-limit: 11.1.6 - '@size-limit/preset-big-lib@11.1.6(size-limit@11.1.6)': + '@size-limit/preset-small-lib@11.1.6(size-limit@11.1.6)': dependencies: + '@size-limit/esbuild': 11.1.6(size-limit@11.1.6) '@size-limit/file': 11.1.6(size-limit@11.1.6) - '@size-limit/time': 11.1.6(size-limit@11.1.6) - '@size-limit/webpack': 11.1.6(size-limit@11.1.6) - size-limit: 11.1.6 - transitivePeerDependencies: - - '@swc/core' - - bufferutil - - esbuild - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - - '@size-limit/time@11.1.6(size-limit@11.1.6)': - dependencies: - estimo: 3.0.3 - size-limit: 11.1.6 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@size-limit/webpack@11.1.6(size-limit@11.1.6)': - dependencies: - nanoid: 5.0.7 size-limit: 11.1.6 - webpack: 5.95.0 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack-cli '@storybook/builder-webpack5@8.5.1(esbuild@0.24.2)(storybook@8.5.1(prettier@3.3.3))(typescript@5.6.3)': dependencies: @@ -19649,8 +19441,6 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@2.0.1': @@ -19979,11 +19769,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 20.17.0 - optional: true - '@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -20233,16 +20018,16 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@vercel/analytics@1.3.1(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/analytics@1.3.1(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: server-only: 0.0.1 optionalDependencies: - next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 - '@vercel/speed-insights@1.0.13(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + '@vercel/speed-insights@1.0.13(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': optionalDependencies: - next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 '@vitejs/plugin-react@4.3.3(vite@5.4.10(@types/node@22.10.9)(terser@5.37.0))': @@ -20272,6 +20057,14 @@ snapshots: typescript: 5.6.3 vitest: 2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.37.0) + '@vitest/eslint-plugin@1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.10.9)(jsdom@25.0.1)(terser@5.37.0))': + dependencies: + '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.13.0(jiti@2.3.3) + optionalDependencies: + typescript: 5.6.3 + vitest: 2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.10.9)(jsdom@25.0.1)(terser@5.37.0) + '@vitest/expect@2.0.5': dependencies: '@vitest/spy': 2.0.5 @@ -20294,6 +20087,15 @@ snapshots: optionalDependencies: vite: 5.3.3(@types/node@20.17.0)(terser@5.37.0) + '@vitest/mocker@2.1.3(@vitest/spy@2.1.3)(vite@5.3.3(@types/node@22.10.9)(terser@5.37.0))': + dependencies: + '@vitest/spy': 2.1.3 + estree-walker: 3.0.3 + magic-string: 0.30.12 + optionalDependencies: + vite: 5.3.3(@types/node@22.10.9)(terser@5.37.0) + optional: true + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -20346,11 +20148,6 @@ snapshots: '@web3-storage/multipart-parser@1.0.0': {} - '@webassemblyjs/ast@1.12.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -20362,20 +20159,14 @@ snapshots: '@webassemblyjs/helper-wasm-bytecode': 1.9.0 '@webassemblyjs/wast-parser': 1.9.0 - '@webassemblyjs/floating-point-hex-parser@1.11.6': {} - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} '@webassemblyjs/floating-point-hex-parser@1.9.0': {} - '@webassemblyjs/helper-api-error@1.11.6': {} - '@webassemblyjs/helper-api-error@1.13.2': {} '@webassemblyjs/helper-api-error@1.9.0': {} - '@webassemblyjs/helper-buffer@1.12.1': {} - '@webassemblyjs/helper-buffer@1.14.1': {} '@webassemblyjs/helper-buffer@1.9.0': {} @@ -20390,31 +20181,16 @@ snapshots: dependencies: '@webassemblyjs/ast': 1.9.0 - '@webassemblyjs/helper-numbers@1.11.6': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.11.6 - '@webassemblyjs/helper-api-error': 1.11.6 - '@xtuc/long': 4.2.2 - '@webassemblyjs/helper-numbers@1.13.2': dependencies: '@webassemblyjs/floating-point-hex-parser': 1.13.2 '@webassemblyjs/helper-api-error': 1.13.2 '@xtuc/long': 4.2.2 - '@webassemblyjs/helper-wasm-bytecode@1.11.6': {} - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} '@webassemblyjs/helper-wasm-bytecode@1.9.0': {} - '@webassemblyjs/helper-wasm-section@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/helper-wasm-section@1.14.1': dependencies: '@webassemblyjs/ast': 1.14.1 @@ -20429,10 +20205,6 @@ snapshots: '@webassemblyjs/helper-wasm-bytecode': 1.9.0 '@webassemblyjs/wasm-gen': 1.9.0 - '@webassemblyjs/ieee754@1.11.6': - dependencies: - '@xtuc/ieee754': 1.2.0 - '@webassemblyjs/ieee754@1.13.2': dependencies: '@xtuc/ieee754': 1.2.0 @@ -20441,10 +20213,6 @@ snapshots: dependencies: '@xtuc/ieee754': 1.2.0 - '@webassemblyjs/leb128@1.11.6': - dependencies: - '@xtuc/long': 4.2.2 - '@webassemblyjs/leb128@1.13.2': dependencies: '@xtuc/long': 4.2.2 @@ -20453,23 +20221,10 @@ snapshots: dependencies: '@xtuc/long': 4.2.2 - '@webassemblyjs/utf8@1.11.6': {} - '@webassemblyjs/utf8@1.13.2': {} '@webassemblyjs/utf8@1.9.0': {} - '@webassemblyjs/wasm-edit@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/helper-wasm-section': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-opt': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - '@webassemblyjs/wast-printer': 1.12.1 - '@webassemblyjs/wasm-edit@1.14.1': dependencies: '@webassemblyjs/ast': 1.14.1 @@ -20492,14 +20247,6 @@ snapshots: '@webassemblyjs/wasm-parser': 1.9.0 '@webassemblyjs/wast-printer': 1.9.0 - '@webassemblyjs/wasm-gen@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 - '@webassemblyjs/wasm-gen@1.14.1': dependencies: '@webassemblyjs/ast': 1.14.1 @@ -20516,13 +20263,6 @@ snapshots: '@webassemblyjs/leb128': 1.9.0 '@webassemblyjs/utf8': 1.9.0 - '@webassemblyjs/wasm-opt@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - '@webassemblyjs/wasm-opt@1.14.1': dependencies: '@webassemblyjs/ast': 1.14.1 @@ -20537,15 +20277,6 @@ snapshots: '@webassemblyjs/wasm-gen': 1.9.0 '@webassemblyjs/wasm-parser': 1.9.0 - '@webassemblyjs/wasm-parser@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-api-error': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 - '@webassemblyjs/wasm-parser@1.14.1': dependencies: '@webassemblyjs/ast': 1.14.1 @@ -20573,11 +20304,6 @@ snapshots: '@webassemblyjs/helper-fsm': 1.9.0 '@xtuc/long': 4.2.2 - '@webassemblyjs/wast-printer@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@xtuc/long': 4.2.2 - '@webassemblyjs/wast-printer@1.14.1': dependencies: '@webassemblyjs/ast': 1.14.1 @@ -20627,10 +20353,6 @@ snapshots: acorn: 8.12.0 acorn-walk: 8.3.3 - acorn-import-attributes@1.9.5(acorn@8.14.0): - dependencies: - acorn: 8.14.0 - acorn-jsx@5.3.2(acorn@8.13.0): dependencies: acorn: 8.13.0 @@ -20958,10 +20680,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - ast-types@0.15.2: dependencies: tslib: 2.8.1 @@ -21018,8 +20736,6 @@ snapshots: axobject-query@4.1.0: {} - b4a@1.6.7: {} - babel-core@7.0.0-bridge.0(@babel/core@7.25.9): dependencies: '@babel/core': 7.25.9 @@ -21198,30 +20914,6 @@ snapshots: balanced-match@1.0.2: {} - bare-events@2.5.0: - optional: true - - bare-fs@2.3.5: - dependencies: - bare-events: 2.5.0 - bare-path: 2.1.3 - bare-stream: 2.3.0 - optional: true - - bare-os@2.4.4: - optional: true - - bare-path@2.1.3: - dependencies: - bare-os: 2.4.4 - optional: true - - bare-stream@2.3.0: - dependencies: - b4a: 1.6.7 - streamx: 2.20.1 - optional: true - base64-js@1.5.1: {} base@0.11.2: @@ -21238,8 +20930,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.0.5: {} - batch@0.6.1: {} before-after-hook@3.0.2: {} @@ -21357,10 +21047,6 @@ snapshots: transitivePeerDependencies: - supports-color - braces@3.0.2: - dependencies: - fill-range: 7.0.1 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -21455,8 +21141,6 @@ snapshots: buffer-alloc-unsafe: 1.1.0 buffer-fill: 1.0.0 - buffer-crc32@0.2.13: {} - buffer-fill@1.0.0: {} buffer-from@1.1.2: {} @@ -21727,7 +21411,7 @@ snapshots: chokidar-cli@3.0.0: dependencies: - chokidar: 3.5.3 + chokidar: 3.6.0 lodash.debounce: 4.0.8 lodash.throttle: 4.1.1 yargs: 13.3.2 @@ -21750,18 +21434,6 @@ snapshots: transitivePeerDependencies: - supports-color - chokidar@3.5.3: - dependencies: - anymatch: 3.1.3 - braces: 3.0.2 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -21784,13 +21456,6 @@ snapshots: chrome-trace-event@1.0.4: {} - chromium-bidi@0.5.17(devtools-protocol@0.0.1262051): - dependencies: - devtools-protocol: 0.0.1262051 - mitt: 3.0.1 - urlpattern-polyfill: 10.0.0 - zod: 3.22.4 - ci-info@2.0.0: {} ci-info@3.9.0: {} @@ -21970,8 +21635,6 @@ snapshots: commander@10.0.1: {} - commander@12.1.0: {} - commander@2.13.0: {} commander@2.20.0: {} @@ -22176,7 +21839,7 @@ snapshots: core-js-compat@3.38.1: dependencies: - browserslist: 4.24.0 + browserslist: 4.24.2 core-js-pure@3.38.0: {} @@ -22251,6 +21914,22 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@22.10.9): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@22.10.9) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + create-react-class@15.7.0: dependencies: loose-envify: 1.4.0 @@ -22646,8 +22325,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - data-urls@3.0.2: dependencies: abab: 2.0.6 @@ -22696,10 +22373,6 @@ snapshots: optionalDependencies: supports-color: 6.1.0 - debug@4.3.4: - dependencies: - ms: 2.1.2 - debug@4.3.5: dependencies: ms: 2.1.2 @@ -22827,12 +22500,6 @@ snapshots: is-descriptor: 1.0.3 isobject: 3.0.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - del@4.1.1: dependencies: '@types/glob': 7.2.0 @@ -22896,8 +22563,6 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1262051: {} - didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -23215,8 +22880,6 @@ snapshots: es-module-lexer@1.5.3: {} - es-module-lexer@1.5.4: {} - es-module-lexer@1.6.0: {} es-object-atoms@1.0.0: @@ -23347,6 +23010,33 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.24.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.24.0 + '@esbuild/android-arm': 0.24.0 + '@esbuild/android-arm64': 0.24.0 + '@esbuild/android-x64': 0.24.0 + '@esbuild/darwin-arm64': 0.24.0 + '@esbuild/darwin-x64': 0.24.0 + '@esbuild/freebsd-arm64': 0.24.0 + '@esbuild/freebsd-x64': 0.24.0 + '@esbuild/linux-arm': 0.24.0 + '@esbuild/linux-arm64': 0.24.0 + '@esbuild/linux-ia32': 0.24.0 + '@esbuild/linux-loong64': 0.24.0 + '@esbuild/linux-mips64el': 0.24.0 + '@esbuild/linux-ppc64': 0.24.0 + '@esbuild/linux-riscv64': 0.24.0 + '@esbuild/linux-s390x': 0.24.0 + '@esbuild/linux-x64': 0.24.0 + '@esbuild/netbsd-x64': 0.24.0 + '@esbuild/openbsd-arm64': 0.24.0 + '@esbuild/openbsd-x64': 0.24.0 + '@esbuild/sunos-x64': 0.24.0 + '@esbuild/win32-arm64': 0.24.0 + '@esbuild/win32-ia32': 0.24.0 + '@esbuild/win32-x64': 0.24.0 + esbuild@0.24.2: optionalDependencies: '@esbuild/aix-ppc64': 0.24.2 @@ -23457,6 +23147,37 @@ snapshots: - typescript - vitest + eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@22.10.9))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.10.9)(jsdom@25.0.1)(terser@5.37.0)): + dependencies: + '@eslint/js': 9.12.0 + '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + '@vitest/eslint-plugin': 1.1.7(@typescript-eslint/utils@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.10.9)(jsdom@25.0.1)(terser@5.37.0)) + confusing-browser-globals: 1.0.11 + eslint: 9.13.0(jiti@2.3.3) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-css-modules: 2.11.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-jest: 28.8.3(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@22.10.9))(typescript@5.6.3) + eslint-plugin-jsx-a11y: 6.10.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-react: 7.37.1(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-react-hooks: 5.0.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-sort-destructure-keys: 2.0.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-tailwindcss: 3.13.0(tailwindcss@3.4.14) + eslint-plugin-unicorn: 56.0.0(eslint@9.13.0(jiti@2.3.3)) + eslint-plugin-unused-imports: 4.1.4(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3)) + typescript-eslint: 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + transitivePeerDependencies: + - '@typescript-eslint/eslint-plugin' + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - jest + - supports-color + - tailwindcss + - typescript + - vitest + eslint-config-molindo@8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.37.0)): dependencies: '@eslint/js': 9.12.0 @@ -23681,6 +23402,17 @@ snapshots: - supports-color - typescript + eslint-plugin-jest@28.8.3(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@22.10.9))(typescript@5.6.3): + dependencies: + '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.13.0(jiti@2.3.3) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.11.0(@typescript-eslint/parser@8.11.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) + jest: 29.7.0(@types/node@22.10.9) + transitivePeerDependencies: + - supports-color + - typescript + eslint-plugin-jest@28.8.3(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(typescript@5.6.3): dependencies: '@typescript-eslint/utils': 8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3) @@ -23876,18 +23608,6 @@ snapshots: dependencies: estraverse: 5.3.0 - estimo@3.0.3: - dependencies: - '@sitespeed.io/tracium': 0.3.3 - commander: 12.1.0 - find-chrome-bin: 2.0.2 - nanoid: 5.0.7 - puppeteer-core: 22.6.5 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - estraverse@4.3.0: {} estraverse@5.3.0: {} @@ -24271,20 +23991,8 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.4.0(supports-color@6.1.0) - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -24359,10 +24067,6 @@ snapshots: transitivePeerDependencies: - encoding - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.4.0(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -24404,10 +24108,6 @@ snapshots: repeat-string: 1.6.1 to-regex-range: 2.1.1 - fill-range@7.0.1: - dependencies: - to-regex-range: 5.0.1 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -24460,12 +24160,6 @@ snapshots: common-path-prefix: 3.0.0 pkg-dir: 7.0.0 - find-chrome-bin@2.0.2: - dependencies: - '@puppeteer/browsers': 2.4.0 - transitivePeerDependencies: - - supports-color - find-up@3.0.0: dependencies: locate-path: 3.0.0 @@ -24627,12 +24321,6 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 - fs-extra@11.3.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -24762,10 +24450,6 @@ snapshots: dependencies: pump: 3.0.2 - get-stream@5.2.0: - dependencies: - pump: 3.0.2 - get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -24785,15 +24469,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.3: - dependencies: - basic-ftp: 5.0.5 - data-uri-to-buffer: 6.0.2 - debug: 4.4.0(supports-color@6.1.0) - fs-extra: 11.3.0 - transitivePeerDependencies: - - supports-color - get-value@2.0.6: {} getenv@1.0.0: {} @@ -25709,10 +25384,6 @@ snapshots: rgb-regex: 1.0.1 rgba-regex: 1.0.0 - is-core-module@2.12.0: - dependencies: - has: 1.0.3 - is-core-module@2.13.1: dependencies: hasown: 2.0.0 @@ -25864,10 +25535,6 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - is-reference@1.2.1: - dependencies: - '@types/estree': 1.0.6 - is-reference@3.0.2: dependencies: '@types/estree': 1.0.6 @@ -26088,6 +25755,26 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@22.10.9): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.10.9) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@22.10.9) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + jest-config@29.7.0(@types/node@20.17.0): dependencies: '@babel/core': 7.25.9 @@ -26118,6 +25805,37 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@22.10.9): + dependencies: + '@babel/core': 7.25.9 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.25.9) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.10.9 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + optional: true + jest-diff@29.7.0: dependencies: chalk: 4.1.2 @@ -26399,6 +26117,19 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@22.10.9): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@22.10.9) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + optional: true + jimp-compact@0.16.1: {} jiti@1.21.6: {} @@ -28214,8 +27945,6 @@ snapshots: stream-each: 1.2.3 through2: 2.0.5 - mitt@3.0.1: {} - mixin-deep@1.3.2: dependencies: for-in: 1.0.2 @@ -28354,13 +28083,11 @@ snapshots: nested-error-stacks@2.0.1: {} - netmask@2.0.2: {} - new-github-release-url@2.0.0: dependencies: type-fest: 2.19.0 - next-auth@4.24.11(next@15.2.2(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.11(next@15.2.2(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -28375,13 +28102,13 @@ snapshots: react-dom: 18.3.1(react@18.3.1) uuid: 8.3.2 - next-sitemap@4.2.3(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + next-sitemap@4.2.3(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 fast-glob: 3.3.2 minimist: 1.2.8 - next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -28399,7 +28126,7 @@ snapshots: transitivePeerDependencies: - supports-color - next@12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): + next@12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@next/env': 12.3.4 '@swc/helpers': 0.4.11 @@ -28407,7 +28134,7 @@ snapshots: postcss: 8.4.14 react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(@babel/core@7.25.9)(react@17.0.2) + styled-jsx: 5.0.7(react@17.0.2) use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.4 @@ -28427,7 +28154,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.16 '@swc/helpers': 0.5.5 @@ -28437,7 +28164,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(@babel/core@7.25.9)(react@18.3.1) + styled-jsx: 5.1.1(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 14.2.16 '@next/swc-darwin-x64': 14.2.16 @@ -28479,21 +28206,21 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextra-theme-docs@3.1.0(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + nextra-theme-docs@3.1.0(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nextra@3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 escape-string-regexp: 5.0.0 flexsearch: 0.7.43 - next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nextra: 3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) + nextra: 3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) scroll-into-view-if-needed: 3.1.0 zod: 3.23.8 - nextra@3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): + nextra@3.1.0(@types/react@18.3.12)(acorn@8.14.0)(next@14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.6.3): dependencies: '@formatjs/intl-localematcher': 0.5.5 '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -28513,7 +28240,7 @@ snapshots: hast-util-to-estree: 3.1.0 katex: 0.16.11 negotiator: 1.0.0 - next: 14.2.16(@babel/core@7.25.9)(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 14.2.16(@playwright/test@1.48.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) p-limit: 6.1.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -29080,24 +28807,6 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.0.2: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.1 - debug: 4.4.0(supports-color@6.1.0) - get-uri: 6.0.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.4 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - package-json-from-dist@1.0.0: {} package-manager-detector@0.2.2: {} @@ -29317,8 +29026,6 @@ snapshots: duplexify: 3.7.1 through2: 2.0.5 - pend@1.2.0: {} - periscopic@3.1.0: dependencies: '@types/estree': 1.0.6 @@ -29888,21 +29595,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.1 - debug: 4.4.0(supports-color@6.1.0) - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.5 - lru-cache: 7.18.3 - pac-proxy-agent: 7.0.2 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.4 - transitivePeerDependencies: - - supports-color - - proxy-from-env@1.1.0: {} - prr@1.0.1: {} pseudomap@1.0.2: {} @@ -29951,18 +29643,6 @@ snapshots: punycode@2.3.1: {} - puppeteer-core@22.6.5: - dependencies: - '@puppeteer/browsers': 2.2.2 - chromium-bidi: 0.5.17(devtools-protocol@0.0.1262051) - debug: 4.3.4 - devtools-protocol: 0.0.1262051 - ws: 8.16.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - pure-rand@6.1.0: {} q@1.5.1: {} @@ -29981,8 +29661,6 @@ snapshots: queue-microtask@1.2.3: {} - queue-tick@1.0.1: {} - queue@6.0.2: dependencies: inherits: 2.0.4 @@ -30391,10 +30069,6 @@ snapshots: globalthis: 1.0.4 which-builtin-type: 1.1.3 - regenerate-unicode-properties@10.1.0: - dependencies: - regenerate: 1.4.2 - regenerate-unicode-properties@10.2.0: dependencies: regenerate: 1.4.2 @@ -30427,15 +30101,6 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 - regexpu-core@5.3.2: - dependencies: - '@babel/regjsgen': 0.8.0 - regenerate: 1.4.2 - regenerate-unicode-properties: 10.1.0 - regjsparser: 0.9.1 - unicode-match-property-ecmascript: 2.0.0 - unicode-match-property-value-ecmascript: 2.1.0 - regexpu-core@6.1.1: dependencies: regenerate: 1.4.2 @@ -30464,10 +30129,6 @@ snapshots: dependencies: jsesc: 3.0.2 - regjsparser@0.9.1: - dependencies: - jsesc: 0.5.0 - rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -30711,12 +30372,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@1.22.2: - dependencies: - is-core-module: 2.12.0 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - resolve@1.22.8: dependencies: is-core-module: 2.13.1 @@ -30989,10 +30644,6 @@ snapshots: semver@7.3.2: {} - semver@7.6.0: - dependencies: - lru-cache: 6.0.0 - semver@7.6.2: {} semver@7.6.3: {} @@ -31043,10 +30694,6 @@ snapshots: dependencies: randombytes: 2.1.0 - serialize-javascript@6.0.1: - dependencies: - randombytes: 2.1.0 - serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -31506,14 +31153,6 @@ snapshots: streamsearch@1.1.0: {} - streamx@2.20.1: - dependencies: - fast-fifo: 1.3.2 - queue-tick: 1.0.1 - text-decoder: 1.2.0 - optionalDependencies: - bare-events: 2.5.0 - string-hash@1.1.3: {} string-length@4.0.2: @@ -31675,18 +31314,14 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.0.7(@babel/core@7.25.9)(react@17.0.2): + styled-jsx@5.0.7(react@17.0.2): dependencies: react: 17.0.2 - optionalDependencies: - '@babel/core': 7.25.9 - styled-jsx@5.1.1(@babel/core@7.25.9)(react@18.3.1): + styled-jsx@5.1.1(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 - optionalDependencies: - '@babel/core': 7.25.9 styled-jsx@5.1.6(@babel/core@7.25.9)(react@18.3.1): dependencies: @@ -31817,22 +31452,6 @@ snapshots: pump: 3.0.0 tar-stream: 2.2.0 - tar-fs@3.0.5: - dependencies: - pump: 3.0.2 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 2.3.5 - bare-path: 2.1.3 - - tar-fs@3.0.6: - dependencies: - pump: 3.0.2 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 2.3.5 - bare-path: 2.1.3 - tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -31841,12 +31460,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar-stream@3.1.7: - dependencies: - b4a: 1.6.7 - fast-fifo: 1.3.2 - streamx: 2.20.1 - tar@6.2.1: dependencies: chownr: 2.0.0 @@ -31918,15 +31531,6 @@ snapshots: transitivePeerDependencies: - bluebird - terser-webpack-plugin@5.3.10(webpack@5.95.0): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.36.0 - webpack: 5.95.0 - terser-webpack-plugin@5.3.11(esbuild@0.24.2)(webpack@5.97.1(esbuild@0.24.2)): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -31945,13 +31549,6 @@ snapshots: source-map: 0.6.1 source-map-support: 0.5.21 - terser@5.18.2: - dependencies: - '@jridgewell/source-map': 0.3.5 - acorn: 8.12.0 - commander: 2.20.3 - source-map-support: 0.5.21 - terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -31972,10 +31569,6 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 - text-decoder@1.2.0: - dependencies: - b4a: 1.6.7 - text-extensions@2.4.0: {} text-table@0.2.0: {} @@ -32132,8 +31725,6 @@ snapshots: ts-dedent@2.2.0: {} - ts-expose-internals-conditionally@1.0.0-empty.0: {} - ts-interface-checker@0.1.13: {} ts-pnp@1.2.0(typescript@5.6.3): @@ -32301,7 +31892,7 @@ snapshots: - eslint - supports-color - typescript@5.3.3: {} + typescript@5.6.1-rc: {} typescript@5.6.3: {} @@ -32326,11 +31917,6 @@ snapshots: has-symbols: 1.0.3 which-boxed-primitive: 1.0.2 - unbzip2-stream@1.4.3: - dependencies: - buffer: 5.7.1 - through: 2.3.8 - undici-types@6.19.8: {} undici-types@6.20.0: {} @@ -32346,8 +31932,6 @@ snapshots: unicode-canonical-property-names-ecmascript: 2.0.0 unicode-property-aliases-ecmascript: 2.1.0 - unicode-match-property-value-ecmascript@2.1.0: {} - unicode-match-property-value-ecmascript@2.2.0: {} unicode-property-aliases-ecmascript@2.1.0: {} @@ -32586,8 +32170,6 @@ snapshots: punycode: 1.4.1 qs: 6.13.0 - urlpattern-polyfill@10.0.0: {} - use-callback-ref@1.3.2(@types/react@18.3.12)(react@18.3.1): dependencies: react: 18.3.1 @@ -32760,6 +32342,23 @@ snapshots: - supports-color - terser + vite-node@2.1.3(@types/node@22.10.9)(terser@5.37.0): + dependencies: + cac: 6.7.14 + debug: 4.3.7(supports-color@6.1.0) + pathe: 1.1.2 + vite: 5.3.3(@types/node@22.10.9)(terser@5.37.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + optional: true + vite@4.5.5(@types/node@22.10.9)(terser@5.37.0): dependencies: esbuild: 0.18.20 @@ -32780,6 +32379,17 @@ snapshots: fsevents: 2.3.3 terser: 5.37.0 + vite@5.3.3(@types/node@22.10.9)(terser@5.37.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.4.47 + rollup: 4.24.0 + optionalDependencies: + '@types/node': 22.10.9 + fsevents: 2.3.3 + terser: 5.37.0 + optional: true + vite@5.4.10(@types/node@22.10.9)(terser@5.37.0): dependencies: esbuild: 0.21.5 @@ -32860,6 +32470,42 @@ snapshots: - supports-color - terser + vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.10.9)(jsdom@25.0.1)(terser@5.37.0): + dependencies: + '@vitest/expect': 2.1.3 + '@vitest/mocker': 2.1.3(@vitest/spy@2.1.3)(vite@5.3.3(@types/node@22.10.9)(terser@5.37.0)) + '@vitest/pretty-format': 2.1.3 + '@vitest/runner': 2.1.3 + '@vitest/snapshot': 2.1.3 + '@vitest/spy': 2.1.3 + '@vitest/utils': 2.1.3 + chai: 5.1.1 + debug: 4.3.7(supports-color@6.1.0) + magic-string: 0.30.12 + pathe: 1.1.2 + std-env: 3.7.0 + tinybench: 2.9.0 + tinyexec: 0.3.1 + tinypool: 1.0.0 + tinyrainbow: 1.2.0 + vite: 5.3.3(@types/node@22.10.9)(terser@5.37.0) + vite-node: 2.1.3(@types/node@22.10.9)(terser@5.37.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 4.0.3 + '@types/node': 22.10.9 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - stylus + - sugarss + - supports-color + - terser + optional: true + vlq@1.0.1: {} vm-browserify@1.1.2: {} @@ -33125,36 +32771,6 @@ snapshots: transitivePeerDependencies: - supports-color - webpack@5.95.0: - dependencies: - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.14.0 - acorn-import-attributes: 1.9.5(acorn@8.14.0) - browserslist: 4.24.2 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.95.0) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - webpack@5.97.1(esbuild@0.24.2): dependencies: '@types/eslint-scope': 3.7.7 @@ -33380,8 +32996,6 @@ snapshots: ws@7.5.10: {} - ws@8.16.0: {} - ws@8.17.1: {} ws@8.18.0: {} @@ -33490,11 +33104,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@0.1.0: {} yocto-queue@1.1.1: {} @@ -33507,8 +33116,6 @@ snapshots: dependencies: zod: 3.23.8 - zod@3.22.4: {} - zod@3.23.8: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd8cb5185..10da0f192 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - 'packages/*' - 'examples/*' - 'docs' + - 'tools' diff --git a/tools/eslint.config.mjs b/tools/eslint.config.mjs new file mode 100644 index 000000000..0a6518b8f --- /dev/null +++ b/tools/eslint.config.mjs @@ -0,0 +1,8 @@ +import {getPresets} from 'eslint-config-molindo'; +import globals from 'globals'; + +export default (await getPresets('javascript')).concat({ + languageOptions: { + globals: globals.node + } +}); diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 000000000..5e20d0de4 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,28 @@ +{ + "name": "tools", + "type": "module", + "version": "1.0.0", + "description": "Shared tools for the repo", + "main": "src/index.js", + "scripts": { + "lint": "eslint src" + }, + "keywords": [], + "author": "Jan Amann", + "license": "MIT", + "devDependencies": { + "@babel/core": "^7.24.7", + "@babel/preset-env": "^7.24.7", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@rollup/plugin-babel": "^6.0.3", + "@rollup/plugin-node-resolve": "^15.2.1", + "@rollup/plugin-replace": "^5.0.7", + "@rollup/plugin-terser": "^0.4.3", + "eslint-config-molindo": "^8.0.0", + "eslint": "^9.11.1", + "execa": "^9.2.0", + "globals": "^15.11.0", + "rollup": "^4.18.0" + } +} diff --git a/scripts/getBuildConfig.mjs b/tools/src/getBuildConfig.js similarity index 50% rename from scripts/getBuildConfig.mjs rename to tools/src/getBuildConfig.js index be13c66f7..08e7fd226 100644 --- a/scripts/getBuildConfig.mjs +++ b/tools/src/getBuildConfig.js @@ -1,7 +1,4 @@ -/* eslint-env node */ -import fs from 'fs'; import {babel} from '@rollup/plugin-babel'; -import commonjs from '@rollup/plugin-commonjs'; import resolve, { DEFAULTS as resolveDefaults } from '@rollup/plugin-node-resolve'; @@ -13,36 +10,41 @@ const extensions = [...resolveDefaults.extensions, '.tsx']; const outDir = 'dist/'; -function writeEnvIndex(input) { - Object.keys(input).forEach((key) => { - fs.writeFileSync( - `./${outDir}${key}.js`, - `'use strict'; - -if (process.env.NODE_ENV === 'production') { - module.exports = require('./production/${key}.js'); -} else { - module.exports = require('./development/${key}.js'); -} -` - ); - }); -} - async function buildTypes() { - await execa( - 'tsc', - '--noEmit false --emitDeclarationOnly true --outDir dist/types'.split(' ') - ); + await execa('tsc', '-p tsconfig.build.json'.split(' ')); + // eslint-disable-next-line no-console console.log('\ncreated types'); } -export default function getConfig({ +function ignoreSideEffectImports(imports) { + // Rollup somehow leaves a few imports in the bundle that + // would only be relevant if they had side effects. + + const pattern = imports + .map((importName) => `import\\s*['"]${importName}['"];?`) + .join('|'); + const regex = new RegExp(pattern, 'g'); + + return { + name: 'ignore-side-effect-imports', + generateBundle(outputOptions, bundle) { + if (imports.length === 0) return; + + for (const [fileName, file] of Object.entries(bundle)) { + if (file.type === 'chunk' && fileName.endsWith('.js')) { + file.code = file.code.replace(regex, ''); + } + } + } + }; +} + +function getBundleConfig({ env, external = [], input, - output, + output = undefined, plugins = [], ...rest }) { @@ -51,12 +53,8 @@ export default function getConfig({ input, external: [/node_modules/, ...external], output: { - dir: outDir + env, - format: 'cjs', - interop: 'auto', - freeze: false, - esModule: true, - exports: 'named', + dir: outDir + 'esm/' + env, + format: 'es', ...output }, treeshake: { @@ -66,26 +64,21 @@ export default function getConfig({ }, plugins: [ resolve({extensions}), - commonjs(), babel({ babelHelpers: 'bundled', extensions, presets: [ '@babel/preset-typescript', - '@babel/preset-react', + ['@babel/preset-react', {runtime: 'automatic'}], [ '@babel/preset-env', { - targets: { - // Same as https://nextjs.org/docs/architecture/supported-browsers#browserslist - browsers: [ - 'chrome 64', - 'edge 79', - 'firefox 67', - 'opera 51', - 'safari 12' - ] - } + // > 0.5%, last 2 versions, Firefox ESR, not dead + targets: 'defaults', + + // Maybe a bug in browserslist? This is required for + // ios<16.3, but MDN says it's available from Safari 10 + exclude: ['transform-parameters'] } ] ] @@ -94,11 +87,11 @@ export default function getConfig({ 'process.env.NODE_ENV': JSON.stringify(env), preventAssignment: true }), + ignoreSideEffectImports(external), env !== 'development' && terser(), { buildEnd() { if (env === 'production') { - writeEnvIndex(input); buildTypes(); } } @@ -110,3 +103,8 @@ export default function getConfig({ return config; } + +export default function getConfig(config) { + const envNames = config.env || ['development', 'production']; + return envNames.map((env) => getBundleConfig({...config, env})); +} diff --git a/tools/src/index.js b/tools/src/index.js new file mode 100644 index 000000000..77141c6ed --- /dev/null +++ b/tools/src/index.js @@ -0,0 +1 @@ +export {default as getBuildConfig} from './getBuildConfig.js'; diff --git a/turbo.json b/turbo.json index 49eabfe80..c1c687086 100644 --- a/turbo.json +++ b/turbo.json @@ -8,6 +8,9 @@ "lint": { "dependsOn": ["^build"] }, + "example-app-router-playground#lint": { + "dependsOn": ["example-app-router-playground#build"] + }, "test": { "dependsOn": ["build"] },