diff --git a/app/.storybook/I18nStoryWrapper.tsx b/app/.storybook/I18nStoryWrapper.tsx index d158ea19..bf067374 100644 --- a/app/.storybook/I18nStoryWrapper.tsx +++ b/app/.storybook/I18nStoryWrapper.tsx @@ -7,7 +7,7 @@ import { StoryContext } from "@storybook/react"; import { NextIntlClientProvider } from "next-intl"; import React from "react"; -import { defaultLocale, formats, getLocaleMessages } from "../src/i18n"; +import { defaultLocale, formats, timeZone } from "../src/i18n/config"; const I18nStoryWrapper = ( Story: React.ComponentType, @@ -18,8 +18,9 @@ const I18nStoryWrapper = ( return ( diff --git a/app/.storybook/preview.tsx b/app/.storybook/preview.tsx index c8b6da25..923ada0b 100644 --- a/app/.storybook/preview.tsx +++ b/app/.storybook/preview.tsx @@ -2,11 +2,12 @@ * @file Setup the toolbar, styling, and global context for each Storybook story. * @see https://storybook.js.org/docs/configure#configure-story-rendering */ -import { Preview } from "@storybook/react"; +import { Loader, Preview } from "@storybook/react"; import "../src/styles/styles.scss"; -import { defaultLocale, locales } from "../src/i18n"; +import { defaultLocale, locales } from "../src/i18n/config"; +import { getMessagesWithFallbacks } from "../src/i18n/getMessagesWithFallbacks"; import I18nStoryWrapper from "./I18nStoryWrapper"; const parameters = { @@ -35,7 +36,13 @@ const parameters = { }, }; +const i18nMessagesLoader: Loader = async (context) => { + const messages = await getMessagesWithFallbacks(context.globals.locale); + return { messages }; +}; + const preview: Preview = { + loaders: [i18nMessagesLoader], decorators: [I18nStoryWrapper], parameters, globalTypes: { diff --git a/app/README.md b/app/README.md index 18eb5aa2..aac1d93b 100644 --- a/app/README.md +++ b/app/README.md @@ -13,6 +13,9 @@ │ └── locales # Internationalized content ├── src # Source code │ ├── components # Reusable UI components +│ ├── i18n # Internationalization +│ │ ├── config.ts # Supported locales, timezone, and formatters +│ │ └── messages # Translated strings │ ├── pages # Page routes and data fetching │   │ ├── api # API routes (optional) │   │ └── _app.tsx # Global entry point diff --git a/app/next.config.js b/app/next.config.js index d0e0b044..5d07eb07 100644 --- a/app/next.config.js +++ b/app/next.config.js @@ -1,5 +1,5 @@ // @ts-check -const withNextIntl = require("next-intl/plugin")("./src/i18n/index.ts"); +const withNextIntl = require("next-intl/plugin")("./src/i18n/config.ts"); const sassOptions = require("./scripts/sassOptions"); /** diff --git a/app/src/i18n/config.ts b/app/src/i18n/config.ts new file mode 100644 index 00000000..d3ff6bcd --- /dev/null +++ b/app/src/i18n/config.ts @@ -0,0 +1,35 @@ +/** + * @file Shared i18n configuration for use across the server and client + */ +import type { getRequestConfig } from "next-intl/server"; + +type RequestConfig = Awaited< + ReturnType[0]> +>; + +/** + * List of languages supported by the application. Other tools (Storybook, tests) reference this. + * These must be BCP47 language tags: https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags + */ +export const locales = ["en-US", "es-US"] as const; +export type Locale = (typeof locales)[number]; +export const defaultLocale: Locale = "en-US"; + +/** + * Specifying a time zone affects the rendering of dates and times. + * When not defined, the time zone of the server runtime is used. + * @see https://next-intl-docs.vercel.app/docs/usage/configuration#time-zone + */ +export const timeZone: RequestConfig["timeZone"] = "America/New_York"; + +/** + * Define the default formatting for date, time, and numbers. + * @see https://next-intl-docs.vercel.app/docs/usage/configuration#formats + */ +export const formats: RequestConfig["formats"] = { + number: { + currency: { + currency: "USD", + }, + }, +}; diff --git a/app/src/i18n/getMessagesWithFallbacks.ts b/app/src/i18n/getMessagesWithFallbacks.ts new file mode 100644 index 00000000..bd2bdb8e --- /dev/null +++ b/app/src/i18n/getMessagesWithFallbacks.ts @@ -0,0 +1,38 @@ +import { merge } from "lodash"; +import { defaultLocale, Locale, locales } from "src/i18n/config"; + +interface LocaleFile { + messages: Messages; +} + +async function importMessages(locale: Locale) { + const { messages } = (await import(`./messages/${locale}`)) as LocaleFile; + return messages; +} + +/** + * Get all messages for the given locale. If any translations are missing + * from the current locale, the missing key will fallback to the default locale + */ +export async function getMessagesWithFallbacks( + requestedLocale: string = defaultLocale +) { + const isValidLocale = locales.includes(requestedLocale as Locale); // https://github.com/microsoft/TypeScript/issues/26255 + if (!isValidLocale) { + console.error( + "Unsupported locale was requested. Falling back to the default locale.", + { locale: requestedLocale, defaultLocale } + ); + requestedLocale = defaultLocale; + } + + const targetLocale = requestedLocale as Locale; + let messages = await importMessages(targetLocale); + + if (targetLocale !== defaultLocale) { + const fallbackMessages = await importMessages(defaultLocale); + messages = merge({}, fallbackMessages, messages); + } + + return messages; +} diff --git a/app/src/i18n/index.ts b/app/src/i18n/index.ts deleted file mode 100644 index 70a6f178..00000000 --- a/app/src/i18n/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { merge } from "lodash"; - -import { getRequestConfig } from "next-intl/server"; - -import { messages as enUs } from "./messages/en-US"; -import { messages as esUs } from "./messages/es-US"; - -type RequestConfig = Awaited< - ReturnType[0]> ->; -export type Messages = RequestConfig["messages"]; - -/** - * List of languages supported by the application. Other tools (Storybook, tests) reference this. - * These must be BCP47 language tags: https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags - */ -export const locales = ["en-US", "es-US"] as const; -export type Locale = (typeof locales)[number]; -export const defaultLocale: Locale = "en-US"; - -/** - * All messages for the application for each locale. - * Don't export this object!! Use `getLocaleMessages` instead, - * which handles fallbacks to the default locale when a locale - * is missing a translation. - */ -const _messages: { [locale in Locale]: Messages } = { - "en-US": enUs, - "es-US": esUs, -}; - -/** - * Define the default formatting for date, time, and numbers. - * @see https://next-intl-docs.vercel.app/docs/usage/configuration#formats - */ -export const formats: RequestConfig["formats"] = { - number: { - currency: { - currency: "USD", - }, - }, -}; - -/** - * Get the entire locale messages object for the given locale. If any - * translations are missing from the current locale, the missing key will - * fallback to the default locale - */ -export function getLocaleMessages( - requestedLocale: string = defaultLocale -): Messages { - if (requestedLocale in _messages === false) { - console.error( - "Unsupported locale was requested. Falling back to the default locale.", - { locale: requestedLocale, defaultLocale } - ); - requestedLocale = defaultLocale; - } - - const targetLocale = requestedLocale as Locale; - let messages = _messages[targetLocale]; - - if (targetLocale !== defaultLocale) { - const fallbackMessages = _messages[defaultLocale]; - messages = merge({}, fallbackMessages, messages); - } - - return messages; -} diff --git a/app/src/pages/_app.tsx b/app/src/pages/_app.tsx index 314ebaff..dcd6c79a 100644 --- a/app/src/pages/_app.tsx +++ b/app/src/pages/_app.tsx @@ -5,7 +5,7 @@ import Layout from "../components/Layout"; import "../styles/styles.scss"; -import { defaultLocale, formats, Messages } from "src/i18n"; +import { defaultLocale, formats, timeZone } from "src/i18n/config"; import { NextIntlClientProvider } from "next-intl"; import { useRouter } from "next/router"; @@ -23,6 +23,7 @@ function MyApp({ Component, pageProps }: AppProps<{ messages: Messages }>) { diff --git a/app/src/pages/index.tsx b/app/src/pages/index.tsx index 5d57d105..b954ea5a 100644 --- a/app/src/pages/index.tsx +++ b/app/src/pages/index.tsx @@ -1,5 +1,5 @@ import type { GetServerSideProps, NextPage } from "next"; -import { getLocaleMessages } from "src/i18n"; +import { getMessagesWithFallbacks } from "src/i18n/getMessagesWithFallbacks"; import { useTranslations } from "next-intl"; import Head from "next/head"; @@ -42,12 +42,12 @@ const Home: NextPage = () => { }; // Change this to getStaticProps if you're not using server-side rendering -export const getServerSideProps: GetServerSideProps = ({ locale }) => { - return Promise.resolve({ +export const getServerSideProps: GetServerSideProps = async ({ locale }) => { + return { props: { - messages: getLocaleMessages(locale), + messages: await getMessagesWithFallbacks(locale), }, - }); + }; }; export default Home; diff --git a/app/src/types/i18n.d.ts b/app/src/types/i18n.d.ts index b4e6411e..733cc674 100644 --- a/app/src/types/i18n.d.ts +++ b/app/src/types/i18n.d.ts @@ -1,3 +1,3 @@ // Use type safe message keys with `next-intl` -type Messages = typeof import("src/i18n/messages/en-US").default; +type Messages = typeof import("src/i18n/messages/en-US").messages; type IntlMessages = Messages; diff --git a/app/tests/react-utils.tsx b/app/tests/react-utils.tsx index a9b39690..484b1a42 100644 --- a/app/tests/react-utils.tsx +++ b/app/tests/react-utils.tsx @@ -5,7 +5,8 @@ * @see https://testing-library.com/docs/react-testing-library/setup#custom-render */ import { render as _render, RenderOptions } from "@testing-library/react"; -import { defaultLocale, formats, getLocaleMessages } from "src/i18n"; +import { defaultLocale, formats, timeZone } from "src/i18n/config"; +import { messages } from "src/i18n/messages/en-US"; import { NextIntlClientProvider } from "next-intl"; @@ -16,9 +17,10 @@ import { NextIntlClientProvider } from "next-intl"; const GlobalProviders = ({ children }: { children: React.ReactNode }) => { return ( {children} diff --git a/docs/internationalization.md b/docs/internationalization.md index fd174fba..bdf3bbf6 100644 --- a/docs/internationalization.md +++ b/docs/internationalization.md @@ -1,7 +1,7 @@ # Internationalization (i18n) - [next-intl](https://next-intl-docs.vercel.app) is used for internationalization. Toggling between languages is done by changing the URL's path prefix (e.g. `/about` ➡️ `/es-US/about`). -- Configuration and helpers are located in [`i18n/index.ts`](../app/src/i18n/index.ts). For the most part, you shouldn't need to edit this file unless adding a new formatter or new language. +- Configuration and helpers are located in [`i18n/config.ts`](../app/src/i18n/config.ts). For the most part, you shouldn't need to edit this file unless adding a new formatter or new language. ## Managing translations @@ -28,4 +28,4 @@ Locale messages should only ever be loaded on the server-side, to avoid bloating 1. Add a language folder, using the same BCP47 language tag: `mkdir -p src/i18n/messages/` 1. Add a language file: `touch src/i18n/messages//index.ts` and add the translated content. The JSON structure should be the same across languages. However, non-default languages can omit keys, in which case the default language will be used as a fallback. -1. Update [`i18n/index.ts`](../app/src/i18n/index.ts) to include the new language in the `_messages` object and `locales` array. +1. Update [`i18n/config.ts`](../app/src/i18n/config.ts) to include the new language in the `locales` array.