From e519eabf633b166067178db6e3f93132c9286ee8 Mon Sep 17 00:00:00 2001 From: Zerthick Date: Sun, 9 Aug 2026 23:11:39 -0700 Subject: [PATCH 1/2] fix: resolve double-encoded HTML entities in title and description MkDocs pre-escapes content, then Jinja's |e filter escapes again, producing &amp;, &lt;, &gt;. Use html.unescape() in Python before template rendering so |e performs a single correct escape. - Add html_module.unescape() for title and description in plugin.py - Add test fixture with special characters - Add test verifying no double-encoded entities --- mkdocs_rss_plugin/plugin.py | 14 ++-- .../fixtures/docs/page_with_special_chars.md | 11 +++ tests/test_double_encoded_entities.py | 72 +++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/docs/page_with_special_chars.md create mode 100644 tests/test_double_encoded_entities.py diff --git a/mkdocs_rss_plugin/plugin.py b/mkdocs_rss_plugin/plugin.py index a650280a..95cefafc 100644 --- a/mkdocs_rss_plugin/plugin.py +++ b/mkdocs_rss_plugin/plugin.py @@ -5,6 +5,7 @@ # ################################## # standard library +import html as html_module import json from copy import deepcopy from dataclasses import asdict @@ -350,14 +351,17 @@ def on_page_content( ), comments_url=page_url_comments, created=page_dates[0], - description=self.util.get_description_or_abstract( - in_page=page, - chars_count=self.config.abstract_chars_count, - abstract_delimiter=self.config.abstract_delimiter, + description=html_module.unescape( + self.util.get_description_or_abstract( + in_page=page, + chars_count=self.config.abstract_chars_count, + abstract_delimiter=self.config.abstract_delimiter, + ) + or "" ), guid=page.canonical_url, link=page_url_full, - title=page.title, + title=html_module.unescape(page.title) if page.title else None, updated=page_dates[1], # for later fetch _mkdocs_page_ref=MkdocsPageSubset.from_page(page), diff --git a/tests/fixtures/docs/page_with_special_chars.md b/tests/fixtures/docs/page_with_special_chars.md new file mode 100644 index 00000000..ac9e248e --- /dev/null +++ b/tests/fixtures/docs/page_with_special_chars.md @@ -0,0 +1,11 @@ +--- +title: "Test: A & B D" +authors: + - Test Author +date: 2024-01-15 10:00 +description: "Description with & ampersand and brackets" +--- + +# Test page with special characters + +This page tests that special characters are properly encoded in the RSS feed. diff --git a/tests/test_double_encoded_entities.py b/tests/test_double_encoded_entities.py new file mode 100644 index 00000000..f3cb3bba --- /dev/null +++ b/tests/test_double_encoded_entities.py @@ -0,0 +1,72 @@ +#! python3 # noqa: E265 + +"""Test that title and description don't contain double-encoded HTML entities. + +MkDocs pre-escapes content, so Jinja's |e filter should not double-encode. + +Usage from the repo root folder: + + python -m unittest tests.test_double_encoded_entities + +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +import tempfile +from pathlib import Path +from traceback import format_exception + +# test suite +from tests.base import BaseTest + +# -- Globals -- +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +OUTPUT_RSS_FEED_CREATED = "feed_rss_created.xml" + +# ############################################################################# +# ########## Classes ############### +# ################################## + + +class TestDoubleEncodedEntities(BaseTest): + """Test that HTML entities are not double-encoded in RSS feed.""" + + def test_no_double_encoded_entities(self): + """Verify that title and description don't contain double-encoded HTML entities. + + MkDocs pre-escapes content, so Jinja's |e filter should not double-encode. + """ + with tempfile.TemporaryDirectory() as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"), + output_path=tmpdirname, + strict=True, + ) + + if cli_result.exception is not None: + e = cli_result.exception + logger.debug(format_exception(type(e), e, e.__traceback__)) + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Read raw XML content + rss_path = Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + rss_content = rss_path.read_text() + + # Check for double-encoded entities + double_encoded = ["&amp;", "&lt;", "&gt;"] + for entity in double_encoded: + self.assertNotIn( + entity, + rss_content, + f"Found double-encoded HTML entity {entity} in RSS feed. " + "This indicates MkDocs pre-escaping combined with Jinja |e filter.", + ) From fa8485bfa832e9d22bbf253ecb466b2b4d98eba3 Mon Sep 17 00:00:00 2001 From: Zerthick Date: Sun, 25 Oct 2026 13:35:33 -0700 Subject: [PATCH 2/2] Address review feedback - keep unescape on title only: description is HTML and must stay escaped once, unescaping it turned code samples into real markup - fixture: derive the title from the H1 so it actually carries entities (front-matter titles are returned verbatim by MkDocs) - drop the duplicate test module; assert the title encoding in test_xml_escaping_in_author --- mkdocs_rss_plugin/plugin.py | 12 ++-- .../fixtures/docs/page_with_special_chars.md | 5 +- tests/test_build.py | 4 ++ tests/test_double_encoded_entities.py | 72 ------------------- 4 files changed, 12 insertions(+), 81 deletions(-) delete mode 100644 tests/test_double_encoded_entities.py diff --git a/mkdocs_rss_plugin/plugin.py b/mkdocs_rss_plugin/plugin.py index 95cefafc..33ceae83 100644 --- a/mkdocs_rss_plugin/plugin.py +++ b/mkdocs_rss_plugin/plugin.py @@ -351,16 +351,14 @@ def on_page_content( ), comments_url=page_url_comments, created=page_dates[0], - description=html_module.unescape( - self.util.get_description_or_abstract( - in_page=page, - chars_count=self.config.abstract_chars_count, - abstract_delimiter=self.config.abstract_delimiter, - ) - or "" + description=self.util.get_description_or_abstract( + in_page=page, + chars_count=self.config.abstract_chars_count, + abstract_delimiter=self.config.abstract_delimiter, ), guid=page.canonical_url, link=page_url_full, + # MkDocs keeps HTML entities in titles extracted from the H1 title=html_module.unescape(page.title) if page.title else None, updated=page_dates[1], # for later fetch diff --git a/tests/fixtures/docs/page_with_special_chars.md b/tests/fixtures/docs/page_with_special_chars.md index ac9e248e..f3c5f33e 100644 --- a/tests/fixtures/docs/page_with_special_chars.md +++ b/tests/fixtures/docs/page_with_special_chars.md @@ -1,11 +1,12 @@ --- -title: "Test: A & B D" authors: - Test Author date: 2024-01-15 10:00 description: "Description with & ampersand and brackets" --- -# Test page with special characters +# Test: A & B < C > D This page tests that special characters are properly encoded in the RSS feed. +The title is derived from the H1, which is the only case where MkDocs +returns it with HTML entities. diff --git a/tests/test_build.py b/tests/test_build.py index 2a1d0883..08eeb1c3 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -961,6 +961,10 @@ def test_xml_escaping_in_author(self): self.assertIn("OpenSavvy & contributors", feed_xml) self.assertNotIn("OpenSavvy & contributors", feed_xml) + # H1-derived titles arrive from MkDocs with HTML entities: + # they must be unescaped once, then escaped once for the XML + self.assertIn("Test: A & B < C > D", feed_xml) + # ############################################################################## # ##### Stand alone program ######## diff --git a/tests/test_double_encoded_entities.py b/tests/test_double_encoded_entities.py deleted file mode 100644 index f3cb3bba..00000000 --- a/tests/test_double_encoded_entities.py +++ /dev/null @@ -1,72 +0,0 @@ -#! python3 # noqa: E265 - -"""Test that title and description don't contain double-encoded HTML entities. - -MkDocs pre-escapes content, so Jinja's |e filter should not double-encode. - -Usage from the repo root folder: - - python -m unittest tests.test_double_encoded_entities - -""" - -# ############################################################################# -# ########## Libraries ############# -# ################################## - -# Standard library -import logging -import tempfile -from pathlib import Path -from traceback import format_exception - -# test suite -from tests.base import BaseTest - -# -- Globals -- -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - -OUTPUT_RSS_FEED_CREATED = "feed_rss_created.xml" - -# ############################################################################# -# ########## Classes ############### -# ################################## - - -class TestDoubleEncodedEntities(BaseTest): - """Test that HTML entities are not double-encoded in RSS feed.""" - - def test_no_double_encoded_entities(self): - """Verify that title and description don't contain double-encoded HTML entities. - - MkDocs pre-escapes content, so Jinja's |e filter should not double-encode. - """ - with tempfile.TemporaryDirectory() as tmpdirname: - cli_result = self.build_docs_setup( - testproject_path="docs", - mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"), - output_path=tmpdirname, - strict=True, - ) - - if cli_result.exception is not None: - e = cli_result.exception - logger.debug(format_exception(type(e), e, e.__traceback__)) - - self.assertEqual(cli_result.exit_code, 0) - self.assertIsNone(cli_result.exception) - - # Read raw XML content - rss_path = Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED - rss_content = rss_path.read_text() - - # Check for double-encoded entities - double_encoded = ["&amp;", "&lt;", "&gt;"] - for entity in double_encoded: - self.assertNotIn( - entity, - rss_content, - f"Found double-encoded HTML entity {entity} in RSS feed. " - "This indicates MkDocs pre-escaping combined with Jinja |e filter.", - )