fix: build locale based on validated codes and regions (#11912)
* fix: build locale based on validated codes and regions * keep html lang stable * fix type errorpull/11918/head^2
parent
bc81f659aa
commit
f2ecd9818a
|
@ -1,4 +1,5 @@
|
||||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||||
|
import { dir } from "i18next";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { SessionProvider, useSession } from "next-auth/react";
|
import { SessionProvider, useSession } from "next-auth/react";
|
||||||
import { EventCollectionProvider } from "next-collect/client";
|
import { EventCollectionProvider } from "next-collect/client";
|
||||||
|
@ -77,19 +78,33 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||||
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
|
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.document.documentElement.lang = locale;
|
|
||||||
|
|
||||||
let direction = window.document.dir || "ltr";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const intlLocale = new Intl.Locale(locale);
|
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
|
||||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
delete window.document.documentElement["lang"];
|
||||||
direction = intlLocale.textInfo?.direction;
|
|
||||||
|
window.document.documentElement.lang = locale;
|
||||||
|
|
||||||
|
// Next.js writes the locale to the same attribute
|
||||||
|
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
|
||||||
|
// which can result in a race condition
|
||||||
|
// this property descriptor ensures this never happens
|
||||||
|
Object.defineProperty(window.document.documentElement, "lang", {
|
||||||
|
configurable: true,
|
||||||
|
// value: locale,
|
||||||
|
set: function (this) {
|
||||||
|
// empty setter on purpose
|
||||||
|
},
|
||||||
|
get: function () {
|
||||||
|
return locale;
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
window.document.documentElement.lang = locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.document.dir = direction;
|
window.document.dir = dir(locale);
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const clientViewerI18n = useViewerI18n(locale);
|
const clientViewerI18n = useViewerI18n(locale);
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { IncomingMessage } from "http";
|
import type { IncomingMessage } from "http";
|
||||||
|
import { dir } from "i18next";
|
||||||
import type { NextPageContext } from "next";
|
import type { NextPageContext } from "next";
|
||||||
import type { DocumentContext, DocumentProps } from "next/document";
|
import type { DocumentContext, DocumentProps } from "next/document";
|
||||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||||
|
@ -50,21 +51,15 @@ class MyDocument extends Document<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { isEmbed, embedColorScheme } = this.props;
|
const { isEmbed, embedColorScheme } = this.props;
|
||||||
const newLocale = this.props.newLocale || "en";
|
const newLocale = this.props.newLocale || "en";
|
||||||
|
const newDir = dir(newLocale);
|
||||||
|
|
||||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||||
|
|
||||||
const intlLocale = new Intl.Locale(newLocale);
|
|
||||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
|
||||||
const direction = intlLocale.textInfo?.direction;
|
|
||||||
if (!direction) {
|
|
||||||
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html
|
<Html
|
||||||
lang={newLocale}
|
lang={newLocale}
|
||||||
dir={direction}
|
dir={newDir}
|
||||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||||
<Head nonce={nonce}>
|
<Head nonce={nonce}>
|
||||||
<script
|
<script
|
||||||
|
|
|
@ -52,7 +52,151 @@ test.describe("unauthorized user sees correct translations (ar)", async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("authorized user sees correct translations (de) [locale1]", async () => {
|
test.describe("unauthorized user sees correct translations (zh)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "zh",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=zh]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("欢迎回来", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (zh-CN)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "zh-CN",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=zh-CN]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("欢迎回来", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (zh-TW)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "zh-TW",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=zh-TW]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("歡迎回來", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (pt)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "pt",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Olá novamente", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (pt-br)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "pt-br",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bem-vindo(a) novamente", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("unauthorized user sees correct translations (es-419)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "es-419",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should use correct translations and html attributes", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("load");
|
||||||
|
|
||||||
|
await page.locator("html[lang=es-419]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bienvenido de nuevo", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Welcome back", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees correct translations (de)", async () => {
|
||||||
test.use({
|
test.use({
|
||||||
locale: "en",
|
locale: "en",
|
||||||
});
|
});
|
||||||
|
@ -124,6 +268,78 @@ test.describe("authorized user sees correct translations (de) [locale1]", async
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees correct translations (pt-br)", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a pt-br user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "pt-br",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /event-types and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.goto("/event-types");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Tipos de Eventos", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Event Types", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should navigate to /bookings and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.goto("/bookings");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Reservas", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload the /bookings and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-br]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Reservas", { exact: true });
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Bookings", { exact: true });
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("authorized user sees correct translations (ar)", async () => {
|
test.describe("authorized user sees correct translations (ar)", async () => {
|
||||||
test.use({
|
test.use({
|
||||||
locale: "en",
|
locale: "en",
|
||||||
|
@ -137,7 +353,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
|
||||||
await user.apiLogin();
|
await user.apiLogin();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("should navigate to /event-types and show German translations", async () => {
|
await test.step("should navigate to /event-types and show Arabic translations", async () => {
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
@ -156,7 +372,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("should navigate to /bookings and show German translations", async () => {
|
await test.step("should navigate to /bookings and show Arabic translations", async () => {
|
||||||
await page.goto("/bookings");
|
await page.goto("/bookings");
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
@ -175,7 +391,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("should reload the /bookings and show German translations", async () => {
|
await test.step("should reload the /bookings and show Arabic translations", async () => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
@ -257,3 +473,65 @@ test.describe("authorized user sees changed translations (de->ar)", async () =>
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("authorized user sees changed translations (de->pt-BR) [locale1]", async () => {
|
||||||
|
test.use({
|
||||||
|
locale: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return correct translations and html attributes", async ({ page, users }) => {
|
||||||
|
await test.step("should create a de user", async () => {
|
||||||
|
const user = await users.create({
|
||||||
|
locale: "de",
|
||||||
|
});
|
||||||
|
await user.apiLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should change the language and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.goto("/settings/my-account/general");
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
|
||||||
|
await page.locator("#react-select-2-option-14").click();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Aktualisieren" }).click();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Einstellungen erfolgreich aktualisiert" })
|
||||||
|
.waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Geral", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Allgemein", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("should reload and show Brazil-Portuguese translations", async () => {
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
await page.locator("html[lang=pt-BR]").waitFor({ state: "attached" });
|
||||||
|
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Geral", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const locator = page.getByText("Allgemein", { exact: true }); // "general"
|
||||||
|
expect(await locator.count()).toEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -33,6 +33,10 @@ const config = {
|
||||||
"zh-TW",
|
"zh-TW",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
fallbackLng: {
|
||||||
|
default: ["en"],
|
||||||
|
zh: ["zh-CN"],
|
||||||
|
},
|
||||||
reloadOnPrerender: process.env.NODE_ENV !== "production",
|
reloadOnPrerender: process.env.NODE_ENV !== "production",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -29,5 +29,16 @@ export const getLocale = async (req: GetTokenParams["req"]): Promise<string> =>
|
||||||
|
|
||||||
const languages = acceptLanguage ? parse(acceptLanguage) : [];
|
const languages = acceptLanguage ? parse(acceptLanguage) : [];
|
||||||
|
|
||||||
return languages[0]?.code || "en";
|
const code: string = languages[0]?.code ?? "";
|
||||||
|
const region: string = languages[0]?.region ?? "";
|
||||||
|
|
||||||
|
// the code should consist of 2 or 3 lowercase letters
|
||||||
|
// the regex underneath is more permissive
|
||||||
|
const testedCode = /^[a-zA-Z]+$/.test(code) ? code : "en";
|
||||||
|
|
||||||
|
// the code should consist of either 2 uppercase letters or 3 digits
|
||||||
|
// the regex underneath is more permissive
|
||||||
|
const testedRegion = /^[a-zA-Z0-9]+$/.test(region) ? region : "";
|
||||||
|
|
||||||
|
return `${testedCode}${testedRegion !== "" ? "-" : ""}${testedRegion}`;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue