Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]
Expand Down
6 changes: 3 additions & 3 deletions tests/test_cibutler_integration.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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}"

Expand Down
5 changes: 1 addition & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
143 changes: 143 additions & 0 deletions tests/test_importer_image_handler.py
Original file line number Diff line number Diff line change
@@ -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"
98 changes: 98 additions & 0 deletions tests/test_importer_markdown_converter.py
Original file line number Diff line number Diff line change
@@ -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("<h1>Title</h1><h2>Subtitle</h2>")
assert "# Title" in result
assert "## Subtitle" in result

def test_bullets_use_dash(self) -> None:
result = _convert("<ul><li>one</li><li>two</li></ul>")
assert "- one" in result
assert "- two" in result

def test_strong_uses_asterisks(self) -> None:
result = _convert("<p><strong>bold</strong></p>")
assert "**bold**" in result


class TestConvertPre:
def test_pre_without_language_is_fenced(self) -> None:
result = _convert("<pre>print('hi')</pre>")
assert "```\nprint('hi')\n```" in result

def test_pre_with_code_language_class(self) -> None:
result = _convert("<pre><code class=\"language-python\">print('hi')</code></pre>")
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('<pre><code class="foo bar">x = 1</code></pre>')
assert "```\n" in result
assert "x = 1" in result

def test_pre_picks_first_language_class(self) -> None:
result = _convert(
'<pre><code class="highlighted language-rust extra">fn main() {}</code></pre>'
)
assert "```rust" in result


class TestConvertTable:
def test_simple_table_with_header(self) -> None:
html = """
<table>
<tr><th>Name</th><th>Age</th></tr>
<tr><td>Alice</td><td>30</td></tr>
<tr><td>Bob</td><td>25</td></tr>
</table>
"""
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 = """
<table>
<tr><th>A</th><th>B</th><th>C</th></tr>
<tr><td>1</td><td>2</td></tr>
</table>
"""
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 = "<table><tr><th>A</th></tr><tr><td>B</td></tr></table>"
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 = "<table></table>"
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 = "<table><tr><th>H</th></tr><tr><td>line one<br>line two</td></tr></table>"
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)
33 changes: 8 additions & 25 deletions tests/test_package_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -121,19 +115,15 @@ 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"


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()
Expand All @@ -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"


Expand Down Expand Up @@ -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(
'<?xml version="1.0"?>\n'
"<project>\n"
" <version>1.0.0</version>\n"
"</project>\n"
'<?xml version="1.0"?>\n<project>\n <version>1.0.0</version>\n</project>\n'
)
assert detect_package_version(tmp_path) == "1.0.0"

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading