From 100f2768060ae6963086b79f8480684fd1a7bea0 Mon Sep 17 00:00:00 2001 From: Zerthick Date: Tue, 21 Jul 2026 20:26:30 -0700 Subject: [PATCH 1/5] fix: resolve RSS feed validation errors - Use instead of for item authors RSS 2.0 spec requires to be an email address; the dc namespace is already declared and is the correct element for names. - Fix double-encoded HTML entities in title and description MkDocs pre-escapes content, then Jinja's |e filter escapes again, producing &amp;, &lt;, &gt;. Added replace filters to correct the double-encoding. - Guard against None/non-positive length values When a remote image returns 404, the length field becomes None, producing invalid XML. Added guard to skip enclosure when length is None or non-positive. --- mkdocs_rss_plugin/templates/rss.xml.jinja2 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mkdocs_rss_plugin/templates/rss.xml.jinja2 b/mkdocs_rss_plugin/templates/rss.xml.jinja2 index 5328672..d6dca50 100644 --- a/mkdocs_rss_plugin/templates/rss.xml.jinja2 +++ b/mkdocs_rss_plugin/templates/rss.xml.jinja2 @@ -33,11 +33,11 @@ {# Entries #} {% for item in feed.entries %} - {{ item.title|e }} - {# Authors loop #} + {{ item.title|e|replace('&amp;', '&')|replace('&lt;', '<')|replace('&gt;', '>') }} + {# Authors loop - use dc:creator for names (RSS requires email) #} {% if item.authors is not none %} {% for author in item.authors %} - {{ author }} + {{ author }} {% endfor %} {% endif %} {# Categories loop #} @@ -46,13 +46,13 @@ {{ categorie }} {% endfor %} {% endif %} - {{ item.description|e }} + {{ item.description|e|replace('&amp;', '&')|replace('&lt;', '<')|replace('&gt;', '>') }} {% if item.link is not none %}{{ item.link|e }}{% endif %} {{ item.pub_date }} {% if item.link is not none %}{{ feed.title }}{% endif %} {% if item.comments_url is not none %}{{ item.comments_url|e }}{% endif %} {% if item.guid is not none %}{{ item.guid }}{% endif %} - {% if item.image is not none %} + {% if item.image is not none and item.image[2] is not none and item.image[2] > 0 %} {% endif %} From 4b1c11cbdd7a207ea483636ed31f09964a203014 Mon Sep 17 00:00:00 2001 From: Zerthick Date: Tue, 21 Jul 2026 21:16:10 -0700 Subject: [PATCH 2/5] fix: resolve RSS 2.0 feed validation errors - Use instead of for item authors RSS 2.0 spec requires to be an email address; the dc namespace is already declared and is the correct element for names. - Fix double-encoded HTML entities in title/description MkDocs pre-escapes content, then Jinja's |e filter escapes again. Unescape in Python (html_module.unescape) before template rendering so |e performs a single correct escape. - Guard against None/non-positive length values When a remote image returns 404, the length field becomes None, producing invalid XML. Added guard to skip enclosure when length is None or non-positive. - Add tests for all three fixes (test_rss_validation.py) Includes page_with_special_chars.md fixture with & < > characters. --- mkdocs_rss_plugin/plugin.py | 14 +- mkdocs_rss_plugin/templates/rss.xml.jinja2 | 4 +- .../fixtures/docs/page_with_special_chars.md | 11 ++ tests/test_rss_validation.py | 166 ++++++++++++++++++ 4 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/docs/page_with_special_chars.md create mode 100644 tests/test_rss_validation.py diff --git a/mkdocs_rss_plugin/plugin.py b/mkdocs_rss_plugin/plugin.py index a650280..95cefaf 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/mkdocs_rss_plugin/templates/rss.xml.jinja2 b/mkdocs_rss_plugin/templates/rss.xml.jinja2 index d6dca50..5f29326 100644 --- a/mkdocs_rss_plugin/templates/rss.xml.jinja2 +++ b/mkdocs_rss_plugin/templates/rss.xml.jinja2 @@ -33,7 +33,7 @@ {# Entries #} {% for item in feed.entries %} - {{ item.title|e|replace('&amp;', '&')|replace('&lt;', '<')|replace('&gt;', '>') }} + {{ item.title|e }} {# Authors loop - use dc:creator for names (RSS requires email) #} {% if item.authors is not none %} {% for author in item.authors %} @@ -46,7 +46,7 @@ {{ categorie }} {% endfor %} {% endif %} - {{ item.description|e|replace('&amp;', '&')|replace('&lt;', '<')|replace('&gt;', '>') }} + {{ item.description|e }} {% if item.link is not none %}{{ item.link|e }}{% endif %} {{ item.pub_date }} {% if item.link is not none %}{{ feed.title }}{% endif %} 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 0000000..ac9e248 --- /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_rss_validation.py b/tests/test_rss_validation.py new file mode 100644 index 0000000..a20a6b0 --- /dev/null +++ b/tests/test_rss_validation.py @@ -0,0 +1,166 @@ +#! python3 # noqa: E265 + +"""Tests for RSS 2.0 feed validation compliance. + +Verifies: +- used instead of for item authors (RSS spec requires to be email) +- No double-encoded HTML entities in title/description +- omitted when length is None or non-positive + +Usage from the repo root folder: + + python -m unittest tests.test_rss_validation + +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +import tempfile +from pathlib import Path +from traceback import format_exception +from xml.etree import ElementTree + +# 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 TestRssValidation(BaseTest): + """Test RSS 2.0 feed validation compliance.""" + + def test_dc_creator_instead_of_author(self): + """Verify that item authors use instead of . + + RSS 2.0 spec requires to be an email address. + Human-readable names should use . + """ + 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) + + # Parse the raw XML to check element names + rss_path = Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + tree = ElementTree.parse(rss_path) + root = tree.getroot() + + # RSS namespace + ns = { + "rss": "http://www.w3.org/2005/Atom", + "dc": "http://purl.org/dc/elements/1.1/", + } + + # Find all item elements + items = root.findall(".//item") + self.assertTrue(len(items) > 0, "Expected at least one item in feed") + + for item in items: + # Check that is NOT used for item authors + author_elem = item.find("author") + self.assertIsNone( + author_elem, + "Found element in item. RSS 2.0 requires to be " + "an email address. Use for human-readable names.", + ) + + # Check that IS used when authors are present + # The page_with_meta.md has authors, so at least one item should have dc:creator + dc_creators = item.findall("dc:creator", ns) + # Items with authors should have dc:creator elements + # Items without authors may not have any + + 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.", + ) + + def test_enclosure_guard_none_length(self): + """Verify that is omitted when image length is None or non-positive. + + When a remote image returns 404, the length field becomes None, + which would produce invalid XML like . + """ + 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 that no enclosure has length="None" or length="0" or negative + self.assertNotIn( + 'length="None"', + rss_content, + "Found enclosure with length='None'. " + "The template should guard against None length values.", + ) + self.assertNotIn( + 'length="0"', + rss_content, + "Found enclosure with length='0'. " + "The template should guard against zero length values.", + ) From 3e120bb963c95d7af1ca5abe2f282d4a432a223a Mon Sep 17 00:00:00 2001 From: Zerthick Date: Tue, 21 Jul 2026 21:50:54 -0700 Subject: [PATCH 3/5] test: assert dc:creator presence for items with authors --- tests/test_rss_validation.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_rss_validation.py b/tests/test_rss_validation.py index a20a6b0..1f3937b 100644 --- a/tests/test_rss_validation.py +++ b/tests/test_rss_validation.py @@ -91,6 +91,11 @@ def test_dc_creator_instead_of_author(self): dc_creators = item.findall("dc:creator", ns) # Items with authors should have dc:creator elements # Items without authors may not have any + if item.find("title").text == "Page With Explicit Metadata": + self.assertTrue( + len(dc_creators) > 0, + "Expected elements for item with authors.", + ) def test_no_double_encoded_entities(self): """Verify that title and description don't contain double-encoded HTML entities. From b932a21a9140c6efdecceabedddf82ebfb37ba78 Mon Sep 17 00:00:00 2001 From: Zerthick Date: Tue, 21 Jul 2026 21:52:14 -0700 Subject: [PATCH 4/5] test: use assertGreater for len checks instead of assertTrue --- tests/test_rss_validation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_rss_validation.py b/tests/test_rss_validation.py index 1f3937b..43bbd22 100644 --- a/tests/test_rss_validation.py +++ b/tests/test_rss_validation.py @@ -75,7 +75,7 @@ def test_dc_creator_instead_of_author(self): # Find all item elements items = root.findall(".//item") - self.assertTrue(len(items) > 0, "Expected at least one item in feed") + self.assertGreater(len(items), 0, "Expected at least one item in feed") for item in items: # Check that is NOT used for item authors @@ -92,8 +92,9 @@ def test_dc_creator_instead_of_author(self): # Items with authors should have dc:creator elements # Items without authors may not have any if item.find("title").text == "Page With Explicit Metadata": - self.assertTrue( - len(dc_creators) > 0, + self.assertGreater( + len(dc_creators), + 0, "Expected elements for item with authors.", ) From 0d7068ea99268dc3bf0bfb8ce5e68e4d5ab35f4b Mon Sep 17 00:00:00 2001 From: Zerthick Date: Wed, 22 Jul 2026 20:11:39 -0700 Subject: [PATCH 5/5] fix: update XSL HTML preview to read dc:creator and managingEditor --- mkdocs_rss_plugin/templates/default.xsl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mkdocs_rss_plugin/templates/default.xsl b/mkdocs_rss_plugin/templates/default.xsl index 3a11d00..5f989c0 100644 --- a/mkdocs_rss_plugin/templates/default.xsl +++ b/mkdocs_rss_plugin/templates/default.xsl @@ -131,8 +131,8 @@ a:hover {

- - By + + By @@ -162,8 +162,8 @@ a:hover {
- - Par + + By