Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
13c0e04
Upgrade wagtail-localize-smartling and -dashboard; add zh-CN LANG_INF…
stevejalim Apr 13, 2026
bbace8d
Add alias locale transparent serving (pt-PT, en-GB, en-CA)
stevejalim Apr 13, 2026
042efbf
Fix page.localized and URLs for alias locales (port Springfield f50d3…
stevejalim Apr 13, 2026
30b4e9f
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
stevejalim Apr 13, 2026
a9e4b32
Add es-CL, es-MX to WAGTAIL_CONTENT_LANGUAGES; fix EXCLUDE_LOCALES va…
stevejalim Apr 13, 2026
82fa9cd
fix failing tests by correctly adding es-AR as a wagtail lang
stevejalim Apr 13, 2026
60486d3
populate _content_locales_via_cms for requests, so it can be used to …
dchukhin Apr 14, 2026
11b2a24
set up content_locales and alias fallback for non-Wagtail pages
dchukhin Apr 14, 2026
c00fa8b
add CANONICAL_LANG to page context
dchukhin Apr 14, 2026
12291db
update templates to correctly set canonical URL, noindex, and hreflan…
dchukhin Apr 14, 2026
f42f9be
explicitly set SQLITE_EXPORT_MODE when running db export
dchukhin Apr 15, 2026
970d1a7
bundle the admin badge JS like in springfield
dchukhin Apr 15, 2026
6b6dd41
add unit tests for new code that is missing test coverage
dchukhin Apr 15, 2026
1a0a997
add unit tests for fallback scenarios on a prefer_cms() view
dchukhin Apr 15, 2026
804c4f1
auto-exclude alias locales from smartling sync
dchukhin Apr 15, 2026
201d211
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
dchukhin Apr 15, 2026
97f747f
add template for newly-added tests
dchukhin Apr 15, 2026
a60f35b
make sure alias locales show up in language picker for static pages i…
dchukhin Apr 15, 2026
dd0e950
set up es-MX as an alias locale to fall back to es-ES
dchukhin Apr 16, 2026
97f0095
make sure es-MX exists as a locale in Wagtail
dchukhin Apr 16, 2026
712e7e5
update tests to not use es-MX as a fallback locale (since it is an al…
dchukhin Apr 16, 2026
b6e8061
update comment to not refer to es-MX as a fallback locale (since it i…
dchukhin Apr 16, 2026
bb3b483
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
dchukhin Apr 21, 2026
e822faa
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
stevejalim Apr 27, 2026
777706d
make sure that fallback Page content is served for alias locales when…
dchukhin Apr 28, 2026
04b2530
show canonical tag even if a page is not indexed
dchukhin Apr 28, 2026
ae218ea
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
dchukhin Apr 28, 2026
a570ecb
Merge branch 'main' into WT-997--l10n-tech-parity-with-springfield
stevejalim Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion bedrock/base/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
14 changes: 12 additions & 2 deletions bedrock/base/templates/includes/canonical-url.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@
#}

{%- set available_languages = get_locale_options(request, translations) -%}
{%- set alias_locales = settings.FALLBACK_LOCALES|default({}) -%}

<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + LANG + canonical_path }}">
<link rel="canonical" href="{{ settings.CANONICAL_URL + '/' + CANONICAL_LANG + canonical_path }}">
{%- if CANONICAL_LANG != LANG %}
<meta name="robots" content="noindex,follow">
{%- endif %}
{% if is_homepage %}<link rel="alternate" hreflang="x-default" href="{{ settings.CANONICAL_URL }}{{ canonical_path }}">{% 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 -%}
Expand Down Expand Up @@ -38,14 +46,16 @@
<link rel="alternate" hreflang="pa" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="ਪੰਜਾਬੀ">
<link rel="alternate" hreflang="pa-IN" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="{{ label|safe }}">
{% elif code == 'pt-PT' -%}
<link rel="alternate" hreflang="pt" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="Português">
<link rel="alternate" hreflang="pt-PT" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="{{ label|safe }}">
{% elif code == 'sv-SE' -%}
<link rel="alternate" hreflang="sv" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="Svenska">
<link rel="alternate" hreflang="sv-SE" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="{{ label|safe }}">
{% elif code == 'zh-CN' -%}
<link rel="alternate" hreflang="zh" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="中文">
<link rel="alternate" hreflang="zh-CN" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="{{ label|safe }}">
{% elif code == 'pt-BR' -%}
<link rel="alternate" hreflang="pt" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="Português">
<link rel="alternate" hreflang="pt-BR" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="{{ label|safe }}">
{% elif code|length != 3 -%}{#- Bug 1364470: Drop ISO 639-2 and -3 locales not supported by Google -#}
<link rel="alternate" hreflang="{{ code }}" href="{{ settings.CANONICAL_URL + '/' + code + loop_canonical_path }}" title="{{ label|safe }}">
{% endif -%}
Expand Down
13 changes: 13 additions & 0 deletions bedrock/base/templatetags/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
18 changes: 18 additions & 0 deletions bedrock/base/tests/test_context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import jinja2

from bedrock.base.context_processors import i18n
from lib.l10n_utils import translation


Expand Down Expand Up @@ -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"
47 changes: 47 additions & 0 deletions bedrock/base/tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
(
Expand Down Expand Up @@ -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
17 changes: 9 additions & 8 deletions bedrock/cms/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions bedrock/cms/migrations/0005_create_alias_locale_records.py
Original file line number Diff line number Diff line change
@@ -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
# /<alias-locale>/ — 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),
]
Loading
Loading