Skip to content

Commit dfc4046

Browse files
authored
feat: remove language prefix from URL (#1749)
1 parent e4b0314 commit dfc4046

8 files changed

Lines changed: 72 additions & 85 deletions

File tree

src/layout/ThemeContext.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,10 @@ import {
2020
useMemo,
2121
} from 'react'
2222
import { useTranslation } from 'react-i18next'
23-
import { useLocation, useNavigate } from 'react-router'
2423
import { prefixer } from 'stylis'
2524
import { useLocalStorage } from 'usehooks-ts'
2625
import dayjs from 'src/dayjs'
27-
import { getLang, getPathWithoutLang } from 'src/locale/allTranslations'
26+
import { getLang } from 'src/locale/allTranslations'
2827

2928
// Direction-aware emotion caches: the RTL cache runs the vendor prefixer and then
3029
// flips physical CSS (left↔right); the LTR cache uses emotion's default (prefixer
@@ -62,21 +61,18 @@ export const ThemeProvider = ({ children }: PropsWithChildren) => {
6261
})
6362

6463
const { i18n } = useTranslation()
65-
const navigate = useNavigate()
6664

6765
const toggleTheme = useCallback(() => setIsDarkTheme((prev) => !prev), [setIsDarkTheme])
6866

6967
const emotionCache = RTL_LANGUAGES.includes(language) ? cacheRtl : cacheLtr
7068

71-
const location = useLocation()
72-
69+
// The URL no longer encodes the language, so switching is just a state update:
70+
// the effect below syncs i18n, document direction/title and dayjs.
7371
const changeLanguage = useCallback(
7472
(newLanguage: string) => {
7573
setLanguage(newLanguage)
76-
const pathWithoutLang = getPathWithoutLang(location.pathname)
77-
navigate(`/${newLanguage}${pathWithoutLang}`)
7874
},
79-
[setLanguage, navigate, location],
75+
[setLanguage],
8076
)
8177

8278
const contextValue = useMemo(

src/layout/header/shareUrl.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -114,28 +114,6 @@ describe('buildShareUrl — /vehicle page', () => {
114114
})
115115
})
116116

117-
// ---------------------------------------------------------------------------
118-
// buildShareUrl — language prefix stripping
119-
// ---------------------------------------------------------------------------
120-
121-
describe('buildShareUrl — language prefix', () => {
122-
it('strips the lang code from the output pathname', () => {
123-
// A Hebrew user's link must not force Hebrew on the recipient.
124-
// The recipient's localStorage/URL preference picks their own language.
125-
expect(new URL(build('/he/gaps')).pathname).toBe('/gaps')
126-
expect(new URL(build('/en/timeline')).pathname).toBe('/timeline')
127-
expect(new URL(build('/ar/operator')).pathname).toBe('/operator')
128-
})
129-
130-
it('/he/gaps and /gaps produce identical URLs', () => {
131-
expect(build('/he/gaps')).toBe(build('/gaps'))
132-
})
133-
134-
it('page without lang prefix is unaffected', () => {
135-
expect(new URL(build('/gaps')).pathname).toBe('/gaps')
136-
})
137-
})
138-
139117
// ---------------------------------------------------------------------------
140118
// buildShareUrl — round-trip (encode → decode)
141119
// ---------------------------------------------------------------------------

src/layout/header/shareUrl.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { getPathWithoutLang } from 'src/locale/allTranslations'
21
import { GlobalSearchState } from 'src/model/globalState'
32

