├── .github ├── dependabot.yaml └── workflows │ ├── lint.yml │ ├── python-publish-test.yml │ ├── python-publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docker-compose.yml ├── kroki ├── __init__.py ├── client.py ├── common.py ├── config.py ├── diagram_types.py ├── logging.py ├── parsing.py ├── plugin.py └── render.py ├── pyproject.toml └── tests ├── __init__.py ├── compat.py ├── conftest.py ├── data ├── happy_path │ ├── docs │ │ ├── assets │ │ │ └── diagram.plantuml │ │ └── index.md │ └── mkdocs.yml ├── missing_from_file │ ├── docs │ │ └── index.md │ └── mkdocs.yml ├── techdocs │ ├── docs │ │ ├── assets │ │ │ └── diagram.plantuml │ │ └── index.md │ └── mkdocs.yml └── template │ ├── docs │ └── index.md │ └── mkdocs.yml ├── test_errors.py ├── test_errors_fail_fast.py ├── test_fences.py ├── test_happy_path.py ├── test_nested.py └── utils.py /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: weekly 8 | 9 | - package-ecosystem: pip 10 | directory: / 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Project lint 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - "*" 10 | 11 | pull_request: 12 | branches: 13 | - "*" 14 | 15 | env: 16 | FORCE_COLOR: "1" 17 | 18 | jobs: 19 | lint: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - uses: actions/setup-python@v5 26 | with: 27 | python-version: '3.x' 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install --upgrade hatch 32 | - name: Lint 33 | run: hatch fmt --check 34 | - name: Check types 35 | run: hatch run types:check 36 | -------------------------------------------------------------------------------- /.github/workflows/python-publish-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Hatch when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package to Test-Pypi 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - '**' 11 | - '!dependabot/**' 12 | 13 | env: 14 | FORCE_COLOR: "1" 15 | 16 | jobs: 17 | deploy-test: 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.x' 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install --upgrade hatch 31 | - name: Bump temp dev release 32 | run: | 33 | CURRENT_VERSION=$(NO_COLOR="1" hatch version) 34 | TIMESTAMP=$(date +%s) 35 | hatch version "$CURRENT_VERSION-$TIMESTAMP" 36 | - name: Build package 37 | run: hatch build 38 | - name: Publish package 39 | env: 40 | HATCH_INDEX_USER: __token__ 41 | HATCH_INDEX_AUTH: ${{ secrets.TEST_PYPI_API_TOKEN }} 42 | run: hatch publish --repo test 43 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Hatch when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | tags: 10 | - v[0-9].[0-9].[0-9] 11 | 12 | env: 13 | FORCE_COLOR: "1" 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install --upgrade hatch 30 | - name: Check project version 31 | run: | 32 | PROJECT_VERSION=$(TERM=dumb hatch version) 33 | 34 | if [ "$PROJECT_VERSION" != "${TAG_NAME#v}" ]; then 35 | echo "Wrong project version, cannot release: $PROJECT_VERSION, ${TAG_NAME#v} expected" 36 | exit 1 37 | fi 38 | env: 39 | TAG_NAME: ${{ github.ref }} 40 | - name: Build package 41 | run: hatch build 42 | - name: Publish package 43 | env: 44 | HATCH_INDEX_USER: __token__ 45 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_API_TOKEN }} 46 | run: hatch publish 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Project test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - "*" 10 | 11 | pull_request: 12 | branches: 13 | - "*" 14 | 15 | env: 16 | FORCE_COLOR: "1" 17 | 18 | jobs: 19 | test: 20 | 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | python: ["3.10", "3.11", "3.12"] 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python }} 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install --upgrade hatch 36 | - name: Test coverage 37 | run: hatch test --cover 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Pipenv 10 | Pipfile 11 | Pipfile.lock 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | #local dev 34 | .venv 35 | .vscode 36 | test-docs 37 | 38 | 39 | # pytest 40 | junit/ 41 | .coverage 42 | coverage.xml 43 | htmlcov 44 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.5.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - id: detect-private-key 12 | - repo: local 13 | hooks: 14 | - id: lint 15 | name: run the linter 16 | entry: hatch fmt -l 17 | language: system 18 | pass_filenames: false 19 | types: [ python ] 20 | - id: check-types 21 | name: run mypy 22 | entry: hatch run types:check 23 | language: system 24 | pass_filenames: false 25 | types: [ python ] 26 | - id: format 27 | name: run the formatter 28 | entry: hatch fmt -f 29 | language: system 30 | pass_filenames: false 31 | types: [ python ] 32 | - id: test 33 | name: run tests 34 | entry: hatch test -a 35 | language: system 36 | pass_filenames: false 37 | types: [ python ] 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Benjamin Bittner 2 | Copyright (c) 2024 Antonia Siegert 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mkdocs-kroki-plugin 2 | 3 | This is a MkDocs plugin to embed Kroki-Diagrams into your documentation. 4 | 5 | ## Setup 6 | 7 | Install the plugin using pip: 8 | 9 | `pip install mkdocs-kroki-plugin` 10 | 11 | Activate the plugin in `mkdocs.yml`: 12 | 13 | ```yaml 14 | plugins: 15 | ... 16 | - kroki: 17 | ``` 18 | 19 | ## Config 20 | 21 | | Key | Description | 22 | |---|---| 23 | | `ServerURL` | URL of your kroki-Server, default: `!ENV [KROKI_SERVER_URL, 'https://kroki.io']` | 24 | | `FencePrefix` | Diagram prefix, default: `kroki-` | 25 | | `EnableBlockDiag` | Enable BlockDiag (and the related Diagrams), default: `true` | 26 | | `EnableBpmn` | Enable BPMN, default: `true` | 27 | | `EnableExcalidraw` | Enable Excalidraw, default: `true` | 28 | | `EnableMermaid` | Enable Mermaid, default: `true` | 29 | | `EnableDiagramsnet` | Enable diagrams.net (draw.io), default: `false` | 30 | | `HttpMethod` | Http method to use (`GET` or `POST`), default: `GET`
__Note:__ On `POST` the retrieved images are stored next to the including page in the build directory | 31 | | `UserAgent` | User agent for requests to the kroki server, default: `kroki.plugin/` | 32 | | `FileTypes` | File types you want to use, default: `[svg]`
__Note:__ not all file formats work with all diagram types | 33 | | `FileTypeOverrides` | Overrides for specific diagram types to set the desired file type, default: empty | 34 | | `TagFormat` | How the image will be included in the resulting HTML, default: `img`
(`img`, `object`, `svg`) | 35 | | `FailFast` | Errors are raised as plugin errors, default: `false` | 36 | 37 | Example: 38 | ```yaml 39 | - kroki: 40 | ServerURL: !ENV [KROKI_SERVER_URL, 'https://kroki.io'] 41 | FileTypes: 42 | - png 43 | - svg 44 | FileTypeOverrides: 45 | mermaid: png 46 | FailFast: !ENV CI 47 | ``` 48 | 49 | ## Usage 50 | 51 | Use code-fences with a tag of kroki-`` to replace the code with the wanted diagram. 52 | 53 | [Diagram options](https://docs.kroki.io/kroki/setup/diagram-options/) can be set as well. 54 | 55 | Example for BlockDiag: 56 | 57 | ````markdown 58 | ```kroki-blockdiag no-transparency=false 59 | blockdiag { 60 | blockdiag -> generates -> "block-diagrams"; 61 | blockdiag -> is -> "very easy!"; 62 | 63 | blockdiag [color = "greenyellow"]; 64 | "block-diagrams" [color = "pink"]; 65 | "very easy!" [color = "orange"]; 66 | } 67 | ``` 68 | ```` 69 | 70 | You can render diagram from file with `@from_file:` directive: 71 | 72 | ````markdown 73 | ```kroki-bpmn 74 | @from_file:path/to/diagram.bpmn 75 | ``` 76 | ```` 77 | 78 | ## See Also 79 | 80 | Diagram examples can be found [here](https://kroki.io/examples.html). 81 | 82 | More information about installing a self-manged Kroki-Service [here](https://docs.kroki.io/kroki/setup/install/). 83 | 84 | More Plugins for MkDocs can be found [here](http://www.mkdocs.org/user-guide/plugins/) 85 | 86 | ## Pre-Release-Versions 87 | 88 | Install the newest pre-release version using pip: 89 | 90 | `pip install -i https://test.pypi.org/simple/ mkdocs-kroki-plugin` 91 | 92 | 93 | ## Development 94 | 95 | Setup: 96 | 97 | ```sh 98 | git clone git@github.com:AVATEAM-IT-SYSTEMHAUS/mkdocs-kroki-plugin.git 99 | cd mkdocs-kroki-plugin 100 | pipx install hatch 101 | pipx install pre-commit 102 | pre-commit install 103 | ``` 104 | 105 | Run tests (for all supported python versions): 106 | 107 | ```sh 108 | hatch test -a 109 | ``` 110 | 111 | Run static code analysis: 112 | 113 | ```sh 114 | hatch fmt 115 | ``` 116 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | kroki: 3 | image: docker.io/yuzutech/kroki 4 | depends_on: 5 | - mermaid 6 | - bpmn 7 | - excalidraw 8 | environment: 9 | - KROKI_MERMAID_HOST=mermaid 10 | - KROKI_BPMN_HOST=bpmn 11 | - KROKI_EXCALIDRAW_HOST=excalidraw 12 | ports: 13 | - "127.0.0.1:8080:8000" 14 | mermaid: 15 | image: docker.io/yuzutech/kroki-mermaid 16 | bpmn: 17 | image: docker.io/yuzutech/kroki-bpmn 18 | excalidraw: 19 | image: docker.io/yuzutech/kroki-excalidraw 20 | -------------------------------------------------------------------------------- /kroki/__init__.py: -------------------------------------------------------------------------------- 1 | version = "0.8.1" 2 | -------------------------------------------------------------------------------- /kroki/client.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import textwrap 3 | import zlib 4 | from os import makedirs, path 5 | from typing import Final 6 | from uuid import NAMESPACE_OID, uuid3 7 | 8 | import requests 9 | from result import Err, Ok, Result 10 | 11 | from kroki.common import ErrorResult, ImageSrc, KrokiImageContext, MkDocsEventContext, MkDocsFile 12 | from kroki.diagram_types import KrokiDiagramTypes 13 | from kroki.logging import log 14 | 15 | MAX_URI_SIZE: Final[int] = 4096 16 | FILE_PREFIX: Final[str] = "kroki-generated-" 17 | 18 | 19 | class DownloadedContent: 20 | def _ugly_temp_excalidraw_fix(self) -> None: 21 | """TODO: remove me, when excalidraw container works again.. 22 | ref: https://github.com/excalidraw/excalidraw/issues/7366""" 23 | self.file_content: bytes = self.file_content.replace( 24 | b"https://unpkg.com/@excalidraw/excalidraw@undefined/dist", 25 | b"https://unpkg.com/@excalidraw/excalidraw@0.17.1/dist", 26 | ) 27 | 28 | def __init__(self, file_content: bytes, file_extension: str, additional_metadata: None | dict) -> None: 29 | file_uuid = uuid3(NAMESPACE_OID, f"{additional_metadata}{file_content!r}") 30 | 31 | self.file_name = f"{FILE_PREFIX}{file_uuid}.{file_extension}" 32 | self.file_content = file_content 33 | self._ugly_temp_excalidraw_fix() 34 | 35 | def save(self, context: MkDocsEventContext) -> None: 36 | # wherever MkDocs wants to host or build, we plant the image next 37 | # to the generated static page 38 | page_abs_dest_dir = path.dirname(context.page.file.abs_dest_path) 39 | makedirs(page_abs_dest_dir, exist_ok=True) 40 | 41 | file_path = path.join(page_abs_dest_dir, self.file_name) 42 | 43 | log.debug("Saving downloaded data: %s", file_path) 44 | with open(file_path, "wb") as file: 45 | file.write(self.file_content) 46 | 47 | # make MkDocs believe that the file was present from the beginning 48 | file_src_uri = path.join(path.dirname(context.page.file.src_uri), self.file_name) 49 | file_dest_uri = path.join(path.dirname(context.page.file.dest_uri), self.file_name) 50 | 51 | dummy_file = MkDocsFile( 52 | path=file_src_uri, 53 | src_dir="", 54 | dest_dir="", 55 | use_directory_urls=False, 56 | dest_uri=file_dest_uri, 57 | ) 58 | # MkDocs will not copy the file in this case 59 | dummy_file.abs_src_path = dummy_file.abs_dest_path = file_path 60 | 61 | log.debug("Appending dummy mkdocs file: %s", dummy_file) 62 | context.files.append(dummy_file) 63 | 64 | 65 | class KrokiClient: 66 | def __init__(self, server_url: str, http_method: str, user_agent: str, diagram_types: KrokiDiagramTypes) -> None: 67 | self.server_url = server_url 68 | self.http_method = http_method 69 | self.headers = {"User-Agent": user_agent} 70 | self.diagram_types = diagram_types 71 | 72 | log.debug("Client initialized [http_method: %s, server_url: %s]", self.http_method, self.server_url) 73 | 74 | def _kroki_url_base(self, kroki_type: str) -> str: 75 | return f"{self.server_url}/{kroki_type}" 76 | 77 | def _get_file_ext(self, kroki_type: str) -> str: 78 | return self.diagram_types.get_file_ext(kroki_type) 79 | 80 | def _kroki_url_get(self, kroki_context: KrokiImageContext) -> Result[ImageSrc, ErrorResult]: 81 | kroki_data_param = base64.urlsafe_b64encode(zlib.compress(str.encode(kroki_context.data.unwrap()), 9)).decode() 82 | 83 | kroki_query_param = ( 84 | "&".join([f"{k}={v}" for k, v in kroki_context.options.items()]) if len(kroki_context.options) > 0 else "" 85 | ) 86 | 87 | kroki_endpoint = self._kroki_url_base(kroki_type=kroki_context.kroki_type) 88 | file_ext = self._get_file_ext(kroki_context.kroki_type) 89 | image_url = f"{kroki_endpoint}/{file_ext}/{kroki_data_param}?{kroki_query_param}" 90 | if len(image_url) >= MAX_URI_SIZE: 91 | log.warning("Kroki may not be able to read the data completely! [data_len: %i]", len(image_url)) 92 | 93 | log.debug("Image url: %s", textwrap.shorten(image_url, 50)) 94 | return Ok(ImageSrc(url=image_url, file_ext=file_ext)) 95 | 96 | def _kroki_post( 97 | self, kroki_context: KrokiImageContext, context: MkDocsEventContext 98 | ) -> Result[ImageSrc, ErrorResult]: 99 | kroki_endpoint = self._kroki_url_base(kroki_context.kroki_type) 100 | file_ext = self._get_file_ext(kroki_context.kroki_type) 101 | url = f"{kroki_endpoint}/{file_ext}" 102 | 103 | log.debug("POST %s", textwrap.shorten(url, 50)) 104 | try: 105 | response = requests.post( 106 | url, 107 | headers=self.headers, 108 | json={ 109 | "diagram_source": kroki_context.data.unwrap(), 110 | "diagram_options": kroki_context.options, 111 | }, 112 | timeout=10, 113 | stream=True, 114 | ) 115 | except requests.RequestException as error: 116 | return Err(ErrorResult(err_msg=f"Request error [url:{url}]: {error}", error=error)) 117 | 118 | if response.status_code == requests.codes.ok: 119 | downloaded_image = DownloadedContent( 120 | response.content, 121 | file_ext, 122 | kroki_context.options, 123 | ) 124 | downloaded_image.save(context) 125 | return Ok( 126 | ImageSrc( 127 | url=downloaded_image.file_name, 128 | file_ext=file_ext, 129 | file_content=downloaded_image.file_content, 130 | ) 131 | ) 132 | 133 | if response.status_code == requests.codes.bad_request: 134 | return Err(ErrorResult(err_msg="Diagram error!", response_text=response.text)) 135 | 136 | return Err( 137 | ErrorResult(err_msg=f"Could not retrieve image data, got: {response.reason} [{response.status_code}]") 138 | ) 139 | 140 | def get_image_url( 141 | self, kroki_context: KrokiImageContext, context: MkDocsEventContext 142 | ) -> Result[ImageSrc, ErrorResult]: 143 | if self.http_method == "GET": 144 | return self._kroki_url_get(kroki_context) 145 | 146 | return self._kroki_post(kroki_context, context) 147 | -------------------------------------------------------------------------------- /kroki/common.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from mkdocs.config.defaults import MkDocsConfig as _MkDocsConfig 4 | from mkdocs.structure.files import File, Files 5 | from mkdocs.structure.pages import Page 6 | from result import Result 7 | 8 | MkDocsPage = Page 9 | MkDocsConfig = _MkDocsConfig 10 | MkDocsFiles = Files 11 | MkDocsFile = File 12 | 13 | 14 | @dataclass 15 | class ImageSrc: 16 | """Information for the renderer.""" 17 | 18 | url: str 19 | file_ext: str 20 | file_content: None | bytes = None 21 | 22 | 23 | @dataclass 24 | class ErrorResult: 25 | err_msg: str 26 | error: None | Exception = None 27 | response_text: None | str = None 28 | 29 | 30 | @dataclass 31 | class MkDocsEventContext: 32 | """Data supplied by MkDocs on the currently handled page.""" 33 | 34 | page: MkDocsPage 35 | config: MkDocsConfig 36 | files: MkDocsFiles 37 | 38 | 39 | @dataclass 40 | class KrokiImageContext: 41 | """Code block contents that are to be used for the diagram generation.""" 42 | 43 | kroki_type: str 44 | options: dict 45 | data: Result[str, ErrorResult] 46 | -------------------------------------------------------------------------------- /kroki/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from mkdocs.config import config_options 4 | from mkdocs.config.base import ( 5 | Config as MkDocsBaseConfig, 6 | ) 7 | from mkdocs.config.base import ( 8 | ConfigErrors as MkDocsConfigErrors, 9 | ) 10 | from mkdocs.config.base import ( 11 | ConfigWarnings as MkDocsConfigWarnings, 12 | ) 13 | 14 | from kroki import version 15 | from kroki.logging import log 16 | 17 | 18 | class DeprecatedDownloadImagesCompat(config_options.Deprecated): 19 | def pre_validation(self, config: MkDocsBaseConfig, key_name: str) -> None: 20 | """Set `HttpMethod: 'POST'`, if enabled""" 21 | if not isinstance(config, KrokiPluginConfig): 22 | return 23 | 24 | if config.get(key_name) is None: 25 | return 26 | 27 | self.warnings.append(self.message.format(key_name)) 28 | 29 | download_images: bool = config.pop(key_name) 30 | if download_images: 31 | config.HttpMethod = "POST" 32 | 33 | 34 | class KrokiPluginConfig(MkDocsBaseConfig): 35 | ServerURL = config_options.URL(default=os.getenv("KROKI_SERVER_URL", "https://kroki.io")) 36 | EnableBlockDiag = config_options.Type(bool, default=True) 37 | EnableBpmn = config_options.Type(bool, default=True) 38 | EnableExcalidraw = config_options.Type(bool, default=True) 39 | EnableMermaid = config_options.Type(bool, default=True) 40 | EnableDiagramsnet = config_options.Type(bool, default=False) 41 | HttpMethod = config_options.Choice(choices=["GET", "POST"], default="GET") 42 | UserAgent = config_options.Type(str, default=f"{__name__}/{version}") 43 | FencePrefix = config_options.Type(str, default="kroki-") 44 | FileTypes = config_options.Type(list, default=["svg"]) 45 | FileTypeOverrides = config_options.Type(dict, default={}) 46 | TagFormat = config_options.Choice(choices=["img", "object", "svg"], default="img") 47 | FailFast = config_options.Type(bool, default=False) 48 | 49 | DownloadImages = DeprecatedDownloadImagesCompat(moved_to="HttpMethod: 'POST'") 50 | Enablebpmn = config_options.Deprecated(moved_to="EnableBpmn") 51 | DownloadDir = config_options.Deprecated(removed=True) 52 | 53 | def validate(self) -> tuple[MkDocsConfigErrors, MkDocsConfigWarnings]: 54 | result = super().validate() 55 | 56 | if self["TagFormat"] == "svg" and self["HttpMethod"] != "POST": 57 | log.info("Setting Http method to POST to retrieve svg data for inlining.") 58 | self["HttpMethod"] = "POST" 59 | 60 | return result 61 | -------------------------------------------------------------------------------- /kroki/diagram_types.py: -------------------------------------------------------------------------------- 1 | from collections import ChainMap 2 | from typing import ClassVar 3 | 4 | from mkdocs.exceptions import PluginError 5 | 6 | from kroki.logging import log 7 | 8 | 9 | class KrokiDiagramTypes: 10 | _base_diagrams: ClassVar[dict[str, list[str]]] = { 11 | "bytefield": ["svg"], 12 | "ditaa": ["png", "svg"], 13 | "erd": ["png", "svg", "jpeg", "pdf"], 14 | "graphviz": ["png", "svg", "jpeg", "pdf"], 15 | "nomnoml": ["svg"], 16 | "plantuml": ["png", "svg", "jpeg", "base64"], 17 | "structurizr": ["png", "svg"], 18 | "c4plantuml": ["png", "svg", "jpeg", "base64"], 19 | "svgbob": ["svg"], 20 | "vega": ["png", "svg", "pdf"], 21 | "vegalite": ["png", "svg", "pdf"], 22 | "wavedrom": ["svg"], 23 | "pikchr": ["svg"], 24 | "umlet": ["png", "svg"], 25 | "d2": ["svg"], 26 | "dbml": ["svg"], 27 | "tikz": ["png", "svg", "jpeg", "pdf"], 28 | "symbolator": ["svg"], 29 | "wireviz": ["png", "svg"], 30 | } 31 | 32 | _blockdiag: ClassVar[dict[str, list[str]]] = { 33 | "blockdiag": ["png", "svg", "pdf"], 34 | "seqdiag": ["png", "svg", "pdf"], 35 | "actdiag": ["png", "svg", "pdf"], 36 | "nwdiag": ["png", "svg", "pdf"], 37 | "packetdiag": ["png", "svg", "pdf"], 38 | "rackdiag": ["png", "svg", "pdf"], 39 | } 40 | 41 | _bpmn: ClassVar[dict[str, list[str]]] = { 42 | "bpmn": ["svg"], 43 | } 44 | 45 | _excalidraw: ClassVar[dict[str, list[str]]] = { 46 | "excalidraw": ["svg"], 47 | } 48 | 49 | _mermaid: ClassVar[dict[str, list[str]]] = { 50 | "mermaid": ["png", "svg"], 51 | } 52 | 53 | _diagramsnet: ClassVar[dict[str, list[str]]] = { 54 | "diagramsnet": ["svg"], 55 | } 56 | 57 | def __init__( 58 | self, 59 | fence_prefix: str, 60 | file_types: list[str], 61 | file_type_overrides: dict[str, str], 62 | *, 63 | blockdiag_enabled: bool, 64 | bpmn_enabled: bool, 65 | excalidraw_enabled: bool, 66 | mermaid_enabled: bool, 67 | diagramsnet_enabled: bool, 68 | ): 69 | self._fence_prefix = fence_prefix 70 | 71 | diagram_type_file_ext_map = ChainMap( 72 | self._base_diagrams, 73 | self._blockdiag if blockdiag_enabled else {}, 74 | self._bpmn if bpmn_enabled else {}, 75 | self._excalidraw if excalidraw_enabled else {}, 76 | self._mermaid if mermaid_enabled else {}, 77 | self._diagramsnet if diagramsnet_enabled else {}, 78 | ) 79 | 80 | self._file_ext_mapping: dict[str, str] = self._get_file_ext_mapping( 81 | diagram_type_file_ext_map, file_types, file_type_overrides 82 | ) 83 | 84 | log.debug("File and Diagram types configured: %s", self._file_ext_mapping) 85 | 86 | def _get_file_ext_mapping( 87 | self, 88 | diagram_type_file_ext_map: ChainMap[str, list[str]], 89 | file_types: list[str], 90 | file_type_overrides: dict[str, str], 91 | ) -> dict[str, str]: 92 | def get_file_type(diagram_type: str) -> str: 93 | supported_file_types = diagram_type_file_ext_map[diagram_type] 94 | file_type_override = file_type_overrides.get(diagram_type) 95 | if file_type_override is not None: 96 | if file_type_override not in supported_file_types: 97 | err_msg = ( 98 | f"{diagram_type}: {file_type_override} not in supported file types: " f"{supported_file_types}" 99 | ) 100 | raise PluginError(err_msg) 101 | return file_type_override 102 | 103 | target_file_type = next((t for t in file_types if t in supported_file_types), None) 104 | if target_file_type is None: 105 | err_msg = ( 106 | f"{diagram_type}: Not able to satisfy any of {file_types}, " 107 | f"supported file types: {supported_file_types}" 108 | ) 109 | raise PluginError(err_msg) 110 | 111 | return target_file_type 112 | 113 | return {diagram_type: get_file_type(diagram_type) for diagram_type in diagram_type_file_ext_map} 114 | 115 | def get_file_ext(self, kroki_type: str) -> str: 116 | return self._file_ext_mapping[kroki_type] 117 | 118 | def get_kroki_type(self, block_type: None | str) -> str | None: 119 | if block_type is None: 120 | return None 121 | if not block_type.startswith(self._fence_prefix): 122 | return None 123 | diagram_type = block_type.removeprefix(self._fence_prefix).lower() 124 | if diagram_type not in self._file_ext_mapping: 125 | return None 126 | 127 | return diagram_type 128 | -------------------------------------------------------------------------------- /kroki/logging.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from mkdocs.plugins import get_plugin_logger 4 | 5 | log = get_plugin_logger(__name__) 6 | 7 | _COLOR_PURPLE: Final[str] = "\x1b[35;1m" 8 | _COLOR_RESET: Final[str] = "\x1b[0m" 9 | 10 | log.prefix = f"{_COLOR_PURPLE}{log.prefix}{_COLOR_RESET}" 11 | -------------------------------------------------------------------------------- /kroki/parsing.py: -------------------------------------------------------------------------------- 1 | import re 2 | import textwrap 3 | from collections.abc import Callable 4 | from pathlib import Path 5 | from typing import Final 6 | 7 | from result import Err, Ok, Result 8 | 9 | from kroki.common import ErrorResult, KrokiImageContext, MkDocsEventContext 10 | from kroki.diagram_types import KrokiDiagramTypes 11 | from kroki.logging import log 12 | 13 | 14 | class MarkdownParser: 15 | from_file_prefix: Final[str] = "@from_file:" 16 | _FENCE_RE = re.compile( 17 | r"(?P^(?P[ ]*)(?:````*|~~~~*))[ ]*" 18 | r"(\.?(?P[\w#.+-]*)[ ]*)?" 19 | r"(?P(?:[ ]?[a-zA-Z0-9\-_]+=[a-zA-Z0-9\-_]+)*)\n" 20 | r"(?P.*?)(?<=\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'Kroki' 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"dummy data") 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 | --------------------------------------------------------------------------------