From 828a82e5cb62f59b82b9680a64a050d5d7c10f76 Mon Sep 17 00:00:00 2001 From: Daniel Fichtinger Date: Sat, 12 Jul 2025 01:06:31 -0400 Subject: [PATCH] feat: rss feed generation --- CHANGELOG.md | 6 +++ README.md | 63 ++++++++++++++++++++------ pyproject.toml | 3 +- src/zona/builder.py | 56 ++++++++++++++++++++++- src/zona/config.py | 51 ++++++++++++++++++++- src/zona/data/templates/page.html | 6 +-- src/zona/data/templates/post_list.html | 4 +- src/zona/layout.py | 7 ++- src/zona/metadata.py | 37 +++++++++++---- uv.lock | 52 +++++++++++++++++++++ 10 files changed, 251 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104815a..269d2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.3.0 + +- Added RSS feed generation. +- Added default post description to configuration. +- Added time-of-day support to post `date` frontmatter parsing. + # 1.2.1 - Added `--version` flag to CLI. diff --git a/README.md b/README.md index ccf5dd1..6fe124a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ For an example of a website built with zona, please see - [Site Layout](#site-layout) - [Templates](#templates) - [Markdown Footer](#markdown-footer) + - [RSS Feed Generation](#rss-feed-generation) - [Internal Link Resolution](#internal-link-resolution) - [Syntax Highlighting](#syntax-highlighting) - [Markdown Extensions](#markdown-extensions) @@ -49,6 +50,7 @@ For an example of a website built with zona, please see - Live refresh in browser preview. - `jinja2` template support with sensible defaults included. - Basic page, blog post, post list. +- RSS feed generation. - Glob ignore. - YAML frontmatter. - Easily configurable sitemap header. @@ -212,6 +214,17 @@ it's parsed and rendered into HTML, then made available to other templates as the `footer` variable. If `footer.md` is missing but `footer.html` exists, then it's used instead. **Note: links are _not_ resolved in the footer.** +### RSS Feed Generation + +Zona can also generates an RSS feed containing your blog posts. This feature is +disabled by default, and you can enable it in the +[configuration](#configuration). + +The default location is a file called `rss.xml` in the content root. All RSS +related configuration is specified in `config.yml`. If you plan to use the feed, +make sure to replace the placeholder `link`, `title`, `description`, and +`author` configuration values before you set `enabled: true`. + ### Internal Link Resolution When zona encounters links in Markdown documents, it attempts to resolve them as @@ -309,18 +322,19 @@ YAML frontmatter can be used to configure the metadata of documents. All of them are optional. `none` is used when the option is unset. The following options are available: -| Key | Type & Default | Description | -| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `title` | `str` = title-cased filename. | Title of the page. | -| `date` | Date string = file modified time. | Displayed on blog posts and used for post_list sorting. | -| `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. | -| `header` | `bool` = `true` | Whether the header sitemap should be rendered. | -| `footer` | `bool` = `true` | Whether the footer should be rendered. | -| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | -| `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. | -| `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. | -| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. | -| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. | +| Key | Type & Default | Description | +| ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `title` | `str` = title-cased filename. | Title of the page. | +| `description` | `str \| none` = `none` | Description. If omitted, default from [config](#configuration) will be used. | +| `date` | Date string = file modified time. | Displayed on blog posts and used for post_list sorting. | +| `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. | +| `header` | `bool` = `true` | Whether the header sitemap should be rendered. | +| `footer` | `bool` = `true` | Whether the footer should be rendered. | +| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | +| `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. | +| `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. | +| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. | +| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. | **Note**: you can specify the date in any format that can be parsed by [`python-dateutil`](https://pypi.org/project/python-dateutil/). @@ -374,12 +388,23 @@ useful settings are listed here. Please see the default configuration: ```yaml -base_url: / +feed: + enabled: true + timezone: UTC + path: rss.xml + link: https://example.com + title: Zona Website + description: My zona website. + language: en + author: + name: John Doe + email: john@doe.net sitemap: Home: / ignore: - .marksman.toml markdown: + image_labels: true tab_length: 2 syntax_highlighting: enabled: true @@ -392,6 +417,8 @@ build: include_drafts: false blog: dir: blog + defaults: + description: A blog post server: reload: enabled: true @@ -400,6 +427,15 @@ server: | Name | Description | | -------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `feed.enabled` | Whether RSS feed should be generated. **Off by default**. | +| `feed.timezime` | Timezone to use for post `pubDate` values. Must be an IANA compliant string. | +| `feed.path` | Location of the feed, relative to content root. | +| `feed.link` | The base URL of the website. | +| `feed.title` | Website title. | +| `feed.description` | Website description. | +| `feed.language` | String specifying website's language code. | +| `author.name` | Your full name. | +| `author.email` | Your email address. | | `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). | | `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | | `markdown.tab_length` | How many spaces should be considered an indentation level. | @@ -410,6 +446,7 @@ server: | `build.clean_output_dir` | Whether previous build artifacts should be cleared when building. Recommended to leave this on. | | `build.include_drafts` | Whether drafts should be included by default. | | `blog.dir` | Name of a directory relative to `content/` whose children are automatically considered posts. | +| `blog.defaults.description` | Default description for blog posts with no `description` in their frontmatter. | | `server.reload.enabled` | Whether the preview server should use [live reload](#live-preview). | | `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". | diff --git a/pyproject.toml b/pyproject.toml index d852304..b6d7de5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ authors = [ requires-python = ">=3.12" dependencies = [ "dacite>=1.9.2", + "feedgen>=1.0.0", "jinja2>=3.1.6", "l2m4m>=1.0.4", "markdown>=3.8.2", @@ -57,7 +58,7 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false enableTypeIgnoreComments = true reportIgnoreCommentWithoutRule = false -allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] +allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"] [tool.ruff] line-length = 70 diff --git a/src/zona/builder.py b/src/zona/builder.py index 416edc8..37a3bd1 100644 --- a/src/zona/builder.py +++ b/src/zona/builder.py @@ -2,6 +2,8 @@ import shutil from datetime import date from pathlib import Path +from feedgen.feed import FeedGenerator + from zona import markdown as zmd from zona import util from zona.config import ZonaConfig @@ -55,7 +57,9 @@ class ZonaBuilder: layout.root / "content" / "static" ): logger.debug(f"Parsing {path.name}.") - item.metadata, item.content = parse_metadata(path) + item.metadata, item.content = parse_metadata( + path, config=self.config + ) if item.metadata.ignore or ( item.metadata.draft and not self.config.build.include_drafts @@ -97,7 +101,45 @@ class ZonaBuilder: items.append(item) self.items = items - def _build(self): + def generate_feed(self) -> bytes: + post_list = self._get_post_list() + config = self.config.feed + if config.link.endswith("/"): + config.link = config.link[:-2] + fg = FeedGenerator() + fg.id(config.link) + fg.title(config.title) + author = { + "name": config.author.name, + "email": config.author.email, + } + fg.author(author) + fg.link( + href=f"{config.link}/{config.path}", + rel="self", + type="application/rss+xml", + ) + fg.language(config.language) + fg.description(config.description) + + for post in post_list: + assert post.metadata + fe = fg.add_entry() # pyright: ignore[reportUnknownVariableType] + fe.id(f"{config.link}{util.normalize_url(post.url)}") # pyright: ignore[reportUnknownMemberType] + fe.link( # pyright: ignore[reportUnknownMemberType] + href=f"{config.link}{util.normalize_url(post.url)}" + ) + fe.title(post.metadata.title) # pyright: ignore[reportUnknownMemberType] + fe.author(author) # pyright: ignore[reportUnknownMemberType] + desc = post.metadata.description + fe.description(desc) # pyright: ignore[reportUnknownMemberType] + date = post.metadata.date + fe.pubDate(date) # pyright: ignore[reportUnknownMemberType] + out: bytes = fg.rss_str(pretty=True) # pyright: ignore[reportUnknownVariableType] + assert isinstance(out, bytes) + return out + + def _get_post_list(self) -> list[Item]: assert self.items # sort according to date # descending order @@ -108,7 +150,12 @@ class ZonaBuilder: else date.min, reverse=True, ) + return post_list + + def _build(self): + post_list = self._get_post_list() # number of posts + # generate RSS here posts = len(post_list) # link post chronology for i, item in enumerate(post_list): @@ -180,4 +227,9 @@ class ZonaBuilder: self._discover() logger.debug("Building...") self._build() + if self.config.feed.enabled: + rss = self.generate_feed() + path = self.layout.content / self.config.feed.path + util.ensure_parents(path) + path.write_bytes(rss) self.fresh = False diff --git a/src/zona/config.py b/src/zona/config.py index 2a50fd4..84844c7 100644 --- a/src/zona/config.py +++ b/src/zona/config.py @@ -1,7 +1,13 @@ +from __future__ import annotations + from dataclasses import dataclass, field +from datetime import datetime, tzinfo from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo import yaml +from dacite import Config as DaciteConfig from dacite import from_dict from zona.log import get_logger @@ -25,9 +31,17 @@ def find_config(start: Path | None = None) -> Path | None: SitemapConfig = dict[str, str] +@dataclass +class PostDefaultsConfig: + description: str = "A blog post" + + @dataclass class BlogConfig: dir: str = "blog" + defaults: PostDefaultsConfig = field( + default_factory=PostDefaultsConfig + ) @dataclass @@ -69,12 +83,40 @@ class ServerConfig: reload: ReloadConfig = field(default_factory=ReloadConfig) +@dataclass +class AuthorConfig: + name: str = "John Doe" + email: str = "john@doe.net" + + +@dataclass +class FeedConfig: + enabled: bool = True + timezone: tzinfo = field(default_factory=lambda: ZoneInfo("UTC")) + path: str = "rss.xml" + link: str = "https://example.com" + title: str = "Zona Website" + description: str = "My zona website." + language: str = "en" + author: AuthorConfig = field(default_factory=AuthorConfig) + + IGNORELIST = [".marksman.toml"] +def parse_timezone(s: Any) -> tzinfo: + if isinstance(s, str): + return ZoneInfo(s) + else: + raise TypeError( + f"Expected {str}, got {type(s)} for config key timezone" + ) + + @dataclass class ZonaConfig: base_url: str = "/" + feed: FeedConfig = field(default_factory=FeedConfig) # dictionary where key is name, value is url sitemap: SitemapConfig = field( default_factory=lambda: {"Home": "/"} @@ -87,7 +129,12 @@ class ZonaConfig: server: ServerConfig = field(default_factory=ServerConfig) @classmethod - def from_file(cls, path: Path) -> "ZonaConfig": + def from_file(cls, path: Path) -> ZonaConfig: with open(path, "r") as f: raw = yaml.safe_load(f) - return from_dict(data_class=cls, data=raw) + config: ZonaConfig = from_dict( + data_class=cls, + data=raw, + config=DaciteConfig(type_hooks={tzinfo: parse_timezone}), + ) + return config diff --git a/src/zona/data/templates/page.html b/src/zona/data/templates/page.html index 0c22f8b..0ff499b 100644 --- a/src/zona/data/templates/page.html +++ b/src/zona/data/templates/page.html @@ -1,8 +1,8 @@ {% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% -include "title.html" %} {% if metadata.date %} +include "title.html" %} {% if metadata.date.date() %}
- +
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif %} diff --git a/src/zona/data/templates/post_list.html b/src/zona/data/templates/post_list.html index 6b0bdca..30ea74f 100644 --- a/src/zona/data/templates/post_list.html +++ b/src/zona/data/templates/post_list.html @@ -8,8 +8,8 @@ include "title.html" %} {% endif %}