Skip to content

Commit 878bfaf

Browse files
authored
chore(i18n): improve type safety (#7781)
* chore(i18n): improve type safety * fixup! Signed-off-by: Aviv Keller <me@aviv.sh> * improve casting --------- Signed-off-by: Aviv Keller <me@aviv.sh>
1 parent f349d7e commit 878bfaf

File tree

9 files changed

+47
-39
lines changed

9 files changed

+47
-39
lines changed

apps/site/global.d.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,7 @@
1-
import type baseMessages from '@node-core/website-i18n/locales/en.json';
2-
import type { MessageKeys, NestedValueOf, NestedKeyOf } from 'next-intl';
1+
import { Locale } from '@node-core/website-i18n/types';
32

4-
declare global {
5-
// Defines a type for all the IntlMessage shape (which is used internall by next-intl)
6-
// @see https://next-intl.dev/docs/workflows/typescript
7-
type IntlMessages = typeof baseMessages;
8-
9-
// Defines a generic type for all available i18n translation keys, by default not using any namespace
10-
type IntlMessageKeys<
11-
NestedKey extends NamespaceKeys<
12-
IntlMessages,
13-
NestedKeyOf<IntlMessages>
14-
> = never,
15-
> = MessageKeys<
16-
NestedValueOf<
17-
{ '!': IntlMessages },
18-
[NestedKey] extends [never] ? '!' : `!.${NestedKey}`
19-
>,
20-
NestedKeyOf<
21-
NestedValueOf<
22-
{ '!': IntlMessages },
23-
[NestedKey] extends [never] ? '!' : `!.${NestedKey}`
24-
>
25-
>
26-
>;
3+
declare module 'next-intl' {
4+
interface AppConfig {
5+
Messages: Locale;
6+
}
277
}
28-
29-
export {};

apps/site/hooks/react-generic/useSiteNavigation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { HTMLAttributeAnchorTarget } from 'react';
55
import { siteNavigation } from '#site/next.json.mjs';
66
import type {
77
FormattedMessage,
8+
IntlMessageKeys,
89
NavigationEntry,
910
NavigationKeys,
1011
} from '#site/types';

apps/site/tests/e2e/general-behavior.spec.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { importLocale } from '@node-core/website-i18n';
2+
import type { Locale } from '@node-core/website-i18n/types';
23
import { test, expect, type Page } from '@playwright/test';
34

45
const englishLocale = await importLocale('en');
@@ -34,10 +35,7 @@ const openLanguageMenu = async (page: Page) => {
3435
return page.locator(selector);
3536
};
3637

37-
const verifyTranslation = async (
38-
page: Page,
39-
locale: string | Record<string, unknown>
40-
) => {
38+
const verifyTranslation = async (page: Page, locale: Locale | string) => {
4139
// Load locale data if string code provided (e.g., 'es', 'fr')
4240
const localeData =
4341
typeof locale === 'string' ? await importLocale(locale) : locale;

apps/site/types/blog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { IntlMessageKeys } from './i18n';
2+
13
export type BlogPreviewType = 'announcements' | 'release' | 'vulnerability';
24
export type BlogCategory = IntlMessageKeys<'layouts.blog.categories'>;
35

apps/site/types/i18n.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
1+
import type { Locale } from '@node-core/website-i18n/types';
2+
import type {
3+
NamespaceKeys,
4+
MessageKeys,
5+
NestedValueOf,
6+
NestedKeyOf,
7+
} from 'next-intl';
18
import type { JSXElementConstructor, ReactElement, ReactNode } from 'react';
29

310
export type FormattedMessage =
411
| string
512
| ReactElement<HTMLElement, string | JSXElementConstructor<HTMLElement>>
613
| ReadonlyArray<ReactNode>;
14+
15+
// Defines a generic type for all available i18n translation keys, by default not using any namespace
16+
export type IntlMessageKeys<
17+
NestedKey extends NamespaceKeys<Locale, NestedKeyOf<Locale>> = never,
18+
> = MessageKeys<
19+
NestedValueOf<
20+
{ '!': Locale },
21+
[NestedKey] extends [never] ? '!' : `!.${NestedKey}`
22+
>,
23+
NestedKeyOf<
24+
NestedValueOf<
25+
{ '!': Locale },
26+
[NestedKey] extends [never] ? '!' : `!.${NestedKey}`
27+
>
28+
>
29+
>;

apps/site/types/navigation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { HTMLAttributeAnchorTarget } from 'react';
22

3+
import type { IntlMessageKeys } from './i18n';
4+
35
export interface FooterConfig {
46
text: IntlMessageKeys;
57
link: string;

apps/site/util/downloadUtils/index.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as PackageManagerIcons from '@node-core/ui-components/Icons/PackageMana
55
import type { ElementType } from 'react';
66
import satisfies from 'semver/functions/satisfies';
77

8-
import type { NodeReleaseStatus } from '#site/types';
8+
import type { IntlMessageKeys, NodeReleaseStatus } from '#site/types';
99
import type * as Types from '#site/types/release';
1010
import type { UserOS, UserPlatform } from '#site/types/userOS';
1111

@@ -33,10 +33,10 @@ type DownloadCompatibility = {
3333
};
3434

3535
type DownloadDropdownItem<T extends string> = {
36-
label: string;
36+
label: IntlMessageKeys;
3737
recommended?: boolean;
3838
url?: string;
39-
info?: string;
39+
info?: IntlMessageKeys;
4040
compatibility: DownloadCompatibility;
4141
} & Omit<SelectValue<T>, 'label'>;
4242

@@ -102,7 +102,7 @@ type ActualSystems = Omit<typeof systems, 'OTHER' | 'LOADING'>;
102102
export const OPERATING_SYSTEMS = Object.entries(systems as ActualSystems)
103103
.filter(([key]) => key !== 'LOADING' && key !== 'OTHER')
104104
.map(([key, data]) => ({
105-
label: data.name,
105+
label: data.name as IntlMessageKeys,
106106
value: key as UserOS,
107107
compatibility: data.compatibility,
108108
iconImage: createIcon(OSIcons, data.icon),
@@ -112,11 +112,11 @@ export const OPERATING_SYSTEMS = Object.entries(systems as ActualSystems)
112112
export const INSTALL_METHODS = installMethods.map(method => ({
113113
key: method.id,
114114
value: method.id as Types.InstallationMethod,
115-
label: method.name,
115+
label: method.name as IntlMessageKeys,
116116
iconImage: createIcon(InstallMethodIcons, method.icon),
117117
recommended: method.recommended,
118118
url: method.url,
119-
info: method.info,
119+
info: method.info as IntlMessageKeys,
120120
compatibility: {
121121
...method.compatibility,
122122
os: method.compatibility?.os?.map(os => os as UserOS),
@@ -130,7 +130,7 @@ export const INSTALL_METHODS = installMethods.map(method => ({
130130
export const PACKAGE_MANAGERS = packageManagers.map(manager => ({
131131
key: manager.id,
132132
value: manager.id as Types.PackageManager,
133-
label: manager.name,
133+
label: manager.name as IntlMessageKeys,
134134
iconImage: createIcon(PackageManagerIcons, manager.id),
135135
compatibility: {
136136
...manager.compatibility,

packages/i18n/lib/index.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import localeConfig from '../config.json' with { type: 'json' };
66
* Imports a locale when exists from the locales directory
77
*
88
* @param {string} locale The locale code to import
9-
* @returns {Promise<Record<string, any>>} The imported locale
9+
* @returns {Promise<import('../types').Locale>} The imported locale
1010
*/
1111
export const importLocale = async locale => {
1212
return import(`../locales/${locale}.json`, { with: { type: 'json' } }).then(

packages/i18n/types.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type EnglishMessages from './locales/en.json';
2+
13
export interface LocaleConfig {
24
code: string;
35
localName: string;
@@ -8,3 +10,5 @@ export interface LocaleConfig {
810
enabled: boolean;
911
default: boolean;
1012
}
13+
14+
export type Locale = typeof EnglishMessages;

0 commit comments

Comments
 (0)