Skip to content

Commit

Permalink
redirect to your preferred language (github#25664)
Browse files Browse the repository at this point in the history
* redirect to your preferred language

* refactorings

* use js-cookies
  • Loading branch information
peterbe authored Mar 4, 2022
1 parent d75c49b commit a9947c0
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 44 deletions.
29 changes: 28 additions & 1 deletion components/page-header/LanguagePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { useRouter } from 'next/router'
import Cookies from 'js-cookie'

import { Link } from 'components/Link'
import { useLanguages } from 'components/context/LanguagesContext'
import { Picker } from 'components/ui/Picker'
import { useTranslation } from 'components/hooks/useTranslation'

// This value is replicated in two places! See middleware/detect-language.js
const PREFERRED_LOCALE_COOKIE_NAME = 'preferredlang'

type Props = {
variant?: 'inline'
}
Expand All @@ -22,6 +27,22 @@ export const LanguagePicker = ({ variant }: Props) => {
// in a "denormalized" way.
const routerPath = router.asPath.split('#')[0]

function rememberPreferredLanguage(code: string) {
try {
Cookies.set(PREFERRED_LOCALE_COOKIE_NAME, code, {
expires: 365,
secure: document.location.protocol !== 'http:',
})
} catch (err) {
// You can never be too careful because setting a cookie
// can fail. For example, some browser
// extensions disallow all setting of cookies and attempts
// at the `document.cookie` setter could throw. Just swallow
// and move on.
console.warn('Unable to set preferred language cookie', err)
}
}

return (
<Picker
variant={variant}
Expand All @@ -33,7 +54,13 @@ export const LanguagePicker = ({ variant }: Props) => {
text: lang.nativeName || lang.name,
selected: lang === selectedLang,
item: (
<Link href={routerPath} locale={lang.code}>
<Link
href={routerPath}
locale={lang.code}
onClick={() => {
rememberPreferredLanguage(lang.code)
}}
>
{lang.nativeName ? (
<>
<span lang={lang.code}>{lang.nativeName}</span> (
Expand Down
11 changes: 3 additions & 8 deletions lib/get-redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ const nonEnterpriseDefaultVersionPrefix = `/${nonEnterpriseDefaultVersion}`

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

let language = 'en'
let language = userLanguage || 'en'
let withoutLanguage = uri
if (languagePrefixRegex.test(uri)) {
language = uri.match(languagePrefixRegex)[1]
Expand Down Expand Up @@ -109,12 +109,7 @@ export default function getRedirect(uri, context) {
}

if (basicCorrection) {
return (
getRedirect(basicCorrection, {
redirects,
pages,
}) || basicCorrection
)
return getRedirect(basicCorrection, context) || basicCorrection
}

if (withoutLanguage.startsWith('/admin/')) {
Expand Down
31 changes: 21 additions & 10 deletions middleware/detect-language.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import libLanguages from '../lib/languages.js'
import languages, { languageKeys } from '../lib/languages.js'
import parser from 'accept-language-parser'
const languageCodes = Object.keys(libLanguages)

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

// This value is replicated in two places! See <LanguagePicker/> component.
// Note, the only reason this is exported is to benefit the tests.
export const PREFERRED_LOCALE_COOKIE_NAME = 'preferredlang'

function translationExists(language) {
if (language.code === 'zh') {
return chineseRegions.includes(language.region)
}
return languageCodes.includes(language.code)
return languageKeys.includes(language.code)
}

function getLanguageCode(language) {
Expand All @@ -17,33 +20,41 @@ function getLanguageCode(language) {

function getUserLanguage(browserLanguages) {
try {
let userLanguage = getLanguageCode(browserLanguages[0])
let numTopPreferences = 1
for (let lang = 0; lang < browserLanguages.length; lang++) {
// If language has multiple regions, Chrome adds the non-region language to list
if (lang > 0 && browserLanguages[lang].code !== browserLanguages[lang - 1].code)
numTopPreferences++
if (translationExists(browserLanguages[lang]) && numTopPreferences < 3) {
userLanguage = getLanguageCode(browserLanguages[lang])
break
return getLanguageCode(browserLanguages[lang])
}
}
return userLanguage
} catch {
return undefined
}
}

function getUserLanguageFromCookie(req) {
const value = req.cookies[PREFERRED_LOCALE_COOKIE_NAME]
// But if it's a WIP language, reject it.
if (value && languages[value] && !languages[value].wip) {
return value
}
}

// determine language code from a path. Default to en if no valid match
export function getLanguageCodeFromPath(path) {
const maybeLanguage = (path.split('/')[path.startsWith('/_next/data/') ? 4 : 1] || '').slice(0, 2)
return languageCodes.includes(maybeLanguage) ? maybeLanguage : 'en'
return languageKeys.includes(maybeLanguage) ? maybeLanguage : 'en'
}

export default function detectLanguage(req, res, next) {
req.language = getLanguageCodeFromPath(req.path)
// Detecting browser language by user preference
const browserLanguages = parser.parse(req.headers['accept-language'])
req.userLanguage = getUserLanguage(browserLanguages)
req.userLanguage = getUserLanguageFromCookie(req)
if (!req.userLanguage) {
const browserLanguages = parser.parse(req.headers['accept-language'])
req.userLanguage = getUserLanguage(browserLanguages)
}
return next()
}
31 changes: 12 additions & 19 deletions middleware/redirects/handle-redirects.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import patterns from '../../lib/patterns.js'
import { URL } from 'url'
import languages, { pathLanguagePrefixed } from '../../lib/languages.js'
import { pathLanguagePrefixed } from '../../lib/languages.js'
import getRedirect from '../../lib/get-redirect.js'
import { cacheControlFactory } from '../cache-control.js'

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

// blanket redirects for languageless homepage
if (req.path === '/') {
let language = 'en'

// if set, redirect to user's preferred language translation or else English
if (
req.context.userLanguage &&
languages[req.context.userLanguage] &&
!languages[req.context.userLanguage].wip
) {
language = req.context.userLanguage
}
const language = getLanguage(req)

// Undo the cookie setting that CSRF sets.
res.removeHeader('set-cookie')
Expand Down Expand Up @@ -70,17 +61,12 @@ export default function handleRedirects(req, res, next) {
// needs to become `/en/authentication/connecting-to-github-with-ssh`
const possibleRedirectTo = `/en${req.path}`
if (possibleRedirectTo in req.context.pages) {
// As of Jan 2022 we always redirect to `/en` if the URL doesn't
// specify a language. ...except for the root home page (`/`).
// It's unfortunate but that's how it currently works.
// It's tracked in #1145
// Perhaps a more ideal solution would be to do something similar to
// the code above for `req.path === '/'` where we look at the user
// agent for a header and/or cookie.
const language = getLanguage(req)

// Note, it's important to use `req.url` here and not `req.path`
// because the full URL can contain query strings.
// E.g. `/foo?json=breadcrumbs`
redirect = `/en${req.url}`
redirect = `/${language}${req.url}`
}
}

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

function getLanguage(req, default_ = 'en') {
// req.context.userLanguage, if it truthy, is always a valid supported
// language. It's whatever was in the user's request but filtered
// based on non-WIP languages in lib/languages.js
return req.context.userLanguage || default_
}

function usePermanentRedirect(req) {
// If the redirect was to essentially swap `enterprise-server@latest`
// for `[email protected]` then, we definitely don't want to
Expand Down
86 changes: 80 additions & 6 deletions tests/routing/redirects.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { fileURLToPath } from 'url'
import path from 'path'
import { isPlainObject } from 'lodash-es'
import supertest from 'supertest'
import { jest } from '@jest/globals'

import createApp from '../../lib/app.js'
import enterpriseServerReleases from '../../lib/enterprise-server-releases.js'
import Page from '../../lib/page.js'
import { get } from '../helpers/supertest.js'
import versionSatisfiesRange from '../../lib/version-satisfies-range.js'
import { jest } from '@jest/globals'
import { PREFERRED_LOCALE_COOKIE_NAME } from '../../middleware/detect-language.js'

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

Expand Down Expand Up @@ -132,6 +134,28 @@ describe('redirects', () => {
expect(res.headers.location).toBe('/ja')
expect(res.headers['cache-control']).toBe('private, no-store')
})
test('homepage redirects to preferred language by cookie', async () => {
const res = await get('/', {
headers: {
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=ja`,
'Accept-Language': 'es', // note how this is going to be ignored
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toBe('/ja')
expect(res.headers['cache-control']).toBe('private, no-store')
})
test('homepage redirects to preferred language by cookie if valid', async () => {
const res = await get('/', {
headers: {
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=xy`,
'Accept-Language': 'ja', // note how this is going to be ignored
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toBe('/ja')
expect(res.headers['cache-control']).toBe('private, no-store')
})
})

describe('external redirects', () => {
Expand All @@ -149,13 +173,63 @@ describe('redirects', () => {
})

describe('localized redirects', () => {
const redirectFrom =
'/desktop/contributing-to-projects/changing-a-remote-s-url-from-github-desktop'
const redirectTo =
'/desktop/contributing-and-collaborating-using-github-desktop/working-with-your-remote-repository-on-github-or-github-enterprise/changing-a-remotes-url-from-github-desktop'

test('redirect_from for renamed pages', async () => {
const { res } = await get(
'/ja/desktop/contributing-to-projects/changing-a-remote-s-url-from-github-desktop'
)
const { res } = await get(`/ja${redirectFrom}`)
expect(res.statusCode).toBe(301)
const expected =
'/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'
const expected = `/ja${redirectTo}`
expect(res.headers.location).toBe(expected)
})

test('redirect_from for renamed pages by Accept-Language header', async () => {
const { res } = await get(redirectFrom, {
headers: {
'Accept-Language': 'ja',
},
})
expect(res.statusCode).toBe(302)
const expected = `/ja${redirectTo}`
expect(res.headers.location).toBe(expected)
})

test('redirect_from for renamed pages but ignore Accept-Language header if not recognized', async () => {
const { res } = await get(redirectFrom, {
headers: {
// None of these are recognized
'Accept-Language': 'sv,fr,gr',
},
})
expect(res.statusCode).toBe(302)
const expected = `/en${redirectTo}`
expect(res.headers.location).toBe(expected)
})

test('redirect_from for renamed pages but ignore unrecognized Accept-Language header values', async () => {
const { res } = await get(redirectFrom, {
headers: {
// Only the last one is recognized
'Accept-Language': 'sv,ja',
},
})
expect(res.statusCode).toBe(302)
const expected = `/ja${redirectTo}`
expect(res.headers.location).toBe(expected)
})

test('will inject the preferred language from cookie', async () => {
const { res } = await get(redirectFrom, {
headers: {
Cookie: `${PREFERRED_LOCALE_COOKIE_NAME}=ja`,
'Accept-Language': 'es', // note how this is going to be ignored
},
})
// 302 because the redirect depended on cookie
expect(res.statusCode).toBe(302)
const expected = `/ja${redirectTo}`
expect(res.headers.location).toBe(expected)
})
})
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/get-redirect.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,18 @@ describe('getRedirect basics', () => {
// it already has the enterprise-server prefix.
expect(getRedirect('/enterprise-server/foo', ctx)).toBe(`/en/enterprise-server@${latest}/bar`)
})

it('should redirect according to the req.context.userLanguage', () => {
const ctx = {
pages: {},
redirects: {
'/foo': '/bar',
},
userLanguage: 'ja',
}
expect(getRedirect('/foo', ctx)).toBe(`/ja/bar`)
// falls back to 'en' if it's falsy
ctx.userLanguage = null
expect(getRedirect('/foo', ctx)).toBe(`/en/bar`)
})
})

0 comments on commit a9947c0

Please sign in to comment.