Merge pull request #932 from kchuang1015/feat/zh-tw-locale

feat(i18n): add Traditional Chinese (Taiwan) locale
This commit is contained in:
Linus Rath
2026-08-29 12:32:02 +02:00
committed by GitHub
11 changed files with 3700 additions and 27 deletions
+1
View File
@@ -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 {
+17
View File
@@ -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,
+1
View File
@@ -35,6 +35,7 @@ const languages = [
{ value: 'ja', label: '日本語' },
{ value: 'mn', label: 'Монгол' },
{ value: 'zh', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文(台灣)' },
];
function FlagIcon({ locale }: { locale: string }) {
+46
View File
@@ -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();
});
});
+4 -4
View File
@@ -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;
}
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
+30 -2
View File
@@ -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);
}