From ff6d33aa296ccd945aaee19a28778340e31868ed Mon Sep 17 00:00:00 2001 From: rivassec Date: Sat, 3 Oct 2026 14:03:08 -0700 Subject: [PATCH] feeds: absolute URLs in entry content, short feed title, one feed in Follows the feed checklist in kevincox.ca's "RSS Feed Best Practices": - New feed_polish plugin (feed_generated signal): resolves every relative href/src in feed entry content and summaries against the entry permalink. The live feed had 21 relative links (e.g. /tools/iam-blast-radius/, iam-blast-radius-architecture-problem.html) that many readers resolve wrongly. Fragments, mailto:, tel: and data: are left alone. Site pages are not touched. - FEED_TITLE = 'RivasSec': feeds get a short title (category feeds keep the " - " suffix); SITENAME stays the page . The old feed title also still advertised Kubernetes. - Only the Atom feed is advertised in <head>; the RSS file is still built and linked in the footer, so existing RSS subscribers are unaffected. - Plugin imports Pelican lazily so its helpers run under the stdlib unittest workflow; 7 new tests (49 total pass). Verified with a publishconf build: 22 entries, 0 relative href/src left in Atom or RSS, titles "RivasSec" / "RivasSec - DevSecOps", one feed link in head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W7mZgHQBLP7AB6kHoBFk3y --- pelicanconf.py | 3 ++ plugins/feed_polish.py | 64 +++++++++++++++++++++++++ tests/test_feed_polish.py | 62 ++++++++++++++++++++++++ themes/Flex/templates/partial/feed.html | 5 +- 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 plugins/feed_polish.py create mode 100644 tests/test_feed_polish.py diff --git a/pelicanconf.py b/pelicanconf.py index 199399f7..8e6f0870 100644 --- a/pelicanconf.py +++ b/pelicanconf.py @@ -37,6 +37,8 @@ def _asset_version() -> str: SITENAME = 'RivasSec | DevSecOps, Kubernetes, AWS IAM' SITETITLE = 'RivasSec' SITESUBTITLE = 'Infrastructure. Security. Insight.' +# Feed title only (feed_polish plugin); SITENAME stays the page <title>. +FEED_TITLE = 'RivasSec' SITEURL = 'https://rivassec.com' SITEDESCRIPTION = 'Field notes on infrastructure security, cloud hardening, Kubernetes, IAM, and OSINT by RivasSec.' OG_IMAGE = 'images/og-default.png' @@ -90,6 +92,7 @@ def _asset_version() -> str: 'extract_toc', 'img_hygiene', 'md_mirror', + 'feed_polish', ] # related_posts configuration diff --git a/plugins/feed_polish.py b/plugins/feed_polish.py new file mode 100644 index 00000000..5ba92f78 --- /dev/null +++ b/plugins/feed_polish.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +""" +Feed polish +=========== + +A Pelican plugin that fixes two things in the generated Atom/RSS feeds, both +from the "RSS Feed Best Practices" checklist (kevincox.ca, 2022): + +* **Absolute URLs in entry content.** Posts link to each other with relative + hrefs (``iam-safe-defaults-fail-loud.html``, ``/tools/iam-blast-radius/``). + That is fine on the site, but many feed readers resolve relative URLs in + feed content wrongly (or not at all), so the links break in the reader. Every + relative ``href``/``src`` in an entry's content and summary is resolved + against the entry's own permalink. Fragments (``#x``), ``mailto:``, + ``tel:`` and ``data:`` URLs are left alone. +* **Feed title.** Pelican always titles feeds with ``SITENAME`` (the long + ``<title>`` used for SEO). ``FEED_TITLE``, when set, replaces that prefix in + feed titles only, keeping Pelican's `` - <category>`` suffix on category feeds. + +Runs on the ``feed_generated`` signal, so it only touches feeds, never the site +pages. +""" +import re +from urllib.parse import urljoin + +_ATTR_RE = re.compile(r'(\s(?:href|src)\s*=\s*)(["\'])(.*?)\2', re.IGNORECASE | re.DOTALL) +_KEEP_RE = re.compile(r'^(?:[a-z][a-z0-9+.-]*:|//|#)', re.IGNORECASE) + + +def absolutize(html, base): + """Resolve every relative href/src in ``html`` against ``base``.""" + if not html or not base: + return html + + def repl(m): + url = m.group(3).strip() + if not url or _KEEP_RE.match(url): + return m.group(0) + return f"{m.group(1)}{m.group(2)}{urljoin(base, url)}{m.group(2)}" + + return _ATTR_RE.sub(repl, html) + + +def polish_feed(context, feed): + title = context.get("FEED_TITLE") + sitename = context.get("SITENAME", "") + if title and sitename: + current = feed.feed.get("title", "") + if current == sitename or current.startswith(sitename + " - "): + feed.feed["title"] = title + current[len(sitename):] + + for item in feed.items: + base = item.get("link") + for key in ("description", "content"): + if item.get(key): + item[key] = absolutize(item[key], base) + + +def register(): + # Imported here so the helpers above stay testable without Pelican installed + # (the tests workflow runs stdlib unittest only). + from pelican import signals + + signals.feed_generated.connect(polish_feed) diff --git a/tests/test_feed_polish.py b/tests/test_feed_polish.py new file mode 100644 index 00000000..4bcfce56 --- /dev/null +++ b/tests/test_feed_polish.py @@ -0,0 +1,62 @@ +"""Tests for plugins/feed_polish.py (stdlib unittest; Pelican not required).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins")) + +from feed_polish import absolutize, polish_feed # noqa: E402 + +BASE = "https://rivassec.com/devsecops-guide.html" + + +class _Feed: + def __init__(self, title, items): + self.feed = {"title": title} + self.items = items + + +class AbsolutizeTests(unittest.TestCase): + def test_relative_links_become_absolute(self): + html = '<a href="iam-safe-defaults-fail-loud.html">x</a> <a href="/tools/iam-blast-radius/">y</a>' + out = absolutize(html, BASE) + self.assertIn('href="https://rivassec.com/iam-safe-defaults-fail-loud.html"', out) + self.assertIn('href="https://rivassec.com/tools/iam-blast-radius/"', out) + + def test_images_and_single_quotes(self): + out = absolutize("<img src='images/a.png' alt=\"a\">", BASE) + self.assertIn("src='https://rivassec.com/images/a.png'", out) + + def test_absolute_fragment_and_special_schemes_untouched(self): + html = ('<a href="https://example.com/x">a</a><a href="#sec">b</a>' + '<a href="mailto:me@example.com">c</a><img src="data:image/png;base64,AA">' + '<a href="//cdn.example.com/y">d</a>') + self.assertEqual(absolutize(html, BASE), html) + + def test_text_that_mentions_href_is_not_rewritten(self): + html = "<code>href=foo.html</code>" + self.assertEqual(absolutize(html, BASE), html) + + +class PolishFeedTests(unittest.TestCase): + CTX = {"SITENAME": "RivasSec | DevSecOps, Kubernetes, AWS IAM", "FEED_TITLE": "RivasSec"} + + def test_feed_title_and_item_content(self): + feed = _Feed(self.CTX["SITENAME"], [{"link": BASE, "description": '<a href="a.html">a</a>', "content": None}]) + polish_feed(self.CTX, feed) + self.assertEqual(feed.feed["title"], "RivasSec") + self.assertEqual(feed.items[0]["description"], '<a href="https://rivassec.com/a.html">a</a>') + + def test_category_feed_keeps_suffix(self): + feed = _Feed(self.CTX["SITENAME"] + " - DevSecOps", []) + polish_feed(self.CTX, feed) + self.assertEqual(feed.feed["title"], "RivasSec - DevSecOps") + + def test_no_feed_title_setting_leaves_title(self): + feed = _Feed("Site", []) + polish_feed({"SITENAME": "Site"}, feed) + self.assertEqual(feed.feed["title"], "Site") + + +if __name__ == "__main__": + unittest.main() diff --git a/themes/Flex/templates/partial/feed.html b/themes/Flex/templates/partial/feed.html index d7f253dc..37d79ab4 100644 --- a/themes/Flex/templates/partial/feed.html +++ b/themes/Flex/templates/partial/feed.html @@ -2,6 +2,5 @@ <link href="{{ FEED_DOMAIN }}/{{ FEED_ALL_ATOM }}" type="application/atom+xml" rel="alternate" title="{{ SITETITLE|default(SITENAME) }} Atom Feed"> {% endif %} -{% if FEED_ALL_RSS %} - <link rel="alternate" type="application/rss+xml" title="{{ SITETITLE|default(SITENAME) }} RSS Feed" href="{{ SITEURL }}/feeds/all.rss.xml"> -{% endif %} +{# Only the Atom feed is advertised in <head>: one feed per content avoids a confusing + choice for subscribers. The RSS file is still built and linked in the footer. #}