Skip to content

Commit cf84992

Browse files
authored
bug: a Traditional Chinese browser (zh-TW, zh-HK, zh-MO) opens the panel in Traditional Chinese, not Simplified (#971)
1 parent 9534539 commit cf84992

6 files changed

Lines changed: 278 additions & 6 deletions

File tree

‎nuxt.config.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,7 @@ export default defineNuxtConfig({
159159
bundle: {
160160
optimizeTranslationDirective: false,
161161
},
162-
detectBrowserLanguage: {
163-
useCookie: true,
164-
cookieKey: "i18n_redirected",
165-
redirectOn: "root",
166-
fallbackLocale: "en",
167-
},
162+
detectBrowserLanguage: false,
168163
locales: [
169164
{ code: "en", name: "English", file: "en.json", flag: "🇬🇧" },
170165
{ code: "ar", name: "العربية", file: "ar_SA.json", flag: "🇸🇦" }, // Arabic

‎pages/settings/index.vue‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "@/components/ui/popover";
1818
import { useI18n } from "vue-i18n";
1919
import PageTransition from "~/components/ui/transitions/PageTransition.vue";
20+
import { savePickedLocale } from "~/utilities/locale";
2021
const { locale, locales, setLocale } = useI18n();
2122
2223
const availableLocales = computed(() => {
@@ -45,6 +46,7 @@ const handleLocaleChange = (
4546
| "pt-BR"
4647
| "zh",
4748
) => {
49+
savePickedLocale(newLocale);
4850
setLocale(newLocale);
4951
};
5052
</script>

‎plugins/locale.client.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {
2+
LEGACY_LOCALE_COOKIE,
3+
legacyPickedLocale,
4+
readPickedLocale,
5+
resolveLocale,
6+
savePickedLocale,
7+
} from "~/utilities/locale";
8+
9+
export default defineNuxtPlugin(async () => {
10+
const i18n = useNuxtApp().$i18n;
11+
12+
const legacy = useCookie<string | null>(LEGACY_LOCALE_COOKIE);
13+
if (legacy.value) {
14+
const picked = legacyPickedLocale(
15+
legacy.value,
16+
i18n.getBrowserLocale() || "en",
17+
);
18+
if (picked && !readPickedLocale()) {
19+
savePickedLocale(picked);
20+
}
21+
legacy.value = null;
22+
}
23+
24+
await i18n.setLocale(
25+
resolveLocale(
26+
i18n.localeCodes.value,
27+
navigator.languages,
28+
readPickedLocale(),
29+
) as typeof i18n.locale.value,
30+
);
31+
});

‎tests/plugins/locale.spec.ts‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import localePlugin from "~/plugins/locale.client";
3+
import {
4+
resetBrowserLanguages,
5+
setBrowserLanguages,
6+
} from "../helpers/browserLocale";
7+
8+
const PICKED_LOCALE_KEY = "5stack:locale";
9+
10+
const app = () =>
11+
useNuxtApp() as unknown as {
12+
$i18n: {
13+
locale: { value: string };
14+
setLocale: (code: string) => Promise<void>;
15+
};
16+
_vueI18n: { __firstAccess: boolean };
17+
};
18+
19+
const locale = () => app().$i18n.locale.value;
20+
21+
async function boot() {
22+
app()._vueI18n.__firstAccess = true;
23+
await (localePlugin as unknown as () => Promise<void>)();
24+
await new Promise((resolve) => setTimeout(resolve, 10));
25+
}
26+
27+
beforeEach(() => {
28+
localStorage.clear();
29+
document.cookie = "i18n_redirected=; max-age=0; path=/";
30+
});
31+
32+
afterEach(async () => {
33+
resetBrowserLanguages();
34+
localStorage.clear();
35+
document.cookie = "i18n_redirected=; max-age=0; path=/";
36+
await app().$i18n.setLocale("en");
37+
});
38+
39+
describe("the language a visitor first sees", () => {
40+
it("is Traditional Chinese for a zh-TW browser, and stays that way across pages", async () => {
41+
setBrowserLanguages(["zh-TW", "zh", "en-US"]);
42+
43+
await boot();
44+
expect(locale()).toBe("zh-Hant");
45+
46+
await useRouter().push("/faq");
47+
await useRouter().push("/");
48+
expect(locale()).toBe("zh-Hant");
49+
});
50+
51+
it("is the visitor's own pick over their browser", async () => {
52+
setBrowserLanguages(["zh-TW"]);
53+
localStorage.setItem(PICKED_LOCALE_KEY, "ja");
54+
55+
await boot();
56+
57+
expect(locale()).toBe("ja");
58+
});
59+
60+
it("is not written to a cookie for the next visit", async () => {
61+
setBrowserLanguages(["de-DE"]);
62+
63+
await boot();
64+
await useRouter().push("/faq");
65+
await new Promise((resolve) => setTimeout(resolve, 10));
66+
67+
expect(document.cookie).not.toContain("i18n_redirected");
68+
});
69+
});
70+
71+
describe("the old i18n_redirected cookie", () => {
72+
it("no longer holds a zh-TW browser on Simplified Chinese", async () => {
73+
setBrowserLanguages(["zh-TW"]);
74+
document.cookie = "i18n_redirected=zh-Hans; path=/";
75+
76+
await boot();
77+
78+
expect(locale()).toBe("zh-Hant");
79+
expect(localStorage.getItem(PICKED_LOCALE_KEY)).toBeNull();
80+
expect(document.cookie).not.toContain("i18n_redirected");
81+
});
82+
83+
it("carries over a language the visitor picked", async () => {
84+
setBrowserLanguages(["en-US"]);
85+
document.cookie = "i18n_redirected=de; path=/";
86+
87+
await boot();
88+
89+
expect(locale()).toBe("de");
90+
expect(localStorage.getItem(PICKED_LOCALE_KEY)).toBe("de");
91+
expect(document.cookie).not.toContain("i18n_redirected");
92+
});
93+
});

‎tests/utilities/locale.spec.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import {
3+
legacyPickedLocale,
4+
matchBrowserLocale,
5+
resolveLocale,
6+
} from "~/utilities/locale";
7+
import {
8+
resetBrowserLanguages,
9+
setBrowserLanguages,
10+
} from "../helpers/browserLocale";
11+
12+
const i18n = () =>
13+
useNuxtApp().$i18n as unknown as {
14+
localeCodes: { value: string[] };
15+
getBrowserLocale: () => string | undefined;
16+
};
17+
18+
const codes = () => i18n().localeCodes.value;
19+
20+
afterEach(resetBrowserLanguages);
21+
22+
describe("the language a browser is shown", () => {
23+
it.each([
24+
["zh-TW", "zh-Hant"],
25+
["zh-HK", "zh-Hant"],
26+
["zh-MO", "zh-Hant"],
27+
["zh-Hant-TW", "zh-Hant"],
28+
["zh-CN", "zh-Hans"],
29+
["zh-SG", "zh-Hans"],
30+
["zh", "zh-Hans"],
31+
["pt-BR", "pt"],
32+
["pt-PT", "pt"],
33+
["en-US", "en"],
34+
["de", "de"],
35+
])("%s gets %s", (tag, expected) => {
36+
expect(matchBrowserLocale([tag], codes())).toBe(expected);
37+
});
38+
39+
it("takes the first language the browser lists that we have", () => {
40+
expect(matchBrowserLocale(["nl-NL", "zh-TW", "en-US"], codes())).toBe(
41+
"zh-Hant",
42+
);
43+
});
44+
45+
it("falls back to English when we have none of them", () => {
46+
expect(resolveLocale(codes(), ["nl-NL", "fi"], null)).toBe("en");
47+
});
48+
49+
it("skips a tag the browser mangled", () => {
50+
expect(matchBrowserLocale(["en_US!", "de-DE"], codes())).toBe("de");
51+
});
52+
});
53+
54+
describe("a visitor's own pick", () => {
55+
it("wins over the browser", () => {
56+
expect(resolveLocale(codes(), ["zh-TW"], "ja")).toBe("ja");
57+
});
58+
59+
it("is ignored when it names a language we no longer have", () => {
60+
expect(resolveLocale(codes(), ["zh-TW"], "lv")).toBe("zh-Hant");
61+
});
62+
});
63+
64+
describe("the old i18n_redirected cookie", () => {
65+
it("is not a pick when it holds the guess the old detection made", () => {
66+
setBrowserLanguages(["zh-TW"]);
67+
const oldGuess = i18n().getBrowserLocale() || "en";
68+
69+
expect(legacyPickedLocale("zh-Hans", oldGuess)).toBeNull();
70+
});
71+
72+
it("is a pick when it differs from that guess", () => {
73+
setBrowserLanguages(["en-US"]);
74+
const oldGuess = i18n().getBrowserLocale() || "en";
75+
76+
expect(legacyPickedLocale("de", oldGuess)).toBe("de");
77+
});
78+
79+
it("is not a pick when it holds the English fallback", () => {
80+
setBrowserLanguages(["nl-NL"]);
81+
const oldGuess = i18n().getBrowserLocale() || "en";
82+
83+
expect(legacyPickedLocale("en", oldGuess)).toBeNull();
84+
});
85+
});

‎utilities/locale.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const PICKED_LOCALE_KEY = "5stack:locale";
2+
3+
export const LEGACY_LOCALE_COOKIE = "i18n_redirected";
4+
5+
export function readPickedLocale(): string | null {
6+
try {
7+
return localStorage.getItem(PICKED_LOCALE_KEY);
8+
} catch {
9+
return null;
10+
}
11+
}
12+
13+
export function savePickedLocale(code: string) {
14+
try {
15+
localStorage.setItem(PICKED_LOCALE_KEY, code);
16+
} catch {}
17+
}
18+
19+
function findCode(codes: readonly string[], tag: string) {
20+
return codes.find((code) => code.toLowerCase() === tag.toLowerCase());
21+
}
22+
23+
// Browsers send a region (zh-TW, zh-HK), never the script our Chinese locales
24+
// are named by; maximize() supplies the script that region writes in.
25+
export function matchBrowserLocale(
26+
browserTags: readonly string[],
27+
codes: readonly string[],
28+
): string | undefined {
29+
for (const tag of browserTags) {
30+
let locale: Intl.Locale;
31+
try {
32+
locale = new Intl.Locale(tag).maximize();
33+
} catch {
34+
continue;
35+
}
36+
37+
const code =
38+
findCode(codes, tag) ??
39+
findCode(codes, `${locale.language}-${locale.script}`) ??
40+
findCode(codes, locale.language);
41+
if (code) {
42+
return code;
43+
}
44+
}
45+
}
46+
47+
export function resolveLocale(
48+
codes: readonly string[],
49+
browserTags: readonly string[],
50+
picked: string | null,
51+
): string {
52+
if (picked && codes.includes(picked)) {
53+
return picked;
54+
}
55+
return matchBrowserLocale(browserTags, codes) ?? "en";
56+
}
57+
58+
// The old cookie was written on every first visit, so it holds the browser
59+
// guess as often as a real pick. Only a value that guess wouldn't have made is
60+
// known to be the visitor's own choice.
61+
export function legacyPickedLocale(
62+
cookie: string | null | undefined,
63+
oldGuess: string,
64+
): string | null {
65+
return cookie && cookie !== oldGuess ? cookie : null;
66+
}

0 commit comments

Comments
 (0)