This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,3 +18,6 @@ type = text
|
||||
label = Body
|
||||
type = markdown
|
||||
|
||||
[fields.recent_activity_paths]
|
||||
label = Generated recent activity paths
|
||||
type = strings
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 & 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()
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user