mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #932 from kchuang1015/feat/zh-tw-locale
feat(i18n): add Traditional Chinese (Taiwan) locale
This commit is contained in:
@@ -45,6 +45,7 @@ export const LOCALE_LOADERS: Record<string, () => Promise<Messages>> = {
|
||||
tr: () => import('@/locales/tr/common.json').then((m) => m.default),
|
||||
uk: () => import('@/locales/uk/common.json').then((m) => m.default),
|
||||
zh: () => import('@/locales/zh/common.json').then((m) => m.default),
|
||||
'zh-TW': () => import('@/locales/zh-TW/common.json').then((m) => m.default),
|
||||
};
|
||||
|
||||
interface IntlProviderProps {
|
||||
|
||||
@@ -214,6 +214,22 @@ export function FlagCN(props: FlagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Taiwan – Red field with blue canton and twelve-ray white sun */
|
||||
export function FlagTW(props: FlagProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" width={W} height={H} className={flagClass} {...props}>
|
||||
<rect width="48" height="32" fill="#FE0000" />
|
||||
<rect width="24" height="16" fill="#000095" />
|
||||
<polygon
|
||||
points="12.000,1.600 12.932,4.523 15.200,2.457 14.546,5.454 17.543,4.800 15.477,7.068 18.400,8.000 15.477,8.932 17.543,11.200 14.546,10.546 15.200,13.543 12.932,11.477 12.000,14.400 11.068,11.477 8.800,13.543 9.454,10.546 6.457,11.200 8.523,8.932 5.600,8.000 8.523,7.068 6.457,4.800 9.454,5.454 8.800,2.457 11.068,4.523"
|
||||
fill="#fff"
|
||||
/>
|
||||
<circle cx="12" cy="8" r="3.2" fill="#000095" />
|
||||
<circle cx="12" cy="8" r="2.45" fill="#fff" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Czech Republic - White and red horizontal bands with a blue triangle */
|
||||
export function FlagCS(props: FlagProps) {
|
||||
return (
|
||||
@@ -374,6 +390,7 @@ export const flagComponents: Record<string, (props: FlagProps) => ReactElement>
|
||||
tr: FlagTR,
|
||||
uk: FlagUA,
|
||||
zh: FlagCN,
|
||||
'zh-TW': FlagTW,
|
||||
fa: FlagIR,
|
||||
he: FlagIL,
|
||||
ar: FlagAE,
|
||||
|
||||
@@ -35,6 +35,7 @@ const languages = [
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'mn', label: 'Монгол' },
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁體中文(台灣)' },
|
||||
];
|
||||
|
||||
function FlagIcon({ locale }: { locale: string }) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { localeFromAcceptLanguage, matchSupportedLocale } from '../locale-matcher';
|
||||
|
||||
const supported = ['en', 'fr', 'zh', 'zh-TW'] as const;
|
||||
|
||||
describe('matchSupportedLocale', () => {
|
||||
it.each([
|
||||
['zh-TW', 'zh-TW'],
|
||||
['zh_Hant', 'zh-TW'],
|
||||
['zh-Hant-HK', 'zh-TW'],
|
||||
['zh-HK', 'zh-TW'],
|
||||
['zh-MO', 'zh-TW'],
|
||||
['zh-Hans-TW', 'zh'],
|
||||
['zh-Hans', 'zh'],
|
||||
['zh-CN', 'zh'],
|
||||
['zh-SG', 'zh'],
|
||||
['zh', 'zh'],
|
||||
['fr-CA', 'fr'],
|
||||
])('maps %s to %s', (tag, expected) => {
|
||||
expect(matchSupportedLocale(tag, supported)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns null for unsupported and wildcard tags', () => {
|
||||
expect(matchSupportedLocale('eo', supported)).toBeNull();
|
||||
expect(matchSupportedLocale('*', supported)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localeFromAcceptLanguage', () => {
|
||||
it('prefers the highest-quality supported locale', () => {
|
||||
expect(localeFromAcceptLanguage('zh-CN;q=0.7, zh-TW;q=0.9, fr;q=0.8', supported)).toBe('zh-TW');
|
||||
});
|
||||
|
||||
it('uses original order to break equal q-values', () => {
|
||||
expect(localeFromAcceptLanguage('fr-CA, zh-Hant', supported)).toBe('fr');
|
||||
});
|
||||
|
||||
it('skips disabled, malformed, wildcard, and unsupported entries', () => {
|
||||
expect(localeFromAcceptLanguage('zh-TW;q=0, *;q=1, eo;q=0.9, zh-CN;q=oops, en;q=0.5', supported)).toBe('en');
|
||||
});
|
||||
|
||||
it('returns null when no supported preference remains', () => {
|
||||
expect(localeFromAcceptLanguage('eo, *;q=0', supported)).toBeNull();
|
||||
expect(localeFromAcceptLanguage(null, supported)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { routing } from './routing';
|
||||
import { matchSupportedLocale } from './locale-matcher';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
|
||||
/**
|
||||
@@ -11,13 +12,12 @@ import { useLocaleStore } from '@/stores/locale-store';
|
||||
*/
|
||||
export function detectBrowserLocale(fallback: string): string {
|
||||
if (typeof navigator === 'undefined') return fallback;
|
||||
const supported = new Set<string>(routing.locales as readonly string[]);
|
||||
const prefs = navigator.languages?.length ? navigator.languages : [navigator.language];
|
||||
for (const tag of prefs) {
|
||||
if (!tag) continue;
|
||||
const base = tag.toLowerCase().split('-')[0];
|
||||
if (base === 'en') return fallback; // English is the top preference -> keep default
|
||||
if (supported.has(base)) return base; // first supported non-English preference wins
|
||||
const locale = matchSupportedLocale(tag, routing.locales);
|
||||
if (locale === 'en') return fallback; // English is the top preference -> keep default
|
||||
if (locale) return locale; // first supported non-English preference wins
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
type SupportedLocales = readonly string[];
|
||||
|
||||
const TRADITIONAL_CHINESE_REGIONS = new Set(['tw', 'hk', 'mo']);
|
||||
|
||||
/**
|
||||
* Match a BCP-47 language tag to one of the locales shipped by Bulwark.
|
||||
* Chinese needs script/region-aware matching so Traditional Chinese does not
|
||||
* silently fall back to the Simplified Chinese catalog.
|
||||
*/
|
||||
export function matchSupportedLocale(tag: string, supportedLocales: SupportedLocales): string | null {
|
||||
const normalized = tag.trim().replace(/_/g, '-').toLowerCase();
|
||||
if (!normalized || normalized === '*') return null;
|
||||
|
||||
const supported = new Map(supportedLocales.map((locale) => [locale.toLowerCase(), locale]));
|
||||
const exact = supported.get(normalized);
|
||||
if (exact) return exact;
|
||||
|
||||
const parts = normalized.split('-');
|
||||
const base = parts[0];
|
||||
if (base !== 'zh') return supported.get(base) ?? null;
|
||||
|
||||
const script = parts.find((part) => part === 'hans' || part === 'hant');
|
||||
const region = parts.find((part) => TRADITIONAL_CHINESE_REGIONS.has(part));
|
||||
const traditional = script === 'hant' || (!script && Boolean(region));
|
||||
|
||||
if (traditional) return supported.get('zh-tw') ?? supported.get('zh') ?? null;
|
||||
return supported.get('zh') ?? supported.get('zh-tw') ?? null;
|
||||
}
|
||||
|
||||
/** Resolve an Accept-Language header by q-value against shipped locales. */
|
||||
export function localeFromAcceptLanguage(
|
||||
header: string | null,
|
||||
supportedLocales: SupportedLocales
|
||||
): string | null {
|
||||
if (!header) return null;
|
||||
|
||||
const ranked = header
|
||||
.split(',')
|
||||
.map((part, index) => {
|
||||
const [rawTag, ...parameters] = part.trim().split(';');
|
||||
const qParameter = parameters.find((parameter) => parameter.trim().toLowerCase().startsWith('q='));
|
||||
const parsedQ = qParameter ? Number.parseFloat(qParameter.split('=')[1]) : 1;
|
||||
const q = Number.isFinite(parsedQ) && parsedQ >= 0 && parsedQ <= 1 ? parsedQ : 0;
|
||||
return { tag: rawTag, q, index };
|
||||
})
|
||||
.filter(({ tag, q }) => Boolean(tag) && q > 0)
|
||||
.sort((a, b) => b.q - a.q || a.index - b.index);
|
||||
|
||||
for (const { tag } of ranked) {
|
||||
const locale = matchSupportedLocale(tag, supportedLocales);
|
||||
if (locale) return locale;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+5
-20
@@ -1,33 +1,15 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { mergeMessages } from './merge-messages';
|
||||
import { localeFromAcceptLanguage } from './locale-matcher';
|
||||
import { routing, type Locale } from './routing';
|
||||
|
||||
// Default a first-time visitor (no explicit stored choice) to the best supported
|
||||
// language from their browser's Accept-Language header, so "Auto" is the real
|
||||
// default and the first server render already matches the browser language.
|
||||
function localeFromAcceptLanguage(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const supported = new Set<string>(routing.locales as readonly string[]);
|
||||
const ranked = header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag, q] = part.trim().split(';q=');
|
||||
return { base: tag.toLowerCase().split('-')[0], q: q ? parseFloat(q) : 1 };
|
||||
})
|
||||
.sort((a, b) => b.q - a.q);
|
||||
for (const { base } of ranked) {
|
||||
if (supported.has(base)) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
let locale = await requestLocale;
|
||||
|
||||
if (!locale || !routing.locales.includes(locale as Locale)) {
|
||||
const accept = (await headers()).get('accept-language');
|
||||
locale = localeFromAcceptLanguage(accept) ?? routing.defaultLocale;
|
||||
locale = localeFromAcceptLanguage(accept, routing.locales) ?? routing.defaultLocale;
|
||||
}
|
||||
|
||||
// Use static imports for better compatibility
|
||||
@@ -108,6 +90,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
||||
case 'zh':
|
||||
messages = (await import('../locales/zh/common.json')).default;
|
||||
break;
|
||||
case 'zh-TW':
|
||||
messages = (await import('../locales/zh-TW/common.json')).default;
|
||||
break;
|
||||
default:
|
||||
messages = (await import('../locales/en/common.json')).default;
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['ar', 'ca', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'mn', 'nb', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh'] as const;
|
||||
const SUPPORTED_LOCALES = ['ar', 'ca', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'lv', 'mn', 'nb', 'nl', 'pl', 'pt', 'ro', 'ru', 'sk', 'tr', 'uk', 'zh', 'zh-TW'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
|
||||
@@ -28,10 +28,12 @@ vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins:
|
||||
// forwards the FULL request header set, tagged with the resolved locale.
|
||||
// With localePrefix "always" it redirects to the prefixed path instead.
|
||||
let intlMode: 'rewrite' | 'redirect' = 'rewrite';
|
||||
let receivedAcceptLanguage: string | null = null;
|
||||
vi.mock('next-intl/middleware', async () => {
|
||||
const { NextResponse } = await import('next/server');
|
||||
return {
|
||||
default: () => (request: Request) => {
|
||||
receivedAcceptLanguage = request.headers.get('accept-language');
|
||||
const url = new URL(request.url);
|
||||
url.pathname = `/en${url.pathname}`;
|
||||
if (intlMode === 'redirect') return NextResponse.redirect(url);
|
||||
@@ -69,6 +71,7 @@ function nonceFromCsp(response: Response): string | undefined {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
intlMode = 'rewrite';
|
||||
receivedAcceptLanguage = null;
|
||||
});
|
||||
|
||||
describe('proxy forwards the request headers it does not own (#919)', () => {
|
||||
@@ -123,3 +126,46 @@ describe('proxy forwards the request headers it does not own (#919)', () => {
|
||||
expect(response.headers.get('x-middleware-request-x-nonce')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy normalizes Chinese Accept-Language before next-intl matching', () => {
|
||||
it.each([
|
||||
['zh-HK,zh;q=0.9', 'zh-TW'],
|
||||
['zh-MO,zh;q=0.9', 'zh-TW'],
|
||||
['zh-Hant-HK,zh-Hant;q=0.9', 'zh-TW'],
|
||||
['zh-Hans-TW,zh-Hans;q=0.9', 'zh'],
|
||||
['zh-CN,zh;q=0.9', 'zh'],
|
||||
['fr-CA,zh-HK;q=0.9', 'fr-CA,zh-HK;q=0.9'],
|
||||
])('passes %s to next-intl as %s', async (acceptLanguage, expected) => {
|
||||
await proxy(
|
||||
new NextRequest('http://localhost:3000/', {
|
||||
headers: { 'accept-language': acceptLanguage, cookie: 'jmap_session_0=abc' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(receivedAcceptLanguage).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['en', 'zh', 'zh-TW'])(
|
||||
'leaves Accept-Language untouched when a valid NEXT_LOCALE=%s cookie is present',
|
||||
async (cookieLocale) => {
|
||||
const acceptLanguage = 'zh-HK,zh;q=0.9,en;q=0.8';
|
||||
await proxy(
|
||||
new NextRequest('http://localhost:3000/', {
|
||||
headers: { 'accept-language': acceptLanguage, cookie: `NEXT_LOCALE=${cookieLocale}` },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(receivedAcceptLanguage).toBe(acceptLanguage);
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores an invalid locale cookie when normalizing Accept-Language', async () => {
|
||||
await proxy(
|
||||
new NextRequest('http://localhost:3000/', {
|
||||
headers: { 'accept-language': 'zh-HK,zh;q=0.9', cookie: 'NEXT_LOCALE=unsupported' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(receivedAcceptLanguage).toBe('zh-TW');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import createIntlMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./i18n/routing";
|
||||
import { localeFromAcceptLanguage } from "./i18n/locale-matcher";
|
||||
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
||||
import {
|
||||
APP_FRAME_ORIGINS_COOKIE,
|
||||
@@ -12,6 +13,33 @@ import { detectSetupState } from "./lib/setup/state";
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
/**
|
||||
* next-intl uses best-fit locale matching, which can rank `zh` ahead of
|
||||
* `zh-TW` for regional Traditional Chinese tags such as `zh-HK`. Collapse a
|
||||
* Chinese Accept-Language list to the exact locale selected by Bulwark's
|
||||
* script/region-aware matcher before handing the request to next-intl. URL
|
||||
* prefixes and the NEXT_LOCALE cookie still keep their higher precedence.
|
||||
*/
|
||||
function withMatchedChineseAcceptLanguage(request: NextRequest): NextRequest {
|
||||
const localeCookie = routing.localeCookie;
|
||||
const localeCookieName =
|
||||
localeCookie === false
|
||||
? null
|
||||
: typeof localeCookie === "object" && localeCookie.name
|
||||
? localeCookie.name
|
||||
: "NEXT_LOCALE";
|
||||
const cookieLocale = localeCookieName ? request.cookies.get(localeCookieName)?.value : undefined;
|
||||
if (cookieLocale && (routing.locales as readonly string[]).includes(cookieLocale)) return request;
|
||||
|
||||
const acceptLanguage = request.headers.get("accept-language");
|
||||
const locale = localeFromAcceptLanguage(acceptLanguage, routing.locales);
|
||||
if (locale !== "zh" && locale !== "zh-TW") return request;
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("accept-language", locale);
|
||||
return new NextRequest(request, { headers });
|
||||
}
|
||||
|
||||
// Next 16's Proxy always runs on Node.js runtime and route-segment config
|
||||
// (e.g. `export const config = { matcher }`) is no longer allowed in the
|
||||
// proxy file. We replicate the previous matcher inline by short-circuiting
|
||||
@@ -221,7 +249,7 @@ export async function proxy(request: NextRequest) {
|
||||
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
|
||||
if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) {
|
||||
try {
|
||||
intlResponse = intlMiddleware(request);
|
||||
intlResponse = intlMiddleware(withMatchedChineseAcceptLanguage(request));
|
||||
} catch (error) {
|
||||
console.error('Locale middleware error:', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user