From bf92bdd41e0c075216b73dd90968f6a562a987e3 Mon Sep 17 00:00:00 2001 From: uswebk Date: Mon, 13 Jul 2026 23:04:15 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=96=E3=83=A9=E3=82=A6=E3=82=B6=E3=81=AE?= =?UTF-8?q?=E8=A8=80=E8=AA=9E=E8=A8=AD=E5=AE=9A=E3=81=8B=E3=82=89=E3=83=AD?= =?UTF-8?q?=E3=82=B1=E3=83=BC=E3=83=AB=E3=82=92=E8=87=AA=E5=8B=95=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ログイン前の画面は VITE_DEFAULT_LOCALE(既定 en)で固定表示され、 言語の切替UIはサイドバーにしかないため、日本語環境のユーザーでも ログインするまで英語のままだった。 ロケールの解決順を Cookie → Accept-Language → 既定値 に変更する。 判定はSSRの detectLocale で行い、結果は として出力される。 クライアントの検出順は cookie → htmlTag のままなので、サーバーと クライアントの判断が一致し hydration mismatch は起きない。 Co-Authored-By: Claude Opus 4.8 --- README.ja.md | 2 +- README.md | 2 +- apps/web/src/i18n/accept-language.test.ts | 37 +++++++++++++++++++++++ apps/web/src/i18n/accept-language.ts | 34 +++++++++++++++++++++ apps/web/src/i18n/config.ts | 8 +++-- apps/web/src/i18n/server.ts | 17 +++++++---- 6 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/i18n/accept-language.test.ts create mode 100644 apps/web/src/i18n/accept-language.ts diff --git a/README.ja.md b/README.ja.md index ad588ba..815b20b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -76,7 +76,7 @@ http://localhost:3000 を開き、サインアップから会社(組織)を | 機能 | 環境変数 | 既定 | 補足 | |---|---|---|---| -| 言語 | `VITE_DEFAULT_LOCALE` | `en` | `en` または `ja`。デプロイ全体の既定言語。ユーザーは画面の切替UI(サイドバーのユーザーメニュー/アカウント設定)で切り替えでき、その選択は Cookie に保存されこの既定を上書きします。 | +| 言語 | `VITE_DEFAULT_LOCALE` | `en` | `en` または `ja`。言語は Cookie(画面の切替UI=サイドバーのユーザーメニュー/アカウント設定での選択)→ ブラウザの `Accept-Language` → この既定値、の順で決定されます。したがってこの値が使われるのは、ブラウザが対応言語を要求してこなかった場合のみです。 | | タイムゾーン | `VITE_DEFAULT_TIMEZONE` | *(ランタイムのTZ)* | `Asia/Tokyo` のような IANA 名。「今日」やシフト時刻の判定に使います。未設定ならランタイムのTZ(SSRはサーバーの `TZ`、クライアントはブラウザのTZ)にフォールバックします。 | 設定は `apps/web/.env`(`apps/web/.env.example` をコピー)で行います。ユーザー単位のタイムゾーン設定はなく、1デプロイ=単一拠点/単一TZ を前提としています。複数タイムゾーンにまたがる運用には拠点(site)単位のTZ設定が必要です(未実装)。 diff --git a/README.md b/README.md index 9e8140c..53eb548 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The web app (`apps/web`) reads two deploy-time defaults from its environment (Vi | Concern | Env var | Default | Notes | |---|---|---|---| -| Language | `VITE_DEFAULT_LOCALE` | `en` | `en` or `ja`. The deploy-wide default. Users can switch language from the in-app switcher (sidebar user menu / account settings); their choice is remembered in a cookie and overrides this default. | +| Language | `VITE_DEFAULT_LOCALE` | `en` | `en` or `ja`. The locale is resolved in this order: the cookie set by the in-app switcher (sidebar user menu / account settings) → the browser's `Accept-Language` → this default. So this value only applies when the browser asks for no supported language. | | Timezone | `VITE_DEFAULT_TIMEZONE` | *(runtime TZ)* | An IANA name such as `Asia/Tokyo`. Used to decide "today" and shift times. If unset, it falls back to the runtime timezone (the server's `TZ` on SSR, the browser's timezone on the client). | Set them in `apps/web/.env` (copied from `apps/web/.env.example`). There is no per-user timezone setting: the app assumes a single site/timezone per deployment. Running one instance across multiple timezones would need a site-level timezone setting (not implemented). diff --git a/apps/web/src/i18n/accept-language.test.ts b/apps/web/src/i18n/accept-language.test.ts new file mode 100644 index 0000000..47a82e1 --- /dev/null +++ b/apps/web/src/i18n/accept-language.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { pickLocaleFromAcceptLanguage } from "./accept-language"; + +describe("pickLocaleFromAcceptLanguage", () => { + it("picks a plain supported tag", () => { + expect(pickLocaleFromAcceptLanguage("ja")).toBe("ja"); + }); + + it("normalizes a region subtag", () => { + expect(pickLocaleFromAcceptLanguage("ja-JP,ja;q=0.9,en;q=0.8")).toBe("ja"); + expect(pickLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en"); + }); + + it("prefers the highest q value over header order", () => { + expect(pickLocaleFromAcceptLanguage("ja;q=0.7,en;q=0.9")).toBe("en"); + }); + + it("falls back to header order when q values tie", () => { + expect(pickLocaleFromAcceptLanguage("en,ja")).toBe("en"); + expect(pickLocaleFromAcceptLanguage("ja,en")).toBe("ja"); + }); + + it("skips unsupported languages", () => { + expect(pickLocaleFromAcceptLanguage("fr-FR,fr;q=0.9,ja;q=0.5")).toBe("ja"); + }); + + it("ignores languages explicitly refused with q=0", () => { + expect(pickLocaleFromAcceptLanguage("ja;q=0,en;q=0.5")).toBe("en"); + }); + + it("returns undefined when no supported language is requested", () => { + expect(pickLocaleFromAcceptLanguage("fr,de;q=0.9")).toBeUndefined(); + expect(pickLocaleFromAcceptLanguage("*")).toBeUndefined(); + expect(pickLocaleFromAcceptLanguage("")).toBeUndefined(); + expect(pickLocaleFromAcceptLanguage(null)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/i18n/accept-language.ts b/apps/web/src/i18n/accept-language.ts new file mode 100644 index 0000000..92319ee --- /dev/null +++ b/apps/web/src/i18n/accept-language.ts @@ -0,0 +1,34 @@ +import { type Locale, SUPPORTED_LOCALES } from "./config"; + +// Pick the most preferred supported locale from an Accept-Language header +// ("ja,en-US;q=0.9,en;q=0.8"). Returns undefined when the header requests no supported language, +// so the caller can fall back to the deploy default. +export function pickLocaleFromAcceptLanguage( + header: string | null | undefined, +): Locale | undefined { + if (!header) return undefined; + + const ranked = header + .split(",") + .map((part, index) => { + const [tag, ...params] = part.trim().split(";"); + const q = params + .map((p) => p.trim()) + .find((p) => p.toLowerCase().startsWith("q=")) + ?.slice(2); + const quality = q === undefined ? 1 : Number.parseFloat(q); + return { + base: tag.trim().toLowerCase().split("-")[0], + quality: Number.isNaN(quality) ? 0 : quality, + // Keeps the sort stable for equal q values, where header order is the preference. + index, + }; + }) + .filter((entry) => entry.quality > 0) + .sort((a, b) => b.quality - a.quality || a.index - b.index); + + return ranked.find( + (entry): entry is (typeof ranked)[number] & { base: Locale } => + (SUPPORTED_LOCALES as readonly string[]).includes(entry.base), + )?.base; +} diff --git a/apps/web/src/i18n/config.ts b/apps/web/src/i18n/config.ts index ecb00d4..ff1abc3 100644 --- a/apps/web/src/i18n/config.ts +++ b/apps/web/src/i18n/config.ts @@ -9,7 +9,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number]; export const LOCALE_COOKIE = "i18next"; // The deploy default language is set via the VITE_DEFAULT_LOCALE env var (unset = "en"). -// Users can override it with the language switcher, and their choice is saved to a cookie. +// It only applies when the browser asks for no supported language; a user's own choice from the +// language switcher is saved to a cookie and wins over both. export const DEFAULT_LOCALE: Locale = toLocale( import.meta.env.VITE_DEFAULT_LOCALE, "en", @@ -43,8 +44,9 @@ if (!i18n.isInitialized) { supportedLngs: SUPPORTED_LOCALES as unknown as string[], defaultNS: "common", interpolation: { escapeValue: false }, - // The default is the env var (fallbackLng). Once the user switches, the cookie takes priority. - // No browser-language auto-detection; the default is made deterministic via the env var. + // The locale is decided on the server (see i18n/server.ts: cookie -> Accept-Language -> env + // default) and rendered as , so htmlTag is what the client reads when there's no + // cookie yet. Don't add "navigator" here: it would let the client disagree with the server. detection: { order: ["cookie", "htmlTag"], caches: ["cookie"], diff --git a/apps/web/src/i18n/server.ts b/apps/web/src/i18n/server.ts index b1250ac..052a816 100644 --- a/apps/web/src/i18n/server.ts +++ b/apps/web/src/i18n/server.ts @@ -1,5 +1,6 @@ import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; +import { pickLocaleFromAcceptLanguage } from "./accept-language"; import { DEFAULT_LOCALE, LOCALE_COOKIE, @@ -15,14 +16,18 @@ function readCookie(header: string, name: string): string | undefined { return undefined; } -// Resolve the initial SSR locale in order: cookie (user's switch) -> the env-var default. -// This matches the client's LanguageDetector result and prevents hydration mismatches. +// Resolve the initial SSR locale in order: cookie (the user's switch) -> Accept-Language +// (the browser's language) -> the env-var default. +// The result is rendered as in __root, and the client's LanguageDetector reads it back +// via htmlTag, so both sides agree and hydration doesn't mismatch. export const detectLocale = createServerFn({ method: "GET" }).handler( async (): Promise => { - const cookie = readCookie( - getRequest().headers.get("cookie") ?? "", - LOCALE_COOKIE, + const headers = getRequest().headers; + const cookie = readCookie(headers.get("cookie") ?? "", LOCALE_COOKIE); + if (cookie) return normalizeLocale(cookie); + return ( + pickLocaleFromAcceptLanguage(headers.get("accept-language")) ?? + DEFAULT_LOCALE ); - return cookie ? normalizeLocale(cookie) : DEFAULT_LOCALE; }, );