feat: rss feed generation
Some checks failed
/ test-build (push) Failing after 18s

This commit is contained in:
Daniel Fichtinger 2025-07-12 01:06:31 -04:00
parent 4f8979ae9b
commit 828a82e5cb
10 changed files with 251 additions and 34 deletions

View file

@ -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 # 1.2.1
- Added `--version` flag to CLI. - Added `--version` flag to CLI.

View file

@ -28,6 +28,7 @@ For an example of a website built with zona, please see
- [Site Layout](#site-layout) - [Site Layout](#site-layout)
- [Templates](#templates) - [Templates](#templates)
- [Markdown Footer](#markdown-footer) - [Markdown Footer](#markdown-footer)
- [RSS Feed Generation](#rss-feed-generation)
- [Internal Link Resolution](#internal-link-resolution) - [Internal Link Resolution](#internal-link-resolution)
- [Syntax Highlighting](#syntax-highlighting) - [Syntax Highlighting](#syntax-highlighting)
- [Markdown Extensions](#markdown-extensions) - [Markdown Extensions](#markdown-extensions)
@ -49,6 +50,7 @@ For an example of a website built with zona, please see
- Live refresh in browser preview. - Live refresh in browser preview.
- `jinja2` template support with sensible defaults included. - `jinja2` template support with sensible defaults included.
- Basic page, blog post, post list. - Basic page, blog post, post list.
- RSS feed generation.
- Glob ignore. - Glob ignore.
- YAML frontmatter. - YAML frontmatter.
- Easily configurable sitemap header. - 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 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.** 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 ### Internal Link Resolution
When zona encounters links in Markdown documents, it attempts to resolve them as 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 are optional. `none` is used when the option is unset. The following options are
available: available:
| Key | Type & Default | Description | | Key | Type & Default | Description |
| ------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | | ------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `title` | `str` = title-cased filename. | Title of the page. | | `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. | | `description` | `str \| none` = `none` | Description. If omitted, default from [config](#configuration) will be used. |
| `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. | | `date` | Date string = file modified time. | Displayed on blog posts and used for post_list sorting. |
| `header` | `bool` = `true` | Whether the header sitemap should be rendered. | | `show_title` | `bool` = `true` | Whether `metadata.title` should be included in the template. |
| `footer` | `bool` = `true` | Whether the footer should be rendered. | | `header` | `bool` = `true` | Whether the header sitemap should be rendered. |
| `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. | | `footer` | `bool` = `true` | Whether the footer should be rendered. |
| `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. | | `template` | `str \| none` = `none` | Template to use for this page. Relative to `templates/`, `.html` extension optional. |
| `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. | | `post` | `bool \| none` = `none` | Whether this page is a **post**. `true`/`false` is _absolute_. Leave it unset for automatic detection. |
| `ignore` | `bool` = `false` | Whether this page should be ignored in _both_ `final` and `draft` contexts. | | `draft` | `bool` = `false` | Whether this page is a draft. See [drafts](#drafts) for more. |
| `math` | `bool` = `true` | Whether the LaTeX extension should be enabled for this page. | | `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 **Note**: you can specify the date in any format that can be parsed by
[`python-dateutil`](https://pypi.org/project/python-dateutil/). [`python-dateutil`](https://pypi.org/project/python-dateutil/).
@ -374,12 +388,23 @@ useful settings are listed here.
Please see the default configuration: Please see the default configuration:
```yaml ```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: sitemap:
Home: / Home: /
ignore: ignore:
- .marksman.toml - .marksman.toml
markdown: markdown:
image_labels: true
tab_length: 2 tab_length: 2
syntax_highlighting: syntax_highlighting:
enabled: true enabled: true
@ -392,6 +417,8 @@ build:
include_drafts: false include_drafts: false
blog: blog:
dir: blog dir: blog
defaults:
description: A blog post
server: server:
reload: reload:
enabled: true enabled: true
@ -400,6 +427,15 @@ server:
| Name | Description | | 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). | | `sitemap` | Sitemap dictionary. See [Sitemap](#sitemap). |
| `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). | | `ignore` | List of paths to ignore. See [Ignore List](#ignore-list). |
| `markdown.tab_length` | How many spaces should be considered an indentation level. | | `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.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. | | `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.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.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". | | `server.reload.scroll_tolerance` | The distance, in pixels, from the bottom to still count as "scrolled to bottom". |

View file

@ -11,6 +11,7 @@ authors = [
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"dacite>=1.9.2", "dacite>=1.9.2",
"feedgen>=1.0.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"l2m4m>=1.0.4", "l2m4m>=1.0.4",
"markdown>=3.8.2", "markdown>=3.8.2",
@ -57,7 +58,7 @@ reportUnusedCallResult = false
reportCallInDefaultInitializer = false reportCallInDefaultInitializer = false
enableTypeIgnoreComments = true enableTypeIgnoreComments = true
reportIgnoreCommentWithoutRule = false reportIgnoreCommentWithoutRule = false
allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m"] allowedUntypedLibraries = ["frontmatter", "pygments", "pymdownx", "l2m4m", "feedgen", "feedgen.feed"]
[tool.ruff] [tool.ruff]
line-length = 70 line-length = 70

View file

@ -2,6 +2,8 @@ import shutil
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from feedgen.feed import FeedGenerator
from zona import markdown as zmd from zona import markdown as zmd
from zona import util from zona import util
from zona.config import ZonaConfig from zona.config import ZonaConfig
@ -55,7 +57,9 @@ class ZonaBuilder:
layout.root / "content" / "static" layout.root / "content" / "static"
): ):
logger.debug(f"Parsing {path.name}.") 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 ( if item.metadata.ignore or (
item.metadata.draft item.metadata.draft
and not self.config.build.include_drafts and not self.config.build.include_drafts
@ -97,7 +101,45 @@ class ZonaBuilder:
items.append(item) items.append(item)
self.items = items 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 assert self.items
# sort according to date # sort according to date
# descending order # descending order
@ -108,7 +150,12 @@ class ZonaBuilder:
else date.min, else date.min,
reverse=True, reverse=True,
) )
return post_list
def _build(self):
post_list = self._get_post_list()
# number of posts # number of posts
# generate RSS here
posts = len(post_list) posts = len(post_list)
# link post chronology # link post chronology
for i, item in enumerate(post_list): for i, item in enumerate(post_list):
@ -180,4 +227,9 @@ class ZonaBuilder:
self._discover() self._discover()
logger.debug("Building...") logger.debug("Building...")
self._build() 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 self.fresh = False

View file

@ -1,7 +1,13 @@
from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, tzinfo
from pathlib import Path from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import yaml import yaml
from dacite import Config as DaciteConfig
from dacite import from_dict from dacite import from_dict
from zona.log import get_logger from zona.log import get_logger
@ -25,9 +31,17 @@ def find_config(start: Path | None = None) -> Path | None:
SitemapConfig = dict[str, str] SitemapConfig = dict[str, str]
@dataclass
class PostDefaultsConfig:
description: str = "A blog post"
@dataclass @dataclass
class BlogConfig: class BlogConfig:
dir: str = "blog" dir: str = "blog"
defaults: PostDefaultsConfig = field(
default_factory=PostDefaultsConfig
)
@dataclass @dataclass
@ -69,12 +83,40 @@ class ServerConfig:
reload: ReloadConfig = field(default_factory=ReloadConfig) 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"] 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 @dataclass
class ZonaConfig: class ZonaConfig:
base_url: str = "/" base_url: str = "/"
feed: FeedConfig = field(default_factory=FeedConfig)
# dictionary where key is name, value is url # dictionary where key is name, value is url
sitemap: SitemapConfig = field( sitemap: SitemapConfig = field(
default_factory=lambda: {"Home": "/"} default_factory=lambda: {"Home": "/"}
@ -87,7 +129,12 @@ class ZonaConfig:
server: ServerConfig = field(default_factory=ServerConfig) server: ServerConfig = field(default_factory=ServerConfig)
@classmethod @classmethod
def from_file(cls, path: Path) -> "ZonaConfig": def from_file(cls, path: Path) -> ZonaConfig:
with open(path, "r") as f: with open(path, "r") as f:
raw = yaml.safe_load(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

View file

@ -1,8 +1,8 @@
{% extends "base.html" %} {% block content %} {% if metadata.show_title %} {% {% extends "base.html" %} {% block content %} {% if metadata.show_title %} {%
include "title.html" %} {% if metadata.date %} include "title.html" %} {% if metadata.date.date() %}
<center> <center>
<time class="post-date" datetime="{{ metadata.date | safe }}" <time class="post-date" datetime="{{ metadata.date.date() | safe }}"
>{{ metadata.date | safe}}</time> >{{ metadata.date.date() | safe}}</time>
</center> </center>
{% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif {% endif %} {% endif %} {% if is_post %} {% include "post_nav.html" %} {% endif
%} %}

View file

@ -8,8 +8,8 @@ include "title.html" %} {% endif %}
<ul> <ul>
{% for item in post_list %} {% for item in post_list %}
<li> <li>
<time class="post-list-date" datetime="{{ item.metadata.date | safe }}" <time class="post-list-date" datetime="{{ item.metadata.date.date() | safe }}"
>{{ item.metadata.date | safe}}</time>: <a href="/{{ item.url }}" >{{ item.metadata.date.date() | safe}}</time>: <a href="/{{ item.url }}"
>{{ item.metadata.title }}</a> >{{ item.metadata.title }}</a>
</li> </li>
{% endfor %} {% endfor %}

View file

@ -1,5 +1,6 @@
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
import typer import typer
import yaml import yaml
@ -145,9 +146,13 @@ def initialize_site(root: Path | None = None):
logger.debug("Loading default configuation.") logger.debug("Loading default configuation.")
config = ZonaConfig() config = ZonaConfig()
logger.debug(f"Writing default configuration to {config_path}.") logger.debug(f"Writing default configuration to {config_path}.")
config_dict = asdict(config)
if "feed" in config_dict and "timezone" in config_dict["feed"]:
tz: ZoneInfo = config_dict["feed"]["timezone"]
config_dict["feed"]["timezone"] = tz.key
with open(config_path, "w") as f: with open(config_path, "w") as f:
yaml.dump( yaml.dump(
asdict(config), config_dict,
f, f,
sort_keys=False, sort_keys=False,
default_flow_style=False, default_flow_style=False,

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, time, tzinfo
from pathlib import Path from pathlib import Path
import frontmatter import frontmatter
@ -10,13 +10,14 @@ from dateutil import parser as date_parser
from yaml import YAMLError from yaml import YAMLError
import zona.util import zona.util
from zona.config import ZonaConfig
@dataclass @dataclass
class Metadata: class Metadata:
title: str title: str
date: date date: datetime
description: str | None description: str
show_title: bool = True show_title: bool = True
show_date: bool = True show_date: bool = True
show_nav: bool = True show_nav: bool = True
@ -30,14 +31,29 @@ class Metadata:
math: bool = True math: bool = True
def parse_date(raw_date: str | date | object) -> date: def ensure_timezone(dt: datetime, tz: tzinfo) -> datetime:
if isinstance(raw_date, date): if dt.tzinfo is None or dt.utcoffset() is None:
return raw_date dt = dt.replace(tzinfo=tz)
return dt
# TODO: migrate to using datetime, where user can optionall specify
# a time as well. if only date is given, default to time.min
def parse_date(
raw_date: str | datetime | date | object, tz: tzinfo
) -> datetime:
if isinstance(raw_date, datetime):
return ensure_timezone(raw_date, tz)
elif isinstance(raw_date, date):
return datetime.combine(raw_date, time.min, tzinfo=tz)
assert isinstance(raw_date, str) assert isinstance(raw_date, str)
return date_parser.parse(raw_date).date() dt = date_parser.parse(raw_date)
return ensure_timezone(dt, tz)
def parse_metadata(path: Path) -> tuple[Metadata, str]: def parse_metadata(
path: Path, config: ZonaConfig
) -> tuple[Metadata, str]:
""" """
Parses a file and returns parsed Metadata and its content. Defaults Parses a file and returns parsed Metadata and its content. Defaults
are applied for missing fields. If there is no metadata, a Metadata are applied for missing fields. If there is no metadata, a Metadata
@ -53,10 +69,11 @@ def parse_metadata(path: Path) -> tuple[Metadata, str]:
raw_meta = post.metadata or {} raw_meta = post.metadata or {}
defaults = { defaults = {
"title": zona.util.filename_to_title(path), "title": zona.util.filename_to_title(path),
"date": date.fromtimestamp(path.stat().st_ctime), "date": datetime.fromtimestamp(path.stat().st_ctime),
"description": config.blog.defaults.description,
} }
meta = {**defaults, **raw_meta} meta = {**defaults, **raw_meta}
meta["date"] = parse_date(meta.get("date")) meta["date"] = parse_date(meta.get("date"), config.feed.timezone)
try: try:
metadata = from_dict( metadata = from_dict(
data_class=Metadata, data_class=Metadata,

52
uv.lock generated
View file

@ -44,6 +44,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" },
] ]
[[package]]
name = "feedgen"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "python-dateutil" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/59/be0a6f852b5dfbf19e6c8e962c8f41407697f9f52a7902250ed98683ae89/feedgen-1.0.0.tar.gz", hash = "sha256:d9bd51c3b5e956a2a52998c3708c4d2c729f2fcc311188e1e5d3b9726393546a", size = 258496, upload-time = "2023-12-25T18:04:08.421Z" }
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.1.0" version = "2.1.0"
@ -87,6 +97,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" }, { url = "https://files.pythonhosted.org/packages/1e/fd/aba08bb9e527168efad57985d7db9a853eb2384b1efa5ca5f3a3794c9cef/latex2mathml-3.78.0-py3-none-any.whl", hash = "sha256:1aeca3dc027b3006ad7b301b7f4a15ffbb4c1451e3dc8c3389e97b37b497e1d6", size = 73673, upload-time = "2025-05-03T16:51:51.991Z" },
] ]
[[package]]
name = "lxml"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" },
{ url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" },
{ url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" },
{ url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" },
{ url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" },
{ url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" },
{ url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" },
{ url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" },
{ url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" },
{ url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" },
{ url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" },
{ url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" },
{ url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" },
{ url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" },
{ url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" },
{ url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" },
{ url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" },
{ url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" },
{ url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" },
{ url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" },
{ url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" },
{ url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" },
{ url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" },
{ url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" },
]
[[package]] [[package]]
name = "markdown" name = "markdown"
version = "3.8.2" version = "3.8.2"
@ -463,6 +513,7 @@ version = "1.2.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dacite" }, { name = "dacite" },
{ name = "feedgen" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "l2m4m" }, { name = "l2m4m" },
{ name = "markdown" }, { name = "markdown" },
@ -489,6 +540,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dacite", specifier = ">=1.9.2" }, { name = "dacite", specifier = ">=1.9.2" },
{ name = "feedgen", specifier = ">=1.0.0" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "l2m4m", specifier = ">=1.0.4" }, { name = "l2m4m", specifier = ">=1.0.4" },
{ name = "markdown", specifier = ">=3.8.2" }, { name = "markdown", specifier = ">=3.8.2" },