From 0410c7b4cbf2abfc3a75bbf7fca8006aefe62de5 Mon Sep 17 00:00:00 2001 From: manuzhang Date: Thu, 22 Jan 2026 23:53:35 +0800 Subject: [PATCH 1/2] feat: strip HTML comments from RSS feed descriptions Add automatic HTML comment removal from all feed item descriptions to ensure clean content in RSS and JSON feeds. Comments are stripped from all content sources including markdown, page.meta descriptions, and full page content. - Add strip_html_comments() static method to Util class - Update get_description_or_abstract() to strip comments in all code paths - Handle both inline and multiline HTML comments - Preserve content structure while removing comment artifacts This prevents HTML comments (including TODO notes, analytics codes, and other internal annotations) from appearing in published feeds. Co-Authored-By: Claude <> --- mkdocs_rss_plugin/util.py | 42 +++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/mkdocs_rss_plugin/util.py b/mkdocs_rss_plugin/util.py index a0ba0eb..a96d3a1 100644 --- a/mkdocs_rss_plugin/util.py +++ b/mkdocs_rss_plugin/util.py @@ -10,6 +10,8 @@ from functools import lru_cache from mimetypes import guess_type from pathlib import Path +from re import DOTALL +from re import sub as re_sub from typing import Any, Literal from urllib.parse import urlencode, urlparse, urlunparse @@ -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 re_sub(r"", "", html_content, flags=DOTALL) + def get_description_or_abstract( self, in_page: Page, @@ -521,12 +535,14 @@ 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 + return self.strip_html_comments(in_page.content) else: - return markdown.markdown(in_page.markdown, output_format="html5") + return self.strip_html_comments( + markdown.markdown(in_page.markdown, output_format="html5") + ) # If the description is explicitly given elif description: - return description + return self.strip_html_comments(description) # If the abstract is cut by the delimiter elif ( abstract_delimiter @@ -535,18 +551,24 @@ def get_description_or_abstract( ) > -1 ): - return markdown.markdown( - in_page.markdown[:excerpt_separator_position], - output_format="html5", + return self.strip_html_comments( + markdown.markdown( + in_page.markdown[:excerpt_separator_position], + output_format="html5", + ) ) # 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") + return self.strip_html_comments( + markdown.markdown(in_page.markdown, output_format="html5") + ) else: - return markdown.markdown( - f"{in_page.markdown[: chars_count - 3]}...", - output_format="html5", + return self.strip_html_comments( + markdown.markdown( + f"{in_page.markdown[: chars_count - 3]}...", + output_format="html5", + ) ) # No explicit description and no (or empty) abstract found else: From aabb23c2c934f12a3fcf2f075378310f4b6d79d8 Mon Sep 17 00:00:00 2001 From: Manu Zhang Date: Mon, 2 Nov 2026 21:58:19 +0800 Subject: [PATCH 2/2] fix: strip feed comments before truncating excerpts Locate excerpt delimiters before removing comments, then measure and truncate the cleaned Markdown. Consolidate final description cleanup and compile the comment pattern once. Add regressions and document comment examples in code blocks. Co-authored-by: Codex --- docs/configuration.md | 2 + mkdocs_rss_plugin/util.py | 73 ++++++++------------ tests/test_rss_util.py | 137 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 44 deletions(-) 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 a96d3a1..1b2da92 100644 --- a/mkdocs_rss_plugin/util.py +++ b/mkdocs_rss_plugin/util.py @@ -10,8 +10,7 @@ from functools import lru_cache from mimetypes import guess_type from pathlib import Path -from re import DOTALL -from re import sub as re_sub +from re import DOTALL, compile as re_compile from typing import Any, Literal from urllib.parse import urlencode, urlparse, urlunparse @@ -59,6 +58,7 @@ # ################################ logger = get_plugin_logger(MKDOCS_LOGGER_NAME) +HTML_COMMENT_RE = re_compile(r"", flags=DOTALL) # ############################################################################ # ########## Classes ############# @@ -505,7 +505,7 @@ def strip_html_comments(html_content: str) -> str: Returns: str: HTML content with comments removed """ - return re_sub(r"", "", html_content, flags=DOTALL) + return HTML_COMMENT_RE.sub("", html_content) def get_description_or_abstract( self, @@ -534,50 +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 self.strip_html_comments(in_page.content) - else: - return self.strip_html_comments( - markdown.markdown(in_page.markdown, output_format="html5") - ) - # If the description is explicitly given - elif description: - return self.strip_html_comments(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 self.strip_html_comments( - 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 self.strip_html_comments( - markdown.markdown(in_page.markdown, output_format="html5") - ) - else: - return self.strip_html_comments( - 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 ######## # ##################################