Repository: WeblateOrg/weblate
Pull request #14191: Replace ", ".join(...) with format_html_join_comma for proper localization
Author: login: abhinavohri
Created: 2025-03-12T21:24:44Z | Merged: 2025-04-08T11:14:04Z

Replaced instances of `", ".join(...) `with `format_html_join_comma`, ensuring proper localization when rendering UI content.

Fixes https://github.com/WeblateOrg/weblate/issues/13896


Diff:
diff --git a/weblate/billing/models.py b/weblate/billing/models.py
index 5e55b32163b2..ef3033a6ff2e 100644
--- a/weblate/billing/models.py
+++ b/weblate/billing/models.py
@@ -28,6 +28,7 @@
 from weblate.auth.models import User
 from weblate.trans.models import Alert, Component, Project, Translation
 from weblate.utils.decorators import disable_for_loaddata
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 from weblate.utils.stats import prefetch_stats
 
 
@@ -206,7 +207,9 @@ def __str__(self) -> str:
         if projects:
             base = projects
         elif owners:
-            base = ", ".join(x.get_visible_name() for x in owners)
+            base = format_html_join_comma(
+                "{}", list_to_tuples(x.get_visible_name() for x in owners)
+            )
         else:
             base = "Unassigned"
         trial = ", trial" if self.is_trial else ""
@@ -249,7 +252,7 @@ def all_projects(self):
 
     @cached_property
     def projects_display(self):
-        return ", ".join(str(x) for x in self.all_projects)
+        return format_html_join_comma("{}", list_to_tuples(self.all_projects))
 
     @property
     def is_trial(self):
@@ -624,7 +627,7 @@ def clean(self) -> None:
 
         if overlapping.exists():
             msg = "Overlapping invoices exist: {}".format(
-                ", ".join(str(x) for x in overlapping)
+                format_html_join_comma("{}", list_to_tuples(overlapping))
             )
             raise ValidationError(msg)
 
diff --git a/weblate/checks/fluent/inner_html.py b/weblate/checks/fluent/inner_html.py
index 8d5bd8ccd080..49771cd788e5 100644
--- a/weblate/checks/fluent/inner_html.py
+++ b/weblate/checks/fluent/inner_html.py
@@ -19,6 +19,7 @@
     translation_from_check,
     variant_name,
 )
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 
 if TYPE_CHECKING:
     from collections.abc import Iterable, Iterator
@@ -1049,7 +1050,9 @@ def _present_variant_list(
     ) -> str:
         if not variant_list:
             return ""
-        return ", ".join(variant.name() for variant in variant_list)
+        return format_html_join_comma(
+            "{}", list_to_tuples(variant.name() for variant in variant_list)
+        )
 
     def _unique_target_nodes(self) -> Iterator[_HTMLNode]:
         unique_nodes: list[_HTMLNode] = []
diff --git a/weblate/checks/fluent/references.py b/weblate/checks/fluent/references.py
index 1af1097c09ab..e40a3c36b754 100644
--- a/weblate/checks/fluent/references.py
+++ b/weblate/checks/fluent/references.py
@@ -17,6 +17,7 @@
     translation_from_check,
     variant_name,
 )
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 
 if TYPE_CHECKING:
     from collections.abc import Iterable, Iterator
@@ -445,7 +446,9 @@ def _present_variant_list(
     ) -> str:
         if not variant_list:
             return ""
-        return ", ".join(variant.name() for variant in variant_list)
+        return format_html_join_comma(
+            "{}", list_to_tuples(variant.name() for variant in variant_list)
+        )
 
     def _unique_target_refs(self) -> Iterator[_Reference]:
         unique_refs: list[_Reference] = []
diff --git a/weblate/checks/format.py b/weblate/checks/format.py
index e87f16b00f9a..0da5c26d61d1 100644
--- a/weblate/checks/format.py
+++ b/weblate/checks/format.py
@@ -15,6 +15,7 @@
 from django.utils.translation import gettext, gettext_lazy
 
 from weblate.checks.base import MissingExtraDict, SourceCheck, TargetCheck
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 
 if TYPE_CHECKING:
     from collections.abc import Callable, Iterable
@@ -479,7 +480,12 @@ def format_result(self, result: MissingExtraDict) -> Iterable[StrOrPromise]:
         ):
             yield gettext(
                 "The following format strings are in the wrong order: %s"
-            ) % ", ".join(self.format_string(x) for x in sorted(set(result["missing"])))
+            ) % format_html_join_comma(
+                "{}",
+                list_to_tuples(
+                    self.format_string(x) for x in sorted(set(result["missing"]))
+                ),
+            )
         else:
             yield from super().format_result(result)
 
