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:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -27,7 +27,7 @@
|
||||
"Umami"
|
||||
],
|
||||
"i18n-ally.dirStructure": "auto",
|
||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||
"i18n-ally.enabledFrameworks": ["next-intl"],
|
||||
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
|
||||
"i18n-ally.enabledParsers": ["ts"],
|
||||
"i18n-ally.extract.keyMaxLength": 0,
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import "@homarr/auth/env.mjs";
|
||||
|
||||
import MillionLint from "@million/lint";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
import "./src/env.mjs";
|
||||
|
||||
// Package path does not work... so we need to use relative path
|
||||
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
@@ -34,4 +38,4 @@ const config = {
|
||||
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
|
||||
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
|
||||
|
||||
export default config;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
import { defaultLocale } from "@homarr/translation";
|
||||
import { I18nProviderClient } from "@homarr/translation/client";
|
||||
|
||||
export const NextInternationalProvider = ({ children, locale }: PropsWithChildren<{ locale: string }>) => {
|
||||
return (
|
||||
<I18nProviderClient locale={locale} fallback={defaultLocale}>
|
||||
{children}
|
||||
</I18nProviderClient>
|
||||
);
|
||||
};
|
||||
@@ -7,18 +7,20 @@ import "@homarr/ui/styles.css";
|
||||
import "~/styles/scroll-area.scss";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { env } from "@homarr/auth/env.mjs";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { isLocaleSupported } from "@homarr/translation";
|
||||
import { getI18nMessages, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
import { TRPCReactProvider } from "./_client-providers/trpc";
|
||||
import { composeWrappers } from "./compose";
|
||||
@@ -59,10 +61,15 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||
if (!isLocaleSupported(props.params.locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const colorScheme = getColorScheme();
|
||||
const tCommon = await getScopedI18n("common");
|
||||
const direction = tCommon("direction");
|
||||
const i18nMessages = await getI18nMessages();
|
||||
|
||||
const StackedProvider = composeWrappers([
|
||||
(innerProps) => {
|
||||
@@ -70,7 +77,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
},
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <NextInternationalProvider {...innerProps} locale={props.params.locale} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} messages={i18nMessages} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
]);
|
||||
@@ -78,7 +85,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
return (
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
lang="en"
|
||||
lang={props.params.locale}
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
|
||||
@@ -60,7 +60,10 @@ export const UserCreateStepperComponent = () => {
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
params: createCustomErrorParams("passwordsDoNotMatch"),
|
||||
params: createCustomErrorParams({
|
||||
key: "passwordsDoNotMatch",
|
||||
params: {},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
|
||||
@@ -10,8 +10,8 @@ export const ReservedGroupAlert = async () => {
|
||||
|
||||
return (
|
||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t("group.reservedNotice.message", {
|
||||
checkoutDocs: (
|
||||
{t.rich("group.reservedNotice.message", {
|
||||
checkoutDocs: () => (
|
||||
<Anchor
|
||||
size="sm"
|
||||
component={Link}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Combobox, Group, InputBase, Text, useCombobox } from "@mantine/core";
|
||||
import { Combobox, Group, InputBase, Loader, Text, useCombobox } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { SupportedLanguage } from "@homarr/translation";
|
||||
import { localeAttributes, supportedLanguages } from "@homarr/translation";
|
||||
import { localeConfigurations, supportedLanguages } from "@homarr/translation";
|
||||
import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./language-combobox.module.css";
|
||||
@@ -15,7 +15,7 @@ export const LanguageCombobox = () => {
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
const currentLocale = useCurrentLocale();
|
||||
const changeLocale = useChangeLocale();
|
||||
const { changeLocale, isPending } = useChangeLocale();
|
||||
|
||||
const handleOnOptionSubmit = React.useCallback(
|
||||
(value: string) => {
|
||||
@@ -39,6 +39,7 @@ export const LanguageCombobox = () => {
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
leftSection={isPending ? <Loader size={16} /> : null}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={handleOnClick}
|
||||
@@ -72,11 +73,11 @@ const OptionItem = ({
|
||||
return (
|
||||
<Group wrap="nowrap" justify="space-between">
|
||||
<Group wrap="nowrap">
|
||||
<span className={`fi fi-${localeAttributes[localeKey].flagIcon} ${classes.flagIcon}`}></span>
|
||||
<span className={`fi fi-${localeConfigurations[localeKey].flagIcon} ${classes.flagIcon}`}></span>
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Text>{localeAttributes[localeKey].name}</Text>
|
||||
<Text>{localeConfigurations[localeKey].name}</Text>
|
||||
<Text size="xs" c="dimmed" inherit>
|
||||
({localeAttributes[localeKey].translatedName})
|
||||
({localeConfigurations[localeKey].translatedName})
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
19
packages/translation/src/client/index.ts
Normal file
19
packages/translation/src/client/index.ts
Normal 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 };
|
||||
25
packages/translation/src/client/use-change-locale.ts
Normal file
25
packages/translation/src/client/use-change-locale.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
5
packages/translation/src/client/use-current-locale.ts
Normal file
5
packages/translation/src/client/use-current-locale.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useLocale } from "next-intl";
|
||||
|
||||
import type { SupportedLanguage } from "../config";
|
||||
|
||||
export const useCurrentLocale = () => useLocale() as SupportedLanguage;
|
||||
26
packages/translation/src/config.ts
Normal file
26
packages/translation/src/config.ts
Normal 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;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -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*"],
|
||||
};
|
||||
|
||||
34
packages/translation/src/request.ts
Normal file
34
packages/translation/src/request.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
11
packages/translation/src/routing.ts
Normal file
11
packages/translation/src/routing.ts
Normal 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
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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> {}
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`, () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
99
pnpm-lock.yaml
generated
99
pnpm-lock.yaml
generated
@@ -1389,15 +1389,24 @@ importers:
|
||||
|
||||
packages/translation:
|
||||
dependencies:
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
mantine-react-table:
|
||||
specifier: 2.0.0-beta.7
|
||||
version: 2.0.0-beta.7(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/dates@7.13.4(@mantine/core@7.13.4(@mantine/hooks@7.13.4(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.13.4(react@18.3.1))(@tabler/icons-react@3.20.0(react@18.3.1))(clsx@2.1.1)(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next-international:
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
next:
|
||||
specifier: ^14.2.16
|
||||
version: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4)
|
||||
next-intl:
|
||||
specifier: 3.23.2
|
||||
version: 3.23.2(next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4))(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
devDependencies:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -2654,6 +2663,21 @@ packages:
|
||||
'@floating-ui/utils@0.2.8':
|
||||
resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.2.0':
|
||||
resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==}
|
||||
|
||||
'@formatjs/fast-memoize@2.2.1':
|
||||
resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==}
|
||||
|
||||
'@formatjs/icu-messageformat-parser@2.8.0':
|
||||
resolution: {integrity: sha512-r2un3fmF9oJv3mOkH+wwQZ037VpqmdfahbcCZ9Lh+p6Sx+sNsonI7Zcr6jNMm1s+Si7ejQORS4Ezlh05mMPAXA==}
|
||||
|
||||
'@formatjs/icu-skeleton-parser@1.8.4':
|
||||
resolution: {integrity: sha512-LMQ1+Wk1QSzU4zpd5aSu7+w5oeYhupRwZnMQckLPRYhSjf2/8JWQ882BauY9NyHxs5igpuQIXZDgfkaH3PoATg==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.5.5':
|
||||
resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==}
|
||||
|
||||
'@hapi/bourne@3.0.0':
|
||||
resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==}
|
||||
|
||||
@@ -5472,8 +5496,8 @@ packages:
|
||||
resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
international-types@0.8.1:
|
||||
resolution: {integrity: sha512-tajBCAHo4I0LIFlmQ9ZWfjMWVyRffzuvfbXCd6ssFt5u1Zw15DN0UBpVTItXdNa1ls+cpQt3Yw8+TxsfGF8JcA==}
|
||||
intl-messageformat@10.7.1:
|
||||
resolution: {integrity: sha512-xQuJW2WcyzNJZWUu5xTVPOmNSA1Sowuu/NKFdUid5Fxx/Yl6/s4DefTU/y7zy+irZLDmFGmTLtnM8FqpN05wlA==}
|
||||
|
||||
invariant@2.2.4:
|
||||
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
|
||||
@@ -6128,8 +6152,11 @@ packages:
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
next-international@1.2.4:
|
||||
resolution: {integrity: sha512-JQvp+h2iSgA/t8hu5S/Lwow1ZErJutQRdpnplxjv4VTlCiND8T95fYih8BjkHcVhQbtM+Wu9Mb1CM32wD9hlWQ==}
|
||||
next-intl@3.23.2:
|
||||
resolution: {integrity: sha512-SCYEG2i0kYz+OupN6+qH9T+GDRfLCmJuT835uI9ac7AOlYCUbBizj28cti+oGhDkIjueZrweVw7iEiTkqCpKpQ==}
|
||||
peerDependencies:
|
||||
next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
next@14.2.16:
|
||||
resolution: {integrity: sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==}
|
||||
@@ -7008,9 +7035,6 @@ packages:
|
||||
serialize-javascript@6.0.2:
|
||||
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
@@ -7717,6 +7741,11 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.13'
|
||||
|
||||
use-intl@3.23.2:
|
||||
resolution: {integrity: sha512-lrKb5M6zr9YoHK+OuUsRApPPNEMHX8ntx0PDGZ0fxlMmj6W2u/3y++UB4uE/o0C8Jyn7oiHCjShYjgPjDaB1cg==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
use-isomorphic-layout-effect@1.1.2:
|
||||
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
|
||||
peerDependencies:
|
||||
@@ -8767,6 +8796,31 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.8': {}
|
||||
|
||||
'@formatjs/ecma402-abstract@2.2.0':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 2.2.1
|
||||
'@formatjs/intl-localematcher': 0.5.5
|
||||
tslib: 2.7.0
|
||||
|
||||
'@formatjs/fast-memoize@2.2.1':
|
||||
dependencies:
|
||||
tslib: 2.7.0
|
||||
|
||||
'@formatjs/icu-messageformat-parser@2.8.0':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.2.0
|
||||
'@formatjs/icu-skeleton-parser': 1.8.4
|
||||
tslib: 2.7.0
|
||||
|
||||
'@formatjs/icu-skeleton-parser@1.8.4':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.2.0
|
||||
tslib: 2.7.0
|
||||
|
||||
'@formatjs/intl-localematcher@0.5.5':
|
||||
dependencies:
|
||||
tslib: 2.7.0
|
||||
|
||||
'@hapi/bourne@3.0.0': {}
|
||||
|
||||
'@homarr/gridstack@1.0.3': {}
|
||||
@@ -12168,7 +12222,12 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.0.6
|
||||
|
||||
international-types@0.8.1: {}
|
||||
intl-messageformat@10.7.1:
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.2.0
|
||||
'@formatjs/fast-memoize': 2.2.1
|
||||
'@formatjs/icu-messageformat-parser': 2.8.0
|
||||
tslib: 2.7.0
|
||||
|
||||
invariant@2.2.4:
|
||||
dependencies:
|
||||
@@ -12796,11 +12855,13 @@ snapshots:
|
||||
next: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4)
|
||||
react: 18.3.1
|
||||
|
||||
next-international@1.2.4:
|
||||
next-intl@3.23.2(next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4))(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
international-types: 0.8.1
|
||||
server-only: 0.0.1
|
||||
'@formatjs/intl-localematcher': 0.5.5
|
||||
negotiator: 0.6.3
|
||||
next: 14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4)
|
||||
react: 18.3.1
|
||||
use-intl: 3.23.2(react@18.3.1)
|
||||
|
||||
next@14.2.16(@babel/core@7.25.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.80.4):
|
||||
dependencies:
|
||||
@@ -13815,8 +13876,6 @@ snapshots:
|
||||
dependencies:
|
||||
randombytes: 2.1.0
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
@@ -14631,6 +14690,12 @@ snapshots:
|
||||
dequal: 2.0.3
|
||||
react: 18.3.1
|
||||
|
||||
use-intl@3.23.2(react@18.3.1):
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 2.2.1
|
||||
intl-messageformat: 10.7.1
|
||||
react: 18.3.1
|
||||
|
||||
use-isomorphic-layout-effect@1.1.2(@types/react@18.3.12)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
Reference in New Issue
Block a user