# Copyright (c) 2025-2026 Zensical and contributors # SPDX-License-Identifier: MIT # All contributions are certified under the DCO """Integration tests for native Material blog compatibility.""" from __future__ import annotations import json import subprocess import sys import time from typing import TYPE_CHECKING, Any import pytest import zensical if TYPE_CHECKING: from collections.abc import Callable from pathlib import Path _BUILD_OPTIONS: dict[str, Any] = {"clean": False, "strict": False} def _project( root: Path, *, per_page: int = 10, archive: bool = False, categories: bool = False, authors: bool = False, author_profiles: bool = False, entrypoint: bool = True, ) -> Path: docs = root / "docs" posts = docs / "blog" / "posts" overrides = root / "overrides" posts.mkdir(parents=True) overrides.mkdir() (docs / "index.md").write_text("# Home\n", encoding="utf-8") if entrypoint: (docs / "blog" / "index.md").write_text("# Journal\n", encoding="utf-8") (overrides / "main.html").write_text( "{{ page.title }}|{{ page.url }}|{{ page.content }}", encoding="utf-8", ) (overrides / "blog.html").write_text( "BLOG|{{ page.title }}|{{ page.url }}|{{ page.content }}|" "{% for post in posts %}{{ post.title }}:{{ post.content }}" "{% for category in post.categories %}#{{ category.title }}@" "{{ category.url }}{% endfor %}" "{% for author in post.authors %}@{{ author.name }}:" "{{ author.avatar }}:{{ author.url }}{% endfor %};{% endfor %}|" "{% if pagination %}{{ pagination.page }}/{{ pagination.pages }}:" "NEXT={{ pagination.next.url if pagination.next else '' }}" "{% endif %}|SELF={{ page.url | url }}|NAV|" "{% for item in nav.items %}{{ item.title }}(" "{% for child in item.children %}{{ child.title }}[" "{% for leaf in child.children %}{{ leaf.title }}=" "{{ leaf.active }},{% endfor %}];" "{% endfor %});{% endfor %}|TOC|" "{% for section in page.toc %}{{ section.title }}[" "{% for child in section.children %}{{ child.title }}=" "{{ child.url }}(" "{% for leaf in child.children %}{{ leaf.title }}={{ leaf.url }}," "{% endfor %});{% endfor %}];{% endfor %}", encoding="utf-8", ) (overrides / "blog-post.html").write_text( "POST|{{ page.title }}|{{ page.url }}|" "{{ page.config.date.created | date }}|{{ page.parent.url }}|" "{{ page.content }}|PREV={{ page.previous_page.url }}|" "NEXT={{ page.next_page.url }}|READ={{ page.config.readtime }}|" "{% for author in page.authors %}AUTHOR={{ author.name }}:" "{{ author.description }}:{{ author.avatar }}:{{ author.url }}" "{% endfor %}|LINKS=" "{% for link in page.config.links %}{{ link.title }}={{ link.url }}=" "{{ link.meta.subtitle if link.meta else '' }}[" "{% for child in link.children %}{{ child.title }}={{ child.url }};" "{% endfor %}];{% endfor %}", encoding="utf-8", ) config = root / "mkdocs.yml" config.write_text( f"""\ site_name: Test theme: name: material custom_dir: overrides plugins: - material/blog: archive: {str(archive).lower()} categories: {str(categories).lower()} authors: {str(authors).lower()} authors_profiles: {str(author_profiles).lower()} pagination_per_page: {per_page} """, encoding="utf-8", ) return config def _post( root: Path, name: str, title: str, date: str, *, body: str = "Body.", **meta: object, ) -> None: lines = ["---", f"date: {date}", f"title: {title}"] lines.extend(f"{key}: {json.dumps(value)}" for key, value in meta.items()) lines.extend(["---", f"# {title}", "", body]) (root / "docs" / "blog" / "posts" / name).write_text( "\n".join(lines) + "\n", encoding="utf-8", ) def test_posts_are_routed_from_dates_and_native_unicode_slugs( tmp_path: Path, ) -> None: config = _project(tmp_path) _post(tmp_path, "hello.md", "Héllo, World!", "2026-09-03") _post(tmp_path, "draft.md", "Draft", "2026-09-04", draft=True) zensical.build(str(config), _BUILD_OPTIONS) output = tmp_path / "site" / "blog" / "2026" / "09" / "03" assert output.joinpath("héllo-world", "index.html").is_file() assert "|READ=1" in output.joinpath("héllo-world", "index.html").read_text( "utf-8" ) assert not output.parent.joinpath("04", "draft", "index.html").exists() assert not (tmp_path / "site" / "blog" / "posts" / "hello").exists() @pytest.mark.parametrize( ("source_name", "link_name"), [ ("first.md", "first.md"), ("café.md", "café.md"), ("café.md", "caf%C3%A9.md"), ], ) def test_source_links_follow_published_post_routes( tmp_path: Path, source_name: str, link_name: str ) -> None: config = _project(tmp_path) (tmp_path / "docs" / "index.md").write_text( f"[First](blog/posts/{link_name}#details)\n", encoding="utf-8" ) _post( tmp_path, source_name, "First post", "2024-09-01", body="## Details\n\nBody.", ) _post( tmp_path, "second.md", "Second post", "2024-09-02", body=f"[First]({link_name}#details)", ) zensical.build(str(config), {"clean": False, "strict": True}) site = tmp_path / "site" home = (site / "index.html").read_text("utf-8") second = site.joinpath( "blog", "2024", "09", "02", "second-post", "index.html" ).read_text("utf-8") assert 'href="blog/2024/09/01/first-post/#details"' in home assert 'href="../../01/first-post/#details"' in second assert "blog/posts/first/" not in home + second _post( tmp_path, source_name, "Renamed post", "2024-08-31", body="## Details\n\nBody.", ) zensical.build(str(config), {"clean": False, "strict": True}) home = (site / "index.html").read_text("utf-8") second = site.joinpath( "blog", "2024", "09", "02", "second-post", "index.html" ).read_text("utf-8") assert 'href="blog/2024/08/31/renamed-post/#details"' in home assert 'href="../../../08/31/renamed-post/#details"' in second assert "first-post/" not in home + second def test_post_anchor_aliases_survive_routing(tmp_path: Path) -> None: """An empty Markdown link still aliases the next heading after routing.""" config = _project(tmp_path) config.write_text( config.read_text(encoding="utf-8") + " - autorefs\nmarkdown_extensions:\n - attr_list\n", encoding="utf-8", ) _post( tmp_path, "post.md", "Post", "2026-09-03", body="[](){#post-alias}\n\n## Details\n\nContent.", ) (tmp_path / "docs" / "index.md").write_text( "# Home\n\n[Details][post-alias]\n", encoding="utf-8" ) zensical.build(str(config), _BUILD_OPTIONS) home = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") assert 'href="blog/2026/09/03/post/#details"' in home assert "zensical:autoref" not in home def test_explicit_post_metadata_controls_route_order_and_readtime( tmp_path: Path, ) -> None: config = _project(tmp_path) _post(tmp_path, "newer.md", "Newer", "2026-09-03") _post( tmp_path, "pinned.md", "Pinned", "2026-09-01", slug="explicit-route", pin=True, readtime=17, ) zensical.build(str(config), _BUILD_OPTIONS) post = ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "explicit-route" / "index.html" ).read_text("utf-8") assert "|READ=17|" in post view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert view.index("Pinned:") < view.index("Newer:") def test_paginated_blog_pages_are_generated_without_source_files( tmp_path: Path, ) -> None: config = _project(tmp_path, per_page=1) _post(tmp_path, "one.md", "One", "2026-09-01") _post(tmp_path, "two.md", "Two", "2026-09-02") zensical.build(str(config), _BUILD_OPTIONS) page = tmp_path / "site" / "blog" / "page" / "2" / "index.html" assert page.is_file() second = page.read_text(encoding="utf-8") assert "BLOG|Journal|blog/page/2/" in second assert "One:" in second assert "|2/2" in second assert "|SELF=../../|" in second first = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "|1/2:NEXT=blog/page/2/|" in first assert not (tmp_path / "docs" / "blog" / "page").exists() one = ( (tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one") .joinpath("index.html") .read_text("utf-8") ) two = ( (tmp_path / "site" / "blog" / "2026" / "09" / "02" / "two") .joinpath("index.html") .read_text("utf-8") ) assert "|PREV=|NEXT=blog/2026/09/02/two/" in one assert "|PREV=blog/2026/09/01/one/|NEXT=" in two def test_blog_entrypoint_template_applies_to_paginated_pages( tmp_path: Path, ) -> None: config = _project(tmp_path, per_page=1, archive=True) (tmp_path / "docs/blog/index.md").write_text( "---\ntitle: Journal\ntemplate: landing.html\n---\n# Journal\n", encoding="utf-8", ) (tmp_path / "overrides/landing.html").write_text( "LANDING|{{ page.title }}|{{ page.url }}", encoding="utf-8", ) _post(tmp_path, "one.md", "One", "2026-09-01") _post(tmp_path, "two.md", "Two", "2026-09-02") zensical.build(str(config), _BUILD_OPTIONS) site = tmp_path / "site/blog" assert "LANDING|Journal|blog/" in (site / "index.html").read_text("utf-8") assert "LANDING|Journal|blog/page/2/" in ( site / "page/2/index.html" ).read_text("utf-8") assert "BLOG|2026|" in (site / "archive/2026/index.html").read_text("utf-8") def test_single_page_keeps_empty_pagination_context(tmp_path: Path) -> None: config = _project(tmp_path) _post(tmp_path, "one.md", "One", "2026-09-01") zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "|1/1:NEXT=|" in page def test_serve_reconciles_routes_views_and_pagination(tmp_path: Path) -> None: """Retained blog revisions retract every superseded output.""" config = _project(tmp_path, per_page=1, archive=True, categories=True) (tmp_path / "docs" / "index.md").write_text( "[One](blog/posts/one.md)\n", encoding="utf-8" ) with config.open("a", encoding="utf-8") as stream: stream.write("dev_addr: 127.0.0.1:0\n") _post( tmp_path, "one.md", "One", "2026-09-01", categories=["Alpha"], ) _post( tmp_path, "two.md", "Two", "2026-09-02", categories=["Alpha"], body="[One](one.md)", ) log = (tmp_path / "serve.log").open("w+", encoding="utf-8") process = subprocess.Popen( # noqa: S603 [ sys.executable, "-m", "zensical", "serve", "--config-file", str(config), ], cwd=tmp_path, stdout=log, stderr=subprocess.STDOUT, ) site = tmp_path / "site" / "blog" home = tmp_path / "site" / "index.html" index = site / "index.html" second = site / "page" / "2" / "index.html" one = site / "2026" / "09" / "01" / "one" / "index.html" two = site / "2026" / "09" / "02" / "two" / "index.html" moved = site / "2026" / "09" / "03" / "moved" / "index.html" alpha = site / "category" / "alpha" / "index.html" beta = site / "category" / "beta" / "index.html" def contains(path: Path, value: str) -> bool: try: return value in path.read_text(encoding="utf-8") except OSError: return False def wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> None: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if condition(): return if process.poll() is not None: log.flush() log.seek(0) raise AssertionError( f"serve exited with status {process.returncode}: " f"{log.read()}" ) time.sleep(0.02) log.flush() log.seek(0) raise AssertionError( f"serve did not reconcile blog state: {log.read()}" ) try: wait_for( lambda: ( two.is_file() and one.is_file() and contains(index, "Two:") and contains(second, "One:") and contains(home, 'href="blog/2026/09/01/one/"') and contains(two, 'href="../../01/one/"') ) ) _post( tmp_path, "one.md", "Moved", "2026-09-03", categories=["Beta"], ) wait_for( lambda: ( moved.is_file() and not one.exists() and beta.is_file() and contains(index, "Moved:") and contains(second, "Two:") and contains(home, 'href="blog/2026/09/03/moved/"') and contains(two, 'href="../../03/moved/"') ) ) (tmp_path / "docs" / "blog" / "posts" / "two.md").unlink() wait_for( lambda: ( not two.exists() and not second.exists() and not alpha.exists() and contains(index, "Moved:") ) ) assert process.poll() is None finally: if process.poll() is None: process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=5) log.close() def test_pagination_format_exposes_ordered_native_items(tmp_path: Path) -> None: config = _project(tmp_path, per_page=2) with config.open("a", encoding="utf-8") as stream: stream.write( " pagination_format: >-\n" " $link_first|$link_previous|${page}/$page_count|" "$first_item-$last_item/$item_count|$link_next|$link_last|" "$$|$unknown\n" ) (tmp_path / "overrides" / "blog.html").write_text( "{% for item in pagination.items %}" "[{{ item.type }}:{{ item.value }}:" "{{ item.page if item.page else '' }}:" "{{ item.url if item.url else '' }}]" "{% endfor %}", encoding="utf-8", ) _post(tmp_path, "one.md", "One", "2026-09-01") _post(tmp_path, "two.md", "Two", "2026-09-02") _post(tmp_path, "three.md", "Three", "2026-09-03") zensical.build(str(config), _BUILD_OPTIONS) first = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "[text:||1/2|1-2/3|::]" in first assert "[next_page:2:2:blog/page/2/]" in first assert "[last_page:2:2:blog/page/2/]" in first assert "[text:|$|$unknown::]" in first second = ( tmp_path / "site" / "blog" / "page" / "2" / "index.html" ).read_text("utf-8") assert "[first_page:1:1:blog/]" in second assert "[previous_page:1:1:blog/]" in second assert "[text:|2/2|3-3/3|||$|$unknown::]" in second def test_empty_blog_has_no_reachable_pagination_pages(tmp_path: Path) -> None: config = _project(tmp_path) with config.open("a", encoding="utf-8") as stream: stream.write(" pagination_if_single_page: true\n") (tmp_path / "overrides" / "blog.html").write_text( "{{ pagination.page }}/{{ pagination.pages }}|" "{{ pagination.items | length }}|" "{{ pagination.first_page if pagination.first_page else 'none' }}|" "{{ pagination.first_item if pagination.first_item else 'none' }}", encoding="utf-8", ) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert page == "1/0|0|none|none" def test_empty_blog_omits_unreachable_archive_navigation( tmp_path: Path, ) -> None: config = _project(tmp_path, archive=True) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "Archive[" not in page assert not (tmp_path / "site" / "blog" / "archive").exists() def test_missing_blog_entrypoint_is_generated_without_mutating_docs( tmp_path: Path, ) -> None: config = _project(tmp_path, entrypoint=False) zensical.build(str(config), _BUILD_OPTIONS) page = tmp_path / "site" / "blog" / "index.html" assert page.is_file() assert "BLOG|Blog|blog/" in page.read_text("utf-8") assert "Blog(" in page.read_text("utf-8") assert not (tmp_path / "docs" / "blog" / "index.md").exists() def test_archive_and_category_views_share_native_pagination_pipeline( tmp_path: Path, ) -> None: config = _project( tmp_path, per_page=1, archive=True, categories=True, ) _post( tmp_path, "one.md", "One", "2026-09-01", categories=["Rust"], ) _post( tmp_path, "two.md", "Two", "2026-09-02", categories=["Rust"], ) zensical.build(str(config), _BUILD_OPTIONS) archive = tmp_path / "site" / "blog" / "archive" / "2026" category = tmp_path / "site" / "blog" / "category" / "rust" assert "Two:" in archive.joinpath("index.html").read_text("utf-8") assert "One:" in archive.joinpath("page", "2", "index.html").read_text( "utf-8" ) assert "Two:" in category.joinpath("index.html").read_text("utf-8") assert "One:" in category.joinpath("page", "2", "index.html").read_text( "utf-8" ) navigation = archive.joinpath("index.html").read_text("utf-8") assert "Archive[2026=" in navigation assert "Categories[Rust=" in navigation assert "#Rust@blog/category/rust/" in navigation assert "Posts" not in navigation paginated = category.joinpath("page", "2", "index.html").read_text("utf-8") assert "Categories[Rust=true,]" in paginated assert not (tmp_path / "docs" / "blog" / "archive").exists() assert not (tmp_path / "docs" / "blog" / "category").exists() def test_navigation_labels_use_theme_language_partial(tmp_path: Path) -> None: config = _project( tmp_path, archive=True, categories=True, authors=True, author_profiles=True, ) text = config.read_text(encoding="utf-8") config.write_text( text.replace( " custom_dir: overrides\n", " custom_dir: overrides\n language: de\n", ), encoding="utf-8", ) (tmp_path / "docs" / "blog" / ".authors.yml").write_text( "authors:\n" " jane:\n" " name: Jane Doe\n" " description: Technical writer\n" " avatar: assets/jane.png\n", encoding="utf-8", ) _post( tmp_path, "one.md", "One", "2026-09-01", categories=["Rust"], authors=["jane"], ) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "Archiv[2026=" in page assert "Kategorien[Rust=" in page assert "Autoren[Jane Doe=" in page def test_navigation_labels_keep_literal_configuration(tmp_path: Path) -> None: config = _project(tmp_path, archive=True) with config.open("a", encoding="utf-8") as stream: stream.write(" archive_name: History\n") _post(tmp_path, "one.md", "One", "2026-09-01") zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "History[2026=" in page def test_archive_navigation_follows_post_order_for_nonnumeric_urls( tmp_path: Path, ) -> None: config = _project(tmp_path, archive=True) with config.open("a", encoding="utf-8") as stream: stream.write( ' archive_date_format: "MMMM yyyy"\n' " archive_url_date_format: MMMM\n" ) _post(tmp_path, "november.md", "November", "2026-11-01") _post(tmp_path, "december.md", "December", "2026-12-01") zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert page.index("December 2026=") < page.index("November 2026=") def test_generated_view_sections_follow_material_sibling_order( tmp_path: Path, ) -> None: config = _project( tmp_path, archive=True, categories=True, authors=True, author_profiles=True, ) (tmp_path / "docs" / "guide.md").write_text("# Guide\n", encoding="utf-8") (tmp_path / "docs" / "blog" / ".authors.yml").write_text( "authors:\n" " jane:\n" " name: Jane Doe\n" " description: Technical writer\n" " avatar: assets/jane.png\n", encoding="utf-8", ) with config.open("a", encoding="utf-8") as stream: stream.write( "nav:\n" " - Home: index.md\n" " - Blog:\n" " - blog/index.md\n" " - Guide: guide.md\n" ) (tmp_path / "overrides" / "main.html").write_text( "{{ page.url }}|" "PREV={{ page.previous_page.url if page.previous_page else '' }}|" "NEXT={{ page.next_page.url if page.next_page else '' }}", encoding="utf-8", ) _post( tmp_path, "one.md", "One", "2026-09-01", categories=["Rust"], authors=["jane"], ) zensical.build(str(config), _BUILD_OPTIONS) guide = (tmp_path / "site" / "guide" / "index.html").read_text("utf-8") assert guide == "guide/|PREV=blog/archive/2026/|NEXT=" def test_archive_url_keys_can_group_multiple_display_dates( tmp_path: Path, ) -> None: config = _project(tmp_path, archive=True) with config.open("a", encoding="utf-8") as stream: stream.write( " pagination: false\n" " archive_url_date_format: \"'all'\"\n" ) _post(tmp_path, "older.md", "Older", "2025-01-01") _post(tmp_path, "newer.md", "Newer", "2026-01-01") zensical.build(str(config), _BUILD_OPTIONS) archive = tmp_path / "site" / "blog" / "archive" / "all" / "index.html" page = archive.read_text("utf-8") assert "BLOG|2026|" in page assert "Newer:" in page assert "Older:" in page assert not (tmp_path / "site" / "blog" / "archive" / "2025").exists() def test_authored_category_page_becomes_the_view_source(tmp_path: Path) -> None: config = _project(tmp_path, per_page=1, categories=True) category = tmp_path / "docs" / "blog" / "category" category.mkdir() category.joinpath("rust.md").write_text( "---\ntitle: Custom Rust\n---\n# Authored category\n", encoding="utf-8", ) _post( tmp_path, "one.md", "One", "2026-09-01", categories=["Rust"], ) _post( tmp_path, "two.md", "Two", "2026-09-02", categories=["Rust"], ) zensical.build(str(config), _BUILD_OPTIONS) first = tmp_path / "site" / "blog" / "category" / "rust" first_html = first.joinpath("index.html").read_text("utf-8") second_html = first.joinpath("page", "2", "index.html").read_text("utf-8") assert "BLOG|Custom Rust|" in first_html assert "Authored category" in first_html assert "BLOG|Custom Rust|" in second_html assert first_html.count("Categories[") == 1 def test_excerpt_links_are_rebased_for_each_containing_view( tmp_path: Path, ) -> None: config = _project(tmp_path, per_page=1) (tmp_path / "docs" / "notes.md").write_text("# Notes\n", encoding="utf-8") _post( tmp_path, "one.md", "One", "2026-09-01", body="[Notes](../../notes.md)\n\n\n\nRemainder.", ) _post(tmp_path, "two.md", "Two", "2026-09-02") zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "page" / "2" / "index.html").read_text( "utf-8" ) assert 'href="../../../notes/"' in page post = ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one" / "index.html" ).read_text("utf-8") assert 'href="../../../../../notes/"' in post def test_required_excerpt_separator_is_validated(tmp_path: Path) -> None: config = _project(tmp_path) with config.open("a", encoding="utf-8") as stream: stream.write(" post_excerpt: required\n") _post(tmp_path, "one.md", "One", "2026-09-01") with pytest.raises(RuntimeError) as error: zensical.build(str(config), _BUILD_OPTIONS) assert "requires the excerpt separator ''" in str(error.value) def test_url_formats_do_not_require_a_slug_placeholder(tmp_path: Path) -> None: config = _project(tmp_path) with config.open("a", encoding="utf-8") as stream: stream.write(' post_url_format: "{date}/{file}"\n') _post(tmp_path, "source-name.md", "Different title", "2026-09-01") zensical.build(str(config), _BUILD_OPTIONS) assert ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "source-name" / "index.html" ).is_file() def test_disabling_pagination_keeps_all_posts_on_one_view( tmp_path: Path, ) -> None: config = _project(tmp_path, per_page=1) with config.open("a", encoding="utf-8") as stream: stream.write(" pagination: false\n") _post(tmp_path, "one.md", "One", "2026-09-01") _post(tmp_path, "two.md", "Two", "2026-09-02") zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "One:" in page assert "Two:" in page assert not (tmp_path / "site" / "blog" / "page").exists() def test_multiple_blog_instances_keep_routes_and_views_isolated( tmp_path: Path, ) -> None: config = _project(tmp_path) for directory, title in [("journal", "Journal"), ("news", "News")]: root = tmp_path / "docs" / directory root.joinpath("posts").mkdir(parents=True) root.joinpath("index.md").write_text(f"# {title}\n", encoding="utf-8") root.joinpath("posts", "entry.md").write_text( f"---\ndate: 2026-09-01\n---\n# {title} entry\n", encoding="utf-8", ) config.write_text( """\ site_name: Test theme: name: material custom_dir: overrides plugins: - material/blog: blog_dir: journal post_dir: journal/posts archive: false categories: false authors: false - material/blog: blog_dir: news post_dir: news/posts archive: false categories: false authors: false """, encoding="utf-8", ) zensical.build(str(config), _BUILD_OPTIONS) assert ( tmp_path / "site" / "journal" / "2026" / "09" / "01" / "journal-entry" / "index.html" ).is_file() assert ( tmp_path / "site" / "news" / "2026" / "09" / "01" / "news-entry" / "index.html" ).is_file() def test_categories_can_sort_by_post_count(tmp_path: Path) -> None: config = _project(tmp_path, categories=True) with config.open("a", encoding="utf-8") as stream: stream.write( " categories_sort_by:\n" " object: material.plugins.blog.view_post_count\n" " categories_sort_reverse: true\n" ) _post( tmp_path, "one.md", "One", "2026-09-01", categories=["Alpha", "Beta"], ) _post( tmp_path, "two.md", "Two", "2026-09-02", categories=["Beta"], ) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert page.index("Categories[Beta=") < page.index("Alpha=") def test_authors_are_resolved_and_profiles_use_native_routes( tmp_path: Path, ) -> None: config = _project(tmp_path, authors=True, author_profiles=True) (tmp_path / "docs" / "blog" / ".authors.yml").write_text( """\ authors: jane: name: Jane Doe description: Technical writer avatar: assets/jane.png slug: jane-doe """, encoding="utf-8", ) _post( tmp_path, "one.md", "One", "2026-09-01", authors=["jane"], ) zensical.build(str(config), _BUILD_OPTIONS) post = ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one" / "index.html" ).read_text("utf-8") assert ( "AUTHOR=Jane Doe:Technical writer:assets/jane.png:blog/author/jane-doe/" ) in post profile = tmp_path / "site" / "blog" / "author" / "jane-doe" / "index.html" assert profile.is_file() page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "@Jane Doe:assets/jane.png:blog/author/jane-doe/" in page assert "Authors[Jane Doe=" in page assert not (tmp_path / "site" / "blog" / ".authors.yml").exists() def test_author_profiles_follow_first_appearance_in_post_order( tmp_path: Path, ) -> None: config = _project(tmp_path, authors=True, author_profiles=True) (tmp_path / "docs" / "blog" / ".authors.yml").write_text( """\ authors: alpha: name: Alpha Author description: Alpha avatar: alpha.png zeta: name: Zeta Author description: Zeta avatar: zeta.png """, encoding="utf-8", ) _post( tmp_path, "one.md", "One", "2026-09-01", authors=["zeta", "alpha"], ) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert page.index("Zeta Author=") < page.index("Alpha Author=") def test_unknown_post_author_is_rejected(tmp_path: Path) -> None: config = _project(tmp_path, authors=True) _post( tmp_path, "one.md", "One", "2026-09-01", authors=["missing"], ) with pytest.raises(RuntimeError, match="couldn't find author 'missing'"): zensical.build(str(config), _BUILD_OPTIONS) def test_custom_author_catalog_is_consumed_without_being_published( tmp_path: Path, ) -> None: config = _project(tmp_path, authors=True, author_profiles=True) with config.open("a", encoding="utf-8") as stream: stream.write(" authors_file: '{blog}/people.yml'\n") (tmp_path / "docs" / "blog" / "people.yml").write_text( """\ authors: jane: name: Jane Doe description: Technical writer avatar: jane.png slug: jane-doe """, encoding="utf-8", ) _post( tmp_path, "one.md", "One", "2026-09-01", authors=["jane"], ) zensical.build(str(config), _BUILD_OPTIONS) profile = tmp_path / "site" / "blog" / "author" / "jane-doe" / "index.html" assert profile.is_file() assert not (tmp_path / "site" / "blog" / "people.yml").exists() def test_post_assets_are_relocated_to_the_public_blog_tree( tmp_path: Path, ) -> None: config = _project(tmp_path) assets = tmp_path / "docs" / "blog" / "posts" / "assets" assets.mkdir() assets.joinpath("image.png").write_bytes(b"image") _post( tmp_path, "one.md", "One", "2026-09-01", body="![Image](assets/image.png)", ) zensical.build(str(config), _BUILD_OPTIONS) assert (tmp_path / "site" / "blog" / "assets" / "image.png").is_file() assert not ( tmp_path / "site" / "blog" / "posts" / "assets" / "image.png" ).exists() post = ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one" / "index.html" ).read_text("utf-8") assert 'src="../../../../assets/image.png"' in post view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert 'src="assets/image.png"' in view assert "posts/assets/image.png" not in post assert "posts/assets/image.png" not in view def test_nested_post_asset_links_preserve_suffixes_and_url_boundaries( tmp_path: Path, ) -> None: config = _project(tmp_path) posts = tmp_path / "docs" / "blog" / "posts" media = posts / "nested" / "media" media.mkdir(parents=True) media.joinpath("image.svg").write_text("", encoding="utf-8") media.joinpath("reference.txt").write_text("reference", encoding="utf-8") _post( tmp_path, "nested/one.md", "One", "2026-09-01", body=( "[Markdown](media/reference.txt?download=1#part)\n\n" "![Image](media/image.svg?version=2#icon)\n\n" 'Raw\n' 'Raw image\n\n' "[Root](/shared.txt) [External](https://example.org/file)" ), ) zensical.build(str(config), _BUILD_OPTIONS) output = tmp_path / "site" / "blog" / "nested" / "media" assert output.joinpath("image.svg").is_file() post = ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one" / "index.html" ).read_text("utf-8") assert "../../../../nested/media/reference.txt?download=1#part" in post assert "../../../../nested/media/image.svg?version=2#icon" in post assert "../../../../nested/media/reference.txt?raw=1#part" in post assert "../../../../nested/media/image.svg?raw=1#icon" in post assert 'href="/shared.txt"' in post assert 'href="https://example.org/file"' in post view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "nested/media/reference.txt?download=1#part" in view assert "nested/media/reference.txt?raw=1#part" in view @pytest.mark.parametrize( ("date", "categories", "message"), [ ("not-a-date", None, "invalid date"), ("2026-09-01", ["Forbidden"], "outside categories_allowed"), ], ) def test_invalid_post_metadata_is_rejected( tmp_path: Path, date: str, categories: list[str] | None, message: str, ) -> None: config = _project(tmp_path, categories=categories is not None) if categories is not None: with config.open("a", encoding="utf-8") as stream: stream.write(" categories_allowed: [Allowed]\n") if categories is None: _post(tmp_path, "one.md", "One", date) else: _post( tmp_path, "one.md", "One", date, categories=categories, ) with pytest.raises(RuntimeError, match=message): zensical.build(str(config), _BUILD_OPTIONS) def test_date_display_formats_are_independent_from_archive_routes( tmp_path: Path, ) -> None: config = _project(tmp_path, archive=True) with config.open("a", encoding="utf-8") as stream: stream.write( " post_date_format: medium\n" ' archive_date_format: "MMMM yyyy"\n' ) _post(tmp_path, "one.md", "One", "2026-09-03") zensical.build(str(config), _BUILD_OPTIONS) post = ( tmp_path / "site" / "blog" / "2026" / "09" / "03" / "one" / "index.html" ).read_text("utf-8") assert "|Sep 3, 2026|" in post archive = tmp_path / "site" / "blog" / "archive" / "2026" assert archive.joinpath("index.html").is_file() assert "Archive[September 2026=" in archive.joinpath( "index.html" ).read_text("utf-8") def test_fractional_post_dates_preserve_values_and_order( tmp_path: Path, ) -> None: config = _project(tmp_path) _post( tmp_path, "a-older.md", "Older", "2026-09-03T10:30:00.100000Z", ) _post( tmp_path, "z-newer.md", "Newer", "2026-09-03T10:30:00.200000Z", ) (tmp_path / "overrides" / "blog-post.html").write_text( "{{ page.config.date.created }}", encoding="utf-8", ) zensical.build(str(config), _BUILD_OPTIONS) view = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert view.index("Newer:") < view.index("Older:") post = ( tmp_path / "site" / "blog" / "2026" / "09" / "03" / "newer" / "index.html" ).read_text("utf-8") assert post == "2026-09-03 10:30:00.200000+00:00" def test_structured_links_resolve_pages_anchors_and_nested_sections( tmp_path: Path, ) -> None: config = _project(tmp_path) (tmp_path / "docs" / "guide.md").write_text( "# Guide\n\n## Details\n", encoding="utf-8", ) assets = tmp_path / "docs" / "blog" / "posts" / "assets" assets.mkdir() assets.joinpath("reference.pdf").write_bytes(b"reference") _post( tmp_path, "one.md", "One", "2026-09-01", links=[ {"Guide section": "guide.md#details"}, {"Download": "blog/posts/assets/reference.pdf"}, {"Resources": [{"External": "https://example.com"}]}, ], ) zensical.build(str(config), _BUILD_OPTIONS) post = ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one" / "index.html" ).read_text("utf-8") assert "Guide section=guide/#details=Details[]" in post assert "Download=blog/assets/reference.pdf=" in post assert "Resources=none=[External=https://example.com;]" in post def test_excerpt_toc_contains_only_the_post_root( tmp_path: Path, ) -> None: config = _project(tmp_path) with config.open("a", encoding="utf-8") as stream: stream.write(" blog_toc: true\n") _post( tmp_path, "one.md", "One", "2026-09-01", body=( "## Included\n\n[Jump](#included)\n\n" "\n\n" "## Excluded\n\nMore." ), ) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert "TOC|Journal[One=2026/09/01/one/();" in page assert "Included=" not in page assert "Excluded=" not in page assert ( '

