From 7e6608c548c518ab13bb97cd67c7415e0ea7eb34 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:28:17 +0100 Subject: [PATCH 01/14] add tests for atom feed --- .gitignore | 4 + tests/Makefile | 9 +++ tests/fixtures/atom/config.yaml | 18 +++++ tests/fixtures/atom/content/_index.md | 4 + tests/fixtures/atom/content/posts/_index.md | 4 + tests/fixtures/atom/content/posts/newer.md | 10 +++ tests/fixtures/atom/content/posts/older.md | 16 ++++ tests/test_atom.py | 81 +++++++++++++++++++++ 8 files changed, 146 insertions(+) create mode 100644 tests/Makefile create mode 100644 tests/fixtures/atom/config.yaml create mode 100644 tests/fixtures/atom/content/_index.md create mode 100644 tests/fixtures/atom/content/posts/_index.md create mode 100644 tests/fixtures/atom/content/posts/newer.md create mode 100644 tests/fixtures/atom/content/posts/older.md create mode 100644 tests/test_atom.py diff --git a/.gitignore b/.gitignore index 0d1c931c..2eafad14 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ public .*.swp .DS_Store + +__pycache__ +__pycache__/ +*.py[cod] diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 00000000..522562ad --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,9 @@ +.PHONY: help test + +.DEFAULT_GOAL := help + +help: + @grep ": ##" Makefile | grep -v grep | tr -d '#' + +test: ## Run the test suite + cd .. && python -m pytest tests diff --git a/tests/fixtures/atom/config.yaml b/tests/fixtures/atom/config.yaml new file mode 100644 index 00000000..1360979d --- /dev/null +++ b/tests/fixtures/atom/config.yaml @@ -0,0 +1,18 @@ +baseURL: "https://example.org/" +languageCode: "en-GB" +title: "Atom Test Site" +theme: scientific-python-hugo-theme + +params: + author: + name: "Example Team" + email: "team@example.org" + description: "A test feed" + +outputs: + home: + - HTML + - ATOM + section: + - HTML + - ATOM diff --git a/tests/fixtures/atom/content/_index.md b/tests/fixtures/atom/content/_index.md new file mode 100644 index 00000000..7cd893af --- /dev/null +++ b/tests/fixtures/atom/content/_index.md @@ -0,0 +1,4 @@ +--- +title: "Atom Test Site" +description: "The home feed used by the Atom test fixture." +--- diff --git a/tests/fixtures/atom/content/posts/_index.md b/tests/fixtures/atom/content/posts/_index.md new file mode 100644 index 00000000..1822ae79 --- /dev/null +++ b/tests/fixtures/atom/content/posts/_index.md @@ -0,0 +1,4 @@ +--- +title: "Test Posts" +description: "The section feed used by the Atom test fixture." +--- diff --git a/tests/fixtures/atom/content/posts/newer.md b/tests/fixtures/atom/content/posts/newer.md new file mode 100644 index 00000000..73ab65b8 --- /dev/null +++ b/tests/fixtures/atom/content/posts/newer.md @@ -0,0 +1,10 @@ +--- +title: "Newer post" +date: 2026-01-03T11:00:00Z +tags: + - feeds +--- + +This entry was published second. + +It contains **formatted text** and an [ordinary link](https://example.org/documentation/). diff --git a/tests/fixtures/atom/content/posts/older.md b/tests/fixtures/atom/content/posts/older.md new file mode 100644 index 00000000..bff91ea1 --- /dev/null +++ b/tests/fixtures/atom/content/posts/older.md @@ -0,0 +1,16 @@ +--- +title: "Older post with & characters" +date: 2026-01-01T10:00:00Z +lastmod: 2026-01-05T12:00:00Z +tags: + - XML + - feeds +--- + +This entry was published first but updated most recently. + +It contains an ampersand: Smith & Jones. + +It also contains Unicode: λ, café, and naïve. + +A link with query parameters diff --git a/tests/test_atom.py b/tests/test_atom.py new file mode 100644 index 00000000..2d0ec0d2 --- /dev/null +++ b/tests/test_atom.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FIXTURE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "atom" +THEMES_DIR = PROJECT_ROOT.parent + +ATOM_NAMESPACE = "http://www.w3.org/2005/Atom" +ATOM = f"{{{ATOM_NAMESPACE}}}" + + +@pytest.fixture(scope="module") +def built_site(tmp_path_factory: pytest.TempPathFactory) -> Path: + output_dir = tmp_path_factory.mktemp("atom-site") / "public" + + result = subprocess.run( + [ + "hugo", + "--source", + str(FIXTURE_ROOT), + "--themesDir", + str(THEMES_DIR), + "--destination", + str(output_dir), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, ( + "Hugo failed to build the Atom fixture.\n\n" + f"stdout:\n{result.stdout}\n\n" + f"stderr:\n{result.stderr}" + ) + + return output_dir + + +@pytest.fixture(scope="module") +def atom_feed(built_site: Path) -> ET.Element: + atom_path = built_site / "atom.xml" + + assert atom_path.is_file(), "Hugo did not generate atom.xml" + + return ET.parse(atom_path).getroot() + + +def test_atom_feed_uses_atom_namespace(atom_feed: ET.Element) -> None: + assert atom_feed.tag == f"{ATOM}feed" + + +@pytest.mark.parametrize("element_name", ["title", "id", "updated"]) +def test_atom_feed_contains_required_metadata( + atom_feed: ET.Element, + element_name: str, +) -> None: + elements = atom_feed.findall(f"{ATOM}{element_name}") + + assert len(elements) == 1 + assert elements[0].text + + +def test_atom_feed_title(atom_feed: ET.Element) -> None: + assert atom_feed.findtext(f"{ATOM}title") == "Atom Test Site" + + +def test_atom_feed_id_is_canonical_home_url(atom_feed: ET.Element) -> None: + assert atom_feed.findtext(f"{ATOM}id") == "https://example.org/" + + +def test_atom_feed_updated_uses_latest_entry_update( + atom_feed: ET.Element, +) -> None: + assert atom_feed.findtext(f"{ATOM}updated") == "2026-01-05T12:00:00Z" From 04c5bff61c216fa082721022c3707bb134eba060 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:29:02 +0100 Subject: [PATCH 02/14] chore: remove hugo-atom-feed --- .gitmodules | 3 --- config.yaml | 2 -- themes/hugo-atom-feed | 1 - 3 files changed, 6 deletions(-) delete mode 100644 .gitmodules delete mode 160000 themes/hugo-atom-feed diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 50395675..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "themes/hugo-atom-feed"] - path = themes/hugo-atom-feed - url = https://github.com/kaushalmodi/hugo-atom-feed diff --git a/config.yaml b/config.yaml index 1bd5f09e..7b17f836 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,5 @@ disableKinds: - taxonomy -theme: - - scientific-python-hugo-theme/themes/hugo-atom-feed params: colorScheme: auto # can be auto (browser setting), light, or dark images: diff --git a/themes/hugo-atom-feed b/themes/hugo-atom-feed deleted file mode 160000 index d545effe..00000000 --- a/themes/hugo-atom-feed +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d545effed9949bf834eaed09ad423ec3e030794f From c4101341d78bf5b19960d8ad6d484fc65b366569 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:30:14 +0100 Subject: [PATCH 03/14] feat: add initial version of atom --- config.yaml | 12 ++++++++++++ layouts/list.atom.xml | 8 ++++++++ 2 files changed, 20 insertions(+) create mode 100644 layouts/list.atom.xml diff --git a/config.yaml b/config.yaml index 7b17f836..da62002d 100644 --- a/config.yaml +++ b/config.yaml @@ -9,3 +9,15 @@ params: plausible: dataDomain: null javaScript: "https://views.scientific-python.org/js/script.js" + +mediaTypes: + application/atom+xml: + suffixes: + - xml + +outputFormats: + atom: + baseName: atom + mediaType: application/atom+xml + noUgly: true + isPlainText: true diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml new file mode 100644 index 00000000..765b0505 --- /dev/null +++ b/layouts/list.atom.xml @@ -0,0 +1,8 @@ + + + {{ .Title }} + {{ .Permalink }} + {{- with .RegularPagesRecursive.ByLastmod.Reverse }} + {{ (index . 0).Lastmod.Format "2006-01-02T15:04:05Z07:00" }} + {{- end }} + \ No newline at end of file From 46eaa860ab1a7c8e2bf27f39427ee3bb30e25959 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:30:21 +0100 Subject: [PATCH 04/14] feat: add pixi for running tests --- pixi.lock | 970 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ pixi.toml | 14 + 2 files changed, 984 insertions(+) create mode 100644 pixi.lock create mode 100644 pixi.toml diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 00000000..f60a4f05 --- /dev/null +++ b/pixi.lock @@ -0,0 +1,970 @@ +version: 7 +platforms: +- name: linux-64 +- name: osx-64 +- name: osx-arm64 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dart-sass-1.101.3-hfc2019e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/hugo-0.164.0-hc83c272_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-h242f9ac_102_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/dart-sass-1.101.3-h8822bef_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/hugo-0.164.0-h0938e48_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.7.0-h8c408c4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-h77d7759_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.6-hb933c43_102_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hb794df6_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/dart-sass-1.101.3-h820172f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hugo-0.164.0-ha4d9615_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-hf4d206d_102_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 + md5: e675fabcf81499adc7edf58124fb1e01 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 257808 + timestamp: 1785906269155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/dart-sass-1.101.3-hfc2019e_0.conda + sha256: 69536500f63f975797c5d5783e39c44a10d6df18a618e439794025607bfef5c6 + md5: 25aa9feec8bc49793e99f2309b0498c9 + license: MIT + license_family: MIT + run_exports: {} + size: 4102233 + timestamp: 1784613983631 +- conda: https://conda.anaconda.org/conda-forge/linux-64/hugo-0.164.0-hc83c272_0.conda + sha256: 3c6c4a6e11e726a62a4c714d3280d455e80e76f2417fa3875c24ce718a2aff30 + md5: f2046a91afc72270485d553ccdd10c3a + depends: + - __glibc >=2.17 + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 20275039 + timestamp: 1783400884917 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + sha256: d7c260b7e1cf22ce04d6ba8a86eabf4e6c50bc96a5c27fe2ecb32298af3e88eb + md5: 4ef4b977bb216a3001a3334696a80850 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14455340 + timestamp: 1784916378180 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + sha256: 27d83f1188cd19bcb7754a078b3fa7f4cfb8527f8eb2fde54dd01fc529d1adec + md5: 449500f2c089da11c40f5c21312e3e07 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.46.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 745303 + timestamp: 1784214507189 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h3435931_0.conda + sha256: ac38603008bf1e99b8ed379b1a656a67a70e2841f2b6a069c630cdf6316012d2 + md5: 0abe40a9880086ca4d2e5daf09dceff9 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.7.0,<3.8.0a0 + size: 67576 + timestamp: 1783520858222 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + sha256: d5cb8475131c31680f8fd30512c418f373064e272e452063276a8fb14c9fa42f + md5: 5a7d954665c707c93311657cd779c705 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgomp 16.1.0 he0feb66_1 + - libgcc-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1057877 + timestamp: 1785375436766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + sha256: 62cb599ad0539d99386515326d9d5e8f51f75a60c69c2131b21df76edf35bd89 + md5: 88f2d91cb1533194c323534253094d23 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 640415 + timestamp: 1785375373755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda + sha256: 9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab + md5: 1390b7c5ac0b1d8e447bc5efa6d3c8c2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 112995 + timestamp: 1786348617826 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + sha256: 72023efc207fe681e26b65fc9d668062cf0b4f0eacf3431e6eb099b95c1f2efd + md5: df088a279cd5e6fd2790b4c196434da1 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 964200 + timestamp: 1785016112246 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + sha256: 79721dd08aeb0ab9e773f1f9ef41cf4e6c17477e3d72319147619045bce05a09 + md5: aed6cf89adc1e9b846e4367ac538e434 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 16.1.0 ha9f2e26_1 + constrains: + - libstdcxx-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 6631744 + timestamp: 1785375462643 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 + md5: 0de0122d9570a8ab637c6b73db268389 + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63713 + timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda + sha256: 5d46557214ed184381dafe835b7c94a474a1c3b307a08a250b1ea4779b44ffb3 + md5: ee6c0cd80a60961a1f48aa3e0b91f986 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 911196 + timestamp: 1786355078102 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + sha256: 012096056b97abf1f68c46b7146bd2cbd68c1be762340b4f5dad4fbbe99177bc + md5: c5955c27917ff2234def47f075e71e02 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3182423 + timestamp: 1785913583650 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-h242f9ac_102_cp314.conda + build_number: 102 + sha256: f5ff5c1fac471dfed4fc4288856a63eb9d771e6042d9f70420d75b9f488400d8 + md5: 9b6c336ef7195fbee1c10c09bfcf901b + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libuuid >=2.42.2,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 36866750 + timestamp: 1786444737142 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + build_number: 103 + sha256: 43624eab22f5f29df7d6ffe914cf442f28fd559b55b290906255492826e636e8 + md5: 48a1049e710857572fc2a832aa394d9f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + constrains: + - xorg-libx11 >=1.8.13,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3550916 + timestamp: 1784229071544 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + sha256: 0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6 + md5: 0f51e2391ade309db462a55611263e9c + depends: + - __unix + license: ISC + run_exports: {} + size: 131780 + timestamp: 1784754889428 +- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda + sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 + md5: 962b9857ee8e7018c22f2776ffa0b2d7 + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 27011 + timestamp: 1733218222191 +- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + run_exports: {} + size: 21333 + timestamp: 1763918099466 +- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda + sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 + md5: 9614359868482abba1bd15ce465e3c42 + depends: + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 13387 + timestamp: 1760831448842 +- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.3-pyhc364b38_0.conda + sha256: c432626b16768b8dab228bfb706f7060c2d462a21c516d240f68f2f902b5a044 + md5: 936687ed80f295a1f5dbcf8bd34c252c + depends: + - python >=3.9 + - python + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 116363 + timestamp: 1785888127370 +- conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda + sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e + md5: d7585b6550ad04c8c5e21097ada2888e + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 25877 + timestamp: 1764896838868 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + sha256: cf70b2f5ad9ae472b71235e5c8a736c9316df3705746de419b59d442e8348e86 + md5: 16c18772b340887160c79a6acc022db0 + depends: + - python >=3.10 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 893031 + timestamp: 1774796815820 +- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.1.1-pyhc364b38_2.conda + sha256: 430051d80765207a7d782b2b188230ba1489d35c6e75fd9903f76cb9fda4af16 + md5: 64c98a12c4e23eb238bf66bbecafdf3c + depends: + - colorama + - pygments >=2.7.2 + - python >=3.10 + - iniconfig >=1.0.1 + - packaging >=22 + - pluggy >=1.5,<2 + - tomli >=1 + - exceptiongroup >=1 + - python + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + run_exports: {} + size: 306724 + timestamp: 1782127176429 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6989 + timestamp: 1752805904792 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda + sha256: 91cafdb64268e43e0e10d30bd1bef5af392e69f00edd34dfaf909f69ab2da6bd + md5: b5325cf06a000c5b14970462ff5e4d58 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 21561 + timestamp: 1774492402955 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 + md5: c70ad746c22219b9700931707482992c + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 52631 + timestamp: 1783002732887 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + sha256: b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8 + md5: fcb489df604d100968b737f2cb6076c6 + license: LicenseRef-Public-Domain + run_exports: {} + size: 118849 + timestamp: 1784250406640 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda + sha256: 4ed83961876dc8844a6f0df49c07b408efbaea275ffb0b37133e24c006990b3a + md5: 9d9a39212a876e4bb751c1cc3927b678 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 133271 + timestamp: 1785906721507 +- conda: https://conda.anaconda.org/conda-forge/osx-64/dart-sass-1.101.3-h8822bef_0.conda + sha256: e3da7b731922b61f494ba2d74b3aa05587ea234d96758f9e63fa37a384f370ed + md5: 5517d1f3cb90262b5f1b0ea243cc5391 + license: MIT + license_family: MIT + run_exports: {} + size: 3965373 + timestamp: 1784614030450 +- conda: https://conda.anaconda.org/conda-forge/osx-64/hugo-0.164.0-h0938e48_0.conda + sha256: a31720ba10988efa337f4bb6d88f68815a17c7549bb69e3279f13d4a5a9828ec + md5: c7ecd42ed449c36a576ee839d905f2da + depends: + - __osx >=12.3 + - libcxx >=19 + constrains: + - __osx>=10.12 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 19024394 + timestamp: 1783401338521 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + sha256: 57ee997f1f800cf38abc743c0f0a9ddfe6a101c697c35510452ce6f4ddf96361 + md5: 0f600157f28fc7bc9549ecafdfa5bc12 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 566717 + timestamp: 1781672189697 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + sha256: 9c96cc05e056e1bba5b545cbbd57b6e01db622dc2c82934caaaa25cfb22fe666 + md5: dcfdea7b7013beef0a4d744d776ea38f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 76020 + timestamp: 1781204303305 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.7.0-h8c408c4_0.conda + sha256: 525e9b574d6a62b73ccd4cb616495fccd11e80c7e6f4fd7e97a9dd5804bf4c0e + md5: b85d1868b8d9af5306443978da2961d6 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.7.0,<3.8.0a0 + size: 61307 + timestamp: 1783521470318 +- conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_1.conda + sha256: 7915dac7c71c208e40e716e2f6d3eff41a8d5584e0e7c2d46f9bf9bd5f9aa739 + md5: daf49067580fbfeae3ac7f9535400494 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 104919 + timestamp: 1786349094608 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + sha256: 1096c740109386607938ab9f09a7e9bca06d86770a284777586d6c378b8fb3fd + md5: ec88ba8a245855935b871a7324373105 + depends: + - __osx >=10.13 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 79899 + timestamp: 1769482558610 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-h77d7759_0.conda + sha256: 5725d44a17d196adba9798a5fd9f692b7039a827cd6145556a072a80e1931c49 + md5: 993009426e1d3aa90eed155171bd59d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 1008531 + timestamp: 1785016345740 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + sha256: b2dba286dd6632292b12296e761193b5ef9fb0eaeecaa481f5ba9af72c0c18e1 + md5: 7d3fa28263bb7f8ea32db11f570a5bb6 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 58993 + timestamp: 1785276808631 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_1.conda + sha256: 12c1d676b9a0e8109b576672519312c5428308f3f3d7706f8717e2f536d5d9e7 + md5: 88a79390f87c8f3334b9999a892e78d4 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 834865 + timestamp: 1786356957789 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + sha256: d43abd09a455847108fc821e81cf4e36dba31755263a1313b6f1b538ac218998 + md5: da403ed66c373b5fb25266b4be662327 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 2773506 + timestamp: 1785915436200 +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.6-hb933c43_102_cp314.conda + build_number: 102 + sha256: 19e3a84df66b88348387113e2ffa5c5a5d244e15064dc02f805add29815934e2 + md5: 8c2a3f8e3e9aaca5d8336deca3ba483b + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 14503943 + timestamp: 1786445946056 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + sha256: 4614af680aa0920e82b953fece85a03007e0719c3399f13d7de64176874b80d5 + md5: eefd65452dfe7cce476a519bece46704 + depends: + - __osx >=10.13 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 317819 + timestamp: 1765813692798 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hb794df6_3.conda + sha256: 670a364b8285887e5738880bb026f721af2662cfefe9ef64aa9e93eff1981535 + md5: bc699b366e49399bf8e5c6de99bb8cfb + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3516600 + timestamp: 1784229134070 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + sha256: 47101a4055a70a4876ffc87b750ab2287b67eca793f21c8224be5e1ee6394d3f + md5: 727109b184d680772e3122f40136d5ca + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 528148 + timestamp: 1764777156963 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 + md5: b50612e7d190b8061ab4e7dc119cf4d5 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 124965 + timestamp: 1785906749812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/dart-sass-1.101.3-h820172f_0.conda + sha256: fe3c652bd917df379887a6dcd4433e7d59da1f666350ec681f37335ae79c9f89 + md5: 3e77cec4663f3011fc510d44b0be0967 + license: MIT + license_family: MIT + run_exports: {} + size: 3764220 + timestamp: 1784613997813 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/hugo-0.164.0-ha4d9615_0.conda + sha256: aab0f97f766a8d80b5d31ed1c123905874dfae88b8b242980dab277d577a1db9 + md5: c66298dc742f3a1d50767e09d0c3a530 + depends: + - __osx >=12.3 + - libcxx >=19 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 17935717 + timestamp: 1783402039253 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda + sha256: f0b22bc30e4cc29e29ba3234cb38497fe8def2c2aae4b775d42fe5b378a018c9 + md5: 6133ddbb17ba2b50700dd88e9303ce27 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14070698 + timestamp: 1784916459058 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef + md5: 89f76a2a21a3ec3ec983b5eb237c4113 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 569349 + timestamp: 1781670209146 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-hcf2aa1b_0.conda + sha256: 2c6ac9a6cd65af89b2bd448518bb1e13b44a2e48c0d469398e37bcfc0092e832 + md5: 92e8690d170d46d768c32553458c0105 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.7.0,<3.8.0a0 + size: 43734 + timestamp: 1783521647536 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda + sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311 + md5: 8ab10323068b107661a4b9a4af84f3b5 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 91720 + timestamp: 1786348695846 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + sha256: 745662565e103f290e9dc4263bbd88285082f8cf699854fe2d5f1e35a4a0d326 + md5: 0e3477c0c3e718dcf2eb74ccc8f68570 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 929203 + timestamp: 1785016131414 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 + md5: f39288f0ea63ae962e1a2e4f355a0d75 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47822 + timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda + sha256: 7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68 + md5: 3dfa0d0316dc246cd44937a557de4501 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 804298 + timestamp: 1786355189145 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + sha256: 66be2283b5b37dcda1332b5e74c1782a8cb14fd2e62e0d38017c2d35bf73c119 + md5: 65d1906712b85d1679263c518d011b5b + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3109132 + timestamp: 1785913735357 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-hf4d206d_102_cp314.conda + build_number: 102 + sha256: 9767b5eee5cef50716708787bb3b4225d2a974bcd50653e856f9db5910a4b17e + md5: 8da4ea285021110e2617f7538c386afc + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.7.0,<3.8.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.4,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 13960847 + timestamp: 1786444540722 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + sha256: 47186bc7ab8d7e8bee86bbd1a917196f8c21cf63f081fc33cd6d1221af087580 + md5: 8e3cf0e455e6b54519f0b1c72c61780a + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3338712 + timestamp: 1784229090530 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 433413 + timestamp: 1764777166076 diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 00000000..7fc42634 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,14 @@ +[workspace] +name = "scientific-python-hugo-theme" +channels = ["conda-forge"] +platforms = ["linux-64", "osx-64", "osx-arm64"] + +[dependencies] +python = ">=3.11" +pytest = ">=8" +hugo = "==0.164.0" +dart-sass = "==1.101.3" + +[tasks] +test = "python -m pytest" +test-atom = "python -m pytest tests/test_atom.py -v" From 911e068e6fdcb9ed4ebd0bc1a3047bd3b58a106c Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:40:19 +0100 Subject: [PATCH 05/14] feat(atom): add feed author --- layouts/list.atom.xml | 6 ++++++ tests/test_atom.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index 765b0505..6cbcfc6a 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -5,4 +5,10 @@ {{- with .RegularPagesRecursive.ByLastmod.Reverse }} {{ (index . 0).Lastmod.Format "2006-01-02T15:04:05Z07:00" }} {{- end }} + + {{ site.Params.author.name }} + {{- with site.Params.author.email }} + {{ . }} + {{- end }} + \ No newline at end of file diff --git a/tests/test_atom.py b/tests/test_atom.py index 2d0ec0d2..8d42d2c3 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -52,6 +52,9 @@ def atom_feed(built_site: Path) -> ET.Element: return ET.parse(atom_path).getroot() +# RFC 4287: https://www.rfc-editor.org/info/rfc4287/#section-4 + + def test_atom_feed_uses_atom_namespace(atom_feed: ET.Element) -> None: assert atom_feed.tag == f"{ATOM}feed" @@ -79,3 +82,25 @@ def test_atom_feed_updated_uses_latest_entry_update( atom_feed: ET.Element, ) -> None: assert atom_feed.findtext(f"{ATOM}updated") == "2026-01-05T12:00:00Z" + + +def test_atom_feed_contains_author(atom_feed: ET.Element) -> None: + authors = atom_feed.findall(f"{ATOM}author") + + assert len(authors) == 1 + + +def test_atom_feed_author_has_name(atom_feed: ET.Element) -> None: + author = atom_feed.find(f"{ATOM}author") + + assert author is not None + assert author.findtext(f"{ATOM}name") == "Example Team" + + +def test_atom_feed_author_includes_configured_email( + atom_feed: ET.Element, +) -> None: + author = atom_feed.find(f"{ATOM}author") + + assert author is not None + assert author.findtext(f"{ATOM}email") == "team@example.org" From f518602af7b8171cc3337f884f966555402072c8 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:43:23 +0100 Subject: [PATCH 06/14] feat(atom): add feed links --- layouts/list.atom.xml | 8 +++++++- tests/test_atom.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index 6cbcfc6a..6e83b9a0 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -10,5 +10,11 @@ {{- with site.Params.author.email }} {{ . }} {{- end }} - + + {{- with .OutputFormats.Get "atom" }} + + {{- end }} + {{- with .OutputFormats.Get "html" }} + + {{- end }} \ No newline at end of file diff --git a/tests/test_atom.py b/tests/test_atom.py index 8d42d2c3..7c0e5cee 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -104,3 +104,28 @@ def test_atom_feed_author_includes_configured_email( assert author is not None assert author.findtext(f"{ATOM}email") == "team@example.org" + + +def find_link(atom_feed: ET.Element, rel: str) -> ET.Element: + links = [ + link for link in atom_feed.findall(f"{ATOM}link") if link.get("rel") == rel + ] + + assert len(links) == 1 + return links[0] + + +def test_atom_feed_has_self_link(atom_feed: ET.Element) -> None: + link = find_link(atom_feed, "self") + + assert link.get("href") == "https://example.org/atom.xml" + assert link.get("type") == "application/atom+xml" + + +def test_atom_feed_has_alternate_html_link( + atom_feed: ET.Element, +) -> None: + link = find_link(atom_feed, "alternate") + + assert link.get("href") == "https://example.org/" + assert link.get("type") == "text/html" From 983bbc98d8d0b5c3c39d8389b4e92cb1d2da41c7 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 15:53:28 +0100 Subject: [PATCH 07/14] feat(atom): add entries and switch away from plain text --- config.yaml | 1 - layouts/list.atom.xml | 14 +++++++- tests/test_atom.py | 74 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/config.yaml b/config.yaml index da62002d..78765892 100644 --- a/config.yaml +++ b/config.yaml @@ -20,4 +20,3 @@ outputFormats: baseName: atom mediaType: application/atom+xml noUgly: true - isPlainText: true diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index 6e83b9a0..cc20bcda 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -1,4 +1,4 @@ - +{{ printf `` | safeHTML }} {{ .Title }} {{ .Permalink }} @@ -17,4 +17,16 @@ {{- with .OutputFormats.Get "html" }} {{- end }} + + {{- range .RegularPagesRecursive.ByDate.Reverse }} + + {{ .Title }} + {{ .Permalink }} + {{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }} + {{ .Date.Format "2006-01-02T15:04:05Z07:00" }} + {{- with .OutputFormats.Get "html" }} + + {{- end }} + + {{- end }} \ No newline at end of file diff --git a/tests/test_atom.py b/tests/test_atom.py index 7c0e5cee..9ad2292c 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -49,7 +49,22 @@ def atom_feed(built_site: Path) -> ET.Element: assert atom_path.is_file(), "Hugo did not generate atom.xml" - return ET.parse(atom_path).getroot() + source = atom_path.read_text(encoding="utf-8") + + try: + return ET.fromstring(source) + except ET.ParseError as error: + numbered_source = "\n".join( + f"{line_number:4}: {line}" + for line_number, line in enumerate( + source.splitlines(), + start=1, + ) + ) + pytest.fail( + f"Generated Atom feed is not valid XML: {error}\n\n" + f"{numbered_source}" + ) # RFC 4287: https://www.rfc-editor.org/info/rfc4287/#section-4 @@ -129,3 +144,60 @@ def test_atom_feed_has_alternate_html_link( assert link.get("href") == "https://example.org/" assert link.get("type") == "text/html" + + +@pytest.fixture(scope="module") +def atom_entries(atom_feed: ET.Element) -> list[ET.Element]: + return atom_feed.findall(f"{ATOM}entry") + + +def test_atom_feed_contains_entries( + atom_entries: list[ET.Element], +) -> None: + assert len(atom_entries) == 2 + + +def test_atom_entries_are_ordered_by_publication_date( + atom_entries: list[ET.Element], +) -> None: + titles = [entry.findtext(f"{ATOM}title") for entry in atom_entries] + + assert titles == [ + "Newer post", + "Older post with & characters", + ] + + +@pytest.mark.parametrize("element_name", ["title", "id", "updated"]) +def test_atom_entries_contain_required_metadata( + atom_entries: list[ET.Element], + element_name: str, +) -> None: + for entry in atom_entries: + elements = entry.findall(f"{ATOM}{element_name}") + + assert len(elements) == 1 + assert elements[0].text + + +def test_atom_entry_ids_are_canonical_urls( + atom_entries: list[ET.Element], +) -> None: + ids = [entry.findtext(f"{ATOM}id") for entry in atom_entries] + + assert ids == [ + "https://example.org/posts/newer/", + "https://example.org/posts/older/", + ] + + +def test_atom_entry_dates(atom_entries: list[ET.Element]) -> None: + assert [entry.findtext(f"{ATOM}published") for entry in atom_entries] == [ + "2026-01-03T11:00:00Z", + "2026-01-01T10:00:00Z", + ] + + assert [entry.findtext(f"{ATOM}updated") for entry in atom_entries] == [ + "2026-01-03T11:00:00Z", + "2026-01-05T12:00:00Z", + ] From 6fce809f37051bf58aeae61b87b3d9af7aa48161 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 16:21:26 +0100 Subject: [PATCH 08/14] feat(atom): entry content --- layouts/list.atom.xml | 1 + tests/test_atom.py | 59 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index cc20bcda..62294405 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -27,6 +27,7 @@ {{- with .OutputFormats.Get "html" }} {{- end }} + {{ printf "%s" .Content }} {{- end }} \ No newline at end of file diff --git a/tests/test_atom.py b/tests/test_atom.py index 9ad2292c..d0882a98 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -62,8 +62,7 @@ def atom_feed(built_site: Path) -> ET.Element: ) ) pytest.fail( - f"Generated Atom feed is not valid XML: {error}\n\n" - f"{numbered_source}" + f"Generated Atom feed is not valid XML: {error}\n\n{numbered_source}" ) @@ -201,3 +200,59 @@ def test_atom_entry_dates(atom_entries: list[ET.Element]) -> None: "2026-01-03T11:00:00Z", "2026-01-05T12:00:00Z", ] + + +def test_atom_entries_have_alternate_html_links( + atom_entries: list[ET.Element], +) -> None: + expected_urls = [ + "https://example.org/posts/newer/", + "https://example.org/posts/older/", + ] + + for entry, expected_url in zip( + atom_entries, + expected_urls, + strict=True, + ): + links = [ + link + for link in entry.findall(f"{ATOM}link") + if link.get("rel") == "alternate" + ] + + assert len(links) == 1 + assert links[0].get("href") == expected_url + assert links[0].get("type") == "text/html" + + +def test_atom_entries_contain_html_content( + atom_entries: list[ET.Element], +) -> None: + for entry in atom_entries: + contents = entry.findall(f"{ATOM}content") + + assert len(contents) == 1 + assert contents[0].get("type") == "html" + assert contents[0].text + + +def test_atom_entry_content_is_encoded_html( + atom_entries: list[ET.Element], +) -> None: + for entry in atom_entries: + content = entry.find(f"{ATOM}content") + + assert content is not None + assert len(content) == 0 + assert "

" in (content.text or "") + + +def test_atom_entry_content_preserves_html( + atom_entries: list[ET.Element], +) -> None: + content = atom_entries[1].findtext(f"{ATOM}content") + + assert content is not None + assert "

" in content + assert "

" in content From bb6e1de9c7df22428f05993388f2b68a083f14a5 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 16:29:46 +0100 Subject: [PATCH 09/14] feat(atom): entry category --- layouts/list.atom.xml | 3 + tests/fixtures/atom/content/posts/newer.md | 3 +- tests/fixtures/atom/content/posts/older.md | 4 +- tests/test_atom.py | 81 ++++++++++++++++++++++ 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index 62294405..b7965d7f 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -28,6 +28,9 @@ {{- end }} {{ printf "%s" .Content }} + {{- range .GetTerms "tags" }} + + {{- end }} {{- end }} \ No newline at end of file diff --git a/tests/fixtures/atom/content/posts/newer.md b/tests/fixtures/atom/content/posts/newer.md index 73ab65b8..32e73a1e 100644 --- a/tests/fixtures/atom/content/posts/newer.md +++ b/tests/fixtures/atom/content/posts/newer.md @@ -2,7 +2,8 @@ title: "Newer post" date: 2026-01-03T11:00:00Z tags: - - feeds + - Python + - Scientific computing --- This entry was published second. diff --git a/tests/fixtures/atom/content/posts/older.md b/tests/fixtures/atom/content/posts/older.md index bff91ea1..07aa9076 100644 --- a/tests/fixtures/atom/content/posts/older.md +++ b/tests/fixtures/atom/content/posts/older.md @@ -3,8 +3,8 @@ title: "Older post with & characters" date: 2026-01-01T10:00:00Z lastmod: 2026-01-05T12:00:00Z tags: - - XML - - feeds + - Python + - Scientific computing --- This entry was published first but updated most recently. diff --git a/tests/test_atom.py b/tests/test_atom.py index d0882a98..aaeca2e2 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -256,3 +256,84 @@ def test_atom_entry_content_preserves_html( assert content is not None assert "

" in content assert "

" in content + + +def entry_by_title( + atom_entries: list[ET.Element], + title: str, +) -> ET.Element: + matches = [ + entry for entry in atom_entries if entry.findtext(f"{ATOM}title") == title + ] + + assert len(matches) == 1 + return matches[0] + + +def test_atom_entry_categories( + atom_entries: list[ET.Element], +) -> None: + entry = entry_by_title(atom_entries, "Newer post") + + categories = entry.findall(f"{ATOM}category") + + assert [ + ( + category.get("term"), + category.get("scheme"), + category.get("label"), + ) + for category in categories + ] == [ + ( + "Python", + "https://example.org/tags/", + "Python", + ), + ( + "Scientific Computing", + "https://example.org/tags/", + "Scientific Computing", + ), + ] + + +@pytest.fixture(scope="module") +def posts_atom_feed(built_site: Path) -> ET.Element: + atom_path = built_site / "posts" / "atom.xml" + + assert atom_path.is_file(), "Hugo did not generate posts/atom.xml" + + source = atom_path.read_text(encoding="utf-8") + + try: + return ET.fromstring(source) + except ET.ParseError as error: + pytest.fail(f"Generated posts/atom.xml is not valid XML: {error}\n\n{source}") + + +def test_posts_atom_feed_metadata( + posts_atom_feed: ET.Element, +) -> None: + assert posts_atom_feed.tag == f"{ATOM}feed" + assert posts_atom_feed.findtext(f"{ATOM}title") == "Test Posts" + assert posts_atom_feed.findtext(f"{ATOM}id") == "https://example.org/posts/" + + self_link = find_link(posts_atom_feed, "self") + assert self_link.get("href") == "https://example.org/posts/atom.xml" + assert self_link.get("type") == "application/atom+xml" + + alternate_link = find_link(posts_atom_feed, "alternate") + assert alternate_link.get("href") == "https://example.org/posts/" + assert alternate_link.get("type") == "text/html" + + +def test_posts_atom_feed_contains_section_entries( + posts_atom_feed: ET.Element, +) -> None: + entries = posts_atom_feed.findall(f"{ATOM}entry") + + assert [entry.findtext(f"{ATOM}title") for entry in entries] == [ + "Newer post", + "Older post with & characters", + ] From 23967ce791edbc13d3a9af05770a85a779557999 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 16:53:06 +0100 Subject: [PATCH 10/14] feat(atom): implement valid empty feed --- layouts/list.atom.xml | 7 ++-- tests/fixtures/atom/content/empty/_index.md | 5 +++ tests/test_atom.py | 36 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/atom/content/empty/_index.md diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index b7965d7f..d4e052a8 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -2,9 +2,12 @@ {{ .Title }} {{ .Permalink }} - {{- with .RegularPagesRecursive.ByLastmod.Reverse }} - {{ (index . 0).Lastmod.Format "2006-01-02T15:04:05Z07:00" }} + {{- $pages := .RegularPagesRecursive.ByLastmod.Reverse -}} + {{- $updated := .Lastmod -}} + {{- if gt (len $pages) 0 -}} + {{- $updated = (index $pages 0).Lastmod -}} {{- end }} + {{ $updated.Format "2006-01-02T15:04:05Z07:00" }} {{ site.Params.author.name }} {{- with site.Params.author.email }} diff --git a/tests/fixtures/atom/content/empty/_index.md b/tests/fixtures/atom/content/empty/_index.md new file mode 100644 index 00000000..3589ee58 --- /dev/null +++ b/tests/fixtures/atom/content/empty/_index.md @@ -0,0 +1,5 @@ +--- +title: "Empty section" +date: 2026-01-07T09:00:00Z +lastmod: 2026-01-07T09:00:00Z +--- \ No newline at end of file diff --git a/tests/test_atom.py b/tests/test_atom.py index aaeca2e2..6adb7108 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -337,3 +337,39 @@ def test_posts_atom_feed_contains_section_entries( "Newer post", "Older post with & characters", ] + + +@pytest.fixture(scope="module") +def empty_atom_feed(built_site: Path) -> ET.Element: + atom_path = built_site / "empty" / "atom.xml" + + assert atom_path.is_file(), "Hugo did not generate empty/atom.xml" + + source = atom_path.read_text(encoding="utf-8") + + try: + return ET.fromstring(source) + except ET.ParseError as error: + pytest.fail(f"Generated empty/atom.xml is not valid XML: {error}\n\n{source}") + +def test_empty_atom_feed_has_required_metadata( + empty_atom_feed: ET.Element, +) -> None: + assert ( + empty_atom_feed.findtext(f"{ATOM}title") + == "Empty section" + ) + assert ( + empty_atom_feed.findtext(f"{ATOM}id") + == "https://example.org/empty/" + ) + assert ( + empty_atom_feed.findtext(f"{ATOM}updated") + == "2026-01-07T09:00:00Z" + ) + + +def test_empty_atom_feed_contains_no_entries( + empty_atom_feed: ET.Element, +) -> None: + assert empty_atom_feed.findall(f"{ATOM}entry") == [] \ No newline at end of file From 36cf291f23adb670392938caf8131209c996db23 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 16:57:08 +0100 Subject: [PATCH 11/14] feat(atom): implement generator entry --- layouts/list.atom.xml | 3 ++- tests/test_atom.py | 38 +++++++++++++++++++++++++------------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index d4e052a8..0727e340 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -1,5 +1,6 @@ {{ printf `` | safeHTML }} + Hugo {{ .Title }} {{ .Permalink }} {{- $pages := .RegularPagesRecursive.ByLastmod.Reverse -}} @@ -36,4 +37,4 @@ {{- end }} {{- end }} - \ No newline at end of file + diff --git a/tests/test_atom.py b/tests/test_atom.py index 6adb7108..111351e4 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +import re PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -352,24 +353,35 @@ def empty_atom_feed(built_site: Path) -> ET.Element: except ET.ParseError as error: pytest.fail(f"Generated empty/atom.xml is not valid XML: {error}\n\n{source}") + def test_empty_atom_feed_has_required_metadata( empty_atom_feed: ET.Element, ) -> None: - assert ( - empty_atom_feed.findtext(f"{ATOM}title") - == "Empty section" - ) - assert ( - empty_atom_feed.findtext(f"{ATOM}id") - == "https://example.org/empty/" - ) - assert ( - empty_atom_feed.findtext(f"{ATOM}updated") - == "2026-01-07T09:00:00Z" - ) + assert empty_atom_feed.findtext(f"{ATOM}title") == "Empty section" + assert empty_atom_feed.findtext(f"{ATOM}id") == "https://example.org/empty/" + assert empty_atom_feed.findtext(f"{ATOM}updated") == "2026-01-07T09:00:00Z" def test_empty_atom_feed_contains_no_entries( empty_atom_feed: ET.Element, ) -> None: - assert empty_atom_feed.findall(f"{ATOM}entry") == [] \ No newline at end of file + assert empty_atom_feed.findall(f"{ATOM}entry") == [] + + +def test_atom_feed_identifies_hugo_generator( + atom_feed: ET.Element, +) -> None: + generators = atom_feed.findall(f"{ATOM}generator") + + assert len(generators) == 1 + + generator = generators[0] + + assert generator.text == "Hugo" + assert generator.get("uri") == "https://gohugo.io/" + version = generator.get("version") + + assert version is not None + assert re.fullmatch(r"\d+\.\d+\.\d+", version), ( + f"Unexpected Hugo version format: {version!r}" + ) From d6c06f4d13adec0d193693fc4a68cb927318c8e9 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 17:27:09 +0100 Subject: [PATCH 12/14] feat(atom): add RSS limit and relative link for content --- layouts/list.atom.xml | 19 +++++++--- tests/fixtures/atom/config.yaml | 4 ++ .../atom/content/posts/excluded_older.md | 7 ++++ tests/test_atom.py | 38 +++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/atom/content/posts/excluded_older.md diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index 0727e340..865f968c 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -1,12 +1,19 @@ {{ printf `` | safeHTML }} - + Hugo {{ .Title }} + {{- with site.Params.description }} + {{ . }} + {{- end }} {{ .Permalink }} - {{- $pages := .RegularPagesRecursive.ByLastmod.Reverse -}} + {{- $entries := .RegularPagesRecursive.ByDate.Reverse -}} + {{- $limit := site.Config.Services.RSS.Limit -}} + {{- if ge $limit 1 -}} + {{- $entries = first $limit $entries -}} + {{- end -}} {{- $updated := .Lastmod -}} - {{- if gt (len $pages) 0 -}} - {{- $updated = (index $pages 0).Lastmod -}} + {{- with $entries.ByLastmod.Reverse -}} + {{- $updated = (index . 0).Lastmod -}} {{- end }} {{ $updated.Format "2006-01-02T15:04:05Z07:00" }} @@ -22,7 +29,7 @@ {{- end }} - {{- range .RegularPagesRecursive.ByDate.Reverse }} + {{- range $entries }} {{ .Title }} {{ .Permalink }} @@ -31,7 +38,7 @@ {{- with .OutputFormats.Get "html" }} {{- end }} - {{ printf "%s" .Content }} + {{ printf "%s" .Content }} {{- range .GetTerms "tags" }} {{- end }} diff --git a/tests/fixtures/atom/config.yaml b/tests/fixtures/atom/config.yaml index 1360979d..c0962998 100644 --- a/tests/fixtures/atom/config.yaml +++ b/tests/fixtures/atom/config.yaml @@ -16,3 +16,7 @@ outputs: section: - HTML - ATOM + +services: + rss: + limit: 2 diff --git a/tests/fixtures/atom/content/posts/excluded_older.md b/tests/fixtures/atom/content/posts/excluded_older.md new file mode 100644 index 00000000..a4b42a78 --- /dev/null +++ b/tests/fixtures/atom/content/posts/excluded_older.md @@ -0,0 +1,7 @@ +--- +title: "Excluded older post" +date: 2025-12-01T10:00:00Z +lastmod: 2025-12-01T10:00:00Z +--- + +This post should not appear in the limited feed. diff --git a/tests/test_atom.py b/tests/test_atom.py index 111351e4..80ff50cf 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -15,6 +15,9 @@ ATOM_NAMESPACE = "http://www.w3.org/2005/Atom" ATOM = f"{{{ATOM_NAMESPACE}}}" +XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" +XML_LANG = f"{{{XML_NAMESPACE}}}lang" + @pytest.fixture(scope="module") def built_site(tmp_path_factory: pytest.TempPathFactory) -> Path: @@ -385,3 +388,38 @@ def test_atom_feed_identifies_hugo_generator( assert re.fullmatch(r"\d+\.\d+\.\d+", version), ( f"Unexpected Hugo version format: {version!r}" ) + + +def test_atom_feed_declares_language(atom_feed: ET.Element) -> None: + # locale differs depending on the environment, so just check that it exists + assert atom_feed.get(XML_LANG) + + +def test_atom_feed_contains_description( + atom_feed: ET.Element, +) -> None: + assert atom_feed.findtext(f"{ATOM}subtitle") == "A test feed" + + +def test_atom_entry_content_uses_entry_as_base_url( + atom_entries: list[ET.Element], +) -> None: + for entry in atom_entries: + content = entry.find(f"{ATOM}content") + + assert content is not None + assert content.get( + "{http://www.w3.org/XML/1998/namespace}base" + ) == entry.findtext(f"{ATOM}id") + + +def test_atom_feed_respects_configured_item_limit( + built_site: Path, + atom_entries: list[ET.Element], +) -> None: + assert (built_site / "posts" / "excluded_older" / "index.html").is_file() + + assert [entry.findtext(f"{ATOM}title") for entry in atom_entries] == [ + "Newer post", + "Older post with & characters", + ] From 5d02ae12a37e7b03767a2d95a4c65c45224c230c Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 17:37:48 +0100 Subject: [PATCH 13/14] feat(atom): add discovery in generated pages --- layouts/_default/baseof.html | 3 +++ tests/test_atom.py | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 074fefcc..d248ae09 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -6,6 +6,9 @@ {{ with .Params.canonicalURL -}} {{- end }} + {{- range .AlternativeOutputFormats }} + + {{- end }} {{ block "title" . }}{{ .Site.Title }}{{ if not (eq .Site.Title .Title) }} - {{ .Title }}{{end}}{{ end }} diff --git a/tests/test_atom.py b/tests/test_atom.py index 80ff50cf..f330dbd7 100644 --- a/tests/test_atom.py +++ b/tests/test_atom.py @@ -6,6 +6,7 @@ import pytest import re +from html.parser import HTMLParser PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -19,6 +20,26 @@ XML_LANG = f"{{{XML_NAMESPACE}}}lang" +class LinkParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: list[dict[str, str | None]] = [] + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + if tag == "link": + self.links.append(dict(attrs)) + + +def parse_html_links(path: Path) -> list[dict[str, str | None]]: + parser = LinkParser() + parser.feed(path.read_text(encoding="utf-8")) + return parser.links + + @pytest.fixture(scope="module") def built_site(tmp_path_factory: pytest.TempPathFactory) -> Path: output_dir = tmp_path_factory.mktemp("atom-site") / "public" @@ -423,3 +444,34 @@ def test_atom_feed_respects_configured_item_limit( "Newer post", "Older post with & characters", ] + + +@pytest.mark.parametrize( + ("html_path", "expected_feed_url"), + [ + ("index.html", "https://example.org/atom.xml"), + ( + "posts/index.html", + "https://example.org/posts/atom.xml", + ), + ( + "empty/index.html", + "https://example.org/empty/atom.xml", + ), + ], +) +def test_html_pages_advertise_atom_feed( + built_site: Path, + html_path: str, + expected_feed_url: str, +) -> None: + links = parse_html_links(built_site / html_path) + + atom_links = [ + link + for link in links + if link.get("rel") == "alternate" and link.get("type") == "application/atom+xml" + ] + + assert len(atom_links) == 1 + assert atom_links[0].get("href") == expected_feed_url From aa2dd47d68cd4ceca32a8a2371797ccd002b86e6 Mon Sep 17 00:00:00 2001 From: kreczko Date: Tue, 11 Aug 2026 17:56:53 +0100 Subject: [PATCH 14/14] feat(atom): add default if site.params.author is missing --- layouts/list.atom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/layouts/list.atom.xml b/layouts/list.atom.xml index 865f968c..4769affa 100644 --- a/layouts/list.atom.xml +++ b/layouts/list.atom.xml @@ -17,7 +17,7 @@ {{- end }} {{ $updated.Format "2006-01-02T15:04:05Z07:00" }} - {{ site.Params.author.name }} + {{ site.Params.author.name | default site.Title }} {{- with site.Params.author.email }} {{ . }} {{- end }}