43
export type ShareableKey = keyof GlobalSearchState
@@ -31,7 +30,7 @@ export const buildShareUrl = (
3130
pageParams: Record<string, string>,
3231
origin = window.location.origin,
3332
): string => {
34-
const pagePath = getPathWithoutLang(pathname)
33+
const pagePath = pathname
3534
const relevantKeys = PAGE_SHARE_PARAMS[pagePath] ?? []
3635

3736
const params = new URLSearchParams()
@@ -44,7 +43,7 @@ export const buildShareUrl = (
4443
Object.entries(pageParams).forEach(([key, value]) => params.set(key, value))
4544

4645
const query = params.toString()
47-
// Use the lang-stripped path so shared links are language-agnostic.
48-
// The recipient's language preference (localStorage) picks their own lang.
46+
// The URL carries no language segment, so shared links are language-agnostic
47+
// the recipient's stored language preference (localStorage) picks their own lang.
4948
return `${origin}${pagePath}${query ? `?${query}` : ''}`
5049
}

src/layout/sidebar/SideBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default function SideBar() {
1818
const { t, i18n } = useTranslation()
1919
const { drawerOpen, setDrawerOpen } = useContext<LayoutContextInterface>(LayoutCtx)
2020
const [collapsed, setCollapsed] = useState(false)
21-
const { isDarkTheme, currentLanguage } = useTheme()
21+
const { isDarkTheme } = useTheme()
2222

2323
return (
2424
<>
@@ -47,7 +47,7 @@ export default function SideBar() {
4747
}}
4848
onCollapse={setCollapsed}
4949
className={cn('hideOnMobile', { dark: isDarkTheme })}>
50-
<Link to={`/${currentLanguage}${PAGES[0].path}`} replace>
50+
<Link to={PAGES[0].path} replace>
5151
{collapsed ? <CollapsedLogo /> : <Logo title={t('website_name')} dark={isDarkTheme} />}
5252
</Link>
5353
<div className="sidebar-divider" />

src/layout/sidebar/menu/Menu.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import React, { useContext, useEffect, useState } from 'react'
55
import { useTranslation } from 'react-i18next'
66
import { Link, useLocation } from 'react-router'
77
import { LayoutContextInterface, LayoutCtx } from 'src/layout/LayoutContext'
8-
import { useTheme } from 'src/layout/ThemeContext'
9-
import { getPathWithoutLang } from 'src/locale/allTranslations'
108
import DonateModal from 'src/pages/DonateModal/DonateModal'
119
import { EVENT_DATE_ISO, REGISTRATION_CLOSE_ISO } from 'src/pages/hackathon/challenges'
1210
import { PAGES } from 'src/routes'
@@ -61,7 +59,6 @@ const HACKATHON_MENU_HIDE_MS = HACKATHON_EVENT_MS + 3 * 24 * 60 * 60 * 1000 // h
6159

6260
const MainMenu = ({ collapsed = false }: MainMenuProps) => {
6361
const { t } = useTranslation()
64-
const { currentLanguage } = useTheme()
6562
const { setDrawerOpen } = useContext<LayoutContextInterface>(LayoutCtx)
6663
const [isDonateModalVisible, setDonateModalVisible] = useState(false)
6764

@@ -75,7 +72,7 @@ const MainMenu = ({ collapsed = false }: MainMenuProps) => {
7572

7673
const hackathonItem = showHackathon
7774
? getItem(
78-
<Link to={`/${currentLanguage}/hackathon`} onClick={() => setDrawerOpen(false)}>
75+
<Link to="/hackathon" onClick={() => setDrawerOpen(false)}>
7976
{t('hackathon_title')}
8077
{hackathonDaysLeft !== null && (
8178
<span className="hackathon-badge">
@@ -105,7 +102,7 @@ const MainMenu = ({ collapsed = false }: MainMenuProps) => {
105102
itm.icon,
106103
)
107104
: getItem(
108-
<Link to={`/${currentLanguage}${itm.path}`} onClick={() => setDrawerOpen(false)}>
105+
<Link to={itm.path} onClick={() => setDrawerOpen(false)}>
109106
{t(itm.label)}
110107
</Link>,
111108
itm.path,
@@ -135,10 +132,10 @@ const MainMenu = ({ collapsed = false }: MainMenuProps) => {
135132
const items = collapsed ? flatItems : groupedItems
136133

137134
const { pathname } = useLocation()
138-
const [current, setCurrent] = useState(getPathWithoutLang(pathname) || '/')
135+
const [current, setCurrent] = useState(pathname || '/')
139136

140137
useEffect(() => {
141-
const nextPath = getPathWithoutLang(pathname) || '/'
138+
const nextPath = pathname || '/'
142139

143140
if (current !== nextPath) {
144141
setCurrent(nextPath)

src/locale/allTranslations.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,10 @@ import translationsRU from './ru.json'
77

88
export const SUPPORTED_LANGUAGES = ['en', 'ru', 'he', 'ar']
99

10-
// Get the path without the language prefix, if present
11-
export const getPathWithoutLang = (pathname: string): string => {
12-
const parts = pathname.split('/').filter(Boolean)
13-
if (!SUPPORTED_LANGUAGES.includes(parts[0])) return pathname
14-
const rest = parts.slice(1).join('/')
15-
return rest ? `/${rest}` : '/'
16-
}
17-
18-
// Get saved language from URL or localStorage, default to 'he' if not found
10+
// Resolve the language from localStorage, then the browser locale, defaulting
11+
// to 'he'. The URL no longer carries a language prefix; legacy prefixed links
12+
// are handled by LegacyLangRedirect, which persists the language here.
1913
export const getLang = (): string => {
20-
const parts = window.location.pathname.split('/').filter(Boolean)
21-
const langPart = parts.find((part) => SUPPORTED_LANGUAGES.includes(part))
22-
if (langPart) {
23-
localStorage.setItem('language', langPart)
24-
return langPart
25-
}
2614
return (
2715
localStorage.getItem('language') ||
2816
SUPPORTED_LANGUAGES.find((l) => new Intl.Locale(navigator.language).language === l) ||

src/routes/LegacyLangRedirect.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect } from 'react'
2+
import { Navigate, useLocation, useParams } from 'react-router'
3+
import { useTheme } from 'src/layout/ThemeContext'
4+
import { SUPPORTED_LANGUAGES } from 'src/locale/allTranslations'
5+
6+
// Backward-compatibility shim for links that still carry a language prefix
7+
// (/he, /en, /ru, /ar) — the URL scheme no longer includes one. It applies the
8+
// prefixed language, then redirects to the same path without the prefix
9+
// (preserving query string and hash). A first segment that isn't a known
10+
// language falls through to the homepage, matching the catch-all route.
11+
//
12+
// Isolated on purpose: delete this file and its `:lang/*` route in index.tsx
13+
// once old prefixed links have aged out.
14+
export const LegacyLangRedirect = () => {
15+
const { lang, '*': rest } = useParams()
16+
const { search, hash } = useLocation()
17+
const { setLanguage } = useTheme()
18+
const isKnownLang = lang !== undefined && SUPPORTED_LANGUAGES.includes(lang)
19+
20+
useEffect(() => {
21+
if (isKnownLang && lang) setLanguage(lang)
22+
}, [isKnownLang, lang, setLanguage])
23+
24+
return <Navigate to={isKnownLang ? { pathname: `/${rest ?? ''}`, search, hash } : '/'} replace />
25+
}

src/routes/index.tsx

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { DataResearch } from 'src/pages/DataResearch/DataResearch'
2828
import { ErrorPage } from 'src/pages/ErrorPage'
2929
import GapsPatternsPage from 'src/pages/gapsPatterns'
3030
import VelocityHeatmapPage from 'src/pages/velocityHeatmap'
31+
import { LegacyLangRedirect } from './LegacyLangRedirect'
3132
import { MainRoute } from './MainRoute'
3233

3334
const HomePage = lazy(() => import('../pages/homepage/HomePage'))
@@ -165,35 +166,37 @@ const RedirectToHomepage = <Navigate to={routesList[0].path} replace />
165166

166167
export const getRoutesList = () => {
167168
return (
168-
<Route path="/:lang?">
169-
<Route element={<MainRoute />}>
170-
{routesList.map(({ path, element }) => (
171-
<Route
172-
key={path}
173-
path={path === '/' ? undefined : path.replace(/^\//, '')}
174-
index={path === '/'}
175-
element={element}
176-
ErrorBoundary={ErrorPage}
177-
/>
178-
))}
169+
<Route element={<MainRoute />}>
170+
{routesList.map(({ path, element }) => (
179171
<Route
180-
path="profile/:gtfsRideGtfsRouteId"
181-
element={<Profile />}
172+
key={path}
173+
path={path === '/' ? undefined : path.replace(/^\//, '')}
174+
index={path === '/'}
175+
element={element}
182176
ErrorBoundary={ErrorPage}
183-
loader={async ({ params }) => {
184-
try {
185-
const route = await getRouteById(params?.gtfsRideGtfsRouteId)
186-
return { route }
187-
} catch (error) {
188-
return {
189-
route: null,
190-
message: (error as Error).message,
191-
}
192-
}
193-
}}
194177
/>
195-
<Route path="*" element={RedirectToHomepage} key="back" />
196-
</Route>
178+
))}
179+
<Route
180+
path="profile/:gtfsRideGtfsRouteId"
181+
element={<Profile />}
182+
ErrorBoundary={ErrorPage}
183+
loader={async ({ params }) => {
184+
try {
185+
const route = await getRouteById(params?.gtfsRideGtfsRouteId)
186+
return { route }
187+
} catch (error) {
188+
return {
189+
route: null,
190+
message: (error as Error).message,
191+
}
192+
}
193+
}}
194+
/>
195+
{/* Backward-compat: old links carried a language prefix (/he, /en, /ru, /ar).
196+
Strip it, apply the language, and redirect to the clean path.
197+
Remove this route (and LegacyLangRedirect) once such links have aged out. */}
198+
<Route path=":lang/*" element={<LegacyLangRedirect />} />
199+
<Route path="*" element={RedirectToHomepage} key="back" />
197200
</Route>
198201
)
199202
}
@@ -204,7 +207,8 @@ window.addEventListener('vite:preloadError', () => {
204207

205208
const routes = createRoutesFromElements(getRoutesList())
206209

207-
// If the URL doesn't have a language prefix, we will use the saved language or default to Hebrew
210+
// The URL carries no language segment; the language is resolved from
211+
// localStorage / the browser locale (see getLang in allTranslations.ts).
208212
const router = createBrowserRouter(routes, {
209213
basename: import.meta.env.VITE_BASE_PATH || '/',
210214
})

0 commit comments

Comments
 (0)