diff --git a/client/src/telemetry/constants.ts b/client/src/telemetry/constants.ts index 18042dcd2cd4..cf6f586363fd 100644 --- a/client/src/telemetry/constants.ts +++ b/client/src/telemetry/constants.ts @@ -82,5 +82,6 @@ export const BASELINE = Object.freeze({ export const CLIENT_SIDE_NAVIGATION = "client_side_nav"; export const LANGUAGE = "language"; +export const LANGUAGE_REMEMBER = "language_remember"; export const THEME_SWITCHER = "theme_switcher"; export const SURVEY = "survey"; diff --git a/client/src/ui/organisms/article-actions/language-menu/index.scss b/client/src/ui/organisms/article-actions/language-menu/index.scss index 8454294cade8..e36fad565684 100644 --- a/client/src/ui/organisms/article-actions/language-menu/index.scss +++ b/client/src/ui/organisms/article-actions/language-menu/index.scss @@ -7,8 +7,44 @@ } .language-menu { + li { + &:not(:first-child) { + padding-top: 1px; + } + + &:not(:last-child) { + padding-bottom: 1px; + } + } + .submenu-item { - padding: 0.5rem; + // Reduce padding compared to other menus. + padding: 0.5rem !important; + + &.locale-redirect-setting { + border-bottom: 1px solid var(--border-secondary) !important; + border-radius: 0 !important; + display: block; + font-size: var(--type-tiny-font-size); + + &:hover { + background-color: unset; + } + + .switch { + display: flex; + } + + .glean-thumbs { + font-style: italic; + font-variation-settings: "slnt" -10; + margin-top: 0.5em; + + .icon { + margin-right: unset; + } + } + } } @media (min-width: $screen-md) { diff --git a/client/src/ui/organisms/article-actions/language-menu/index.tsx b/client/src/ui/organisms/article-actions/language-menu/index.tsx index 755527d8f77b..9cbf25a009b6 100644 --- a/client/src/ui/organisms/article-actions/language-menu/index.tsx +++ b/client/src/ui/organisms/article-actions/language-menu/index.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; import { useGleanClick } from "../../../../telemetry/glean-context"; @@ -9,10 +9,18 @@ import { Submenu } from "../../../molecules/submenu"; import "./index.scss"; import { DropdownMenu, DropdownMenuWrapper } from "../../../molecules/dropdown"; import { useLocale } from "../../../../hooks"; -import { LANGUAGE } from "../../../../telemetry/constants"; +import { LANGUAGE, LANGUAGE_REMEMBER } from "../../../../telemetry/constants"; +import { + deleteCookie, + getCookieValue, + setCookieValue, +} from "../../../../utils"; +import { GleanThumbs } from "../../../atoms/thumbs"; +import { Switch } from "../../../atoms/switch"; // This needs to match what's set in 'libs/constants.js' on the server/builder! const PREFERRED_LOCALE_COOKIE_NAME = "preferredlocale"; +const PREFERRED_LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 3; // 3 years. export function LanguageMenu({ onClose, @@ -29,51 +37,44 @@ export function LanguageMenu({ const [isOpen, setIsOpen] = useState(false); const changeLocale: React.MouseEventHandler = (event) => { - const preferredLocale = event.currentTarget.dataset.locale; + const newLocale = event.currentTarget.dataset.locale; // The default is the current locale itself. If that's what's chosen, // don't bother redirecting. - if (preferredLocale !== locale) { - let cookieValueBefore = document.cookie - .split("; ") - .find((row) => row.startsWith(`${PREFERRED_LOCALE_COOKIE_NAME}=`)); - if (cookieValueBefore && cookieValueBefore.includes("=")) { - cookieValueBefore = cookieValueBefore.split("=")[1]; - } + if (newLocale !== locale) { + const oldLocale = getCookieValue(PREFERRED_LOCALE_COOKIE_NAME); - for (const translation of translations) { - if (translation.locale === preferredLocale) { - let cookieValue = `${PREFERRED_LOCALE_COOKIE_NAME}=${ - translation.locale - };max-age=${60 * 60 * 24 * 365 * 3};path=/`; - if ( - !( - document.location.hostname === "localhost" || - document.location.hostname === "localhost.org" - ) - ) { - cookieValue += ";secure"; + if (oldLocale === locale) { + for (const translation of translations) { + if (translation.locale === newLocale) { + setCookieValue(PREFERRED_LOCALE_COOKIE_NAME, newLocale, { + maxAge: PREFERRED_LOCALE_COOKIE_MAX_AGE, + }); + gleanClick(`${LANGUAGE_REMEMBER}: ${oldLocale} -> ${newLocale}`); } - document.cookie = cookieValue; } } - const oldValue = cookieValueBefore || "none"; - gleanClick(`${LANGUAGE}: ${oldValue} -> ${preferredLocale}`); + gleanClick(`${LANGUAGE}: ${locale} -> ${newLocale}`); } }; const menuEntry = { label: "Languages", id: menuId, - items: translations.map((translation) => ({ - component: () => ( - - ), - })), + items: [ + { + component: () => , + }, + ...translations.map((translation) => ({ + component: () => ( + + ), + })), + ], }; return ( @@ -127,3 +128,42 @@ function LanguageMenuItem({ ); } + +function LocaleRedirectSetting() { + const gleanClick = useGleanClick(); + const locale = useLocale(); + const [preferredLocale, setPreferredLocale] = useState(); + + useEffect(() => { + setPreferredLocale(getCookieValue(PREFERRED_LOCALE_COOKIE_NAME)); + }, []); + + function toggle(event) { + const oldValue = getCookieValue(PREFERRED_LOCALE_COOKIE_NAME); + const newValue = event.target.checked; + if (newValue) { + setCookieValue(PREFERRED_LOCALE_COOKIE_NAME, locale, { + maxAge: 60 * 60 * 24 * 365 * 3, + }); + setPreferredLocale(locale); + gleanClick(`${LANGUAGE_REMEMBER}: ${oldValue} -> ${locale}`); + } else { + deleteCookie(PREFERRED_LOCALE_COOKIE_NAME); + setPreferredLocale(undefined); + gleanClick(`${LANGUAGE_REMEMBER}: ${oldValue} -> 0`); + } + } + + return ( +
+ + Remember language + + + + ); +} diff --git a/client/src/utils.ts b/client/src/utils.ts index 1105e08da90f..c1917d58d406 100644 --- a/client/src/utils.ts +++ b/client/src/utils.ts @@ -136,3 +136,41 @@ export function splitQuery(term: string): string[] { return term.split(/[ ,.]+/); } } + +export function getCookieValue(name: string) { + let value = document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)); + + if (value && value.includes("=")) { + value = value.split("=")[1]; + } + + return value; +} + +export function setCookieValue( + name: string, + value: string, + { + expires, + maxAge, + path = "/", + }: { expires?: Date; maxAge?: number; path?: string } +) { + const cookieValue = [ + `${name}=${value}`, + expires && `expires=${expires.toUTCString()}`, + maxAge && `max-age=${maxAge}`, + `path=${path}`, + document.location.hostname !== "localhost" && "secure", + ] + .filter(Boolean) + .join(";"); + + document.cookie = cookieValue; +} + +export function deleteCookie(name: string) { + setCookieValue(name, "", { expires: new Date(0) }); +} diff --git a/cloud-function/package-lock.json b/cloud-function/package-lock.json index f30014e60228..86e818501942 100644 --- a/cloud-function/package-lock.json +++ b/cloud-function/package-lock.json @@ -18,6 +18,7 @@ "@yari-internal/pong": "file:src/internal/pong", "@yari-internal/slug-utils": "file:src/internal/slug-utils", "accept-language-parser": "^1.5.0", + "cookie-parser": "^1.4.6", "dotenv": "^16.0.3", "express": "^4.19.2", "http-proxy-middleware": "^3.0.0", @@ -26,6 +27,7 @@ "devDependencies": { "@swc/core": "^1.3.38", "@types/accept-language-parser": "^1.5.3", + "@types/cookie-parser": "^1.4.7", "@types/http-proxy": "^1.17.10", "@types/http-server": "^0.12.1", "cross-env": "^7.0.3", @@ -945,6 +947,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -1471,6 +1483,28 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "license": "MIT", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/cloud-function/package.json b/cloud-function/package.json index ffa641886b73..577949b72cce 100644 --- a/cloud-function/package.json +++ b/cloud-function/package.json @@ -36,6 +36,7 @@ "@yari-internal/pong": "file:src/internal/pong", "@yari-internal/slug-utils": "file:src/internal/slug-utils", "accept-language-parser": "^1.5.0", + "cookie-parser": "^1.4.6", "dotenv": "^16.0.3", "express": "^4.19.2", "http-proxy-middleware": "^3.0.0", @@ -44,6 +45,7 @@ "devDependencies": { "@swc/core": "^1.3.38", "@types/accept-language-parser": "^1.5.3", + "@types/cookie-parser": "^1.4.7", "@types/http-proxy": "^1.17.10", "@types/http-server": "^0.12.1", "cross-env": "^7.0.3", diff --git a/cloud-function/src/app.ts b/cloud-function/src/app.ts index 1f7221f70d4f..7cc61f1cd59c 100644 --- a/cloud-function/src/app.ts +++ b/cloud-function/src/app.ts @@ -1,3 +1,4 @@ +import cookieParser from "cookie-parser"; import express, { Request, Response } from "express"; import { Router } from "express"; @@ -16,6 +17,7 @@ import { redirectMovedPages } from "./middlewares/redirect-moved-pages.js"; import { redirectEnforceTrailingSlash } from "./middlewares/redirect-enforce-trailing-slash.js"; import { redirectFundamental } from "./middlewares/redirect-fundamental.js"; import { redirectLocale } from "./middlewares/redirect-locale.js"; +import { redirectPreferredLocale } from "./middlewares/redirect-preferred-locale.js"; import { redirectTrailingSlash } from "./middlewares/redirect-trailing-slash.js"; import { requireOrigin } from "./middlewares/require-origin.js"; import { notFound } from "./middlewares/not-found.js"; @@ -25,6 +27,7 @@ import { stripForwardedHostHeaders } from "./middlewares/stripForwardedHostHeade import { proxyPong } from "./handlers/proxy-pong.js"; const router = Router(); +router.use(cookieParser()); router.use(stripForwardedHostHeaders); router.use(redirectLeadingSlash); // MDN Plus plans. @@ -85,6 +88,7 @@ router.get( requireOrigin(Origin.main), redirectFundamental, redirectLocale, + redirectPreferredLocale, redirectTrailingSlash, redirectMovedPages, resolveIndexHTML, diff --git a/cloud-function/src/canonicals.ts b/cloud-function/src/canonicals.ts new file mode 100644 index 000000000000..92c5b7ec5ba8 --- /dev/null +++ b/cloud-function/src/canonicals.ts @@ -0,0 +1,5 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +export const CANONICALS = require("../canonicals.json"); diff --git a/cloud-function/src/middlewares/redirect-non-canonicals.ts b/cloud-function/src/middlewares/redirect-non-canonicals.ts index 0c8f1d3eb1b3..57390983bdad 100644 --- a/cloud-function/src/middlewares/redirect-non-canonicals.ts +++ b/cloud-function/src/middlewares/redirect-non-canonicals.ts @@ -1,12 +1,9 @@ -import { createRequire } from "node:module"; - import { NextFunction, Request, Response } from "express"; import { THIRTY_DAYS } from "../constants.js"; import { normalizePath, redirect } from "../utils.js"; +import { CANONICALS } from "../canonicals.js"; -const require = createRequire(import.meta.url); -const REDIRECTS = require("../../canonicals.json"); const REDIRECT_SUFFIXES = ["/index.json", "/bcd.json", ""]; export async function redirectNonCanonicals( @@ -31,10 +28,10 @@ export async function redirectNonCanonicals( ); const source = normalizePath(originalSource); if ( - typeof REDIRECTS[source] == "string" && - REDIRECTS[source] !== originalSource + typeof CANONICALS[source] == "string" && + CANONICALS[source] !== originalSource ) { - const target = joinPath(REDIRECTS[source], suffix) + parsedUrl.search; + const target = joinPath(CANONICALS[source], suffix) + parsedUrl.search; if (pathname !== target) { return redirect(res, target, { status: 301, diff --git a/cloud-function/src/middlewares/redirect-preferred-locale.ts b/cloud-function/src/middlewares/redirect-preferred-locale.ts new file mode 100644 index 000000000000..40a236014cae --- /dev/null +++ b/cloud-function/src/middlewares/redirect-preferred-locale.ts @@ -0,0 +1,47 @@ +import { NextFunction, Request, Response } from "express"; +import { normalizePath, redirect } from "../utils.js"; +import { CANONICALS } from "../canonicals.js"; + +export async function redirectPreferredLocale( + req: Request, + res: Response, + next: NextFunction +) { + // Check 1: Does the user prefer a locale and has redirect enabled? + + const preferredLocale = req.cookies["preferredlocale"]; + + if (!preferredLocale) { + next(); + return; + } + + // Check 2: Does the target have a different locale? + + const target = new URL(req.url, `${req.protocol}://${req.headers.host}`); + const targetPathname = target.pathname; + const [targetLocale, targetSlug] = localeAndSlugOf(target); + + if (targetLocale.toLowerCase() === preferredLocale.toLowerCase()) { + next(); + return; + } + + // Check 3: Does the target exist in the preferred locale? + + const preferredPathname = + CANONICALS[normalizePath(`/${preferredLocale}/${targetSlug}`)] ?? null; + if (preferredPathname && preferredPathname !== targetPathname) { + const location = preferredPathname + target.search; + return redirect(res, location); + } + + next(); +} + +function localeAndSlugOf(url: URL): [string, string] { + const locale = url.pathname.split("/").at(1) || ""; + const slug = url.pathname.split("/").slice(2).join("/"); + + return [locale, slug]; +}