fix(i18n): persist CMS translations and validate message catalogs
This commit is contained in:
1 parent
96234075f8
commit
ef94646d60
37 files changed
+1621
-463
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { parse } from "@formatjs/icu-messageformat-parser";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { flattenObject } from "@/lib/translations-utils";
|
||||
|
||||
describe("CMS message catalogs", () => {
|
||||
it("contains valid ICU messages in every shipped locale", async () => {
|
||||
const errors: string[] = [];
|
||||
for (const file of await readdir("src/messages")) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const messages = flattenObject(
|
||||
JSON.parse(await readFile(`src/messages/${file}`, "utf8")),
|
||||
);
|
||||
for (const [key, value] of Object.entries(messages)) {
|
||||
try {
|
||||
parse(value);
|
||||
} catch {
|
||||
errors.push(`${file}: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
it("provides actual text for the search result headings and dashboard counts", async () => {
|
||||
const { createTranslator } = await import("next-intl");
|
||||
const messages = JSON.parse(await readFile("src/messages/en.json", "utf8"));
|
||||
const errors: unknown[] = [];
|
||||
const t = createTranslator({
|
||||
locale: "en",
|
||||
messages,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
expect(t("pages.admin.dashboard.totalAccounts", { count: 12 })).toContain(
|
||||
"12",
|
||||
);
|
||||
expect(t("pages.admin.search.noResults")).not.toContain("pages.");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||
|
||||
export function isSupportedLocale(
|
||||
value: string | undefined,
|
||||
): value is AppLocale {
|
||||
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
+12
-39
@@ -1,41 +1,17 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { getRequestConfig } from "next-intl/server";
|
||||
import { loadCmsMessages } from "@/lib/cms-translations";
|
||||
import { mergeMessages } from "./merge-messages";
|
||||
|
||||
// i18n WITHOUT routing: the locale is chosen by a `NEXT_LOCALE` cookie, then
|
||||
// the Accept-Language header, and finally English as fallback.
|
||||
export const SUPPORTED_LOCALES = [
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: AppLocale = "en";
|
||||
export {
|
||||
DEFAULT_LOCALE,
|
||||
isSupportedLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "./locales";
|
||||
|
||||
export function isSupportedLocale(
|
||||
value: string | undefined,
|
||||
): value is AppLocale {
|
||||
return !!value && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
import { type AppLocale, DEFAULT_LOCALE, isSupportedLocale } from "./locales";
|
||||
|
||||
function detectLocaleFromAcceptLanguage(
|
||||
acceptLanguage: string | null,
|
||||
@@ -73,14 +49,10 @@ export default getRequestConfig(async () => {
|
||||
locale = detected ?? DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// Safe: `locale` is validated against SUPPORTED_LOCALES above (only en/it/nl/de/fr/es).
|
||||
const messages = (await import(`../messages/${locale}.json`)).default;
|
||||
// English is the source of truth; fall back to it for any key missing from a
|
||||
// translation so the UI never shows a raw key path.
|
||||
const fallback =
|
||||
locale === DEFAULT_LOCALE
|
||||
? messages
|
||||
: (await import(`../messages/${DEFAULT_LOCALE}.json`)).default;
|
||||
const [messages, fallback] = await Promise.all([
|
||||
loadCmsMessages(locale),
|
||||
loadCmsMessages(DEFAULT_LOCALE),
|
||||
]);
|
||||
|
||||
return {
|
||||
locale,
|
||||
@@ -108,6 +80,7 @@ export default getRequestConfig(async () => {
|
||||
if (typeof fromEnglish === "string") return fromEnglish;
|
||||
const seg = key.split(".").pop() ?? key;
|
||||
return seg
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[._-]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user