Add Atom and RSS feeds
Deploy production / deploy (push) Successful in 21s

This commit is contained in:
2026-08-23 17:37:05 -07:00
parent 623686590c
commit a88092631a
13 changed files with 910 additions and 27 deletions
+37
View File
@@ -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/<project-id>/feed.xml` | `/projects/<project-id>/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
+6 -1
View File
@@ -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 {
+3
View File
@@ -18,3 +18,6 @@ type = text
label = Body
type = markdown
[fields.recent_activity_paths]
label = Generated recent activity paths
type = strings
+3 -1
View File
@@ -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 = [
+488
View File
@@ -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"</{tag}>")
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"<!--{data}-->")
def handle_decl(self, decl: str) -> None:
self.parts.append(f"<!{decl}>")
def unknown_decl(self, data: str) -> None:
self.parts.append(f"<![{data}]>")
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
+2 -1
View File
@@ -6,11 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% if this._path != '/' %}{{ this.title }} — {% endif %}Labyricorn</title>
{% block social_head %}{{ social_metadata(this) }}{% endblock %}
{% block feed_head %}{% endblock %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&family=Playfair+Display:ital,wght@0,600;0,700;1,500;1,600&display=swap" rel="stylesheet">
<link rel="icon" href="{{ '/static/favicon.svg'|url }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=24">
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=25">
<script src="{{ '/static/tags.js'|url }}?v=2" defer></script>
<script src="{{ '/static/activity.js'|url }}?v=1" defer></script>
<script src="{{ '/static/hero-circuit.js'|url }}?v=2" defer></script>
+6 -15
View File
@@ -1,3 +1,4 @@
{% from "macros/feed-links.html" import visible_feed_links with context %}
<section class="circuit-hero" aria-label="Labyricorn navigation hero" data-circuit-hero>
<div class="circuit-hero-topline">
<span><strong>LABYRICORN</strong> // SIGNAL ROUTER</span>
@@ -103,23 +104,12 @@
<section class="homepage-panel" aria-labelledby="recent-activity-heading">
<div class="section-heading">
<span id="recent-activity-heading">Recent Activity</span>
<span>Sorted by date</span>
<span>Sorted by date · {{ visible_feed_links('https://www.labyricorn.com/feed.xml', 'https://www.labyricorn.com/rss.xml') }}</span>
</div>
<div class="timeline" id="recent-activity-list" data-activity-feed data-batch-size="6">
{% 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 %}
<article class="timeline-entry accent-{{ item.kicker|lower }}" data-activity-item>
<div class="entry-meta">
<span class="label">{{ item.kicker }}</span>
@@ -129,6 +119,7 @@
<p>{{ item.summary }}</p>
<a class="read-more" href="{{ item|url }}">Read entry →</a>
</article>
{% endif %}
{% endfor %}
</div>
<div class="activity-controls" data-activity-controls hidden>
+13
View File
@@ -0,0 +1,13 @@
{% macro feed_autodiscovery(atom_url, rss_url, title) -%}
<link rel="alternate" type="application/atom+xml" title="{{ title }} (Atom)" href="{{ atom_url }}">
<link rel="alternate" type="application/rss+xml" title="{{ title }} (RSS)" href="{{ rss_url }}">
{%- endmacro %}
{% macro visible_feed_links(atom_url, rss_url) -%}
<span class="feed-links" aria-label="Syndication feeds">
<span>Feeds:</span>
<a href="{{ atom_url }}" type="application/atom+xml">Atom</a>
<span aria-hidden="true">/</span>
<a href="{{ rss_url }}" type="application/rss+xml">RSS</a>
</span>
{%- endmacro %}
+4 -1
View File
@@ -1,4 +1,8 @@
{% extends "base.html" %}
{% from "macros/feed-links.html" import feed_autodiscovery with context %}
{% block feed_head %}
{% if this._path == '/' %}{{ feed_autodiscovery('https://www.labyricorn.com/feed.xml', 'https://www.labyricorn.com/rss.xml', 'Labyricorn - Recent Activity') }}{% endif %}
{% endblock %}
{% block body %}
{% if this._path == '/' %}
{% include "home.html" %}
@@ -14,4 +18,3 @@
</section>
{% endif %}
{% endblock %}
+7
View File
@@ -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 %}
<article class="project-page">
<header class="project-hero">
<div class="shell project-hero-inner">
@@ -19,6 +25,7 @@
<a class="button-link button-link-primary" href="{{ this.repository_url }}">Source repository →</a>
<a class="button-link" href="{{ this.repository_readme_url }}">README →</a>
{% if devlog %}<a class="button-link" href="{{ devlog|url }}">Development log →</a>{% endif %}
{{ visible_feed_links(project_feed_base ~ 'feed.xml', project_feed_base ~ 'rss.xml') }}
</div>
</div>
</header>
+17 -1
View File
@@ -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 %}
<section class="activity-section">
<div class="shell">
<div class="section-heading"><span>{{ this.title }}</span><span data-entry-count>{{ this.children.count() }} entries</span></div>
<div class="section-heading">
<span>{{ this.title }}</span>
<span>
<span data-entry-count>{{ this.children.count() }} entries</span>
{% 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 %}
</span>
</div>
{% if filter_tags.values %}
<div class="tag-filter" data-tag-filter aria-label="Filter {{ this.title }} by tag">
<button class="tag-filter-button is-active" type="button" data-tag-value="" aria-pressed="true">All</button>
+312
View File
@@ -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 <older> 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(
'<p><a href="notes/">Notes</a><img src="cover.png" '
'srcset="small.png 1x, /large.png 2x"><a href="mailto:[email protected]">Mail</a></p>',
"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:[email protected]"', rendered)
unsafe = absolute_html_urls(
'<a href="javascript:alert(1)">Unsafe</a>',
"https://www.labyricorn.com/",
)
self.assertEqual('<a href="#">Unsafe</a>', 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 <summary> & test",
content_html='<p>Readable &amp; complete <a href="https://example.test/">content</a>.</p>',
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()
+12 -7
View File
@@ -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(
'<section class="homepage-panel featured-projects-panel"', 1
)[0]
self.assertIn(
"devlog.children.order_by('-date', '-source_commit_time').first()",
recent_activity,
)
self.assertIn("entries.append(latest_devlog_entry)", recent_activity)
self.assertNotIn("for entry in devlog.children", recent_activity)
self.assertIn("for item_path in this.recent_activity_paths", recent_activity)
self.assertIn("site.get(item_path)", recent_activity)
self.assertNotIn("entries.append", recent_activity)
def test_recent_activity_exposes_visible_atom_and_rss_links(self) -> None:
recent_activity = self.template.split(
'<section class="homepage-panel featured-projects-panel"', 1
)[0]
self.assertIn("https://www.labyricorn.com/feed.xml", recent_activity)
self.assertIn("https://www.labyricorn.com/rss.xml", recent_activity)
def test_tag_matrix_is_last_homepage_section_and_uses_canonical_tags(self) -> None:
matrix_position = self.template.index("{% set tracked_tags")