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 &, <, >. 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('&', '&')|replace('<', '<')|replace('>', '>') }}
+ {# 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('&', '&')|replace('<', '<')|replace('>', '>') }}
{% 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('&', '&')|replace('<', '<')|replace('>', '>') }}
+ {{ 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('&', '&')|replace('<', '<')|replace('>', '>') }}
+ {{ 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 = ["&", "<", ">"]
+ 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 {