From c2710d6f1c292115400056048a8c131ce1429f32 Mon Sep 17 00:00:00 2001 From: Mohit Sapat Date: Sun, 1 Nov 2026 21:35:55 +0530 Subject: [PATCH] feat: language dropdown on the landing page and docs Visitors could only change language by editing the URL. The landing navbar (desktop and mobile menu) and the docs sidebar now have a language dropdown built on our own LanguageSwitcher. Picking a language saves it in NEXT_LOCALE and reloads the same page under that language's prefix (/docs/x -> /ja/docs/x; English has none). It's a full page load, so the root layout's lang/dir and messages switch with it. --- public/locales/ar.json | 4 ++ public/locales/en.json | 4 ++ public/locales/es.json | 4 ++ public/locales/ja.json | 4 ++ src/app/docs/layout.tsx | 43 ++++++++++--------- src/components/landing/navbar.tsx | 8 ++++ src/components/site-language-switcher.tsx | 50 +++++++++++++++++++++++ test/site-language-switcher.test.ts | 21 ++++++++++ 8 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/components/site-language-switcher.tsx create mode 100644 test/site-language-switcher.test.ts diff --git a/public/locales/ar.json b/public/locales/ar.json index 57a91db..2fdd339 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -152,5 +152,9 @@ "gettingStarted": "البدء في العمل", "home": "الرئيسية", "toggleMenu": "تغيير القائمة" + }, + "languageSwitcher": { + "listbox": "اختيار اللغة", + "current": "اللغة الحالية: {name}" } } diff --git a/public/locales/en.json b/public/locales/en.json index 3f0deda..dc0c6b2 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -161,5 +161,9 @@ "gettingStarted": "Getting Started", "home": "Home", "toggleMenu": "Toggle menu" + }, + "languageSwitcher": { + "listbox": "Select language", + "current": "Current language: {name}" } } diff --git a/public/locales/es.json b/public/locales/es.json index 88d9132..f2b18a3 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -157,5 +157,9 @@ "gettingStarted": "Comenzando", "home": "Inicio", "toggleMenu": "Cambiar el menú" + }, + "languageSwitcher": { + "listbox": "Seleccionar idioma", + "current": "Idioma actual: {name}" } } diff --git a/public/locales/ja.json b/public/locales/ja.json index 1ce9f1f..cc89448 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -145,5 +145,9 @@ "gettingStarted": "始めること", "home": "ホーム", "toggleMenu": "トーグルメニュー" + }, + "languageSwitcher": { + "listbox": "言語を選択", + "current": "現在の言語: {name}" } } diff --git a/src/app/docs/layout.tsx b/src/app/docs/layout.tsx index f5f9e9d..68c9620 100644 --- a/src/app/docs/layout.tsx +++ b/src/app/docs/layout.tsx @@ -3,6 +3,7 @@ import { DocsLayout } from "fumadocs-ui/layouts/docs"; import { getLocale } from "next-intl/server"; import { source } from "@/lib/source"; import { localizeTree } from "@/lib/localized-href"; +import { SiteLanguageSwitcher } from "@/components/site-language-switcher"; import { baseOptions } from "../layout.config"; import { version as localVersion } from "../../../package.json"; @@ -52,25 +53,29 @@ export default async function Layout({ children }: { children: ReactNode }) { {...baseOptions} sidebar={{ footer: ( -
- - - - llms-full.txt - - - v{version} - +
+ +
+ + + + llms-full.txt + + + v{version} + +
), }} diff --git a/src/components/landing/navbar.tsx b/src/components/landing/navbar.tsx index 55ed5c8..5ff256a 100644 --- a/src/components/landing/navbar.tsx +++ b/src/components/landing/navbar.tsx @@ -6,6 +6,7 @@ import Image from "next/image"; import { useState } from "react"; import { Menu, X, CornerDownLeft } from "lucide-react"; import { GitHubCounter, RotatingGitHubCounter } from "./github-counter"; +import { SiteLanguageSwitcher } from "../site-language-switcher"; import type { GitHubStats } from "@/lib/github"; const navLinks = [ @@ -50,6 +51,10 @@ export function Navbar({ stats, starsCount }: NavbarProps = {}) { {/* Right side actions */}
+
+ +
+ {/* GitHub counter - desktop (cycles stars → forks → PRs) */}
@@ -97,6 +102,9 @@ export function Navbar({ stats, starsCount }: NavbarProps = {}) { {t(link.label)} ))} +
+ +
({ code })); + +/** `pathname` in `locale`: `/docs/x` → `/ja/docs/x`; English has no prefix. */ +export function pathInLocale(pathname: string, locale: string): string { + const [, first = "", ...rest] = pathname.split("/"); + const bare = locales.includes(first) && first !== sourceLocale ? `/${rest.join("/")}` : pathname; + if (locale === sourceLocale) return bare; + return bare === "/" ? `/${locale}` : `/${locale}${bare}`; +} + +/** + * The site's language dropdown, built on our own LanguageSwitcher. Remembers + * the choice in NEXT_LOCALE and reloads the same page in that language. + */ +export function SiteLanguageSwitcher({ className }: { className?: string }) { + const locale = useLocale(); + const t = useTranslations("languageSwitcher"); + + const change = (code: string) => { + if (code === locale) return; + document.cookie = `NEXT_LOCALE=${code}; path=/; max-age=${ONE_YEAR}; samesite=lax`; + const { pathname, search, hash } = window.location; + // A full load, not router.push: the root layout (, messages) + // only re-renders on a document request. + // eslint-disable-next-line @next/next/no-location-assign-relative-destination + window.location.assign(`${pathInLocale(pathname, code)}${search}${hash}`); + }; + + return ( + t("current", { name }) }} + className={className} + /> + ); +} diff --git a/test/site-language-switcher.test.ts b/test/site-language-switcher.test.ts new file mode 100644 index 0000000..40fc37c --- /dev/null +++ b/test/site-language-switcher.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { pathInLocale } from "@/components/site-language-switcher"; + +describe("pathInLocale", () => { + it("adds the language prefix, keeping the page", () => { + expect(pathInLocale("/", "ja")).toBe("/ja"); + expect(pathInLocale("/docs/getting-started/install", "ar")).toBe( + "/ar/docs/getting-started/install", + ); + }); + + it("swaps one language prefix for another, and drops it for English", () => { + expect(pathInLocale("/es/docs/components", "ja")).toBe("/ja/docs/components"); + expect(pathInLocale("/ja", "en")).toBe("/"); + expect(pathInLocale("/ar/docs", "en")).toBe("/docs"); + }); + + it("doesn't mistake a path segment for a language", () => { + expect(pathInLocale("/docs/es-module", "ja")).toBe("/ja/docs/es-module"); + }); +});