Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions mkdocs_rss_plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# ##################################

# standard library
import html as html_module
import json
from copy import deepcopy
from dataclasses import asdict
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions mkdocs_rss_plugin/templates/default.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ a:hover {
</p>
<div class="meta">

<xsl:if test="author">
By <xsl:value-of select="author"/>
<xsl:if test="managingEditor">
By <xsl:value-of select="managingEditor"/>
</xsl:if>

<xsl:if test="pubDate">
Expand Down Expand Up @@ -162,8 +162,8 @@ a:hover {
</h2>

<div class="meta">
<xsl:if test="author">
Par <xsl:value-of select="author"/>
<xsl:if test="dc:creator">
By <xsl:value-of select="dc:creator"/>
</xsl:if>
<xsl:if test="pubDate">
— <xsl:value-of select="pubDate"/>
Expand Down
6 changes: 3 additions & 3 deletions mkdocs_rss_plugin/templates/rss.xml.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@
{% for item in feed.entries %}
<item>
<title>{{ item.title|e }}</title>
{# Authors loop #}
{# Authors loop - use dc:creator for names (RSS <author> requires email) #}
{% if item.authors is not none %}
{% for author in item.authors %}
<author>{{ author }}</author>
<dc:creator>{{ author }}</dc:creator>
{% endfor %}
{% endif %}
{# Categories loop #}
Expand All @@ -52,7 +52,7 @@
{% if item.link is not none %}<source url="{{ feed.rss_url }}">{{ feed.title }}</source>{% endif %}
{% if item.comments_url is not none %}<comments>{{ item.comments_url|e }}</comments>{% endif %}
{% if item.guid is not none %}<guid isPermaLink="true">{{ item.guid }}</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 %}
<enclosure url="{{ item.image[0] }}" type="{{ item.image[1] }}" length="{{ item.image[2] }}" />
{% endif %}
</item>
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/docs/page_with_special_chars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: "Test: A & B <C> D"
authors:
- Test Author
date: 2024-01-15 10:00
description: "Description with & ampersand and <angle> brackets"
---

# Test page with special characters

This page tests that special characters are properly encoded in the RSS feed.
172 changes: 172 additions & 0 deletions tests/test_rss_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#! python3 # noqa: E265

"""Tests for RSS 2.0 feed validation compliance.

Verifies:
- <dc:creator> used instead of <author> for item authors (RSS spec requires <author> to be email)
- No double-encoded HTML entities in title/description
- <enclosure> 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 <dc:creator> instead of <author>.

RSS 2.0 spec requires <author> to be an email address.
Human-readable names should use <dc:creator>.
"""
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.assertGreater(len(items), 0, "Expected at least one item in feed")

for item in items:
# Check that <author> is NOT used for item authors
author_elem = item.find("author")
self.assertIsNone(
author_elem,
"Found <author> element in item. RSS 2.0 requires <author> to be "
"an email address. Use <dc:creator> for human-readable names.",
)

# Check that <dc:creator> 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
if item.find("title").text == "Page With Explicit Metadata":
self.assertGreater(
len(dc_creators),
0,
"Expected <dc:creator> elements for item with authors.",
)

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;amp;", "&amp;lt;", "&amp;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 <enclosure> 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 <enclosure length="None" />.
"""
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.",
)