Skip to content

Commit 526ef47

Browse files
authored
Dynamically load locale messages (#261)
## Ticket #66 ## Changes - Use a [dynamic import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) to load all strings for the selected locale. This simplifies the process of adding a new language — now someone just needs to add the locale code (e.g. `es-US`) to the `locales` array, whereas before they'd also have to add an import statement. - Renamed `getLocaleMessages` to `getMessagesWithFallbacks` and moved it to its own file. A few reasons for this: (1) Project engineers shouldn't ever need to interact with the `getMessagesWithFallbacks` logic — it should Just Work. (2) We can now rename `i18n/index.ts` to `i18n/config.ts` to make it clearer what the purpose of that file is. ## Context for reviewers I'm pulling this out from the larger App Router migration branch. There's an additional piece that will be in that branch, resulting in a final directory structure looking something like this: ``` ├── i18n │ ├── config.ts # Supported locales and formatting options │ ├── getMessagesWithFallbacks.ts │ └── server.ts # 🆕 next-intl setup logic that can't be imported into client components ``` We could expand this also to export the safelisted subset of hooks and components ``` ├── i18n │ ├── config.ts # Supported locales and formatting options │ ├── getMessagesWithFallbacks.ts │ ├── index.ts # 🆕 Hooks and components for rendering content │ └── server.ts # next-intl setup logic that can't be imported into client components ```
1 parent ed37131 commit 526ef47

12 files changed

+104
-86
lines changed

app/.storybook/I18nStoryWrapper.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { StoryContext } from "@storybook/react";
77
import { NextIntlClientProvider } from "next-intl";
88
import React from "react";
99

10-
import { defaultLocale, formats, getLocaleMessages } from "../src/i18n";
10+
import { defaultLocale, formats, timeZone } from "../src/i18n/config";
1111

1212
const I18nStoryWrapper = (
1313
Story: React.ComponentType,
@@ -18,8 +18,9 @@ const I18nStoryWrapper = (
1818
return (
1919
<NextIntlClientProvider
2020
formats={formats}
21+
timeZone={timeZone}
2122
locale={locale}
22-
messages={getLocaleMessages(locale)}
23+
messages={context.loaded.messages}
2324
>
2425
<Story />
2526
</NextIntlClientProvider>

app/.storybook/preview.tsx

+9-2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
* @file Setup the toolbar, styling, and global context for each Storybook story.
33
* @see https://storybook.js.org/docs/configure#configure-story-rendering
44
*/
5-
import { Preview } from "@storybook/react";
5+
import { Loader, Preview } from "@storybook/react";
66

77
import "../src/styles/styles.scss";
88

9-
import { defaultLocale, locales } from "../src/i18n";
9+
import { defaultLocale, locales } from "../src/i18n/config";
10+
import { getMessagesWithFallbacks } from "../src/i18n/getMessagesWithFallbacks";
1011
import I18nStoryWrapper from "./I18nStoryWrapper";
1112

1213
const parameters = {
@@ -35,7 +36,13 @@ const parameters = {
3536
},
3637
};
3738

39+
const i18nMessagesLoader: Loader = async (context) => {
40+
const messages = await getMessagesWithFallbacks(context.globals.locale);
41+
return { messages };
42+
};
43+
3844
const preview: Preview = {
45+
loaders: [i18nMessagesLoader],
3946
decorators: [I18nStoryWrapper],
4047
parameters,
4148
globalTypes: {

app/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
│ └── locales # Internationalized content
1414
├── src # Source code
1515
│ ├── components # Reusable UI components
16+
│ ├── i18n # Internationalization
17+
│ │ ├── config.ts # Supported locales, timezone, and formatters
18+
│ │ └── messages # Translated strings
1619
│ ├── pages # Page routes and data fetching
1720
│   │ ├── api # API routes (optional)
1821
│   │ └── _app.tsx # Global entry point

app/next.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-check
2-
const withNextIntl = require("next-intl/plugin")("./src/i18n/index.ts");
2+
const withNextIntl = require("next-intl/plugin")("./src/i18n/config.ts");
33
const sassOptions = require("./scripts/sassOptions");
44

55
/**

app/src/i18n/config.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @file Shared i18n configuration for use across the server and client
3+
*/
4+
import type { getRequestConfig } from "next-intl/server";
5+
6+
type RequestConfig = Awaited<
7+
ReturnType<Parameters<typeof getRequestConfig>[0]>
8+
>;
9+
10+
/**
11+
* List of languages supported by the application. Other tools (Storybook, tests) reference this.
12+
* These must be BCP47 language tags: https://en.wikipedia.org/wiki/IETF_language_tag#List_of_common_primary_language_subtags
13+
*/
14+
export const locales = ["en-US", "es-US"] as const;
15+
export type Locale = (typeof locales)[number];
16+
export const defaultLocale: Locale = "en-US";
17+
18+
/**
19+
* Specifying a time zone affects the rendering of dates and times.
20+
* When not defined, the time zone of the server runtime is used.
21+
* @see https://next-intl-docs.vercel.app/docs/usage/configuration#time-zone
22+
*/
23+
export const timeZone: RequestConfig["timeZone"] = "America/New_York";
24+
25+
/**
26+
* Define the default formatting for date, time, and numbers.
27+
* @see https://next-intl-docs.vercel.app/docs/usage/configuration#formats
28+
*/
29+
export const formats: RequestConfig["formats"] = {
30+
number: {
31+
currency: {
32+
currency: "USD",
33+
},
34+
},
35+
};
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { merge } from "lodash";
2+
import { defaultLocale, Locale, locales } from "src/i18n/config";
3+
4+
interface LocaleFile {
5+
messages: Messages;
6+
}
7+
8+
async function importMessages(locale: Locale) {
9+
const { messages } = (await import(`./messages/${locale}`)) as LocaleFile;
10+
return messages;
11+
}
12+
13+
/**
14+
* Get all messages for the given locale. If any translations are missing
15+
* from the current locale, the missing key will fallback to the default locale
16+
*/
17+
export async function getMessagesWithFallbacks(
18+
requestedLocale: string = defaultLocale
19+
) {
20+
const isValidLocale = locales.includes(requestedLocale as Locale); // https://github.com/microsoft/TypeScript/issues/26255
21+
if (!isValidLocale) {
22+
console.error(
23+
"Unsupported locale was requested. Falling back to the default locale.",
24+
{ locale: requestedLocale, defaultLocale }
25+
);
26+
requestedLocale = defaultLocale;
27+
}
28+
29+
const targetLocale = requestedLocale as Locale;
30+
let messages = await importMessages(targetLocale);
31+
32+
if (targetLocale !== defaultLocale) {
33+
const fallbackMessages = await importMessages(defaultLocale);
34+
messages = merge({}, fallbackMessages, messages);
35+
}
36+
37+
return messages;
38+
}

app/src/i18n/index.ts

-69
This file was deleted.

app/src/pages/_app.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import Layout from "../components/Layout";
55

66
import "../styles/styles.scss";
77

8-
import { defaultLocale, formats, Messages } from "src/i18n";
8+
import { defaultLocale, formats, timeZone } from "src/i18n/config";
99

1010
import { NextIntlClientProvider } from "next-intl";
1111
import { useRouter } from "next/router";
@@ -23,6 +23,7 @@ function MyApp({ Component, pageProps }: AppProps<{ messages: Messages }>) {
2323
</Head>
2424
<NextIntlClientProvider
2525
formats={formats}
26+
timeZone={timeZone}
2627
locale={router.locale ?? defaultLocale}
2728
messages={pageProps.messages}
2829
>

app/src/pages/index.tsx

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { GetServerSideProps, NextPage } from "next";
2-
import { getLocaleMessages } from "src/i18n";
2+
import { getMessagesWithFallbacks } from "src/i18n/getMessagesWithFallbacks";
33

44
import { useTranslations } from "next-intl";
55
import Head from "next/head";
@@ -42,12 +42,12 @@ const Home: NextPage = () => {
4242
};
4343

4444
// Change this to getStaticProps if you're not using server-side rendering
45-
export const getServerSideProps: GetServerSideProps = ({ locale }) => {
46-
return Promise.resolve({
45+
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
46+
return {
4747
props: {
48-
messages: getLocaleMessages(locale),
48+
messages: await getMessagesWithFallbacks(locale),
4949
},
50-
});
50+
};
5151
};
5252

5353
export default Home;

app/src/types/i18n.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// Use type safe message keys with `next-intl`
2-
type Messages = typeof import("src/i18n/messages/en-US").default;
2+
type Messages = typeof import("src/i18n/messages/en-US").messages;
33
type IntlMessages = Messages;

app/tests/react-utils.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* @see https://testing-library.com/docs/react-testing-library/setup#custom-render
66
*/
77
import { render as _render, RenderOptions } from "@testing-library/react";
8-
import { defaultLocale, formats, getLocaleMessages } from "src/i18n";
8+
import { defaultLocale, formats, timeZone } from "src/i18n/config";
9+
import { messages } from "src/i18n/messages/en-US";
910

1011
import { NextIntlClientProvider } from "next-intl";
1112

@@ -16,9 +17,10 @@ import { NextIntlClientProvider } from "next-intl";
1617
const GlobalProviders = ({ children }: { children: React.ReactNode }) => {
1718
return (
1819
<NextIntlClientProvider
19-
locale={defaultLocale}
20-
messages={getLocaleMessages(defaultLocale)}
2120
formats={formats}
21+
timeZone={timeZone}
22+
locale={defaultLocale}
23+
messages={messages}
2224
>
2325
{children}
2426
</NextIntlClientProvider>

docs/internationalization.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Internationalization (i18n)
22

33
- [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`).
4-
- 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.
4+
- 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.
55

66
## Managing translations
77

@@ -28,4 +28,4 @@ Locale messages should only ever be loaded on the server-side, to avoid bloating
2828

2929
1. Add a language folder, using the same BCP47 language tag: `mkdir -p src/i18n/messages/<lang>`
3030
1. Add a language file: `touch src/i18n/messages/<lang>/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.
31-
1. Update [`i18n/index.ts`](../app/src/i18n/index.ts) to include the new language in the `_messages` object and `locales` array.
31+
1. Update [`i18n/config.ts`](../app/src/i18n/config.ts) to include the new language in the `locales` array.

0 commit comments

Comments
 (0)