Repository: freedomofpress/securedrop

Issue:
make BCP47/RFC5646 `RequestLocaleInfo.language_tag` property available in locale selectors
## Description

In `securedrop/source_templates/locales.html`, the `lang` and `hreflang` attributes require the value of the `RequestLocaleInfo.language_tag` property, which is not available in the `g.locales` dictionary.

## Steps to Reproduce

After #6041, validate the HTML of a Source Interface page including the `locales.html` locale selector (i.e., in any of them).

## Expected Behavior

The page validates.

## Actual Behavior

Some locales' `lang` and `hreflang` attributes are invalid.

## Comments

The trick will be to make the `RequestLocaleInfo.language_tag` property available to the Jinja template, which currently has access only to the contents of the `g.locales` dictionary.

1. [x] `securedrop/i18n.py`: `map_locale_display_names()`: return map of `RequestLocaleInfo` objects
2. [x] `securedrop/{journalist,source}_templates/locales.html`: `s/g.locales[locale]/g.locales[locale].display_name` (pending #6041)
3. [x] `securedrop/{journalist,source}_templates/locales.html`: use `g.locales[locale].language_tag` in `lang` and `hreflang` attributes (pending #6041)


Discussion:

Resolving patch:
diff --git a/securedrop/i18n.py b/securedrop/i18n.py
--- a/securedrop/i18n.py
+++ b/securedrop/i18n.py
@@ -40,12 +40,27 @@ class RequestLocaleInfo:
     def __init__(self, locale: str):
         self.locale = Locale.parse(locale)
 
+        # This attribute can be set to `True` to differentiate multiple
+        # locales currently available (supported) for the same language.
+        self.use_display_name = False
+
     def __str__(self) -> str:
         """
         The Babel string representation of the locale.
         """
         return str(self.locale)
 
+    @property
+    def display_name(self) -> str:
+        """
+        Give callers (i.e., templates) the `Locale` object's display name when
+        such resolution is warranted, otherwise the language name---as
+        determined by `map_locale_display_names()`.
+        """
+        if self.use_display_name:
+            return self.locale.display_name
+        return self.locale.language_name
+
     @property
     def text_direction(self) -> str:
         """
@@ -135,7 +150,7 @@ def validate_locale_configuration(config: SDConfig, app: Flask) -> None:
             )
 
 
-LOCALES = collections.OrderedDict()  # type: collections.OrderedDict[str, str]
+LOCALES = collections.OrderedDict()  # type: collections.OrderedDict[str, RequestLocaleInfo]
 
 
 def map_locale_display_names(config: SDConfig) -> None:
@@ -149,17 +164,15 @@ def map_locale_display_names(config: SDConfig) -> None:
     """
     language_locale_counts = collections.defaultdict(int)  # type: Dict[str, int]
     for l in sorted(config.SUPPORTED_LOCALES):
-        locale = Locale.parse(l)
-        language_locale_counts[locale.language_name] += 1
+        locale = RequestLocaleInfo(l)
+        language_locale_counts[locale.language] += 1
 
     locale_map = collections.OrderedDict()
     for l in sorted(config.SUPPORTED_LOCALES):
-        locale = Locale.parse(l)
-        if language_locale_counts[locale.language_name] == 1:
-            name = locale.language_name
-        else:
-            name = locale.display_name
-        locale_map[str(locale)] = name
+        locale = RequestLocaleInfo(l)
+        if language_locale_counts[locale.language] > 1:
+            locale.use_display_name = True
+        locale_map[str(locale)] = locale
 
     global LOCALES
     LOCALES = locale_map


Test patch:
diff --git a/securedrop/tests/test_i18n.py b/securedrop/tests/test_i18n.py
--- a/securedrop/tests/test_i18n.py
+++ b/securedrop/tests/test_i18n.py
@@ -158,6 +158,16 @@ def verify_i18n(app):
         locales = render_template('locales.html')
         assert '?l=fr_FR' in locales
         assert '?l=en_US' not in locales
+
+        # Test that A[lang,hreflang] attributes (if present) will validate as
+        # BCP47/RFC5646 language tags from `i18n.RequestLocaleInfo.language_tag`.
+        if 'lang="' in locales:
+            assert 'lang="en-US"' in locales
+            assert 'lang="fr-FR"' in locales
+        if 'hreflang="' in locales:
+            assert 'hreflang="en-US"' in locales
+            assert 'hreflang="fr-FR"' in locales
+
         c.get('/?l=ar')
         base = render_template('base.html')
         assert 'dir="rtl"' in base