.*?)(?<=\n)"
21 | r"(?P=fence)[ ]*$",
22 | flags=re.IGNORECASE + re.DOTALL + re.MULTILINE,
23 | )
24 |
25 | def __init__(self, docs_dir: str, diagram_types: KrokiDiagramTypes) -> None:
26 | self.diagram_types = diagram_types
27 | self.docs_dir = docs_dir
28 |
29 | def _get_block_content(self, block_data: str) -> Result[str, ErrorResult]:
30 | if not block_data.startswith(self.from_file_prefix):
31 | return Ok(block_data)
32 |
33 | file_name = block_data.removeprefix(self.from_file_prefix).strip()
34 | file_path = Path(self.docs_dir) / file_name
35 | log.debug('Reading kroki block from file: "%s"', file_path.absolute())
36 | try:
37 | with open(file_path) as data_file:
38 | return Ok(data_file.read())
39 | except OSError as error:
40 | return Err(
41 | ErrorResult(
42 | err_msg=f'Can\'t read file: "{file_path.absolute()}" from code block "{block_data}"', error=error
43 | )
44 | )
45 |
46 | def replace_kroki_blocks(
47 | self,
48 | markdown: str,
49 | block_callback: Callable[[KrokiImageContext, MkDocsEventContext], str],
50 | context: MkDocsEventContext,
51 | ) -> str:
52 | def replace_kroki_block(match_obj: re.Match):
53 | kroki_type = self.diagram_types.get_kroki_type(match_obj.group("lang"))
54 | if kroki_type is None:
55 | # Skip not supported code blocks
56 | return match_obj.group()
57 |
58 | kroki_options = match_obj.group("opts")
59 | kroki_context = KrokiImageContext(
60 | kroki_type=kroki_type,
61 | options=dict(x.split("=") for x in kroki_options.strip().split(" ")) if kroki_options else {},
62 | data=self._get_block_content(textwrap.dedent(match_obj.group("code"))),
63 | )
64 | return textwrap.indent(block_callback(kroki_context, context), match_obj.group("indent"))
65 |
66 | return re.sub(self._FENCE_RE, replace_kroki_block, markdown)
67 |
--------------------------------------------------------------------------------
/kroki/plugin.py:
--------------------------------------------------------------------------------
1 | from mkdocs.plugins import BasePlugin as MkDocsBasePlugin
2 |
3 | from kroki.client import KrokiClient
4 | from kroki.common import MkDocsConfig, MkDocsEventContext, MkDocsFiles, MkDocsPage
5 | from kroki.config import KrokiPluginConfig
6 | from kroki.diagram_types import KrokiDiagramTypes
7 | from kroki.logging import log
8 | from kroki.parsing import MarkdownParser
9 | from kroki.render import ContentRenderer
10 |
11 |
12 | class KrokiPlugin(MkDocsBasePlugin[KrokiPluginConfig]):
13 | def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
14 | log.debug("Configuring config: %s", self.config)
15 |
16 | self.diagram_types = KrokiDiagramTypes(
17 | self.config.FencePrefix,
18 | self.config.FileTypes,
19 | self.config.FileTypeOverrides,
20 | blockdiag_enabled=self.config.EnableBlockDiag,
21 | bpmn_enabled=self.config.EnableBpmn,
22 | excalidraw_enabled=self.config.EnableExcalidraw,
23 | mermaid_enabled=self.config.EnableMermaid,
24 | diagramsnet_enabled=self.config.EnableDiagramsnet,
25 | )
26 |
27 | self.kroki_client = KrokiClient(
28 | server_url=self.config.ServerURL,
29 | http_method=self.config.HttpMethod,
30 | user_agent=self.config.UserAgent,
31 | diagram_types=self.diagram_types,
32 | )
33 | self.parser = MarkdownParser(config.docs_dir, self.diagram_types)
34 | self.renderer = ContentRenderer(
35 | self.kroki_client,
36 | tag_format=self.config.TagFormat,
37 | fail_fast=self.config.FailFast,
38 | )
39 |
40 | return config
41 |
42 | def on_page_markdown(self, markdown: str, page: MkDocsPage, config: MkDocsConfig, files: MkDocsFiles) -> str:
43 | mkdocs_context = MkDocsEventContext(page=page, config=config, files=files)
44 | log.debug("on_page_content [%s]", mkdocs_context)
45 |
46 | return self.parser.replace_kroki_blocks(markdown, self.renderer.render_kroki_block, mkdocs_context)
47 |
--------------------------------------------------------------------------------
/kroki/render.py:
--------------------------------------------------------------------------------
1 | from xml.etree import ElementTree as XmlElementTree
2 |
3 | from defusedxml import ElementTree as DefuseElementTree
4 | from mkdocs.exceptions import PluginError
5 | from result import Err, Ok
6 |
7 | from kroki.client import KrokiClient
8 | from kroki.common import ErrorResult, ImageSrc, KrokiImageContext, MkDocsEventContext
9 | from kroki.logging import log
10 |
11 |
12 | class ContentRenderer:
13 | def __init__(self, kroki_client: KrokiClient, tag_format: str, *, fail_fast: bool) -> None:
14 | self.fail_fast = fail_fast
15 | self.kroki_client = kroki_client
16 | self.tag_format = tag_format
17 |
18 | def _get_object_media_type(self, file_ext: str) -> str:
19 | match file_ext:
20 | case "png":
21 | return "image/png"
22 | case "svg":
23 | return "image/svg+xml"
24 | case "jpeg":
25 | return "image/jpg"
26 | case "pdf":
27 | return "application/pdf"
28 | case _:
29 | err_msg = f"Not implemented: '{file_ext}"
30 | raise PluginError(err_msg)
31 |
32 | @classmethod
33 | def _svg_data(cls, image_src: ImageSrc) -> str:
34 | if image_src.file_content is None:
35 | err_msg = "Cannot include empty SVG data"
36 | raise PluginError(err_msg)
37 |
38 | XmlElementTree.register_namespace("", "http://www.w3.org/2000/svg")
39 | XmlElementTree.register_namespace("xlink", "http://www.w3.org/1999/xlink")
40 | svg_tag = DefuseElementTree.fromstring(
41 | image_src.file_content.decode("UTF-8"),
42 | )
43 | svg_tag.attrib["preserveAspectRatio"] = "xMaxYMax meet"
44 | svg_tag.attrib["id"] = "Kroki"
45 |
46 | return DefuseElementTree.tostring(svg_tag, short_empty_elements=True).decode()
47 |
48 | def _image_response(self, image_src: ImageSrc) -> str:
49 | tag_format = self.tag_format
50 | if tag_format == "svg":
51 | if image_src.file_ext != "svg":
52 | log.warning("Cannot render '%s' in svg tag -> using img tag.", image_src.url)
53 | tag_format = "img"
54 | if image_src.file_content is None:
55 | log.warning("Cannot render missing data in svg tag -> using img tag.")
56 | tag_format = "img"
57 |
58 | match tag_format:
59 | case "object":
60 | media_type = self._get_object_media_type(image_src.file_ext)
61 | return f''
62 | case "svg":
63 | return ContentRenderer._svg_data(image_src)
64 | case "img":
65 | return f'
'
66 | case _:
67 | err_msg = "Unknown tag format set."
68 | raise PluginError(err_msg)
69 |
70 | def _err_response(self, err_result: ErrorResult, kroki_data: None | str = None) -> str:
71 | if ErrorResult.error is None:
72 | log.error("%s", err_result.err_msg)
73 | else:
74 | log.error("%s [raised by %s]", err_result.err_msg, err_result.error)
75 |
76 | if self.fail_fast:
77 | raise PluginError(err_result.err_msg) from err_result.error
78 |
79 | return (
80 | ''
81 | f"{err_result.err_msg}
"
82 | f'{err_result.response_text or ""}
'
83 | f'{kroki_data or ""}
'
84 | ""
85 | )
86 |
87 | def render_kroki_block(self, kroki_context: KrokiImageContext, context: MkDocsEventContext) -> str:
88 | match kroki_context.data:
89 | case Ok(kroki_data):
90 | match self.kroki_client.get_image_url(kroki_context, context):
91 | case Ok(image_src):
92 | return self._image_response(image_src)
93 | case Err(err_result):
94 | return self._err_response(err_result, kroki_data)
95 | case Err(err_result):
96 | return self._err_response(err_result)
97 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "mkdocs-kroki-plugin"
7 | dynamic = ["version"]
8 | description = "MkDocs plugin for Kroki-Diagrams"
9 | readme = "README.md"
10 | license = "MIT"
11 | requires-python = ">=3.10"
12 | authors = [
13 | { name = "Benjamin Bittner", email = "benjamin.bittner@avateam.com" },
14 | { name = "Antonia Siegert", email = "oniboni@mailbox.org" },
15 | ]
16 | keywords = [
17 | "diagram",
18 | "kroki",
19 | "markdown",
20 | "mkdocs",
21 | "python",
22 | ]
23 | classifiers = [
24 | "Development Status :: 4 - Beta",
25 | "Intended Audience :: Developers",
26 | "Intended Audience :: Information Technology",
27 | "Intended Audience :: Science/Research",
28 | "License :: OSI Approved :: MIT License",
29 | "Programming Language :: Python",
30 | "Programming Language :: Python :: 3 :: Only",
31 | "Programming Language :: Python :: 3.10",
32 | "Programming Language :: Python :: 3.11",
33 | "Programming Language :: Python :: 3.12",
34 | ]
35 | dependencies = [
36 | "mkdocs>=1.5.0",
37 | "requests>=2.27.0",
38 | "result>=0.17.0",
39 | "defusedxml>=0.7.1"
40 | ]
41 |
42 | [project.entry-points."mkdocs.plugins"]
43 | kroki = "kroki.plugin:KrokiPlugin"
44 |
45 | [project.urls]
46 | Documentation = "https://github.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin#readme"
47 | Issues = "https://github.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin/issues"
48 | Source = "https://github.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin"
49 |
50 | [tool.hatch.version]
51 | path = "kroki/__init__.py"
52 |
53 | [tool.hatch.build.targets.sdist]
54 | include = [
55 | "/kroki",
56 | ]
57 | [tool.hatch.build.targets.wheel]
58 | include = [
59 | "/kroki",
60 | ]
61 |
62 | # this enables you to run arbitrary commands inside of the hatch-managed hatch-test environment
63 | # e.g.
64 | # REPL:
65 | # hatch run test:python
66 | # serve happy path test:
67 | # hatch run test:mkdocs serve -f tests/data/happy_path/mkdocs.yml
68 | [tool.hatch.envs.test]
69 | template = "hatch-test"
70 | [tool.hatch.envs.test.env-vars]
71 | # start local test kroki:
72 | # docker-compose up
73 | KROKI_SERVER_URL = "http://localhost:8080"
74 |
75 | [tool.hatch.envs.techdocs]
76 | template = "test"
77 | extra-dependencies = [
78 | "mkdocs_monorepo_plugin",
79 | "mkdocs-techdocs-core",
80 | "nodeenv",
81 | ]
82 | [tool.hatch.envs.techdocs.scripts]
83 | serve = [
84 | "if [ ! $(command -v npm) ];then nodeenv --python-virtualenv; fi",
85 | "if [ ! $(command -v techdocs-cli) ];then npm install -g @techdocs/cli; fi",
86 | "cd tests/data/techdocs && techdocs-cli serve --no-docker --verbose",
87 | ]
88 |
89 | [tool.hatch.envs.hatch-test]
90 | extra-dependencies = [
91 | "pytest-mock",
92 | "beautifulsoup4",
93 | "mkdocs-material",
94 | "click",
95 | ]
96 | [[tool.hatch.envs.hatch-test.matrix]]
97 | python = ["3.10", "3.11", "3.12"]
98 | [tool.hatch.envs.hatch-test.scripts]
99 | run = "pytest {env:HATCH_TEST_ARGS:} {args:tests}"
100 | run-cov = "coverage run -m pytest {env:HATCH_TEST_ARGS:} {args:tests} --junitxml=junit/test-results.xml"
101 | cov-combine = "coverage combine"
102 | cov-report = [
103 | "coverage xml",
104 | "coverage report --omit='tests/*'",
105 | ]
106 |
107 | [tool.hatch.envs.types]
108 | template = "hatch-test"
109 | extra-dependencies = [
110 | "mypy",
111 | "types-PyYAML",
112 | "types-beautifulsoup4",
113 | "types-requests",
114 | "types-babel",
115 | ]
116 | [tool.hatch.envs.types.scripts]
117 | check = "mypy --install-types --non-interactive {args:kroki tests}"
118 | [tool.mypy]
119 | disable_error_code="import-untyped"
120 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin/8f8a6c433d8049e76b1a5cfb98be88a4f4ee4068/tests/__init__.py
--------------------------------------------------------------------------------
/tests/compat.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | # backport chdir context manager
4 | if sys.version_info < (3, 11):
5 | import os
6 | from contextlib import AbstractContextManager
7 |
8 | class chdir(AbstractContextManager): # noqa: N801
9 | """Non thread-safe context manager to change the current working directory."""
10 |
11 | def __init__(self, path):
12 | self.path = path
13 | self._old_cwd = []
14 |
15 | def __enter__(self):
16 | self._old_cwd.append(os.getcwd())
17 | os.chdir(self.path)
18 |
19 | def __exit__(self, *excinfo):
20 | os.chdir(self._old_cwd.pop())
21 | else:
22 | from contextlib import chdir # noqa: F401
23 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass
2 |
3 | import pytest
4 | import requests
5 |
6 | from kroki.diagram_types import KrokiDiagramTypes
7 |
8 |
9 | @pytest.fixture(autouse=True)
10 | def no_actual_requests_please(monkeypatch):
11 | """Safeguard for missing requests mocks."""
12 | monkeypatch.delattr("requests.sessions.Session.request")
13 |
14 |
15 | @pytest.fixture
16 | def mock_kroki_diagram_types() -> KrokiDiagramTypes:
17 | return KrokiDiagramTypes(
18 | "",
19 | ["svg"],
20 | {},
21 | blockdiag_enabled=True,
22 | bpmn_enabled=True,
23 | excalidraw_enabled=True,
24 | mermaid_enabled=True,
25 | diagramsnet_enabled=True,
26 | )
27 |
28 |
29 | @dataclass
30 | class MockResponse:
31 | status_code: int
32 | content: None | bytes = None
33 | text: None | str = None
34 |
35 | @property
36 | def reason(self) -> str:
37 | return requests.codes.get(self.status_code)
38 |
39 |
40 | @pytest.fixture
41 | def kroki_timeout(monkeypatch) -> None:
42 | """Let request post calls always raise a ConnectionTimeout."""
43 |
44 | def mock_post(*_args, **_kwargs):
45 | raise requests.exceptions.ConnectTimeout
46 |
47 | monkeypatch.setattr(requests, "post", mock_post)
48 |
49 |
50 | @pytest.fixture
51 | def kroki_bad_request(monkeypatch) -> None:
52 | """Let request post calls always return a mocked response with status code 400"""
53 |
54 | def mock_post(*_args, **_kwargs):
55 | return MockResponse(status_code=400, text="Error 400: Syntax Error? (line: 10)")
56 |
57 | monkeypatch.setattr(requests, "post", mock_post)
58 |
59 |
60 | @pytest.fixture
61 | def kroki_is_a_teapot(monkeypatch) -> None:
62 | """Let request post calls always return a mocked response with status code 418"""
63 |
64 | def mock_post(*_args, **_kwargs):
65 | return MockResponse(status_code=418)
66 |
67 | monkeypatch.setattr(requests, "post", mock_post)
68 |
69 |
70 | @pytest.fixture
71 | def kroki_dummy(monkeypatch) -> None:
72 | """Let request post calls always return a mocked response with status code 200 and dummy content data"""
73 |
74 | def mock_post(*_args, **_kwargs):
75 | return MockResponse(status_code=200, content=b"")
76 |
77 | monkeypatch.setattr(requests, "post", mock_post)
78 |
--------------------------------------------------------------------------------
/tests/data/happy_path/docs/assets/diagram.plantuml:
--------------------------------------------------------------------------------
1 | @startuml
2 | Bob -> Alice : hello
3 | @enduml
4 |
--------------------------------------------------------------------------------
/tests/data/happy_path/docs/index.md:
--------------------------------------------------------------------------------
1 | # Test
2 |
3 | ## inline
4 |
5 | ```c4plantuml
6 | !include
7 | !include
8 |
9 | title System Context diagram for MkDocs Kroki Plugin
10 |
11 | Person(writer, "Technical Writer", "Writes documentaion in markdown")
12 | Person(reader, "Audience", "Reads documentaion represented as a web page")
13 |
14 | System_Boundary(builder, "Static Site Generation") {
15 | Container(mkdocs, "MkDocs", "python", "Static Site Generator")
16 | Container(plugin, "MkDocs Kroki Plugin", "python", "Handles fence block contents")
17 |
18 | ContainerDb(db, "Storage", "gh-pages, S3 or else", "Stores the generated static site")
19 | }
20 |
21 | System_Ext(kroki, "kroki.io", "Generates images from fenced contents")
22 | System_Ext(site_host, "Site Host", "Serves the site contents to the audience")
23 |
24 | Rel(writer, mkdocs, "use")
25 | BiRel(mkdocs, plugin, "handle diagram blocks")
26 |
27 | BiRel(plugin, kroki, "get image data")
28 |
29 | Rel(mkdocs, db, "build")
30 | Rel(site_host, db, "read")
31 |
32 | Rel(site_host, reader, "serve")
33 | ```
34 |
35 | ## from file
36 |
37 | ```plantuml
38 | @from_file:assets/diagram.plantuml
39 | ```
40 |
--------------------------------------------------------------------------------
/tests/data/happy_path/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Test pages for mkdocs-kroki-plugin
2 |
3 | theme:
4 | name: material
5 |
6 | plugins:
7 | - kroki:
8 | FencePrefix: ''
9 |
--------------------------------------------------------------------------------
/tests/data/missing_from_file/docs/index.md:
--------------------------------------------------------------------------------
1 | # Test nonexistent include file
2 |
3 |
4 | ```mermaid
5 | @from_file:does/not/exist.mermaid
6 | ```
7 |
--------------------------------------------------------------------------------
/tests/data/missing_from_file/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Test pages for mkdocs-kroki-plugin
2 |
3 | theme:
4 | name: material
5 |
6 | plugins:
7 | - kroki:
8 | FencePrefix: ''
9 |
--------------------------------------------------------------------------------
/tests/data/techdocs/docs/assets/diagram.plantuml:
--------------------------------------------------------------------------------
1 | @startuml
2 | Bob -> Alice : hello
3 | @enduml
4 |
--------------------------------------------------------------------------------
/tests/data/techdocs/docs/index.md:
--------------------------------------------------------------------------------
1 | # Test
2 |
3 | ## inline
4 |
5 | ```c4plantuml
6 | !include
7 | !include
8 |
9 | title System Context diagram for MkDocs Kroki Plugin
10 |
11 | Person(writer, "Technical Writer", "Writes documentaion in markdown")
12 | Person(reader, "Audience", "Reads documentaion represented as a web page")
13 |
14 | System_Boundary(builder, "Static Site Generation") {
15 | Container(mkdocs, "MkDocs", "python", "Static Site Generator")
16 | Container(plugin, "MkDocs Kroki Plugin", "python", "Handles fence block contents")
17 |
18 | ContainerDb(db, "Storage", "gh-pages, S3 or else", "Stores the generated static site")
19 | }
20 |
21 | System_Ext(kroki, "kroki.io", "Generates images from fenced contents")
22 | System_Ext(site_host, "Site Host", "Serves the site contents to the audience")
23 |
24 | Rel(writer, mkdocs, "use")
25 | BiRel(mkdocs, plugin, "handle diagram blocks")
26 |
27 | BiRel(plugin, kroki, "get image data")
28 |
29 | Rel(mkdocs, db, "build")
30 | Rel(site_host, db, "read")
31 |
32 | Rel(site_host, reader, "serve")
33 | ```
34 |
35 | ## from file
36 |
37 | ```plantuml
38 | @from_file:assets/diagram.plantuml
39 | ```
40 |
--------------------------------------------------------------------------------
/tests/data/techdocs/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Test pages for mkdocs-kroki-plugin
2 | docs_dir: docs
3 |
4 | plugins:
5 | - techdocs-core
6 | - kroki:
7 | FencePrefix: ''
8 | TagFormat: 'svg'
9 |
--------------------------------------------------------------------------------
/tests/data/template/docs/index.md:
--------------------------------------------------------------------------------
1 | # Test
2 |
3 | $code_block
4 |
--------------------------------------------------------------------------------
/tests/data/template/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Test pages for mkdocs-kroki-plugin
2 |
3 | theme:
4 | name: material
5 |
6 | plugins:
7 | - kroki:
8 | FencePrefix: ''
9 |
--------------------------------------------------------------------------------
/tests/test_errors.py:
--------------------------------------------------------------------------------
1 | import bs4
2 | import pytest
3 |
4 | from tests.utils import MkDocsHelper, get_expected_log_line
5 |
6 |
7 | def _assert_error_block(err_msg: str, index_html: str):
8 | index_soup = bs4.BeautifulSoup(index_html)
9 | details_tag = index_soup.find("details")
10 | assert isinstance(details_tag, bs4.Tag), "Error message container not in resulting HTML"
11 | summary_tag = details_tag.summary
12 | assert isinstance(summary_tag, bs4.Tag), "Error message container has no summary element"
13 | assert err_msg in summary_tag.text
14 |
15 |
16 | @pytest.mark.usefixtures("kroki_timeout")
17 | def test_request_timeout() -> None:
18 | # Arrange
19 | with MkDocsHelper("happy_path") as mkdocs_helper:
20 | mkdocs_helper.set_http_method("POST")
21 | # Act
22 | result = mkdocs_helper.invoke_build()
23 | # Assert
24 | assert result.exit_code == 0
25 | assert get_expected_log_line("Request error") in result.output
26 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
27 | _assert_error_block("Request error", index_html_file.read())
28 |
29 |
30 | @pytest.mark.usefixtures("kroki_bad_request")
31 | def test_request_bad_request() -> None:
32 | # Arrange
33 | with MkDocsHelper("happy_path") as mkdocs_helper:
34 | mkdocs_helper.set_http_method("POST")
35 | # Act
36 | result = mkdocs_helper.invoke_build()
37 | # Assert
38 | assert result.exit_code == 0
39 | assert get_expected_log_line("Diagram error!") in result.output
40 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
41 | _assert_error_block("Diagram error!", index_html_file.read())
42 |
43 |
44 | @pytest.mark.usefixtures("kroki_is_a_teapot")
45 | def test_request_other_error() -> None:
46 | # Arrange
47 | with MkDocsHelper("happy_path") as mkdocs_helper:
48 | mkdocs_helper.set_http_method("POST")
49 | # Act
50 | result = mkdocs_helper.invoke_build()
51 | # Assert
52 | assert result.exit_code == 0
53 | assert get_expected_log_line("Could not retrieve image data") in result.output
54 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
55 | _assert_error_block("Could not retrieve image data", index_html_file.read())
56 |
57 |
58 | @pytest.mark.usefixtures("kroki_dummy")
59 | def test_missing_from_file() -> None:
60 | # Arrange
61 | with MkDocsHelper("missing_from_file") as mkdocs_helper:
62 | mkdocs_helper.set_http_method("POST")
63 | # Act
64 | result = mkdocs_helper.invoke_build()
65 | # Assert
66 | assert result.exit_code == 0
67 | assert get_expected_log_line("Can't read file:") in result.output
68 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
69 | _assert_error_block("Can't read file: ", index_html_file.read())
70 |
--------------------------------------------------------------------------------
/tests/test_errors_fail_fast.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tests.utils import MkDocsHelper, get_expected_log_line
4 |
5 |
6 | @pytest.mark.usefixtures("kroki_timeout")
7 | def test_request_timeout() -> None:
8 | # Arrange
9 | with MkDocsHelper("happy_path") as mkdocs_helper:
10 | mkdocs_helper.set_http_method("POST")
11 | mkdocs_helper.enable_fail_fast()
12 | # Act
13 | result = mkdocs_helper.invoke_build()
14 | # Assert
15 | assert result.exit_code == 1
16 | assert get_expected_log_line("Request error") in result.output
17 | assert "Aborted with a BuildError!" in result.output
18 |
19 |
20 | @pytest.mark.usefixtures("kroki_bad_request")
21 | def test_request_bad_request() -> None:
22 | # Arrange
23 | with MkDocsHelper("happy_path") as mkdocs_helper:
24 | mkdocs_helper.set_http_method("POST")
25 | mkdocs_helper.enable_fail_fast()
26 | # Act
27 | result = mkdocs_helper.invoke_build()
28 | # Assert
29 | assert result.exit_code == 1
30 | assert get_expected_log_line("Diagram error!") in result.output
31 | assert "Aborted with a BuildError!" in result.output
32 |
33 |
34 | @pytest.mark.usefixtures("kroki_is_a_teapot")
35 | def test_request_other_error() -> None:
36 | # Arrange
37 | with MkDocsHelper("happy_path") as mkdocs_helper:
38 | mkdocs_helper.set_http_method("POST")
39 | mkdocs_helper.enable_fail_fast()
40 | # Act
41 | result = mkdocs_helper.invoke_build()
42 | # Assert
43 | assert result.exit_code == 1
44 | assert get_expected_log_line("Could not retrieve image data") in result.output
45 | assert "Aborted with a BuildError!" in result.output
46 |
47 |
48 | @pytest.mark.usefixtures("kroki_dummy")
49 | def test_missing_from_file() -> None:
50 | # Arrange
51 | with MkDocsHelper("missing_from_file") as mkdocs_helper:
52 | mkdocs_helper.enable_fail_fast()
53 | # Act
54 | result = mkdocs_helper.invoke_build()
55 | # Assert
56 | assert result.exit_code == 1
57 | assert get_expected_log_line("Can't read file:") in result.output
58 | assert "Aborted with a BuildError!" in result.output
59 |
--------------------------------------------------------------------------------
/tests/test_fences.py:
--------------------------------------------------------------------------------
1 | from dataclasses import dataclass, field
2 |
3 | import pytest
4 | from pytest_mock import MockerFixture
5 | from result import Ok
6 |
7 | from kroki.common import KrokiImageContext
8 | from kroki.parsing import KrokiDiagramTypes, MarkdownParser
9 |
10 |
11 | @dataclass
12 | class StubInput:
13 | page_data: str
14 | expected_code_block_data: str = ""
15 | epxected_options: dict = field(default_factory=dict)
16 | expected_kroki_type: str = ""
17 |
18 |
19 | TEST_CASES = {
20 | "#35": StubInput(
21 | page_data="""
22 | ```` plantuml
23 | stuff containing ```
24 |
25 | ````
26 | """,
27 | expected_code_block_data="stuff containing ```\n\n",
28 | expected_kroki_type="plantuml",
29 | ),
30 | "https://pandoc.org/MANUAL.html#fenced-code-blocks": StubInput(
31 | page_data="""~~~~~~~~~~~~~~~~ mermaid
32 | ~~~~~~~~~~
33 | code including tildes
34 | ~~~~~~~~~~
35 | ~~~~~~~~~~~~~~~~""",
36 | expected_code_block_data="~~~~~~~~~~\ncode including tildes\n~~~~~~~~~~\n",
37 | expected_kroki_type="mermaid",
38 | ),
39 | "https://spec.commonmark.org/0.31.2/#example-119": StubInput(
40 | page_data="""
41 | ``` mermaid
42 | <
43 | >
44 | ```
45 | """,
46 | expected_code_block_data="<\n >\n",
47 | expected_kroki_type="mermaid",
48 | ),
49 | "https://spec.commonmark.org/0.31.2/#example-120": StubInput(
50 | page_data="""
51 | ~~~ mermaid
52 | <
53 | >
54 | ~~~
55 | """,
56 | expected_code_block_data="<\n >\n",
57 | expected_kroki_type="mermaid",
58 | ),
59 | "https://spec.commonmark.org/0.31.2/#example-122": StubInput(
60 | page_data="""
61 | ``` mermaid
62 | aaa
63 | ~~~
64 | ```
65 | """,
66 | expected_code_block_data="aaa\n~~~\n",
67 | expected_kroki_type="mermaid",
68 | ),
69 | "https://spec.commonmark.org/0.31.2/#example-123": StubInput(
70 | page_data="""
71 | ~~~ mermaid
72 | aaa
73 | ```
74 | ~~~
75 | """,
76 | expected_code_block_data="aaa\n```\n",
77 | expected_kroki_type="mermaid",
78 | ),
79 | "https://spec.commonmark.org/0.31.2/#example-125": StubInput(
80 | page_data="""
81 | ~~~~ mermaid
82 | aaa
83 | ~~~
84 | ~~~~
85 | """,
86 | expected_code_block_data="aaa\n~~~\n",
87 | expected_kroki_type="mermaid",
88 | ),
89 | "https://spec.commonmark.org/0.31.2/#example-129": StubInput(
90 | page_data="""
91 | ``` mermaid
92 |
93 |
94 | ```
95 | """,
96 | expected_code_block_data="\n\n",
97 | expected_kroki_type="mermaid",
98 | ),
99 | "https://spec.commonmark.org/0.31.2/#example-130": StubInput(
100 | page_data="""
101 | ``` mermaid
102 | ```
103 | """,
104 | expected_code_block_data="",
105 | expected_kroki_type="mermaid",
106 | ),
107 | "https://spec.commonmark.org/0.31.2/#example-147": StubInput(
108 | page_data="""
109 | ``` mermaid
110 | ``` aaa
111 | ```
112 | """,
113 | expected_code_block_data="``` aaa\n",
114 | expected_kroki_type="mermaid",
115 | ),
116 | }
117 |
118 | TEST_CASES_NOT_COMPLYING = {
119 | "https://spec.commonmark.org/0.31.2/#example-132": StubInput(
120 | page_data="""
121 | ``` mermaid
122 | aaa
123 | aaa
124 | aaa
125 | ```
126 | """,
127 | expected_code_block_data="aaa\n aaa\naaa\n", # "aaa\naaa\naaa\n",
128 | expected_kroki_type="mermaid",
129 | ),
130 | "https://spec.commonmark.org/0.31.2/#example-133": StubInput(
131 | page_data="""
132 | ``` mermaid
133 | aaa
134 | aaa
135 | aaa
136 | ```
137 | """,
138 | expected_code_block_data=" aaa\n aaa\naaa\n", # "aaa\n aaa\naaa\n",
139 | expected_kroki_type="mermaid",
140 | ),
141 | "https://spec.commonmark.org/0.31.2/#example-134": StubInput(
142 | page_data="""
143 | ``` mermaid
144 | aaa
145 | ```
146 | """,
147 | expected_code_block_data="aaa\n", # should not be replaced..
148 | expected_kroki_type="mermaid",
149 | ),
150 | }
151 |
152 | TEST_CASES_NOT_SUPPORTED = {
153 | "https://spec.commonmark.org/0.31.2/#example-121": StubInput(
154 | page_data="""
155 | `` mermaid
156 | foo
157 | ``
158 | """,
159 | expected_code_block_data="foo\n",
160 | expected_kroki_type="mermaid",
161 | ),
162 | "https://spec.commonmark.org/0.31.2/#example-124": StubInput(
163 | page_data="""
164 | ```` mermaid
165 | aaa
166 | ```
167 | ``````
168 | """,
169 | expected_code_block_data="aaa\n```\n",
170 | expected_kroki_type="mermaid",
171 | ),
172 | "https://spec.commonmark.org/0.31.2/#example-126": StubInput(
173 | page_data="""
174 | ``` mermaid
175 | """,
176 | expected_code_block_data="\n",
177 | expected_kroki_type="mermaid",
178 | ),
179 | "https://spec.commonmark.org/0.31.2/#example-127": StubInput(
180 | page_data="""
181 | ````` mermaid
182 |
183 | ```
184 | aaa
185 | """,
186 | expected_code_block_data="\n```\naaa\n",
187 | expected_kroki_type="mermaid",
188 | ),
189 | "https://spec.commonmark.org/0.31.2/#example-128": StubInput(
190 | page_data="""
191 | > ``` mermaid
192 | > aaa
193 |
194 | bbb
195 | """,
196 | expected_code_block_data="aaa\n",
197 | expected_kroki_type="mermaid",
198 | ),
199 | "https://spec.commonmark.org/0.31.2/#example-131": StubInput(
200 | page_data="""
201 | ``` mermaid
202 | aaa
203 | aaa
204 | ```
205 | """,
206 | expected_code_block_data="aaa\naaa\n",
207 | expected_kroki_type="mermaid",
208 | ),
209 | "https://spec.commonmark.org/0.31.2/#example-135": StubInput(
210 | page_data="""
211 | ```
212 | aaa
213 | ```
214 | """,
215 | expected_code_block_data="aaa\n",
216 | expected_kroki_type="mermaid",
217 | ),
218 | "https://spec.commonmark.org/0.31.2/#example-136": StubInput(
219 | page_data="""
220 | ```
221 | aaa
222 | ```
223 | """,
224 | expected_code_block_data="aaa\n",
225 | expected_kroki_type="mermaid",
226 | ),
227 | "https://spec.commonmark.org/0.31.2/#example-140": StubInput(
228 | page_data="""
229 | foo
230 | ```
231 | bar
232 | ```
233 | baz
234 | """,
235 | expected_code_block_data="bar\n",
236 | expected_kroki_type="mermaid",
237 | ),
238 | "https://spec.commonmark.org/0.31.2/#example-141": StubInput(
239 | page_data="""
240 | foo
241 | ---
242 | ~~~
243 | bar
244 | ~~~
245 | # baz
246 | """,
247 | expected_code_block_data="bar\n",
248 | expected_kroki_type="mermaid",
249 | ),
250 | "https://spec.commonmark.org/0.31.2/#example-146": StubInput(
251 | page_data="""
252 | ~~~ mermaid ``` ~~~
253 | foo
254 | ~~~
255 | """,
256 | expected_code_block_data="foo\n",
257 | expected_kroki_type="mermaid",
258 | ),
259 | }
260 |
261 |
262 | @pytest.mark.parametrize(
263 | "test_data",
264 | [pytest.param(v, id=k) for k, v in TEST_CASES.items()]
265 | + [pytest.param(v, id=k) for k, v in TEST_CASES_NOT_COMPLYING.items()],
266 | )
267 | def test_fences(test_data: StubInput, mock_kroki_diagram_types: KrokiDiagramTypes, mocker: MockerFixture) -> None:
268 | # Arrange
269 | parser = MarkdownParser("", mock_kroki_diagram_types)
270 | callback_stub = mocker.stub()
271 | context_stub = mocker.stub()
272 | # Act
273 | parser.replace_kroki_blocks(test_data.page_data, callback_stub, context_stub)
274 | # Assert
275 | callback_stub.assert_called_once_with(
276 | KrokiImageContext(
277 | kroki_type=test_data.expected_kroki_type,
278 | data=Ok(test_data.expected_code_block_data),
279 | options=test_data.epxected_options,
280 | ),
281 | context_stub,
282 | )
283 |
284 |
285 | @pytest.mark.parametrize("test_data", [pytest.param(v, id=k) for k, v in TEST_CASES_NOT_SUPPORTED.items()])
286 | def test_fences_not_supported(
287 | test_data: StubInput, mock_kroki_diagram_types: KrokiDiagramTypes, mocker: MockerFixture
288 | ) -> None:
289 | # Arrange
290 | parser = MarkdownParser("", mock_kroki_diagram_types)
291 | callback_stub = mocker.stub()
292 | context_stub = mocker.stub()
293 | # Act
294 | parser.replace_kroki_blocks(test_data.page_data, callback_stub, context_stub)
295 | # Assert
296 | callback_stub.assert_not_called()
297 |
--------------------------------------------------------------------------------
/tests/test_happy_path.py:
--------------------------------------------------------------------------------
1 | import bs4
2 | import pytest
3 |
4 | from tests.utils import MkDocsHelper
5 |
6 |
7 | @pytest.mark.usefixtures("kroki_dummy")
8 | def test_happy_path() -> None:
9 | # Arrange
10 | with MkDocsHelper("happy_path") as mkdocs_helper:
11 | mkdocs_helper.set_http_method("POST")
12 | # Act
13 | result = mkdocs_helper.invoke_build()
14 | # Assert
15 | assert result.exit_code == 0
16 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
17 | index_html = index_html_file.read()
18 |
19 | index_soup = bs4.BeautifulSoup(index_html)
20 | img_tags = index_soup.find_all("img", attrs={"alt": "Kroki"})
21 |
22 | assert len(img_tags) == 2
23 |
24 |
25 | @pytest.mark.usefixtures("kroki_dummy")
26 | def test_happy_path_object() -> None:
27 | # Arrange
28 | with MkDocsHelper("happy_path") as mkdocs_helper:
29 | mkdocs_helper.set_http_method("POST")
30 | mkdocs_helper.set_tag_format("object")
31 | # Act
32 | result = mkdocs_helper.invoke_build()
33 | # Assert
34 | assert result.exit_code == 0
35 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
36 | index_html = index_html_file.read()
37 |
38 | index_soup = bs4.BeautifulSoup(index_html)
39 | img_tags = index_soup.find_all("object", attrs={"id": "Kroki"})
40 |
41 | assert len(img_tags) == 2
42 |
43 |
44 | @pytest.mark.usefixtures("kroki_dummy")
45 | def test_happy_path_svg() -> None:
46 | # Arrange
47 | with MkDocsHelper("happy_path") as mkdocs_helper:
48 | mkdocs_helper.set_http_method("POST")
49 | mkdocs_helper.set_tag_format("svg")
50 | # Act
51 | result = mkdocs_helper.invoke_build()
52 | # Assert
53 | assert result.exit_code == 0
54 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html_file:
55 | index_html = index_html_file.read()
56 |
57 | index_soup = bs4.BeautifulSoup(index_html)
58 | img_tags = index_soup.find_all("svg", attrs={"id": "Kroki"})
59 |
60 | assert len(img_tags) == 2
61 |
--------------------------------------------------------------------------------
/tests/test_nested.py:
--------------------------------------------------------------------------------
1 | import bs4
2 | import pytest
3 |
4 | from tests.utils import MkDocsTemplateHelper
5 |
6 |
7 | @pytest.mark.usefixtures("kroki_dummy")
8 | def test_block_inside_html() -> None:
9 | # Arrange
10 | with MkDocsTemplateHelper("""
11 |
12 | Show Sequence diagram...
13 | ```mermaid
14 | graph TD
15 | a --> b
16 | ```
17 |
18 |
19 | ```mermaid
20 | graph TD
21 | a --> b
22 | ```
23 | """) as mkdocs_helper:
24 | mkdocs_helper.set_http_method("POST")
25 | # Act
26 | result = mkdocs_helper.invoke_build()
27 | # Assert
28 | assert result.exit_code == 0, f"exit code {result.exit_code}, expected 0"
29 | with open(mkdocs_helper.test_dir / "site/index.html") as index_html:
30 | index_soup = bs4.BeautifulSoup(index_html.read())
31 | assert len(index_soup.find_all("img", attrs={"alt": "Kroki"})) == 2, "not all images were included"
32 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pathlib
3 | import shutil
4 | import tempfile
5 | from contextlib import AbstractContextManager
6 | from pathlib import Path
7 | from string import Template
8 | from typing import Final, Literal
9 |
10 | import yaml
11 | from click.testing import CliRunner, Result
12 | from mkdocs.__main__ import build_command
13 |
14 | from kroki.logging import log
15 | from tests.compat import chdir
16 |
17 |
18 | def get_expected_log_line(log_msg: str) -> str:
19 | return f"{log.prefix}: {log_msg}"
20 |
21 |
22 | class NoPluginEntryError(ValueError):
23 | def __init__(self) -> None:
24 | super().__init__("No kroki plugin entry found")
25 |
26 |
27 | class MkDocsHelper(AbstractContextManager):
28 | class Context:
29 | def __init__(self, test_dir: Path) -> None:
30 | self.test_dir: Final[Path] = test_dir
31 | self.config_file_path: Final[Path] = test_dir / "mkdocs.yml"
32 | with open(self.test_dir / "mkdocs.yml") as file:
33 | self.config_file = yaml.safe_load(file)
34 |
35 | def _dump_config(self) -> None:
36 | with open(self.config_file_path, mode="w") as file:
37 | yaml.safe_dump(self.config_file, file)
38 |
39 | def _get_plugin_config_entry(self) -> dict:
40 | for plugin_entry in self.config_file["plugins"]:
41 | if "kroki" in plugin_entry:
42 | return plugin_entry["kroki"]
43 | raise NoPluginEntryError
44 |
45 | def enable_fail_fast(self) -> None:
46 | self._get_plugin_config_entry()["FailFast"] = True
47 |
48 | def set_http_method(self, method: Literal["GET", "POST"]) -> None:
49 | self._get_plugin_config_entry()["HttpMethod"] = method
50 |
51 | def set_fence_prefix(self, fence_prefix: str) -> None:
52 | self._get_plugin_config_entry()["FencePrefix"] = fence_prefix
53 |
54 | def set_tag_format(self, tag_format: Literal["img", "object", "svg"]) -> None:
55 | self._get_plugin_config_entry()["TagFormat"] = tag_format
56 |
57 | def invoke_build(self) -> Result:
58 | self._dump_config()
59 | runner = CliRunner()
60 | with chdir(self.test_dir):
61 | return runner.invoke(build_command)
62 |
63 | def __init__(self, test_case: str) -> None:
64 | self.test_case = test_case
65 | self.test_dir = Path(tempfile.mkdtemp())
66 |
67 | def _copy_test_case(self) -> None:
68 | # equals to `../data`, using current source file as a pin
69 | data_dir = pathlib.Path(os.path.realpath(__file__)).parent / "data"
70 | shutil.copytree(data_dir / self.test_case, self.test_dir, dirs_exist_ok=True)
71 |
72 | def __enter__(self) -> Context:
73 | self._copy_test_case()
74 | return MkDocsHelper.Context(self.test_dir)
75 |
76 | def __exit__(self, *_args) -> None:
77 | shutil.rmtree(self.test_dir)
78 |
79 |
80 | class MkDocsTemplateHelper(MkDocsHelper):
81 | def _substitute_code_block(self):
82 | with open(self.test_dir / "docs/index.md") as in_file:
83 | file_content = Template(in_file.read())
84 | with open(self.test_dir / "docs/index.md", "w") as out_file:
85 | out_file.write(file_content.substitute(code_block=self.code_block))
86 |
87 | def __init__(self, code_block: str) -> None:
88 | super().__init__("template")
89 | self.code_block = code_block
90 |
91 | def __enter__(self) -> MkDocsHelper.Context:
92 | context = super().__enter__()
93 | self._substitute_code_block()
94 | return context
95 |
--------------------------------------------------------------------------------