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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Used, in combination with `abstract_delimiter`, to determine each [item descript
- If that fails and `abstract_delimiter` is found in the page, the article content up to (but not including) the delimiter is used.
- If the above has failed, then the plugin retrieves the first number of characters of the page content defined by this setting. Retrieved content is the raw markdown converted roughly into HTML.

HTML comments are removed from feed descriptions. For Markdown excerpts, the delimiter is located first, then comments are removed before counting or truncating characters. This also removes comment examples inside fenced code blocks in excerpts. To preserve those examples, escape them as `<!-- example -->` or use full HTML content (`abstract_chars_count: -1`), where rendered code examples are preserved.

Be careful: if set to `0` and there is no description, the feed's compliance is broken (an item must have a description).

#### Override feed description per page
Expand Down
73 changes: 40 additions & 33 deletions mkdocs_rss_plugin/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from functools import lru_cache
from mimetypes import guess_type
from pathlib import Path
from re import DOTALL, compile as re_compile
from typing import Any, Literal
from urllib.parse import urlencode, urlparse, urlunparse

Expand Down Expand Up @@ -57,6 +58,7 @@
# ################################

logger = get_plugin_logger(MKDOCS_LOGGER_NAME)
HTML_COMMENT_RE = re_compile(r"<!--.*?-->", flags=DOTALL)

# ############################################################################
# ########## Classes #############
Expand Down Expand Up @@ -493,6 +495,18 @@ def get_date_from_meta(

return out_date

@staticmethod
def strip_html_comments(html_content: str) -> str:
"""Remove HTML comments from content.

Args:
html_content (str): HTML content potentially containing comments

Returns:
str: HTML content with comments removed
"""
return HTML_COMMENT_RE.sub("", html_content)

def get_description_or_abstract(
self,
in_page: Page,
Expand Down Expand Up @@ -520,42 +534,35 @@ def get_description_or_abstract(

# If the full page is wanted (unlimited chars count)
if chars_count == -1 and (in_page.content or in_page.markdown):
if in_page.content:
return in_page.content
else:
return markdown.markdown(in_page.markdown, output_format="html5")
# If the description is explicitly given
elif description:
return description
# If the abstract is cut by the delimiter
elif (
abstract_delimiter
and (
excerpt_separator_position := in_page.markdown.find(abstract_delimiter)
description = in_page.content or markdown.markdown(
in_page.markdown, output_format="html5"
)
> -1
):
return markdown.markdown(
in_page.markdown[:excerpt_separator_position],
output_format="html5",
# Otherwise, keep explicit descriptions or generate a Markdown excerpt
elif not description:
page_markdown = in_page.markdown or ""
# Locate the delimiter before stripping, since it may itself be a comment
excerpt_separator_position = (
page_markdown.find(abstract_delimiter) if abstract_delimiter else -1
)
# Use first chars_count from the markdown
elif chars_count > 0 and in_page.markdown:
if len(in_page.markdown) <= chars_count:
return markdown.markdown(in_page.markdown, output_format="html5")
else:
return markdown.markdown(
f"{in_page.markdown[: chars_count - 3]}...",
output_format="html5",
if excerpt_separator_position == -1 and (
chars_count <= 0 or not page_markdown
):
logger.warning(
f"No description generated from metadata or content of the page {in_page.file.src_uri}, "
"therefore the feed won't be compliant, "
"because an item must have a description."
)
# No explicit description and no (or empty) abstract found
else:
logger.warning(
f"No description generated from metadata or content of the page {in_page.file.src_uri}, "
"therefore the feed won't be compliant, "
"because an item must have a description."
)
return ""
return ""

if excerpt_separator_position > -1:
page_markdown = page_markdown[:excerpt_separator_position]
# Strip before measuring or cutting to avoid exposing partial comments
page_markdown = self.strip_html_comments(page_markdown)
if excerpt_separator_position == -1 and len(page_markdown) > chars_count:
page_markdown = f"{page_markdown[: chars_count - 3]}..."
description = markdown.markdown(page_markdown, output_format="html5")

return self.strip_html_comments(description)

def load_images_for_pages(
self,
Expand Down
137 changes: 137 additions & 0 deletions tests/test_rss_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,143 @@ def test_get_value_from_dot_key(self):
self.assertEqual(result, param["value"])


class TestFeedDescription(unittest.TestCase):
"""Test description sources and comment removal before excerpt truncation."""

def setUp(self):
self.utils = Util(use_git=False)
self.page = MagicMock()
self.page.meta = {}
self.page.content = None
self.page.markdown = ""
self.page.file.src_uri = "page.md"

def test_strip_html_comments(self):
cases = [
("", ""),
("<p>Visible</p>", "<p>Visible</p>"),
("Before<!-- internal -->after", "Beforeafter"),
("Before<!-- internal\nmultiline -->after", "Beforeafter"),
("<!-- first --><p>Visible</p><!-- second -->", "<p>Visible</p>"),
("&lt;!-- example --&gt;", "&lt;!-- example --&gt;"),
]
for source, expected in cases:
with self.subTest(source=source):
self.assertEqual(self.utils.strip_html_comments(source), expected)

def test_truncation_does_not_expose_partial_comment(self):
self.page.markdown = (
"Début. <!-- note interne très longue qui sera coupée -->\n\nFin."
)
self.assertEqual(
self.utils.get_description_or_abstract(self.page, chars_count=25),
"<p>Début. </p>\n<p>Fin.</p>",
)

def test_comments_do_not_consume_character_limit(self):
self.page.markdown = f"<!-- {'x' * 80} -->Visible content"
self.assertEqual(
self.utils.get_description_or_abstract(self.page, chars_count=50),
"<p>Visible content</p>",
)

def test_visible_content_is_still_truncated(self):
self.page.markdown = "<!-- internal -->" + "0123456789" * 8
self.assertEqual(
self.utils.get_description_or_abstract(self.page, chars_count=20),
"<p>01234567890123456...</p>",
)

def test_delimiter_is_located_before_stripping_comments(self):
for delimiter in ("<!-- more -->", "[CUT]"):
for chars_count in (0, 5, 160):
with self.subTest(delimiter=delimiter, chars_count=chars_count):
self.page.markdown = (
f"Intro <!-- internal -->text.\n\n{delimiter}\n\nAfter."
)
self.assertEqual(
self.utils.get_description_or_abstract(
self.page,
chars_count=chars_count,
abstract_delimiter=delimiter,
),
"<p>Intro text.</p>",
)

def test_empty_or_missing_delimiter_uses_character_limit(self):
self.page.markdown = "<!-- internal -->0123456789"
for delimiter in (None, "", "<!-- more -->"):
with self.subTest(delimiter=delimiter):
self.assertEqual(
self.utils.get_description_or_abstract(
self.page, chars_count=8, abstract_delimiter=delimiter
),
"<p>01234...</p>",
)

def test_metadata_descriptions(self):
for meta in (
{"description": "<b>Visible</b><!-- internal\nnote -->"},
{
"description": "SEO description",
"rss": {"feed_description": "<b>Visible</b><!-- internal\nnote -->"},
},
):
with self.subTest(meta=meta):
self.page.meta = meta
self.page.markdown = "Unused <!-- more -->body"
self.assertEqual(
self.utils.get_description_or_abstract(
self.page, chars_count=5, abstract_delimiter="<!-- more -->"
),
"<b>Visible</b>",
)

def test_full_content_takes_precedence_over_metadata_and_delimiter(self):
self.page.meta = {"description": "Unused description"}
self.page.markdown = "Unused <!-- more -->markdown"
self.page.content = "<p>Visible</p><!-- internal\nnote --><p>After</p>"
self.assertEqual(
self.utils.get_description_or_abstract(
self.page, chars_count=-1, abstract_delimiter="<!-- more -->"
),
"<p>Visible</p><p>After</p>",
)

def test_full_content_falls_back_to_markdown(self):
self.page.markdown = "Before<!-- internal\nnote -->after"
self.assertEqual(
self.utils.get_description_or_abstract(self.page, chars_count=-1),
"<p>Beforeafter</p>",
)

def test_comment_examples_in_markdown_excerpts_are_removed(self):
self.page.markdown = "```html\n<!-- example -->\n```\n\nVisible"
description = self.utils.get_description_or_abstract(self.page)
self.assertNotIn("example", description)
self.assertIn("Visible", description)

def test_escaped_code_examples_in_full_content_are_preserved(self):
self.page.content = (
"<pre><code>&lt;!-- example --&gt;</code></pre><!-- internal -->"
)
self.assertEqual(
self.utils.get_description_or_abstract(self.page, chars_count=-1),
"<pre><code>&lt;!-- example --&gt;</code></pre>",
)

def test_empty_descriptions(self):
for source, chars_count in (("", 160), ("Visible", 0)):
with self.subTest(source=source, chars_count=chars_count):
self.page.markdown = source
self.assertEqual(
self.utils.get_description_or_abstract(
self.page, chars_count=chars_count
),
"",
)


# ##############################################################################
# ##### Stand alone program ########
# ##################################
Expand Down
Loading