From 5ad19b732bc0bcbf76b5b531854bd158c568f75a Mon Sep 17 00:00:00 2001 From: avivkeller Date: Mon, 26 May 2025 17:56:42 -0400 Subject: [PATCH 1/3] chore(i18n): improve type safety --- .../Downloads/Release/ReleaseCodeBox.tsx | 3 +- apps/site/global.d.ts | 32 +++---------------- .../hooks/react-generic/useSiteNavigation.ts | 1 + apps/site/tests/e2e/general-behavior.spec.ts | 6 ++-- apps/site/types/blog.ts | 2 ++ apps/site/types/i18n.ts | 23 +++++++++++++ apps/site/types/navigation.ts | 2 ++ apps/site/util/downloadUtils/index.tsx | 10 +++--- packages/i18n/lib/index.mjs | 2 +- packages/i18n/types.d.ts | 4 +++ 10 files changed, 47 insertions(+), 38 deletions(-) diff --git a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx index 0497ca0d234a9..45267980a3112 100644 --- a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx +++ b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx @@ -15,6 +15,7 @@ import { ReleaseContext, ReleasesContext, } from '#site/providers/releaseProvider'; +import type { IntlMessageKeys } from '#site/types/i18n.js'; import type { ReleaseContextType } from '#site/types/release'; import { INSTALL_METHODS } from '#site/util/downloadUtils'; @@ -144,7 +145,7 @@ const ReleaseCodeBox: FC = () => { - {t(info, { platform: label })}{' '} + {t(info as IntlMessageKeys, { platform: label })}{' '} {t.rich('layouts.download.codeBox.externalSupportInfo', { platform: label, link: text => ( diff --git a/apps/site/global.d.ts b/apps/site/global.d.ts index 8959340122443..aceb9aee665e9 100644 --- a/apps/site/global.d.ts +++ b/apps/site/global.d.ts @@ -1,29 +1,7 @@ -import type baseMessages from '@node-core/website-i18n/locales/en.json'; -import type { MessageKeys, NestedValueOf, NestedKeyOf } from 'next-intl'; +import { Locale } from '@node-core/website-i18n/types'; -declare global { - // Defines a type for all the IntlMessage shape (which is used internall by next-intl) - // @see https://next-intl.dev/docs/workflows/typescript - type IntlMessages = typeof baseMessages; - - // Defines a generic type for all available i18n translation keys, by default not using any namespace - type IntlMessageKeys< - NestedKey extends NamespaceKeys< - IntlMessages, - NestedKeyOf - > = never, - > = MessageKeys< - NestedValueOf< - { '!': IntlMessages }, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - >, - NestedKeyOf< - NestedValueOf< - { '!': IntlMessages }, - [NestedKey] extends [never] ? '!' : `!.${NestedKey}` - > - > - >; +declare module 'next-intl' { + interface AppConfig { + Messages: Locale; + } } - -export {}; diff --git a/apps/site/hooks/react-generic/useSiteNavigation.ts b/apps/site/hooks/react-generic/useSiteNavigation.ts index 768092b72f31b..edf49a5fc765e 100644 --- a/apps/site/hooks/react-generic/useSiteNavigation.ts +++ b/apps/site/hooks/react-generic/useSiteNavigation.ts @@ -5,6 +5,7 @@ import type { HTMLAttributeAnchorTarget } from 'react'; import { siteNavigation } from '#site/next.json.mjs'; import type { FormattedMessage, + IntlMessageKeys, NavigationEntry, NavigationKeys, } from '#site/types'; diff --git a/apps/site/tests/e2e/general-behavior.spec.ts b/apps/site/tests/e2e/general-behavior.spec.ts index b3607453b58d2..78d455b81e411 100644 --- a/apps/site/tests/e2e/general-behavior.spec.ts +++ b/apps/site/tests/e2e/general-behavior.spec.ts @@ -1,4 +1,5 @@ import { importLocale } from '@node-core/website-i18n'; +import type { Locale } from '@node-core/website-i18n/types'; import { test, expect, type Page } from '@playwright/test'; const englishLocale = await importLocale('en'); @@ -34,10 +35,7 @@ const openLanguageMenu = async (page: Page) => { return page.locator(selector); }; -const verifyTranslation = async ( - page: Page, - locale: string | Record -) => { +const verifyTranslation = async (page: Page, locale: Locale | string) => { // Load locale data if string code provided (e.g., 'es', 'fr') const localeData = typeof locale === 'string' ? await importLocale(locale) : locale; diff --git a/apps/site/types/blog.ts b/apps/site/types/blog.ts index 33e917c19a761..79db83ad1ed97 100644 --- a/apps/site/types/blog.ts +++ b/apps/site/types/blog.ts @@ -1,3 +1,5 @@ +import type { IntlMessageKeys } from './i18n'; + export type BlogPreviewType = 'announcements' | 'release' | 'vulnerability'; export type BlogCategory = IntlMessageKeys<'layouts.blog.categories'>; diff --git a/apps/site/types/i18n.ts b/apps/site/types/i18n.ts index c3666688c6ec9..71ac2b3abd2d0 100644 --- a/apps/site/types/i18n.ts +++ b/apps/site/types/i18n.ts @@ -1,6 +1,29 @@ +import type { Locale } from '@node-core/website-i18n/types'; +import type { + NamespaceKeys, + MessageKeys, + NestedValueOf, + NestedKeyOf, +} from 'next-intl'; import type { JSXElementConstructor, ReactElement, ReactNode } from 'react'; export type FormattedMessage = | string | ReactElement> | ReadonlyArray; + +// Defines a generic type for all available i18n translation keys, by default not using any namespace +export type IntlMessageKeys< + NestedKey extends NamespaceKeys> = never, +> = MessageKeys< + NestedValueOf< + { '!': Locale }, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + >, + NestedKeyOf< + NestedValueOf< + { '!': Locale }, + [NestedKey] extends [never] ? '!' : `!.${NestedKey}` + > + > +>; diff --git a/apps/site/types/navigation.ts b/apps/site/types/navigation.ts index b45d233bbb890..44618de780706 100644 --- a/apps/site/types/navigation.ts +++ b/apps/site/types/navigation.ts @@ -1,5 +1,7 @@ import type { HTMLAttributeAnchorTarget } from 'react'; +import type { IntlMessageKeys } from './i18n'; + export interface FooterConfig { text: IntlMessageKeys; link: string; diff --git a/apps/site/util/downloadUtils/index.tsx b/apps/site/util/downloadUtils/index.tsx index 2ecfeb77bdea0..d0b77042d2aaa 100644 --- a/apps/site/util/downloadUtils/index.tsx +++ b/apps/site/util/downloadUtils/index.tsx @@ -5,7 +5,7 @@ import * as PackageManagerIcons from '@node-core/ui-components/Icons/PackageMana import type { ElementType } from 'react'; import satisfies from 'semver/functions/satisfies'; -import type { NodeReleaseStatus } from '#site/types'; +import type { IntlMessageKeys, NodeReleaseStatus } from '#site/types'; import type * as Types from '#site/types/release'; import type { UserOS, UserPlatform } from '#site/types/userOS'; @@ -102,7 +102,7 @@ type ActualSystems = Omit; export const OPERATING_SYSTEMS = Object.entries(systems as ActualSystems) .filter(([key]) => key !== 'LOADING' && key !== 'OTHER') .map(([key, data]) => ({ - label: data.name, + label: data.name as IntlMessageKeys, value: key as UserOS, compatibility: data.compatibility, iconImage: createIcon(OSIcons, data.icon), @@ -112,7 +112,7 @@ export const OPERATING_SYSTEMS = Object.entries(systems as ActualSystems) export const INSTALL_METHODS = installMethods.map(method => ({ key: method.id, value: method.id as Types.InstallationMethod, - label: method.name, + label: method.name as IntlMessageKeys, iconImage: createIcon(InstallMethodIcons, method.icon), recommended: method.recommended, url: method.url, @@ -130,7 +130,7 @@ export const INSTALL_METHODS = installMethods.map(method => ({ export const PACKAGE_MANAGERS = packageManagers.map(manager => ({ key: manager.id, value: manager.id as Types.PackageManager, - label: manager.name, + label: manager.name as IntlMessageKeys, iconImage: createIcon(PackageManagerIcons, manager.id), compatibility: { ...manager.compatibility, @@ -143,7 +143,7 @@ export const PLATFORMS = Object.fromEntries( Object.entries(systems).map(([key, data]) => [ key, data.platforms.map(platform => ({ - label: platform.label, + label: platform.label as IntlMessageKeys, value: platform.value as UserPlatform, compatibility: platform.compatibility || {}, })), diff --git a/packages/i18n/lib/index.mjs b/packages/i18n/lib/index.mjs index 25336fe7330a4..acd86ce74653c 100644 --- a/packages/i18n/lib/index.mjs +++ b/packages/i18n/lib/index.mjs @@ -6,7 +6,7 @@ import localeConfig from '../config.json' with { type: 'json' }; * Imports a locale when exists from the locales directory * * @param {string} locale The locale code to import - * @returns {Promise>} The imported locale + * @returns {Promise} The imported locale */ export const importLocale = async locale => { return import(`../locales/${locale}.json`, { with: { type: 'json' } }).then( diff --git a/packages/i18n/types.d.ts b/packages/i18n/types.d.ts index 7e0a501af9810..d091091106255 100644 --- a/packages/i18n/types.d.ts +++ b/packages/i18n/types.d.ts @@ -1,3 +1,5 @@ +import type EnglishMessages from './locales/en.json'; + export interface LocaleConfig { code: string; localName: string; @@ -8,3 +10,5 @@ export interface LocaleConfig { enabled: boolean; default: boolean; } + +export type Locale = typeof EnglishMessages; From 75bc5ef7c9e30b7569580f390601984b23961014 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Mon, 26 May 2025 18:04:01 -0400 Subject: [PATCH 2/3] fixup! Signed-off-by: Aviv Keller --- apps/site/components/Downloads/Release/ReleaseCodeBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx index 45267980a3112..37358b2b0799e 100644 --- a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx +++ b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx @@ -15,7 +15,7 @@ import { ReleaseContext, ReleasesContext, } from '#site/providers/releaseProvider'; -import type { IntlMessageKeys } from '#site/types/i18n.js'; +import type { IntlMessageKeys } from '#site/types/i18n'; import type { ReleaseContextType } from '#site/types/release'; import { INSTALL_METHODS } from '#site/util/downloadUtils'; From 27f2631280c35035174521169d3cbc7c6954a76f Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 27 May 2025 16:05:32 -0400 Subject: [PATCH 3/3] improve casting --- apps/site/components/Downloads/Release/ReleaseCodeBox.tsx | 3 +-- apps/site/util/downloadUtils/index.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx index 37358b2b0799e..0497ca0d234a9 100644 --- a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx +++ b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx @@ -15,7 +15,6 @@ import { ReleaseContext, ReleasesContext, } from '#site/providers/releaseProvider'; -import type { IntlMessageKeys } from '#site/types/i18n'; import type { ReleaseContextType } from '#site/types/release'; import { INSTALL_METHODS } from '#site/util/downloadUtils'; @@ -145,7 +144,7 @@ const ReleaseCodeBox: FC = () => { - {t(info as IntlMessageKeys, { platform: label })}{' '} + {t(info, { platform: label })}{' '} {t.rich('layouts.download.codeBox.externalSupportInfo', { platform: label, link: text => ( diff --git a/apps/site/util/downloadUtils/index.tsx b/apps/site/util/downloadUtils/index.tsx index d0b77042d2aaa..b7fa11d3dca8b 100644 --- a/apps/site/util/downloadUtils/index.tsx +++ b/apps/site/util/downloadUtils/index.tsx @@ -33,10 +33,10 @@ type DownloadCompatibility = { }; type DownloadDropdownItem = { - label: string; + label: IntlMessageKeys; recommended?: boolean; url?: string; - info?: string; + info?: IntlMessageKeys; compatibility: DownloadCompatibility; } & Omit, 'label'>; @@ -116,7 +116,7 @@ export const INSTALL_METHODS = installMethods.map(method => ({ iconImage: createIcon(InstallMethodIcons, method.icon), recommended: method.recommended, url: method.url, - info: method.info, + info: method.info as IntlMessageKeys, compatibility: { ...method.compatibility, os: method.compatibility?.os?.map(os => os as UserOS), @@ -143,7 +143,7 @@ export const PLATFORMS = Object.fromEntries( Object.entries(systems).map(([key, data]) => [ key, data.platforms.map(platform => ({ - label: platform.label as IntlMessageKeys, + label: platform.label, value: platform.value as UserPlatform, compatibility: platform.compatibility || {}, })),