diff --git a/.github/actions/waffles/requirements.txt b/.github/actions/waffles/requirements.txt index 44c043b0..c6ee73ab 100644 --- a/.github/actions/waffles/requirements.txt +++ b/.github/actions/waffles/requirements.txt @@ -2,4 +2,4 @@ docopt==0.6.2 Flask==2.3.3 markupsafe==2.1.5 setuptools==75.6.0 # required for distutils in Python 3.12 -git+https://github.com/cds-snc/notifier-utils.git@53.0.1#egg=notifications-utils +git+https://github.com/cds-snc/notifier-utils.git@53.0.2#egg=notifications-utils diff --git a/notifications_utils/formatters.py b/notifications_utils/formatters.py index c6c48d67..aa5d8034 100644 --- a/notifications_utils/formatters.py +++ b/notifications_utils/formatters.py @@ -31,10 +31,14 @@ FR_CLOSE = r"\[\[/fr\]\]" # matches [[/fr]] EN_OPEN = r"\[\[en\]\]" # matches [[en]] EN_CLOSE = r"\[\[/en\]\]" # matches [[/en]] +RTL_OPEN = r"\[\[rtl\]\]" # matches [[rtl]] +RTL_CLOSE = r"\[\[/rtl\]\]" # matches [[/rtl]] FR_OPEN_LITERAL = "[[fr]]" FR_CLOSE_LITERAL = "[[/fr]]" EN_OPEN_LITERAL = "[[en]]" EN_CLOSE_LITERAL = "[[/en]]" +RTL_OPEN_LITERAL = "[[rtl]]" +RTL_CLOSE_LITERAL = "[[/rtl]]" BR_TAG = r"" @@ -621,6 +625,20 @@ def escape_lang_tags(_content: str) -> str: return _content +def escape_rtl_tags(_content: str) -> str: + """ + Escape RTL tags into code tags in the content so mistune doesn't put them inside p tags. This makes it simple + to replace them afterwards, and avoids creating invalid HTML in the process + """ + + # check to ensure we have the same number of opening and closing tags before escaping tags + if _content.count(RTL_OPEN_LITERAL) == _content.count(RTL_CLOSE_LITERAL): + _content = _content.replace(RTL_OPEN_LITERAL, f"\n```\n{RTL_OPEN_LITERAL}\n```\n") + _content = _content.replace(RTL_CLOSE_LITERAL, f"\n```\n{RTL_CLOSE_LITERAL}\n```\n") + + return _content + + def add_language_divs(_content: str) -> str: """ Custom parser to add the language divs. @@ -646,6 +664,27 @@ def remove_language_divs(_content: str) -> str: return remove_tags(_content, FR_OPEN, FR_CLOSE, EN_OPEN, EN_CLOSE) +def add_rtl_divs(_content: str) -> str: + """ + Custom parser to add the language divs. + + String replace language tags in-place + """ + + # check to ensure we have the same number of opening and closing tags before escaping tags + if _content.count(RTL_OPEN_LITERAL) == _content.count(RTL_CLOSE_LITERAL): + _content = _content.replace(RTL_OPEN_LITERAL, '
') + _content = _content.replace(RTL_CLOSE_LITERAL, "
") + + return _content + + +def remove_rtl_divs(_content: str) -> str: + """Remove the tags from content. This fn is for use in the email + preheader, since this is plain text not html""" + return remove_tags(_content, RTL_OPEN, RTL_CLOSE) + + def remove_tags(_content: str, *tags) -> str: """Remove the tags in parameters from content. diff --git a/notifications_utils/template.py b/notifications_utils/template.py index c6e00d77..54738c13 100644 --- a/notifications_utils/template.py +++ b/notifications_utils/template.py @@ -13,10 +13,12 @@ from notifications_utils.formatters import ( add_language_divs, add_prefix, + add_rtl_divs, add_trailing_newline, autolink_sms, escape_html, escape_lang_tags, + escape_rtl_tags, make_quotes_smart, nl2br, nl2li, @@ -28,6 +30,7 @@ notify_plain_text_email_markdown, remove_empty_lines, remove_language_divs, + remove_rtl_divs, remove_smart_quotes_from_email_addresses, remove_whitespace_before_punctuation, replace_hyphens_with_en_dashes, @@ -398,6 +401,7 @@ def preheader(self): .then(add_trailing_newline) .then(notify_email_preheader_markdown) .then(remove_language_divs) + .then(remove_rtl_divs) .then(do_nice_typography) .split() )[: self.PREHEADER_LENGTH_IN_CHARACTERS].strip() @@ -805,8 +809,10 @@ def get_html_email_body(template_content, template_values, redact_missing_person .then(strip_unsupported_characters) .then(add_trailing_newline) .then(escape_lang_tags) + .then(escape_rtl_tags) .then(notify_email_markdown) .then(add_language_divs) + .then(add_rtl_divs) .then(do_nice_typography) ) diff --git a/pyproject.toml b/pyproject.toml index c83e8250..dba1474c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "notifications-utils" -version = "53.0.1" +version = "53.0.2" description = "Shared python code for Notification - Provides logging utils etc." authors = ["Canadian Digital Service"] license = "MIT license" diff --git a/tests/test_template.py b/tests/test_template.py index e9dd4006..2d1a8107 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -38,3 +38,92 @@ def test_lang_tags_in_templates_bad_content(bad_content: str): def test_lang_tags_in_templates_good_content(good_content: str): html = get_html_email_body(good_content, {}) assert '
' in html + + +class TestRTLTags: + def test_rtl_tags_in_templates(self): + content = "[[rtl]]\nRTL content\n[[/rtl]]" + html = get_html_email_body(content, {}) + assert '
' in html + assert "RTL content" in html + + @pytest.mark.parametrize( + "nested_content", + [ + "[[rtl]]\nRTL content\n[[/rtl]]\n[[rtl]]\nMore RTL content\n[[/rtl]]", + "[[rtl]]\nRTL content with [[en]]\nEN content\n[[/en]]\n[[/rtl]]", + ], + ) + def test_rtl_tags_in_templates_nested_content(self, nested_content: str): + html = get_html_email_body(nested_content, {}) + assert '
' in html + assert "RTL content" in html + + @pytest.mark.parametrize( + "bad_content", + [ + "[[rtl]\nRTL content\n[[/rtl]]", # missing bracket + "[[RTL]]\nRTL content\n[[/RTL]]", # tags not lowercase + "[[rtl]]\nRTL content\n", # tag missing + "RTL content\n[[/rtl]]", # tag missing + "((rtl))\nRTL content\n((/rtl))", # wrong brackets + ], + ) + def test_rtl_tags_in_templates_bad_content(self, bad_content: str): + html = get_html_email_body(bad_content, {}) + assert '
' not in html + + @pytest.mark.parametrize( + "mixed_content", + [ + "[[rtl]]\nRTL content\n[[/rtl]]\nLTR content", + "LTR content\n[[rtl]]\nRTL content\n[[/rtl]]", + ], + ) + def test_rtl_tags_in_templates_mixed_content(self, mixed_content: str): + html = get_html_email_body(mixed_content, {}) + assert '
' in html + assert "RTL content" in html + assert "LTR content" in html + + @pytest.mark.parametrize( + "content, extra_tag", + [ + ("[[rtl]] # RTL CONTENT [[/rtl]]", "h2"), + ("[[rtl]] ## RTL CONTENT [[/rtl]]", "h3"), + ("[[rtl]]\n- RTL CONTENT 1\n-item 2\n[[/rtl]]", "ul"), + ("[[rtl]]\n1. RTL CONTENT 1\n1. item 2\n[[/rtl]]", "ol"), + ("[[rtl]]**RTL CONTENT**[[/rtl]]", "strong"), + ("[[rtl]]_RTL CONTENT_[[/rtl]]", "em"), + ("[[rtl]]---\nRTL CONTENT[[/rtl]]", "hr"), + ( + "[[rtl]]1. RTL CONTENT\n1. First level\n 1. Second level\n 1. Second level\n 1. Second level\n 1. Third level\n 1. Third level\n 1. Fourth level\n 1. Fourth level\n 1. Fifth level\n 1. Fifth level[[/rtl]]", + "ol", + ), + ("[[rtl]]^RTL CONTENT[[/rtl]]", "blockquote"), + ("[[rtl]]RTL CONTENT now at https://www.canada.ca[[/rtl]]", "a"), + ("[[rtl]][RTL CONTENT](https://www.canada.ca/sign-in)[[/rtl]]", "a"), + ("[[rtl]][[en]]RTL CONTENT[[/en]][[/rtl]]", 'div lang="en-ca"'), + ("[[rtl]][[fr]]RTL CONTENT[[/fr]][[/rtl]]", 'div lang="fr-ca"'), + ], + ids=[ + "heading_1", + "heading_2", + "list_unordered", + "list_ordered", + "bold", + "italic", + "hr", + "nested_list", + "blockquote", + "link", + "link_with_text", + "nested_lang_tags_en", + "nested_lang_tags_fr", + ], + ) + def test_rtl_tags_work_with_other_features(self, content: str, extra_tag: str): + html = get_html_email_body(content, {}) + assert '
' in html + assert "RTL CONTENT" in html + assert "<{}".format(extra_tag) in html