feat(routing): shorten language URL prefixes to ISO codes with legacy redirects
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 31s
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 31s
Rename the localized URL prefixes from full English names to short ISO-style codes: /chinese -> /cn /japanese -> /ja /korean -> /ko /vietnamese -> /vi /indonesian -> /id /malay -> /ms Add legacyLanguageRedirects mapping and a LegacyLangRedirect component in App.tsx so links shared on WeChat (and elsewhere) that still use the long-form paths keep landing on the right page. The redirect preserves the sub-path, query string, and hash, e.g. /malay/browse?post=42#x -> /ms/browse?post=42#x Also refresh doc-comment examples in i18n.tsx, FigmaBanner.tsx, PublicLayout.tsx, and useLocalizedPath.ts so future readers see the new prefixes.
This commit is contained in:
39
src/App.tsx
39
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 <Navigate to={`${to}${sub}${search}${hash}`} replace />;
|
||||
}
|
||||
|
||||
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
|
||||
|
||||
@@ -94,6 +114,21 @@ 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()
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user