From a88092631a686cb3ede6d31481cc1f056c135357 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Sun, 23 Aug 2026 17:37:05 -0700 Subject: [PATCH] Add Atom and RSS feeds --- README.md | 37 +++ assets/static/style.css | 7 +- models/page.ini | 3 + scripts/build_with_projects.py | 4 +- scripts/feed_generator.py | 488 +++++++++++++++++++++++++++++++ templates/base.html | 3 +- templates/home.html | 21 +- templates/macros/feed-links.html | 13 + templates/page.html | 5 +- templates/project.html | 7 + templates/section.html | 18 +- tests/test_feed_generator.py | 312 ++++++++++++++++++++ tests/test_home_template.py | 19 +- 13 files changed, 910 insertions(+), 27 deletions(-) create mode 100644 scripts/feed_generator.py create mode 100644 templates/macros/feed-links.html create mode 100644 tests/test_feed_generator.py diff --git a/README.md b/README.md index 4972948..9656afe 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,43 @@ README.md This runbook Generated output such as `build/`, `dist/`, and `.lektor/` is intentionally ignored and must not be committed. +## Atom and RSS feeds + +The project-aware build generates Atom 1.0 and RSS 2.0 feeds from one shared +feed catalog in `scripts/feed_generator.py`. Feed XML is materialized only in +the isolated build workspace and is then handled as normal Lektor static output; +generated feed files are never added to the trusted source tree or Git. + +| Scope | Atom | RSS | +| --- | --- | --- | +| Recent Activity | `/feed.xml` | `/rss.xml` | +| Articles | `/articles/feed.xml` | `/articles/rss.xml` | +| Blog | `/blog/feed.xml` | `/blog/rss.xml` | +| Project history | `/projects//feed.xml` | `/projects//rss.xml` | + +Recent Activity paths are selected by the feed catalog and added to the +isolated homepage record before Lektor renders it, so the homepage and both +feed formats use the same public-content and latest-devlog-per-project rules. +General feeds are limited to the newest 50 selected records. Each project feed +is untruncated and intentionally renders the project overview first, followed +by its public devlog entries from oldest to newest. Canonical record URLs are +stable entry IDs/GUIDs, and rendered body links and media URLs are made absolute +under `https://www.labyricorn.com/`. + +Always use the existing wrapper for builds and previews so validated remote +projects, homepage activity data, and feeds are prepared together: + +```bash +python scripts/build_with_projects.py --output-path build +python scripts/build_with_projects.py --serve +``` + +After feed changes, parse every generated XML file, compare Atom entry IDs with +RSS GUIDs for each scope, confirm hidden records are absent, and check at least +two project feeds for overview-first chronological history. Also verify the +homepage, Articles, Blog, and project pages expose only their contextual feed +autodiscovery and visible links. + ## Top-level listing heroes The three primary listing routes each render one design-specific circuit hero diff --git a/assets/static/style.css b/assets/static/style.css index 31dda6d..1c910e1 100644 --- a/assets/static/style.css +++ b/assets/static/style.css @@ -728,7 +728,11 @@ h1 { max-width: 880px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style: .activity-section { min-height: 370px; padding: 48px 0 72px; } .homepage-discovery-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(290px, 1fr); gap: 36px; align-items: start; } .homepage-panel { min-width: 0; } -.section-heading { display: flex; justify-content: space-between; padding-bottom: 22px; border-bottom: 1px solid var(--rule); color: var(--muted); font-size: 10px; } +.section-heading { display: flex; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; padding-bottom: 22px; border-bottom: 1px solid var(--rule); color: var(--muted); font-size: 10px; } +.feed-links { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +.feed-links a { color: var(--teal); text-decoration: none; } +.feed-links a:hover, +.feed-links a:focus-visible { color: var(--paper); text-decoration: underline; text-underline-offset: 3px; } .timeline { position: relative; margin: 16px 0 0 7px; padding-left: 30px; border-left: 1px solid var(--rule); } .timeline-entry { position: relative; padding: 0 0 30px; } .timeline-entry + .timeline-entry { padding-top: 26px; border-top: 1px solid rgba(39,48,57,.65); } @@ -1199,6 +1203,7 @@ h1 { max-width: 880px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style: .project-identity .dek { max-width: 760px; font-size: 18px; } .project-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; min-width: 190px; } .project-actions .button-link { margin: 0; } +.project-actions .feed-links { margin-top: 4px; color: var(--muted); font: 500 10px var(--mono); letter-spacing: .1em; text-transform: uppercase; } .button-link-primary { color: var(--teal); } .project-content { diff --git a/models/page.ini b/models/page.ini index 4013c52..8b03927 100644 --- a/models/page.ini +++ b/models/page.ini @@ -18,3 +18,6 @@ type = text label = Body type = markdown +[fields.recent_activity_paths] +label = Generated recent activity paths +type = strings diff --git a/scripts/build_with_projects.py b/scripts/build_with_projects.py index 2e7056f..7d64d37 100644 --- a/scripts/build_with_projects.py +++ b/scripts/build_with_projects.py @@ -13,6 +13,7 @@ import sys import uuid from project_sources import ProjectSourceError, sync_projects +from feed_generator import prepare_feed_assets IGNORED_NAMES = { @@ -34,7 +35,7 @@ def copy_site_source(site_root: Path, workspace: Path) -> None: shutil.copytree( source, destination, - ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ignore=shutil.ignore_patterns(*IGNORED_NAMES, "*.pyc"), ) elif source.is_file(): shutil.copy2(source, destination) @@ -99,6 +100,7 @@ def main() -> int: manifest["site_commit"] = site_commit manifest["site_dirty"] = dirty project_file = find_project_file(workspace) + prepare_feed_assets(project_file, workspace) if args.serve: command = [ diff --git a/scripts/feed_generator.py b/scripts/feed_generator.py new file mode 100644 index 0000000..b78e687 --- /dev/null +++ b/scripts/feed_generator.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""Generate Labyricorn's Atom and RSS feeds from one shared feed catalog.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timezone +from email.utils import format_datetime as format_rfc2822_datetime +from html import escape +from html.parser import HTMLParser +from pathlib import Path +import re +from typing import Any, Iterable +from urllib.parse import urljoin, urlparse +import xml.etree.ElementTree as ET + + +CANONICAL_BASE_URL = "https://www.labyricorn.com/" +DEFAULT_AUTHOR = "Christopher Chambers" +GENERAL_FEED_LIMIT = 50 +ATOM_NS = "http://www.w3.org/2005/Atom" +CONTENT_NS = "http://purl.org/rss/1.0/modules/content/" +DC_NS = "http://purl.org/dc/elements/1.1/" +XML_NS = "http://www.w3.org/XML/1998/namespace" +EMPTY_FEED_DATE = datetime(1970, 1, 1, tzinfo=timezone.utc) +INVALID_XML_CHARACTERS = re.compile( + "[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]" +) + + +@dataclass(frozen=True) +class FeedEntry: + title: str + canonical_url: str + stable_id: str + summary: str + content_html: str + published: datetime + updated: datetime + author: str | None + + +@dataclass(frozen=True) +class Feed: + title: str + description: str + canonical_url: str + atom_url: str + rss_url: str + entries: tuple[FeedEntry, ...] + author: str = DEFAULT_AUTHOR + + @property + def updated(self) -> datetime: + return max((entry.updated for entry in self.entries), default=EMPTY_FEED_DATE) + + +def _field(record: Any, name: str, default: Any = None) -> Any: + try: + value = record[name] + except (KeyError, TypeError): + return default + if value is None or value.__class__.__name__ == "Undefined": + return default + return value + + +def _text(value: Any, default: str = "") -> str: + if value is None or value.__class__.__name__ == "Undefined": + return default + return str(value) + + +def _datetime(value: Any, fallback: Any = None) -> datetime: + if value is None or value.__class__.__name__ == "Undefined": + value = fallback + if hasattr(value, "datetime") and isinstance(value.datetime, datetime): + value = value.datetime + if isinstance(value, datetime): + result = value + elif isinstance(value, date): + result = datetime.combine(value, time.min) + elif value: + candidate = str(value).strip().replace("Z", "+00:00") + try: + result = datetime.fromisoformat(candidate) + except ValueError: + result = datetime.combine(date.fromisoformat(candidate[:10]), time.min) + else: + result = EMPTY_FEED_DATE + if result.tzinfo is None: + result = result.replace(tzinfo=timezone.utc) + return result.astimezone(timezone.utc) + + +def _public(record: Any) -> bool: + return not bool(record.is_hidden) and not bool(record.is_undiscoverable) + + +def _query_records(query: Any, *order_by: str) -> list[Any]: + if hasattr(query, "include_hidden"): + query = query.include_hidden(False) + if order_by: + query = query.order_by(*order_by) + return [record for record in query if _public(record)] + + +def select_recent_activity(pad: Any) -> list[Any]: + """Return the homepage Recent Activity records in homepage display order.""" + records: list[Any] = [] + for section_path in ("/articles", "/blog"): + section = pad.get(section_path) + if section is not None: + records.extend(_query_records(section.children)) + + projects = pad.get("/projects") + if projects is not None: + for project in _query_records(projects.children): + if _text(_field(project, "_model")) != "project": + continue + devlog = pad.get(f"{project.path}/devlog") + if devlog is None: + continue + devlog_entries = _query_records( + devlog.children, "-date", "-source_commit_time" + ) + if devlog_entries: + records.append(devlog_entries[0]) + + return sorted( + records, + key=lambda record: _datetime(_field(record, "date")), + reverse=True, + ) + + +def _canonical_url(record: Any) -> str: + return urljoin(CANONICAL_BASE_URL, record.url_path.lstrip("/")) + + +def _published(record: Any) -> datetime: + return _datetime(_field(record, "date", _field(record, "started"))) + + +def _updated(record: Any) -> datetime: + model = _text(_field(record, "_model")) + if model == "project": + value = _field( + record, + "repository_commit_date", + _field(record, "synchronized_at", _field(record, "date")), + ) + elif model == "devlog-entry": + value = _field(record, "source_commit_time", _field(record, "date")) + else: + value = _field(record, "updated", _field(record, "date")) + return _datetime(value, _field(record, "date")) + + +class _AbsoluteURLHTMLParser(HTMLParser): + URL_ATTRIBUTES = {"action", "href", "poster", "src"} + + def __init__(self, base_url: str) -> None: + super().__init__(convert_charrefs=False) + self.base_url = base_url + self.parts: list[str] = [] + + def _absolute(self, value: str) -> str: + parsed = urlparse(value) + if parsed.scheme == "javascript": + return "#" + if parsed.scheme in {"data", "mailto", "tel"}: + return value + return urljoin(self.base_url, value) + + def _attributes(self, attrs: list[tuple[str, str | None]]) -> str: + rendered: list[str] = [] + for name, value in attrs: + if value is None: + rendered.append(name) + continue + if name.lower() in self.URL_ATTRIBUTES: + value = self._absolute(value) + elif name.lower() == "srcset": + candidates = [] + for candidate in value.split(","): + pieces = candidate.strip().split(None, 1) + if pieces: + pieces[0] = self._absolute(pieces[0]) + candidates.append(" ".join(pieces)) + value = ", ".join(candidates) + rendered.append(f'{name}="{escape(value, quote=True)}"') + return (" " + " ".join(rendered)) if rendered else "" + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self.parts.append(f"<{tag}{self._attributes(attrs)}>") + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self.parts.append(f"<{tag}{self._attributes(attrs)} />") + + def handle_endtag(self, tag: str) -> None: + self.parts.append(f"") + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + def handle_entityref(self, name: str) -> None: + self.parts.append(f"&{name};") + + def handle_charref(self, name: str) -> None: + self.parts.append(f"&#{name};") + + def handle_comment(self, data: str) -> None: + self.parts.append(f"") + + def handle_decl(self, decl: str) -> None: + self.parts.append(f"") + + def unknown_decl(self, data: str) -> None: + self.parts.append(f"") + + +def absolute_html_urls(html: str, base_url: str) -> str: + parser = _AbsoluteURLHTMLParser(base_url) + parser.feed(html) + parser.close() + return "".join(parser.parts) + + +def _render_body(record: Any, canonical_url: str) -> str: + body = _field(record, "body") + if body is None: + return "" + from lektor.context import Context + + context = Context(pad=record.pad) + context.source = record + context._forced_base_url = record.url_path + with context: + rendered = str(body) + return absolute_html_urls(rendered, canonical_url) + + +def _entry(record: Any) -> FeedEntry: + canonical_url = _canonical_url(record) + author = _text(_field(record, "author")) or None + published = _published(record) + return FeedEntry( + title=_text(_field(record, "title"), "Untitled"), + canonical_url=canonical_url, + stable_id=canonical_url, + summary=_text(_field(record, "summary")), + content_html=_render_body(record, canonical_url), + published=published, + updated=max(published, _updated(record)), + author=author, + ) + + +def _general_feed( + title: str, + description: str, + canonical_path: str, + feed_path: str, + rss_path: str, + records: Iterable[Any], +) -> Feed: + return Feed( + title=title, + description=description, + canonical_url=urljoin(CANONICAL_BASE_URL, canonical_path.lstrip("/")), + atom_url=urljoin(CANONICAL_BASE_URL, feed_path.lstrip("/")), + rss_url=urljoin(CANONICAL_BASE_URL, rss_path.lstrip("/")), + entries=tuple(_entry(record) for record in list(records)[:GENERAL_FEED_LIMIT]), + ) + + +def build_feed_catalog(pad: Any) -> tuple[list[Any], tuple[Feed, ...]]: + recent_records = select_recent_activity(pad) + articles = pad.get("/articles") + blog = pad.get("/blog") + article_records = _query_records(articles.children, "-date") if articles else [] + blog_records = _query_records(blog.children, "-date") if blog else [] + + feeds: list[Feed] = [ + _general_feed( + "Labyricorn - Recent Activity", + "Recent articles, blog dispatches, and the latest public devlog update from each project.", + "/", + "/feed.xml", + "/rss.xml", + recent_records, + ), + _general_feed( + "Labyricorn - Articles", + _text(_field(articles, "summary"), "Published Labyricorn articles."), + "/articles/", + "/articles/feed.xml", + "/articles/rss.xml", + article_records, + ), + _general_feed( + "Labyricorn - Blog", + _text(_field(blog, "summary"), "Published Labyricorn blog entries."), + "/blog/", + "/blog/feed.xml", + "/blog/rss.xml", + blog_records, + ), + ] + + projects = pad.get("/projects") + if projects is not None: + for project in _query_records(projects.children): + if _text(_field(project, "_model")) != "project": + continue + devlog = pad.get(f"{project.path}/devlog") + devlog_entries = ( + _query_records(devlog.children, "date", "source_commit_time") + if devlog is not None + else [] + ) + project_url = _canonical_url(project) + feeds.append( + Feed( + title=f"Labyricorn - {_text(_field(project, 'title'), 'Project')}", + description=_text(_field(project, "summary")), + canonical_url=project_url, + atom_url=urljoin(project_url, "feed.xml"), + rss_url=urljoin(project_url, "rss.xml"), + entries=tuple( + [_entry(project), *(_entry(item) for item in devlog_entries)] + ), + author=_text(_field(project, "author")) or DEFAULT_AUTHOR, + ) + ) + + return recent_records, tuple(feeds) + + +def _clean_xml(value: str) -> str: + return INVALID_XML_CHARACTERS.sub("", value) + + +def _atom_element( + parent: ET.Element, + name: str, + text: str | None = None, + **attrs: str, +) -> ET.Element: + element = ET.SubElement(parent, f"{{{ATOM_NS}}}{name}", attrs) + if text is not None: + element.text = _clean_xml(text) + return element + + +def atom_xml(feed: Feed) -> bytes: + ET.register_namespace("", ATOM_NS) + root = ET.Element(f"{{{ATOM_NS}}}feed") + _atom_element(root, "title", feed.title) + _atom_element(root, "id", feed.atom_url) + _atom_element(root, "updated", feed.updated.isoformat().replace("+00:00", "Z")) + _atom_element( + root, + "link", + rel="self", + href=feed.atom_url, + type="application/atom+xml", + ) + _atom_element( + root, + "link", + rel="alternate", + href=feed.canonical_url, + type="text/html", + ) + author = _atom_element(root, "author") + _atom_element(author, "name", feed.author) + + for item in feed.entries: + entry = _atom_element(root, "entry") + _atom_element(entry, "title", item.title) + _atom_element(entry, "id", item.stable_id) + _atom_element( + entry, + "link", + rel="alternate", + href=item.canonical_url, + type="text/html", + ) + _atom_element(entry, "published", item.published.isoformat().replace("+00:00", "Z")) + _atom_element(entry, "updated", item.updated.isoformat().replace("+00:00", "Z")) + if item.author: + entry_author = _atom_element(entry, "author") + _atom_element(entry_author, "name", item.author) + if item.summary: + _atom_element(entry, "summary", item.summary, type="text") + content = _atom_element( + entry, + "content", + item.content_html, + type="html", + ) + content.set(f"{{{XML_NS}}}base", item.canonical_url) + + ET.indent(root, space=" ") + return ET.tostring( + root, + encoding="utf-8", + xml_declaration=True, + ) + b"\n" + + +def rss_xml(feed: Feed) -> bytes: + ET.register_namespace("atom", ATOM_NS) + ET.register_namespace("content", CONTENT_NS) + ET.register_namespace("dc", DC_NS) + root = ET.Element("rss", {"version": "2.0"}) + channel = ET.SubElement(root, "channel") + ET.SubElement(channel, "title").text = _clean_xml(feed.title) + ET.SubElement(channel, "link").text = feed.canonical_url + ET.SubElement(channel, "description").text = _clean_xml(feed.description) + ET.SubElement(channel, "language").text = "en-us" + ET.SubElement(channel, "lastBuildDate").text = format_rfc2822_datetime(feed.updated) + ET.SubElement( + channel, + f"{{{ATOM_NS}}}link", + {"rel": "self", "href": feed.rss_url, "type": "application/rss+xml"}, + ) + + for entry in feed.entries: + item = ET.SubElement(channel, "item") + ET.SubElement(item, "title").text = _clean_xml(entry.title) + ET.SubElement(item, "link").text = entry.canonical_url + ET.SubElement(item, "guid", {"isPermaLink": "true"}).text = entry.stable_id + ET.SubElement(item, "pubDate").text = format_rfc2822_datetime(entry.published) + ET.SubElement(item, f"{{{ATOM_NS}}}updated").text = entry.updated.isoformat().replace( + "+00:00", "Z" + ) + if entry.author: + ET.SubElement(item, f"{{{DC_NS}}}creator").text = _clean_xml(entry.author) + ET.SubElement(item, "description").text = _clean_xml(entry.summary) + ET.SubElement(item, f"{{{CONTENT_NS}}}encoded").text = _clean_xml( + entry.content_html + ) + + ET.indent(root, space=" ") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) + b"\n" + + +def _asset_path(workspace: Path, public_url: str) -> Path: + relative = urlparse(public_url).path.lstrip("/") + return workspace / "assets" / Path(relative) + + +def _write_recent_activity_paths(workspace: Path, records: Iterable[Any]) -> None: + root_record = workspace / "content" / "contents.lr" + text = root_record.read_text(encoding="utf-8").rstrip("\r\n") + marker = "\n---\nrecent_activity_paths:\n" + if marker in text: + text = text.split(marker, 1)[0].rstrip("\r\n") + paths = "\n".join(record.path for record in records) + root_record.write_text( + f"{text}{marker}\n{paths}\n", + encoding="utf-8", + ) + + +def prepare_feed_assets(project_file: Path, workspace: Path) -> tuple[Feed, ...]: + """Create feed assets and homepage activity data inside an isolated workspace.""" + from lektor.db import Database + from lektor.environment import Environment + from lektor.project import Project + + project = Project.from_file(str(project_file)) + environment = Environment(project, load_plugins=False) + pad = Database(environment).new_pad() + recent_records, feeds = build_feed_catalog(pad) + _write_recent_activity_paths(workspace, recent_records) + + for feed in feeds: + atom_path = _asset_path(workspace, feed.atom_url) + rss_path = _asset_path(workspace, feed.rss_url) + for path in (atom_path, rss_path): + path.parent.mkdir(parents=True, exist_ok=True) + atom_path.write_bytes(atom_xml(feed)) + rss_path.write_bytes(rss_xml(feed)) + return feeds diff --git a/templates/base.html b/templates/base.html index 6a149b8..cfda3dc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -6,11 +6,12 @@ {% if this._path != '/' %}{{ this.title }} — {% endif %}Labyricorn {% block social_head %}{{ social_metadata(this) }}{% endblock %} + {% block feed_head %}{% endblock %} - + diff --git a/templates/home.html b/templates/home.html index 2cd5e77..bc2f057 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,3 +1,4 @@ +{% from "macros/feed-links.html" import visible_feed_links with context %}
LABYRICORN // SIGNAL ROUTER @@ -103,23 +104,12 @@
Recent Activity - Sorted by date + Sorted by date · {{ visible_feed_links('https://www.labyricorn.com/feed.xml', 'https://www.labyricorn.com/rss.xml') }}
- {% set entries = [] %} - {% for section_id in ['articles', 'blog'] %} - {% for item in site.get('/' ~ section_id).children %} - {% if entries.append(item) %}{% endif %} - {% endfor %} - {% endfor %} - {% for project in site.query('/projects').filter(F._model == 'project') %} - {% set devlog = site.get(project.path ~ '/devlog') %} - {% if devlog %} - {% set latest_devlog_entry = devlog.children.order_by('-date', '-source_commit_time').first() %} - {% if latest_devlog_entry and entries.append(latest_devlog_entry) %}{% endif %} - {% endif %} - {% endfor %} - {% for item in entries|sort(attribute='date', reverse=true) %} + {% for item_path in this.recent_activity_paths %} + {% set item = site.get(item_path) %} + {% if item %}
+ {% endif %} {% endfor %}
{% endif %} {% endblock %} - diff --git a/templates/project.html b/templates/project.html index 070e91a..473f912 100644 --- a/templates/project.html +++ b/templates/project.html @@ -1,9 +1,15 @@ {% extends "base.html" %} {% from "macros/tag-links.html" import tag_links, taxonomy_labels with context %} {% from "macros/social-sharing.html" import social_metadata, share_controls with context %} +{% from "macros/feed-links.html" import feed_autodiscovery, visible_feed_links with context %} {% block social_head %}{{ social_metadata(this, 'SoftwareSourceCode', 'website') }}{% endblock %} +{% block feed_head %} + {% set project_feed_base = 'https://www.labyricorn.com' ~ this.url_path %} + {{ feed_autodiscovery(project_feed_base ~ 'feed.xml', project_feed_base ~ 'rss.xml', 'Labyricorn - ' ~ this.title) }} +{% endblock %} {% block body %} {% set devlog = site.get(this.path ~ '/devlog') %} +{% set project_feed_base = 'https://www.labyricorn.com' ~ this.url_path %}
diff --git a/templates/section.html b/templates/section.html index 69a24f1..acf1901 100644 --- a/templates/section.html +++ b/templates/section.html @@ -1,5 +1,12 @@ {% extends "base.html" %} {% from "macros/tag-links.html" import tag_links with context %} +{% from "macros/feed-links.html" import feed_autodiscovery, visible_feed_links with context %} +{% block feed_head %} + {% if this._id in ['articles', 'blog'] %} + {% set feed_base = 'https://www.labyricorn.com/' ~ this._id ~ '/' %} + {{ feed_autodiscovery(feed_base ~ 'feed.xml', feed_base ~ 'rss.xml', 'Labyricorn - ' ~ this.title) }} + {% endif %} +{% endblock %} {% block body %} {% set filter_tags = namespace(values=[]) %} {% if this._id in ['blog', 'articles', 'projects'] %} @@ -158,7 +165,16 @@ {% endif %}
-
{{ this.title }}{{ this.children.count() }} entries
+
+ {{ this.title }} + + {{ this.children.count() }} entries + {% if this._id in ['articles', 'blog'] %} + {% set feed_base = 'https://www.labyricorn.com/' ~ this._id ~ '/' %} + · {{ visible_feed_links(feed_base ~ 'feed.xml', feed_base ~ 'rss.xml') }} + {% endif %} + +
{% if filter_tags.values %}
diff --git a/tests/test_feed_generator.py b/tests/test_feed_generator.py new file mode 100644 index 0000000..45e584e --- /dev/null +++ b/tests/test_feed_generator.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import sys +import unittest +import xml.etree.ElementTree as ET + + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from feed_generator import ( # noqa: E402 + ATOM_NS, + CONTENT_NS, + Feed, + FeedEntry, + absolute_html_urls, + atom_xml, + build_feed_catalog, + rss_xml, + select_recent_activity, +) + + +class FakeQuery: + def __init__(self, records): + self.records = list(records) + + def include_hidden(self, include): + records = ( + self.records + if include + else [item for item in self.records if not item.is_hidden] + ) + return FakeQuery(records) + + def order_by(self, *fields): + records = list(self.records) + for field in reversed(fields): + reverse = field.startswith("-") + name = field.lstrip("-") + records.sort(key=lambda item: str(item.data.get(name, "")), reverse=reverse) + return FakeQuery(records) + + def __iter__(self): + return iter(self.records) + + +class FakeRecord: + def __init__(self, path, *, children=(), hidden=False, discoverable=True, **data): + self.path = path + self.url_path = f"{path}/" if path != "/" else "/" + self.children = FakeQuery(children) + self.is_hidden = hidden + self.is_undiscoverable = not discoverable + self.data = {"body": None, **data} + self.pad = None + + def __getitem__(self, name): + if name == "_model": + return self.data.get("model") + return self.data[name] + + +class FakePad: + def __init__(self, records): + self.records = records + for record in records.values(): + record.pad = self + + def get(self, path): + return self.records.get(path) + + +def make_pad(): + article_old = FakeRecord( + "/articles/older", + model="entry", + title="Older & wiser", + summary="An article", + date="2026-01-01", + updated="2026-01-03", + author="Article Author", + ) + article_new = FakeRecord( + "/articles/newer", + model="entry", + title="Newer article", + summary="Newest article", + date="2026-04-01", + author="Article Author", + ) + article_draft = FakeRecord( + "/articles/draft", + model="entry", + title="Draft article", + date="2026-05-01", + hidden=True, + ) + blog = FakeRecord( + "/blog/dispatch", + model="entry", + title="Dispatch", + summary="A dispatch", + date="2026-03-01", + author="Blog Author", + ) + + alpha_old = FakeRecord( + "/projects/alpha/devlog/one", + model="devlog-entry", + title="Alpha one", + summary="First", + date="2026-01-10", + source_commit_time="2026-01-10 12:00:00 +0000", + author="Alpha Author", + ) + alpha_new = FakeRecord( + "/projects/alpha/devlog/two", + model="devlog-entry", + title="Alpha two", + summary="Second", + date="2026-03-15", + source_commit_time="2026-03-15 12:00:00 +0000", + author="Alpha Author", + ) + alpha_hidden = FakeRecord( + "/projects/alpha/devlog/hidden", + model="devlog-entry", + title="Hidden alpha entry", + date="2026-04-15", + hidden=True, + ) + beta_entry = FakeRecord( + "/projects/beta/devlog/only", + model="devlog-entry", + title="Beta only", + summary="Beta history", + date="2026-02-01", + source_commit_time="2026-02-01 12:00:00 +0000", + author="Beta Author", + ) + alpha = FakeRecord( + "/projects/alpha", + model="project", + title="Alpha", + summary="Alpha summary", + date="2025-12-01", + repository_commit_date="2026-03-15 12:00:00 +0000", + author="Alpha Author", + ) + beta = FakeRecord( + "/projects/beta", + model="project", + title="Beta", + summary="Beta summary", + date="2026-01-01", + repository_commit_date="2026-02-01 12:00:00 +0000", + author="Beta Author", + ) + + records = { + "/articles": FakeRecord( + "/articles", + children=[article_old, article_new, article_draft], + model="section", + summary="Article feed description", + ), + "/blog": FakeRecord( + "/blog", children=[blog], model="section", summary="Blog feed description" + ), + "/projects": FakeRecord( + "/projects", children=[alpha, beta], model="section" + ), + "/projects/alpha/devlog": FakeRecord( + "/projects/alpha/devlog", + children=[alpha_old, alpha_new, alpha_hidden], + model="devlog", + ), + "/projects/beta/devlog": FakeRecord( + "/projects/beta/devlog", children=[beta_entry], model="devlog" + ), + } + for record in [ + article_old, + article_new, + article_draft, + blog, + alpha, + beta, + alpha_old, + alpha_new, + alpha_hidden, + beta_entry, + ]: + records[record.path] = record + return FakePad(records) + + +class FeedGeneratorTests(unittest.TestCase): + def test_recent_activity_matches_home_rules_and_excludes_hidden_records(self): + paths = [record.path for record in select_recent_activity(make_pad())] + + self.assertEqual( + [ + "/articles/newer", + "/projects/alpha/devlog/two", + "/blog/dispatch", + "/projects/beta/devlog/only", + "/articles/older", + ], + paths, + ) + self.assertNotIn("/articles/draft", paths) + self.assertNotIn("/projects/alpha/devlog/hidden", paths) + self.assertNotIn("/projects/alpha/devlog/one", paths) + + def test_atom_and_rss_use_equivalent_entries_and_stable_canonical_ids(self): + _, feeds = build_feed_catalog(make_pad()) + recent = feeds[0] + atom = ET.fromstring(atom_xml(recent)) + rss = ET.fromstring(rss_xml(recent)) + + atom_ids = [ + node.text + for node in atom.findall(f"{{{ATOM_NS}}}entry/{{{ATOM_NS}}}id") + ] + rss_ids = [node.text for node in rss.findall("channel/item/guid")] + + self.assertEqual(atom_ids, rss_ids) + self.assertEqual([entry.stable_id for entry in recent.entries], atom_ids) + self.assertTrue( + all(value.startswith("https://www.labyricorn.com/") for value in atom_ids) + ) + self.assertNotIn("Draft article", atom_xml(recent).decode("utf-8")) + + articles = feeds[1] + older = next(entry for entry in articles.entries if entry.title == "Older & wiser") + self.assertEqual("2026-01-01", older.published.date().isoformat()) + self.assertEqual("2026-01-03", older.updated.date().isoformat()) + + def test_project_feeds_start_with_overview_then_oldest_devlogs(self): + _, feeds = build_feed_catalog(make_pad()) + alpha = next( + feed for feed in feeds if feed.atom_url.endswith("/projects/alpha/feed.xml") + ) + beta = next( + feed for feed in feeds if feed.atom_url.endswith("/projects/beta/feed.xml") + ) + + self.assertEqual( + ["Alpha", "Alpha one", "Alpha two"], + [item.title for item in alpha.entries], + ) + self.assertEqual(["Beta", "Beta only"], [item.title for item in beta.entries]) + self.assertNotIn("Beta only", [item.title for item in alpha.entries]) + self.assertEqual( + "https://www.labyricorn.com/projects/alpha/", + alpha.entries[0].stable_id, + ) + + def test_full_content_urls_become_absolute(self): + rendered = absolute_html_urls( + '

NotesMail

', + "https://www.labyricorn.com/articles/example/", + ) + + self.assertIn('href="https://www.labyricorn.com/articles/example/notes/"', rendered) + self.assertIn('src="https://www.labyricorn.com/articles/example/cover.png"', rendered) + self.assertIn("https://www.labyricorn.com/large.png 2x", rendered) + self.assertIn('href="mailto:test@example.com"', rendered) + + unsafe = absolute_html_urls( + 'Unsafe', + "https://www.labyricorn.com/", + ) + self.assertEqual('Unsafe', unsafe) + + def test_xml_serializers_preserve_full_html_and_special_characters(self): + entry = FeedEntry( + title="Signals & systems", + canonical_url="https://www.labyricorn.com/articles/signals/", + stable_id="https://www.labyricorn.com/articles/signals/", + summary="A & test", + content_html='

Readable & complete content.

', + published=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated=datetime(2026, 1, 2, tzinfo=timezone.utc), + author="Test Author", + ) + feed = Feed( + title="Test & Feed", + description="Description & details", + canonical_url="https://www.labyricorn.com/", + atom_url="https://www.labyricorn.com/feed.xml", + rss_url="https://www.labyricorn.com/rss.xml", + entries=(entry,), + ) + + atom = ET.fromstring(atom_xml(feed)) + rss = ET.fromstring(rss_xml(feed)) + atom_content = atom.find(f"{{{ATOM_NS}}}entry/{{{ATOM_NS}}}content") + rss_content = rss.find(f"channel/item/{{{CONTENT_NS}}}encoded") + + self.assertEqual(entry.content_html, atom_content.text) + self.assertEqual(entry.content_html, rss_content.text) + self.assertEqual(entry.stable_id, rss.findtext("channel/item/guid")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_home_template.py b/tests/test_home_template.py index f331960..d3640e8 100644 --- a/tests/test_home_template.py +++ b/tests/test_home_template.py @@ -11,17 +11,22 @@ class HomeTemplateTests(unittest.TestCase): encoding="utf-8" ) - def test_recent_activity_adds_only_latest_devlog_from_each_project(self) -> None: + def test_recent_activity_uses_shared_generated_record_paths(self) -> None: recent_activity = self.template.split( '