From 6020d9950a66de4f566310a1f32da5a829cc1314 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 21 Jun 2023 14:27:51 +0200 Subject: [PATCH] feat: Propose new APIs from using i18n outside of components --- .../next-intl/src/react-server/useLocale.tsx | 4 +- packages/next-intl/src/server/getConfig.tsx | 13 ++- .../next-intl/src/server/getFormatter.tsx | 32 +++++- packages/next-intl/src/server/getIntl.tsx | 6 +- packages/next-intl/src/server/getLocale.tsx | 51 +++------ .../src/server/getLocaleFromHeader.tsx | 40 +++++++ packages/next-intl/src/server/getNow.tsx | 22 +++- packages/next-intl/src/server/getTimeZone.tsx | 22 +++- .../next-intl/src/server/getTranslations.tsx | 11 ++ .../next-intl/src/server/getTranslator.tsx | 105 ++++++++++++++++++ packages/next-intl/src/server/index.tsx | 1 + packages/next-intl/src/server/redirect.tsx | 4 +- 12 files changed, 259 insertions(+), 52 deletions(-) create mode 100644 packages/next-intl/src/server/getLocaleFromHeader.tsx create mode 100644 packages/next-intl/src/server/getTranslator.tsx diff --git a/packages/next-intl/src/react-server/useLocale.tsx b/packages/next-intl/src/react-server/useLocale.tsx index 31a685242..b55d36088 100644 --- a/packages/next-intl/src/react-server/useLocale.tsx +++ b/packages/next-intl/src/react-server/useLocale.tsx @@ -1,9 +1,9 @@ import type {useLocale as useLocaleType} from 'use-intl'; -import getLocale from '../server/getLocale'; +import getLocaleFromHeader from '../server/getLocaleFromHeader'; export default function useLocale( // eslint-disable-next-line no-empty-pattern ...[]: Parameters ): ReturnType { - return getLocale(); + return getLocaleFromHeader(); } diff --git a/packages/next-intl/src/server/getConfig.tsx b/packages/next-intl/src/server/getConfig.tsx index 483883b44..da598f68f 100644 --- a/packages/next-intl/src/server/getConfig.tsx +++ b/packages/next-intl/src/server/getConfig.tsx @@ -1,7 +1,10 @@ import {cache} from 'react'; import getInitializedConfig from 'use-intl/dist/src/react/getInitializedConfig'; import createRequestConfig from '../server/createRequestConfig'; -import getLocale from './getLocale'; +import getLocaleFromHeader from './getLocaleFromHeader'; + +// Make sure `now` is consistent across the request in case none was configured +const getDefaultNow = cache(() => new Date()); const receiveRuntimeConfig = cache( async (locale: string, getConfig?: typeof createRequestConfig) => { @@ -11,14 +14,14 @@ const receiveRuntimeConfig = cache( } return { ...result, - // Make sure `now` is consistent across the request in case none was configured - now: result?.now || new Date() + now: result?.now || getDefaultNow() }; } ); -const getConfig = cache(async () => { - const locale = getLocale(); +const getConfig = cache(async (locale?: string) => { + if (!locale) locale = getLocaleFromHeader(); + const runtimeConfig = await receiveRuntimeConfig(locale, createRequestConfig); const opts = {...runtimeConfig, locale}; return getInitializedConfig(opts); diff --git a/packages/next-intl/src/server/getFormatter.tsx b/packages/next-intl/src/server/getFormatter.tsx index c0c0b3a3e..a730b87d1 100644 --- a/packages/next-intl/src/server/getFormatter.tsx +++ b/packages/next-intl/src/server/getFormatter.tsx @@ -2,9 +2,35 @@ import {cache} from 'react'; import {createFormatter} from 'use-intl/dist/src/core'; import getConfig from './getConfig'; -const getFormatter = cache(async () => { - const config = await getConfig(); - return createFormatter(config); +type Opts = Parameters[0]; + +let hasWarned = false; + +/** + * Returns a formatter based on the given locale. + * + * The formatter automatically receives the request config, but + * you can override it by passing in additional options. + */ +const getFormatter = cache(async (opts?: Opts) => { + if (!opts?.locale && !hasWarned) { + hasWarned = true; + console.warn(` +Calling \`getFormatter\` without a locale is deprecated, please update the call: + +// app/[locale]/layout.tsx +export async function generateMetadata({locale}) { + const t = await getFormatter({locale}); + + // ... +} + +Learn more: https://next-intl-docs.vercel.app/docs/next-13/server-components#using-internationalization-outside-of-components +`); + } + + const config = await getConfig(opts?.locale); + return createFormatter({...config, ...opts}); }); export default getFormatter; diff --git a/packages/next-intl/src/server/getIntl.tsx b/packages/next-intl/src/server/getIntl.tsx index ab701e956..f71223f39 100644 --- a/packages/next-intl/src/server/getIntl.tsx +++ b/packages/next-intl/src/server/getIntl.tsx @@ -9,7 +9,11 @@ const getIntl = cache(async () => { if (!hasWarned) { hasWarned = true; console.warn( - '`getIntl()` is deprecated and will be removed in the next major version. Please switch to `getFormatter()`.' + ` +\`getIntl()\` is deprecated and will be removed in the next major version. Please switch to \`getFormatter()\`. + +Learn more: https://next-intl-docs.vercel.app/docs/next-13/server-components#using-internationalization-outside-of-components +` ); } diff --git a/packages/next-intl/src/server/getLocale.tsx b/packages/next-intl/src/server/getLocale.tsx index 2d95e2c62..aa546be6a 100644 --- a/packages/next-intl/src/server/getLocale.tsx +++ b/packages/next-intl/src/server/getLocale.tsx @@ -1,40 +1,21 @@ -import {cookies, headers} from 'next/headers'; -import {cache} from 'react'; -import {COOKIE_LOCALE_NAME, HEADER_LOCALE_NAME} from '../shared/constants'; +import getLocaleFromHeader from './getLocaleFromHeader'; -const getLocale = cache(() => { - let locale; +let hasWarned = false; - try { - // A header is only set when we're changing the locale, - // otherwise we reuse an existing one from the cookie. - const requestHeaders = headers(); - if (requestHeaders.has(HEADER_LOCALE_NAME)) { - locale = requestHeaders.get(HEADER_LOCALE_NAME); - } else { - locale = cookies().get(COOKIE_LOCALE_NAME)?.value; - } - } catch (error) { - if ( - error instanceof Error && - (error as any).digest === 'DYNAMIC_SERVER_USAGE' - ) { - throw new Error( - 'Usage of next-intl APIs in Server Components is currently only available for dynamic rendering (i.e. no `generateStaticParams`).\n\nSupport for static rendering is under consideration, please refer to the roadmap: https://next-intl-docs.vercel.app/docs/next-13/server-components#roadmap', - {cause: error} - ); - } else { - throw error; - } - } +export default function getLocale() { + if (!hasWarned) { + console.warn(` +\`getLocale\` is deprecated. Please use the \`locale\` parameter from Next.js instead: - if (!locale) { - throw new Error( - 'Unable to find `next-intl` locale, have you configured the middleware?`' - ); - } +// app/[locale]/layout.tsx +export async function generateMetadata({locale}) { + // Use \`locale\` here +} - return locale; -}); +Learn more: https://next-intl-docs.vercel.app/docs/next-13/server-components#using-internationalization-outside-of-components +`); + hasWarned = true; + } -export default getLocale; + return getLocaleFromHeader(); +} diff --git a/packages/next-intl/src/server/getLocaleFromHeader.tsx b/packages/next-intl/src/server/getLocaleFromHeader.tsx new file mode 100644 index 000000000..7ddeb78e0 --- /dev/null +++ b/packages/next-intl/src/server/getLocaleFromHeader.tsx @@ -0,0 +1,40 @@ +import {cookies, headers} from 'next/headers'; +import {cache} from 'react'; +import {COOKIE_LOCALE_NAME, HEADER_LOCALE_NAME} from '../shared/constants'; + +const getLocaleFromHeader = cache(() => { + let locale; + + try { + // A header is only set when we're changing the locale, + // otherwise we reuse an existing one from the cookie. + const requestHeaders = headers(); + if (requestHeaders.has(HEADER_LOCALE_NAME)) { + locale = requestHeaders.get(HEADER_LOCALE_NAME); + } else { + locale = cookies().get(COOKIE_LOCALE_NAME)?.value; + } + } catch (error) { + if ( + error instanceof Error && + (error as any).digest === 'DYNAMIC_SERVER_USAGE' + ) { + throw new Error( + 'Usage of next-intl APIs in Server Components is currently only available for dynamic rendering (i.e. no `generateStaticParams`).\n\nSupport for static rendering is under consideration, please refer to the roadmap: https://next-intl-docs.vercel.app/docs/next-13/server-components#roadmap', + {cause: error} + ); + } else { + throw error; + } + } + + if (!locale) { + throw new Error( + 'Unable to find `next-intl` locale, have you configured the middleware?`' + ); + } + + return locale; +}); + +export default getLocaleFromHeader; diff --git a/packages/next-intl/src/server/getNow.tsx b/packages/next-intl/src/server/getNow.tsx index e541f7560..43682a230 100644 --- a/packages/next-intl/src/server/getNow.tsx +++ b/packages/next-intl/src/server/getNow.tsx @@ -1,8 +1,26 @@ import {cache} from 'react'; import getConfig from './getConfig'; -const getNow = cache(async () => { - const config = await getConfig(); +let hasWarned = false; + +const getNow = cache(async (opts?: {locale: string}) => { + if (!opts?.locale && !hasWarned) { + hasWarned = true; + console.warn(` +Calling \`getNow\` without a locale is deprecated. Please update the call: + +// app/[locale]/layout.tsx +export async function generateMetadata({locale}) { + const t = await getNow({locale}); + + // ... +} + +Learn more: https://next-intl-docs.vercel.app/docs/next-13/server-components#using-internationalization-outside-of-components +`); + } + + const config = await getConfig(opts?.locale); return config.now; }); diff --git a/packages/next-intl/src/server/getTimeZone.tsx b/packages/next-intl/src/server/getTimeZone.tsx index 8fd3ddaf4..029ef5592 100644 --- a/packages/next-intl/src/server/getTimeZone.tsx +++ b/packages/next-intl/src/server/getTimeZone.tsx @@ -1,8 +1,26 @@ import {cache} from 'react'; import getConfig from './getConfig'; -const getTimeZone = cache(async () => { - const config = await getConfig(); +let hasWarned = false; + +const getTimeZone = cache(async (opts?: {locale: string}) => { + if (!opts?.locale && !hasWarned) { + hasWarned = true; + console.warn(` +Calling \`getTimeZone\` without a locale is deprecated. Please update the call: + +// app/[locale]/layout.tsx +export async function generateMetadata({locale}) { + const t = await getTimeZone({locale}); + + // ... +} + +Learn more: https://next-intl-docs.vercel.app/docs/next-13/server-components#using-internationalization-outside-of-components +`); + } + + const config = await getConfig(opts?.locale); return config.timeZone; }); diff --git a/packages/next-intl/src/server/getTranslations.tsx b/packages/next-intl/src/server/getTranslations.tsx index 95a1163b6..00b3f8da5 100644 --- a/packages/next-intl/src/server/getTranslations.tsx +++ b/packages/next-intl/src/server/getTranslations.tsx @@ -13,6 +13,8 @@ import NestedKeyOf from 'use-intl/dist/src/core/utils/NestedKeyOf'; import NestedValueOf from 'use-intl/dist/src/core/utils/NestedValueOf'; import getConfig from './getConfig'; +let hasWarned = false; + async function getTranslationsImpl< NestedKey extends NamespaceKeys< IntlMessages, @@ -80,6 +82,15 @@ Promise<{ key: TargetKey ): any; }> { + if (!hasWarned) { + console.warn(` +\`getTranslations\` is deprecated, please switch to \`getTranslator\`. + +Learn more: https://next-intl-docs.vercel.app/docs/next-13/server-components#using-internationalization-outside-of-components + `); + hasWarned = true; + } + const config = await getConfig(); const messagesOrError = getMessagesOrError({ diff --git a/packages/next-intl/src/server/getTranslator.tsx b/packages/next-intl/src/server/getTranslator.tsx new file mode 100644 index 000000000..4f2385699 --- /dev/null +++ b/packages/next-intl/src/server/getTranslator.tsx @@ -0,0 +1,105 @@ +/* eslint-disable import/default */ + +import {cache} from 'react'; +import type Formats from 'use-intl/dist/src/core/Formats'; +import type TranslationValues from 'use-intl/dist/src/core/TranslationValues'; +import createBaseTranslator, { + getMessagesOrError +} from 'use-intl/dist/src/core/createBaseTranslator'; +import {CoreRichTranslationValues} from 'use-intl/dist/src/core/createTranslatorImpl'; +import MessageKeys from 'use-intl/dist/src/core/utils/MessageKeys'; +import NamespaceKeys from 'use-intl/dist/src/core/utils/NamespaceKeys'; +import NestedKeyOf from 'use-intl/dist/src/core/utils/NestedKeyOf'; +import NestedValueOf from 'use-intl/dist/src/core/utils/NestedValueOf'; +import getConfig from './getConfig'; + +async function getTranslatorImpl< + NestedKey extends NamespaceKeys< + IntlMessages, + NestedKeyOf + > = never +>( + opts: {namespace?: NestedKey} & Omit< + Parameters[0], + 'cachedFormatsByLocale' | ' messagesOrError' | 'namespace' + > +): // Explicitly defining the return type is necessary as TypeScript would get it wrong +Promise<{ + // Default invocation + < + TargetKey extends MessageKeys< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > + > + >( + key: TargetKey, + values?: TranslationValues, + formats?: Partial + ): string; + + // `rich` + rich< + TargetKey extends MessageKeys< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > + > + >( + key: TargetKey, + values?: CoreRichTranslationValues, + formats?: Partial + ): string; + + // `raw` + raw< + TargetKey extends MessageKeys< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + {'!': IntlMessages}, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > + > + >( + key: TargetKey + ): any; +}> { + const config = await getConfig(); + + const messagesOrError = getMessagesOrError({ + messages: config.messages as any, + namespace: opts.namespace, + onError: config.onError + }); + + // We allow to resolve rich text formatting here, but the types forbid it when + // `getTranslations` is used directly. Supporting rich text is important when + // the react-server implementation calls into this function. + // @ts-ignore + return createBaseTranslator({ + ...config, + ...opts, + messagesOrError + }); +} + +export default cache(getTranslatorImpl); diff --git a/packages/next-intl/src/server/index.tsx b/packages/next-intl/src/server/index.tsx index 068533ce2..c3488c338 100644 --- a/packages/next-intl/src/server/index.tsx +++ b/packages/next-intl/src/server/index.tsx @@ -40,5 +40,6 @@ export {default as getLocale} from './getLocale'; export {default as getNow} from './getNow'; export {default as getTimeZone} from './getTimeZone'; export {default as getTranslations} from './getTranslations'; +export {default as getTranslator} from './getTranslator'; export {default as redirect} from './redirect'; diff --git a/packages/next-intl/src/server/redirect.tsx b/packages/next-intl/src/server/redirect.tsx index 6358fde83..487ba71c0 100644 --- a/packages/next-intl/src/server/redirect.tsx +++ b/packages/next-intl/src/server/redirect.tsx @@ -1,7 +1,7 @@ import baseRedirect from '../shared/redirect'; -import getLocale from './getLocale'; +import getLocaleFromHeader from './getLocaleFromHeader'; export default function redirect(pathname: string) { - const locale = getLocale(); + const locale = getLocaleFromHeader(); return baseRedirect(pathname, locale); }