summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDennis Fink2026-08-10 19:53:17 +0200
committerDennis Fink2026-08-10 19:53:17 +0200
commitbe8614b3c5f63b9e94341166567b9198746f98aa (patch)
tree6c75ea8318a83e64dcc430dd4340446368ae0ee2
parent0449117060a436792d4f7d89e053b478fe953a5a (diff)
downloaddennisfink.me-be8614b3c5f63b9e94341166567b9198746f98aa.tar.gz
dennisfink.me-be8614b3c5f63b9e94341166567b9198746f98aa.zip
Add RSS feed support
This adds a project-local RSS plugin that generates RSS 2.0 feeds from configured Lektor sources, including feed titles, item dates, authors, body content, and stable permalink IDs. It also adds latest and all-post RSS feed configurations and advertises both feeds from the base template alongside the existing Atom feeds.
Diffstat (limited to '')
-rw-r--r--configs/rss.ini18
-rw-r--r--packages/rss/lektor_rss.py260
-rw-r--r--packages/rss/setup.py15
-rw-r--r--templates/base.html8
4 files changed, 301 insertions, 0 deletions
diff --git a/configs/rss.ini b/configs/rss.ini
new file mode 100644
index 0000000..ed86ddc
--- /dev/null
+++ b/configs/rss.ini
@@ -0,0 +1,18 @@
+[latest]
+name = Still Thinking About It — Latest posts
+source_path = /blog
+url_path = /blog/latest.rss.xml
+item_model = blog-post
+item_date_field = publication_date
+blog_author_field = author
+item_author_field = author
+
+[all]
+name = Still Thinking About It — All posts
+source_path = /blog
+url_path = /blog/all.rss.xml
+item_model = blog-post
+item_date_field = publication_date
+blog_author_field = author
+item_author_field = author
+limit = 0
diff --git a/packages/rss/lektor_rss.py b/packages/rss/lektor_rss.py
new file mode 100644
index 0000000..224d6f8
--- /dev/null
+++ b/packages/rss/lektor_rss.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+from datetime import date, datetime, time, timezone
+from typing import Any
+
+from feedgenerator.django.utils.feedgenerator import Rss201rev2Feed
+from lektor.build_programs import BuildProgram
+from lektor.context import get_ctx, url_to
+from lektor.db import F
+from lektor.environment import Expression
+from lektor.pluginsystem import Plugin
+from lektor.sourceobj import VirtualSourceObject
+from lektor.utils import build_url
+
+
+def get_field(record: Any, field: str, default: Any = None) -> Any:
+ if field in record:
+ return record[field]
+ return default
+
+
+def get_item_title(item: Any, field: str) -> str:
+ if field in item:
+ return str(item[field])
+ return item.record_label
+
+
+def get_item_body(item: Any, field: str) -> str:
+ if field not in item:
+ raise RuntimeError(f"Body field {field!r} not found in {item!r}")
+
+ with get_ctx().changed_base_url(item.url_path):
+ body = item[field]
+
+ if hasattr(body, "__html__"):
+ return str(body.__html__())
+
+ return str(body)
+
+
+def get_item_date(item: Any, field: str) -> datetime:
+ value = get_field(item, field)
+
+ if value is None:
+ raise RuntimeError(f"Publication date field {field!r} not found in {item!r}")
+
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value
+
+ if isinstance(value, date):
+ return datetime.combine(value, time.min, tzinfo=timezone.utc)
+
+ raise TypeError(f"Publication date field {field!r} must be a date or datetime")
+
+
+class RssFeedSource(VirtualSourceObject):
+ def __init__(
+ self,
+ parent: Any,
+ feed_id: str,
+ plugin: RssPlugin,
+ ) -> None:
+ super().__init__(parent)
+ self.feed_id = feed_id
+ self.plugin = plugin
+
+ @property
+ def path(self) -> str:
+ return f"{self.parent.path}@rss/{self.feed_id}"
+
+ @property
+ def url_path(self) -> str:
+ configured_path = self.plugin.get_rss_config(
+ self.feed_id,
+ "url_path",
+ )
+
+ if configured_path:
+ return configured_path
+
+ return build_url([self.parent.url_path, self.filename])
+
+ @property
+ def feed_name(self) -> str:
+ return self.plugin.get_rss_config(self.feed_id, "name") or self.feed_id
+
+ def iter_source_filenames(self):
+ return self.parent.iter_source_filenames()
+
+ def __getattr__(self, name: str) -> Any:
+ try:
+ return self.plugin.get_rss_config(self.feed_id, name)
+ except KeyError as exc:
+ raise AttributeError(name) from exc
+
+
+class RssFeedBuilderProgram(BuildProgram):
+ def produce_artifacts(self) -> None:
+ self.declare_artifact(
+ self.source.url_path,
+ sources=list(self.source.iter_source_filenames()),
+ )
+
+ def build_artifact(self, artifact: Any) -> None:
+ ctx = get_ctx()
+ source = self.source
+ blog = source.parent
+
+ summary = get_field(
+ blog,
+ source.blog_summary_field,
+ "",
+ )
+
+ if hasattr(summary, "__html__"):
+ summary = str(summary.__html__())
+ else:
+ summary = str(summary)
+
+ blog_author = str(
+ get_field(
+ blog,
+ source.blog_author_field,
+ "",
+ )
+ or ""
+ )
+
+ feed = Rss201rev2Feed(
+ title=source.feed_name,
+ link=url_to(blog, external=True),
+ description=summary,
+ feed_url=url_to(source, external=True),
+ language="en",
+ )
+
+ if source.items:
+ expression = Expression(ctx.env, source.items)
+ items = expression.evaluate(ctx.pad)
+ else:
+ items = blog.children
+
+ if source.item_model:
+ items = items.filter(F._model == source.item_model)
+
+ items = items.order_by(f"-{source.item_date_field}").limit(int(source.limit))
+
+ for item in items:
+ item_url = url_to(item, external=True)
+
+ item_author = (
+ get_field(
+ item,
+ source.item_author_field,
+ )
+ or blog_author
+ )
+
+ feed.add_item(
+ title=get_item_title(
+ item,
+ source.item_title_field,
+ ),
+ link=item_url,
+ description=get_item_body(
+ item,
+ source.item_body_field,
+ ),
+ author_name=str(item_author),
+ pubdate=get_item_date(
+ item,
+ source.item_date_field,
+ ),
+ unique_id=item_url,
+ unique_id_is_permalink=True,
+ )
+
+ with artifact.open("wb") as file:
+ feed.write(file, "utf-8")
+
+
+class RssPlugin(Plugin):
+ name = "RSS"
+ description = "Generate RSS 2.0 feeds."
+
+ defaults = {
+ "source_path": "/",
+ "name": None,
+ "url_path": None,
+ "filename": "feed.rss",
+ "blog_author_field": "author",
+ "blog_summary_field": "summary",
+ "items": None,
+ "limit": 50,
+ "item_title_field": "title",
+ "item_body_field": "body",
+ "item_author_field": "author",
+ "item_date_field": "pub_date",
+ "item_model": None,
+ }
+
+ def get_rss_config(
+ self,
+ feed_id: str,
+ key: str,
+ ) -> Any:
+ return self.get_config().get(
+ f"{feed_id}.{key}",
+ self.defaults[key],
+ )
+
+ def on_setup_env(self, **extra: Any) -> None:
+ self.env.add_build_program(
+ RssFeedSource,
+ RssFeedBuilderProgram,
+ )
+
+ @self.env.virtualpathresolver("rss")
+ def rss_path_resolver(
+ node: Any,
+ pieces: list[str],
+ ) -> RssFeedSource | None:
+ if len(pieces) != 1:
+ return None
+
+ feed_id = pieces[0]
+
+ if feed_id not in self.get_config().sections():
+ return None
+
+ source_path = self.get_rss_config(
+ feed_id,
+ "source_path",
+ )
+
+ if node.path != source_path:
+ return None
+
+ return RssFeedSource(
+ node,
+ feed_id,
+ self,
+ )
+
+ @self.env.generator
+ def generate_rss_feeds(source: Any):
+ for feed_id in self.get_config().sections():
+ source_path = self.get_rss_config(
+ feed_id,
+ "source_path",
+ )
+
+ if source.path == source_path:
+ yield RssFeedSource(
+ source,
+ feed_id,
+ self,
+ )
diff --git a/packages/rss/setup.py b/packages/rss/setup.py
new file mode 100644
index 0000000..b036f8b
--- /dev/null
+++ b/packages/rss/setup.py
@@ -0,0 +1,15 @@
+from setuptools import setup
+
+setup(
+ name="lektor-rss",
+ version="0.1.0",
+ py_modules=["lektor_rss"],
+ entry_points={
+ "lektor.plugins": [
+ "rss = lektor_rss:RssPlugin",
+ ]
+ },
+ install_requires=[
+ "feedgenerator",
+ ],
+)
diff --git a/templates/base.html b/templates/base.html
index 3ac053c..051abf7 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -102,6 +102,14 @@
type="application/atom+xml"
title="{{ bag('blog.name') }} — All posts"
href="{{ '/blog@atom/all'|url(absolute=True) }}">
+ <link rel="alternate"
+ type="application/rss+xml"
+ title="{{ bag('blog.name') }} — Latest posts"
+ href="{{ '/blog@rss/latest'|url(absolute=True) }}">
+ <link rel="alternate"
+ type="application/rss+xml"
+ title="{{ bag('blog.name') }} — All posts"
+ href="{{ '/blog@rss/all'|url(absolute=True) }}">
<link rel="shortcut icon"
type="image/png"
href="{{ '/static/images/favicon.png' | asseturl }}">