diff --git a/pyproject.toml b/pyproject.toml
index 7ac56cf..47a60da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "uv_build"
[project]
name = "leafpress"
-version = "0.8.1"
+version = "0.8.2"
description = "Convert MkDocs sites to PDF, Word, HTML, ODT, EPUB, and Markdown documents with branding"
readme = "README.md"
authors = [{name = "Shane Hutchins", email = "hutchins@users.noreply.github.com"}]
diff --git a/tests/test_cibutler_integration.py b/tests/test_cibutler_integration.py
index 40beee9..aef0aaf 100644
--- a/tests/test_cibutler_integration.py
+++ b/tests/test_cibutler_integration.py
@@ -1,7 +1,7 @@
"""Integration tests using the OSDU CIButler MkDocs site.
These tests exercise leafpress against a real MkDocs project with Material theme,
-pymdownx extensions (including !!python/name references), and 12 markdown pages.
+pymdownx extensions (including !!python/name references), and auto-discovered pages.
"""
from pathlib import Path
@@ -98,7 +98,7 @@ def test_nav_auto_discovered(self, cibutler_config: MkDocsConfig) -> None:
page_paths = [str(p.path) for p in pages if p.path is not None]
assert any("index.md" in p for p in page_paths)
assert any("install.md" in p for p in page_paths)
- assert len(page_paths) == 14
+ assert len(page_paths) >= 14
def test_all_pages_exist(self, cibutler_config: MkDocsConfig) -> None:
pages = flatten_nav(cibutler_config.nav_items)
@@ -116,7 +116,7 @@ def test_all_pages_render(self, cibutler_html_pages: list[tuple]) -> None:
content_pages = [
(item, html) for item, html in cibutler_html_pages if item.path is not None
]
- assert len(content_pages) == 14
+ assert len(content_pages) >= 14
for item, html in content_pages:
assert len(html) > 0, f"Empty HTML for {item.path}"
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 42b6e4b..bd93490 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -195,10 +195,7 @@ def test_convert_monorepo_config_no_mkdocs_required(tmp_path: Path) -> None:
# Create monorepo config in tmp_path (no mkdocs.yml here)
config = tmp_path / "leafpress.yml"
config.write_text(
- 'company_name: "Test Corp"\n'
- 'project_name: "Monorepo Docs"\n'
- "projects:\n"
- " - services/api\n"
+ 'company_name: "Test Corp"\nproject_name: "Monorepo Docs"\nprojects:\n - services/api\n'
)
out = tmp_path / "output"
diff --git a/tests/test_importer_image_handler.py b/tests/test_importer_image_handler.py
new file mode 100644
index 0000000..d71dda4
--- /dev/null
+++ b/tests/test_importer_image_handler.py
@@ -0,0 +1,143 @@
+"""Tests for the importer ImageHandler."""
+
+from __future__ import annotations
+
+import hashlib
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+from helpers import make_png
+
+from leafpress.importer.image_handler import (
+ ImageHandler,
+ content_type_for_extension,
+)
+
+
+class TestSaveImage:
+ def test_writes_file_to_assets_dir(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ data = make_png()
+
+ rel_path = handler.save_image(data, "image/png")
+
+ saved = tmp_path / "assets" / Path(rel_path).name
+ assert saved.exists()
+ assert saved.read_bytes() == data
+
+ def test_returns_relative_markdown_path(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ rel_path = handler.save_image(make_png(), "image/png")
+ assert rel_path.startswith("assets/image-001-")
+ assert rel_path.endswith(".png")
+
+ def test_filename_includes_counter_and_hash(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ data = make_png()
+ expected_hash = hashlib.sha256(data).hexdigest()[:12]
+
+ rel_path = handler.save_image(data, "image/png")
+
+ assert f"image-001-{expected_hash}.png" in rel_path
+
+ def test_counter_increments_across_calls(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ first = handler.save_image(make_png(), "image/png")
+ second = handler.save_image(b"other-bytes", "image/jpeg")
+
+ assert "image-001-" in first
+ assert "image-002-" in second
+ assert second.endswith(".jpg")
+
+ def test_creates_assets_dir_if_missing(self, tmp_path: Path) -> None:
+ assets_dir = tmp_path / "deep" / "nested" / "assets"
+ assert not assets_dir.exists()
+
+ handler = ImageHandler(assets_dir)
+ handler.save_image(make_png(), "image/png")
+
+ assert assets_dir.is_dir()
+
+ def test_unknown_content_type_defaults_to_png(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ rel_path = handler.save_image(b"data", "application/octet-stream")
+ assert rel_path.endswith(".png")
+
+ @pytest.mark.parametrize(
+ ("content_type", "expected_ext"),
+ [
+ ("image/png", ".png"),
+ ("image/jpeg", ".jpg"),
+ ("image/gif", ".gif"),
+ ("image/svg+xml", ".svg"),
+ ("image/bmp", ".bmp"),
+ ("image/tiff", ".tiff"),
+ ("image/webp", ".webp"),
+ ("image/x-emf", ".emf"),
+ ("image/x-wmf", ".wmf"),
+ ],
+ )
+ def test_extension_mapping(self, tmp_path: Path, content_type: str, expected_ext: str) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ rel_path = handler.save_image(b"x", content_type)
+ assert rel_path.endswith(expected_ext)
+
+ def test_saved_images_tracks_paths(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ handler.save_image(make_png(), "image/png")
+ handler.save_image(b"x", "image/jpeg")
+
+ assert len(handler.saved_images) == 2
+ assert all(p.exists() for p in handler.saved_images)
+
+ def test_saved_images_returns_copy(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ handler.save_image(make_png(), "image/png")
+
+ snapshot = handler.saved_images
+ snapshot.clear()
+
+ assert len(handler.saved_images) == 1
+
+
+class TestHandleImage:
+ def test_reads_image_via_open_callback(self, tmp_path: Path) -> None:
+ handler = ImageHandler(tmp_path / "assets")
+ data = make_png()
+
+ class FakeMammothImage:
+ content_type = "image/png"
+
+ def open(self) -> BytesIO:
+ return BytesIO(data)
+
+ result = handler.handle_image(FakeMammothImage())
+
+ assert "src" in result
+ assert result["src"].startswith("assets/image-001-")
+ assert result["src"].endswith(".png")
+ assert len(handler.saved_images) == 1
+
+
+class TestContentTypeForExtension:
+ @pytest.mark.parametrize(
+ ("ext", "expected"),
+ [
+ (".png", "image/png"),
+ (".jpg", "image/jpeg"),
+ (".jpeg", "image/jpeg"),
+ (".gif", "image/gif"),
+ (".svg", "image/svg+xml"),
+ (".bmp", "image/bmp"),
+ (".tiff", "image/tiff"),
+ (".webp", "image/webp"),
+ (".emf", "image/x-emf"),
+ (".wmf", "image/x-wmf"),
+ ],
+ )
+ def test_known_extensions(self, ext: str, expected: str) -> None:
+ assert content_type_for_extension(ext) == expected
+
+ def test_unknown_extension_defaults_to_png(self) -> None:
+ assert content_type_for_extension(".xyz") == "image/png"
diff --git a/tests/test_importer_markdown_converter.py b/tests/test_importer_markdown_converter.py
new file mode 100644
index 0000000..d97fd8c
--- /dev/null
+++ b/tests/test_importer_markdown_converter.py
@@ -0,0 +1,98 @@
+"""Tests for LeafpressMarkdownConverter."""
+
+from __future__ import annotations
+
+from leafpress.importer.markdown_converter import LeafpressMarkdownConverter
+
+
+def _convert(html: str) -> str:
+ return LeafpressMarkdownConverter().convert(html)
+
+
+class TestOptions:
+ def test_headings_use_atx_style(self) -> None:
+ result = _convert("
Title
Subtitle
")
+ assert "# Title" in result
+ assert "## Subtitle" in result
+
+ def test_bullets_use_dash(self) -> None:
+ result = _convert("")
+ assert "- one" in result
+ assert "- two" in result
+
+ def test_strong_uses_asterisks(self) -> None:
+ result = _convert("bold
")
+ assert "**bold**" in result
+
+
+class TestConvertPre:
+ def test_pre_without_language_is_fenced(self) -> None:
+ result = _convert("print('hi')")
+ assert "```\nprint('hi')\n```" in result
+
+ def test_pre_with_code_language_class(self) -> None:
+ result = _convert("print('hi')
")
+ assert "```python" in result
+ assert "print('hi')" in result
+ assert result.rstrip().endswith("```")
+
+ def test_pre_with_non_language_class_has_no_lang(self) -> None:
+ result = _convert('x = 1
')
+ assert "```\n" in result
+ assert "x = 1" in result
+
+ def test_pre_picks_first_language_class(self) -> None:
+ result = _convert(
+ ''
+ )
+ assert "```rust" in result
+
+
+class TestConvertTable:
+ def test_simple_table_with_header(self) -> None:
+ html = """
+
+ | Name | Age |
+ | Alice | 30 |
+ | Bob | 25 |
+
+ """
+ result = _convert(html)
+ assert "| Name" in result
+ assert "| Age" in result
+ assert "| Alice" in result
+ assert "| Bob" in result
+ # separator row of dashes
+ assert "| ---" in result
+
+ def test_ragged_rows_are_padded(self) -> None:
+ html = """
+
+ """
+ result = _convert(html)
+ lines = [ln for ln in result.splitlines() if ln.startswith("|")]
+ # All table lines should have the same pipe count (4: leading + 3 separators)
+ pipe_counts = {ln.count("|") for ln in lines}
+ assert pipe_counts == {4}
+
+ def test_columns_padded_to_min_width_three(self) -> None:
+ html = ""
+ result = _convert(html)
+ # Header cell "A" padded to width 3 -> "| A |"
+ assert "| A |" in result
+ assert "| --- |" in result
+
+ def test_empty_table_returns_input_text(self) -> None:
+ html = ""
+ result = _convert(html)
+ # No pipe-table output when there are no rows
+ assert "|" not in result
+
+ def test_cell_with_newlines_is_flattened(self) -> None:
+ html = ""
+ result = _convert(html)
+ table_lines = [ln for ln in result.splitlines() if ln.startswith("|")]
+ assert any("line one line two" in ln for ln in table_lines)
diff --git a/tests/test_package_version.py b/tests/test_package_version.py
index 56b9eb3..d0c7231 100644
--- a/tests/test_package_version.py
+++ b/tests/test_package_version.py
@@ -44,25 +44,19 @@ def test_candidate_dirs_includes_start(tmp_path: Path) -> None:
def test_pyproject_pep621(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
- (tmp_path / "pyproject.toml").write_text(
- '[project]\nname = "myapp"\nversion = "1.2.3"\n'
- )
+ (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\nversion = "1.2.3"\n')
assert detect_package_version(tmp_path) == "1.2.3"
def test_pyproject_poetry(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
- (tmp_path / "pyproject.toml").write_text(
- '[tool.poetry]\nname = "myapp"\nversion = "2.0.0"\n'
- )
+ (tmp_path / "pyproject.toml").write_text('[tool.poetry]\nname = "myapp"\nversion = "2.0.0"\n')
assert detect_package_version(tmp_path) == "2.0.0"
def test_cargo_toml(tmp_path: Path) -> None:
(tmp_path / ".git").mkdir()
- (tmp_path / "Cargo.toml").write_text(
- '[package]\nname = "myapp"\nversion = "0.3.1"\n'
- )
+ (tmp_path / "Cargo.toml").write_text('[package]\nname = "myapp"\nversion = "0.3.1"\n')
assert detect_package_version(tmp_path) == "0.3.1"
@@ -121,9 +115,7 @@ def test_csproj_version_prefix(tmp_path: Path) -> None:
def test_finds_manifest_in_parent(tmp_path: Path) -> None:
"""Manifest in project root should be found when starting from docs subdir."""
(tmp_path / ".git").mkdir()
- (tmp_path / "pyproject.toml").write_text(
- '[project]\nname = "myapp"\nversion = "9.9.9"\n'
- )
+ (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\nversion = "9.9.9"\n')
docs = tmp_path / "docs"
docs.mkdir()
assert detect_package_version(docs) == "9.9.9"
@@ -131,9 +123,7 @@ def test_finds_manifest_in_parent(tmp_path: Path) -> None:
def test_does_not_cross_vcs_boundary(tmp_path: Path) -> None:
"""Should not find a manifest above the .git root."""
- (tmp_path / "pyproject.toml").write_text(
- '[project]\nname = "outer"\nversion = "0.0.1"\n'
- )
+ (tmp_path / "pyproject.toml").write_text('[project]\nname = "outer"\nversion = "0.0.1"\n')
inner = tmp_path / "inner"
inner.mkdir()
(inner / ".git").mkdir()
@@ -149,12 +139,8 @@ def test_no_manifest_returns_none(tmp_path: Path) -> None:
def test_priority_pyproject_over_cargo(tmp_path: Path) -> None:
"""pyproject.toml takes priority over Cargo.toml in the same directory."""
(tmp_path / ".git").mkdir()
- (tmp_path / "pyproject.toml").write_text(
- '[project]\nname = "myapp"\nversion = "1.0.0"\n'
- )
- (tmp_path / "Cargo.toml").write_text(
- '[package]\nname = "myapp"\nversion = "2.0.0"\n'
- )
+ (tmp_path / "pyproject.toml").write_text('[project]\nname = "myapp"\nversion = "1.0.0"\n')
+ (tmp_path / "Cargo.toml").write_text('[package]\nname = "myapp"\nversion = "2.0.0"\n')
assert detect_package_version(tmp_path) == "1.0.0"
@@ -228,10 +214,7 @@ def test_pom_xml_no_namespace(tmp_path: Path) -> None:
"""pom.xml without a namespace should still detect version."""
(tmp_path / ".git").mkdir()
(tmp_path / "pom.xml").write_text(
- '\n'
- "\n"
- " 1.0.0\n"
- "\n"
+ '\n\n 1.0.0\n\n'
)
assert detect_package_version(tmp_path) == "1.0.0"
diff --git a/uv.lock b/uv.lock
index 5abaea1..4719464 100644
--- a/uv.lock
+++ b/uv.lock
@@ -416,7 +416,7 @@ wheels = [
[[package]]
name = "leafpress"
-version = "0.8.1"
+version = "0.8.2"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },