fix(i18n): persist CMS translations and validate message catalogs
CI / check (push) Successful in 1m14s
CI / deploy (push) Successful in 1m27s

This commit is contained in:
Simo committed 2026-09-06 17:55:11 +02:00
1 parent 96234075f8
commit ef94646d60
37 files changed
+1621 -463

No files matched your search

+39
View File
@@ -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([]);
});
});
+32
View File
@@ -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
View File
@@ -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());
},