refactor: move from next-international to next-intl (#1368)

* refactor: move from next-international to next-intl

* refactor: restructure translation package,

* chore: change i18n-allay framework to next-intl

* fix: add missing bold html tag to translation

* fix: format issue

* fix: address deepsource issues

* fix: remove international-types dependency

* fix: lint and typecheck issues

* fix: typecheck issue

* fix: typecheck issue

* fix: issue with translations
This commit is contained in:
Meier Lukas
2024-10-26 22:46:14 +02:00
committed by GitHub
parent db198c6dab
commit 4502569223
33 changed files with 331 additions and 160 deletions

View File

@@ -5,3 +5,7 @@ export type AtLeastOneOf<T> = [T, ...T[]];
export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
[P in keyof (Omit<T, keyof R> & R)]: (Omit<T, keyof R> & R)[P];
};
export type RemoveReadonly<T> = {
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
};

View File

@@ -12,8 +12,11 @@ export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite
return (
<Stack>
<Text>{t("action.copy.description")}</Text>
{/* TODO: When next-international v2 is released the descriptions bold element can be implemented, see https://github.com/QuiiBz/next-international/pull/361 for progress */}
<Text>
{t.rich("action.copy.description", {
b: (children) => <b>{children}</b>,
})}
</Text>
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
<Stack gap="xs">
<Text fw="bold">{t("field.id.label")}:</Text>

View File

@@ -1,7 +1,7 @@
import { Group, Stack, Text } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { localeAttributes, supportedLanguages } from "@homarr/translation";
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../../lib/children";
@@ -11,34 +11,34 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
const normalizedQuery = query.trim().toLowerCase();
const currentLocale = useCurrentLocale();
return supportedLanguages
.map((localeKey) => ({ localeKey, attributes: localeAttributes[localeKey] }))
.map((localeKey) => ({ localeKey, configuration: localeConfigurations[localeKey] }))
.filter(
({ attributes }) =>
attributes.name.toLowerCase().includes(normalizedQuery) ||
attributes.translatedName.toLowerCase().includes(normalizedQuery),
({ configuration }) =>
configuration.name.toLowerCase().includes(normalizedQuery) ||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
)
.sort(
(languageA, languageB) =>
Math.min(
languageA.attributes.name.toLowerCase().indexOf(normalizedQuery),
languageA.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
languageA.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
) -
Math.min(
languageB.attributes.name.toLowerCase().indexOf(normalizedQuery),
languageB.attributes.translatedName.toLowerCase().indexOf(normalizedQuery),
languageB.configuration.name.toLowerCase().indexOf(normalizedQuery),
languageB.configuration.translatedName.toLowerCase().indexOf(normalizedQuery),
),
)
.map(({ localeKey, attributes }) => ({
.map(({ localeKey, configuration }) => ({
key: localeKey,
Component() {
return (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap">
<span className={`fi fi-${attributes.flagIcon}`} style={{ borderRadius: 4 }}></span>
<span className={`fi fi-${configuration.flagIcon}`} style={{ borderRadius: 4 }}></span>
<Group wrap="nowrap" gap="xs">
<Text>{attributes.name}</Text>
<Text>{configuration.name}</Text>
<Text size="xs" c="dimmed" inherit>
({attributes.translatedName})
({configuration.translatedName})
</Text>
</Group>
</Group>
@@ -47,7 +47,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
);
},
useInteraction() {
const changeLocale = useChangeLocale();
const { changeLocale } = useChangeLocale();
return { type: "javaScript", onSelect: () => changeLocale(localeKey) };
},

View File

@@ -6,9 +6,10 @@
"type": "module",
"exports": {
".": "./index.ts",
"./client": "./src/client.ts",
"./client": "./src/client/index.ts",
"./server": "./src/server.ts",
"./middleware": "./src/middleware.ts"
"./middleware": "./src/middleware.ts",
"./request": "./src/request.ts"
},
"typesVersions": {
"*": {
@@ -25,9 +26,12 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.7",
"next-international": "^1.2.4"
"next": "^14.2.16",
"next-intl": "3.23.2",
"react": "^18.3.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -1,13 +0,0 @@
"use client";
import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
import enTranslation from "./lang/en";
export const { useI18n, useScopedI18n, useCurrentLocale, useChangeLocale, I18nProviderClient } = createI18nClient(
languageMapping(),
{
fallbackLocale: enTranslation,
},
);

View File

@@ -0,0 +1,19 @@
"use client";
import { useMessages, useTranslations } from "next-intl";
import type { TranslationObject } from "../type";
export { useChangeLocale } from "./use-change-locale";
export { useCurrentLocale } from "./use-current-locale";
export const { useI18n, useScopedI18n } = {
useI18n: useTranslations,
useScopedI18n: useTranslations,
};
export const { useI18nMessages } = {
useI18nMessages: () => useMessages() as TranslationObject,
};
export { useTranslations };

View File

@@ -0,0 +1,25 @@
import { useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { SupportedLanguage } from "../config";
import { useCurrentLocale } from "./use-current-locale";
export const useChangeLocale = () => {
const currentLocale = useCurrentLocale();
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
return {
changeLocale: (newLocale: SupportedLanguage) => {
if (newLocale === currentLocale) {
return;
}
startTransition(() => {
router.replace(`/${newLocale}/${pathname}`);
});
},
isPending,
};
};

View File

@@ -0,0 +1,5 @@
import { useLocale } from "next-intl";
import type { SupportedLanguage } from "../config";
export const useCurrentLocale = () => useLocale() as SupportedLanguage;

View File

@@ -0,0 +1,26 @@
import { objectKeys } from "@homarr/common";
export const localeConfigurations = {
de: {
name: "Deutsch",
translatedName: "German",
flagIcon: "de",
},
en: {
name: "English",
translatedName: "English",
flagIcon: "us",
},
} satisfies Record<
string,
{
name: string;
translatedName: string;
flagIcon: string;
}
>;
export const supportedLanguages = objectKeys(localeConfigurations);
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const defaultLocale = "en" satisfies SupportedLanguage;

View File

@@ -1,14 +1,11 @@
import type { SupportedLanguage } from "./config";
import { supportedLanguages } from "./config";
import type { stringOrTranslation, TranslationFunction } from "./type";
export * from "./type";
export * from "./locale-attributes";
export const supportedLanguages = ["en", "de"] as const;
export type SupportedLanguage = (typeof supportedLanguages)[number];
export const defaultLocale = "en";
export { languageMapping } from "./lang";
export type { TranslationKeys } from "./lang";
export * from "./config";
export { createLanguageMapping } from "./mapping";
export type { TranslationKeys } from "./mapping";
export const translateIfNecessary = (t: TranslationFunction, value: stringOrTranslation | undefined) => {
if (typeof value === "function") {
@@ -16,3 +13,7 @@ export const translateIfNecessary = (t: TranslationFunction, value: stringOrTran
}
return value;
};
export const isLocaleSupported = (locale: string): locale is SupportedLanguage => {
return supportedLanguages.includes(locale as SupportedLanguage);
};

View File

@@ -706,7 +706,7 @@ export default {
},
},
},
mantineReactTable: MRT_Localization_EN,
mantineReactTable: MRT_Localization_EN as Readonly<Record<keyof typeof MRT_Localization_EN, string>>,
},
section: {
dynamic: {
@@ -1205,11 +1205,11 @@ export default {
},
integration: {
noData: "No integration found",
description: "Click {here} to create a new integration",
description: "Click <here></here> to create a new integration",
},
app: {
noData: "No app found",
description: "Click {here} to create a new app",
description: "Click <here></here> to create a new app",
},
error: {
action: {
@@ -1842,7 +1842,7 @@ export default {
copy: {
title: "Copy invite",
description:
"Your invitation has been generated. After this modal closes, you'll not be able to copy this link anymore. If you do no longer wish to invite said person, you can delete this invitation any time.",
"Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore.</b> If you do no longer wish to invite said person, you can delete this invitation any time.",
link: "Invitation link",
button: "Copy & close",
},

View File

@@ -1,21 +0,0 @@
import type { SupportedLanguage } from ".";
export const localeAttributes: Record<
SupportedLanguage,
{
name: string;
translatedName: string;
flagIcon: string;
}
> = {
de: {
name: "Deutsch",
translatedName: "German",
flagIcon: "de",
},
en: {
name: "English",
translatedName: "English",
flagIcon: "us",
},
};

View File

@@ -1,9 +1,9 @@
import { supportedLanguages } from ".";
import { supportedLanguages } from "./config";
const _enTranslations = () => import("./lang/en");
type EnTranslation = typeof _enTranslations;
export const languageMapping = () => {
export const createLanguageMapping = () => {
const mapping: Record<string, unknown> = {};
for (const language of supportedLanguages) {

View File

@@ -1,9 +1,10 @@
import { createI18nMiddleware } from "next-international/middleware";
import createMiddleware from "next-intl/middleware";
import { defaultLocale, supportedLanguages } from ".";
import { routing } from "./routing";
export const I18nMiddleware = createI18nMiddleware({
locales: supportedLanguages,
defaultLocale,
urlMappingStrategy: "rewrite",
});
export const I18nMiddleware = createMiddleware(routing);
export const config = {
// Match only internationalized pathnames
matcher: ["/", "/(de|en)/:path*"],
};

View File

@@ -0,0 +1,34 @@
import deepmerge from "deepmerge";
import { getRequestConfig } from "next-intl/server";
import { isLocaleSupported } from ".";
import type { SupportedLanguage } from "./config";
import { createLanguageMapping } from "./mapping";
import { routing } from "./routing";
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
export default getRequestConfig(async ({ requestLocale }) => {
let currentLocale = await requestLocale;
if (!currentLocale || !isLocaleSupported(currentLocale)) {
currentLocale = routing.defaultLocale;
}
const typedLocale = currentLocale as SupportedLanguage;
const languageMap = createLanguageMapping();
const currentMessages = (await languageMap[typedLocale]()).default;
// Fallback to default locale if the current locales messages if not all messages are present
if (currentLocale !== routing.defaultLocale) {
const fallbackMessages = (await languageMap[routing.defaultLocale]()).default;
return {
locale: currentLocale,
messages: deepmerge(fallbackMessages, currentMessages),
};
}
return {
locale: currentLocale,
messages: currentMessages,
};
});

View File

@@ -0,0 +1,11 @@
import { defineRouting } from "next-intl/routing";
import { defaultLocale, supportedLanguages } from "./config";
export const routing = defineRouting({
locales: supportedLanguages,
defaultLocale,
localePrefix: {
mode: "never", // Rewrite the URL with locale parameter but without shown in url
},
});

View File

@@ -1,8 +1,8 @@
import { createI18nServer } from "next-international/server";
import { getTranslations } from "next-intl/server";
import { languageMapping } from "./lang";
import enTranslation from "./lang/en";
export const { getI18n, getScopedI18n } = {
getI18n: getTranslations,
getScopedI18n: getTranslations,
};
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(languageMapping(), {
fallbackLocale: enTranslation,
});
export { getMessages as getI18nMessages } from "next-intl/server";

View File

@@ -1,9 +1,19 @@
import type { NamespaceKeys, NestedKeyOf } from "next-intl";
import type { RemoveReadonly } from "@homarr/common/types";
import type { useI18n, useScopedI18n } from "./client";
import type enTranslation from "./lang/en";
export type TranslationFunction = ReturnType<typeof useI18n>;
export type ScopedTranslationFunction<T extends Parameters<typeof useScopedI18n>[0]> = ReturnType<
typeof useScopedI18n<T>
>;
export type TranslationFunction = ReturnType<typeof useI18n<never>>;
export type ScopedTranslationFunction<
NestedKey extends NamespaceKeys<IntlMessages, NestedKeyOf<IntlMessages>> = never,
> = ReturnType<typeof useScopedI18n<NestedKey>>;
export type TranslationObject = typeof enTranslation;
export type stringOrTranslation = string | ((t: TranslationFunction) => string);
declare global {
// Use type safe message keys with `next-intl`
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface IntlMessages extends RemoveReadonly<TranslationObject> {}
}

View File

@@ -1,14 +1,16 @@
"use client";
import type { BadgeProps } from "@mantine/core";
import { Badge } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
import { useTranslations } from "@homarr/translation/client";
interface BetaBadgeProps {
size: BadgeProps["size"];
}
export const BetaBadge = ({ size }: BetaBadgeProps) => {
const t = useI18n();
const t = useTranslations();
return (
<Badge size={size} color="green" variant="outline">
{t("common.beta")}

View File

@@ -1,22 +1,14 @@
import type { MRT_RowData, MRT_TableOptions } from "mantine-react-table";
import { useMantineReactTable } from "mantine-react-table";
import { MRT_Localization_EN } from "mantine-react-table/locales/en/index.cjs";
import { objectKeys } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18nMessages } from "@homarr/translation/client";
export const useTranslatedMantineReactTable = <TData extends MRT_RowData>(
tableOptions: Omit<MRT_TableOptions<TData>, "localization">,
) => {
const t = useScopedI18n("common.mantineReactTable");
const messages = useI18nMessages();
return useMantineReactTable<TData>({
...tableOptions,
localization: objectKeys(MRT_Localization_EN).reduce(
(acc, key) => {
acc[key] = t(key);
return acc;
},
{} as typeof MRT_Localization_EN,
),
localization: messages.common.mantineReactTable,
});
};

View File

@@ -1,15 +1,9 @@
import type { ParamsObject } from "international-types";
import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod";
import { ZodIssueCode } from "zod";
import type { TranslationObject } from "@homarr/translation";
import type { TranslationFunction, TranslationObject } from "@homarr/translation";
export const zodErrorMap = <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TFunction extends (key: string, ...params: any[]) => string,
>(
t: TFunction,
) => {
export const zodErrorMap = <TFunction extends TranslationFunction>(t: TFunction) => {
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
const error = handleZodError(issue, ctx);
if ("message" in error && error.message) {
@@ -139,7 +133,7 @@ type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom
export interface CustomErrorParams<TKey extends CustomErrorKey> {
i18n: {
key: TKey;
params: ParamsObject<TranslationObject["common"]["zod"]["errors"]["custom"][TKey]>;
params: Record<string, unknown>;
};
}

View File

@@ -30,7 +30,10 @@ const passwordSchema = z
return passwordRequirements.every((requirement) => requirement.check(value));
},
{
params: createCustomErrorParams("passwordRequirements"),
params: createCustomErrorParams({
key: "passwordRequirements",
params: {},
}),
},
);
@@ -38,7 +41,10 @@ const confirmPasswordRefine = [
(data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword,
{
path: ["confirmPassword"],
params: createCustomErrorParams("passwordsDoNotMatch"),
params: createCustomErrorParams({
key: "passwordsDoNotMatch",
params: {},
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] satisfies [(args: any) => boolean, unknown];

View File

@@ -43,8 +43,8 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t("widget.common.app.description", {
here: (
{t.rich("widget.common.app.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>

View File

@@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common";
import { languageMapping } from "@homarr/translation";
import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from "..";
describe("Widget properties with description should have matching translations", async () => {
const enTranslation = await languageMapping().en();
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.entries(value.definition.options).forEach(
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => {
@@ -25,7 +25,7 @@ describe("Widget properties with description should have matching translations",
});
describe("Widget properties should have matching name translations", async () => {
const enTranslation = await languageMapping().en();
const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => {
Object.keys(value.definition.options).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {

View File

@@ -92,8 +92,8 @@ export const WidgetIntegrationSelect = ({
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text size="xs">
{t("widget.common.integration.description", {
here: (
{t.rich("widget.common.integration.description", {
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
{t("common.here")}
</Anchor>