diff --git a/.unipi/docs/fix/2026-06-04-language-route-short-codes-fix.md b/.unipi/docs/fix/2026-06-04-language-route-short-codes-fix.md new file mode 100644 index 0000000..2de1f05 --- /dev/null +++ b/.unipi/docs/fix/2026-06-04-language-route-short-codes-fix.md @@ -0,0 +1,38 @@ +--- +title: "Language route prefixes — short ISO codes with legacy redirects" +type: quick-fix +date: 2026-06-04 +--- + +# Language route prefixes — short ISO codes with legacy redirects + +## Bug +Localized URLs used full English names (`/chinese`, `/japanese`, `/korean`, `/vietnamese`, `/indonesian`, `/malay`). Per the broadcast in WeChat (screenshot), they need to be short ISO-style codes (`/cn`, `/ja`, `/ko`, `/vi`, `/id`, `/ms`). + +## Root Cause +The mapping is sourced from one place — `src/languageRoutes.ts` `localizedHomeRoutes` — and that array hard-coded the long names. + +## Fix +- Rename every localized prefix in `localizedHomeRoutes` to its short code. +- Add `legacyLanguageRedirects` (the old → new map) and render client redirects in `App.tsx` so links previously shared (`/chinese`, `/malay/browse?post=42`, etc.) keep landing on the right destination. The redirect preserves sub-path, query string, and hash. +- Refresh doc-comment examples (`/malay/...`) in unrelated files so future readers don't get confused. +- Update `languageRoutes.test.ts` to assert the new mapping. + +### Files Modified +- `src/languageRoutes.ts` — paths swapped to short codes; added `legacyLanguageRedirects`; refreshed doc-comment examples. +- `src/languageRoutes.test.ts` — expectations updated to short codes; test description renamed accordingly. +- `src/App.tsx` — added `LegacyLangRedirect` component (uses `useParams`/`useLocation`) and rendered `` pairs (`/old` and `/old/*`) for each entry in `legacyLanguageRedirects`. +- `src/i18n.tsx`, `src/components/FigmaBanner.tsx`, `src/layouts/PublicLayout.tsx`, `src/useLocalizedPath.ts` — doc-comment example paths updated for consistency. + +## Verification +- `npx tsc --noEmit` — clean. +- `npm run format` then `npm run format:check` — clean. +- `npm test` — 49/49 passing (includes the updated `languageRoutes.test.ts`). +- Expected runtime behavior: + - `/cn`, `/ja`, `/ko`, `/vi`, `/id`, `/ms` (and their nested routes) resolve to localized pages. + - `/chinese`, `/japanese`, `/korean`, `/vietnamese`, `/indonesian`, `/malay` (and their nested routes) redirect via React Router `` to the new short-code equivalents, preserving `?query` and `#hash`. + +## Notes +- Server-side considerations: this works for SPA navigation because BrowserRouter handles all paths client-side. Any reverse-proxy/CDN rule that hardcoded the long names should be reviewed (e.g. nginx rewrites, prerender configs). The `nginx.conf` and Gitea deploy workflow only reference `index.html`, so no server-side path rules to update here. +- If the SEO sitemap / canonical URLs are generated elsewhere, those should also pick up the new prefixes. +- `legacyLanguageRedirects` is intentionally kept distinct from `localizedHomeRoutes` so we can sunset it later by deleting the export and the corresponding routes block in `App.tsx`. diff --git a/src/App.tsx b/src/App.tsx index 0141b7a..0c122c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,11 @@ -import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { + BrowserRouter, + Navigate, + Route, + Routes, + useLocation, + useParams, +} from "react-router-dom"; import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; @@ -19,7 +26,20 @@ 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"; +import { legacyLanguageRedirects, localizedHomeRoutes } from "./languageRoutes"; + +/** + * Redirects shared links that still use the old long-form language prefix + * (e.g. /chinese, /malay/browse) to the new short codes (/cn, /ms/browse). + * Preserves the sub-path, query string, and hash. + */ +function LegacyLangRedirect({ to }: { to: string }) { + const params = useParams(); + const { search, hash } = useLocation(); + const splat = params["*"]; + const sub = splat ? `/${splat}` : ""; + return ; +} const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true"; @@ -94,6 +114,21 @@ export default function App() { ))} + {/* Legacy long-form language URLs → short-code + redirects. Shared links (e.g. WeChat) keep working. */} + {legacyLanguageRedirects.map((redirect) => ( + + } + /> + } + /> + + ))} + {adminEnabled ? ( AdminRouteTree() ) : ( diff --git a/src/components/FigmaBanner.tsx b/src/components/FigmaBanner.tsx index 19ebbbc..10532f5 100644 --- a/src/components/FigmaBanner.tsx +++ b/src/components/FigmaBanner.tsx @@ -70,7 +70,7 @@ function internalPath(linkUrl: string): string | null { /** * Banner link URLs are stored unprefixed (e.g. `/browse?post=123`). When the * viewer is on a non-English locale we must re-prefix them with the active - * language path (`/malay/browse?post=123`) so navigation doesn't drop into + * language path (`/ms/browse?post=123`) so navigation doesn't drop into * the English version of the post. */ function localizeLinkUrl(linkUrl: string, lang: Lang): string { diff --git a/src/i18n.tsx b/src/i18n.tsx index c3efa17..43ae6c1 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -46,7 +46,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { const [lang, setLangState] = useState(() => { const path = window.location.pathname; // Any URL whose first path segment is a known language prefix wins - // (covers /malay, /malay/browse, /korean/category/foo, etc.). + // (covers /ms, /ms/browse, /ko/category/foo, etc.). const homeLang = languageForHomePathname(path); if (homeLang) return homeLang; const deepLang = languageFromPathname(path); diff --git a/src/languageRoutes.test.ts b/src/languageRoutes.test.ts index 36bb616..be25719 100644 --- a/src/languageRoutes.test.ts +++ b/src/languageRoutes.test.ts @@ -7,16 +7,16 @@ import { } from "./languageRoutes"; const expectedRoutes = [ - ["/chinese", "zh-CN"], - ["/japanese", "ja"], - ["/korean", "ko"], - ["/vietnamese", "vi"], - ["/indonesian", "id"], - ["/malay", "ms"], + ["/cn", "zh-CN"], + ["/ja", "ja"], + ["/ko", "ko"], + ["/vi", "vi"], + ["/id", "id"], + ["/ms", "ms"], ] as const; describe("language home routes", () => { - it("uses CTO-style full language path names except default English root", () => { + it("uses short ISO-style language path codes except default English root", () => { expect(localizedHomeRoutes.map(({ path, lang }) => [path, lang])).toEqual( expectedRoutes, ); @@ -33,17 +33,17 @@ describe("language home routes", () => { it("treats root and localized language paths as home pages", () => { expect(isHomePathname("/")).toBe(true); - expect(isHomePathname("/chinese")).toBe(true); + expect(isHomePathname("/cn")).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"); + expect(homePathForLang("zh-CN")).toBe("/cn"); + expect(homePathForLang("ja")).toBe("/ja"); + expect(homePathForLang("ko")).toBe("/ko"); + expect(homePathForLang("vi")).toBe("/vi"); + expect(homePathForLang("id")).toBe("/id"); + expect(homePathForLang("ms")).toBe("/ms"); }); }); diff --git a/src/languageRoutes.ts b/src/languageRoutes.ts index 106f730..1332b11 100644 --- a/src/languageRoutes.ts +++ b/src/languageRoutes.ts @@ -2,14 +2,31 @@ 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" }, + { lang: "zh-CN", path: "/cn" }, + { lang: "ja", path: "/ja" }, + { lang: "ko", path: "/ko" }, + { lang: "vi", path: "/vi" }, + { lang: "id", path: "/id" }, + { lang: "ms", path: "/ms" }, ]; +/** + * Legacy long-form language paths kept alive as 301-style client redirects so + * URLs shared before the short-code rename (e.g. WeChat broadcasts pointing to + * https://ark-library.com/chinese) still land on the right page. + */ +export const legacyLanguageRedirects: ReadonlyArray<{ + from: string; + to: string; +}> = [ + { from: "/chinese", to: "/cn" }, + { from: "/japanese", to: "/ja" }, + { from: "/korean", to: "/ko" }, + { from: "/vietnamese", to: "/vi" }, + { from: "/indonesian", to: "/id" }, + { from: "/malay", to: "/ms" }, +]; + function normalizePathname(pathname: string): string { const normalized = pathname.replace(/\/+$/, ""); return normalized || "/"; @@ -34,7 +51,7 @@ export function homePathForLang(lang: Lang): string { return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/"; } -/** Returns the URL prefix for a language (e.g. "/malay"), or "" for English. */ +/** Returns the URL prefix for a language (e.g. "/ms"), or "" for English. */ export function langPathPrefix(lang: Lang): string { if (lang === "en") return ""; return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? ""; @@ -55,8 +72,8 @@ export function languageFromPathname(pathname: string): Lang { * Prepends a language prefix to a path. Path may include `?query` or `#hash`; * the prefix is inserted before the pathname only. * - * localizePath("/browse", "ms") -> "/malay/browse" - * localizePath("/", "ms") -> "/malay" + * localizePath("/browse", "ms") -> "/ms/browse" + * localizePath("/", "ms") -> "/ms" * localizePath("/browse", "en") -> "/browse" */ export function localizePath(path: string, lang: Lang): string { @@ -71,9 +88,9 @@ export function localizePath(path: string, lang: Lang): string { * Removes any known language prefix from a pathname. Useful when comparing * the current route against canonical (unprefixed) paths. * - * stripLangPrefix("/malay/browse") -> "/browse" - * stripLangPrefix("/malay") -> "/" - * stripLangPrefix("/browse") -> "/browse" + * stripLangPrefix("/ms/browse") -> "/browse" + * stripLangPrefix("/ms") -> "/" + * stripLangPrefix("/browse") -> "/browse" */ export function stripLangPrefix(pathname: string): string { const normalized = normalizePathname(pathname); diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index f1c62bc..ca18d58 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -305,7 +305,7 @@ export function PublicLayout() { const nav = useNavigate(); const lp = useLocalizedPath(); - // Keep i18n state in sync with URL so deep links (`/malay/browse`) flip the + // Keep i18n state in sync with URL so deep links (`/ms/browse`) flip the // UI language even if the user navigated via address bar or shared link. useEffect(() => { const urlLang = languageFromPathname(pathname); diff --git a/src/useLocalizedPath.ts b/src/useLocalizedPath.ts index b145ffa..b94bb63 100644 --- a/src/useLocalizedPath.ts +++ b/src/useLocalizedPath.ts @@ -5,7 +5,7 @@ import { localizePath } from "./languageRoutes"; /** * Returns a stable `(path) => localized path` function bound to the current * UI language. Use this anywhere a `` or `navigate()` target needs to - * preserve the active language prefix (e.g. `/malay/browse`). + * preserve the active language prefix (e.g. `/ms/browse`). */ export function useLocalizedPath() { const { lang } = useI18n();