Included

' in page ) assert 'Jump' in page def test_excerpt_inserts_a_linked_title_when_the_post_has_no_h1( tmp_path: Path, ) -> None: config = _project(tmp_path) (tmp_path / "docs" / "blog" / "posts" / "one.md").write_text( "---\ndate: 2026-09-01\ntitle: One & Two\n---\nBody only.\n", encoding="utf-8", ) zensical.build(str(config), _BUILD_OPTIONS) page = (tmp_path / "site" / "blog" / "index.html").read_text("utf-8") assert ( '

One & Two

' in page ) def test_standalone_blog_uses_root_entrypoint_and_post_routes( tmp_path: Path, ) -> None: config = _project(tmp_path) posts = tmp_path / "docs" / "posts" posts.mkdir() posts.joinpath("one.md").write_text( "---\ndate: 2026-09-01\n---\n# One\n", encoding="utf-8", ) config.write_text( """\ site_name: Test theme: name: material custom_dir: overrides plugins: - material/blog: blog_dir: . archive: false categories: false authors: false """, encoding="utf-8", ) zensical.build(str(config), _BUILD_OPTIONS) assert (tmp_path / "site" / "index.html").is_file() assert ( tmp_path / "site" / "2026" / "09" / "01" / "one" / "index.html" ).is_file() assert not (tmp_path / "site" / "posts" / "one" / "index.html").exists() def test_blog_routes_respect_disabled_directory_urls(tmp_path: Path) -> None: config = _project(tmp_path, per_page=1) with config.open("a", encoding="utf-8") as stream: stream.write("use_directory_urls: false\n") _post(tmp_path, "one.md", "One", "2026-09-01") _post(tmp_path, "two.md", "Two", "2026-09-02") zensical.build(str(config), _BUILD_OPTIONS) assert ( tmp_path / "site" / "blog" / "2026" / "09" / "01" / "one.html" ).is_file() assert (tmp_path / "site" / "blog" / "page" / "2" / "index.html").is_file() def test_posts_receive_inherited_meta_before_blog_classification( tmp_path: Path, ) -> None: config = _project(tmp_path, categories=True) config.write_text( config.read_text(encoding="utf-8").replace( "plugins:\n", "plugins:\n - material/meta\n", ), encoding="utf-8", ) posts = tmp_path / "docs" / "blog" / "posts" (posts / ".meta.yml").write_text( "date: 2026-08-31\ncategories: [Inherited]\n", encoding="utf-8", ) (posts / "inherited.md").write_text( "---\ntitle: Inherited post\n---\n# Content heading\n", encoding="utf-8", ) zensical.build(str(config), _BUILD_OPTIONS) assert ( tmp_path / "site" / "blog" / "2026" / "08" / "31" / "inherited-post" / "index.html" ).is_file() category = ( tmp_path / "site" / "blog" / "category" / "inherited" / "index.html" ).read_text("utf-8") assert "Inherited post:" in category def test_search_uses_final_blog_post_routes(tmp_path: Path) -> None: config = _project(tmp_path) config.write_text( config.read_text(encoding="utf-8").replace( "plugins:\n", "plugins:\n - search\n", ), encoding="utf-8", ) _post(tmp_path, "one.md", "Searchable", "2026-09-01") zensical.build(str(config), _BUILD_OPTIONS) index = json.loads((tmp_path / "site" / "search.json").read_text("utf-8")) locations = [item["location"] for item in index["items"]] assert "blog/2026/09/01/searchable/" in locations def test_tag_listings_link_to_final_blog_post_routes(tmp_path: Path) -> None: config = _project(tmp_path) config.write_text( config.read_text(encoding="utf-8").replace( "plugins:\n", "plugins:\n - material/tags\n", ), encoding="utf-8", ) (tmp_path / "docs" / "tags.md").write_text( "# Tags\n\n\n", encoding="utf-8", ) _post( tmp_path, "one.md", "Tagged post", "2026-09-01", tags=["Feature"], ) zensical.build(str(config), _BUILD_OPTIONS) listing = (tmp_path / "site" / "tags" / "index.html").read_text("utf-8") assert 'href="../blog/2026/09/01/tagged-post/"' in listing