diff --git a/docs/configuration.md b/docs/configuration.md index 4871896..85d1c0d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/mkdocs_rss_plugin/util.py b/mkdocs_rss_plugin/util.py index a0ba0eb..1b2da92 100644 --- a/mkdocs_rss_plugin/util.py +++ b/mkdocs_rss_plugin/util.py @@ -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 @@ -57,6 +58,7 @@ # ################################ logger = get_plugin_logger(MKDOCS_LOGGER_NAME) +HTML_COMMENT_RE = re_compile(r"", flags=DOTALL) # ############################################################################ # ########## Classes ############# @@ -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, @@ -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, diff --git a/tests/test_rss_util.py b/tests/test_rss_util.py index f09d165..fc61918 100644 --- a/tests/test_rss_util.py +++ b/tests/test_rss_util.py @@ -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 = [ + ("", ""), + ("

Visible

", "

Visible

"), + ("Beforeafter", "Beforeafter"), + ("Beforeafter", "Beforeafter"), + ("

Visible

", "

Visible

"), + ("<!-- example -->", "<!-- example -->"), + ] + 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. \n\nFin." + ) + self.assertEqual( + self.utils.get_description_or_abstract(self.page, chars_count=25), + "

Début.

\n

Fin.

", + ) + + def test_comments_do_not_consume_character_limit(self): + self.page.markdown = f"Visible content" + self.assertEqual( + self.utils.get_description_or_abstract(self.page, chars_count=50), + "

Visible content

", + ) + + def test_visible_content_is_still_truncated(self): + self.page.markdown = "" + "0123456789" * 8 + self.assertEqual( + self.utils.get_description_or_abstract(self.page, chars_count=20), + "

01234567890123456...

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

Intro text.

", + ) + + def test_empty_or_missing_delimiter_uses_character_limit(self): + self.page.markdown = "0123456789" + for delimiter in (None, "", ""): + with self.subTest(delimiter=delimiter): + self.assertEqual( + self.utils.get_description_or_abstract( + self.page, chars_count=8, abstract_delimiter=delimiter + ), + "

01234...

", + ) + + def test_metadata_descriptions(self): + for meta in ( + {"description": "Visible"}, + { + "description": "SEO description", + "rss": {"feed_description": "Visible"}, + }, + ): + with self.subTest(meta=meta): + self.page.meta = meta + self.page.markdown = "Unused body" + self.assertEqual( + self.utils.get_description_or_abstract( + self.page, chars_count=5, abstract_delimiter="" + ), + "Visible", + ) + + def test_full_content_takes_precedence_over_metadata_and_delimiter(self): + self.page.meta = {"description": "Unused description"} + self.page.markdown = "Unused markdown" + self.page.content = "

Visible

After

" + self.assertEqual( + self.utils.get_description_or_abstract( + self.page, chars_count=-1, abstract_delimiter="" + ), + "

Visible

After

", + ) + + def test_full_content_falls_back_to_markdown(self): + self.page.markdown = "Beforeafter" + self.assertEqual( + self.utils.get_description_or_abstract(self.page, chars_count=-1), + "

Beforeafter

", + ) + + def test_comment_examples_in_markdown_excerpts_are_removed(self): + self.page.markdown = "```html\n\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 = ( + "
<!-- example -->
" + ) + self.assertEqual( + self.utils.get_description_or_abstract(self.page, chars_count=-1), + "
<!-- example -->
", + ) + + 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 ######## # ##################################