diff --git a/bedrock/base/context_processors.py b/bedrock/base/context_processors.py
index 0221762a436..ca57892ae81 100644
--- a/bedrock/base/context_processors.py
+++ b/bedrock/base/context_processors.py
@@ -13,9 +13,19 @@ def geo(request):
def i18n(request):
+ url_locale = translation.get_language()
+ lang = dict(settings.LANGUAGE_URL_MAP).get(url_locale) or url_locale
+ # Normally, CANONICAL_LANG == LANG, but sometimes, a user requests a page
+ # that does not exist, but the locale has a fallback locale, so the user is
+ # served the content from the fallback locale at the requested URL (for
+ # example, the user requests /pt-PT/somepage, which does not exist, so the
+ # user gets /pt-BR/somepage content at the /pt-PT/somepage/ URL). In this
+ # case, pt-PT is the LANG, and pt-BR is the CANONICAL_LANG.
+ content_locale = getattr(request, "content_locale", None)
return {
"LANGUAGES": settings.LANGUAGES,
- "LANG": (dict(settings.LANGUAGE_URL_MAP).get(translation.get_language()) or translation.get_language()),
+ "LANG": lang,
+ "CANONICAL_LANG": content_locale or lang,
"DIR": "rtl" if translation.get_language_bidi() else "ltr",
}
diff --git a/bedrock/base/templates/includes/canonical-url.html b/bedrock/base/templates/includes/canonical-url.html
index 44996f1710b..44476e632a9 100644
--- a/bedrock/base/templates/includes/canonical-url.html
+++ b/bedrock/base/templates/includes/canonical-url.html
@@ -5,11 +5,19 @@
#}
{%- set available_languages = get_locale_options(request, translations) -%}
+{%- set alias_locales = settings.FALLBACK_LOCALES|default({}) -%}
-
+
+ {%- if CANONICAL_LANG != LANG %}
+
+ {%- endif %}
{% if is_homepage %}{% endif %}
{% if available_languages -%}
{%- for code, label in available_languages|dictsort -%}
+ {#- Skip alias locales that don't have their own content -#}
+ {%- if code in alias_locales and content_locales is defined and code not in content_locales -%}
+ {%- continue -%}
+ {%- endif -%}
{%- set alt_url = alternate_url(canonical_path, code) -%}
{%- if alt_url -%}
{%- set loop_canonical_path = alt_url -%}
@@ -38,7 +46,6 @@
{% elif code == 'pt-PT' -%}
-
{% elif code == 'sv-SE' -%}
@@ -46,6 +53,9 @@
{% elif code == 'zh-CN' -%}
+ {% elif code == 'pt-BR' -%}
+
+
{% elif code|length != 3 -%}{#- Bug 1364470: Drop ISO 639-2 and -3 locales not supported by Google -#}
{% endif -%}
diff --git a/bedrock/base/templatetags/helpers.py b/bedrock/base/templatetags/helpers.py
index 648e13c13fa..7cf7aa37a95 100644
--- a/bedrock/base/templatetags/helpers.py
+++ b/bedrock/base/templatetags/helpers.py
@@ -183,6 +183,19 @@ def get_locale_options(request, translations):
if cms_locale_count > 0 and django_fallback_locale_count > 0:
available_locales = get_translations_native_names(sorted(set(request._locales_available_via_cms + request._locales_for_django_fallback_view)))
+ # For pure Fluent pages, translations only reflects FTL-active locales and does not
+ # include alias locales. Add alias locales whose fallback canonical locale is already
+ # present. (CMS pages are already handled upstream by get_locales_for_cms_page().)
+ alias_additions = get_translations_native_names(
+ [
+ alias_code
+ for alias_code, fallback_code in getattr(settings, "FALLBACK_LOCALES", {}).items()
+ if fallback_code in available_locales and alias_code not in available_locales
+ ]
+ )
+ if alias_additions:
+ available_locales = {**available_locales, **alias_additions}
+
return available_locales
diff --git a/bedrock/base/tests/test_context_processors.py b/bedrock/base/tests/test_context_processors.py
index 9b4bb0db9b2..c5386433186 100644
--- a/bedrock/base/tests/test_context_processors.py
+++ b/bedrock/base/tests/test_context_processors.py
@@ -8,6 +8,7 @@
import jinja2
+from bedrock.base.context_processors import i18n
from lib.l10n_utils import translation
@@ -69,3 +70,20 @@ def test_invalid_geo_param(self):
req = self.factory.get("/", data={"geo": "france"})
assert self.render("{{ country_code }}", req) == "None"
+
+ def test_canonical_lang_equals_lang_normally(self):
+ """CANONICAL_LANG should equal LANG when there is no content_locale on the request."""
+ translation.activate("fr")
+ req = self.factory.get("/fr/page/")
+ ctx = i18n(req)
+ assert ctx["LANG"] == "fr"
+ assert ctx["CANONICAL_LANG"] == "fr"
+
+ def test_canonical_lang_uses_content_locale(self):
+ """CANONICAL_LANG uses request.content_locale when set (alias locale serving)."""
+ translation.activate("pt-PT")
+ req = self.factory.get("/pt-PT/page/")
+ req.content_locale = "pt-BR"
+ ctx = i18n(req)
+ assert ctx["LANG"] == "pt-PT"
+ assert ctx["CANONICAL_LANG"] == "pt-BR"
diff --git a/bedrock/base/tests/test_helpers.py b/bedrock/base/tests/test_helpers.py
index 7b1eefc8775..a63448638b6 100644
--- a/bedrock/base/tests/test_helpers.py
+++ b/bedrock/base/tests/test_helpers.py
@@ -112,6 +112,7 @@ def test_switch():
waffle.switch.assert_called_with("dude")
+@override_settings(FALLBACK_LOCALES={})
@pytest.mark.parametrize(
"translations_locales, cms_locales, django_locales, expected",
(
@@ -159,3 +160,49 @@ def test_get_locale_options(rf, translations_locales, cms_locales, django_locale
request=request,
translations=native_translations,
)
+
+
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES", "es-CL": "es-ES"})
+def test_get_locale_options_adds_alias_locales_when_fallback_present(rf):
+ """Alias locales whose fallback is in translations are added to the language picker.
+
+ For pure Fluent pages, translations only reflects FTL-active locales. When a
+ fallback locale (e.g. es-ES) is present, its aliases (es-AR, es-CL) must also
+ appear so the language picker offers them to users browsing those locales.
+ """
+ request = rf.get("/dummy/path/")
+ translations = get_translations_native_names(["en-US", "es-ES", "fr"])
+
+ result = helpers.get_locale_options(request=request, translations=translations)
+
+ assert "es-AR" in result
+ assert "es-CL" in result
+ assert "en-US" in result
+ assert "es-ES" in result
+ assert "fr" in result
+
+
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_get_locale_options_does_not_add_alias_when_fallback_absent(rf):
+ """Alias locales are not added when their fallback locale is not in translations."""
+ request = rf.get("/dummy/path/")
+ translations = get_translations_native_names(["en-US", "fr"]) # no es-ES
+
+ result = helpers.get_locale_options(request=request, translations=translations)
+
+ assert "es-AR" not in result
+
+
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_get_locale_options_does_not_double_add_alias_already_present(rf):
+ """Alias locales are not duplicated when already present (e.g. from CMS path)."""
+ # Simulate CMS page where get_locales_for_cms_page() already added es-AR.
+ translations_locales = ["en-US", "es-ES", "es-AR"]
+ native_translations = get_translations_native_names(translations_locales)
+ request = rf.get("/dummy/path/")
+
+ result = helpers.get_locale_options(request=request, translations=native_translations)
+
+ assert len(result) == len(translations_locales)
+ for key in translations_locales:
+ assert list(result).count(key) == 1 # no duplicate
diff --git a/bedrock/cms/decorators.py b/bedrock/cms/decorators.py
index 88f3e57d9ce..94f28d88b41 100644
--- a/bedrock/cms/decorators.py
+++ b/bedrock/cms/decorators.py
@@ -6,9 +6,8 @@
from django.http import Http404
-from wagtail.views import serve as wagtail_serve
-
from bedrock.base.i18n import remove_lang_prefix
+from bedrock.cms.views import wagtail_serve_with_locale_fallback
from lib.l10n_utils.fluent import get_active_locales
from .utils import get_cms_locales_for_path
@@ -142,8 +141,10 @@ def wrapped_view(request, *args, **kwargs):
)
try:
- # Does Wagtail have a route that matches this? If so, show that page
- wagtail_response = wagtail_serve(request, path)
+ # Does Wagtail have a route that matches this? If so, show that page.
+ # wagtail_serve_with_locale_fallback handles alias-locale
+ # transparent serving before deferring to Wagtail's own serve.
+ wagtail_response = wagtail_serve_with_locale_fallback(request, path)
if wagtail_response.status_code == HTTP_200_OK:
return wagtail_response
except Http404:
@@ -152,15 +153,15 @@ def wrapped_view(request, *args, **kwargs):
# If the page does not exist in Wagtail, call the original view function and...
#
# 1) Un-mark this request as being for a CMS page (which happened
- # via wagtail_serve()) to avoid lib.l10n_utils.render() incorrectly
- # looking for available translations based on CMS data, rather than
- # Fluent files
+ # via wagtail_serve_with_locale_fallback()) to avoid lib.l10n_utils.render()
+ # incorrectly looking for available translations based on CMS data, rather
+ # than Fluent files
request.is_cms_page = False
# 2) Make extra sure this request is still annotated with any CMS-backed
# locale versions that are available, so that we can populate the
# language picker appropriately. (The annotation also happened via
- # wagtail_serve() thanks to AbstractBedrockCMSPage._patch_request_for_bedrock
+ # wagtail_serve_with_locale_fallback() thanks to AbstractBedrockCMSPage._patch_request_for_bedrock
request._locales_available_via_cms = getattr(
request,
"_locales_available_via_cms",
diff --git a/bedrock/cms/migrations/0005_create_alias_locale_records.py b/bedrock/cms/migrations/0005_create_alias_locale_records.py
new file mode 100644
index 00000000000..256ee2cf532
--- /dev/null
+++ b/bedrock/cms/migrations/0005_create_alias_locale_records.py
@@ -0,0 +1,106 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+import sys
+
+from django.db import migrations
+
+from bedrock.base.config_manager import config
+
+
+def create_alias_locales(apps, schema_editor):
+ # Skip in test environments — test fixtures create the locale records they need.
+ if "pytest" in sys.modules or config("SQLITE_EXPORT_MODE", parser=bool, default="false"):
+ return
+
+ from wagtail.models import Locale, Page, Site
+
+ alias_locales = ["es-AR", "es-CL", "es-MX", "pt-PT", "en-GB", "en-CA"]
+
+ site = Site.objects.filter(is_default_site=True).select_related("root_page").first()
+ if not site:
+ return
+
+ # The locale root pages live at depth 2 (children of the Wagtail tree
+ # root at depth 1). Note: site.root_page is the *homepage* (depth 3),
+ # not the locale root — using it would place new pages one level too deep.
+ en_us_locale_root = Page.objects.filter(
+ depth=2,
+ locale__language_code="en-US",
+ ).first()
+ if not en_us_locale_root:
+ return
+ wagtail_root = en_us_locale_root.get_parent()
+
+ for code in alias_locales:
+ locale, _ = Locale.objects.get_or_create(language_code=code)
+
+ if en_us_locale_root.get_translation_or_none(locale) is not None:
+ continue
+
+ # Create a non-live locale root page for the alias locale at depth 2,
+ # alongside the other locale roots (de, fr, es-ES, etc.).
+ #
+ # We use copy() rather than copy_for_translation() because the Wagtail
+ # tree root (depth 1) has no per-locale translations, which causes
+ # copy_for_translation()'s parent-translation check to fail.
+ #
+ # keep_live=False ensures Wagtail won't serve this page at
+ # // — the fallback machinery
+ # (wagtail_serve_with_locale_fallback + find_fallback_page_for_locale)
+ # handles those requests and serves the canonical locale's content.
+ # The root page is a structural record; the fallback machinery
+ # does not require it to exist.
+ en_us_locale_root.copy(
+ to=wagtail_root,
+ update_attrs={
+ "locale": locale,
+ "slug": f"home-{code}",
+ },
+ copy_revisions=False,
+ keep_live=False,
+ reset_translation_key=False,
+ log_action=None,
+ )
+
+
+def remove_alias_locales(apps, schema_editor):
+ if "pytest" in sys.modules or config("SQLITE_EXPORT_MODE", parser=bool, default="false"):
+ return
+
+ from wagtail.models import Locale, Page
+
+ alias_locales = ["es-AR", "es-CL", "es-MX", "pt-PT", "en-GB", "en-CA"]
+
+ en_us_locale_root = Page.objects.filter(
+ depth=2,
+ locale__language_code="en-US",
+ ).first()
+ if not en_us_locale_root:
+ return
+
+ for code in alias_locales:
+ locale = Locale.objects.filter(language_code=code).first()
+ if not locale:
+ continue
+
+ # Delete the locale root page only if it has no child pages
+ # (i.e. the alias locale was never promoted to a full locale with its own content).
+ alias_root = en_us_locale_root.get_translation_or_none(locale)
+ if alias_root is not None and not alias_root.get_children().exists():
+ alias_root.delete()
+
+ # Delete the Locale record if no pages remain under it.
+ if not Page.objects.filter(locale=locale).exists():
+ locale.delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("cms", "0004_bedrocklocale"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_alias_locales, remove_alias_locales),
+ ]
diff --git a/bedrock/cms/models/base.py b/bedrock/cms/models/base.py
index 085f5bc179d..b88d9dd724d 100644
--- a/bedrock/cms/models/base.py
+++ b/bedrock/cms/models/base.py
@@ -3,14 +3,16 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from django.conf import settings
+from django.utils import translation
from django.utils.cache import add_never_cache_headers
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
-from wagtail.models import Page as WagtailBasePage
+from wagtail.models import Locale, Page as WagtailBasePage
from wagtail_localize.fields import SynchronizedField
-from bedrock.cms.utils import get_locales_for_cms_page
+from bedrock.base.i18n import normalize_language
+from bedrock.cms.utils import compute_cms_page_locales
from lib import l10n_utils
@@ -48,6 +50,58 @@ class AbstractBedrockCMSPage(WagtailBasePage):
class Meta:
abstract = True
+ @property
+ def localized(self):
+ """
+ Extends Wagtail's localized to handle alias locales in FALLBACK_LOCALES.
+
+ When the active locale is an alias (e.g. pt-PT → pt-BR) and the page has
+ no translation in that alias locale, returns the fallback locale's translation
+ instead of the source-locale original.
+ """
+ localized = super().localized
+
+ lang_code = normalize_language(translation.get_language())
+
+ if localized.locale.language_code == lang_code:
+ return localized
+
+ fallback_locales = getattr(settings, "FALLBACK_LOCALES", {})
+ if lang_code in fallback_locales:
+ fallback_code = fallback_locales[lang_code]
+ try:
+ fallback_locale = Locale.objects.get(language_code=fallback_code)
+ if localized.locale_id != fallback_locale.id:
+ fallback_page = self.get_translation_or_none(fallback_locale)
+ if fallback_page:
+ return fallback_page
+ except Locale.DoesNotExist:
+ pass
+
+ return localized
+
+ def get_active_locale_url(self, request=None):
+ """
+ Returns the page URL with the locale prefix rewritten to the active
+ alias locale when serving alias locale content.
+
+ If the active locale is an alias (e.g. pt-PT → pt-BR) and this page is
+ in the fallback locale (e.g. pt-BR), swaps the prefix so the user stays
+ on their preferred alias URL rather than being sent to the canonical one.
+ host/pt-BR/page/ → host/pt-PT/page/
+ """
+ url = super().get_url(request)
+
+ active_language = normalize_language(translation.get_language())
+ fallback_locales = getattr(settings, "FALLBACK_LOCALES", {})
+
+ if active_language in fallback_locales:
+ fallback_code = fallback_locales[active_language]
+ if self.locale.language_code == fallback_code:
+ url = url.replace(f"/{fallback_code}/", f"/{active_language}/", 1)
+
+ return url
+
@classmethod
def can_create_at(cls, parent):
"""Only allow users to add new child pages that are permitted by configuration."""
@@ -62,8 +116,18 @@ def _patch_request_for_bedrock(self, request):
# Quick annotation to help us track the origin of the page
request.is_cms_page = True
- # Patch in a list of available locales for pages that are translations, not just aliases
- request._locales_available_via_cms = get_locales_for_cms_page(self)
+ # Compute locales in two sets:
+ # _locales_available_via_cms – all locales, including alias expansion from
+ # FALLBACK_LOCALES. Used for the language picker
+ # and redirect decisions.
+ # _content_locales_via_cms – only locales with real translated content (no
+ # alias expansion). Used by l10n_utils.render() to
+ # set context["content_locales"], which the template
+ # uses for hreflang filtering and noindex.
+ all_locales, content_locales = compute_cms_page_locales(self)
+ request._locales_available_via_cms = all_locales
+ request._content_locales_via_cms = content_locales
+
return request
def _render_with_fluent_string_support(self, request, *args, **kwargs):
diff --git a/bedrock/cms/models/locale.py b/bedrock/cms/models/locale.py
index 493b5f5e6e9..d54a9e2f538 100644
--- a/bedrock/cms/models/locale.py
+++ b/bedrock/cms/models/locale.py
@@ -60,11 +60,20 @@ def get_active(cls):
logger.warning(f"[BedrockLocale.get_active] Normalization returned None, using original: {language_code}")
return cls.objects.get(language_code=language_code)
except cls.DoesNotExist:
- # Fall back to default locale
from django.conf import settings
- logger.warning(
- f"[BedrockLocale.get_active] Locale not found for '{normalized_code or language_code}', "
- f"falling back to default: {settings.LANGUAGE_CODE}"
- )
+ code = normalized_code or language_code
+ # Before falling back all the way to the default locale, check if
+ # this language code has a configured fallback (e.g. pt-PT → pt-BR).
+ # This ensures that page.localized resolves to the fallback locale's
+ # pages rather than the en-US originals when the alias locale has no
+ # Locale DB record.
+ fallback_locales = getattr(settings, "FALLBACK_LOCALES", {})
+ if code in fallback_locales:
+ try:
+ return cls.objects.get(language_code=fallback_locales[code])
+ except cls.DoesNotExist:
+ pass
+
+ logger.warning(f"[BedrockLocale.get_active] Locale not found for '{code}', falling back to default: {settings.LANGUAGE_CODE}")
return cls.objects.get(language_code=settings.LANGUAGE_CODE)
diff --git a/bedrock/cms/tests/decorator_test_views.py b/bedrock/cms/tests/decorator_test_views.py
index 2902199953c..c7dce3b67d9 100644
--- a/bedrock/cms/tests/decorator_test_views.py
+++ b/bedrock/cms/tests/decorator_test_views.py
@@ -19,7 +19,7 @@ def decorated_dummy_view(request):
return HttpResponse("This is a dummy response from the decorated view")
-@prefer_cms(fallback_lang_codes=["fr-CA", "es-MX", "sco"])
+@prefer_cms(fallback_lang_codes=["fr-CA", "es-ES", "sco"])
def decorated_dummy_view_with_locale_strings(request):
return HttpResponse("This is a dummy response from the decorated view with locale strings passed in")
diff --git a/bedrock/cms/tests/templates/test-hreflang.html b/bedrock/cms/tests/templates/test-hreflang.html
new file mode 100644
index 00000000000..40083d9aac8
--- /dev/null
+++ b/bedrock/cms/tests/templates/test-hreflang.html
@@ -0,0 +1,13 @@
+{#
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+#}
+
+
+
+
+{% include 'includes/canonical-url.html' %}
+
+
+
diff --git a/bedrock/cms/tests/test_alias_locale_serving.py b/bedrock/cms/tests/test_alias_locale_serving.py
new file mode 100644
index 00000000000..36ea5b5d81e
--- /dev/null
+++ b/bedrock/cms/tests/test_alias_locale_serving.py
@@ -0,0 +1,415 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+from django.conf import settings
+from django.test.utils import override_settings
+
+import pytest
+from wagtail.models import Locale as WagtailLocale, Page, Site
+
+from bedrock.cms.tests.factories import LocaleFactory
+
+pytestmark = [pytest.mark.django_db]
+
+ALIAS_SETTINGS = {
+ "FALLBACK_LOCALES": {
+ "pt-PT": "pt-BR",
+ "en-GB": "en-US",
+ }
+}
+
+
+@pytest.fixture
+def localized_site_with_alias(tiny_localized_site):
+ """
+ Extends tiny_localized_site with a non-live pt-PT root page.
+
+ Uses .copy() rather than .copy_for_translation() since copy_for_translation()
+ walks up the tree and requires the Wagtail root (depth 1) to already have a
+ pt-PT translation, which it never does. .copy() places the page directly
+ without that parent check.
+ """
+ pt_pt_locale = LocaleFactory(language_code="pt-PT")
+
+ en_us_locale_root = Page.objects.filter(
+ depth=2,
+ locale__language_code="en-US",
+ ).first()
+ wagtail_root = en_us_locale_root.get_parent()
+
+ en_us_locale_root.copy(
+ to=wagtail_root,
+ update_attrs={
+ "locale": pt_pt_locale,
+ "slug": "home-pt-PT",
+ },
+ copy_revisions=False,
+ keep_live=False,
+ reset_translation_key=False,
+ log_action=None,
+ )
+
+ return tiny_localized_site
+
+
+@pytest.fixture
+def site_with_es_es_and_aliases(tiny_localized_site):
+ """
+ Extends tiny_localized_site with:
+ - es-ES: real translated content (test-page + child-page)
+ - es-AR, es-CL: alias locales with non-live root pages only (no child-page)
+
+ Uses copy_for_translation(copy_parents=True) for es-ES so the depth-2 site
+ root is also translated. Uses .copy() for the alias locale roots for the same
+ reason as the migration: copy_for_translation walks up to depth 1 and fails
+ unless every ancestor is already translated.
+ """
+ site = Site.objects.get(is_default_site=True)
+ en_us_root = site.root_page
+ en_us_test_page = en_us_root.get_children()[0]
+ en_us_child = Page.objects.get(locale__language_code="en-US", slug="child-page")
+ wagtail_root = en_us_root.get_parent()
+
+ es_es_locale = LocaleFactory(language_code="es-ES")
+ es_es_test_page = en_us_test_page.copy_for_translation(es_es_locale, copy_parents=True)
+ es_es_test_page.save_revision().publish()
+ es_es_child = en_us_child.copy_for_translation(es_es_locale)
+ es_es_child.save_revision().publish()
+
+ for lang_code in ("es-AR", "es-CL"):
+ alias_locale = LocaleFactory(language_code=lang_code)
+ en_us_root.copy(
+ to=wagtail_root,
+ update_attrs={"locale": alias_locale, "slug": f"home-{lang_code}"},
+ copy_revisions=False,
+ keep_live=False,
+ reset_translation_key=False,
+ log_action=None,
+ )
+
+ return tiny_localized_site
+
+
+@pytest.fixture
+def localized_site_with_live_alias_root(tiny_localized_site):
+ """
+ Extends tiny_localized_site with a LIVE pt-PT root page.
+ The pt-PT locale has a live root but no 'test-page' translation.
+ This simulates an alias locale being promoted to its own live root
+ while not yet having all pages translated.
+ """
+ pt_pt_locale = LocaleFactory(language_code="pt-PT")
+
+ en_us_locale_root = Page.objects.filter(
+ depth=2,
+ locale__language_code="en-US",
+ ).first()
+ wagtail_root = en_us_locale_root.get_parent()
+
+ en_us_locale_root.copy(
+ to=wagtail_root,
+ update_attrs={
+ "locale": pt_pt_locale,
+ "slug": "home-pt-PT",
+ },
+ copy_revisions=False,
+ keep_live=True,
+ reset_translation_key=False,
+ log_action=None,
+ )
+
+ return tiny_localized_site
+
+
+@pytest.fixture
+def localized_site_with_live_alias_and_test_page(localized_site_with_live_alias_root):
+ """
+ Extends localized_site_with_live_alias_root by adding a live pt-PT translation
+ of 'test-page'. This simulates an alias locale that has been fully promoted and
+ has its own page — Wagtail should serve it directly.
+ """
+ pt_pt_locale = WagtailLocale.objects.get(language_code="pt-PT")
+ en_us_test_page = Page.objects.get(locale__language_code="en-US", slug="test-page")
+
+ pt_pt_test_page = en_us_test_page.copy_for_translation(pt_pt_locale)
+ pt_pt_test_page.title = "Página de Teste PT-PT"
+ pt_pt_test_page.save_revision().publish()
+
+ return localized_site_with_live_alias_root
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_alias_locale_served_transparently(localized_site_with_alias, client):
+ """GET /pt-PT/test-page/ returns 200 with pt-BR content, no redirect."""
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ # The "test-page" does not exist in the pt-PT locale.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ response = client.get("/pt-PT/test-page/", follow=False)
+
+ assert response.status_code == 200
+ assert pt_br_page.title in response.text
+ # request.content_locale is set to the fallback locale code when serving an alias
+ assert response.wsgi_request.content_locale == "pt-BR"
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_alias_sets_locales_on_request(localized_site_with_alias, client):
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+ # The "test-page" does not exist in the pt-PT locale.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ response = client.get("/pt-PT/test-page/")
+
+ assert response.status_code == 200
+ # Both pt-PT and pt-BR are in _locales_available_via_cms.
+ assert "pt-PT" in response.wsgi_request._locales_available_via_cms
+ assert "pt-BR" in response.wsgi_request._locales_available_via_cms
+ # The alias locale is not a content locale.
+ assert "pt-PT" not in response.wsgi_request._content_locales_via_cms
+ # The fallback locale is a content locale (since the page exists).
+ assert "pt-BR" in response.wsgi_request._content_locales_via_cms
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_alias_no_page_returns_404(localized_site_with_alias, client):
+ """GET /pt-PT/nonexistent/ returns 404 when there is no matching pt-BR page."""
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="nonexistent").exists() is False
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="nonexistent").exists() is False
+
+ response = client.get("/pt-PT/nonexistent/")
+
+ assert response.status_code == 404
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_alias_canonical_url_uses_fallback_locale(localized_site_with_alias, client):
+ """The canonical on an alias-served page points to the fallback locale URL."""
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+ # The "test-page" does not exist in the pt-PT locale.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ response = client.get("/pt-PT/test-page/")
+
+ assert response.status_code == 200
+ assert 'rel="canonical"' in response.text
+ # The pt-PT URL must NOT appear as the canonical href
+ canonical_line = [line for line in response.text.splitlines() if 'rel="canonical"' in line][0]
+ assert "/pt-PT/" not in canonical_line
+ # The pt-BR does appear as the canonical href
+ assert "/pt-BR/" in canonical_line
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_alias_noindex_present(localized_site_with_alias, client):
+ """Alias-served pages carry noindex,follow robots meta."""
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+ # The "test-page" does not exist in the pt-PT locale.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ response = client.get("/pt-PT/test-page/")
+
+ assert response.status_code == 200
+ assert 'content="noindex,follow"' in response.text
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_non_alias_no_noindex(localized_site_with_alias, client):
+ """A normal (non-alias) page does NOT carry the noindex meta."""
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+
+ response = client.get("/pt-BR/test-page/")
+
+ assert response.status_code == 200
+ assert 'content="noindex,follow"' not in response.text
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_alias_hreflang_excludes_alias_locale(localized_site_with_alias, client):
+ """
+ Alias locales without their own content are excluded from hreflang output.
+ pt-PT has no translated content in this fixture, so it should not appear.
+ """
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+ # The "test-page" does not exist in the pt-PT locale.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ response = client.get("/pt-PT/test-page/")
+
+ assert response.status_code == 200
+ assert 'hreflang="pt-PT"' not in response.text
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_hreflang_pt_br_claims_bare_pt(localized_site_with_alias, client):
+ """
+ The pt-BR page claims hreflang='pt' (bare) now that pt-PT is an alias.
+ Verify from a pt-BR page (the content locale URL).
+ """
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+
+ response = client.get("/pt-BR/test-page/")
+
+ assert response.status_code == 200
+ assert 'hreflang="pt"' in response.text
+ bare_pt_line = [line for line in response.text.splitlines() if 'hreflang="pt"' in line][0]
+ assert "/pt-BR/" in bare_pt_line # bare "pt" points to pt-BR, not pt-PT
+
+
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES", "es-CL": "es-ES"})
+def test_cms_alias_locales_excluded_from_hreflang_on_all_pages(site_with_es_es_and_aliases, client):
+ """
+ Alias locales without their own content are excluded from hreflang on every page:
+ the canonical locale, the fallback target, and the alias page itself.
+ """
+ page_path = "/test-page/child-page/"
+ en_us_child = Page.objects.get(locale__language_code="en-US", slug="child-page")
+ es_es_child = Page.objects.get(locale__language_code="es-ES", slug="child-page")
+ # The "child-page" does not exist in the es-AR or es-CL locales.
+ assert Page.objects.filter(locale__language_code="es-AR", slug="child-page").exists() is False
+ assert Page.objects.filter(locale__language_code="es-CL", slug="child-page").exists() is False
+
+ # --- 1. Canonical locale (en-US) ---
+ response = client.get(en_us_child.url)
+ assert response.status_code == 200
+ html = response.text
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html
+ assert 'content="noindex,follow"' not in html
+ assert 'hreflang="es-ES"' in html
+ assert 'hreflang="es-AR"' not in html
+ assert 'hreflang="es-CL"' not in html
+
+ # --- 2. Fallback target (es-ES, has real content) ---
+ es_es_child.refresh_from_db()
+ response = client.get(es_es_child.url)
+ assert response.status_code == 200
+ html = response.text
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html
+ assert 'content="noindex,follow"' not in html
+ assert 'hreflang="es-AR"' not in html
+ assert 'hreflang="es-CL"' not in html
+
+ # --- 3. Alias locale (es-AR) served via fallback ---
+ es_ar_url = es_es_child.url.replace("es-ES", "es-AR")
+ response = client.get(es_ar_url)
+ assert response.status_code == 200
+ html = response.text
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-AR{page_path}"' not in html
+ assert 'content="noindex,follow"' in html
+ assert 'hreflang="es-ES"' in html
+ assert 'hreflang="es-AR"' not in html
+ assert 'hreflang="es-CL"' not in html
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_alias_locale_with_live_root_and_missing_page_serves_fallback(localized_site_with_live_alias_root, client):
+ """
+ When an alias locale (pt-PT) has a live root but no translation for a specific page,
+ the fallback locale's (pt-BR) page is served transparently.
+ """
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="test-page").exists() is True
+
+ response = client.get("/pt-PT/test-page/", follow=False)
+
+ assert response.status_code == 200
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ assert pt_br_page.title in response.text
+ assert response.wsgi_request.content_locale == "pt-BR"
+ # The page's canonical language is from the fallback locale.
+ assert 'rel="canonical"' in response.text
+ canonical_line = [line for line in response.text.splitlines() if 'rel="canonical"' in line][0]
+ assert "/pt-PT/" not in canonical_line
+ assert "/pt-BR/" in canonical_line
+ # The page is not indexable.
+ assert 'content="noindex,follow"' in response.text
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_alias_locale_with_live_root_and_own_page_serves_own_content(localized_site_with_live_alias_and_test_page, client):
+ """
+ When an alias locale has a live root AND its own translated page, Wagtail
+ serves the alias locale's own content directly (not the fallback locale's).
+ """
+ # Make sure the test page exists in pt-PT and pt-BR, and the page has
+ # different titles.
+ pt_pt_page = Page.objects.get(locale__language_code="pt-PT", slug="test-page")
+ pt_pt_page.title = "Página de Teste PT-PT"
+ pt_pt_page.save()
+ rev = pt_pt_page.specific.save_revision()
+ pt_pt_page.publish(rev)
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ pt_br_page.title = "Página de Teste PT-BR"
+ pt_br_page.save()
+ rev = pt_br_page.specific.save_revision()
+ pt_br_page.publish(rev)
+
+ response = client.get("/pt-PT/test-page/", follow=False)
+
+ assert response.status_code == 200
+ assert pt_pt_page.title in response.text
+ assert f"{pt_pt_page.title}" in response.text
+ assert f"{pt_br_page.title}" not in response.text
+ # The page's canonical language is from the alias locale.
+ assert 'rel="canonical"' in response.text
+ canonical_line = [line for line in response.text.splitlines() if 'rel="canonical"' in line][0]
+ assert "/pt-PT/" in canonical_line
+ assert "/pt-BR/" not in canonical_line
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_alias_locale_with_live_root_and_no_fallback_returns_404(localized_site_with_live_alias_root, client):
+ """
+ When an alias locale has a live root, Wagtail 404s on a missing page, and no
+ fallback page exists, the final response is 404.
+ """
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="nonexistent").exists() is False
+ assert Page.objects.filter(locale__language_code="pt-BR", slug="nonexistent").exists() is False
+
+ response = client.get("/pt-PT/nonexistent/")
+
+ assert response.status_code == 404
+
+
+@override_settings(**ALIAS_SETTINGS)
+def test_cms_promoted_alias_locale_included_in_hreflang(tiny_localized_site, client):
+ """
+ When an alias locale has its own translated content ('promoted'), it appears
+ in hreflang alternates like any other real locale.
+ """
+ site = Site.objects.get(is_default_site=True)
+ en_us_root = site.root_page
+ en_us_test_page = en_us_root.get_children()[0]
+ en_us_child = Page.objects.get(locale__language_code="en-US", slug="child-page")
+
+ pt_pt_locale = LocaleFactory(language_code="pt-PT")
+ pt_pt_root = en_us_root.copy_for_translation(pt_pt_locale)
+ pt_pt_root.live = True
+ pt_pt_root.save()
+ pt_pt_test_page = en_us_test_page.copy_for_translation(pt_pt_locale)
+ pt_pt_test_page.save_revision().publish()
+ pt_pt_child = en_us_child.copy_for_translation(pt_pt_locale)
+ pt_pt_child.save_revision().publish()
+
+ page_path = "/test-page/child-page/"
+ pt_br_child = Page.objects.get(locale__language_code="pt-BR", slug="child-page")
+
+ response_pt_br = client.get(pt_br_child.url)
+
+ assert response_pt_br.status_code == 200
+ html = response_pt_br.text
+ # Both pt-BR and pt-PT have their own content — both must appear.
+ assert f'hreflang="pt-BR" href="{settings.CANONICAL_URL}/pt-BR{page_path}"' in html
+ assert f'hreflang="pt-PT" href="{settings.CANONICAL_URL}/pt-PT{page_path}"' in html
+ assert 'content="noindex,follow"' not in html
+
+ response_pt_pt = client.get(pt_pt_child.url)
+
+ assert response_pt_pt.status_code == 200
+ html = response_pt_pt.text
+ # Both pt-BR and pt-PT have their own content — both must appear.
+ assert f'hreflang="pt-BR" href="{settings.CANONICAL_URL}/pt-BR{page_path}"' in html
+ assert f'hreflang="pt-PT" href="{settings.CANONICAL_URL}/pt-PT{page_path}"' in html
+ assert 'content="noindex,follow"' not in html
diff --git a/bedrock/cms/tests/test_decorators.py b/bedrock/cms/tests/test_decorators.py
index c4a5a66387e..e96fe0c2ab1 100644
--- a/bedrock/cms/tests/test_decorators.py
+++ b/bedrock/cms/tests/test_decorators.py
@@ -3,9 +3,11 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+from django.test import override_settings
from django.urls import path
import pytest
+from wagtail.models import Locale, Site
from wagtail.rich_text import RichText
from bedrock.base.i18n import bedrock_i18n_patterns
@@ -13,7 +15,7 @@
from bedrock.cms.tests import decorator_test_views
from bedrock.urls import urlpatterns as mozorg_urlpatterns
-from .factories import SimpleRichTextPageFactory
+from .factories import LocaleFactory, SimpleRichTextPageFactory
urlpatterns = (
bedrock_i18n_patterns(
@@ -61,7 +63,7 @@
"wrapped/view/path/with/locale/strings/",
prefer_cms(
decorator_test_views.wrapped_dummy_view,
- fallback_lang_codes=["fr-CA", "es-MX", "sco"],
+ fallback_lang_codes=["fr-CA", "es-ES", "sco"],
),
name="url_wrapped_dummy_view",
),
@@ -73,6 +75,14 @@
),
name="url_wrapped_dummy_view",
),
+ path(
+ "prefer-cms-alias-test/",
+ prefer_cms(
+ decorator_test_views.wrapped_dummy_view,
+ fallback_lang_codes=["es-AR", "es-ES", "en-US"],
+ ),
+ name="prefer_cms_alias_test",
+ ),
)
+ mozorg_urlpatterns # we need to extend these so Jinja2 can call url() in the templates
)
@@ -137,7 +147,7 @@ def test_decorating_django_view__passing_fallback_lang_codes(
assert resp.status_code == 200
assert resp.text == "This is a dummy response from the decorated view with locale strings passed in"
# Show that the expected locales are annotated onto the request
- assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"]
+ assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"]
assert resp.wsgi_request._locales_available_via_cms == [] # No page in CMS yet
# Show the decorated view will "prefer" to render the Wagtail page when it exists
@@ -149,8 +159,8 @@ def test_decorating_django_view__passing_fallback_lang_codes(
resp = client.get(f"{lang_code_prefix}/decorated/view/path/with/locale/strings/", follow=True)
assert resp.status_code == 200
# Show that the expected locales are annotated onto the request
- assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"]
- assert resp.wsgi_request._locales_available_via_cms == ["en-US"]
+ assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"]
+ assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of strings" in resp.text
@@ -181,7 +191,7 @@ def test_decorating_django_view__passing_callable_for_locales(
assert resp.status_code == 200
# Show that the expected locales are annotated onto the request
assert resp.wsgi_request._locales_for_django_fallback_view == ["sco", "es-ES"]
- assert resp.wsgi_request._locales_available_via_cms == ["en-US"]
+ assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of a-slug-here" in resp.text
@@ -223,7 +233,7 @@ def test_decorating_django_view__passing_ftl_files(lang_code_prefix, minimal_sit
["test/fluentA", "test/fluentB"],
force=True,
)
- assert resp.wsgi_request._locales_available_via_cms == ["en-US"]
+ assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of files" in resp.text
@@ -261,7 +271,7 @@ def test_patching_in_urlconf__standard_django_view__with_locale_list(
assert resp.status_code == 200
assert resp.text == "This is a dummy response from the wrapped view"
# Show that the expected locales are annotated onto the request
- assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"]
+ assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"]
assert resp.wsgi_request._locales_available_via_cms == [] # No page in CMS yet
# Show the decorated view will "prefer" to render the Wagtail page when it exists
@@ -273,8 +283,8 @@ def test_patching_in_urlconf__standard_django_view__with_locale_list(
resp = client.get(f"{lang_code_prefix}/wrapped/view/path/with/locale/strings/", follow=True)
assert resp.status_code == 200
# Show that the expected locales are annotated onto the request
- assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-MX", "sco"]
- assert resp.wsgi_request._locales_available_via_cms == ["en-US"]
+ assert resp.wsgi_request._locales_for_django_fallback_view == ["fr-CA", "es-ES", "sco"]
+ assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of strings" in resp.text
@@ -305,7 +315,7 @@ def test_patching_in_urlconf__standard_django_view__with_callback_for_locales(
assert resp.status_code == 200
# Show that the expected locales are annotated onto the request
assert resp.wsgi_request._locales_for_django_fallback_view == ["sco", "es-ES"]
- assert resp.wsgi_request._locales_available_via_cms == ["en-US"]
+ assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of a-slug-here" in resp.text
@@ -352,7 +362,7 @@ def test_patching_in_urlconf__standard_django_view__with_fluent_files(
)
assert resp.wsgi_request._locales_for_django_fallback_view == ["sco", "es-ES", "fr-CA"]
- assert resp.wsgi_request._locales_available_via_cms == ["en-US"]
+ assert resp.wsgi_request._locales_available_via_cms == ["en-US", "en-GB", "en-CA"]
assert "This is a CMS page now, with the slug of files" in resp.text
@@ -446,3 +456,195 @@ def test_prefer_cms_rejects_invalid_setup(mocker, config, expect_exeption):
prefer_cms(view_func=fake_view, **config)
else:
prefer_cms(view_func=fake_view, **config)
+
+
+@pytest.fixture()
+def prefer_cms_alias_site(tiny_localized_site):
+ """Site with a CMS page at /prefer-cms-alias-test/ in en-US, with es-ES locale available.
+
+ Uses tiny_localized_site (not minimal_site) because its root page is at depth 2 with a
+ proper parent, which is required for copy_for_translation to succeed.
+ """
+ site = Site.objects.get(is_default_site=True)
+ es_es_locale = LocaleFactory(language_code="es-ES")
+ # Translate the site root so child pages can be translated without copy_parents=True.
+ site.root_page.copy_for_translation(es_es_locale)
+
+ en_us_page = SimpleRichTextPageFactory(
+ slug="prefer-cms-alias-test",
+ parent=site.root_page,
+ content=RichText("CMS page content"),
+ )
+ en_us_page.save_revision()
+ en_us_page.publish(en_us_page.latest_revision)
+
+ return {"site": site, "es_es_locale": es_es_locale, "en_us_page": en_us_page}
+
+
+@pytest.mark.urls(__name__)
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_prefer_cms_serves_alias_fallback_cms_page_when_no_alias_locale(prefer_cms_alias_site, client):
+ """
+ prefer_cms serves the CMS's fallback page for an alias locale, rather than the static es-AR page.
+
+ This test verifies the following scenario:
+ - a static page exists in an alias locale (es-AR)
+ - an alias locale (es-AR) does not exist
+ - a CMS page does exist in the fallback locale (es-ES)
+ In this case, the user should get the CMS's es-ES page at the es-AR URL.
+ """
+ en_us_page = prefer_cms_alias_site["en_us_page"]
+ es_es_locale = prefer_cms_alias_site["es_es_locale"]
+
+ es_es_page = en_us_page.copy_for_translation(es_es_locale)
+ es_es_page.content = RichText("ES-ES CMS page content")
+ es_es_page.save()
+ es_es_page.save_revision()
+ es_es_page.publish(es_es_page.latest_revision)
+
+ # The es-AR locale does not exist
+ assert not Locale.objects.filter(language_code="es-AR").exists()
+
+ response = client.get("/es-AR/prefer-cms-alias-test/")
+
+ assert response.status_code == 200
+ # The es-ES CMS page was served
+ assert "ES-ES CMS page content" in response.text
+ assert "dummy response from the wrapped view" not in response.text # Django view was NOT reached
+ assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"}
+
+
+@pytest.mark.urls(__name__)
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_prefer_cms_serves_alias_fallback_cms_page_when_alias_locale_has_no_live_root(prefer_cms_alias_site, client):
+ """
+ prefer_cms serves the CMS's fallback page for an alias locale, rather than the static es-AR page.
+
+ This test verifies the following scenario:
+ - a static page exists in an alias locale (es-AR)
+ - an alias locale (es-AR) exists, but has no live root page
+ - a CMS page does exist in the fallback locale (es-ES)
+ In this case, the user should get the CMS's es-ES page at the es-AR URL.
+ """
+ en_us_page = prefer_cms_alias_site["en_us_page"]
+ es_es_locale = prefer_cms_alias_site["es_es_locale"]
+
+ es_es_page = en_us_page.copy_for_translation(es_es_locale)
+ es_es_page.content = RichText("ES-ES CMS page content")
+ es_es_page.save()
+ es_es_page.save_revision()
+ es_es_page.publish(es_es_page.latest_revision)
+
+ # es-AR also exists, but its root page is not live.
+ es_ar_locale = LocaleFactory(language_code="es-AR")
+ es_ar_root = prefer_cms_alias_site["site"].root_page.copy_for_translation(es_ar_locale)
+ es_ar_root.live = False
+ es_ar_root.save()
+
+ response = client.get("/es-AR/prefer-cms-alias-test/")
+
+ assert response.status_code == 200
+ # The es-ES CMS page was served
+ assert "ES-ES CMS page content" in response.text
+ assert "dummy response from the wrapped view" not in response.text # Django view was NOT reached
+ assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"}
+
+
+@pytest.mark.urls(__name__)
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_prefer_cms_serves_alias_fallback_cms_page_when_alias_locale_has_live_root(prefer_cms_alias_site, client):
+ """
+ prefer_cms serves the CMS's fallback page for an alias locale, rather than the static es-AR page.
+
+ This test verifies the following scenario:
+ - a static page exists in an alias locale (es-AR)
+ - an alias locale (es-AR) exists, and has a live root page
+ - a CMS page does NOT exist in the alias locale (es-AR)
+ - a CMS page does exist in the fallback locale (es-ES)
+ In this case, the user should get the CMS's es-ES page at the es-AR URL.
+ """
+ en_us_page = prefer_cms_alias_site["en_us_page"]
+ es_es_locale = prefer_cms_alias_site["es_es_locale"]
+
+ es_es_page = en_us_page.copy_for_translation(es_es_locale)
+ es_es_page.content = RichText("ES-ES CMS page content")
+ es_es_page.save()
+ es_es_page.save_revision()
+ es_es_page.publish(es_es_page.latest_revision)
+
+ # es-AR also exists and has a live root page.
+ es_ar_locale = LocaleFactory(language_code="es-AR")
+ es_ar_root = prefer_cms_alias_site["site"].root_page.copy_for_translation(es_ar_locale)
+ es_ar_root.live = True
+ es_ar_root.save()
+
+ response = client.get("/es-AR/prefer-cms-alias-test/")
+
+ assert response.status_code == 200
+ # The es-ES CMS page was served
+ assert "ES-ES CMS page content" in response.text
+ assert "dummy response from the wrapped view" not in response.text # Django view was NOT reached
+ assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"}
+
+
+@pytest.mark.urls(__name__)
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_prefer_cms_serves_direct_cms_page_for_alias_locale(prefer_cms_alias_site, client):
+ """
+ When a CMS page exists in an alias locale, prefer_cms serves it, rather than the static alias locale page.
+
+ This test verifies the following scenario:
+ - a static page exists in an alias locale (es-AR)
+ - a CMS page does exist in an alias locale (es-AR)
+ - a CMS page does exist in a fallback locale (es-ES)
+ In this case, the user should get the CMS's es-AR page at the es-AR URL.
+ """
+ en_us_page = prefer_cms_alias_site["en_us_page"]
+ es_es_locale = prefer_cms_alias_site["es_es_locale"]
+
+ # es-ES page must exist for the locale chain to be valid
+ es_es_page = en_us_page.copy_for_translation(es_es_locale)
+ es_es_page.content = RichText("ES-ES CMS page content")
+ es_es_page.save()
+ es_es_page.save_revision()
+ es_es_page.publish(es_es_page.latest_revision)
+
+ # es-AR also has its own promoted page; the root must be live so
+ # _alias_needs_prewagtail_intercept returns False and Wagtail serves es-AR directly.
+ es_ar_locale = LocaleFactory(language_code="es-AR")
+ es_ar_root = prefer_cms_alias_site["site"].root_page.copy_for_translation(es_ar_locale)
+ es_ar_root.live = True
+ es_ar_root.save()
+ es_ar_page = en_us_page.copy_for_translation(es_ar_locale)
+ es_ar_page.content = RichText("ES-AR CMS page content")
+ es_ar_page.save()
+ es_ar_page.save_revision()
+ es_ar_page.publish(es_ar_page.latest_revision)
+
+ response = client.get("/es-AR/prefer-cms-alias-test/")
+
+ assert response.status_code == 200
+ # The es-AR CMS page was served
+ assert "ES-AR CMS page content" in response.text
+ assert "dummy response from the wrapped view" not in response.text
+ assert set(response.wsgi_request._locales_available_via_cms) == {"es-ES", "en-US", "es-AR"}
+
+
+@pytest.mark.urls(__name__)
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES"})
+def test_prefer_cms_falls_through_to_django_when_no_alias_cms_page(prefer_cms_alias_site, client):
+ """
+ prefer_cms falls through to the Django view when CMS has no page for es-AR or es-ES.
+
+ This test verifies the following scenario:
+ - a static page exists in an alias locale (es-AR)
+ - a CMS page does NOT exist in an alias locale (es-AR)
+ - a CMS page does NOT exist in a fallback locale (es-ES)
+ - a CMS page does exist in the default locale (en-US)
+ In this case, the user should get the static es-AR page at the es-AR URL.
+ """
+ # prefer_cms_alias_site has an en-US CMS page only — no es-ES or es-AR translations.
+ response = client.get("/es-AR/prefer-cms-alias-test/")
+ assert response.status_code == 200
+ assert "dummy response from the wrapped view" in response.text # Django view served
+ assert "CMS page content" not in response.text
diff --git a/bedrock/cms/tests/test_locale_fallback_rendering.py b/bedrock/cms/tests/test_locale_fallback_rendering.py
new file mode 100644
index 00000000000..8d9a836a020
--- /dev/null
+++ b/bedrock/cms/tests/test_locale_fallback_rendering.py
@@ -0,0 +1,109 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+import os
+
+from django.conf import settings
+from django.template import engines
+from django.test import override_settings
+from django.urls import path
+
+import pytest
+
+from bedrock.base.i18n import bedrock_i18n_patterns
+from bedrock.urls import urlpatterns as bedrock_urlpatterns
+from lib import l10n_utils
+
+pytestmark = [pytest.mark.django_db]
+
+TEST_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
+
+
+def _hreflang_test_view(request):
+ return l10n_utils.render(
+ request,
+ "test-hreflang.html",
+ {"active_locales": ["en-US", "es-ES", "fr", "de"]},
+ )
+
+
+urlpatterns = (
+ bedrock_i18n_patterns(
+ path("test-hreflang/", _hreflang_test_view, name="test-hreflang"),
+ )
+ + bedrock_urlpatterns
+)
+
+
+@pytest.fixture()
+def _add_test_templates_dir():
+ """Temporarily prepend the test templates directory to the Jinja2 searchpath.
+
+ Modifies the loader's searchpath directly instead of resetting engines._engines,
+ which would invalidate module-level references to the Jinja2 environment used by
+ other tests' mock patches.
+ """
+ jinja2_loader = engines["jinja2"].env.loader
+ jinja2_loader.searchpath.insert(0, TEST_TEMPLATES_DIR)
+ try:
+ yield
+ finally:
+ jinja2_loader.searchpath.remove(TEST_TEMPLATES_DIR)
+
+
+@pytest.mark.urls(__name__)
+@pytest.mark.usefixtures("_add_test_templates_dir")
+@override_settings(FALLBACK_LOCALES={"es-AR": "es-ES", "es-CL": "es-ES"})
+def test_non_cms_page_hreflang_alternates(client):
+ """
+ Non-CMS (Django/Fluent) pages render correct hreflang alternates.
+
+ Uses a test-only view with controlled active_locales. Alias locales whose
+ fallback is in active_locales must not appear in hreflang. Verifies from
+ three perspectives: canonical locale, fallback target, and alias locale.
+ """
+ page_path = "/test-hreflang/"
+
+ # --- 1. Canonical locale (en-US) ---
+ response = client.get(f"/en-US{page_path}")
+ assert response.status_code == 200
+ html = response.text
+ # The page is indexable
+ assert 'content="noindex,follow"' not in html
+ # The page is the canonical link.
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html
+ # The supported languages have hreflang entries.
+ assert f'hreflang="en" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html
+ assert f'hreflang="es-ES" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html
+ # The alias languages do not have a hreflang entries.
+ assert 'hreflang="es-AR"' not in html
+ assert 'hreflang="es-CL"' not in html
+
+ # --- 2. Fallback target (es-ES, has content in active_locales) ---
+ response = client.get(f"/es-ES{page_path}")
+ assert response.status_code == 200
+ html = response.text
+ # The page is indexable
+ assert 'content="noindex,follow"' not in html
+ # The page is the canonical link.
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html
+ # The supported languages have hreflang entries.
+ assert f'hreflang="en" href="{settings.CANONICAL_URL}/en-US{page_path}"' in html
+ assert f'hreflang="es-ES" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html
+ # The alias languages do not have a hreflang entries.
+ assert 'hreflang="es-AR"' not in html
+ assert 'hreflang="es-CL"' not in html
+
+ # --- 3. Alias locale (es-AR) served via non-CMS fallback ---
+ response = client.get(f"/es-AR{page_path}")
+ assert response.status_code == 200
+ html = response.text
+ # The page is not indexable
+ assert 'content="noindex,follow"' in html
+ # The page has a canonical link pointing to the es-ES page
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-ES{page_path}"' in html
+ assert f'rel="canonical" href="{settings.CANONICAL_URL}/es-AR{page_path}"' not in html
+ # The alias languages do not have a hreflang entries.
+ assert 'hreflang="es-AR"' not in html
+ assert 'hreflang="es-CL"' not in html
diff --git a/bedrock/cms/tests/test_models.py b/bedrock/cms/tests/test_models.py
index 5ed8f039e85..894dc9000a7 100644
--- a/bedrock/cms/tests/test_models.py
+++ b/bedrock/cms/tests/test_models.py
@@ -5,6 +5,7 @@
from unittest import mock
from django.test import override_settings
+from django.utils import translation
import pytest
from wagtail.models import Locale, Page
@@ -100,9 +101,9 @@ def test_CMS_ALLOWED_PAGE_MODELS_controls_Page_can_create_at(
assert page_class.can_create_at(home_page) == success_expected
-@mock.patch("bedrock.cms.models.base.get_locales_for_cms_page")
+@mock.patch("bedrock.cms.models.base.compute_cms_page_locales")
def test__patch_request_for_bedrock__locales_available_via_cms(
- mock_get_locales_for_cms_page,
+ mock_compute_cms_page_locales,
minimal_site,
rf,
):
@@ -110,10 +111,101 @@ def test__patch_request_for_bedrock__locales_available_via_cms(
page = SimpleRichTextPage.objects.last() # made by the minimal_site fixture
- mock_get_locales_for_cms_page.return_value = ["en-US", "fr", "pt-BR"]
+ mock_compute_cms_page_locales.return_value = (["en-US", "fr", "pt-BR"], ["en-US", "fr"])
patched_request = page.specific._patch_request_for_bedrock(request)
assert sorted(patched_request._locales_available_via_cms) == ["en-US", "fr", "pt-BR"]
+ assert sorted(patched_request._content_locales_via_cms) == ["en-US", "fr"]
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_localized_returns_fallback_translation_for_alias_locale(tiny_localized_site):
+ """When the active locale is an alias (pt-PT) and the page has no pt-PT
+ translation, localized returns the pt-BR translation instead."""
+ en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page")
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ # The page has no pt-PT translation.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ with translation.override("pt-pt"): # Django uses lowercase internally
+ result = en_us_page.specific.localized
+
+ assert result.pk == pt_br_page.pk
+ assert result.locale.language_code == "pt-BR"
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_localized_returns_alias_translation_for_alias_locale_when_alias_translation_exists(tiny_localized_site):
+ """When the active locale is an alias (pt-PT) and the page has a pt-PT
+ translation, localized returns the pt-PT translation."""
+ en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page")
+ # Translate the page into pt-PT: create the locale, copy the root, then the page.
+ pt_pt_locale = LocaleFactory(language_code="pt-PT")
+ en_us_page.get_parent().copy_for_translation(pt_pt_locale)
+ pt_pt_page = en_us_page.copy_for_translation(pt_pt_locale)
+ pt_pt_page.save_revision().publish()
+ pt_pt_page.refresh_from_db()
+
+ with translation.override("pt-pt"): # Django uses lowercase internally
+ result = en_us_page.specific.localized
+
+ assert result.pk == pt_pt_page.pk
+ assert result.locale.language_code == "pt-PT"
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_localized_returns_original_when_no_fallback_translation(tiny_localized_site):
+ """When alias locale is active but no fallback translation exists, localized
+ falls back to Wagtail's default (returns the en-US original)."""
+ en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page")
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ pt_br_page.delete()
+ # The page has no pt-PT translation.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ with translation.override("pt-pt"):
+ result = en_us_page.specific.localized
+
+ # Wagtail default: returns en-US when no translation found
+ assert result.locale.language_code == "en-US"
+
+
+def test_localized_non_alias_locale_unchanged(tiny_localized_site):
+ """For a non-alias locale, localized behaves exactly like Wagtail's default."""
+ en_us_page = Page.objects.get(locale__language_code="en-US", slug="test-page")
+ fr_page = Page.objects.get(locale__language_code="fr", slug="test-page")
+
+ with translation.override("fr"):
+ result = en_us_page.specific.localized
+
+ assert result.pk == fr_page.pk
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"}, WAGTAILFRONTENDCHACHE={})
+def test_get_active_locale_url_rewrites_prefix_for_alias_locale(tiny_localized_site):
+ """When the active locale is pt-PT (alias for pt-BR), get_active_locale_url
+ returns the URL with /pt-BR/ replaced by /pt-PT/."""
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ # The page has no pt-PT translation.
+ assert Page.objects.filter(locale__language_code="pt-PT", slug="test-page").exists() is False
+
+ with translation.override("pt-pt"):
+ url = pt_br_page.specific.get_active_locale_url()
+
+ assert "/pt-PT/" in url
+ assert "/pt-BR/" not in url
+
+
+def test_get_active_locale_url_unchanged_for_non_alias_locale(tiny_localized_site):
+ """For a non-alias locale, get_active_locale_url returns the standard URL."""
+ fr_page = Page.objects.get(locale__language_code="fr", slug="test-page")
+
+ with translation.override("fr"):
+ url = fr_page.specific.get_active_locale_url()
+
+ assert "/fr/" in url
+ assert "/pt-BR/" not in url
+ assert "/pt-PT/" not in url
def test__patch_request_for_bedrock_annotates_is_cms_page(tiny_localized_site, rf):
diff --git a/bedrock/cms/tests/test_utils.py b/bedrock/cms/tests/test_utils.py
index f925fa3ce2f..8cac3dfde64 100644
--- a/bedrock/cms/tests/test_utils.py
+++ b/bedrock/cms/tests/test_utils.py
@@ -3,11 +3,13 @@
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+from django.test import override_settings
+
import pytest
from wagtail.coreutils import get_dummy_request
from wagtail.models import Locale, Page
-from bedrock.cms.utils import get_cms_locales_for_path, get_locales_for_cms_page, get_page_for_request
+from bedrock.cms.utils import find_fallback_page_for_locale, get_cms_locales_for_path, get_locales_for_cms_page, get_page_for_request
pytestmark = [pytest.mark.django_db]
@@ -20,7 +22,7 @@ def test_get_locales_for_cms_page(tiny_localized_site):
# match the pages set up in the tiny_localized_site fixture
assert Page.objects.filter(alias_of__isnull=False).count() == 0
- assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-US", "fr", "pt-BR"]
+ assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-CA", "en-GB", "en-US", "fr", "pt-BR", "pt-PT"]
# now make aliases of the test_page into Dutch and Spanish
nl_locale = Locale.objects.create(language_code="nl")
@@ -34,8 +36,9 @@ def test_get_locales_for_cms_page(tiny_localized_site):
assert Page.objects.filter(alias_of__isnull=False).count() == 4 # 2 child + 2 parent pages, which had to be copied too
- # Show that the aliases don't appear in the available locales
- assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-US", "fr", "pt-BR"]
+ # Show that the Wagtail page aliases don't appear in the available locales
+ # (FALLBACK_LOCALES alias expansion still applies for en-US and pt-BR)
+ assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-CA", "en-GB", "en-US", "fr", "pt-BR", "pt-PT"]
def test_get_locales_for_cms_page__ensure_draft_pages_are_excluded(tiny_localized_site):
@@ -46,7 +49,7 @@ def test_get_locales_for_cms_page__ensure_draft_pages_are_excluded(tiny_localize
fr_test_page.unpublish()
- assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-US", "pt-BR"]
+ assert sorted(get_locales_for_cms_page(en_us_test_page)) == ["en-CA", "en-GB", "en-US", "pt-BR", "pt-PT"]
@pytest.mark.parametrize(
@@ -142,3 +145,42 @@ def test_get_cms_locales_for_path(
if get_page_for_request_should_return_a_page:
mock_get_page_for_request.assert_called_once_with(request=request)
mock_get_locales_for_cms_page.assert_called_once_with(page=page)
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_find_fallback_page_for_locale_returns_fallback_page(tiny_localized_site):
+ """Alias locale with a live fallback page returns that page."""
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+
+ fallback_page = find_fallback_page_for_locale("pt-PT", "test-page/")
+
+ assert fallback_page == pt_br_page
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_find_fallback_page_for_locale_returns_none_when_no_fallback_page(tiny_localized_site):
+ """Alias locale configured but no page at the path in fallback locale → None."""
+ assert Page.objects.filter(slug="nonexistent-path").exists() is False
+ fallback_page = find_fallback_page_for_locale("pt-PT", "nonexistent-path/")
+ assert fallback_page is None
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_find_fallback_page_for_locale_returns_none_for_non_alias_locale(tiny_localized_site):
+ """Locale not in FALLBACK_LOCALES → None (no fallback lookup performed)."""
+ # The fr Page exists in the database.
+ assert Page.objects.filter(locale__language_code="fr", slug="test-page").exists() is True
+
+ fallback_page = find_fallback_page_for_locale("fr", "test-page/")
+
+ # Since "fr" is not in the FALLBACK_LOCALES, the function returns None.
+ assert fallback_page is None
+
+
+@override_settings(FALLBACK_LOCALES={"pt-PT": "pt-BR"})
+def test_find_fallback_page_for_locale_returns_none_for_draft_page(tiny_localized_site):
+ """Fallback page exists but is unpublished → None."""
+ pt_br_page = Page.objects.get(locale__language_code="pt-BR", slug="test-page")
+ pt_br_page.unpublish()
+ fallback_page = find_fallback_page_for_locale("pt-PT", "test-page/")
+ assert fallback_page is None
diff --git a/bedrock/cms/utils.py b/bedrock/cms/utils.py
index eed3b69b408..04f4fe73881 100644
--- a/bedrock/cms/utils.py
+++ b/bedrock/cms/utils.py
@@ -1,14 +1,19 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+import logging
+
+from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Subquery
from django.http import Http404
-from wagtail.models import Locale, Page
+from wagtail.models import Locale, Page, Site
from bedrock.base.i18n import split_path_and_normalize_language
+logger = logging.getLogger(__name__)
+
def get_page_for_request(*, request):
"""For the given HTTPRequest (and its path) find the corresponding Wagtail
@@ -31,11 +36,47 @@ def get_page_for_request(*, request):
return page
-def get_locales_for_cms_page(page):
- # Patch in a list of CMS-available locales for pages that are
- # translations, not just aliases
+def find_fallback_page_for_locale(locale_code, url_path):
+ """
+ For an alias locale (e.g. 'pt-PT'), find the corresponding live page
+ in the fallback locale's page tree (e.g. 'pt-BR').
+ url_path is the bare path without locale prefix (normalized internally).
+ Returns a Page instance or None.
+ """
+ fallback_locale_code = getattr(settings, "FALLBACK_LOCALES", {}).get(locale_code)
+ if not fallback_locale_code:
+ return None
- locales_available_via_cms = [page.locale.language_code]
+ try:
+ fallback_locale = Locale.objects.get(language_code=fallback_locale_code)
+ except Locale.DoesNotExist:
+ return None
+
+ site = Site.objects.filter(is_default_site=True).select_related("root_page").first()
+ if not site:
+ return None
+ try:
+ locale_root = site.root_page.get_translation(fallback_locale)
+ except Page.DoesNotExist:
+ logger.exception("No root page translation found for fallback locale %r", fallback_locale_code)
+ return None
+
+ _url_path = url_path.strip("/")
+ if not _url_path:
+ return locale_root if locale_root.live else None
+ full_url_path = f"{locale_root.url_path}{_url_path}/"
+
+ return Page.objects.live().filter(url_path=full_url_path).first()
+
+
+def compute_cms_page_locales(page):
+ """
+ Return a tuple of locales: (all_locales, content_locales) for a CMS page.
+
+ all_locales: content_locales + alias locales from FALLBACK_LOCALES.
+ content_locales: locales with real translated content (no alias expansion).
+ """
+ content_locales = [page.locale.language_code]
try:
_actual_translations = (
page.get_translations()
@@ -46,12 +87,26 @@ def get_locales_for_cms_page(page):
)
)
)
- locales_available_via_cms += [x.locale.language_code for x in _actual_translations]
+ content_locales += [x.locale.language_code for x in _actual_translations]
except ValueError:
# when there's no draft and no potential for aliases, etc, the above lookup will fail
pass
- return locales_available_via_cms
+ # Expand with alias locales from FALLBACK_LOCALES reverse map.
+ # e.g. if pt-BR is in the list, also add pt-PT.
+ alias_additions = [alias for alias, target in getattr(settings, "FALLBACK_LOCALES", {}).items() if target in content_locales]
+
+ all_locales = list(dict.fromkeys(content_locales + alias_additions))
+ deduped_content = list(dict.fromkeys(content_locales))
+
+ return all_locales, deduped_content
+
+
+def get_locales_for_cms_page(page):
+ # Patch in a list of CMS-available locales for pages that are
+ # translations, not just aliases
+ all_locales, _ = compute_cms_page_locales(page)
+ return all_locales
def get_cms_locales_for_path(request):
diff --git a/bedrock/cms/views.py b/bedrock/cms/views.py
new file mode 100644
index 00000000000..0a76a951442
--- /dev/null
+++ b/bedrock/cms/views.py
@@ -0,0 +1,118 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+from django.conf import settings
+from django.http import Http404, HttpResponseRedirect
+
+from wagtail.models import Locale as WagtailLocale, Site
+from wagtail.views import serve as wagtail_serve
+
+from bedrock.cms.utils import find_fallback_page_for_locale
+
+
+def _alias_needs_prewagtail_intercept(lang_prefix):
+ """
+ Return True if the alias locale requires interception before Wagtail handles the request:
+
+ 1. If lang_prefix is not a configured alias locale, we do not intercept.
+ 2. Otherwise, pre-Wagtail interception is needed when Wagtail cannot correctly
+ serve pages for this alias locale on its own. Either of these:
+ a. No Locale DB record exists, OR
+ b. The Locale record exists but site.root_page has no live translation in this locale.
+ Wagtail's default behaviour would serve the en-US homepage, which is not
+ what we want for alias locales.
+ """
+ fallback_locales = getattr(settings, "FALLBACK_LOCALES", {})
+ if lang_prefix not in fallback_locales:
+ return False
+
+ alias_locale = WagtailLocale.objects.filter(language_code=lang_prefix).first()
+ if not alias_locale:
+ return True
+
+ site = Site.objects.filter(is_default_site=True).select_related("root_page").first()
+ if not site:
+ return False
+
+ alias_root = site.root_page.get_translation_or_none(alias_locale)
+ return not alias_root or not alias_root.live
+
+
+def _serve_fallback_page(request, lang_prefix, sub_path, fallback_locales):
+ """
+ Find and serve the fallback locale's page for an alias locale URL.
+
+ Returns an HttpResponse if a fallback page is found, or None if no fallback
+ page exists (allowing the caller to fall through to other checks).
+
+ View-restricted pages are never transparently served; they redirect to the
+ canonical URL so Wagtail's restriction enforcement fires there.
+ """
+ fallback_page = find_fallback_page_for_locale(lang_prefix, sub_path)
+ if not fallback_page:
+ return None
+
+ if fallback_page.get_view_restrictions():
+ return HttpResponseRedirect(fallback_page.url)
+
+ specific_page = fallback_page.specific
+ request.content_locale = fallback_locales[lang_prefix]
+ # Note: we intentionally do NOT call translation.activate(request.content_locale)
+ # here, even though it would make BedrockLocale.get_active() resolve to the
+ # fallback locale's Wagtail Locale object.
+ # The reason: Django's LocalePrefixPattern.language_prefix reads
+ # translation.get_language() directly, so activating a different locale
+ # (e.g. pt-BR while serving /pt-PT/) would cause all url() template calls
+ # to generate links with the wrong locale prefix.
+ # Setting request.content_locale carries the fallback locale into the render
+ # pipeline without disturbing URL generation.
+ return specific_page.serve(request)
+
+
+def wagtail_serve_with_locale_fallback(request, path=""):
+ """
+ Wagtail serve wrapper that handles alias-locale fallback.
+
+ This can be used both as the Wagtail catch-all URL pattern (replacing
+ ``wagtail.views.serve``) and by ``prefer_cms`` (replacing its direct
+ ``wagtail_serve`` call).
+
+ For alias locales that Wagtail cannot serve correctly on its own
+ (no Locale DB record, or no live root page translation), this view
+ tries to serve the fallback locale's page before deferring to Wagtail.
+
+ For alias locales with a live root page in Wagtail, but no page at the specific
+ path (Wagtail would Http404), the fallback locale's page is also tried.
+
+ If no fallback page exists either, raises Http404 so that callers
+ (prefer_cms, middleware) can apply their own fallback logic.
+ """
+ _path = request.path.lstrip("/")
+ lang_prefix, _, sub_path = _path.partition("/")
+ fallback_locales = getattr(settings, "FALLBACK_LOCALES", {})
+
+ if _alias_needs_prewagtail_intercept(lang_prefix):
+ response = _serve_fallback_page(request, lang_prefix, sub_path, fallback_locales)
+ if response is not None:
+ return response
+ # No fallback page and Wagtail can't serve this alias locale
+ # correctly — raise 404 so:
+ # - prefer_cms can fall through to its Django view
+ # - CMSLocaleFallbackMiddleware can try the Accept-Language redirect
+ raise Http404
+
+ # Try to serve the response with Wagtail, but if Wagtail gives us a 404 for
+ # an alias locale, then we try to serve the fallback page.
+ # If we can't serve the fallback page, then keep Wagtail's 404, so:
+ # - prefer_cms can fall through to its Django view
+ # - CMSLocaleFallbackMiddleware can try the Accept-Language redirect
+ try:
+ wagtail_response = wagtail_serve(request, path)
+ except Http404:
+ if lang_prefix in fallback_locales:
+ response = _serve_fallback_page(request, lang_prefix, sub_path, fallback_locales)
+ if response is not None:
+ return response
+ raise
+ return wagtail_response
diff --git a/bedrock/cms/wagtail_hooks.py b/bedrock/cms/wagtail_hooks.py
index 8d419ffd24c..4effdbf016e 100644
--- a/bedrock/cms/wagtail_hooks.py
+++ b/bedrock/cms/wagtail_hooks.py
@@ -2,14 +2,19 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+import json
+
+from django.conf import settings
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import format_html
+from django.utils.safestring import mark_safe
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
from wagtail import hooks
from wagtail.admin.menu import MenuItem
from wagtail.admin.rich_text.converters.html_to_contentstate import InlineStyleElementHandler
+from wagtail.models import Locale as WagtailLocale
@hooks.register("register_admin_menu_item")
@@ -37,6 +42,28 @@ def global_admin_css():
return format_html('', static("css/cms/wagtail_admin.css"))
+@hooks.register("insert_global_admin_js")
+def mark_locale_roles_in_admin():
+ """Adds 'alias → X' badges next to alias locale names on the locales list page.
+
+ Injects the alias map as window.WAGTAIL_LOCALE_ALIAS_MAP, then loads the
+ static JS file that reads it and applies the badges to the DOM.
+ """
+ fallback_locales = getattr(settings, "FALLBACK_LOCALES", {})
+ if not fallback_locales:
+ return ""
+
+ alias_rows = WagtailLocale.objects.filter(language_code__in=fallback_locales.keys()).values_list("id", "language_code")
+ alias_id_map = {id: fallback_locales[code] for id, code in alias_rows}
+ if not alias_id_map:
+ return ""
+
+ return mark_safe(
+ f""
+ f''
+ )
+
+
@hooks.register("register_rich_text_features")
def register_underline_feature(features):
"""
diff --git a/bedrock/cms/wagtail_urls.py b/bedrock/cms/wagtail_urls.py
new file mode 100644
index 00000000000..aad812b02af
--- /dev/null
+++ b/bedrock/cms/wagtail_urls.py
@@ -0,0 +1,47 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+"""
+Custom Wagtail URL configuration that replaces the catch-all serve view.
+
+Wagtail's default urls.py registers a catch-all regex that routes all
+remaining page-like paths to ``wagtail.views.serve``. This module keeps
+Wagtail's utility URL patterns (password-protected page auth, frontend
+login) but swaps the catch-all with our own view that handles alias-locale
+fallback *before* deferring to Wagtail's serve.
+
+Because this lives in the URL router, it only runs for paths that no other
+Django view (including ``prefer_cms``-decorated views) has claimed.
+"""
+
+import logging
+
+from django.urls import re_path
+
+from wagtail.coreutils import WAGTAIL_APPEND_SLASH
+from wagtail.urls import urlpatterns as _wagtail_urlpatterns
+
+from bedrock.cms.views import wagtail_serve_with_locale_fallback
+
+logger = logging.getLogger(__name__)
+
+if WAGTAIL_APPEND_SLASH:
+ _serve_pattern = r"^((?:[\w\-]+/)*)$"
+else:
+ _serve_pattern = r"^([\w\-/]*)$"
+
+# Keep all Wagtail utility patterns; replace only the catch-all serve view.
+urlpatterns = [p for p in _wagtail_urlpatterns if getattr(p, "name", None) != "wagtail_serve"]
+
+_removed_count = len(_wagtail_urlpatterns) - len(urlpatterns)
+if _removed_count != 1:
+ logger.error(
+ "Expected to remove exactly 1 pattern ('wagtail_serve') from Wagtail's urlpatterns, but removed %d. Wagtail patterns: %r",
+ _removed_count,
+ _wagtail_urlpatterns,
+ )
+
+urlpatterns.append(
+ re_path(_serve_pattern, wagtail_serve_with_locale_fallback, name="wagtail_serve"),
+)
diff --git a/bedrock/firefox/templates/firefox/all/base.html b/bedrock/firefox/templates/firefox/all/base.html
index a1a257e3b2b..ee0d4471025 100644
--- a/bedrock/firefox/templates/firefox/all/base.html
+++ b/bedrock/firefox/templates/firefox/all/base.html
@@ -21,6 +21,7 @@
{% if product %}
{# do not index or follow child pages, we prefer people visit more user friendly pages from search results bedrock/16104 #}
+
{% else %}
{{ super() }}
{% endif %}
diff --git a/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html b/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html
index f565e9548fd..19e6ebac757 100644
--- a/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html
+++ b/bedrock/firefox/templates/firefox/browsers/mobile/get-ios.html
@@ -8,7 +8,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_css %}
{{ css_bundle('get-ios-attr') }}
diff --git a/bedrock/firefox/templates/firefox/developer/firstrun.html b/bedrock/firefox/templates/firefox/developer/firstrun.html
index 6e8a264f2bf..d7dd0011d7e 100644
--- a/bedrock/firefox/templates/firefox/developer/firstrun.html
+++ b/bedrock/firefox/templates/firefox/developer/firstrun.html
@@ -6,8 +6,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}{{ ftl('firefox-developer-page-title') }}{% endblock %}
{% block page_desc %}{{ ftl('firefox-developer-firefox-developer-edition-desc') }}{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/installer-help.html b/bedrock/firefox/templates/firefox/installer-help.html
index bf4728c62d6..546d3996034 100644
--- a/bedrock/firefox/templates/firefox/installer-help.html
+++ b/bedrock/firefox/templates/firefox/installer-help.html
@@ -7,8 +7,10 @@
{% extends "/firefox/base/base-protocol.html" %}
{% from "macros-protocol.html" import callout with context %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}{{ ftl('installer-help-page-title') }}{% endblock %}
{% block body_id %}installer-help{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/landing/education.html b/bedrock/firefox/templates/firefox/landing/education.html
index b5fa3e31798..936ec3e696a 100644
--- a/bedrock/firefox/templates/firefox/landing/education.html
+++ b/bedrock/firefox/templates/firefox/landing/education.html
@@ -8,7 +8,10 @@
{% extends "firefox/new/desktop/base.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block experiments %}{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/landing/gaming.html b/bedrock/firefox/templates/firefox/landing/gaming.html
index 8c57cb64fc1..a8d976b6b18 100644
--- a/bedrock/firefox/templates/firefox/landing/gaming.html
+++ b/bedrock/firefox/templates/firefox/landing/gaming.html
@@ -8,7 +8,10 @@
{% extends "firefox/new/desktop/base.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block experiments %}{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/landing/get.html b/bedrock/firefox/templates/firefox/landing/get.html
index f6b3316701c..dbdcdc1303f 100644
--- a/bedrock/firefox/templates/firefox/landing/get.html
+++ b/bedrock/firefox/templates/firefox/landing/get.html
@@ -6,7 +6,10 @@
{% extends "firefox/new/desktop/download.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block experiments %}{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/landing/tech.html b/bedrock/firefox/templates/firefox/landing/tech.html
index 3260c08c528..fc1294f5e79 100644
--- a/bedrock/firefox/templates/firefox/landing/tech.html
+++ b/bedrock/firefox/templates/firefox/landing/tech.html
@@ -6,7 +6,10 @@
{% extends "firefox/new/desktop/download.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% set win_custom_download_id = 'partner-firefox-release-smi-smi-001-stub' %}
{% set mac_custom_download_id = 'partner-firefox-release-smi-smi-001-latest' %}
diff --git a/bedrock/firefox/templates/firefox/new/basic/thanks.html b/bedrock/firefox/templates/firefox/new/basic/thanks.html
index 38de65a8a2e..7567a1a8381 100644
--- a/bedrock/firefox/templates/firefox/new/basic/thanks.html
+++ b/bedrock/firefox/templates/firefox/new/basic/thanks.html
@@ -10,7 +10,10 @@
{% block html_attrs %}data-test-fxa-template="firefox-download-thanks"{% endblock %}
{# "scene2" page should not be indexed to avoid it appearing in search results: issue 7024 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block extrahead %}
{{ super() }}
diff --git a/bedrock/firefox/templates/firefox/new/desktop/thanks.html b/bedrock/firefox/templates/firefox/new/desktop/thanks.html
index 3c61c3c2073..b6c1b03a911 100644
--- a/bedrock/firefox/templates/firefox/new/desktop/thanks.html
+++ b/bedrock/firefox/templates/firefox/new/desktop/thanks.html
@@ -10,7 +10,10 @@
{% block html_attrs %}data-test-fxa-template="firefox-download-thanks"{% endblock %}
{# "scene2" page should not be indexed to avoid it appearing in search results: issue 7024 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block extrahead %}
{{ super() }}
diff --git a/bedrock/firefox/templates/firefox/nightly/firstrun.html b/bedrock/firefox/templates/firefox/nightly/firstrun.html
index cf8b7420b6e..19b6990fefc 100644
--- a/bedrock/firefox/templates/firefox/nightly/firstrun.html
+++ b/bedrock/firefox/templates/firefox/nightly/firstrun.html
@@ -8,8 +8,10 @@
{% extends "/firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block site_css %}
{% if ftl_file_is_active('navigation_refresh') and ftl_file_is_active('footer-refresh') %}
diff --git a/bedrock/firefox/templates/firefox/nightly/whatsnew.html b/bedrock/firefox/templates/firefox/nightly/whatsnew.html
index 9d94c298b4c..948642adb61 100644
--- a/bedrock/firefox/templates/firefox/nightly/whatsnew.html
+++ b/bedrock/firefox/templates/firefox/nightly/whatsnew.html
@@ -6,8 +6,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block site_css %}
{% if ftl_file_is_active('navigation_refresh') and ftl_file_is_active('footer-refresh') %}
diff --git a/bedrock/firefox/templates/firefox/set-as-default/thanks.html b/bedrock/firefox/templates/firefox/set-as-default/thanks.html
index b6e7f1791a0..24055dbdac6 100644
--- a/bedrock/firefox/templates/firefox/set-as-default/thanks.html
+++ b/bedrock/firefox/templates/firefox/set-as-default/thanks.html
@@ -8,8 +8,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}{{ ftl('set-as-default-thanks-set-as-default-thanks-for-choosing-firefox') }}{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/welcome/base.html b/bedrock/firefox/templates/firefox/welcome/base.html
index da85d28c9c8..60ab06d9cd2 100644
--- a/bedrock/firefox/templates/firefox/welcome/base.html
+++ b/bedrock/firefox/templates/firefox/welcome/base.html
@@ -6,8 +6,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block site_css %}
{% if ftl_file_is_active('navigation_refresh') and ftl_file_is_active('footer-refresh') %}
diff --git a/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html b/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html
index 2aa19dd43f1..1fd63da884d 100644
--- a/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html
+++ b/bedrock/firefox/templates/firefox/whatsnew/base-new-theme.html
@@ -32,6 +32,7 @@
+
{% block extra_meta %}{% endblock %}
diff --git a/bedrock/firefox/templates/firefox/whatsnew/base.html b/bedrock/firefox/templates/firefox/whatsnew/base.html
index 600b05d3180..49bb110ef0a 100644
--- a/bedrock/firefox/templates/firefox/whatsnew/base.html
+++ b/bedrock/firefox/templates/firefox/whatsnew/base.html
@@ -6,8 +6,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{# Exclude stub attribution for in-product pages: issus 9620 #}
{% block stub_attribution %}{% endblock %}
diff --git a/bedrock/foundation/templates/foundation/openwebfund/thanks.html b/bedrock/foundation/templates/foundation/openwebfund/thanks.html
index 6ead2b3becd..acb02ab39ed 100644
--- a/bedrock/foundation/templates/foundation/openwebfund/thanks.html
+++ b/bedrock/foundation/templates/foundation/openwebfund/thanks.html
@@ -6,8 +6,10 @@
{% extends "foundation/base.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}Thank you{% endblock %}
diff --git a/bedrock/mozorg/templates/mozorg/about/this-site.html b/bedrock/mozorg/templates/mozorg/about/this-site.html
index c592f86906c..3dfa93be9e6 100644
--- a/bedrock/mozorg/templates/mozorg/about/this-site.html
+++ b/bedrock/mozorg/templates/mozorg/about/this-site.html
@@ -9,7 +9,10 @@
{% block page_title %}{{ ftl('about-this-site-title') }}{% endblock %}
{% block page_desc %}{{ ftl('about-this-site-desc') }}{% endblock %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_favicon %}{{ static('img/favicons/mozilla/dino/favicon.ico') }}{% endblock %}
{% block page_favicon_large %}{{ static('img/favicons/mozilla/dino/favicon-196x196.png') }}{% endblock %}
diff --git a/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html b/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html
index d24e33ffa51..29afe6fbf6f 100644
--- a/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html
+++ b/bedrock/mozorg/templates/mozorg/analytics-tests/ga-index.html
@@ -6,8 +6,10 @@
{% extends "firefox/base/base-protocol.html" %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block content %}
diff --git a/bedrock/newsletter/templates/newsletter/confirm.html b/bedrock/newsletter/templates/newsletter/confirm.html
index 7b10dea5cb3..c02025c5d60 100644
--- a/bedrock/newsletter/templates/newsletter/confirm.html
+++ b/bedrock/newsletter/templates/newsletter/confirm.html
@@ -6,8 +6,10 @@
{% extends 'newsletter/base.html' %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}{{ ftl('newsletters-newsletter-confirm') }}{% endblock page_title %}
diff --git a/bedrock/newsletter/templates/newsletter/country.html b/bedrock/newsletter/templates/newsletter/country.html
index 2a5aa8158d4..c9c88138e92 100644
--- a/bedrock/newsletter/templates/newsletter/country.html
+++ b/bedrock/newsletter/templates/newsletter/country.html
@@ -6,8 +6,10 @@
{% extends 'newsletter/base.html' %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}Newsletter Country or Region{% endblock page_title %}
diff --git a/bedrock/newsletter/templates/newsletter/fxa-error.html b/bedrock/newsletter/templates/newsletter/fxa-error.html
index df9f73bba81..a436dd5ec81 100644
--- a/bedrock/newsletter/templates/newsletter/fxa-error.html
+++ b/bedrock/newsletter/templates/newsletter/fxa-error.html
@@ -6,8 +6,10 @@
{% extends 'newsletter/base.html' %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block content %}
diff --git a/bedrock/newsletter/templates/newsletter/management.html b/bedrock/newsletter/templates/newsletter/management.html
index ba4665c4d9d..5f375d4ccec 100644
--- a/bedrock/newsletter/templates/newsletter/management.html
+++ b/bedrock/newsletter/templates/newsletter/management.html
@@ -8,8 +8,10 @@
{# Template used for a user to manage their subscriptions #}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_css %}
{{ css_bundle('protocol-emphasis-box') }}
diff --git a/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html b/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html
index e04489b9168..693fc5f60f6 100644
--- a/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html
+++ b/bedrock/newsletter/templates/newsletter/opt-out-confirmation.html
@@ -9,7 +9,10 @@
{% block page_title %}{{ ftl('opt-out-confirmation-cool-we-hear') }}{% endblock page_title %}
{% block page_desc %}{{ ftl('opt-out-confirmation-youre-now-opted') }}{% endblock %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_css %}
{{ css_bundle('newsletter-opt-out-confirmation') }}
diff --git a/bedrock/newsletter/templates/newsletter/recovery.html b/bedrock/newsletter/templates/newsletter/recovery.html
index ba8a91c8090..3641ba6a825 100644
--- a/bedrock/newsletter/templates/newsletter/recovery.html
+++ b/bedrock/newsletter/templates/newsletter/recovery.html
@@ -6,8 +6,10 @@
{% extends 'newsletter/base.html' %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title %}{{ ftl('newsletters-newsletter-email-recovery') }}{% endblock page_title %}
diff --git a/bedrock/newsletter/templates/newsletter/updated.html b/bedrock/newsletter/templates/newsletter/updated.html
index 7f2db46f6a8..caf21859dd6 100644
--- a/bedrock/newsletter/templates/newsletter/updated.html
+++ b/bedrock/newsletter/templates/newsletter/updated.html
@@ -8,8 +8,10 @@
{% extends 'newsletter/base.html' %}
-{# "noindex" pages should not have the canonical or hreflang tags: bug 1442331 #}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_css %}
{{ css_bundle('protocol-card') }}
diff --git a/bedrock/privacy/templates/privacy/data-preferences.html b/bedrock/privacy/templates/privacy/data-preferences.html
index 8ac36255344..a1ab0832c41 100644
--- a/bedrock/privacy/templates/privacy/data-preferences.html
+++ b/bedrock/privacy/templates/privacy/data-preferences.html
@@ -9,7 +9,10 @@
{% block page_title %}{{ ftl('data-preferences-page-title') }}{% endblock %}
{% block page_desc %}{{ ftl('data-preferences-page-desc') }}{% endblock %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_css %}
{{ css_bundle('protocol-article') }}
diff --git a/bedrock/products/templates/products/monitor/waitlist/base.html b/bedrock/products/templates/products/monitor/waitlist/base.html
index 1209c1002dd..ef3bf894cea 100644
--- a/bedrock/products/templates/products/monitor/waitlist/base.html
+++ b/bedrock/products/templates/products/monitor/waitlist/base.html
@@ -6,7 +6,10 @@
{% extends 'base-protocol-mozilla.html' %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block body_class %}{{ super() }} monitor-waitlist{% endblock %}
diff --git a/bedrock/products/templates/products/vpn/mac-download.html b/bedrock/products/templates/products/vpn/mac-download.html
index e22477d36cf..b14ab915f1a 100644
--- a/bedrock/products/templates/products/vpn/mac-download.html
+++ b/bedrock/products/templates/products/vpn/mac-download.html
@@ -6,7 +6,10 @@
{% extends "products/vpn/download.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title_full %}{{ ftl('vpn-mac-download-page-title') }}{% endblock %}
diff --git a/bedrock/products/templates/products/vpn/pricing-refresh.html b/bedrock/products/templates/products/vpn/pricing-refresh.html
index bcb2871577d..275fd5ed613 100644
--- a/bedrock/products/templates/products/vpn/pricing-refresh.html
+++ b/bedrock/products/templates/products/vpn/pricing-refresh.html
@@ -14,7 +14,10 @@
{% block body_class %}mozilla-vpn-pricing-page{% endblock %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% set _utm_source = 'www.mozilla.org-vpn-product-page' %}
{% set _utm_campaign = 'vpn-pricing-page' %}
diff --git a/bedrock/products/templates/products/vpn/windows-download.html b/bedrock/products/templates/products/vpn/windows-download.html
index 81a381687da..02aabe84373 100644
--- a/bedrock/products/templates/products/vpn/windows-download.html
+++ b/bedrock/products/templates/products/vpn/windows-download.html
@@ -6,7 +6,10 @@
{% extends "products/vpn/download.html" %}
-{% block canonical_urls %}{% endblock %}
+{% block canonical_urls %}
+
+
+{% endblock %}
{% block page_title_full %}{{ ftl('vpn-windows-download-page-title') }}{% endblock %}
diff --git a/bedrock/settings/base.py b/bedrock/settings/base.py
index 57a2290d049..9a4b02c2e46 100644
--- a/bedrock/settings/base.py
+++ b/bedrock/settings/base.py
@@ -11,9 +11,7 @@
from pathlib import Path
from urllib.parse import urlparse
-from django.conf.locale import (
- LANG_INFO, # we patch this in bedrock.base.apps.BaseAppConfig # noqa: F401
-)
+from django.conf.locale import LANG_INFO
from django.utils.functional import lazy
import dj_database_url
@@ -145,6 +143,9 @@ def data_path(*args):
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = "en-US"
+# Add a fallback for zh-CN, which doesn't exist in core Django
+LANG_INFO["zh-CN"] = {"fallback": ["zh-hans"]}
+
# Languages using BiDi (right-to-left) layout. Overrides/extends Django default.
LANGUAGES_BIDI = ["ar", "ar-dz", "fa", "he", "skr", "ur"]
@@ -371,6 +372,9 @@ def get_dev_languages():
"es-AR": "es-ES",
"es-CL": "es-ES",
"es-MX": "es-ES",
+ "pt-PT": "pt-BR",
+ "en-GB": "en-US",
+ "en-CA": "en-US",
}
@@ -2580,14 +2584,20 @@ def lazy_wagtail_langs():
# 2) These are the Bedrock-side lang codes. They are mapped to
# Smartling-specific ones in the WAGTAIL_LOCALIZE_SMARTLING settings, below
("en-US", "English (US)"),
+ ("en-GB", "English (Great Britain)"),
+ ("en-CA", "English (Canada)"),
("de", "German"),
("fr", "French"),
+ ("es-AR", "Spanish (Argentina)"),
("es-ES", "Spanish (Spain)"),
+ ("es-CL", "Spanish (Chile)"),
+ ("es-MX", "Spanish (México)"),
("it", "Italian"),
("ja", "Japanese"),
("nl", "Dutch (Netherlands)"),
("pl", "Polish"),
("pt-BR", "Portuguese (Brazil)"),
+ ("pt-PT", "Portuguese (Portugal)"),
("ru", "Russian"),
("zh-CN", "Chinese (China-Simplified)"),
]
@@ -2658,6 +2668,7 @@ def lazy_wagtail_core_langs():
),
"REFORMAT_LANGUAGE_CODES": False, # don't force language codes into Django's all-lowercase pattern
"VISUAL_CONTEXT_CALLBACK": "bedrock.cms.wagtail_localize_smartling.callbacks.visual_context",
+ "EXCLUDE_LOCALES": list(FALLBACK_LOCALES.keys()),
}
WAGTAILDRAFTSHARING = {
diff --git a/bedrock/urls.py b/bedrock/urls.py
index 88d079136ff..9dc8b31e4a3 100644
--- a/bedrock/urls.py
+++ b/bedrock/urls.py
@@ -8,13 +8,13 @@
from django.utils.module_loading import import_string
import wagtaildraftsharing.urls as wagtaildraftsharing_urls
-from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
from watchman import views as watchman_views
from bedrock.base import views as base_views
from bedrock.base.i18n import bedrock_i18n_patterns
+from bedrock.cms import wagtail_urls
from bedrock.cms.decorators import prefer_cms
from bedrock.mozorg import views as mozorg_views
@@ -96,8 +96,10 @@
)
# Note that statics are handled via Whitenoise's middleware
-# Wagtail is the catch-all route, and it will raise a 404 if needed.
-# Note that we're also using localised URLs here
+# Wagtail catch-all: uses our custom wagtail_urls module which replaces
+# Wagtail's serve view with one that handles alias-locale fallback.
+# Because this is in the URL router, it only fires for paths that no other
+# Django view (including prefer_cms-decorated views) has claimed.
urlpatterns += bedrock_i18n_patterns(
path("", include(wagtail_urls)),
)
diff --git a/bin/export-db-to-sqlite.sh b/bin/export-db-to-sqlite.sh
index 0b44d02185a..7c0780f1f49 100755
--- a/bin/export-db-to-sqlite.sh
+++ b/bin/export-db-to-sqlite.sh
@@ -212,6 +212,7 @@ export DATABASE_URL=sqlite:///$output_db # Note that the three slashes is key
check_status_and_handle_failure "Setting up new output DB at $output_db"
PROD_DETAILS_STORAGE=product_details.storage.PDFileStorage \
+SQLITE_EXPORT_MODE=True \
python manage.py migrate || all_well=false
check_status_and_handle_failure "Running Django migrations"
diff --git a/lib/l10n_utils/__init__.py b/lib/l10n_utils/__init__.py
index c8e44bab149..774aec0e685 100644
--- a/lib/l10n_utils/__init__.py
+++ b/lib/l10n_utils/__init__.py
@@ -110,6 +110,13 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non
ftl_files = ftl_files or context.get("ftl_files")
locale = get_locale(request)
+ # For alias fallback pages (for example, if a user requests /es-AR/somepage/,
+ # but a somepage does not exist in the es-AR locale, so the user is served
+ # a page from the fallback es-ES locale), the user-facing locale is the
+ # alias (es-AR), but the content locale is the fallback locale (es-ES).
+ # Use locale_in_url for URL prefix comparisons to avoid spurious redirects.
+ locale_in_url, _, _ = split_path_and_normalize_language(request.path)
+
# is this a non-locale page?
name_prefix = request.path_info.split("/", 2)[1]
non_locale_url = non_locale_url or name_prefix in settings.SUPPORTED_NONLOCALES or request.path_info in settings.SUPPORTED_LOCALE_IGNORE
@@ -164,6 +171,17 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non
context["translations"] = get_translations_native_names(translations)
+ # content_locales: locales with content (not including alias locales that
+ # serve another locale's content).
+ # For CMS pages, _content_locales_via_cms only includes aliases that have
+ # their own translated page. For non-CMS pages, it equals `translations`
+ # (Fluent content is always real content, never an alias).
+ if is_cms_page:
+ content_locales = getattr(request, "_content_locales_via_cms", translations)
+ else:
+ content_locales = translations
+ context["content_locales"] = set(content_locales)
+
# Ensure the path requires a locale prefix.
if not non_locale_url:
# If the requested path's locale is different from the best matching
@@ -177,10 +195,27 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non
# Redirect to the locale if:
# - The URL is the root path but is missing the trailing slash OR
# - The locale isn't the current prefix in the URL
- if request.path == f"/{locale}" or locale != request.path.lstrip("/").partition("/")[0]:
- return redirect_to_locale(request, locale)
+ # Use locale_in_url (the URL prefix) for both the comparison and the
+ # redirect target so that CMS alias pages (where locale = fallback, e.g.
+ # pt-BR, but locale_in_url = alias, e.g. pt-PT) don't trigger a spurious
+ # redirect.
+ if request.path == f"/{locale_in_url}" or locale_in_url != request.path.lstrip("/").partition("/")[0]:
+ return redirect_to_locale(request, locale_in_url or locale)
else:
- return redirect_to_best_locale(request, translations)
+ # Before redirecting, check whether this locale is a configured alias
+ # (e.g. en-CA → en-US) and the fallback locale has translations. If
+ # the fallback locale has content, serve it transparently at the
+ # alias URL instead of redirecting.
+ fallback_locale = getattr(settings, "FALLBACK_LOCALES", {}).get(locale_in_url)
+ if fallback_locale and fallback_locale in translations and not is_root_path_with_no_language_clues(request):
+ # Serve the fallback locale's content at the alias URL.
+ request.content_locale = fallback_locale
+ locale = normalize_language(fallback_locale)
+ # Reload Fluent with the fallback locale so templates render the
+ # correct translations instead of falling back to en-US.
+ context["fluent_l10n"] = fluent_l10n([locale, "en"], ftl_files or settings.FLUENT_DEFAULT_FILES)
+ else:
+ return redirect_to_best_locale(request, translations)
# Look for locale-specific template in app/templates/
locale_tmpl = f".{locale}".join(splitext(template))
@@ -194,8 +229,12 @@ def render(request, template, context=None, ftl_files=None, activation_files=Non
def get_locale(request):
+ # content_locale is set on the request when serving fallback content at an
+ # alias locale URL (e.g. pt-BR content at /pt-PT/). Use it as the authoritative
+ # locale for Fluent loading so that nav/footer strings render in the correct
+ # language rather than falling back to English.
# request.locale is added in bedrock.base.middleware.BedrockLangCodeFixupMiddleware
- lang = getattr(request, "locale", None)
+ lang = getattr(request, "content_locale", None) or getattr(request, "locale", None)
if not lang:
lang = settings.LANGUAGE_CODE
return normalize_language(lang)
diff --git a/lib/l10n_utils/tests/test_base.py b/lib/l10n_utils/tests/test_base.py
index f3ead5e0be8..e69010869e5 100644
--- a/lib/l10n_utils/tests/test_base.py
+++ b/lib/l10n_utils/tests/test_base.py
@@ -5,6 +5,7 @@
import os
from unittest.mock import ANY, Mock, call, patch
+from django.http import HttpResponse
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
@@ -176,6 +177,90 @@ def test_activation_files(self, fal_mock, dr_mock):
any_order=True,
)
+ @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"})
+ def test_alias_locale_served_transparently(self):
+ """A request to an alias locale URL serves fallback content at that URL (no redirect)."""
+ # en-US is in translations, en-CA is not — alias fallback should kick in
+ self._test(
+ "/en-CA/firefox/new/",
+ "firefox/new.html",
+ locale="en-CA",
+ accept_lang="",
+ status=200,
+ active_locales=["en-US", "fr"],
+ )
+
+ @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"})
+ def test_alias_locale_sets_content_locale_on_request(self):
+ """render() sets request.content_locale to the fallback locale code."""
+ request = RequestFactory().get("/en-CA/firefox/new/")
+ request.locale = "en-CA"
+ with patch.object(l10n_utils, "django_render") as mock_render:
+ mock_render.return_value = HttpResponse()
+ l10n_utils.render(request, "firefox/new.html", {"active_locales": ["en-US", "fr"]})
+ assert getattr(request, "content_locale", None) == "en-US"
+
+ @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"})
+ def test_alias_locale_sets_content_locales_in_context(self):
+ """context['content_locales'] equals translations (no alias expansion) for non-CMS pages."""
+ request = RequestFactory().get("/en-CA/firefox/new/")
+ request.locale = "en-CA"
+ with patch.object(l10n_utils, "django_render") as mock_render:
+ mock_render.return_value = HttpResponse()
+ l10n_utils.render(request, "firefox/new.html", {"active_locales": ["en-US", "fr"]})
+ ctx = mock_render.call_args[0][2]
+ assert ctx["content_locales"] == {"en-US", "fr"}
+ assert "en-CA" not in ctx["content_locales"]
+
+ @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"})
+ def test_alias_locale_redirects_when_fallback_also_missing(self):
+ """If the fallback locale is also not in translations, still redirect."""
+ self._test(
+ "/en-CA/firefox/new/",
+ "firefox/new.html",
+ locale="en-CA",
+ accept_lang="",
+ status=302,
+ destination="/fr/firefox/new/",
+ active_locales=["fr"], # neither en-CA nor en-US present
+ )
+
+ @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"})
+ def test_alias_locale_root_url_served_transparently(self):
+ """GET /en-CA/ (alias locale root with no sub-path) is served transparently — no redirect."""
+ self._test(
+ "/en-CA/",
+ "firefox/new.html",
+ locale="en-CA",
+ accept_lang="",
+ status=200,
+ active_locales=["en-US", "fr"],
+ )
+
+ @override_settings(FALLBACK_LOCALES={"en-CA": "en-US"})
+ def test_alias_locale_served_when_is_cms_page_false(self):
+ """
+ When prefer_cms falls back to the Django view after a CMS 404, it explicitly
+ sets request.is_cms_page = False before calling the view. Verify that
+ l10n_utils.render() still performs alias serving in that state.
+
+ This guards against a regression where the FALLBACK_LOCALES check in render()
+ would accidentally be gated on is_cms_page, which would break the
+ prefer_cms → Django-fallback → alias-serving path.
+ """
+ request = RequestFactory().get("/en-CA/firefox/new/")
+ request.locale = "en-CA"
+ request.is_cms_page = False # explicitly set by prefer_cms after CMS 404
+
+ with patch.object(l10n_utils, "django_render") as mock_render:
+ mock_render.return_value = HttpResponse()
+ l10n_utils.render(request, "firefox/new.html", {"active_locales": ["en-US", "fr"]})
+
+ # Alias serving fired: django_render was called (no redirect), and
+ # content_locale was set to the fallback locale.
+ assert mock_render.called
+ assert getattr(request, "content_locale", None) == "en-US"
+
class TestGetAcceptLanguages(TestCase):
def _test(self, accept_lang, list):
@@ -311,3 +396,45 @@ def test_get_best_translation(translations, accept_languages, expected):
def test_get_best_translation__strict(translations, accept_languages, expected):
# Strict is used for the root path, to return the list of localized home pages for bots.
assert l10n_utils.get_best_translation(translations, accept_languages, strict=True) == expected
+
+
+@pytest.mark.parametrize(
+ "locale_is_set, locale_value, content_locale_is_set, content_locale_value, language_code_setting, expected",
+ (
+ (True, "es-AR", True, "es-MX", "en-US", "es-MX"), # content_locale wins over locale
+ (False, "", True, "es-MX", "en-US", "es-MX"), # content_locale wins even without locale
+ (True, "es-AR", False, "", "en-US", "es-AR"), # locale wins when no content_locale
+ (False, "", False, "", "en-US", "en-US"), # LANGUAGE_CODE fallback
+ ),
+)
+def test_get_locale_preference_order(
+ locale_is_set,
+ locale_value,
+ content_locale_is_set,
+ content_locale_value,
+ language_code_setting,
+ expected,
+ rf,
+):
+ request = rf.get("/")
+ if locale_is_set:
+ request.locale = locale_value
+ if content_locale_is_set:
+ request.content_locale = content_locale_value
+ with override_settings(LANGUAGE_CODE=language_code_setting):
+ assert l10n_utils.get_locale(request) == expected
+
+
+@patch.object(l10n_utils, "django_render")
+def test_render_does_not_redirect_when_content_locale_differs_from_url_locale(render_mock, rf):
+ """When content_locale is already set on the request (CMS alias page),
+ render() must not redirect even though locale != locale_in_url."""
+ render_mock.return_value = HttpResponse()
+ request = rf.get("/es-AR/download/")
+ request.locale = "es-AR"
+ request.content_locale = "es-MX" # already set by CMS alias serving logic
+
+ response = l10n_utils.render(request, "some.html", {"active_locales": ["es-MX"]})
+
+ assert response.status_code == 200 # must serve, not redirect to /es-MX/
+ render_mock.assert_called_once()
diff --git a/media/js/cms/wagtailadmin-locale-badges.js b/media/js/cms/wagtailadmin-locale-badges.js
new file mode 100644
index 00000000000..436686e6327
--- /dev/null
+++ b/media/js/cms/wagtailadmin-locale-badges.js
@@ -0,0 +1,26 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+!(function () {
+ 'use strict';
+ document.addEventListener('DOMContentLoaded', function () {
+ var e = document.getElementById('locales-list');
+ if (e) {
+ var t = window.WAGTAIL_LOCALE_ALIAS_MAP || {};
+ Object.entries(t).forEach(function (t) {
+ var n = t[0],
+ a = t[1],
+ s = e.querySelector('a[href$="/locales/edit/' + n + '/"]');
+ if (s) {
+ var c = document.createElement('span');
+ (c.className = 'w-status w-status--label'),
+ (c.textContent = 'alias → ' + a),
+ s.insertAdjacentElement('afterend', c);
+ }
+ });
+ }
+ });
+})();
diff --git a/media/static-bundles.json b/media/static-bundles.json
index 5ef1fa96f4f..bfee459af6a 100644
--- a/media/static-bundles.json
+++ b/media/static-bundles.json
@@ -1912,6 +1912,12 @@
"js/anonym/anonym-contact-form.js"
],
"name": "anonym"
+ },
+ {
+ "files": [
+ "js/cms/wagtailadmin-locale-badges.js"
+ ],
+ "name": "wagtailadmin-locale-badges"
}
]
}