diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index 574542e8a..ba8f1b760 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -170,8 +170,12 @@ export default getRequestConfig(async ({requestLocale}) => { ```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: 'en' + locale // ... }; }); @@ -214,6 +218,8 @@ import {getLocale} from 'next-intl/server'; const locale = await getLocale(); ``` +### `Locale` [#locale-type] + When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter: ```tsx diff --git a/docs/src/pages/docs/workflows/typescript.mdx b/docs/src/pages/docs/workflows/typescript.mdx index 878d5003d..e48ded0e9 100644 --- a/docs/src/pages/docs/workflows/typescript.mdx +++ b/docs/src/pages/docs/workflows/typescript.mdx @@ -9,18 +9,78 @@ import Callout from '@/components/Callout'; 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. ```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`](#locale) + +## `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` @@ -50,18 +110,90 @@ function About() { 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 en; + 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. +### Strict 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}" + } +} +``` + +```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 the types of an imported JSON module as rather wide. Due to this, `next-intl` provides a stopgap solution that allows you to generate an accompanying `.d.json.ts` file for the messages that you're assigning to your `AppConfig`. + +**Usage:** + +1. Enable the `createMessagesDeclaration` setting in your Next.js config: + +```tsx filename="next.config.mjs" +import {createNextIntlPlugin} from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin({ + experimental: { + // Use the path to the messages that you're using in `AppConfig` + createMessagesDeclaration: './messages/en.json' + } + // ... +}); + +// ... +``` + +2. Add support for JSON type declarations in your `tsconfig.json`: + +```json filename="tsconfig.json" +{ + "compilerOptions": { + // ... + "allowArbitraryExtensions": true + } +} +``` + +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`: + +```diff + messages/en.json ++ messages/en.d.json.ts +``` + +This declaration file will provide the exact types for the messages that you're using in `AppConfig`, enabling type safety for message arguments. + +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`. @@ -126,111 +258,6 @@ declare module 'next-intl' { } ``` -## `Locale` - -Augmenting the `Locale` type will affect the return type of [`useLocale`](/docs/usage/configuration#locale), as well as all `locale` arguments that are accepted by APIs from `next-intl` (e.g. the `locale` prop of [``](/docs/routing/navigation#link)). - -```tsx -// ✅ 'en' | 'de' -const locale = useLocale(); -``` - -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]; - } -} -``` - - - - -### Using the `Locale` type for arguments - -Once the `Locale` type is augmented, it can be used across your codebase if you need to pass the locale to functions outside of your components: - -```tsx {1,10} -import {Locale} from 'next-intl'; -import {getLocale} from 'next-intl/server'; - -async function BlogPosts() { - const locale = await getLocale(); - const posts = await getPosts(locale); - // ... -} - -async function getPosts(locale: Locale) { - // ... -} -``` - -### Using the `Locale` type for layout and page params [#locale-segment-params] - -You can also use the `Locale` type when working with the `[locale]` parameter in layouts and pages: - -```tsx filename="app/[locale]/page.tsx" -import {Locale} from 'next-intl'; - -type Props = { - params: { - locale: Locale; - }; -}; - -export default function Page(props: Props) { - // ... -} -``` - -However, keep in mind that this _assumes_ the locale to be valid in this place—Next.js doesn't validate the `[locale]` parameter automatically for you. Due to this, you can add your own validation logic in a central place like the root layout: - -```tsx filename="app/[locale]/layout.tsx" -import {hasLocale} from 'next-intl'; - -// Can be imported e.g. from `@/i18n/routing` -const locales = ['en', 'de'] as const; - -type Props = { - params: { - children: React.ReactNode; - locale: string; - }; -}; - -export default async function LocaleLayout({params: {locale}}: Props) { - if (!hasLocale(locales, locale)) { - notFound(); - } - - // ✅ 'en' | 'de' - console.log(locale); -} -``` - ## Troubleshooting If you're encountering problems, double check that: diff --git a/examples/example-app-router-mixed-routing/global.d.ts b/examples/example-app-router-mixed-routing/global.d.ts index 604dfbb40..98a911d1a 100644 --- a/examples/example-app-router-mixed-routing/global.d.ts +++ b/examples/example-app-router-mixed-routing/global.d.ts @@ -1,9 +1,9 @@ import {locales} from '@/config'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-app-router-next-auth/global.d.ts b/examples/example-app-router-next-auth/global.d.ts index 62bfc23e3..6cb8e005a 100644 --- a/examples/example-app-router-next-auth/global.d.ts +++ b/examples/example-app-router-next-auth/global.d.ts @@ -1,9 +1,9 @@ import {routing} from '@/i18n/routing'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; - Messages: typeof en; + 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-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 277003def..85c56e020 100644 --- a/examples/example-app-router-playground/global.d.ts +++ b/examples/example-app-router-playground/global.d.ts @@ -1,11 +1,11 @@ import {formats} from '@/i18n/request'; import {routing} from '@/i18n/routing'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; Formats: typeof formats; - Messages: typeof en; + 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..eb3878299 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -3,7 +3,12 @@ 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( 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/components/UseTranslationsTypeTests.tsx b/examples/example-app-router-playground/src/components/UseTranslationsTypeTests.tsx new file mode 100644 index 000000000..51a26c5ef --- /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: 1}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: 1}); +} + +export function CreateTranslator() { + const messages = useMessages(); + const locale = useLocale(); + const t = createTranslator({ + locale, + messages, + namespace: 'ClientCounter' + }); + + t('count', {count: 1}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: 1}); +} + +export async function AsyncComponent() { + const t = await getTranslations('ClientCounter'); + t('count', {count: 1}); + + // @ts-expect-error + t('count'); + // @ts-expect-error + t('count', {num: 1}); +} diff --git a/examples/example-app-router-playground/tsconfig.json b/examples/example-app-router-playground/tsconfig.json index 8d6bca754..dc45b2e97 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/.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 62bfc23e3..6cb8e005a 100644 --- a/examples/example-app-router/global.d.ts +++ b/examples/example-app-router/global.d.ts @@ -1,9 +1,9 @@ import {routing} from '@/i18n/routing'; -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { Locale: (typeof routing.locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/examples/example-app-router/next.config.mjs b/examples/example-app-router/next.config.mjs index 46841e0e7..1751fe61a 100644 --- a/examples/example-app-router/next.config.mjs +++ b/examples/example-app-router/next.config.mjs @@ -2,7 +2,11 @@ import createNextIntlPlugin from 'next-intl/plugin'; -const withNextIntl = createNextIntlPlugin(); +const withNextIntl = createNextIntlPlugin({ + experimental: { + createMessagesDeclaration: './messages/en.json' + } +}); /** @type {import('next').NextConfig} */ const config = {}; 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/global.d.ts b/examples/example-pages-router-advanced/global.d.ts index 02f24a1b3..bc828b1cf 100644 --- a/examples/example-pages-router-advanced/global.d.ts +++ b/examples/example-pages-router-advanced/global.d.ts @@ -1,7 +1,7 @@ -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { - Messages: typeof en; + Messages: typeof messages; } } 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/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/global.d.ts b/examples/example-pages-router/global.d.ts index 02f24a1b3..bc828b1cf 100644 --- a/examples/example-pages-router/global.d.ts +++ b/examples/example-pages-router/global.d.ts @@ -1,7 +1,7 @@ -import en from './messages/en.json'; +import messages from './messages/en.json'; declare module 'next-intl' { interface AppConfig { - Messages: typeof en; + 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 index 5f39e9b68..9db98bbdf 100644 --- a/examples/example-use-intl/global.d.ts +++ b/examples/example-use-intl/global.d.ts @@ -1,10 +1,10 @@ import 'use-intl'; -import en from './messages/en.json'; +import messages from './messages/en.json'; import {locales} from './src/config'; declare module 'use-intl' { interface AppConfig { Locale: (typeof locales)[number]; - Messages: typeof en; + Messages: typeof messages; } } diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 57b69eb26..b591b7ed6 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -111,6 +111,7 @@ ], "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", + "chokidar": "^4.0.1", "negotiator": "^1.0.0", "use-intl": "workspace:^" }, diff --git a/packages/next-intl/plugin.d.cts b/packages/next-intl/plugin.d.cts index 46a5d3fe8..266baeabc 100644 --- a/packages/next-intl/plugin.d.cts +++ b/packages/next-intl/plugin.d.cts @@ -1,7 +1,3 @@ -import {NextConfig} from 'next'; - -function createNextIntlPlugin( - i18nPath?: string -): (config?: NextConfig) => NextConfig; +import createNextIntlPlugin from './dist/types/plugin.ts'; export = createNextIntlPlugin; diff --git a/packages/next-intl/src/plugin.tsx b/packages/next-intl/src/plugin.tsx index b6d6e8616..ef53890a0 100644 --- a/packages/next-intl/src/plugin.tsx +++ b/packages/next-intl/src/plugin.tsx @@ -1,122 +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` - ]; -} - -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; - } - } - - 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-docs.vercel.app/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' - ); - } - - // `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(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); -} - -export default 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..fc1162727 --- /dev/null +++ b/packages/next-intl/src/plugin/createMessagesDeclaration.tsx @@ -0,0 +1,84 @@ +import fs from 'fs'; +import path from 'path'; +import {watch} from 'chokidar'; +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 = watch(messagesPath); + + watcher.on('change', () => { + compileDeclaration(messagesPath, true); + }); + + process.on('exit', () => { + void 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-docs.vercel.app/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..87d81d85a --- /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-docs.vercel.app/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..c77f23f45 --- /dev/null +++ b/packages/next-intl/src/plugin/getNextConfig.tsx @@ -0,0 +1,110 @@ +import fs from 'fs'; +import path from 'path'; +import {NextConfig} from 'next'; +import {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-server/getTranslator.tsx b/packages/next-intl/src/react-server/getTranslator.tsx index 7e462d8ce..3b4d7f50c 100644 --- a/packages/next-intl/src/react-server/getTranslator.tsx +++ b/packages/next-intl/src/react-server/getTranslator.tsx @@ -1,14 +1,8 @@ -import {ReactNode, cache} from 'react'; +import {cache} from 'react'; import { - Formats, - MarkupTranslationValues, - MessageKeys, Messages, NamespaceKeys, NestedKeyOf, - NestedValueOf, - RichTranslationValues, - TranslationValues, createTranslator } from 'use-intl/core'; @@ -17,104 +11,7 @@ function getTranslatorImpl< >( config: Parameters[0], namespace?: NestedKey -): // Explicitly defining the return type is necessary as TypeScript would get it wrong -{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): boolean; -} { +): ReturnType> { return createTranslator({ ...config, namespace diff --git a/packages/next-intl/src/server/react-server/getTranslations.tsx b/packages/next-intl/src/server/react-server/getTranslations.tsx index ce1129e97..e3d5d8c68 100644 --- a/packages/next-intl/src/server/react-server/getTranslations.tsx +++ b/packages/next-intl/src/server/react-server/getTranslations.tsx @@ -1,15 +1,9 @@ -import {ReactNode, cache} from 'react'; +import {cache} from 'react'; import { - Formats, Locale, - MarkupTranslationValues, - MessageKeys, Messages, NamespaceKeys, NestedKeyOf, - NestedValueOf, - RichTranslationValues, - TranslationValues, createTranslator } from 'use-intl/core'; import getConfig from './getConfig.tsx'; @@ -23,190 +17,14 @@ function getTranslations< 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< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: [TargetKey] extends [never] ? string : TargetKey - ): boolean; -}>; +): Promise>>; // CALL SIGNATURE 2: `getTranslations({locale, namespace})` function getTranslations< NestedKey extends NamespaceKeys> = never >(opts?: { locale: Locale; namespace?: NestedKey; -}): // Explicitly defining the return type is necessary as TypeScript would get it wrong -Promise<{ - // Default invocation - < - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; -}>; +}): Promise>>; // IMPLEMENTATION async function getTranslations< NestedKey extends NamespaceKeys> = never diff --git a/packages/use-intl/src/core/ICUArgs.tsx b/packages/use-intl/src/core/ICUArgs.tsx new file mode 100644 index 000000000..72e6ddc57 --- /dev/null +++ b/packages/use-intl/src/core/ICUArgs.tsx @@ -0,0 +1,181 @@ +// See https://github.com/schummar/schummar-translate/issues/28 + +export type Flatten = T extends object + ? { + [P in keyof T]: T[P]; + } + : T; + +type OtherString = string & {__type: 'other'}; + +type Whitespace = ' ' | '\t' | '\n' | '\r'; + +/** Remove leading and tailing whitespace */ +type Trim = T extends `${Whitespace}${infer Rest}` + ? Trim + : T extends `${infer Rest}${Whitespace}` + ? Trim + : T extends string + ? T + : never; + +/** Returns an array of top level blocks */ +type FindBlocks = Text extends `${string}{${infer Right}` // find first { + ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] + ? [Block, ...FindBlocks] // read block and find next block for tail + : [{}] + : []; // no {, return empty result + +/** Find blocks for each tuple entry */ +type TupleFindBlocks = T extends readonly [infer First, ...infer Rest] + ? [...FindBlocks, ...TupleFindBlocks] + : []; + +/** Read tail until the currently open block is closed. Return the block content and rest of tail */ +type ReadBlock< + Block extends string, + Tail extends string, + Depth extends string +> = Tail extends `${infer L1}}${infer R1}` // find first } + ? L1 extends `${infer L2}{${infer R2}` // if preceeded by {, this opens a nested block + ? ReadBlock<`${Block}${L2}{`, `${R2}}${R1}`, `${Depth}+`> // then continue search right of this { + : Depth extends `+${infer Rest}` // else if depth > 0 + ? ReadBlock<`${Block}${L1}}`, R1, Rest> // then finished nested block, continue search right of first } + : [`${Block}${L1}`, R1] // else return full block and search for next + : []; // no }, return emptry result + +/** Parse block, return variables with types and recursively find nested blocks within */ +type ParseBlock = + Block extends `${infer Name},${infer Format},${infer Rest}` + ? Trim extends 'select' + ? SelectOptions< + Trim, + Trim, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + : { + [K in Trim]: VariableType< + Trim, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + >; + } & TupleParseBlock< + TupleFindBlocks>, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + : Block extends `${infer Name},${infer Format}` + ? { + [K in Trim]: VariableType< + Trim, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + >; + } + : {[K in Trim]: ICUArgument}; + +/** Parse block for each tuple entry */ +type TupleParseBlock = + T extends readonly [infer First, ...infer Rest] + ? ParseBlock & + TupleParseBlock + : {}; + +type VariableType< + T extends string, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = T extends 'number' | 'plural' | 'selectordinal' + ? ICUNumberArgument + : T extends 'date' | 'time' + ? ICUDateArgument + : ICUArgument; + +// Select ////////////////////////////////////////////////////////////////////// + +type SelectOptions< + Name extends string, + Rest, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = KeepAndMerge< + ParseSelectBlock +>; + +type ParseSelectBlock< + Name extends string, + Rest, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = Rest extends `${infer Left}{${infer Right}` + ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] + ? + | ({[K in Name]: HandleOther>} & TupleParseBlock< + FindBlocks, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + >) + | ParseSelectBlock< + Name, + Tail, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > + : never + : never; + +type HandleOther = 'other' extends T ? Exclude | OtherString : T; + +type KeepAndMerge = T | MergeTypeUnion; + +type KeysFromUnion = T extends T ? keyof T : never; + +type SimpleTypeMerge = T extends {[k in K]?: any} + ? T[K] extends OtherString + ? string & {} + : T[K] + : never; + +type MergeTypeUnion = { + [k in KeysFromUnion]: SimpleTypeMerge; +}; + +// Escapes ///////////////////////////////////////////////////////////////////// + +type EscapeLike = `'${'{' | '}' | '<' | '>'}`; +type StripEscapes = T extends `${infer Left}''${infer Right}` + ? `${Left}${Right}` + : T extends `${infer Start}${EscapeLike}${string}'${infer End}` + ? `${Start}${StripEscapes}` + : T extends `${infer Start}${EscapeLike}${string}` + ? Start + : T; + +// Export ////////////////////////////////////////////////////////////////////// + +/** Calculates an object type with all variables and their types in the given ICU format string */ +type ICUArgs< + T extends string, + ICUArgument, + ICUNumberArgument, + ICUDateArgument +> = Flatten< + TupleParseBlock< + FindBlocks>, + ICUArgument, + ICUNumberArgument, + ICUDateArgument + > +>; + +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/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..591bad7f3 100644 --- a/packages/use-intl/src/core/TranslationValues.tsx +++ b/packages/use-intl/src/core/TranslationValues.tsx @@ -1,27 +1,20 @@ import {ReactNode} from 'react'; -// From IntlMessageFormat#format -export type TranslationValue = - | string - | number - | boolean - | Date - | null - | undefined; +// These type names are shown to consumers in autocomplete +export type ICUArg = string | number | boolean | Date; +export type ICUNumber = number; +export type ICUDate = Date | number | string; -type TranslationValues = Record; +type TranslationValues = Record; + +export type RichTextFunction = (chunks: ReactNode) => ReactNode; +export type MarkupFunction = (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) ->; +export type RichTranslationValues = Record; -export type MarkupTranslationValues = Record< - string, - TranslationValue | ((chunks: string) => string) ->; +export type MarkupTranslationValues = Record; export default TranslationValues; diff --git a/packages/use-intl/src/core/createBaseTranslator.tsx b/packages/use-intl/src/core/createBaseTranslator.tsx index 2806023be..fce3eef24 100644 --- a/packages/use-intl/src/core/createBaseTranslator.tsx +++ b/packages/use-intl/src/core/createBaseTranslator.tsx @@ -5,6 +5,7 @@ import {Locale} from './AppConfig.tsx'; import Formats from './Formats.tsx'; import {InitializedIntlConfig} from './IntlConfig.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; +import {MessageKeys, NestedKeyOf, NestedValueOf} from './MessageKeys.tsx'; import TranslationValues, { MarkupTranslationValues, RichTranslationValues @@ -19,9 +20,6 @@ import { memoFn } from './formatters.tsx'; import joinPath from './joinPath.tsx'; -import MessageKeys from './utils/MessageKeys.tsx'; -import NestedKeyOf from './utils/NestedKeyOf.tsx'; -import NestedValueOf from './utils/NestedValueOf.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`. diff --git a/packages/use-intl/src/core/createTranslator.test.tsx b/packages/use-intl/src/core/createTranslator.test.tsx index ff9ed2923..8b01fe927 100644 --- a/packages/use-intl/src/core/createTranslator.test.tsx +++ b/packages/use-intl/src/core/createTranslator.test.tsx @@ -1,6 +1,7 @@ import {isValidElement} from 'react'; import {renderToString} from 'react-dom/server'; import {describe, expect, it, vi} from 'vitest'; +import {Messages} from './AppConfig.tsx'; import IntlError, {IntlErrorCode} from './IntlError.tsx'; import createTranslator from './createTranslator.tsx'; @@ -10,7 +11,7 @@ const messages = { rich: 'Hello {name}!', markup: 'Hello {name}!' } -}; +} as const; it('can translate a message within a namespace', () => { const t = createTranslator({ @@ -39,6 +40,7 @@ it('handles formatting errors', () => { onError }); + // @ts-expect-error const result = t('price'); const error: IntlError = onError.mock.calls[0][0]; @@ -74,6 +76,471 @@ 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('can handle undefined values', () => { + const t = translateMessage('Hello {name}'); + + const obj = { + name: 'Jane', + age: undefined + }; + t('msg', obj); + }); + + 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('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})}}.' + ); + + 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('dates in messages', () => { it.each([ ['G', '7/9/2024 AD'], // 🤔 Includes date diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index cf51904fb..d9735fe11 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -1,10 +1,21 @@ import {ReactNode} from 'react'; import {Messages} from './AppConfig.tsx'; import Formats from './Formats.tsx'; +import ICUArgs from './ICUArgs.tsx'; +import ICUTags from './ICUTags.tsx'; import IntlConfig from './IntlConfig.tsx'; -import TranslationValues, { - MarkupTranslationValues, - RichTranslationValues +import { + MessageKeys, + NamespaceKeys, + NestedKeyOf, + NestedValueOf +} from './MessageKeys.tsx'; +import { + ICUArg, + ICUDate, + ICUNumber, + MarkupFunction, + RichTextFunction } from './TranslationValues.tsx'; import createTranslatorImpl from './createTranslatorImpl.tsx'; import {defaultGetMessageFallback, defaultOnError} from './defaults.tsx'; @@ -14,10 +25,31 @@ import { createCache, createIntlFormatters } from './formatters.tsx'; -import MessageKeys from './utils/MessageKeys.tsx'; -import NamespaceKeys from './utils/NamespaceKeys.tsx'; -import NestedKeyOf from './utils/NestedKeyOf.tsx'; -import NestedValueOf from './utils/NestedValueOf.tsx'; +import {OnlyOptional, Prettify} from './types.tsx'; + +type ICUArgsWithTags< + MessageString extends string, + TagsFn extends RichTextFunction | MarkupFunction = never +> = ICUArgs & + ([TagsFn] extends [never] ? {} : ICUTags); + +type TranslateArgs< + Value extends string, + Formats, + TagsFn extends RichTextFunction | MarkupFunction = 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; /** * Translates messages from the given namespace by using the ICU syntax. @@ -28,7 +60,11 @@ import NestedValueOf from './utils/NestedValueOf.tsx'; * (e.g. `namespace.Component`). */ export default function createTranslator< - NestedKey extends NamespaceKeys> = never + const TranslatorMessages extends Messages = Messages, + const Namespace extends NamespaceKeys< + TranslatorMessages, + NestedKeyOf + > = never >({ _cache = createCache(), _formatters = createIntlFormatters(_cache), @@ -37,9 +73,9 @@ export default function createTranslator< namespace, onError = defaultOnError, ...rest -}: Omit, 'messages'> & { - messages?: IntlConfig['messages']; - namespace?: NestedKey; +}: Omit, 'messages'> & { + messages?: TranslatorMessages; + namespace?: Namespace; /** @private */ _formatters?: Formatters; /** @private */ @@ -50,73 +86,90 @@ export default function createTranslator< < TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > >( key: TargetKey, - values?: TranslationValues, - formats?: Formats + ...args: TranslateArgs< + NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` + >, + Formats + > ): string; // `rich` rich< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > >( key: TargetKey, - values?: RichTranslationValues, - formats?: Formats + ...args: TranslateArgs< + NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` + >, + Formats, + RichTextFunction + > ): ReactNode; // `markup` markup< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > >( key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats + ...args: TranslateArgs< + NestedValueOf< + TranslatorMessages, + [Namespace] extends [never] ? TargetKey : `${Namespace}.${TargetKey}` + >, + Formats, + MarkupFunction + > ): string; // `raw` raw< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > @@ -128,13 +181,13 @@ export default function createTranslator< has< TargetKey extends MessageKeys< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` >, NestedKeyOf< NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + {'!': TranslatorMessages}, + [Namespace] extends [never] ? '!' : `!.${Namespace}` > > > @@ -145,9 +198,10 @@ export default function createTranslator< // 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< - {'!': Messages}, - [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 23c9a8d50..3f2897fb4 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.tsx'; import {InitializedIntlConfig} from './IntlConfig.tsx'; +import {NestedKeyOf} from './MessageKeys.tsx'; import createBaseTranslator from './createBaseTranslator.tsx'; import {Formatters, IntlCache} from './formatters.tsx'; import resolveNamespace from './resolveNamespace.tsx'; -import NestedKeyOf from './utils/NestedKeyOf.tsx'; export type CreateTranslatorImplProps = Omit< InitializedIntlConfig, diff --git a/packages/use-intl/src/core/index.tsx b/packages/use-intl/src/core/index.tsx index 55459b66e..ed9bd2067 100644 --- a/packages/use-intl/src/core/index.tsx +++ b/packages/use-intl/src/core/index.tsx @@ -12,10 +12,12 @@ export {default as IntlError, IntlErrorCode} from './IntlError.tsx'; export {default as createTranslator} from './createTranslator.tsx'; export {default as createFormatter} from './createFormatter.tsx'; export {default as initializeConfig} from './initializeConfig.tsx'; -export type {default as MessageKeys} from './utils/MessageKeys.tsx'; -export type {default as NamespaceKeys} from './utils/NamespaceKeys.tsx'; -export type {default as NestedKeyOf} from './utils/NestedKeyOf.tsx'; -export type {default as NestedValueOf} from './utils/NestedValueOf.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'; diff --git a/packages/use-intl/src/core/types.tsx b/packages/use-intl/src/core/types.tsx new file mode 100644 index 000000000..343dfa422 --- /dev/null +++ b/packages/use-intl/src/core/types.tsx @@ -0,0 +1,6 @@ +export type OnlyOptional = Partial extends T ? true : false; + +// 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 af713473a..000000000 --- a/packages/use-intl/src/core/utils/MessageKeys.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import NestedValueOf from './NestedValueOf.tsx'; - -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 d0e69f840..000000000 --- a/packages/use-intl/src/core/utils/NamespaceKeys.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import NestedValueOf from './NestedValueOf.tsx'; - -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/react/useTranslations.tsx b/packages/use-intl/src/react/useTranslations.tsx index 1428779f2..0a5b7266f 100644 --- a/packages/use-intl/src/react/useTranslations.tsx +++ b/packages/use-intl/src/react/useTranslations.tsx @@ -1,14 +1,6 @@ -import {ReactNode} from 'react'; import {Messages} from '../core/AppConfig.tsx'; -import Formats from '../core/Formats.tsx'; -import TranslationValues, { - MarkupTranslationValues, - RichTranslationValues -} from '../core/TranslationValues.tsx'; -import MessageKeys from '../core/utils/MessageKeys.tsx'; -import NamespaceKeys from '../core/utils/NamespaceKeys.tsx'; -import NestedKeyOf from '../core/utils/NestedKeyOf.tsx'; -import NestedValueOf from '../core/utils/NestedValueOf.tsx'; +import {NamespaceKeys, NestedKeyOf} from '../core/MessageKeys.tsx'; +import type createTranslator from '../core/createTranslator.tsx'; import useIntlContext from './useIntlContext.tsx'; import useTranslationsImpl from './useTranslationsImpl.tsx'; @@ -24,110 +16,14 @@ export default function useTranslations< 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< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: TranslationValues, - formats?: Formats - ): string; - - // `rich` - rich< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: RichTranslationValues, - formats?: Formats - ): ReactNode; - - // `markup` - markup< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey, - values?: MarkupTranslationValues, - formats?: Formats - ): string; - - // `raw` - raw< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): any; - - // `has` - has< - TargetKey extends MessageKeys< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - {'!': Messages}, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - > - >( - key: TargetKey - ): boolean; -} { +): ReturnType> { const context = useIntlContext(); 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< {'!': Messages}, [NestedKey] extends [never] ? '!' : `!.${NestedKey}` diff --git a/packages/use-intl/src/react/useTranslationsImpl.tsx b/packages/use-intl/src/react/useTranslationsImpl.tsx index ada612785..742dad2a7 100644 --- a/packages/use-intl/src/react/useTranslationsImpl.tsx +++ b/packages/use-intl/src/react/useTranslationsImpl.tsx @@ -1,8 +1,8 @@ import {useMemo} from 'react'; import AbstractIntlMessages from '../core/AbstractIntlMessages.tsx'; +import {NestedKeyOf} from '../core/MessageKeys.tsx'; import createBaseTranslator from '../core/createBaseTranslator.tsx'; import resolveNamespace from '../core/resolveNamespace.tsx'; -import NestedKeyOf from '../core/utils/NestedKeyOf.tsx'; import {IntlError, IntlErrorCode} from '../core.tsx'; import useIntlContext from './useIntlContext.tsx'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a8edd04e..611533286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,13 +40,13 @@ importers: version: 2.1.5(react@18.3.1) '@vercel/analytics': specifier: 1.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) + 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) '@vercel/og': specifier: ^0.6.3 version: 0.6.3 '@vercel/speed-insights': specifier: ^1.0.12 - 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) + 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) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -58,10 +58,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(@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(@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-theme-docs: specifier: ^3.1.0 - 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) + 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) react: specifier: ^18.3.1 version: 18.3.1 @@ -92,13 +92,13 @@ importers: 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.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.36.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 next-sitemap: specifier: ^4.2.3 - 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)) + 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)) prettier: specifier: ^3.3.3 version: 3.3.3 @@ -263,7 +263,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(@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(@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-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -379,7 +379,10 @@ importers: 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@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)) + version: 8.0.0(@typescript-eslint/eslint-plugin@8.11.0(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(@typescript-eslint/parser@8.9.0(eslint@9.13.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.13.0(jiti@2.3.3))(jest@29.7.0(@types/node@20.17.0))(tailwindcss@3.4.14)(typescript@5.6.3)(vitest@2.1.3(@edge-runtime/vm@4.0.3)(@types/node@20.17.0)(jsdom@25.0.1)(terser@5.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) @@ -611,7 +614,7 @@ importers: dependencies: next: specifier: ^12.0.0 - version: 12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) react: specifier: ^17.0.0 version: 17.0.2 @@ -757,6 +760,9 @@ importers: '@formatjs/intl-localematcher': specifier: ^0.5.4 version: 0.5.5 + chokidar: + specifier: ^4.0.1 + version: 4.0.1 negotiator: specifier: ^1.0.0 version: 1.0.0 @@ -2737,7 +2743,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.4.11': resolution: {integrity: sha512-L9Ci9RBh0aPFEDF1AjDYPk54OgeUJIKzxF3lRgITm+lQpI3IEKjAc9LaYeQeO1mlZMUQmPkHArF8iyz1eOeVoQ==} @@ -5796,10 +5802,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'} @@ -6054,10 +6056,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'} @@ -7864,10 +7862,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'} @@ -19585,7 +19579,7 @@ snapshots: '@vanilla-extract/private@1.0.3': {} - '@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)': + '@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)': dependencies: server-only: 0.0.1 optionalDependencies: @@ -19598,7 +19592,7 @@ snapshots: satori: 0.10.9 yoga-wasm-web: 0.3.3 - '@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)': + '@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)': 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 @@ -20615,10 +20609,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 @@ -20959,7 +20949,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 @@ -20982,18 +20972,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 @@ -23537,10 +23515,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 @@ -27460,7 +27434,7 @@ snapshots: dependencies: type-fest: 2.19.0 - 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): + 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): dependencies: '@babel/runtime': 7.24.7 '@panva/hkdf': 1.2.0 @@ -27475,7 +27449,7 @@ snapshots: react-dom: 18.3.1(react@18.3.1) uuid: 8.3.2 - 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)): + 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)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.6 @@ -27488,7 +27462,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@12.3.4(react-dom@17.0.2(react@17.0.2))(react@17.0.2): + next@12.3.4(@babel/core@7.25.9)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@next/env': 12.3.4 '@swc/helpers': 0.4.11 @@ -27496,7 +27470,7 @@ snapshots: postcss: 8.4.14 react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - styled-jsx: 5.0.7(react@17.0.2) + styled-jsx: 5.0.7(@babel/core@7.25.9)(react@17.0.2) use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: '@next/swc-android-arm-eabi': 12.3.4 @@ -27542,7 +27516,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - 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): + 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): dependencies: '@headlessui/react': 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) clsx: 2.1.1 @@ -27550,13 +27524,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(@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(@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: 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(@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(@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): 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) @@ -30625,9 +30599,11 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.0.7(react@17.0.2): + styled-jsx@5.0.7(@babel/core@7.25.9)(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: