terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
8 changed files with 126 additions and 32 deletions
Showing only changes of commit 863a448ec9 - Show all commits

View File

@@ -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 `<Route>` 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 `<Navigate replace />` 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`.

View File

@@ -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";
@@ -25,7 +32,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 <Navigate to={`${to}${sub}${search}${hash}`} replace />;
}
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
@@ -117,6 +137,25 @@ export default function App() {
))}
</Route>
{/* Legacy long-form language URLs → short-code
redirects. Shared links (e.g. WeChat) keep working. */}
{legacyLanguageRedirects.map((redirect) => (
<Route key={redirect.from}>
<Route
path={redirect.from}
element={
<LegacyLangRedirect to={redirect.to} />
}
/>
<Route
path={`${redirect.from}/*`}
element={
<LegacyLangRedirect to={redirect.to} />
}
/>
</Route>
))}
{adminEnabled ? (
AdminRouteTree()
) : (

View File

@@ -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 {

View File

@@ -46,7 +46,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => {
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);

View File

@@ -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");
});
});

View File

@@ -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,8 +88,8 @@ 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("/ms/browse") -> "/browse"
* stripLangPrefix("/ms") -> "/"
* stripLangPrefix("/browse") -> "/browse"
*/
export function stripLangPrefix(pathname: string): string {

View File

@@ -302,7 +302,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);

View File

@@ -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 `<Link to>` 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();