Skip to content

Commit a9947c0

Browse files
authored
redirect to your preferred language (github#25664)
* redirect to your preferred language * refactorings * use js-cookies
1 parent d75c49b commit a9947c0

File tree

6 files changed

+158
-44
lines changed

6 files changed

+158
-44
lines changed

components/page-header/LanguagePicker.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { useRouter } from 'next/router'
2+
import Cookies from 'js-cookie'
3+
24
import { Link } from 'components/Link'
35
import { useLanguages } from 'components/context/LanguagesContext'
46
import { Picker } from 'components/ui/Picker'
57
import { useTranslation } from 'components/hooks/useTranslation'
68

9+
// This value is replicated in two places! See middleware/detect-language.js
10+
const PREFERRED_LOCALE_COOKIE_NAME = 'preferredlang'
11+
712
type Props = {
813
variant?: 'inline'
914
}
@@ -22,6 +27,22 @@ export const LanguagePicker = ({ variant }: Props) => {
2227
// in a "denormalized" way.
2328
const routerPath = router.asPath.split('#')[0]
2429

30+
function rememberPreferredLanguage(code: string) {
31+
try {
32+
Cookies.set(PREFERRED_LOCALE_COOKIE_NAME, code, {
33+
expires: 365,
34+
secure: document.location.protocol !== 'http:',
35+
})
36+
} catch (err) {
37+
// You can never be too careful because setting a cookie
38+
// can fail. For example, some browser
39+
// extensions disallow all setting of cookies and attempts
40+
// at the `document.cookie` setter could throw. Just swallow
41+
// and move on.
42+
console.warn('Unable to set preferred language cookie', err)
43+
}
44+
}
45+
2546
return (
2647
<Picker
2748
variant={variant}
@@ -33,7 +54,13 @@ export const LanguagePicker = ({ variant }: Props) => {
3354
text: lang.nativeName || lang.name,
3455
selected: lang === selectedLang,
3556
item: (
36-
<Link href={routerPath} locale={lang.code}>
57+
<Link
58+
href={routerPath}
59+
locale={lang.code}
60+
onClick={() => {
61+
rememberPreferredLanguage(lang.code)
62+
}}
63+
>
3764
{lang.nativeName ? (
3865
<>
3966
<span lang={lang.code}>{lang.nativeName}</span> (

lib/get-redirect.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const nonEnterpriseDefaultVersionPrefix = `/${nonEnterpriseDefaultVersion}`
99

1010
// Return the new URI if there is one, otherwise return undefined.
1111
export default function getRedirect(uri, context) {
12-
const { redirects, pages } = context
12+
const { redirects, userLanguage } = context
1313

14-
let language = 'en'
14+
let language = userLanguage || 'en'
1515
let withoutLanguage = uri
1616
if (languagePrefixRegex.test(uri)) {
1717
language = uri.match(languagePrefixRegex)[1]
@@ -109,12 +109,7 @@ export default function getRedirect(uri, context) {
109109
}
110110

111111
if (basicCorrection) {
112-
return (
113-
getRedirect(basicCorrection, {
114-
redirects,
115-
pages,
116-
}) || basicCorrection
117-
)
112+
return getRedirect(basicCorrection, context) || basicCorrection
118113
}
119114

120115
if (withoutLanguage.startsWith('/admin/')) {

middleware/detect-language.js

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import libLanguages from '../lib/languages.js'
1+
import languages, { languageKeys } from '../lib/languages.js'
22
import parser from 'accept-language-parser'
3-
const languageCodes = Object.keys(libLanguages)
43

54
const chineseRegions = ['CN', 'HK']
65

6+
// This value is replicated in two places! See <LanguagePicker/> component.
7+
// Note, the only reason this is exported is to benefit the tests.
8+
export const PREFERRED_LOCALE_COOKIE_NAME = 'preferredlang'
9+
710
function translationExists(language) {
811
if (language.code === 'zh') {
912
return chineseRegions.includes(language.region)
1013
}
11-
return languageCodes.includes(language.code)
14+
return languageKeys.includes(language.code)
1215
}
1316

1417
function getLanguageCode(language) {
@@ -17,33 +20,41 @@ function getLanguageCode(language) {
1720

1821
function getUserLanguage(browserLanguages) {
1922
try {
20-
let userLanguage = getLanguageCode(browserLanguages[0])
2123
let numTopPreferences = 1
2224
for (let lang = 0; lang < browserLanguages.length; lang++) {
2325
// If language has multiple regions, Chrome adds the non-region language to list
2426
if (lang > 0 && browserLanguages[lang].code !== browserLanguages[lang - 1].code)
2527
numTopPreferences++
2628
if (translationExists(browserLanguages[lang]) && numTopPreferences < 3) {
27-
userLanguage = getLanguageCode(browserLanguages[lang])
28-
break
29+
return getLanguageCode(browserLanguages[lang])
2930
}
3031
}
31-
return userLanguage
3232
} catch {
3333
return undefined
3434
}
3535
}
3636

37+
function getUserLanguageFromCookie(req) {
38+
const value = req.cookies[PREFERRED_LOCALE_COOKIE_NAME]
39+
// But if it's a WIP language, reject it.
40+
if (value && languages[value] && !languages[value].wip) {
41+
return value
42+
}
43+
}
44+
3745
// determine language code from a path. Default to en if no valid match
3846
export function getLanguageCodeFromPath(path) {
3947
const maybeLanguage = (path.split('/')[path.startsWith('/_next/data/') ? 4 : 1] || '').slice(0, 2)
40-
return languageCodes.includes(maybeLanguage) ? maybeLanguage : 'en'
48+
return languageKeys.includes(maybeLanguage) ? maybeLanguage : 'en'
4149
}
4250

4351
export default function detectLanguage(req, res, next) {
4452
req.language = getLanguageCodeFromPath(req.path)
4553
// Detecting browser language by user preference
46-
const browserLanguages = parser.parse(req.headers['accept-language'])
47-
req.userLanguage = getUserLanguage(browserLanguages)
54+
req.userLanguage = getUserLanguageFromCookie(req)
55+
if (!req.userLanguage) {
56+
const browserLanguages = parser.parse(req.headers['accept-language'])
57+
req.userLanguage = getUserLanguage(browserLanguages)
58+
}
4859
return next()
4960
}

middleware/redirects/handle-redirects.js

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import patterns from '../../lib/patterns.js'
22
import { URL } from 'url'
3-
import languages, { pathLanguagePrefixed } from '../../lib/languages.js'
3+
import { pathLanguagePrefixed } from '../../lib/languages.js'
44
import getRedirect from '../../lib/get-redirect.js'
55
import { cacheControlFactory } from '../cache-control.js'
66

@@ -13,16 +13,7 @@ export default function handleRedirects(req, res, next) {
1313

1414
// blanket redirects for languageless homepage
1515
if (req.path === '/') {
16-
let language = 'en'
17-
18-
// if set, redirect to user's preferred language translation or else English
19-
if (
20-
req.context.userLanguage &&
21-
languages[req.context.userLanguage] &&
22-
!languages[req.context.userLanguage].wip
23-
) {
24-
language = req.context.userLanguage
25-
}
16+
const language = getLanguage(req)
2617

2718
// Undo the cookie setting that CSRF sets.
2819
res.removeHeader('set-cookie')
@@ -70,17 +61,12 @@ export default function handleRedirects(req, res, next) {
7061
// needs to become `/en/authentication/connecting-to-github-with-ssh`
7162
const possibleRedirectTo = `/en${req.path}`
7263
if (possibleRedirectTo in req.context.pages) {
73-
// As of Jan 2022 we always redirect to `/en` if the URL doesn't
74-
// specify a language. ...except for the root home page (`/`).
75-
// It's unfortunate but that's how it currently works.
76-
// It's tracked in #1145
77-
// Perhaps a more ideal solution would be to do something similar to
78-
// the code above for `req.path === '/'` where we look at the user
79-
// agent for a header and/or cookie.
64+
const language = getLanguage(req)
65+
8066
// Note, it's important to use `req.url` here and not `req.path`
8167
// because the full URL can contain query strings.
8268
// E.g. `/foo?json=breadcrumbs`
83-
redirect = `/en${req.url}`
69+
redirect = `/${language}${req.url}`
8470
}
8571
}
8672

@@ -112,6 +98,13 @@ export default function handleRedirects(req, res, next) {
11298
return res.redirect(permanent ? 301 : 302, redirect)
11399
}
114100

101+
function getLanguage(req, default_ = 'en') {
102+
// req.context.userLanguage, if it truthy, is always a valid supported
103+
// language. It's whatever was in the user's request but filtered
104+
// based on non-WIP languages in lib/languages.js
105+
return req.context.userLanguage || default_
106+
}
107+
115108
function usePermanentRedirect(req) {
116109
// If the redirect was to essentially swap `enterprise-server@latest`
117110
// for `[email protected]` then, we definitely don't want to

tests/routing/redirects.js

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { fileURLToPath } from 'url'
22
import path from 'path'
33
import { isPlainObject } from 'lodash-es'
44
import supertest from 'supertest'
5+
import { jest } from '@jest/globals'
6+
57
import createApp from '../../lib/app.js'
68
import enterpriseServerReleases from '../../lib/enterprise-server-releases.js'
79
import Page from '../../lib/page.js'
810
import { get } from '../helpers/supertest.js'
911
import versionSatisfiesRange from '../../lib/version-satisfies-range.js'
10-
import { jest } from '@jest/globals'
12+
import { PREFERRED_LOCALE_COOKIE_NAME } from '../../middleware/detect-language.js'
1113

1214
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1315

@@ -132,6 +134,28 @@ describe('redirects', () => {
132134
expect(res.headers.location).toBe('/ja')
133135
expect(res.headers['cache-control']).toBe('private, no-store')
134136
})
137+
test('homepage redirects to preferred language by cookie', async () => {
138+
const res = await get('/', {
139+
headers: {
140+
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=ja`,
141+
'Accept-Language': 'es', // note how this is going to be ignored
142+
},
143+
})
144+
expect(res.statusCode).toBe(302)
145+
expect(res.headers.location).toBe('/ja')
146+
expect(res.headers['cache-control']).toBe('private, no-store')
147+
})
148+
test('homepage redirects to preferred language by cookie if valid', async () => {
149+
const res = await get('/', {
150+
headers: {
151+
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=xy`,
152+
'Accept-Language': 'ja', // note how this is going to be ignored
153+
},
154+
})
155+
expect(res.statusCode).toBe(302)
156+
expect(res.headers.location).toBe('/ja')
157+
expect(res.headers['cache-control']).toBe('private, no-store')
158+
})
135159
})
136160

137161
describe('external redirects', () => {
@@ -149,13 +173,63 @@ describe('redirects', () => {
149173
})
150174

151175
describe('localized redirects', () => {
176+
const redirectFrom =
177+
'/desktop/contributing-to-projects/changing-a-remote-s-url-from-github-desktop'
178+
const redirectTo =
179+
'/desktop/contributing-and-collaborating-using-github-desktop/working-with-your-remote-repository-on-github-or-github-enterprise/changing-a-remotes-url-from-github-desktop'
180+
152181
test('redirect_from for renamed pages', async () => {
153-
const { res } = await get(
154-
'/ja/desktop/contributing-to-projects/changing-a-remote-s-url-from-github-desktop'
155-
)
182+
const { res } = await get(`/ja${redirectFrom}`)
156183
expect(res.statusCode).toBe(301)
157-
const expected =
158-
'/ja/desktop/contributing-and-collaborating-using-github-desktop/working-with-your-remote-repository-on-github-or-github-enterprise/changing-a-remotes-url-from-github-desktop'
184+
const expected = `/ja${redirectTo}`
185+
expect(res.headers.location).toBe(expected)
186+
})
187+
188+
test('redirect_from for renamed pages by Accept-Language header', async () => {
189+
const { res } = await get(redirectFrom, {
190+
headers: {
191+
'Accept-Language': 'ja',
192+
},
193+
})
194+
expect(res.statusCode).toBe(302)
195+
const expected = `/ja${redirectTo}`
196+
expect(res.headers.location).toBe(expected)
197+
})
198+
199+
test('redirect_from for renamed pages but ignore Accept-Language header if not recognized', async () => {
200+
const { res } = await get(redirectFrom, {
201+
headers: {
202+
// None of these are recognized
203+
'Accept-Language': 'sv,fr,gr',
204+
},
205+
})
206+
expect(res.statusCode).toBe(302)
207+
const expected = `/en${redirectTo}`
208+
expect(res.headers.location).toBe(expected)
209+
})
210+
211+
test('redirect_from for renamed pages but ignore unrecognized Accept-Language header values', async () => {
212+
const { res } = await get(redirectFrom, {
213+
headers: {
214+
// Only the last one is recognized
215+
'Accept-Language': 'sv,ja',
216+
},
217+
})
218+
expect(res.statusCode).toBe(302)
219+
const expected = `/ja${redirectTo}`
220+
expect(res.headers.location).toBe(expected)
221+
})
222+
223+
test('will inject the preferred language from cookie', async () => {
224+
const { res } = await get(redirectFrom, {
225+
headers: {
226+
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=ja`,
227+
'Accept-Language': 'es', // note how this is going to be ignored
228+
},
229+
})
230+
// 302 because the redirect depended on cookie
231+
expect(res.statusCode).toBe(302)
232+
const expected = `/ja${redirectTo}`
159233
expect(res.headers.location).toBe(expected)
160234
})
161235
})

tests/unit/get-redirect.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,18 @@ describe('getRedirect basics', () => {
148148
// it already has the enterprise-server prefix.
149149
expect(getRedirect('/enterprise-server/foo', ctx)).toBe(`/en/enterprise-server@${latest}/bar`)
150150
})
151+
152+
it('should redirect according to the req.context.userLanguage', () => {
153+
const ctx = {
154+
pages: {},
155+
redirects: {
156+
'/foo': '/bar',
157+
},
158+
userLanguage: 'ja',
159+
}
160+
expect(getRedirect('/foo', ctx)).toBe(`/ja/bar`)
161+
// falls back to 'en' if it's falsy
162+
ctx.userLanguage = null
163+
expect(getRedirect('/foo', ctx)).toBe(`/en/bar`)
164+
})
151165
})

0 commit comments

Comments
 (0)