diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml
index 53dd92f..fde3206 100644
--- a/.forgejo/workflows/publish.yml
+++ b/.forgejo/workflows/publish.yml
@@ -7,6 +7,9 @@ jobs:
runs-on: based-alpine
steps:
- uses: actions/checkout@v4
+ - name: setup cache
+ id: uv-cache
+ uses: https://git.ficd.sh/ficd/uv-cache@v1
- name: build
run: |
uv sync
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 104815a..9f6eed9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+# 1.3.0
+
+- Added RSS feed generation.
+- Added default post description to configuration.
+- Added time-of-day support to post `date` frontmatter parsing.
+- `zona init` now only writes `footer.md` to the templates directory.
+
# 1.2.1
- Added `--version` flag to CLI.
diff --git a/README.md b/README.md
index c3facfb..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
@@ -231,11 +244,11 @@ modified if they point to a real file that's not included in the ignore list.
Zona uses [Pygments] to provide syntax highlighting for fenced code blocks. The
following Pygments plugins are included:
-- [pygments-kakoune](https://codeberg.com/ficd/pygments-kakoune)
+- [pygments-kakoune](https://codeberg.org/ficd/pygments-kakoune)
- A lexer providing for highlighting Kakoune code. Available under the `kak`
and `kakrc` aliases.
-- [pygments-ashen](https://codeberg.com/ficd/ashen/tree/main/item/pygments/README.md)
- - An implementation of the [Ashen](https://codeberg.com/ficd/ashen) theme for
+- [pygments-ashen](https://codeberg.org/ficd/ashen/tree/main/item/pygments/README.md)
+ - An implementation of the [Ashen](https://codeberg.org/ficd/ashen) theme for
Pygments.
If you want to use any external Pygments styles or lexers, they must be
@@ -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". |
@@ -442,7 +479,7 @@ output. If you set `draft: true` in a page's frontmatter, it will be marked as a
draft. Drafts are completely excluded from `zona build` and `zona serve` unless
the `--draft` flag is specified.
-[Ashen]: https://codeberg.com/ficd/ashen
+[Ashen]: https://codeberg.org/ficd/ashen
[Pygments]: https://pygments.org/
## Known Problems
diff --git a/pyproject.toml b/pyproject.toml
index d852304..185c12d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "zona"
-version = "1.2.1"
+version = "1.2.2"
description = "Opinionated static site generator."
license = "BSD-3-Clause "
license-files = ["LICENSE"]
@@ -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..dd9509f 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.output / 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 %}