{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 ({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{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{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{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{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