diff --git a/src/App.tsx b/src/App.tsx
index 076f0ca..2dd6d7a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,7 +3,7 @@ import { I18nProvider } from "./i18n";
import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast";
import { PublicLayout } from "./layouts/PublicLayout";
-import { Home } from "./pages/Home";
+import { LocalizedHomePage } from "./pages/LocalizedHome";
import { Browse } from "./pages/Browse";
import { CategoriesPage } from "./pages/Categories";
import { CategoryPage } from "./pages/Category";
@@ -18,6 +18,7 @@ import { AdminRouteTree } from "./adminRouteTree";
import { AdminRouterModeProvider } from "./adminRouterMode";
import { ImageLightboxProvider } from "./components/messageStream/overlays/ImageLightbox";
import { VideoPlayerProvider } from "./components/messageStream/overlays/VideoPlayer";
+import { localizedHomeRoutes } from "./languageRoutes";
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
@@ -34,7 +35,19 @@ export default function App() {
}>
- } />
+ }
+ />
+ {localizedHomeRoutes.map((route) => (
+
+ }
+ />
+ ))}
} />
{
const brand = t("brand");
- const title = pathname === "/" ? meta.title : `${meta.title} | ${brand}`;
+ const title = isHomePathname(pathname)
+ ? meta.title
+ : `${meta.title} | ${brand}`;
const canonical = `${window.location.origin}${pathname}${search}`;
document.documentElement.lang = lang;
diff --git a/src/i18n.tsx b/src/i18n.tsx
index 9f5994b..3c5952a 100644
--- a/src/i18n.tsx
+++ b/src/i18n.tsx
@@ -5,6 +5,7 @@ import React, {
useMemo,
useState,
} from "react";
+import { languageForHomePathname } from "./languageRoutes";
export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
@@ -372,6 +373,10 @@ const LANG_KEY = "ark_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState(() => {
+ const routeLang = languageForHomePathname(window.location.pathname);
+ if (routeLang) return routeLang;
+ if (window.location.pathname === "/") return "en";
+
const s = localStorage.getItem(LANG_KEY);
if (s === "zh" || s === "zh-TW") return "zh-CN";
if (
@@ -386,10 +391,10 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
return s;
return "en";
});
- const setLang = (l: Lang) => {
+ const setLang = useCallback((l: Lang) => {
localStorage.setItem(LANG_KEY, l);
setLangState(l);
- };
+ }, []);
const t = useCallback(
(k: string) => dict[lang][k] || dict.en[k] || k,
[lang],
diff --git a/src/languageRoutes.test.ts b/src/languageRoutes.test.ts
new file mode 100644
index 0000000..36bb616
--- /dev/null
+++ b/src/languageRoutes.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import {
+ homePathForLang,
+ isHomePathname,
+ languageForHomePathname,
+ localizedHomeRoutes,
+} from "./languageRoutes";
+
+const expectedRoutes = [
+ ["/chinese", "zh-CN"],
+ ["/japanese", "ja"],
+ ["/korean", "ko"],
+ ["/vietnamese", "vi"],
+ ["/indonesian", "id"],
+ ["/malay", "ms"],
+] as const;
+
+describe("language home routes", () => {
+ it("uses CTO-style full language path names except default English root", () => {
+ expect(localizedHomeRoutes.map(({ path, lang }) => [path, lang])).toEqual(
+ expectedRoutes,
+ );
+ });
+
+ it("maps localized home paths to language codes", () => {
+ for (const [path, lang] of expectedRoutes) {
+ expect(languageForHomePathname(path)).toBe(lang);
+ expect(languageForHomePathname(`${path}/`)).toBe(lang);
+ }
+ expect(languageForHomePathname("/english")).toBeNull();
+ expect(languageForHomePathname("/unknown")).toBeNull();
+ });
+
+ it("treats root and localized language paths as home pages", () => {
+ expect(isHomePathname("/")).toBe(true);
+ expect(isHomePathname("/chinese")).toBe(true);
+ expect(isHomePathname("/browse")).toBe(false);
+ });
+
+ it("links each active language back to its localized home", () => {
+ expect(homePathForLang("en")).toBe("/");
+ expect(homePathForLang("zh-CN")).toBe("/chinese");
+ expect(homePathForLang("ja")).toBe("/japanese");
+ expect(homePathForLang("ko")).toBe("/korean");
+ expect(homePathForLang("vi")).toBe("/vietnamese");
+ expect(homePathForLang("id")).toBe("/indonesian");
+ expect(homePathForLang("ms")).toBe("/malay");
+ });
+});
diff --git a/src/languageRoutes.ts b/src/languageRoutes.ts
new file mode 100644
index 0000000..b990a7b
--- /dev/null
+++ b/src/languageRoutes.ts
@@ -0,0 +1,35 @@
+import type { Lang } from "./i18n";
+
+export const localizedHomeRoutes: ReadonlyArray<{ lang: Lang; path: string }> =
+ [
+ { lang: "zh-CN", path: "/chinese" },
+ { lang: "ja", path: "/japanese" },
+ { lang: "ko", path: "/korean" },
+ { lang: "vi", path: "/vietnamese" },
+ { lang: "id", path: "/indonesian" },
+ { lang: "ms", path: "/malay" },
+ ];
+
+function normalizePathname(pathname: string): string {
+ const normalized = pathname.replace(/\/+$/, "");
+ return normalized || "/";
+}
+
+export function languageForHomePathname(pathname: string): Lang | null {
+ const normalized = normalizePathname(pathname);
+ return (
+ localizedHomeRoutes.find((route) => route.path === normalized)?.lang ?? null
+ );
+}
+
+export function isHomePathname(pathname: string): boolean {
+ return (
+ normalizePathname(pathname) === "/" ||
+ languageForHomePathname(pathname) !== null
+ );
+}
+
+export function homePathForLang(lang: Lang): string {
+ if (lang === "en") return "/";
+ return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/";
+}
diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx
index 68916df..a609d44 100644
--- a/src/layouts/PublicLayout.tsx
+++ b/src/layouts/PublicLayout.tsx
@@ -11,6 +11,7 @@ import { DocumentMeta } from "../components/DocumentMeta";
import { SearchPanel } from "../components/SearchPanel";
import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages";
+import { homePathForLang, isHomePathname } from "../languageRoutes";
type PublicNavWhich =
| "home"
@@ -30,13 +31,13 @@ function navIsActive(
const sp = new URLSearchParams(search);
switch (which) {
case "home":
- return pathname === "/";
+ return isHomePathname(pathname);
case "browseAll":
return pathname === "/browse" && !sp.has("sort");
case "categories":
return (
pathname === "/categories" ||
- (pathname === "/" && hash === "#categories")
+ (isHomePathname(pathname) && hash === "#categories")
);
case "browseLatest":
return pathname === "/browse" && sp.get("sort") === "latest";
@@ -46,7 +47,8 @@ function navIsActive(
return pathname === "/browse" && sp.get("sort") === "popular";
case "favorites":
return (
- pathname === "/favorites" || (pathname === "/" && hash === "#favorites")
+ pathname === "/favorites" ||
+ (isHomePathname(pathname) && hash === "#favorites")
);
default:
return false;
@@ -296,7 +298,12 @@ export function PublicLayout() {
const na = (which: PublicNavWhich) =>
navIsActive(pathname, search, hash, which);
- const isHome = pathname === "/";
+ const isHome = isHomePathname(pathname);
+ const homePath = homePathForLang(lang);
+ const changeLang = (nextLang: Lang) => {
+ setLang(nextLang);
+ if (isHome) nav(homePathForLang(nextLang), { replace: true });
+ };
const footerInContentFlow = pathname === "/browse";
// Current page name shown in the header brand slot (falls back to the brand).
const pageTitle = usePageTitle();
@@ -453,7 +460,7 @@ export function PublicLayout() {
{/* Logo → home; page-name text → scroll to top of the current page. */}
{
if (isHome) {
@@ -498,7 +505,7 @@ export function PublicLayout() {
{
setOpen(false);
@@ -541,7 +548,7 @@ export function PublicLayout() {
{/* Logo → home; page-name text → scroll to top of the current page. */}
{
if (isHome) {
@@ -628,7 +635,7 @@ export function PublicLayout() {
@@ -754,10 +761,10 @@ export function PublicLayout() {