From 28d3649e2dba741345e1390148579ef422b02850 Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 1 May 2026 02:00:25 +0200 Subject: [PATCH 1/5] refactor(cloud-function): extract getLocalesWithPath into utils --- cloud-function/src/middlewares/redirect-locale.js | 14 +------------- cloud-function/src/utils.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cloud-function/src/middlewares/redirect-locale.js b/cloud-function/src/middlewares/redirect-locale.js index 6fb43916..62fc2505 100644 --- a/cloud-function/src/middlewares/redirect-locale.js +++ b/cloud-function/src/middlewares/redirect-locale.js @@ -2,23 +2,11 @@ import { getLocale } from "../internal/locale-utils/index.js"; import { VALID_LOCALES } from "../internal/constants/index.js"; -import { redirect, normalizePath } from "../utils.js"; -import { CANONICALS } from "../canonicals.js"; +import { getLocalesWithPath, redirect } from "../utils.js"; const NEEDS_LOCALE = /^\/(?:blog|curriculum|docs|play|search|settings|plus)(?:$|\/)/; -/** - * Finds all locales where a given path (without locale) is available. - * @param {string} path - The path without locale prefix (e.g., "/docs/Web/API") - * @returns {string[]} Array of locale codes where the page exists - */ -function getLocalesWithPath(path) { - return [...VALID_LOCALES.values()].filter( - (locale) => CANONICALS[normalizePath(`/${locale}${path}`)] - ); -} - /** * Middleware that handles locale-related redirects. * Inserts missing locales and corrects locale casing. diff --git a/cloud-function/src/utils.js b/cloud-function/src/utils.js index 82b4eb56..ed7e83bf 100644 --- a/cloud-function/src/utils.js +++ b/cloud-function/src/utils.js @@ -3,8 +3,10 @@ import { ANY_ATTACHMENT_EXT, createRegExpFromExtensions, + VALID_LOCALES, } from "./internal/constants/index.js"; +import { CANONICALS } from "./canonicals.js"; import { DEFAULT_COUNTRY } from "./constants.js"; /** @@ -92,3 +94,14 @@ export function isAsset(url) { export function normalizePath(path) { return path.toLowerCase().replace(/\/$/, ""); } + +/** + * Finds all locales where a given path (without locale) is available. + * @param {string} path - The path without locale prefix (e.g., "/docs/Web/API") + * @returns {string[]} Array of locale codes where the page exists + */ +export function getLocalesWithPath(path) { + return [...VALID_LOCALES.values()].filter( + (locale) => CANONICALS[normalizePath(`/${locale}${path}`)] + ); +} From 65ce5faa88bfef7f3cdb6d14683bee35c9e5dbe2 Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 1 May 2026 02:00:56 +0200 Subject: [PATCH 2/5] feat(cloud-function): add redirect-locale-fallback middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses CANONICALS to insert missing locales (e.g. /about → /en-US/about), fall back to en-US when the requested locale lacks a translation, and recover from unsupported locale prefixes when the rest exists in en-US. --- .../middlewares/redirect-locale-fallback.js | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 cloud-function/src/middlewares/redirect-locale-fallback.js diff --git a/cloud-function/src/middlewares/redirect-locale-fallback.js b/cloud-function/src/middlewares/redirect-locale-fallback.js new file mode 100644 index 00000000..8d67a781 --- /dev/null +++ b/cloud-function/src/middlewares/redirect-locale-fallback.js @@ -0,0 +1,69 @@ +/** @import { NextFunction, Request, Response } from "express" */ + +import { CANONICALS } from "../canonicals.js"; +import { DEFAULT_LOCALE, VALID_LOCALES } from "../internal/constants/index.js"; +import { getLocale } from "../internal/locale-utils/index.js"; +import { getLocalesWithPath, normalizePath, redirect } from "../utils.js"; + +/** + * Middleware that uses CANONICALS to redirect requests where the locale is + * missing or where the requested locale doesn't have the page but en-US does. + * + * Runs after `redirectLocale`, which has already covered casing fixes and the + * `NEEDS_LOCALE` allowlist of dynamic prefixes (`/search`, `/settings`, etc.). + * + * @param {Request} req - Express request + * @param {Response} res - Express response + * @param {NextFunction} next - Express next function + * @returns {Promise} + */ +export async function redirectLocaleFallback(req, res, next) { + const url = new URL(req.url, `${req.protocol}://${req.headers.host}`); + const requestURI = url.pathname; + const qs = url.search; + + const uriParts = requestURI.split("/"); + const uriFirstPart = uriParts[1] ?? ""; + const rest = uriParts.slice(2).filter(Boolean).join("/"); + + // Step 1: first segment is a valid locale. + if (VALID_LOCALES.has(uriFirstPart.toLowerCase())) { + if (CANONICALS[normalizePath(requestURI)]) { + return next(); + } + if (uriFirstPart !== DEFAULT_LOCALE) { + const enCanonical = + CANONICALS[normalizePath(`/${DEFAULT_LOCALE}/${rest}`)]; + if (enCanonical) { + return redirect(res, enCanonical + qs); + } + } + return next(); + } + + // Step 2: first segment is not a valid locale. + + // 2.a: treat first segment as an unsupported locale (only when there's a rest). + if (rest) { + const enCanonical = CANONICALS[normalizePath(`/${DEFAULT_LOCALE}/${rest}`)]; + if (enCanonical) { + return redirect(res, enCanonical + qs); + } + } + + // 2.b: treat the whole path as a slug missing its locale prefix. + const path = requestURI.replace(/\/$/, "") || "/"; + const fullEnCanonical = + CANONICALS[normalizePath(`/${DEFAULT_LOCALE}${path === "/" ? "" : path}`)]; + if (fullEnCanonical) { + const locales = getLocalesWithPath(path); + const locale = getLocale(req, { + locales: locales.length > 0 ? locales : VALID_LOCALES, + }); + const target = + CANONICALS[normalizePath(`/${locale}${path}`)] ?? fullEnCanonical; + return redirect(res, target + qs); + } + + next(); +} From cd5e62932d19f6ac99465108aaee1ee30b786adc Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 1 May 2026 02:01:05 +0200 Subject: [PATCH 3/5] feat(cloud-function): wire redirect-locale-fallback into routes --- cloud-function/src/app.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cloud-function/src/app.js b/cloud-function/src/app.js index 9f89f975..201b2acf 100644 --- a/cloud-function/src/app.js +++ b/cloud-function/src/app.js @@ -18,6 +18,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 { redirectLocaleFallback } from "./middlewares/redirect-locale-fallback.js"; import { redirectPreferredLocale } from "./middlewares/redirect-preferred-locale.js"; import { redirectTrailingSlash } from "./middlewares/redirect-trailing-slash.js"; import { requireOrigin } from "./middlewares/require-origin.js"; @@ -116,6 +117,7 @@ router.get( requireOrigin(Origin.main), redirectFundamental, redirectLocale, + redirectLocaleFallback, redirectPreferredLocale, redirectTrailingSlash, redirectMovedPages, @@ -136,6 +138,7 @@ router.get( requireOrigin(Origin.main), redirectFundamental, redirectLocale, + redirectLocaleFallback, redirectPreferredLocale, redirectTrailingSlash, resolveIndexHTML, From c77d53c24a7cc234337c65c319d7474ea0245958 Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Fri, 1 May 2026 02:01:10 +0200 Subject: [PATCH 4/5] test(cloud-function): cover canonicals-aware locale redirects --- cloud-function/src/app.test.js | 158 ++++++++++++++++++++ cloud-function/src/fixtures/canonicals.json | 3 + 2 files changed, 161 insertions(+) diff --git a/cloud-function/src/app.test.js b/cloud-function/src/app.test.js index 46cb92a9..9f0fc7aa 100644 --- a/cloud-function/src/app.test.js +++ b/cloud-function/src/app.test.js @@ -190,4 +190,162 @@ describe("mdnHandler", () => { strictEqual(res.statusCode, 200); }); }); + + describe("missing locale (canonical-aware fallback)", () => { + it("redirects /about to /en-US/about (issue #279)", async () => { + const req = createRequest({ + method: "GET", + url: "/about", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/about"); + }); + + it("redirects /community to /en-US/community", async () => { + const req = createRequest({ + method: "GET", + url: "/community", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/community"); + }); + + it("redirects /advertising to /en-US/advertising", async () => { + const req = createRequest({ + method: "GET", + url: "/advertising", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/advertising"); + }); + + it("preserves query string when inserting locale", async () => { + const req = createRequest({ + method: "GET", + url: "/about?foo=bar", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/about?foo=bar"); + }); + + it("uses Accept-Language to pick best locale when multiple are available", async () => { + const req = createRequest({ + method: "GET", + url: "/docs/Web", + hostname: "localhost", + headers: { host: "localhost", "accept-language": "fr" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/fr/docs/Web"); + }); + + it("falls back to en-US when preferredlocale has no translation", async () => { + const req = createRequest({ + method: "GET", + url: "/about", + hostname: "localhost", + headers: { host: "localhost" }, + cookies: { preferredlocale: "de" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/about"); + }); + }); + + describe("locale fallback to en-US", () => { + it("redirects /de/about to /en-US/about when de translation is missing", async () => { + const req = createRequest({ + method: "GET", + url: "/de/about", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/about"); + }); + + it("does not fall back from en-US to itself for missing pages", async () => { + const req = createRequest({ + method: "GET", + url: "/en-US/non-existent-page", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 200); + }); + + it("does not redirect when neither current locale nor en-US has the page", async () => { + const req = createRequest({ + method: "GET", + url: "/de/non-existent-page", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 200); + }); + }); + + describe("unsupported locale recovery", () => { + it("redirects /xy/about to /en-US/about (treats xy as bogus locale)", async () => { + const req = createRequest({ + method: "GET", + url: "/xy/about", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 302); + strictEqual(res._getRedirectUrl(), "/en-US/about"); + }); + + it("does not redirect /xy/non-existent (no canonical match in either form)", async () => { + const req = createRequest({ + method: "GET", + url: "/xy/non-existent", + hostname: "localhost", + headers: { host: "localhost" }, + }); + const res = createResponse(); + mdnHandler(req, res); + + strictEqual(res.statusCode, 200); + }); + }); }); diff --git a/cloud-function/src/fixtures/canonicals.json b/cloud-function/src/fixtures/canonicals.json index 6a8cd034..b837d7a6 100644 --- a/cloud-function/src/fixtures/canonicals.json +++ b/cloud-function/src/fixtures/canonicals.json @@ -1,6 +1,9 @@ { "/en-us": "/en-US/", + "/en-us/about": "/en-US/about", + "/en-us/advertising": "/en-US/advertising", "/en-us/blog": "/en-US/blog/", + "/en-us/community": "/en-US/community", "/en-us/docs/web": "/en-US/docs/Web", "/en-us/docs/web/api": "/en-US/docs/Web/API", "/fr": "/fr/", From 1fb5b854ae424f9c7fdc3f2a8c278dafca941516 Mon Sep 17 00:00:00 2001 From: Claas Augner Date: Mon, 11 May 2026 15:30:17 +0200 Subject: [PATCH 5/5] docs(cloud-function): clarify redirect-locale-fallback comments --- .../src/middlewares/redirect-locale-fallback.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cloud-function/src/middlewares/redirect-locale-fallback.js b/cloud-function/src/middlewares/redirect-locale-fallback.js index 8d67a781..5bde0600 100644 --- a/cloud-function/src/middlewares/redirect-locale-fallback.js +++ b/cloud-function/src/middlewares/redirect-locale-fallback.js @@ -26,12 +26,14 @@ export async function redirectLocaleFallback(req, res, next) { const uriFirstPart = uriParts[1] ?? ""; const rest = uriParts.slice(2).filter(Boolean).join("/"); - // Step 1: first segment is a valid locale. + // Case 1: first segment is a valid locale (e.g. `/de/about`, `/fr/docs/Web`). if (VALID_LOCALES.has(uriFirstPart.toLowerCase())) { if (CANONICALS[normalizePath(requestURI)]) { return next(); } if (uriFirstPart !== DEFAULT_LOCALE) { + // Page missing in this locale; fall back to en-US. + // E.g. `/de/about` -> `/en-US/about`. const enCanonical = CANONICALS[normalizePath(`/${DEFAULT_LOCALE}/${rest}`)]; if (enCanonical) { @@ -41,9 +43,10 @@ export async function redirectLocaleFallback(req, res, next) { return next(); } - // Step 2: first segment is not a valid locale. + // Case 2: first segment is not a valid locale. - // 2.a: treat first segment as an unsupported locale (only when there's a rest). + // Case 2.a: treat first segment as an unsupported locale (only when there's + // a rest). E.g. `/xy/docs/Web` -> `/en-US/docs/Web`. if (rest) { const enCanonical = CANONICALS[normalizePath(`/${DEFAULT_LOCALE}/${rest}`)]; if (enCanonical) { @@ -51,7 +54,8 @@ export async function redirectLocaleFallback(req, res, next) { } } - // 2.b: treat the whole path as a slug missing its locale prefix. + // Case 2.b: treat the whole path as a slug missing its locale prefix. + // E.g. `/about` -> `/en-US/about`. const path = requestURI.replace(/\/$/, "") || "/"; const fullEnCanonical = CANONICALS[normalizePath(`/${DEFAULT_LOCALE}${path === "/" ? "" : path}`)];