diff --git a/weblate/checks/icu.py b/weblate/checks/icu.py
index 65dcfd06d5f2..259a13270163 100644
--- a/weblate/checks/icu.py
+++ b/weblate/checks/icu.py
@@ -12,6 +12,7 @@
 
 from weblate.checks.base import SourceCheck
 from weblate.checks.format import BaseFormatCheck
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 
 if TYPE_CHECKING:
     from weblate.trans.models import Unit
@@ -452,61 +453,66 @@ def check_tags(self, result, name, data, src_data, flags) -> None:
 
     def format_result(self, result):
         if result.get("syntax"):
-            yield gettext("Syntax error: %s") % ", ".join(
-                err.msg or "unknown error" for err in result["syntax"]
+            yield gettext("Syntax error: %s") % format_html_join_comma(
+                "{}",
+                list_to_tuples(err.msg or "unknown error" for err in result["syntax"]),
             )
 
         if result.get("extra"):
             yield gettext(
                 "One or more unknown placeholders in the translation: %s"
-            ) % ", ".join(result["extra"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["extra"]))
 
         if result.get("missing"):
             yield gettext(
                 "One or more placeholders missing in the translation: %s"
-            ) % ", ".join(result["missing"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["missing"]))
 
         if result.get("wrong_type"):
             yield gettext(
                 "One or more placeholder types are incorrect: %s"
-            ) % ", ".join(result["wrong_type"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["wrong_type"]))
 
         if result.get("no_other"):
-            yield gettext("Missing other sub-message for: %s") % ", ".join(
-                result["no_other"]
+            yield gettext("Missing other sub-message for: %s") % format_html_join_comma(
+                "{}", list_to_tuples(result["no_other"])
             )
 
         if result.get("bad_plural"):
-            yield gettext("Incorrect plural selectors for: %s") % ", ".join(
-                f"{x[0]} ({', '.join(x[1])})" for x in result["bad_plural"]
+            yield gettext(
+                "Incorrect plural selectors for: %s"
+            ) % format_html_join_comma(
+                "{}", (f"{x[0]} ({', '.join(x[1])})" for x in result["bad_plural"])
             )
 
         if result.get("bad_submessage"):
-            yield gettext("Incorrect sub-message selectors for: %s") % ", ".join(
-                f"{x[0]} ({', '.join(x[1])})" for x in result["bad_submessage"]
+            yield gettext(
+                "Incorrect sub-message selectors for: %s"
+            ) % format_html_join_comma(
+                "{}", (f"{x[0]} ({', '.join(x[1])})" for x in result["bad_submessage"])
             )
 
         if result.get("should_be_tag"):
             yield gettext(
                 "One or more placeholders should have "
                 "a corresponding XML tag in the translation: %s"
-            ) % ", ".join(result["should_be_tag"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["should_be_tag"]))
 
         if result.get("not_tag"):
             yield gettext(
                 "One or more placeholders should not be "
                 "an XML tag in the translation: %s"
-            ) % ", ".join(result["not_tag"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["not_tag"]))
 
         if result.get("tag_not_empty"):
             yield gettext(
                 "One or more XML tags has unexpected content in the translation: %s"
-            ) % ", ".join(result["tag_not_empty"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["tag_not_empty"]))
 
         if result.get("tag_empty"):
             yield gettext(
                 "One or more XML tags missing content in the translation: %s"
-            ) % ", ".join(result["tag_empty"])
+            ) % format_html_join_comma("{}", list_to_tuples(result["tag_empty"]))
 
     def check_highlight(self, source: str, unit: Unit):
         if self.should_skip(unit):
diff --git a/weblate/lang/models.py b/weblate/lang/models.py
index 35f2280936b0..3f00d39ffea0 100644
--- a/weblate/lang/models.py
+++ b/weblate/lang/models.py
@@ -37,6 +37,7 @@
 from weblate.trans.defines import LANGUAGE_CODE_LENGTH, LANGUAGE_NAME_LENGTH
 from weblate.trans.mixins import CacheKeyMixin
 from weblate.trans.util import sort_objects, sort_unicode
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 from weblate.utils.validators import validate_plural_formula
 
 if TYPE_CHECKING:
@@ -1098,7 +1099,9 @@ def get_plural_label(self, idx):
         return format_html(
             PLURAL_TITLE,
             name=self.get_plural_name(idx),
-            examples=", ".join(self.examples.get(idx, [])),
+            examples=format_html_join_comma(
+                "{}", list_to_tuples(self.examples.get(idx, []))
+            ),
             title=gettext("Example counts for this plural form."),
         )
 
@@ -1118,7 +1121,9 @@ def list_plurals(self):
             yield {
                 "index": i,
                 "name": self.get_plural_name(i),
-                "examples": ", ".join(self.examples.get(i, [])),
+                "examples": format_html_join_comma(
+                    "{}", list_to_tuples(self.examples.get(i, []))
+                ),
             }
 
 
diff --git a/weblate/memory/forms.py b/weblate/memory/forms.py
index b4c7c0dccbbc..4a0aa0a249c4 100644
--- a/weblate/memory/forms.py
+++ b/weblate/memory/forms.py
@@ -11,6 +11,7 @@
 from weblate.lang.models import Language
 from weblate.memory.models import SUPPORTED_FORMATS
 from weblate.utils.forms import SortedSelect
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 
 
 class UploadForm(forms.Form):
@@ -20,7 +21,7 @@ class UploadForm(forms.Form):
         label=gettext_lazy("File"),
         validators=[FileExtensionValidator(allowed_extensions=SUPPORTED_FORMATS)],
         help_text=gettext_lazy("You can upload a file of following formats: %s.")
-        % ", ".join(SUPPORTED_FORMATS),
+        % format_html_join_comma("{}", list_to_tuples(SUPPORTED_FORMATS)),
     )
     source_language = forms.ModelChoiceField(
         widget=SortedSelect,
diff --git a/weblate/trans/admin.py b/weblate/trans/admin.py
index 93f5b9ceb8a6..54998df951ee 100644
--- a/weblate/trans/admin.py
+++ b/weblate/trans/admin.py
@@ -25,6 +25,7 @@
     Unit,
 )
 from weblate.trans.util import sort_choices
+from weblate.utils.html import format_html_join_comma
 from weblate.wladmin.models import WeblateModelAdmin
 
 
@@ -84,8 +85,8 @@ class ProjectAdmin(WeblateModelAdmin, RepoAdminMixin):
 
     @admin.display(description=gettext_lazy("Administrators"))
     def list_admins(self, obj):
-        return ", ".join(
-            User.objects.all_admins(obj).values_list("username", flat=True)
+        return format_html_join_comma(
+            "{}", (User.objects.all_admins(obj).values_list("username"))
         )
 
     @admin.display(description=gettext_lazy("Source strings"))
diff --git a/weblate/trans/models/component.py b/weblate/trans/models/component.py
index 6c5b4f14fc72..11c4fa74e201 100644
--- a/weblate/trans/models/component.py
+++ b/weblate/trans/models/component.py
@@ -84,6 +84,7 @@
 from weblate.utils.decorators import disable_for_loaddata
 from weblate.utils.errors import report_error
 from weblate.utils.fields import EmailField
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 from weblate.utils.licenses import (
     get_license_choices,
     get_license_name,
@@ -2951,7 +2952,7 @@ def clean_new_lang(self) -> None:
         if errors:
             message = gettext(
                 "Could not parse base file for new translations: %s"
-            ) % ", ".join(str(error) for error in errors)
+            ) % format_html_join_comma("{}", list_to_tuples(errors))
             raise ValidationError({"new_base": message})
         raise ValidationError(
             {"new_base": gettext("Unrecognized base file for new translations.")}
diff --git a/weblate/trans/templatetags/translations.py b/weblate/trans/templatetags/translations.py
index 5a1bfef57a71..d5fd71bae27f 100644
--- a/weblate/trans/templatetags/translations.py
+++ b/weblate/trans/templatetags/translations.py
@@ -44,6 +44,7 @@
 from weblate.utils.diff import Differ
 from weblate.utils.docs import get_doc_url
 from weblate.utils.hash import hash_to_checksum
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 from weblate.utils.markdown import render_markdown
 from weblate.utils.messages import get_message_kind as get_message_kind_impl
 from weblate.utils.random import get_random_identifier
@@ -968,7 +969,10 @@ def unit_state_title(unit) -> str:
         state.append(
             "{} {}".format(
                 pgettext("String state", "Failing checks:"),
-                ", ".join(str(check) for check in checks),
+                format_html_join_comma(
+                    "{}",
+                    list_to_tuples(checks),
+                ),
             )
         )
     checks = unit.dismissed_checks
@@ -976,7 +980,7 @@ def unit_state_title(unit) -> str:
         state.append(
             "{} {}".format(
                 pgettext("String state", "Dismissed checks:"),
-                ", ".join(str(check) for check in checks),
+                format_html_join_comma("{}", list_to_tuples(checks)),
             )
         )
     if unit.has_comment:
@@ -1367,7 +1371,9 @@ def choiceval(boundfield):
         return value
     choices = {str(choice): value for choice, value in boundfield.field.choices}
     if isinstance(value, list):
-        return ", ".join(choices.get(val, val) for val in value)
+        return format_html_join_comma(
+            "{}", list_to_tuples(choices.get(val, val) for val in value)
+        )
     return choices.get(value, value)
 
 
diff --git a/weblate/trans/views/edit.py b/weblate/trans/views/edit.py
index 409211df95a3..6a6e54a9951d 100644
--- a/weblate/trans/views/edit.py
+++ b/weblate/trans/views/edit.py
@@ -57,6 +57,7 @@
 from weblate.utils import messages
 from weblate.utils.antispam import is_spam
 from weblate.utils.hash import hash_to_checksum
+from weblate.utils.html import format_html_join_comma, list_to_tuples
 from weblate.utils.messages import get_message_kind
 from weblate.utils.ratelimit import revert_rate_limit, session_ratelimit_post
 from weblate.utils.state import STATE_APPROVED, STATE_FUZZY, STATE_TRANSLATED
@@ -78,7 +79,7 @@ def display_fixups(request: AuthenticatedHttpRequest, fixups) -> None:
     messages.info(
         request,
         gettext("Following fixups were applied to translation: %s")
-        % ", ".join(str(f) for f in fixups),
+        % format_html_join_comma("{}", list_to_tuples(fixups)),
     )
 
 
@@ -414,7 +415,11 @@ def perform_translation(unit, form, request: AuthenticatedHttpRequest) -> bool:
             gettext(
                 "The translation has been saved, however there "
                 "are some newly failing checks: {0}"
-            ).format(", ".join(str(CHECKS[check].name) for check in newchecks)),
+            ).format(
+                format_html_join_comma(
+                    "{}", list_to_tuples(CHECKS[check].name for check in newchecks)
+                )
+            ),
         )
         # Stay on same entry
         return False
diff --git a/weblate/utils/apps.py b/weblate/utils/apps.py
index 6fbba7d65782..83d3c17fa8bf 100644
--- a/weblate/utils/apps.py
+++ b/weblate/utils/apps.py
@@ -26,6 +26,8 @@
 from django.utils import timezone
 from packaging.version import Version
 
+from weblate.utils.html import format_html_join_comma, list_to_tuples
+
 from .celery import is_celery_queue_long
 from .checks import weblate_check
 from .classloader import ClassLoader
@@ -133,7 +135,9 @@ def check_celery(
                     weblate_check(
                         "weblate.E034",
                         "The Celery process is outdated or misconfigured."
-                        " Following items differ: {}".format(", ".join(differing)),
+                        " Following items differ: {}".format(
+                            format_html_join_comma("{}", list_to_tuples(differing))
+                        ),
                     )
                 )
         except CeleryTimeoutError:
diff --git a/weblate/utils/html.py b/weblate/utils/html.py
index 639ca5b6685c..d99c86767682 100644
--- a/weblate/utils/html.py
+++ b/weblate/utils/html.py
@@ -199,3 +199,8 @@ def format_html_join_comma(
     return format_html_join(
         pgettext("Joins a list of values", ", "), format_string, args_generator
     )
+
+
+def list_to_tuples(strings: Iterable[str]) -> list[tuple[str]]:
+    """Convert a list of strings into a list of single-element tuples."""
+    return [(s,) for s in strings]
diff --git a/weblate/utils/tests/test_html.py b/weblate/utils/tests/test_html.py
index 69deed6020e1..0ea008dcad67 100644
--- a/weblate/utils/tests/test_html.py
+++ b/weblate/utils/tests/test_html.py
@@ -11,6 +11,7 @@
     HTML2Text,
     HTMLSanitizer,
     extract_html_tags,
+    list_to_tuples,
     mail_quote_value,
 )
 
@@ -129,3 +130,17 @@ def test_url(self):
             mail_quote_value("https://test.example.com"),
             "https<span>:</span>//test<span>.</span>example<span>.</span>com",
         )
+
+
+class TypeConversionTestCase(SimpleTestCase):
+    def test_list_to_tuples(self) -> None:
+        self.assertEqual(
+            list_to_tuples(["string1", "string2", "string3"]),
+            [("string1",), ("string2",), ("string3",)],
+        )
+
+    def test_empty_list(self) -> None:
+        self.assertEqual(list_to_tuples([]), [])
+
+    def test_single_element_list(self) -> None:
+        self.assertEqual(list_to_tuples(["only_one"]), [("only_one",)])


Reviews:
user: login: abhinavohri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T05:13:24Z
user: login: nijel
body: Thanks, it goes in the correct direction. I've made some comments on places I see problematic. 

Maybe introducing an additional function that would accept a list and convert it to a list of tuples would be useful as I see this pattern repeats frequently.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-19T10:18:18Z
user: login: abhinavohri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T11:45:47Z
user: login: abhinavohri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:58:03Z
user: login: abhinavohri
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T19:58:59Z
user: login: nijel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T06:20:23Z
user: login: nijel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T06:20:56Z
user: login: nijel
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-02T11:00:52Z

Review comments:
diff_hunk: @@ -283,7 +285,9 @@ def _format_values(self):
         return (self.format_flag(item) for item in self._items.values())
 
     def format(self) -> str:
-        return ", ".join(sorted(self._format_values()))
+        return format_html_join_comma(
+            "{}", ((mark_safe(flag),) for flag in sorted(self._format_values()))
path: weblate/checks/flags.py
user: login: abhinavohri
body: Ruff wants to remove mark_safe() but the tests fail if I remove it because the quotes get html escaped. Is it safe to use mark_safe here?
created_at: 2025-03-13T05:13:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,7 +252,7 @@ def all_projects(self):
 
     @cached_property
     def projects_display(self):
-        return ", ".join(str(x) for x in self.all_projects)
+        return format_html_join_comma("{}", ((str(x),) for x in self.all_projects))
path: weblate/billing/models.py
user: login: nijel
body: `str` is not needed here, format does it anyway:

```suggestion
        return format_html_join_comma("{}", ((x,) for x in self.all_projects))
```
created_at: 2025-03-19T10:07:20Z
author_association: MEMBER
diff_hunk: @@ -624,7 +627,7 @@ def clean(self) -> None:
 
         if overlapping.exists():
             msg = "Overlapping invoices exist: {}".format(
-                ", ".join(str(x) for x in overlapping)
+                format_html_join_comma("{}", ((str(x),) for x in overlapping))
path: weblate/billing/models.py
user: login: nijel
body: ```suggestion
                format_html_join_comma("{}", ((x,) for x in overlapping))
```
created_at: 2025-03-19T10:07:31Z
author_association: MEMBER
diff_hunk: @@ -283,7 +285,9 @@ def _format_values(self):
         return (self.format_flag(item) for item in self._items.values())
 
     def format(self) -> str:
-        return ", ".join(sorted(self._format_values()))
+        return format_html_join_comma(
+            "{}", ((mark_safe(flag),) for flag in sorted(self._format_values()))
path: weblate/checks/flags.py
user: login: nijel
body: This one should not be localized at all as it used to serialize flags into the database.
created_at: 2025-03-19T10:08:14Z
author_association: MEMBER
diff_hunk: @@ -1551,7 +1552,9 @@ def saml_metadata(request: AuthenticatedHttpRequest):
     if errors:
         add_breadcrumb(category="auth", message="SAML errors", errors=errors)
         report_error("SAML metadata", level="error")
-        return HttpResponseServerError(content=", ".join(errors))
+        return HttpResponseServerError(
+            content=format_html_join_comma("{}", ((err,) for err in errors))
+        )
path: weblate/accounts/views.py
user: login: nijel
body: As this is a server error, localizing is not that useful.
created_at: 2025-03-19T10:08:59Z
author_association: MEMBER
diff_hunk: @@ -1098,7 +1099,7 @@ def get_plural_label(self, idx):
         return format_html(
             PLURAL_TITLE,
             name=self.get_plural_name(idx),
-            examples=", ".join(self.examples.get(idx, [])),
+            examples=format_html_join_comma("{}", self.examples.get(idx, [])),
path: weblate/lang/models.py
user: login: nijel
body: Conversion to tuple is missing here?
created_at: 2025-03-19T10:14:05Z
author_association: MEMBER
diff_hunk: @@ -1118,7 +1119,7 @@ def list_plurals(self):
             yield {
                 "index": i,
                 "name": self.get_plural_name(i),
-                "examples": ", ".join(self.examples.get(i, [])),
+                "examples": format_html_join_comma("{}", self.examples.get(i, [])),
path: weblate/lang/models.py
user: login: nijel
body: Conversion to tuple is missing here?
created_at: 2025-03-19T10:14:11Z
author_association: MEMBER
diff_hunk: @@ -84,8 +85,8 @@ class ProjectAdmin(WeblateModelAdmin, RepoAdminMixin):
 
     @admin.display(description=gettext_lazy("Administrators"))
     def list_admins(self, obj):
-        return ", ".join(
-            User.objects.all_admins(obj).values_list("username", flat=True)
+        return format_html_join_comma(
+            "{}", (User.objects.all_admins(obj).values_list("username", flat=False))
path: weblate/trans/admin.py
user: login: nijel
body: `flat` defaults to false, make it simpler:

```suggestion
            "{}", User.objects.all_admins(obj).values_list("username")
```
created_at: 2025-03-19T10:14:51Z
author_association: MEMBER
diff_hunk: @@ -2535,7 +2535,7 @@ def get_glossary_flags(self) -> str:
             result.append("forbidden")
         if self.cleaned_data.get("read_only"):
             result.append("read-only")
-        return ", ".join(result)
+        return format_html_join_comma("{}", ((flag,) for flag in result))
path: weblate/trans/forms.py
user: login: nijel
body: This is used for serialization of flags, needs to stick with comma.
created_at: 2025-03-19T10:15:29Z
author_association: MEMBER
diff_hunk: @@ -2951,7 +2952,7 @@ def clean_new_lang(self) -> None:
         if errors:
             message = gettext(
                 "Could not parse base file for new translations: %s"
-            ) % ", ".join(str(error) for error in errors)
+            ) % format_html_join_comma("{}", ((str(error),) for error in errors))
path: weblate/trans/models/component.py
user: login: nijel
body: str is not needed:

```suggestion
            ) % format_html_join_comma("{}", ((error,) for error in errors))
```
created_at: 2025-03-19T10:15:51Z
author_association: MEMBER
diff_hunk: @@ -968,15 +969,18 @@ def unit_state_title(unit) -> str:
         state.append(
             "{} {}".format(
                 pgettext("String state", "Failing checks:"),
-                ", ".join(str(check) for check in checks),
+                format_html_join_comma(
+                    "{}",
+                    ((str(check),) for check in checks),
path: weblate/trans/templatetags/translations.py
user: login: nijel
body: ```suggestion
                    ((check,) for check in checks),
```
created_at: 2025-03-19T10:16:04Z
author_association: MEMBER
diff_hunk: @@ -968,15 +969,18 @@ def unit_state_title(unit) -> str:
         state.append(
             "{} {}".format(
                 pgettext("String state", "Failing checks:"),
-                ", ".join(str(check) for check in checks),
+                format_html_join_comma(
+                    "{}",
+                    ((str(check),) for check in checks),
+                ),
             )
         )
     checks = unit.dismissed_checks
     if checks:
         state.append(
             "{} {}".format(
                 pgettext("String state", "Dismissed checks:"),
-                ", ".join(str(check) for check in checks),
+                format_html_join_comma("{}", ((str(check),) for check in checks)),
path: weblate/trans/templatetags/translations.py
user: login: nijel
body: ```suggestion
                format_html_join_comma("{}", ((check,) for check in checks)),
```
created_at: 2025-03-19T10:16:15Z
author_association: MEMBER
diff_hunk: @@ -78,7 +79,7 @@ def display_fixups(request: AuthenticatedHttpRequest, fixups) -> None:
     messages.info(
         request,
         gettext("Following fixups were applied to translation: %s")
-        % ", ".join(str(f) for f in fixups),
+        % format_html_join_comma("{}", ((str(f),) for f in fixups)),
path: weblate/trans/views/edit.py
user: login: nijel
body: ```suggestion
        % format_html_join_comma("{}", ((f,) for f in fixups)),
```
created_at: 2025-03-19T10:16:26Z
author_association: MEMBER
diff_hunk: @@ -414,7 +415,11 @@ def perform_translation(unit, form, request: AuthenticatedHttpRequest) -> bool:
             gettext(
                 "The translation has been saved, however there "
                 "are some newly failing checks: {0}"
-            ).format(", ".join(str(CHECKS[check].name) for check in newchecks)),
+            ).format(
+                format_html_join_comma(
+                    "{}", ((str(CHECKS[check].name),) for check in newchecks)
path: weblate/trans/views/edit.py
user: login: nijel
body: ```suggestion
                    "{}", ((CHECKS[check].name,) for check in newchecks)
```
created_at: 2025-03-19T10:16:38Z
author_association: MEMBER
diff_hunk: @@ -1118,7 +1119,7 @@ def list_plurals(self):
             yield {
                 "index": i,
                 "name": self.get_plural_name(i),
-                "examples": ", ".join(self.examples.get(i, [])),
+                "examples": format_html_join_comma("{}", self.examples.get(i, [])),
path: weblate/lang/models.py
user: login: abhinavohri
body: My apologies, I missed it here.
created_at: 2025-03-26T11:45:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,10 +205,13 @@ class Meta:
     def __str__(self) -> str:
         projects = self.projects_display
         owners = self.owners.order()
+        owners_visible_names_tuples = list_to_tuples([x.get_visible_name() for x in owners])
         if projects:
             base = projects
         elif owners:
-            base = ", ".join(x.get_visible_name() for x in owners)
+            base = format_html_join_comma(
+                "{}", (x for x in owners_visible_names_tuples)
path: weblate/billing/models.py
user: login: abhinavohri
body: I have implemented the util function i created here. We would have to create an extra variable for some cases. Is this ok with you? Currently, I have only implemented it in this file. If this looks good I would apply it elsewhere as well. 
created_at: 2025-04-01T19:58:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -395,3 +395,8 @@ def count_words(string: str, language: Language | None = None) -> int:
                 even = not even
         return count
     return sum(len(s.split()) for s in split_plural(string) if not is_unused_string(s))
+
+
path: weblate/trans/util.py
user: login: abhinavohri
body: Is this the correct file for this function?
created_at: 2025-04-01T19:58:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,10 +205,15 @@ class Meta:
     def __str__(self) -> str:
         projects = self.projects_display
         owners = self.owners.order()
+        owners_visible_names_tuples = list_to_tuples(
+            [x.get_visible_name() for x in owners]
+        )
         if projects:
             base = projects
         elif owners:
-            base = ", ".join(x.get_visible_name() for x in owners)
+            base = format_html_join_comma(
+                "{}", (x for x in owners_visible_names_tuples)
+            )
path: weblate/billing/models.py
user: login: nijel
body: The additional comprehension here is not needed at all:

```suggestion
            base = format_html_join_comma(
                "{}", owners_visible_names_tuples
            )
```


But keeping it fully inline might be even easier to understand:

```suggestion
            base = format_html_join_comma(
                "{}", list_to_tuples(x.get_visible_name() for x in owners)
            )
```
created_at: 2025-04-02T06:17:48Z
author_association: MEMBER
diff_hunk: @@ -249,7 +256,9 @@ def all_projects(self):
 
     @cached_property
     def projects_display(self):
-        return ", ".join(str(x) for x in self.all_projects)
+        return format_html_join_comma(
+            "{}", (x for x in list_to_tuples(self.all_projects))
path: weblate/billing/models.py
user: login: nijel
body: ```suggestion
            "{}", list_to_tuples(self.all_projects)
```

...and pretty much anywhere else.
created_at: 2025-04-02T06:18:05Z
author_association: MEMBER
diff_hunk: @@ -3491,6 +3492,7 @@ def load_intermediate_store(self):
             source_language=self.source_language.code,
         )
         if self.pk:
+            print(store)
path: weblate/trans/models/component.py
user: login: nijel
body: Debugging statements?
created_at: 2025-04-02T06:18:58Z
author_association: MEMBER
diff_hunk: @@ -395,3 +395,8 @@ def count_words(string: str, language: Language | None = None) -> int:
                 even = not even
         return count
     return sum(len(s.split()) for s in split_plural(string) if not is_unused_string(s))
+
+
+def list_to_tuples(strings: list[str]) -> list[tuple]:
path: weblate/trans/util.py
user: login: nijel
body: Make the type annotations more generic on input and specific on output:

```suggestion
def list_to_tuples(strings: Iterable[str]) -> list[tuple[str]]:
```
created_at: 2025-04-02T06:19:52Z
author_association: MEMBER
diff_hunk: @@ -395,3 +395,8 @@ def count_words(string: str, language: Language | None = None) -> int:
                 even = not even
         return count
     return sum(len(s.split()) for s in split_plural(string) if not is_unused_string(s))
+
+
path: weblate/trans/util.py
user: login: nijel
body: Yes, it could be placed here.
created_at: 2025-04-02T06:20:15Z
author_association: MEMBER
diff_hunk: @@ -395,3 +395,8 @@ def count_words(string: str, language: Language | None = None) -> int:
                 even = not even
         return count
     return sum(len(s.split()) for s in split_plural(string) if not is_unused_string(s))
+
+
path: weblate/trans/util.py
user: login: nijel
body: Looking again at it, placing it next to `format_html_join_comma` might be a better fit.
created_at: 2025-04-02T06:20:55Z
author_association: MEMBER
diff_hunk: @@ -256,9 +253,7 @@ def all_projects(self):
 
     @cached_property
     def projects_display(self):
-        return format_html_join_comma(
-            "{}", (x for x in list_to_tuples(self.all_projects))
-        )
+        return format_html_join_comma("{}", list_to_tuples(x for x in self.all_projects))
path: weblate/billing/models.py
user: login: nijel
body: These no-op expressions are not really needed (more of these are present):

```suggestion
        return format_html_join_comma("{}", list_to_tuples(self.all_projects))
```
created_at: 2025-04-02T11:00:52Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-13T05:17:40Z
body: ## [Codecov](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg) Report
Attention: Patch coverage is `90.69767%` with `4 lines` in your changes missing coverage. Please review.
> Project coverage is 91.05%. Comparing base [(`d0f2d6d`)](https://app.codecov.io/gh/WeblateOrg/weblate/commit/d0f2d6dfcd2fa0880f1bcbf880a61d235aa7e5a8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg) to head [(`5cfd7af`)](https://app.codecov.io/gh/WeblateOrg/weblate/commit/5cfd7afe950b3ce2d507888905d3950766f64860?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg).
> Report is 3722 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg) | Patch % | Lines |
|---|---|---|
| [weblate/checks/icu.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Fchecks%2Ficu.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9jaGVja3MvaWN1LnB5) | 20.00% | [4 Missing :warning: ](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg) |

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191/graphs/tree.svg?width=650&height=150&src=pr&token=9rzx9QBq35&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg)](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg)

```diff
@@            Coverage Diff             @@
##             main   #14191      +/-   ##
==========================================
+ Coverage   90.99%   91.05%   +0.05%     
==========================================
  Files         608      615       +7     
  Lines       63655    64181     +526     
  Branches     6588     6643      +55     
==========================================
+ Hits        57924    58437     +513     
+ Misses       4034     4029       -5     
- Partials     1697     1715      +18     
```

| [Files with missing lines](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg) | Coverage Δ | |
|---|---|---|
| [weblate/billing/models.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Fbilling%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9iaWxsaW5nL21vZGVscy5weQ==) | `87.87% <100.00%> (-0.39%)` | :arrow_down: |
| [weblate/checks/fluent/inner\_html.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Fchecks%2Ffluent%2Finner_html.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9jaGVja3MvZmx1ZW50L2lubmVyX2h0bWwucHk=) | `98.52% <100.00%> (+<0.01%)` | :arrow_up: |
| [weblate/checks/fluent/references.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Fchecks%2Ffluent%2Freferences.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9jaGVja3MvZmx1ZW50L3JlZmVyZW5jZXMucHk=) | `98.51% <100.00%> (+<0.01%)` | :arrow_up: |
| [weblate/checks/format.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Fchecks%2Fformat.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9jaGVja3MvZm9ybWF0LnB5) | `94.27% <100.00%> (+0.28%)` | :arrow_up: |
| [weblate/lang/models.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Flang%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9sYW5nL21vZGVscy5weQ==) | `88.50% <100.00%> (+0.44%)` | :arrow_up: |
| [weblate/memory/forms.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Fmemory%2Fforms.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS9tZW1vcnkvZm9ybXMucHk=) | `100.00% <100.00%> (ø)` | |
| [weblate/trans/admin.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Ftrans%2Fadmin.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS90cmFucy9hZG1pbi5weQ==) | `92.43% <100.00%> (+0.06%)` | :arrow_up: |
| [weblate/trans/models/component.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Ftrans%2Fmodels%2Fcomponent.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS90cmFucy9tb2RlbHMvY29tcG9uZW50LnB5) | `81.47% <100.00%> (+0.50%)` | :arrow_up: |
| [weblate/trans/templatetags/translations.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Ftrans%2Ftemplatetags%2Ftranslations.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS90cmFucy90ZW1wbGF0ZXRhZ3MvdHJhbnNsYXRpb25zLnB5) | `90.98% <100.00%> (+0.26%)` | :arrow_up: |
| [weblate/trans/views/edit.py](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree&filepath=weblate%2Ftrans%2Fviews%2Fedit.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg#diff-d2VibGF0ZS90cmFucy92aWV3cy9lZGl0LnB5) | `81.41% <100.00%> (+0.07%)` | :arrow_up: |
| ... and [4 more](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg) | |

... and [113 files with indirect coverage changes](https://app.codecov.io/gh/WeblateOrg/weblate/pull/14191/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=WeblateOrg)

</details>
<details><summary> :rocket: New features to boost your workflow: </summary>

- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: abhinavohri
created_at: 2025-04-01T19:33:15Z
body: > Thanks, it goes in the correct direction. I've made some comments on places I see problematic.
> 
> Maybe introducing an additional function that would accept a list and convert it to a list of tuples would be useful as I see this pattern repeats frequently.

Should I add a check inside the format_html_join_comma to check if  the element is a string then convert it to a tuple?
author_association: CONTRIBUTOR
user: login: nijel
created_at: 2025-04-08T11:23:09Z
body: Merged, thanks for your contribution!
author_association: MEMBER