Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cloud-function/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -116,6 +117,7 @@ router.get(
requireOrigin(Origin.main),
redirectFundamental,
redirectLocale,
redirectLocaleFallback,
redirectPreferredLocale,
redirectTrailingSlash,
redirectMovedPages,
Expand All @@ -136,6 +138,7 @@ router.get(
requireOrigin(Origin.main),
redirectFundamental,
redirectLocale,
redirectLocaleFallback,
redirectPreferredLocale,
redirectTrailingSlash,
resolveIndexHTML,
Expand Down
158 changes: 158 additions & 0 deletions cloud-function/src/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
3 changes: 3 additions & 0 deletions cloud-function/src/fixtures/canonicals.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand Down
73 changes: 73 additions & 0 deletions cloud-function/src/middlewares/redirect-locale-fallback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** @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<void>}
*/
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("/");

// 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) {
return redirect(res, enCanonical + qs);
}
}
return next();
}

// Case 2: first segment is not a valid locale.

// 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) {
return redirect(res, enCanonical + qs);
}
}

// 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}`)];
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();
}
14 changes: 1 addition & 13 deletions cloud-function/src/middlewares/redirect-locale.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions cloud-function/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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}`)]
);
}
Loading