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/prerelease-v4.yml b/.github/workflows/prerelease-v4.yml new file mode 100644 index 000000000..adfba9583 --- /dev/null +++ b/.github/workflows/prerelease-v4.yml @@ -0,0 +1,33 @@ +name: prerelease (v4) + +on: + push: + branches: + - v4 + +jobs: + main: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + registry-url: 'https://registry.npmjs.org' + node-version: 20.x + cache: 'pnpm' + - run: pnpm install + - run: | + git config --global user.name "${{ github.actor }}" + git config --global user.email "${{ github.actor }}@users.noreply.github.com" + - 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 4.0.0-beta-${GITHUB_SHA::7} --no-git-reset --dist-tag v4-beta --no-push --yes + 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 }} + NPM_CONFIG_PROVENANCE: true 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/docs/src/components/StayUpdated.mdx b/docs/src/components/StayUpdated.mdx index 436d0c69d..5fc1a2720 100644 --- a/docs/src/components/StayUpdated.mdx +++ b/docs/src/components/StayUpdated.mdx @@ -2,6 +2,5 @@ **Let's keep in touch:** -- [GitHub releases](https://github.com/amannn/next-intl/releases) - [Bluesky (Jan Amann)](https://bsky.app/profile/amann.work) - [X (Jan Amann)](https://x.com/jamannnnnn) 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 01f8eb579..16a926ac1 100644 --- a/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx +++ b/docs/src/pages/docs/environments/actions-metadata-route-handlers.mdx @@ -154,8 +154,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) as of version 14.2. You can use your default locale for the main URL and provide alternate URLs based on all locales that your app supports. Keep in mind that also the default locale should be included in the `alternates` object. -```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 @@ -179,7 +180,7 @@ function getEntry(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; } @@ -206,12 +207,16 @@ 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'; 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(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 90da28c7c..9ac7e5233 100644 --- a/docs/src/pages/docs/environments/error-files.mdx +++ b/docs/src/pages/docs/environments/error-files.mdx @@ -77,14 +77,15 @@ 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 function LocaleLayout({children, params: {locale}}) { - // 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..b7c743ef4 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')}

@@ -275,7 +278,7 @@ 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. +To reduce bundle size, `next-intl` doesn't automatically provide [messages](/docs/usage/configuration#messages) 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. @@ -366,7 +369,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 c2b8e3a7e..58f3e7b83 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 @@ -147,16 +147,15 @@ When using features from `next-intl` in Server Components, the relevant configur ```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, @@ -186,7 +185,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 {NextIntlClientProvider, Locale, hasLocale} from 'next-intl'; import {getMessages} from 'next-intl/server'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; @@ -196,10 +195,9 @@ export default async function LocaleLayout({ params: {locale} }: { children: React.ReactNode; - params: {locale: string}; + params: {locale: Locale}; }) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } @@ -297,12 +295,12 @@ 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: {locale}}) { - // 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/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 ab41eac40..acf996ce8 100644 --- a/docs/src/pages/docs/routing.mdx +++ b/docs/src/pages/docs/routing.mdx @@ -110,8 +110,8 @@ 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`. +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] @@ -297,11 +297,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: { - locale: string; + locale: Locale; slug: Array; }; }; @@ -472,11 +473,11 @@ 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 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. 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. +1. [**`maxAge`**](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#max-agenumber): This value is set to 5 hours in order to be [GDPR-compliant](#locale-cookie-gdpr) out of the box. 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. @@ -492,8 +493,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 } }); ``` @@ -510,7 +511,18 @@ 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] diff --git a/docs/src/pages/docs/routing/middleware.mdx b/docs/src/pages/docs/routing/middleware.mdx index 70ce7c58b..6069007f1 100644 --- a/docs/src/pages/docs/routing/middleware.mdx +++ b/docs/src/pages/docs/routing/middleware.mdx @@ -30,7 +30,7 @@ export const config = { ## 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 +48,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? diff --git a/docs/src/pages/docs/routing/navigation.mdx b/docs/src/pages/docs/routing/navigation.mdx index ba72c6fd0..a9c30ef58 100644 --- a/docs/src/pages/docs/routing/navigation.mdx +++ b/docs/src/pages/docs/routing/navigation.mdx @@ -374,14 +374,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 c01364810..d17394040 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,9 +59,7 @@ 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 ( @@ -121,49 +78,35 @@ These props are inherited if you're rendering `NextIntlClientProvider` from a Se 1. `locale` 2. `now` 3. `timeZone` +4. `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` +2. `onError` and `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,25 +114,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, getMessages} 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 +136,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 +311,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 +360,42 @@ Note that [the VSCode integration for `next-intl`](/docs/workflows/vscode-integr
+### `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'; + +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 +435,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 +451,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 +463,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 +474,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 +482,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 +498,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 +538,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 +586,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 +611,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 +653,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 +691,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..7155919ae 100644 --- a/docs/src/pages/docs/usage/dates-times.mdx +++ b/docs/src/pages/docs/usage/dates-times.mdx @@ -67,32 +67,82 @@ 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); } ``` -If you want the relative time value to update over time, you can do so with [the `useNow` hook](/docs/usage/configuration#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); +} +``` + +
+ +### `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 +162,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: 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/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/theme.config.tsx b/docs/src/theme.config.tsx index 63bf9b886..769a9158e 100644 --- a/docs/src/theme.config.tsx +++ b/docs/src/theme.config.tsx @@ -27,21 +27,12 @@ export default { pre: Pre }, banner: { - key: 'banner-learn-next-intl', content: (
- Announcing{' '} - - learn.next-intl.dev - - ! + You‘re viewing the next-intl 4 beta docs
- ) + ), + dismissible: false }, footer: { component: Footer 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 5d3c87400..5f131d8c9 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,6 @@ import {notFound} from 'next/navigation'; +import {NextIntlClientProvider, hasLocale} from 'next-intl'; +import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -8,17 +10,24 @@ type Props = { }; export default async function LocaleLayout({children, params}: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(params.locale as any)) { + if (!hasLocale(routing.locales, params.locale)) { notFound(); } + // 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-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-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 a7f92ad64..2ad9172f8 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 45ce1900d..986e6e93d 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/routing.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 260a990c6..ffe4b971b 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,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function About({params: {locale}}: 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 258c83e5f..cde039f11 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,6 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; -import {NextIntlClientProvider} from 'next-intl'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import {getMessages, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; @@ -10,7 +10,7 @@ import PublicNavigationLocaleSwitcher from './PublicNavigationLocaleSwitcher'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export function generateStaticParams() { @@ -25,14 +25,14 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { - // Enable static rendering - setRequestLocale(locale); - // Ensure that the incoming locale is valid - if (!locales.includes(locale as any)) { + if (!hasLocale(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(); 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 f3ee4cbf4..a12206760 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,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageTitle from '@/components/PageTitle'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function Index({params: {locale}}: 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/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 266e9e6a9..bd327e120 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 cookies().get(COOKIE_NAME)?.value || defaultLocale; +export async function getUserLocale(): Promise { + const candidate = 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-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 0acc48b28..c8df9eb83 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,20 +1,19 @@ 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: {locale: string}; + params: {locale: Locale}; }; export default async function LocaleLayout({ children, params: {locale} }: Props) { - // 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-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/eslint.config.mjs b/examples/example-app-router-playground/eslint.config.mjs index 8a4bf6954..8ae9b8ef2 100644 --- a/examples/example-app-router-playground/eslint.config.mjs +++ b/examples/example-app-router-playground/eslint.config.mjs @@ -1,3 +1,8 @@ import {getPresets} from 'eslint-config-molindo'; +import globals from 'globals'; -export default await getPresets('typescript', 'react', 'jest'); +export default (await getPresets('typescript', 'react', 'jest')).concat({ + languageOptions: { + globals: globals.node + } +}); 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 f49d4d53e..8c0332c69 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -3,11 +3,19 @@ import mdxPlugin from '@next/mdx'; import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin('./src/i18n/request.tsx'); +const withNextIntl = createNextIntlPlugin({ + requestConfig: './src/i18n/request.tsx', + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); const withMdx = mdxPlugin(); export default withMdx( withNextIntl({ + eslint: { + ignoreDuringBuilds: true + }, trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash', basePath: process.env.NEXT_PUBLIC_USE_CASE === 'base-path' diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index 916883681..941e94bc8 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -40,6 +40,7 @@ "css-loader": "^6.8.1", "eslint": "^9.11.1", "eslint-config-molindo": "^8.0.0", + "globals": "^15.11.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "prettier": "^3.3.3", 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 cf16681e1..5f9e2a5cb 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,6 +1,8 @@ +import {Locale} from 'next-intl'; + type Props = { params: { - locale: string; + locale: Locale; }; }; 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 921971072..890078add 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: { - 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 fae9dea09..90c291ab3 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/routing'; 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 a46626c9d..2a9b487b4 100644 --- a/examples/example-app-router-playground/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/layout.tsx @@ -1,5 +1,6 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; +import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; import { getFormatter, getNow, @@ -12,7 +13,7 @@ import Navigation from '../../components/Navigation'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export async function generateMetadata({ @@ -29,14 +30,13 @@ export async function generateMetadata({ description: t('description'), other: { currentYear: formatter.dateTime(now, {year: 'numeric'}), - timeZone: timeZone || 'N/A' + timeZone } }; } export default function LocaleLayout({children, params: {locale}}: Props) { - // Ensure that the incoming `locale` is valid - if (!routing.locales.includes(locale as any)) { + if (!hasLocale(routing.locales, locale)) { notFound(); } @@ -50,8 +50,10 @@ export default function LocaleLayout({children, params: {locale}}: 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 7b080c397..010fd66e5 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,6 +1,6 @@ import {Metadata} from 'next'; -import {useTranslations} from 'next-intl'; -import {Locale, getPathname} from '@/i18n/routing'; +import {Locale, useTranslations} from 'next-intl'; +import {getPathname} from '@/i18n/routing'; type Props = { params: { 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 94% 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..af644fd62 100644 --- a/examples/example-app-router-playground/src/type-portability-test.ts +++ b/examples/example-app-router-playground/src/components/TypePortabilityTest.ts @@ -5,7 +5,6 @@ import { createFormatter, - createTranslator, initializeConfig, useFormatter, useLocale, @@ -62,7 +61,7 @@ export async function asyncApis() { export const withNextIntl = createNextIntlPlugin(); export const config = initializeConfig({locale: 'en'}); -export const translator = createTranslator({locale: 'en'}); +// export const translator = createTranslator({locale: 'en'}); export const formatter = createFormatter({ locale: 'en', now: new Date(2022, 10, 6, 20, 20, 0, 0) 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..5030bb594 --- /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/routing'; + +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 d132c19e2..2f329e1a9 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 = headers().get('x-now'); const timeZone = 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 4232fffa7..1da45abec 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -60,8 +60,5 @@ export const routing = defineRouting({ } }); -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; - export const {Link, getPathname, redirect, usePathname, useRouter} = createNavigation(routing); 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..f113e76ee 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 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 fb5c746cd..7882adc7b 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -1,5 +1,5 @@ -import {test as it, expect, BrowserContext} from '@playwright/test'; -import {getAlternateLinks, assertLocaleCookieValue} from './utils'; +import {BrowserContext, expect, test as it} from '@playwright/test'; +import {assertLocaleCookieValue, getAlternateLinks} from './utils'; const describe = it.describe; @@ -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/trailing-slash.spec.ts b/examples/example-app-router-playground/tests/trailing-slash.spec.ts index cdcea2132..30650ba7a 100644 --- a/examples/example-app-router-playground/tests/trailing-slash.spec.ts +++ b/examples/example-app-router-playground/tests/trailing-slash.spec.ts @@ -1,4 +1,4 @@ -import {test as it, expect} from '@playwright/test'; +import {expect, test as it} from '@playwright/test'; import {getAlternateLinks} from './utils'; it('redirects to a locale prefix correctly', async ({request}) => { 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.js b/examples/example-app-router-single-locale/next.config.js deleted file mode 100644 index d9797bdf4..000000000 --- a/examples/example-app-router-single-locale/next.config.js +++ /dev/null @@ -1,3 +0,0 @@ -const withNextIntl = require('next-intl/plugin')(); - -module.exports = withNextIntl(); 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 244511749..3db1a9abf 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-without-i18n-routing/next.config.js b/examples/example-app-router-without-i18n-routing/next.config.js deleted file mode 100644 index d9797bdf4..000000000 --- a/examples/example-app-router-without-i18n-routing/next.config.js +++ /dev/null @@ -1,3 +0,0 @@ -const withNextIntl = require('next-intl/plugin')(); - -module.exports = withNextIntl(); 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 317bf1a64..e076cf1f2 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/.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.js b/examples/example-app-router/next.config.js deleted file mode 100644 index 12b5a862d..000000000 --- a/examples/example-app-router/next.config.js +++ /dev/null @@ -1,8 +0,0 @@ -// @ts-check - -const withNextIntl = require('next-intl/plugin')(); - -/** @type {import('next').NextConfig} */ -const config = {}; - -module.exports = withNextIntl(config); diff --git a/examples/example-app-router/next.config.mjs b/examples/example-app-router/next.config.mjs new file mode 100644 index 000000000..1751fe61a --- /dev/null +++ b/examples/example-app-router/next.config.mjs @@ -0,0 +1,14 @@ +// @ts-check + +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin({ + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); + +/** @type {import('next').NextConfig} */ +const config = {}; + +export default withNextIntl(config); diff --git a/examples/example-app-router/package.json b/examples/example-app-router/package.json index ae0d30e58..c6eeb30e1 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 2a517d046..7dc3091ae 100644 --- a/examples/example-app-router/src/app/[locale]/layout.tsx +++ b/examples/example-app-router/src/app/[locale]/layout.tsx @@ -1,4 +1,5 @@ import {notFound} from 'next/navigation'; +import {Locale, hasLocale} from 'next-intl'; import {getTranslations, setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import BaseLayout from '@/components/BaseLayout'; @@ -6,7 +7,7 @@ import {routing} from '@/i18n/routing'; type Props = { children: ReactNode; - params: {locale: string}; + params: {locale: Locale}; }; export function generateStaticParams() { @@ -27,8 +28,7 @@ export default async function LocaleLayout({ children, params: {locale} }: Props) { - // 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/src/app/[locale]/page.tsx b/examples/example-app-router/src/app/[locale]/page.tsx index a5b6ad8ef..ea963c340 100644 --- a/examples/example-app-router/src/app/[locale]/page.tsx +++ b/examples/example-app-router/src/app/[locale]/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageLayout from '@/components/PageLayout'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function IndexPage({params: {locale}}: 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 943615b11..b53ab8535 100644 --- a/examples/example-app-router/src/app/[locale]/pathnames/page.tsx +++ b/examples/example-app-router/src/app/[locale]/pathnames/page.tsx @@ -1,9 +1,9 @@ -import {useTranslations} from 'next-intl'; +import {Locale, useTranslations} from 'next-intl'; import {setRequestLocale} from 'next-intl/server'; import PageLayout from '@/components/PageLayout'; type Props = { - params: {locale: string}; + params: {locale: Locale}; }; export default function PathnamesPage({params: {locale}}: Props) { diff --git a/examples/example-app-router/src/app/sitemap.ts b/examples/example-app-router/src/app/sitemap.ts index 0fefe33a2..7f021f249 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 {Locale, getPathname, routing} from '@/i18n/routing'; +import {getPathname, routing} from '@/i18n/routing'; export default function sitemap(): MetadataRoute.Sitemap { return [getEntry('/'), getEntry('/pathnames')]; diff --git a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx index 051a36f61..ca8b90fe3 100644 --- a/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx +++ b/examples/example-app-router/src/components/LocaleSwitcherSelect.tsx @@ -2,8 +2,9 @@ import clsx from 'clsx'; import {useParams} from 'next/navigation'; +import {Locale} from 'next-intl'; import {ChangeEvent, ReactNode, useTransition} from 'react'; -import {Locale, usePathname, useRouter} from '@/i18n/routing'; +import {usePathname, useRouter} from '@/i18n/routing'; type Props = { children: ReactNode; 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 001b856ff..9ad5090ad 100644 --- a/examples/example-app-router/src/i18n/routing.ts +++ b/examples/example-app-router/src/i18n/routing.ts @@ -13,8 +13,5 @@ export const routing = defineRouting({ } }); -export type Pathnames = keyof typeof routing.pathnames; -export type Locale = (typeof routing.locales)[number]; - export const {Link, getPathname, redirect, usePathname, useRouter} = createNavigation(routing); diff --git a/examples/example-app-router/tests/main.spec.ts b/examples/example-app-router/tests/main.spec.ts index 0456be01b..8fcb44cf8 100644 --- a/examples/example-app-router/tests/main.spec.ts +++ b/examples/example-app-router/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('handles i18n routing', async ({page}) => { await page.goto('/'); @@ -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,18 @@ 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'); + expect(value).toContain('Max-Age=18000;'); + expect(value).toContain('Expires='); +}); + 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 1cd053951..dedef4b0c 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 4907a1bf4..03da96bce 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": { "eslint": "^9.11.1", 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/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 1e129f675..e00f8f64a 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' + path: 'dist/esm/production/index.react-client.js', + limit: '13.065 KB' }, { - name: "import * from 'next-intl' (react-server)", - path: 'dist/production/index.react-server.js', - limit: '14.735 KB' - }, - { - name: "import {createSharedPathnamesNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', - import: '{createSharedPathnamesNavigation}', - limit: '4.125 KB' + name: "import {NextIntlClientProvider} from 'next-intl' (react-client)", + import: '{NextIntlClientProvider}', + path: 'dist/esm/production/index.react-client.js', + limit: '1 KB' }, { - name: "import {createLocalizedPathnamesNavigation} from 'next-intl/navigation' (react-client)", - path: 'dist/production/navigation.react-client.js', - import: '{createLocalizedPathnamesNavigation}', - limit: '4.115 KB' + name: "import * from 'next-intl' (react-server)", + path: 'dist/esm/production/index.react-server.js', + limit: '14.035 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.115 KB' - }, - { - name: "import {createSharedPathnamesNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', - import: '{createSharedPathnamesNavigation}', - limit: '16.805 KB' - }, - { - name: "import {createLocalizedPathnamesNavigation} from 'next-intl/navigation' (react-server)", - path: 'dist/production/navigation.react-server.js', - import: '{createLocalizedPathnamesNavigation}', - limit: '16.805 KB' + limit: '2.475 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.805 KB' + limit: '3.25 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.035 KB' + path: 'dist/esm/production/server.react-server.js', + limit: '13.335 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.305 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.365 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/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..a2d92e6aa 100644 --- a/packages/next-intl/eslint.config.mjs +++ b/packages/next-intl/eslint.config.mjs @@ -6,6 +6,27 @@ 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}] } }); 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 f395ecd5f..e9fef23b6 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..d1d057aec 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.tsx'; diff --git a/packages/next-intl/src/index.react-server.tsx b/packages/next-intl/src/index.react-server.tsx index 812d94f58..172bd7da0 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.tsx'; diff --git a/packages/next-intl/src/middleware.tsx b/packages/next-intl/src/middleware.tsx index 0b94d4d5a..0943679ef 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.tsx'; diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.test.tsx index 2a0be8b3e..562331ec8 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.tsx'; +import type {Pathnames} from '../routing.tsx'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; describe.each([{basePath: undefined}, {basePath: '/base'}])( 'basePath: $basePath', diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index 4ce604ee2..52f627ba3 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.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {normalizeTrailingSlash} from '../shared/utils'; +} from '../routing/types.tsx'; +import {normalizeTrailingSlash} from '../shared/utils.tsx'; import { applyBasePath, formatTemplatePathname, @@ -14,7 +14,7 @@ import { getLocalePrefixes, getNormalizedPathname, isLocaleSupportedOnDomain -} from './utils'; +} from './utils.tsx'; /** * See https://developers.google.com/search/docs/specialty/international/localized-versions @@ -59,7 +59,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) { diff --git a/packages/next-intl/src/middleware/index.tsx b/packages/next-intl/src/middleware/index.tsx index ccc76b9f6..0cb9c5e88 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.tsx'; diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 7449040eb..ea4fd43f8 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.tsx'; +import {type Pathnames, defineRouting} from '../routing.tsx'; 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,13 @@ describe('prefix-based routing', () => { ); }); + 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(); @@ -1022,13 +1029,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 +1102,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('/'))); @@ -2025,22 +2043,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') @@ -2449,6 +2471,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')); @@ -3329,16 +3375,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..c41100d4e 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -1,20 +1,20 @@ -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.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {HEADER_LOCALE_NAME} from '../shared/constants'; +} from '../routing/types.tsx'; +import {HEADER_LOCALE_NAME} from '../shared/constants.tsx'; import { getLocalePrefix, matchesPathname, normalizeTrailingSlash -} from '../shared/utils'; -import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue'; -import resolveLocale from './resolveLocale'; -import syncCookie from './syncCookie'; +} from '../shared/utils.tsx'; +import getAlternateLinksHeaderValue from './getAlternateLinksHeaderValue.tsx'; +import resolveLocale from './resolveLocale.tsx'; +import syncCookie from './syncCookie.tsx'; import { applyBasePath, formatPathname, @@ -26,51 +26,22 @@ import { getPathnameMatch, isLocaleSupportedOnDomain, sanitizePathname -} from './utils'; +} from './utils.tsx'; 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; @@ -330,9 +301,7 @@ export default function createMiddleware< } } - if (resolvedRouting.localeDetection && resolvedRouting.localeCookie) { - syncCookie(request, response, locale, resolvedRouting.localeCookie); - } + syncCookie(request, response, locale, resolvedRouting, domain); if ( resolvedRouting.localePrefix.mode !== 'never' && diff --git a/packages/next-intl/src/middleware/resolveLocale.test.tsx b/packages/next-intl/src/middleware/resolveLocale.test.tsx index c8117757c..93a7bdfd8 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.tsx'; 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..ee4abdea0 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -1,15 +1,20 @@ 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.tsx'; +import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; -import {getHost, getPathnameMatch, isLocaleSupportedOnDomain} from './utils'; +} from '../routing/types.tsx'; +import { + getHost, + getPathnameMatch, + isLocaleSupportedOnDomain +} from './utils.tsx'; function findDomainFromHost( requestHeaders: Headers, @@ -32,7 +37,7 @@ function orderLocales(locales: AppLocales) { export function getAcceptLanguageLocale( requestHeaders: Headers, locales: AppLocales, - defaultLocale: string + defaultLocale: Locale ) { let locale; @@ -43,12 +48,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 } diff --git a/packages/next-intl/src/middleware/syncCookie.tsx b/packages/next-intl/src/middleware/syncCookie.tsx index c924b104b..b37015cd4 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.tsx'; +import type { + DomainConfig, + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../routing/types.tsx'; +import {getAcceptLanguageLocale} from './resolveLocale.tsx'; -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..61a0a1ecd 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.tsx'; 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..996368ac9 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -1,11 +1,12 @@ -import { +import type {Locale} from 'use-intl'; +import type { DomainConfig, DomainsConfig, LocalePrefixConfigVerbose, LocalePrefixMode, Locales, Pathnames -} from '../routing/types'; +} from '../routing/types.tsx'; import { getLocalePrefix, getSortedPathnames, @@ -13,7 +14,7 @@ import { normalizeTrailingSlash, prefixPathname, templateToRegex -} from '../shared/utils'; +} from '../shared/utils.tsx'; export function getFirstPathnameSegment(pathname: string) { return pathname.split('/')[1]; @@ -254,7 +255,7 @@ export function getHost(requestHeaders: Headers) { } export function isLocaleSupportedOnDomain( - locale: string, + locale: Locale, domain: DomainConfig ) { return ( @@ -266,7 +267,7 @@ export function isLocaleSupportedOnDomain( export function getBestMatchingDomain( curHostDomain: DomainConfig | undefined, - locale: string, + locale: Locale, domainsConfig: DomainsConfig ) { let domainConfig; diff --git a/packages/next-intl/src/navigation.react-client.tsx b/packages/next-intl/src/navigation.react-client.tsx index 773e086d1..1b752df45 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.tsx'; diff --git a/packages/next-intl/src/navigation.react-server.tsx b/packages/next-intl/src/navigation.react-server.tsx index c207e942e..de1da08cd 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.tsx'; 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 eacb29ea9..a6ea4c698 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -2,30 +2,33 @@ 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.tsx'; +import createNavigationClient from './react-client/createNavigation.tsx'; +import createNavigationServer from './react-server/createNavigation.tsx'; +import getServerLocale from './react-server/getServerLocale.tsx'; 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 +37,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) { @@ -110,6 +111,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 +229,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 +328,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 +387,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(); + }); }); }); 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..b5893db41 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.tsx'; +import createNavigation from './createNavigation.tsx'; -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( @@ -113,29 +112,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 +147,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 +208,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' @@ -363,7 +347,7 @@ describe("localePrefix: 'always', with `basePath`", () => { expect(cookieSpy).toHaveBeenCalledWith( [ 'NEXT_LOCALE=de', - 'max-age=31536000', + 'max-age=18000', 'sameSite=lax', 'path=/base/path' ].join(';') + ';' @@ -777,7 +761,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..68f97e3b9 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.tsx'; +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.tsx'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.tsx'; +import syncLocaleCookie from '../shared/syncLocaleCookie.tsx'; +import {getRoute} from '../shared/utils.tsx'; +import useBasePathname from './useBasePathname.tsx'; 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,7 +81,7 @@ 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 || {}; 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..c3cb1a965 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.tsx'; +export type {QueryParams} from '../shared/utils.tsx'; 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..715256f4e 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.tsx'; +import useBasePathname from './useBasePathname.tsx'; -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..5e283ff17 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.tsx'; import { getLocaleAsPrefix, getLocalePrefix, hasPathnamePrefixed, unprefixPathname -} from '../../shared/utils'; +} from '../../shared/utils.tsx'; 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..73277434f 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.tsx'; 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..96c921c58 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.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import createSharedNavigationFns from '../shared/createSharedNavigationFns'; -import getServerLocale from './getServerLocale'; +} from '../../routing/types.tsx'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns.tsx'; +import getServerLocale from './getServerLocale.tsx'; 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..e6bd11965 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.tsx'; /** * 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..7bc2e12ed 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.tsx'; +export type {Pathnames} from '../../routing/types.tsx'; 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..10aa07c93 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -1,25 +1,29 @@ 'use client'; -import NextLink from 'next/link'; -import {usePathname} from 'next/navigation'; -import React, { - ComponentProps, - MouseEvent, +import NextLink, {type LinkProps} from 'next/link.js'; +import {usePathname} from 'next/navigation.js'; +import { + type ComponentProps, + type MouseEvent, + type Ref, forwardRef, useEffect, useState } 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.tsx'; +import syncLocaleCookie from './syncLocaleCookie.tsx'; -type Props = Omit, 'locale'> & { - locale?: string; - defaultLocale?: string; +type NextLinkProps = Omit, keyof LinkProps> & + Omit; + +type Props = NextLinkProps & { + locale?: Locale; + defaultLocale?: Locale; localeCookie: InitializedLocaleCookieConfig; /** Special case for `localePrefix: 'as-needed'` and `domains`. */ unprefixed?: { - domains: {[domain: string]: string}; + domains: {[domain: string]: Locale}; pathname: string; }; }; @@ -35,7 +39,7 @@ function BaseLink( unprefixed, ...rest }: Props, - ref: ComponentProps['ref'] + ref: Ref ) { const curLocale = useLocale(); const isChangingLocale = locale != null && locale !== curLocale; @@ -76,8 +80,12 @@ 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 ( - , '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 aa05d2a68..9d973300d 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -1,37 +1,37 @@ 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 { +} from '../../routing/config.tsx'; +import type { DomainConfig, DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; -import {ParametersExceptFirst, Prettify} from '../../shared/types'; -import {isLocalizableHref} from '../../shared/utils'; -import BaseLink from './BaseLink'; +} from '../../routing/types.tsx'; +import type {ParametersExceptFirst, Prettify} from '../../shared/types.tsx'; +import use from '../../shared/use.tsx'; +import {isLocalizableHref} from '../../shared/utils.tsx'; +import BaseLink from './BaseLink.tsx'; import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - QueryParams, + type HrefOrHrefWithParams, + type HrefOrUrlObjectWithParams, + type QueryParams, applyPathnamePrefix, compileLocalizedPathname, normalizeNameOrNameWithParams, serializeSearchParams, validateReceivedConfig -} from './utils'; +} from './utils.tsx'; type PromiseOrValue = Type | Promise; -type UnwrapPromiseOrValue = - Type extends Promise ? Value : Type; /** * Shared implementations for `react-server` and `react-client` @@ -42,9 +42,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 +58,6 @@ export default function createSharedNavigationFns< AppDomains > ) { - type Locale = UnwrapPromiseOrValue>; - const config = receiveRoutingConfig(routing || {}); if (process.env.NODE_ENV !== 'production') { validateReceivedConfig(config); @@ -91,7 +87,7 @@ export default function createSharedNavigationFns< ? ComponentProps['href'] : HrefOrUrlObjectWithParams; /** @see https://next-intl.dev/docs/routing/navigation#link */ - locale?: string; + locale?: Locale; } >; function Link( @@ -147,10 +143,9 @@ export default function createSharedNavigationFns< ? { domains: (config as any).domains.reduce( ( - acc: Record, + acc: Record, domain: DomainConfig ) => { - // @ts-expect-error -- This is ok acc[domain.domain] = domain.defaultLocale; return acc; }, @@ -193,7 +188,7 @@ export default function createSharedNavigationFns< href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - locale: string; + locale: Locale; } & DomainConfigForAsNeeded, /** @private Removed in types returned below */ _forcePrefix?: boolean 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..41b42531a 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.tsx'; +import {getBasePath} from './utils.tsx'; /** * 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..1e99fe485 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.tsx'; 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..26f6372f0 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,12 +1,13 @@ 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.tsx'; +import type { DomainsConfig, LocalePrefixMode, Locales, Pathnames -} from '../../routing/types'; +} from '../../routing/types.tsx'; import { getLocalePrefix, getSortedPathnames, @@ -14,8 +15,8 @@ import { matchesPathname, normalizeTrailingSlash, prefixPathname -} from '../../shared/utils'; -import StrictParams from './StrictParams'; +} from '../../shared/utils.tsx'; +import type StrictParams from './StrictParams.tsx'; type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput]; @@ -49,7 +50,7 @@ export function normalizeNameOrNameWithParams( href: | HrefOrHrefWithParams | { - locale: string; + locale: Locale; href: HrefOrHrefWithParams; } ): { diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index 96a2ec8a8..ef53890a0 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.tsx'; diff --git a/packages/next-intl/src/plugin/createMessagesDeclaration.tsx b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx new file mode 100644 index 000000000..1fc6b2427 --- /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.tsx'; + +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(messagesPath: string) { + 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}` + ); + } + + const isDev = process.argv.includes('dev'); + const isBuild = process.argv.includes('build'); + + if (!isDev && !isBuild) { + return; + } + + // Next.js can call the Next.js config multiple + // times - ensure we only run once. + runOnce(() => { + compileDeclaration(messagesPath); + + if (isDev) { + startWatching(messagesPath); + } + }); +} + +function startWatching(messagesPath: string) { + const watcher = fs.watch(messagesPath, (eventType) => { + if (eventType === 'change') { + 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..c6464a960 --- /dev/null +++ b/packages/next-intl/src/plugin/createNextIntlPlugin.tsx @@ -0,0 +1,36 @@ +import type {NextConfig} from 'next'; +import createMessagesDeclaration from './createMessagesDeclaration.tsx'; +import getNextConfig from './getNextConfig.tsx'; +import type {PluginConfig} from './types.tsx'; +import {warn} from './utils.tsx'; + +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" + ); + } + + if (pluginConfig.experimental?.createMessagesDeclaration) { + createMessagesDeclaration( + pluginConfig.experimental.createMessagesDeclaration + ); + } + + 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..f417a0028 --- /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.tsx'; +import {throwError} from './utils.tsx'; + +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..d3403203e --- /dev/null +++ b/packages/next-intl/src/plugin/index.tsx @@ -0,0 +1 @@ +export {default} from './createNextIntlPlugin.tsx'; diff --git a/packages/next-intl/src/plugin/types.tsx b/packages/next-intl/src/plugin/types.tsx new file mode 100644 index 000000000..915461238 --- /dev/null +++ b/packages/next-intl/src/plugin/types.tsx @@ -0,0 +1,6 @@ +export type PluginConfig = { + requestConfig?: string; + experimental?: { + createMessagesDeclaration?: string; + }; +}; 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/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx index 335f33d66..dbfac9656 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.tsx'; diff --git a/packages/next-intl/src/react-client/useFormatter.test.tsx b/packages/next-intl/src/react-client/useFormatter.test.tsx index a1ec1bff2..d77ad0825 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.tsx'; 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..82d134004 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.tsx'; 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..bae67114c 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.tsx'; 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..ac1264e8c 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.tsx'; 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..013d5e0fd 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,14 +1,29 @@ 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.tsx'; +import getFormats from '../server/react-server/getFormats.tsx'; +import {getLocale, getTimeZone} from '../server.react-server.tsx'; +import NextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; +import NextIntlClientProviderServer from './NextIntlClientProviderServer.tsx'; 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')), 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 +33,8 @@ 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: {} }); expect(result.type).toBe(NextIntlClientProvider); @@ -26,12 +42,14 @@ 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: {} }); expect(getLocale).not.toHaveBeenCalled(); - expect(getNow).not.toHaveBeenCalled(); + expect(getConfigNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); + expect(getFormats).not.toHaveBeenCalled(); }); it('reads missing configuration from getter functions', async () => { @@ -44,10 +62,18 @@ 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', + formats: { + dateTime: { + short: { + day: 'numeric' + } + } + } }); expect(getLocale).toHaveBeenCalled(); - expect(getNow).toHaveBeenCalled(); + expect(getConfigNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); + expect(getFormats).toHaveBeenCalled(); }); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index 941187dcc..abf19ee30 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,10 +1,13 @@ -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.tsx'; +import getFormats from '../server/react-server/getFormats.tsx'; +import {getLocale, getTimeZone} from '../server.react-server.tsx'; +import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.tsx'; type Props = ComponentProps; export default async function NextIntlClientProviderServer({ + formats, locale, now, timeZone, @@ -14,8 +17,12 @@ 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 ab84710ce..1bbe1511b 100644 --- a/packages/next-intl/src/react-server/index.test.tsx +++ b/packages/next-intl/src/react-server/index.test.tsx @@ -1,7 +1,5 @@ -import React from 'react'; import {describe, expect, it, vi} from 'vitest'; -import {getTranslations} from '../server.react-server'; -import {renderToStream} from './testUtils'; +import {getTranslations} from '../server.react-server.tsx'; import { _createCache, useFormatter, @@ -9,7 +7,8 @@ import { useMessages, useNow, useTranslations -} from '.'; +} from './index.tsx'; +import {renderToStream} from './testUtils.tsx'; 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..f7bc68c7d 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.tsx'; +export {default as useTranslations} from './useTranslations.tsx'; +export {default as useFormatter} from './useFormatter.tsx'; +export {default as useNow} from './useNow.tsx'; +export {default as useTimeZone} from './useTimeZone.tsx'; +export {default as useMessages} from './useMessages.tsx'; +export {default as NextIntlClientProvider} from './NextIntlClientProviderServer.tsx'; // 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..fcba08ca5 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.tsx'; +import use from '../shared/use.tsx'; 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..e6b5a3bee --- /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.tsx'; +import {renderToStream} from './testUtils.tsx'; +import useFormatter from './useFormatter.tsx'; + +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..7e41b86b6 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.tsx'; +import useConfig from './useConfig.tsx'; -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..a517f0afc 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.tsx'; -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..789304044 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.tsx'; +import useConfig from './useConfig.tsx'; -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..3f103db46 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.tsx'; +import useConfig from './useConfig.tsx'; 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..c527b20c1 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.tsx'; -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..6631d3943 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 {createTranslator, useTranslations} from './index.tsx'; +import {renderToStream} from './testUtils.tsx'; 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..836b415bf 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.tsx'; +import useConfig from './useConfig.tsx'; 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..3cb1cbc99 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.tsx'; diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 46d357d85..83f05bf1a 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.tsx'; type CookieAttributes = Pick< NonNullable['2']>, @@ -151,13 +151,13 @@ export function receiveRoutingConfig< }; } -export function receiveLocaleCookie( +function receiveLocaleCookie( localeCookie?: boolean | CookieAttributes ): InitializedLocaleCookieConfig { return (localeCookie ?? true) ? { name: 'NEXT_LOCALE', - maxAge: 31536000, // 1 year + maxAge: 5 * 60 * 60, // 5 hours sameSite: 'lax', ...(typeof localeCookie === 'object' && localeCookie) @@ -175,7 +175,7 @@ export type LocaleCookieConfig = Omit< > & 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..d3ea1fa2a 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 defineRouting from './defineRouting.tsx'; describe('defaultLocale', () => { it('ensures the `defaultLocale` is within `locales`', () => { diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index d470db867..1b34187ce 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -1,5 +1,11 @@ -import {RoutingConfig} from './config'; -import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types'; +import type {RoutingConfig} from './config.tsx'; +import type { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from './types.tsx'; +import validateLocales from './validateLocales.tsx'; export default function defineRouting< const AppLocales extends Locales, @@ -14,5 +20,8 @@ export default function defineRouting< AppDomains > ) { + if (process.env.NODE_ENV !== 'production') { + validateLocales(config.locales); + } return config; } diff --git a/packages/next-intl/src/routing/index.tsx b/packages/next-intl/src/routing/index.tsx index ac10d4159..6a8aa9cd7 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.tsx'; +export {default as defineRouting} from './defineRouting.tsx'; +export type {RoutingConfig} from './config.tsx'; diff --git a/packages/next-intl/src/routing/types.test.tsx b/packages/next-intl/src/routing/types.test.tsx index 0cd4bc418..5b93b4deb 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.tsx'; describe('LocalePrefix', () => { it('does not require a type param for simple values', () => { diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index d47c5ee2e..48929e150 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'; diff --git a/packages/next-intl/src/routing/validateLocales.test.tsx b/packages/next-intl/src/routing/validateLocales.test.tsx new file mode 100644 index 000000000..5e6d0f851 --- /dev/null +++ b/packages/next-intl/src/routing/validateLocales.test.tsx @@ -0,0 +1,71 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import validateLocales from './validateLocales.tsx'; + +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) => { + validateLocales([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) => { + validateLocales([locale]); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/next-intl/src/routing/validateLocales.tsx b/packages/next-intl/src/routing/validateLocales.tsx new file mode 100644 index 000000000..ba09f20b7 --- /dev/null +++ b/packages/next-intl/src/routing/validateLocales.tsx @@ -0,0 +1,16 @@ +import type {Locales} from './types.tsx'; + +export default function validateLocales(locales: Locales) { + for (const locale of locales) { + try { + const constructed = new Intl.Locale(locale); + if (!constructed.language) { + throw new Error('Language is required'); + } + } catch { + console.error( + `Found invalid locale within provided \`locales\`: "${locale}"\nPlease ensure you're using a valid Unicode locale identifier (e.g. "en-US").` + ); + } + } +} diff --git a/packages/next-intl/src/server.react-client.tsx b/packages/next-intl/src/server.react-client.tsx index 5d866c5b5..4461b2caf 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.tsx'; diff --git a/packages/next-intl/src/server.react-server.tsx b/packages/next-intl/src/server.react-server.tsx index 4e88250c9..d5cd6fcab 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.tsx'; 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..9ad50db29 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.tsx'; 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..5dbc371ec 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.tsx'; /** * 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 2a62aeaaa..2d9d5492f 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -1,7 +1,8 @@ -import {headers} from 'next/headers'; +import {headers} from 'next/headers.js'; import {cache} from 'react'; -import {HEADER_LOCALE_NAME} from '../../shared/constants'; -import {getCachedRequestLocale} from './RequestLocaleCache'; +import type {Locale} from 'use-intl'; +import {HEADER_LOCALE_NAME} from '../../shared/constants.tsx'; +import {getCachedRequestLocale} from './RequestLocaleCache.tsx'; async function getHeadersImpl(): Promise { const promiseOrValue = headers(); @@ -13,7 +14,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..a433c1f13 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.tsx'; 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 362dc915f..4b07ff97b 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -1,24 +1,14 @@ -import {notFound} from 'next/navigation'; import {cache} from 'react'; import { - IntlConfig, + type IntlConfig, + type Locale, _createCache, _createIntlFormatters, initializeConfig } from 'use-intl/core'; -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 {getRequestLocale} from './RequestLocale.tsx'; +import createRequestConfig from './createRequestConfig.tsx'; +import type {GetRequestConfigParams} from './getRequestConfig.tsx'; // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component @@ -29,7 +19,7 @@ const getDefaultTimeZone = cache(getDefaultTimeZoneImpl); async function receiveRuntimeConfigImpl( getConfig: typeof createRequestConfig, - localeOverride?: string + localeOverride?: Locale ) { if ( process.env.NODE_ENV !== 'production' && @@ -48,22 +38,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) @@ -76,46 +55,22 @@ 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' + ); } - 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< +async function getConfigImpl(localeOverride?: Locale): Promise< IntlConfig & { getMessageFallback: NonNullable; - now: NonNullable; onError: NonNullable; timeZone: NonNullable; _formatters: ReturnType; @@ -127,7 +82,8 @@ async function getConfigImpl(localeOverride?: string): Promise< ); 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..1a0750dc9 --- /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.tsx'; + +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..4532b90ec --- /dev/null +++ b/packages/next-intl/src/server/react-server/getFormats.tsx @@ -0,0 +1,10 @@ +import {cache} from 'react'; +import getConfig from './getConfig.tsx'; + +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..2499706da --- /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.tsx'; +import getFormatter from './getFormatter.tsx'; + +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..f90c99ea8 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.tsx'; +import getServerFormatter from './getServerFormatter.tsx'; -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..4911306c1 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.tsx'; -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..b752a99b6 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.tsx'; 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..8daf796e8 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.tsx'; +import getDefaultNow from './getDefaultNow.tsx'; -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..5beeb0820 --- /dev/null +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -0,0 +1,21 @@ +import {cache} from 'react'; +import {createFormatter} from 'use-intl/core'; +import getDefaultNow from './getDefaultNow.tsx'; + +function getFormatterCachedImpl(config: Parameters[0]) { + // same here? + // also add a test + // also for getTranslations/useTranslations + // add a test with a getter maybe, don't mock + 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..fca1601f9 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.tsx'; -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..5a19977c5 --- /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.tsx'; + +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..51690d083 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.tsx'; +import getServerTranslator from './getServerTranslator.tsx'; // 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..8f6054387 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.tsx'; import { getFormatter, getMessages, getNow, getTimeZone, getTranslations -} from '.'; +} from './index.tsx'; 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..524ae9fa0 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.tsx'; +export {default as getFormatter} from './getFormatter.tsx'; +export {default as getNow} from './getNow.tsx'; +export {default as getTimeZone} from './getTimeZone.tsx'; +export {default as getTranslations} from './getTranslations.tsx'; +export {default as getMessages} from './getMessages.tsx'; +export {default as getLocale} from './getLocale.tsx'; -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.tsx'; 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..b16504cb6 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.tsx'; 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 bd6b28065..02c6960f1 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,13 +1,11 @@ -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'; +} from '../routing/types.tsx'; -type Href = ComponentProps['href']; +type Href = LinkProps['href']; function isRelativeHref(href: Href) { const pathname = typeof href === 'object' ? href.pathname : href; @@ -27,61 +25,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}`), '') || '/'; } 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..f32652031 100644 --- a/packages/next-intl/tsconfig.json +++ b/packages/next-intl/tsconfig.json @@ -2,20 +2,15 @@ "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "__mocks__", "types", "next-env.d.ts"], "compilerOptions": { - "moduleDetection": "force", - "isolatedModules": true, - "module": "esnext", + "allowImportingTsExtensions": true, // For ESM "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..c6dae5c70 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: '12.945 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: '1.98 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..48b5300b4 100644 --- a/packages/use-intl/eslint.config.mjs +++ b/packages/use-intl/eslint.config.mjs @@ -6,6 +6,12 @@ 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}] } }); diff --git a/packages/use-intl/package.json b/packages/use-intl/package.json index 45ee6b801..3cf3d35b7 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..4a80db990 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.tsx'; 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..6fca63f22 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.tsx'; /** * 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..96eb1e6be 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.tsx'; +import type NumberFormatOptions from './NumberFormatOptions.tsx'; 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..e5b379a9d 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -1,8 +1,8 @@ -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 AbstractIntlMessages from './AbstractIntlMessages.tsx'; +import type {Locale} from './AppConfig.tsx'; +import type Formats from './Formats.tsx'; +import type IntlError from './IntlError.tsx'; +import type TimeZone from './TimeZone.tsx'; /** * Should be used for entry points that configure the library. @@ -10,7 +10,7 @@ import type {RichTranslationValues} from './TranslationValues'; 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; @@ -41,13 +41,6 @@ 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; }; /** diff --git a/packages/use-intl/src/core/IntlError.tsx b/packages/use-intl/src/core/IntlError.tsx index cfce8f64b..aa25d0385 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.tsx'; 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..71c76e64b 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.tsx'; +import type TimeZone from './TimeZone.tsx'; /** * `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..c4ac43ec0 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.tsx'; +import type {Locale} from './AppConfig.tsx'; +import type Formats from './Formats.tsx'; +import type {InitializedIntlConfig} from './IntlConfig.tsx'; +import IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; +import type {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.tsx'; +import type { MarkupTranslationValues, - RichTranslationValues -} from './TranslationValues'; -import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat'; -import {defaultGetMessageFallback, defaultOnError} from './defaults'; + RichTranslationValues, + TranslationValues +} from './TranslationValues.tsx'; +import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat.tsx'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; 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.tsx'; +import joinPath from './joinPath.tsx'; // 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..cd7014cc9 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.tsx'; describe('dateTime', () => { it('formats a date and time', () => { diff --git a/packages/use-intl/src/core/createFormatter.tsx b/packages/use-intl/src/core/createFormatter.tsx index f4064fb68..2d92573e5 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.tsx'; +import type DateTimeFormatOptions from './DateTimeFormatOptions.tsx'; +import type Formats from './Formats.tsx'; +import IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; +import type NumberFormatOptions from './NumberFormatOptions.tsx'; +import type RelativeTimeFormatOptions from './RelativeTimeFormatOptions.tsx'; +import type TimeZone from './TimeZone.tsx'; +import {defaultOnError} from './defaults.tsx'; import { - Formatters, - IntlCache, + type Formatters, + type IntlCache, createCache, createIntlFormatters -} from './formatters'; +} from './formatters.tsx'; 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) { @@ -122,7 +125,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); @@ -163,9 +166,7 @@ export default function createFormatter({ 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 + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -185,9 +186,7 @@ export default function createFormatter({ 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 + formatOrOptions?: FormatNames['dateTime'] | DateTimeFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -204,9 +203,7 @@ export default function createFormatter({ function number( value: number | bigint, - formatOrOptions?: - | Extract - | NumberFormatOptions + formatOrOptions?: FormatNames['number'] | NumberFormatOptions ) { return getFormattedValue( formatOrOptions, @@ -217,14 +214,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 +234,7 @@ 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. */ nowOrOptions?: RelativeTimeFormatOptions['now'] | RelativeTimeFormatOptions ) { try { @@ -290,9 +289,7 @@ export default function createFormatter({ type FormattableListValue = string | ReactElement; function list( value: Iterable, - formatOrOptions?: - | Extract - | Intl.ListFormatOptions + formatOrOptions?: FormatNames['list'] | Intl.ListFormatOptions ): Value extends string ? string : Iterable { const serializedValue: Array = []; const richValues = new Map(); diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index 159715758..1a4a4c9bd 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.tsx'; +import type IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; +import createTranslator from './createTranslator.tsx'; 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..eef02b5da 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,22 +1,97 @@ -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 {Messages} from './AppConfig.tsx'; +import type Formats from './Formats.tsx'; +import type ICUArgs from './ICUArgs.tsx'; +import type ICUTags from './ICUTags.tsx'; +import type IntlConfig from './IntlConfig.tsx'; +import type { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.tsx'; +import type { + MarkupTagsFunction, + RichTagsFunction, + TranslationValues +} from './TranslationValues.tsx'; +import createTranslatorImpl from './createTranslatorImpl.tsx'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; 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.tsx'; +import type {Prettify} from './types.tsx'; + +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; + +type NamespacedMessageKeys< + TranslatorMessages extends Messages, + Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + > = never +> = MessageKeys< + NestedValueOf< + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` + >, + NestedKeyOf< + NestedValueOf< + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` + > + > +>; + +type NamespacedValue< + TranslatorMessages extends Messages, + 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 +102,10 @@ import NestedValueOf from './utils/NestedValueOf'; * (e.g. `namespace.Component`). */ export default function createTranslator< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf + const TranslatorMessages extends Messages = Messages, + const Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf > = never >({ _cache = createCache(), @@ -39,9 +115,9 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'defaultTranslationValues' | 'messages'> & { - messages?: IntlConfig['messages']; - namespace?: NestedKey; +}: Omit, 'messages'> & { + messages?: TranslatorMessages; + namespace?: Namespace; /** @private */ _formatters?: Formatters; /** @private */ @@ -49,107 +125,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..a87097776 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.tsx'; +import type {InitializedIntlConfig} from './IntlConfig.tsx'; +import type {NestedKeyOf} from './MessageKeys.tsx'; +import createBaseTranslator from './createBaseTranslator.tsx'; +import type {Formatters, IntlCache} from './formatters.tsx'; +import resolveNamespace from './resolveNamespace.tsx'; 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..f86619763 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.tsx'; +import joinPath from './joinPath.tsx'; /** * 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..36c611f3b --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.test.tsx @@ -0,0 +1,35 @@ +import {expect, it} from 'vitest'; +import hasLocale from './hasLocale.tsx'; + +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..576ee3289 --- /dev/null +++ b/packages/use-intl/src/core/hasLocale.tsx @@ -0,0 +1,13 @@ +import type {Locale} from './AppConfig.tsx'; + +/** + * 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..4f5c6e503 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.tsx'; 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.tsx'; +export type {default as Formats} from './Formats.tsx'; +export type {default as IntlConfig} from './IntlConfig.tsx'; +export type {default as DateTimeFormatOptions} from './DateTimeFormatOptions.tsx'; +export type {default as NumberFormatOptions} from './NumberFormatOptions.tsx'; +export {default as IntlError} from './IntlError.tsx'; +export {default as IntlErrorCode} from './IntlErrorCode.tsx'; +export {default as createTranslator} from './createTranslator.tsx'; +export {default as createFormatter} from './createFormatter.tsx'; +export {default as initializeConfig} from './initializeConfig.tsx'; +export type { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.tsx'; +export {createIntlFormatters as _createIntlFormatters} from './formatters.tsx'; +export {createCache as _createCache} from './formatters.tsx'; +export type {default as AppConfig, Locale, Messages} from './AppConfig.tsx'; +export {default as hasLocale} from './hasLocale.tsx'; +export type {default as RelativeTimeFormatOptions} from './RelativeTimeFormatOptions.tsx'; +export type {default as Timezone} from './TimeZone.tsx'; +export type {default as ICUArgs} from './ICUArgs.tsx'; +export type {default as ICUTags} from './ICUTags.tsx'; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index a784b2ce2..3a52d6c6b 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.tsx'; +import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; +import validateMessages from './validateMessages.tsx'; /** * Enhances the incoming props with defaults. diff --git a/packages/use-intl/src/core/types.tsx b/packages/use-intl/src/core/types.tsx new file mode 100644 index 000000000..654654c8a --- /dev/null +++ b/packages/use-intl/src/core/types.tsx @@ -0,0 +1,4 @@ +// https://www.totaltypescript.com/concepts/the-prettify-helper +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; 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..d8f4b31f0 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.tsx'; +import IntlError from './IntlError.tsx'; +import IntlErrorCode from './IntlErrorCode.tsx'; +import joinPath from './joinPath.tsx'; function validateMessagesSegment( messages: AbstractIntlMessages, diff --git a/packages/use-intl/src/index.tsx b/packages/use-intl/src/index.tsx index 78a29b8b5..a7f352cea 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.tsx'; +export * from './react.tsx'; diff --git a/packages/use-intl/src/react.tsx b/packages/use-intl/src/react.tsx index f97dcae79..858a6b562 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.tsx'; diff --git a/packages/use-intl/src/react/IntlContext.tsx b/packages/use-intl/src/react/IntlContext.tsx index 3606b83d6..1ba115907 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.tsx'; +import type {Formatters, IntlCache} from '../core/formatters.tsx'; 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..fccc57726 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.tsx'; +import useNow from './useNow.tsx'; +import useTranslations from './useTranslations.tsx'; it("doesn't re-render context consumers unnecessarily", () => { const messages = {StaticText: {hello: 'Hello!'}}; @@ -44,3 +45,107 @@ 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); +}); diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index f62da4c8e..58e5e1abc 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.tsx'; import { - Formatters, + type Formatters, createCache, createIntlFormatters -} from '../core/formatters'; -import initializeConfig from '../core/initializeConfig'; -import IntlContext from './IntlContext'; +} from '../core/formatters.tsx'; +import initializeConfig from '../core/initializeConfig.tsx'; +import IntlContext from './IntlContext.tsx'; 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 || prevContext?.formats, + getMessageFallback: + getMessageFallback || prevContext?.getMessageFallback, + messages: messages || prevContext?.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..225aa933b 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.tsx'; +import IntlProvider from './IntlProvider.tsx'; +import useFormatter from './useFormatter.tsx'; +import useNow from './useNow.tsx'; +import useTranslations from './useTranslations.tsx'; describe('performance', () => { beforeEach(() => { @@ -25,7 +25,7 @@ describe('performance', () => { ); } - function App({locale}: {locale: string}) { + function App({locale}: {locale: Locale}) { return ( > & {children: ReactNode} @@ -207,7 +207,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 +234,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 +279,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'); }); @@ -399,7 +397,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'); @@ -622,9 +620,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); }); }); diff --git a/packages/use-intl/src/react/useFormatter.tsx b/packages/use-intl/src/react/useFormatter.tsx index abfa58e8e..c551c7333 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.tsx'; +import useIntlContext from './useIntlContext.tsx'; 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..ec688ca75 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.tsx'; 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..732aee4d7 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.tsx'; +import useLocale from './useLocale.tsx'; 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..621b18847 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.tsx'; +import useIntlContext from './useIntlContext.tsx'; -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..d8563b0e1 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.tsx'; +import useMessages from './useMessages.tsx'; 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..987637ccf 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.tsx'; +import useIntlContext from './useIntlContext.tsx'; -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..0e43f2eed 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.tsx'; +import useNow from './useNow.tsx'; 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..3c0b054c4 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.tsx'; 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..57d4031e3 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.tsx'; +import useTimeZone from './useTimeZone.tsx'; 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..f95f5b719 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.tsx'; 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..cabcc94ab 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.tsx'; +import IntlProvider from './IntlProvider.tsx'; +import useTranslations from './useTranslations.tsx'; // 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( @@ -776,9 +776,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'); }); @@ -989,89 +987,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 +996,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..57d42b11b 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.tsx'; +import type {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.tsx'; +import type createTranslator from '../core/createTranslator.tsx'; +import useIntlContext from './useIntlContext.tsx'; +import useTranslationsImpl from './useTranslationsImpl.tsx'; /** * 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..243397100 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.tsx'; +import type {NestedKeyOf} from '../core/MessageKeys.tsx'; +import createBaseTranslator from '../core/createBaseTranslator.tsx'; +import resolveNamespace from '../core/resolveNamespace.tsx'; +import {IntlError, IntlErrorCode} from '../core.tsx'; +import useIntlContext from './useIntlContext.tsx'; 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..1a0feb584 100644 --- a/packages/use-intl/tsconfig.json +++ b/packages/use-intl/tsconfig.json @@ -1,24 +1,16 @@ { + "extends": "eslint-config-molindo/tsconfig.json", "include": ["src", "test", "types"], "compilerOptions": { - "module": "esnext", + "allowImportingTsExtensions": true, // For ESM "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 ae2e5c120..ec944187c 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.7.9)(typescript@5.6.3))(@lerna-lite/version@3.10.0(@lerna-lite/publish@3.10.0(@types/node@22.7.9)(typescript@5.6.3))(@types/node@22.7.9)(typescript@5.6.3))(@types/node@22.7.9)(typescript@5.6.3) '@lerna-lite/publish': specifier: ^3.9.0 version: 3.10.0(@types/node@22.7.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 @@ -88,10 +55,10 @@ importers: 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) nextra: specifier: ^3.1.0 - version: 3.1.0(@types/react@18.3.12)(acorn@8.13.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.13.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.13.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.13.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 @@ -299,7 +266,7 @@ importers: 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) next-auth: specifier: ^4.24.7 - version: 4.24.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))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.8(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) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -416,6 +383,9 @@ importers: 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@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.36.0)) + globals: + specifier: ^15.11.0 + version: 15.11.0 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.17.0) @@ -647,16 +617,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: eslint: specifier: ^9.11.1 @@ -801,12 +771,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': @@ -860,6 +830,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 @@ -872,14 +845,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': @@ -927,6 +903,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 @@ -934,6 +913,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.36.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.7.9))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@22.7.9)(jsdom@25.0.1)(terser@5.36.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': @@ -1045,13 +1066,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'} '@babel/code-frame@7.10.4': @@ -1081,10 +1102,6 @@ packages: resolution: {integrity: sha512-omlUGkr5EaoIJrhLf9CJ0TvjBRpd9+AXRG//0GEQ9THSo8wPiTlbpy1/Ow8ZTrbXpjd9FHXfbFQx32I04ht0FA==} 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'} @@ -1103,12 +1120,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'} @@ -1136,10 +1147,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'} @@ -1900,9 +1907,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'} @@ -2020,6 +2024,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.24.0': + resolution: {integrity: sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.17.6': resolution: {integrity: sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg==} engines: {node: '>=12'} @@ -2044,6 +2054,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-arm@0.17.6': resolution: {integrity: sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g==} engines: {node: '>=12'} @@ -2068,6 +2084,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-x64@0.17.6': resolution: {integrity: sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ==} engines: {node: '>=12'} @@ -2092,6 +2114,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/darwin-arm64@0.17.6': resolution: {integrity: sha512-bsDRvlbKMQMt6Wl08nHtFz++yoZHsyTOxnjfB2Q95gato+Yi4WnRl13oC2/PJJA9yLCoRv9gqT/EYX0/zDsyMA==} engines: {node: '>=12'} @@ -2116,6 +2144,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-x64@0.17.6': resolution: {integrity: sha512-xh2A5oPrYRfMFz74QXIQTQo8uA+hYzGWJFoeTE8EvoZGHb+idyV4ATaukaUvnnxJiauhs/fPx3vYhU4wiGfosg==} engines: {node: '>=12'} @@ -2140,6 +2174,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/freebsd-arm64@0.17.6': resolution: {integrity: sha512-EnUwjRc1inT4ccZh4pB3v1cIhohE2S4YXlt1OvI7sw/+pD+dIE4smwekZlEPIwY6PhU6oDWwITrQQm5S2/iZgg==} engines: {node: '>=12'} @@ -2164,6 +2204,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-x64@0.17.6': resolution: {integrity: sha512-Uh3HLWGzH6FwpviUcLMKPCbZUAFzv67Wj5MTwK6jn89b576SR2IbEp+tqUHTr8DIl0iDmBAf51MVaP7pw6PY5Q==} engines: {node: '>=12'} @@ -2188,6 +2234,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/linux-arm64@0.17.6': resolution: {integrity: sha512-bUR58IFOMJX523aDVozswnlp5yry7+0cRLCXDsxnUeQYJik1DukMY+apBsLOZJblpH+K7ox7YrKrHmJoWqVR9w==} engines: {node: '>=12'} @@ -2212,6 +2264,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-arm@0.17.6': resolution: {integrity: sha512-7YdGiurNt7lqO0Bf/U9/arrPWPqdPqcV6JCZda4LZgEn+PTQ5SMEI4MGR52Bfn3+d6bNEGcWFzlIxiQdS48YUw==} engines: {node: '>=12'} @@ -2236,6 +2294,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-ia32@0.17.6': resolution: {integrity: sha512-ujp8uoQCM9FRcbDfkqECoARsLnLfCUhKARTP56TFPog8ie9JG83D5GVKjQ6yVrEVdMie1djH86fm98eY3quQkQ==} engines: {node: '>=12'} @@ -2260,6 +2324,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-loong64@0.17.6': resolution: {integrity: sha512-y2NX1+X/Nt+izj9bLoiaYB9YXT/LoaQFYvCkVD77G/4F+/yuVXYCWz4SE9yr5CBMbOxOfBcy/xFL4LlOeNlzYQ==} engines: {node: '>=12'} @@ -2284,6 +2354,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-mips64el@0.17.6': resolution: {integrity: sha512-09AXKB1HDOzXD+j3FdXCiL/MWmZP0Ex9eR8DLMBVcHorrWJxWmY8Nms2Nm41iRM64WVx7bA/JVHMv081iP2kUA==} engines: {node: '>=12'} @@ -2308,6 +2384,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-ppc64@0.17.6': resolution: {integrity: sha512-AmLhMzkM8JuqTIOhxnX4ubh0XWJIznEynRnZAVdA2mMKE6FAfwT2TWKTwdqMG+qEaeyDPtfNoZRpJbD4ZBv0Tg==} engines: {node: '>=12'} @@ -2332,6 +2414,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-riscv64@0.17.6': resolution: {integrity: sha512-Y4Ri62PfavhLQhFbqucysHOmRamlTVK10zPWlqjNbj2XMea+BOs4w6ASKwQwAiqf9ZqcY9Ab7NOU4wIgpxwoSQ==} engines: {node: '>=12'} @@ -2356,6 +2444,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-s390x@0.17.6': resolution: {integrity: sha512-SPUiz4fDbnNEm3JSdUW8pBJ/vkop3M1YwZAVwvdwlFLoJwKEZ9L98l3tzeyMzq27CyepDQ3Qgoba44StgbiN5Q==} engines: {node: '>=12'} @@ -2380,6 +2474,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-x64@0.17.6': resolution: {integrity: sha512-a3yHLmOodHrzuNgdpB7peFGPx1iJ2x6m+uDvhP2CKdr2CwOaqEFMeSqYAHU7hG+RjCq8r2NFujcd/YsEsFgTGw==} engines: {node: '>=12'} @@ -2404,6 +2504,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/netbsd-x64@0.17.6': resolution: {integrity: sha512-EanJqcU/4uZIBreTrnbnre2DXgXSa+Gjap7ifRfllpmyAU7YMvaXmljdArptTHmjrkkKm9BK6GH5D5Yo+p6y5A==} engines: {node: '>=12'} @@ -2428,12 +2534,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/openbsd-arm64@0.23.1': resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.24.0': + resolution: {integrity: sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.17.6': resolution: {integrity: sha512-xaxeSunhQRsTNGFanoOkkLtnmMn5QbA0qBhNet/XLVsc+OVkpIWPHcr3zTW2gxVU5YOHFbIHR9ODuaUdNza2Vw==} engines: {node: '>=12'} @@ -2458,6 +2576,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/sunos-x64@0.17.6': resolution: {integrity: sha512-gnMnMPg5pfMkZvhHee21KbKdc6W3GR8/JuE0Da1kjwpK6oiFU3nqfHuVPgUX2rsOx9N2SadSQTIYV1CIjYG+xw==} engines: {node: '>=12'} @@ -2482,6 +2606,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/win32-arm64@0.17.6': resolution: {integrity: sha512-G95n7vP1UnGJPsVdKXllAJPtqjMvFYbN20e8RK8LVLhlTiSOH1sd7+Gt7rm70xiG+I5tM58nYgwWrLs6I1jHqg==} engines: {node: '>=12'} @@ -2506,6 +2636,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-ia32@0.17.6': resolution: {integrity: sha512-96yEFzLhq5bv9jJo5JhTs1gI+1cKQ83cUpyxHuGqXVwQtY5Eq54ZEsKs8veKtiKwlrNimtckHEkj4mRh4pPjsg==} engines: {node: '>=12'} @@ -2530,6 +2666,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-x64@0.17.6': resolution: {integrity: sha512-n6d8MOyUrNp6G4VSpRcgjs5xj4A91svJSaiwLIDWVWEsZtpN5FA9NlBbZHDmAJc2e8e6SF4tkBD3HAvPF+7igA==} engines: {node: '>=12'} @@ -2554,6 +2696,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.24.0': + resolution: {integrity: sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.4.0': resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3033,9 +3181,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==} @@ -3570,16 +3715,6 @@ packages: webpack-plugin-serve: optional: true - '@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==} @@ -4062,15 +4197,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'} @@ -4098,15 +4224,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'} @@ -4199,6 +4316,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@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==} @@ -4273,30 +4393,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 @@ -4468,9 +4578,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} @@ -4539,8 +4646,8 @@ packages: '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} - '@types/express-serve-static-core@5.0.0': - resolution: {integrity: sha512-AbXMTZGt40T+KON9/Fdxx0B2WK5hsgxcfXJLr5bFpZ7b4JCex2WyQPTEKdXqfHiY5nKKBScZ7yCoO6Pvgxfvnw==} + '@types/express-serve-static-core@5.0.1': + resolution: {integrity: sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==} '@types/express@4.17.21': resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} @@ -4629,6 +4736,9 @@ packages: '@types/node@20.17.0': resolution: {integrity: sha512-a7zRo0f0eLo9K5X9Wp5cAqTUNGzuFLDG2R7C4HY2BhcMAsxgSPuRvAC1ZB6QkuUQXf0YZAgfOX2ZyrBa2n4nHQ==} + '@types/node@20.17.1': + resolution: {integrity: sha512-j2VlPv1NnwPJbaCNv69FO/1z4lId0QmGvpT41YxitRtWlg96g/j8qcv2RKsLKe2F6OJgyXhupN1Xo17b2m139Q==} + '@types/node@22.7.9': resolution: {integrity: sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==} @@ -4725,9 +4835,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} @@ -5440,10 +5547,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'} @@ -5503,9 +5606,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: @@ -5594,21 +5694,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==} @@ -5620,10 +5705,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==} @@ -5710,10 +5791,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'} @@ -5769,9 +5846,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==} @@ -5968,10 +6042,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'} @@ -5991,11 +6061,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==} @@ -6177,10 +6242,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==} @@ -6728,10 +6789,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'} @@ -6774,15 +6831,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'} @@ -6907,10 +6955,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'} @@ -6974,9 +7018,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==} @@ -7278,6 +7319,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.24.0: + resolution: {integrity: sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7483,11 +7529,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'} @@ -7696,17 +7737,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.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -7752,9 +7785,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: @@ -7802,10 +7832,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'} @@ -7838,10 +7864,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'} @@ -8098,10 +8120,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'} @@ -8121,10 +8139,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'} @@ -8837,9 +8851,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==} @@ -9026,9 +9037,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==} @@ -10349,9 +10357,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'} @@ -10476,10 +10481,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} @@ -11015,14 +11016,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==} @@ -11206,9 +11199,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==} @@ -11721,13 +11711,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==} @@ -11767,10 +11750,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==} @@ -11779,7 +11758,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: @@ -11805,9 +11783,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==} @@ -12072,10 +12047,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'} @@ -12110,10 +12081,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'} @@ -12136,10 +12103,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==} @@ -12291,10 +12254,6 @@ packages: resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} engines: {node: '>=10'} - 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 @@ -12527,11 +12486,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'} @@ -12561,9 +12515,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==} @@ -12911,9 +12862,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==} @@ -13186,19 +13134,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'} @@ -13268,11 +13207,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'} @@ -13282,9 +13216,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'} @@ -13357,11 +13288,11 @@ packages: resolution: {integrity: sha512-TARUb7z1pGvlLxgPk++7wJ6aycXF3GJ0sNSBTAsTuJrQG5QuZlkUQP+zl+nbjAh4gMX9yDw9ZYklMd7vAfJKEw==} engines: {node: '>=0.10.0'} - tldts-core@6.1.54: - resolution: {integrity: sha512-5cc42+0G0EjYRDfIJHKraaT3I5kPm7j6or3Zh1T9sF+Ftj1T+isT4thicUyQQ1bwN7/xjHQIuY2fXCoXP8Haqg==} + tldts-core@6.1.55: + resolution: {integrity: sha512-BL+BuKHHaOpntE5BGI6naXjULU6aRlgaYdfDHR3T/hdbNTWkWUZ9yuc11wGnwgpvRwlyUiIK+QohYK3olaVU6Q==} - tldts@6.1.54: - resolution: {integrity: sha512-rDaL1t59gb/Lg0HPMUGdV1vAKLQcXwU74D26aMaYV4QW7mnMvShd1Vmkg3HYAPWx2JCTUmsrXt/Yl9eJ5UFBQw==} + tldts@6.1.55: + resolution: {integrity: sha512-HxQR/9roQ07Pwc8RyyrJMAxRz5/ssoF3qIPPUiIo3zUt6yMdmYZjM2OZIFMiZ3jHyz9jrGHEHuQZrUhoc1LkDw==} hasBin: true tmp@0.0.33: @@ -13453,9 +13384,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==} @@ -13621,8 +13549,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 @@ -13654,9 +13582,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==} @@ -13676,10 +13601,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'} @@ -13874,9 +13795,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'} @@ -14453,18 +14371,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'} @@ -14588,9 +14494,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'} @@ -14613,9 +14516,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==} @@ -14769,9 +14669,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 @@ -14779,13 +14679,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 '@babel/code-frame@7.10.4': @@ -14854,10 +14755,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 - '@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 @@ -14890,13 +14787,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 @@ -14935,10 +14825,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 @@ -15291,7 +15177,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)': @@ -15849,8 +15735,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 @@ -15978,6 +15862,9 @@ snapshots: '@esbuild/aix-ppc64@0.23.1': optional: true + '@esbuild/aix-ppc64@0.24.0': + optional: true + '@esbuild/android-arm64@0.17.6': optional: true @@ -15990,6 +15877,9 @@ snapshots: '@esbuild/android-arm64@0.23.1': optional: true + '@esbuild/android-arm64@0.24.0': + optional: true + '@esbuild/android-arm@0.17.6': optional: true @@ -16002,6 +15892,9 @@ snapshots: '@esbuild/android-arm@0.23.1': optional: true + '@esbuild/android-arm@0.24.0': + optional: true + '@esbuild/android-x64@0.17.6': optional: true @@ -16014,6 +15907,9 @@ snapshots: '@esbuild/android-x64@0.23.1': optional: true + '@esbuild/android-x64@0.24.0': + optional: true + '@esbuild/darwin-arm64@0.17.6': optional: true @@ -16026,6 +15922,9 @@ snapshots: '@esbuild/darwin-arm64@0.23.1': optional: true + '@esbuild/darwin-arm64@0.24.0': + optional: true + '@esbuild/darwin-x64@0.17.6': optional: true @@ -16038,6 +15937,9 @@ snapshots: '@esbuild/darwin-x64@0.23.1': optional: true + '@esbuild/darwin-x64@0.24.0': + optional: true + '@esbuild/freebsd-arm64@0.17.6': optional: true @@ -16050,6 +15952,9 @@ snapshots: '@esbuild/freebsd-arm64@0.23.1': optional: true + '@esbuild/freebsd-arm64@0.24.0': + optional: true + '@esbuild/freebsd-x64@0.17.6': optional: true @@ -16062,6 +15967,9 @@ snapshots: '@esbuild/freebsd-x64@0.23.1': optional: true + '@esbuild/freebsd-x64@0.24.0': + optional: true + '@esbuild/linux-arm64@0.17.6': optional: true @@ -16074,6 +15982,9 @@ snapshots: '@esbuild/linux-arm64@0.23.1': optional: true + '@esbuild/linux-arm64@0.24.0': + optional: true + '@esbuild/linux-arm@0.17.6': optional: true @@ -16086,6 +15997,9 @@ snapshots: '@esbuild/linux-arm@0.23.1': optional: true + '@esbuild/linux-arm@0.24.0': + optional: true + '@esbuild/linux-ia32@0.17.6': optional: true @@ -16098,6 +16012,9 @@ snapshots: '@esbuild/linux-ia32@0.23.1': optional: true + '@esbuild/linux-ia32@0.24.0': + optional: true + '@esbuild/linux-loong64@0.17.6': optional: true @@ -16110,6 +16027,9 @@ snapshots: '@esbuild/linux-loong64@0.23.1': optional: true + '@esbuild/linux-loong64@0.24.0': + optional: true + '@esbuild/linux-mips64el@0.17.6': optional: true @@ -16122,6 +16042,9 @@ snapshots: '@esbuild/linux-mips64el@0.23.1': optional: true + '@esbuild/linux-mips64el@0.24.0': + optional: true + '@esbuild/linux-ppc64@0.17.6': optional: true @@ -16134,6 +16057,9 @@ snapshots: '@esbuild/linux-ppc64@0.23.1': optional: true + '@esbuild/linux-ppc64@0.24.0': + optional: true + '@esbuild/linux-riscv64@0.17.6': optional: true @@ -16146,6 +16072,9 @@ snapshots: '@esbuild/linux-riscv64@0.23.1': optional: true + '@esbuild/linux-riscv64@0.24.0': + optional: true + '@esbuild/linux-s390x@0.17.6': optional: true @@ -16158,6 +16087,9 @@ snapshots: '@esbuild/linux-s390x@0.23.1': optional: true + '@esbuild/linux-s390x@0.24.0': + optional: true + '@esbuild/linux-x64@0.17.6': optional: true @@ -16170,6 +16102,9 @@ snapshots: '@esbuild/linux-x64@0.23.1': optional: true + '@esbuild/linux-x64@0.24.0': + optional: true + '@esbuild/netbsd-x64@0.17.6': optional: true @@ -16182,9 +16117,15 @@ snapshots: '@esbuild/netbsd-x64@0.23.1': optional: true + '@esbuild/netbsd-x64@0.24.0': + optional: true + '@esbuild/openbsd-arm64@0.23.1': optional: true + '@esbuild/openbsd-arm64@0.24.0': + optional: true + '@esbuild/openbsd-x64@0.17.6': optional: true @@ -16197,6 +16138,9 @@ snapshots: '@esbuild/openbsd-x64@0.23.1': optional: true + '@esbuild/openbsd-x64@0.24.0': + optional: true + '@esbuild/sunos-x64@0.17.6': optional: true @@ -16209,6 +16153,9 @@ snapshots: '@esbuild/sunos-x64@0.23.1': optional: true + '@esbuild/sunos-x64@0.24.0': + optional: true + '@esbuild/win32-arm64@0.17.6': optional: true @@ -16221,6 +16168,9 @@ snapshots: '@esbuild/win32-arm64@0.23.1': optional: true + '@esbuild/win32-arm64@0.24.0': + optional: true + '@esbuild/win32-ia32@0.17.6': optional: true @@ -16233,6 +16183,9 @@ snapshots: '@esbuild/win32-ia32@0.23.1': optional: true + '@esbuild/win32-ia32@0.24.0': + optional: true + '@esbuild/win32-x64@0.17.6': optional: true @@ -16245,6 +16198,9 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true + '@esbuild/win32-x64@0.24.0': + optional: true + '@eslint-community/eslint-utils@4.4.0(eslint@9.13.0(jiti@2.3.3))': dependencies: eslint: 9.13.0(jiti@2.3.3) @@ -17100,11 +17056,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.5 @@ -17821,32 +17772,6 @@ snapshots: webpack-dev-server: 5.1.0(webpack@5.95.0(esbuild@0.23.1)) webpack-hot-middleware: 2.26.1 - '@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.3.7(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': {} @@ -18501,30 +18426,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 @@ -18537,17 +18453,9 @@ snapshots: '@rollup/plugin-terser@0.4.4(rollup@4.24.0)': dependencies: - serialize-javascript: 6.0.1 - 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 + serialize-javascript: 6.0.2 + smob: 1.4.1 + terser: 5.36.0 optionalDependencies: rollup: 4.24.0 @@ -18609,6 +18517,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@schummar/icu-type-parser@1.21.5': {} + '@sec-ant/readable-stream@0.4.1': {} '@segment/loosely-validate-event@2.0.0': @@ -18706,50 +18616,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.3.7(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.3.6(esbuild@0.23.1)(storybook@8.3.6)(typescript@5.6.3)': dependencies: @@ -19073,8 +18954,6 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tufjs/canonical-json@2.0.0': {} '@tufjs/models@2.0.1': @@ -19118,13 +18997,13 @@ snapshots: '@types/bonjour@3.5.13': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/connect-history-api-fallback@1.5.4': dependencies: - '@types/express-serve-static-core': 5.0.0 - '@types/node': 20.17.0 + '@types/express-serve-static-core': 5.0.1 + '@types/node': 20.17.1 optional: true '@types/connect@3.4.38': @@ -19160,9 +19039,9 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.4 - '@types/express-serve-static-core@5.0.0': + '@types/express-serve-static-core@5.0.1': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 '@types/qs': 6.9.16 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -19200,7 +19079,7 @@ snapshots: '@types/http-proxy@1.17.15': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/istanbul-lib-coverage@2.0.6': {} @@ -19258,13 +19137,18 @@ snapshots: '@types/node-forge@1.3.11': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/node@20.17.0': dependencies: undici-types: 6.19.8 + '@types/node@20.17.1': + dependencies: + undici-types: 6.19.8 + optional: true + '@types/node@22.7.9': dependencies: undici-types: 6.19.8 @@ -19317,7 +19201,7 @@ snapshots: '@types/sockjs@0.3.36': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/source-list-map@0.1.6': {} @@ -19353,7 +19237,7 @@ snapshots: '@types/webpack@5.28.5(esbuild@0.23.1)': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 tapable: 2.2.1 webpack: 5.95.0(esbuild@0.23.1) transitivePeerDependencies: @@ -19365,7 +19249,7 @@ snapshots: '@types/ws@8.5.12': dependencies: - '@types/node': 20.17.0 + '@types/node': 20.17.1 optional: true '@types/yargs-parser@21.0.3': {} @@ -19382,11 +19266,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.11.1 @@ -19644,14 +19523,14 @@ 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) 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) react: 18.3.1 @@ -20299,10 +20178,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.0 - ast-types@0.15.2: dependencies: tslib: 2.8.0 @@ -20359,8 +20234,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 @@ -20539,30 +20412,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: @@ -20579,8 +20428,6 @@ snapshots: dependencies: safe-buffer: 5.1.2 - basic-ftp@5.0.5: {} - batch@0.6.1: {} before-after-hook@3.0.2: {} @@ -20698,10 +20545,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 @@ -20789,8 +20632,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: {} @@ -21042,7 +20883,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 @@ -21065,18 +20906,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 @@ -21099,13 +20928,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: {} @@ -21285,8 +21107,6 @@ snapshots: commander@10.0.1: {} - commander@12.1.0: {} - commander@2.13.0: {} commander@2.20.0: {} @@ -21478,7 +21298,7 @@ snapshots: core-js-compat@3.38.1: dependencies: - browserslist: 4.24.0 + browserslist: 4.24.2 core-js-pure@3.38.0: {} @@ -21963,8 +21783,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 @@ -22011,10 +21829,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 @@ -22136,12 +21950,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 @@ -22205,8 +22013,6 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1262051: {} - didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -22662,6 +22468,33 @@ snapshots: '@esbuild/win32-ia32': 0.23.1 '@esbuild/win32-x64': 0.23.1 + 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 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -23155,18 +22988,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: {} @@ -23513,20 +23334,8 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.3.7(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.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -23585,10 +23394,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 @@ -23630,10 +23435,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 @@ -23686,12 +23487,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 @@ -23962,10 +23757,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: {} @@ -23985,15 +23776,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.3.7(supports-color@6.1.0) - fs-extra: 11.2.0 - transitivePeerDependencies: - - supports-color - get-value@2.0.6: {} getenv@1.0.0: {} @@ -24890,10 +24672,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 @@ -25038,10 +24816,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 @@ -27433,8 +27207,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 @@ -27571,13 +27343,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.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))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.8(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): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -27592,7 +27362,7 @@ 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 @@ -27616,7 +27386,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 @@ -27624,7 +27394,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 @@ -27670,7 +27440,7 @@ 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.13.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.13.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 @@ -27678,13 +27448,13 @@ snapshots: 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-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.13.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.13.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.13.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.13.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) @@ -28267,24 +28037,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.3.7(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: {} @@ -28496,8 +28248,6 @@ snapshots: duplexify: 3.7.1 through2: 2.0.5 - pend@1.2.0: {} - periscopic@3.1.0: dependencies: '@types/estree': 1.0.6 @@ -29067,21 +28817,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.4.0: - dependencies: - agent-base: 7.1.1 - debug: 4.3.7(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: {} @@ -29130,18 +28865,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: {} @@ -29160,8 +28883,6 @@ snapshots: queue-microtask@1.2.3: {} - queue-tick@1.0.1: {} - queue@6.0.2: dependencies: inherits: 2.0.4 @@ -29580,10 +29301,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 @@ -29616,15 +29333,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 @@ -29653,10 +29361,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 @@ -29894,12 +29598,6 @@ snapshots: resolve.exports@2.0.2: {} - 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 @@ -30155,10 +29853,6 @@ snapshots: semver@7.3.2: {} - semver@7.6.0: - dependencies: - lru-cache: 6.0.0 - semver@7.6.2: {} semver@7.6.3: {} @@ -30209,10 +29903,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 @@ -30661,14 +30351,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: @@ -30830,11 +30512,9 @@ 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): dependencies: @@ -30972,22 +30652,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 @@ -30996,12 +30660,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 @@ -31084,15 +30742,6 @@ snapshots: optionalDependencies: esbuild: 0.23.1 - 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@4.8.1: dependencies: acorn: 8.13.0 @@ -31100,13 +30749,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 @@ -31120,10 +30762,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: {} @@ -31184,12 +30822,12 @@ snapshots: titleize@1.0.0: {} - tldts-core@6.1.54: + tldts-core@6.1.55: optional: true - tldts@6.1.54: + tldts@6.1.55: dependencies: - tldts-core: 6.1.54 + tldts-core: 6.1.55 optional: true tmp@0.0.33: @@ -31235,7 +30873,7 @@ snapshots: tough-cookie@5.0.0: dependencies: - tldts: 6.1.54 + tldts: 6.1.55 optional: true tr46@0.0.3: {} @@ -31274,8 +30912,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): @@ -31439,7 +31075,7 @@ snapshots: - eslint - supports-color - typescript@5.3.3: {} + typescript@5.6.1-rc: {} typescript@5.6.3: {} @@ -31464,11 +31100,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@6.19.2: {} @@ -31482,8 +31113,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: {} @@ -31716,8 +31345,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 @@ -32300,36 +31927,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.13.0 - acorn-import-attributes: 1.9.5(acorn@8.13.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.95.0(esbuild@0.23.1): dependencies: '@types/estree': 1.0.6 @@ -32546,8 +32143,6 @@ snapshots: ws@7.5.10: {} - ws@8.16.0: {} - ws@8.17.1: {} ws@8.18.0: {} @@ -32656,11 +32251,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: {} @@ -32673,8 +32263,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"] },