markdown-exec 1.10.1__py3-none-any.whl → 1.10.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. markdown_exec/__init__.py +50 -138
  2. markdown_exec/_internal/__init__.py +0 -0
  3. markdown_exec/{debug.py → _internal/debug.py} +13 -15
  4. markdown_exec/_internal/formatters/__init__.py +1 -0
  5. markdown_exec/{formatters → _internal/formatters}/_exec_python.py +1 -1
  6. markdown_exec/_internal/formatters/base.py +191 -0
  7. markdown_exec/_internal/formatters/bash.py +32 -0
  8. markdown_exec/_internal/formatters/console.py +29 -0
  9. markdown_exec/_internal/formatters/markdown.py +11 -0
  10. markdown_exec/_internal/formatters/pycon.py +26 -0
  11. markdown_exec/_internal/formatters/pyodide.py +73 -0
  12. markdown_exec/_internal/formatters/python.py +85 -0
  13. markdown_exec/_internal/formatters/sh.py +32 -0
  14. markdown_exec/_internal/formatters/tree.py +60 -0
  15. markdown_exec/_internal/logger.py +89 -0
  16. markdown_exec/_internal/main.py +123 -0
  17. markdown_exec/_internal/mkdocs_plugin.py +143 -0
  18. markdown_exec/_internal/processors.py +136 -0
  19. markdown_exec/_internal/rendering.py +280 -0
  20. markdown_exec/{pyodide.js → assets/pyodide.js} +12 -6
  21. markdown_exec/formatters/__init__.py +17 -1
  22. markdown_exec/formatters/base.py +12 -183
  23. markdown_exec/formatters/bash.py +10 -25
  24. markdown_exec/formatters/console.py +12 -24
  25. markdown_exec/formatters/markdown.py +11 -5
  26. markdown_exec/formatters/pycon.py +12 -24
  27. markdown_exec/formatters/pyodide.py +12 -65
  28. markdown_exec/formatters/python.py +11 -79
  29. markdown_exec/formatters/sh.py +10 -25
  30. markdown_exec/formatters/tree.py +12 -55
  31. markdown_exec/logger.py +12 -87
  32. markdown_exec/mkdocs_plugin.py +11 -135
  33. markdown_exec/processors.py +12 -118
  34. markdown_exec/rendering.py +11 -270
  35. {markdown_exec-1.10.1.dist-info → markdown_exec-1.10.2.dist-info}/METADATA +4 -3
  36. markdown_exec-1.10.2.dist-info/RECORD +42 -0
  37. markdown_exec-1.10.2.dist-info/entry_points.txt +7 -0
  38. markdown_exec-1.10.1.dist-info/RECORD +0 -26
  39. markdown_exec-1.10.1.dist-info/entry_points.txt +0 -7
  40. /markdown_exec/{ansi.css → assets/ansi.css} +0 -0
  41. /markdown_exec/{pyodide.css → assets/pyodide.css} +0 -0
  42. {markdown_exec-1.10.1.dist-info → markdown_exec-1.10.2.dist-info}/WHEEL +0 -0
  43. {markdown_exec-1.10.1.dist-info → markdown_exec-1.10.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,85 @@
1
+ # Formatter for executing Python code.
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ import traceback
8
+ from collections import defaultdict
9
+ from functools import partial
10
+ from io import StringIO
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+ from markdown_exec._internal.formatters._exec_python import exec_python
15
+ from markdown_exec._internal.formatters.base import ExecutionError, base_format
16
+ from markdown_exec._internal.rendering import code_block
17
+
18
+ _sessions_globals: dict[str, dict] = defaultdict(dict)
19
+ _sessions_counter: dict[str | None, int] = defaultdict(int)
20
+ _code_blocks: dict[str, list[str]] = {}
21
+
22
+
23
+ def _buffer_print(buffer: StringIO, *texts: str, end: str = "\n", **kwargs: Any) -> None: # noqa: ARG001
24
+ buffer.write(" ".join(str(text) for text in texts) + end)
25
+
26
+
27
+ def _code_block_id(
28
+ id: str | None = None, # noqa: A002
29
+ session: str | None = None,
30
+ title: str | None = None,
31
+ ) -> str:
32
+ _sessions_counter[session] += 1
33
+ if id:
34
+ code_block_id = f"id {id}"
35
+ elif session:
36
+ code_block_id = f"session {session}; n{_sessions_counter[session]}"
37
+ if title:
38
+ code_block_id = f"{code_block_id}; title {title}"
39
+ else:
40
+ code_block_id = f"n{_sessions_counter[session]}"
41
+ if title:
42
+ code_block_id = f"{code_block_id}; title {title}"
43
+ return f"<code block: {code_block_id}>"
44
+
45
+
46
+ def _run_python(
47
+ code: str,
48
+ returncode: int | None = None, # noqa: ARG001
49
+ session: str | None = None,
50
+ id: str | None = None, # noqa: A002
51
+ **extra: str,
52
+ ) -> str:
53
+ title = extra.get("title")
54
+ code_block_id = _code_block_id(id, session, title)
55
+ _code_blocks[code_block_id] = code.split("\n")
56
+ exec_globals = _sessions_globals[session] if session else {}
57
+
58
+ # Other libraries expect functions to have a valid `__module__` attribute.
59
+ # To achieve this, we need to add a `__name__` attribute to the globals.
60
+ # We compute the name from the code block ID, replacing invalid characters with `_`.
61
+ # We also create a module object with the same name and add it to `sys.modules`,
62
+ # because that's what yet other libraries expect (`dataclasses` for example).
63
+ module_name = re.sub(r"[^a-zA-Z\d]+", "_", code_block_id)
64
+ exec_globals["__name__"] = module_name
65
+ sys.modules[module_name] = ModuleType(module_name)
66
+
67
+ buffer = StringIO()
68
+ exec_globals["print"] = partial(_buffer_print, buffer)
69
+
70
+ try:
71
+ exec_python(code, code_block_id, exec_globals)
72
+ except Exception as error:
73
+ trace = traceback.TracebackException.from_exception(error)
74
+ for frame in trace.stack:
75
+ if frame.filename.startswith("<code block: "):
76
+ if sys.version_info >= (3, 13):
77
+ frame._lines = _code_blocks[frame.filename][frame.lineno - 1] # type: ignore[attr-defined,operator]
78
+ else:
79
+ frame._line = _code_blocks[frame.filename][frame.lineno - 1] # type: ignore[attr-defined,operator]
80
+ raise ExecutionError(code_block("python", "".join(trace.format()), **extra)) from error
81
+ return buffer.getvalue()
82
+
83
+
84
+ def _format_python(**kwargs: Any) -> str:
85
+ return base_format(language="python", run=_run_python, **kwargs)
@@ -0,0 +1,32 @@
1
+ # Formatter for executing shell code.
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from typing import Any
7
+
8
+ from markdown_exec._internal.formatters.base import ExecutionError, base_format
9
+ from markdown_exec._internal.rendering import code_block
10
+
11
+
12
+ def _run_sh(
13
+ code: str,
14
+ returncode: int | None = None,
15
+ session: str | None = None, # noqa: ARG001
16
+ id: str | None = None, # noqa: A002,ARG001
17
+ **extra: str,
18
+ ) -> str:
19
+ process = subprocess.run( # noqa: S603
20
+ ["sh", "-c", code], # noqa: S607
21
+ stdout=subprocess.PIPE,
22
+ stderr=subprocess.STDOUT,
23
+ text=True,
24
+ check=False,
25
+ )
26
+ if process.returncode != returncode:
27
+ raise ExecutionError(code_block("sh", process.stdout, **extra), process.returncode)
28
+ return process.stdout
29
+
30
+
31
+ def _format_sh(**kwargs: Any) -> str:
32
+ return base_format(language="sh", run=_run_sh, **kwargs)
@@ -0,0 +1,60 @@
1
+ # Formatter for file-system trees.
2
+
3
+ from __future__ import annotations
4
+
5
+ from textwrap import dedent
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from markdown_exec._internal.rendering import MarkdownConverter, code_block
9
+
10
+ if TYPE_CHECKING:
11
+ from markdown import Markdown
12
+
13
+
14
+ def _rec_build_tree(lines: list[str], parent: list, offset: int, base_indent: int) -> int:
15
+ while offset < len(lines):
16
+ line = lines[offset]
17
+ lstripped = line.lstrip()
18
+ indent = len(line) - len(lstripped)
19
+ if indent == base_indent:
20
+ parent.append((lstripped, []))
21
+ offset += 1
22
+ elif indent > base_indent:
23
+ offset = _rec_build_tree(lines, parent[-1][1], offset, indent)
24
+ else:
25
+ return offset
26
+ return offset
27
+
28
+
29
+ def _build_tree(code: str) -> list[tuple[str, list]]:
30
+ lines = dedent(code.strip()).split("\n")
31
+ root_layer: list[tuple[str, list]] = []
32
+ _rec_build_tree(lines, root_layer, 0, 0)
33
+ return root_layer
34
+
35
+
36
+ def _rec_format_tree(tree: list[tuple[str, list]], *, root: bool = True) -> list[str]:
37
+ lines = []
38
+ n_items = len(tree)
39
+ for index, node in enumerate(tree):
40
+ last = index == n_items - 1
41
+ prefix = "" if root else f"{'└' if last else '├'}── "
42
+ if node[1]:
43
+ lines.append(f"{prefix}📁 {node[0]}")
44
+ sublines = _rec_format_tree(node[1], root=False)
45
+ if root:
46
+ lines.extend(sublines)
47
+ else:
48
+ indent_char = " " if last else "│"
49
+ lines.extend([f"{indent_char} {line}" for line in sublines])
50
+ else:
51
+ name = node[0].split()[0]
52
+ icon = "📁" if name.endswith("/") else "📄"
53
+ lines.append(f"{prefix}{icon} {node[0]}")
54
+ return lines
55
+
56
+
57
+ def _format_tree(code: str, md: Markdown, result: str, **options: Any) -> str:
58
+ markdown = MarkdownConverter(md)
59
+ output = "\n".join(_rec_format_tree(_build_tree(code)))
60
+ return markdown.convert(code_block(result or "bash", output, **options.get("extra", {})))
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Callable, ClassVar
5
+
6
+
7
+ class _Logger:
8
+ _default_logger: Any = logging.getLogger
9
+ _instances: ClassVar[dict[str, _Logger]] = {}
10
+
11
+ # See same code in Griffe project.
12
+ def __init__(self, name: str) -> None:
13
+ # Default logger that can be patched by third-party.
14
+ self._logger = self.__class__._default_logger(name)
15
+
16
+ def __getattr__(self, name: str) -> Any:
17
+ # Forward everything to the logger.
18
+ return getattr(self._logger, name)
19
+
20
+ @classmethod
21
+ def get(cls, name: str) -> _Logger:
22
+ """Get a logger instance.
23
+
24
+ Parameters:
25
+ name: The logger name.
26
+
27
+ Returns:
28
+ The logger instance.
29
+ """
30
+ if name not in cls._instances:
31
+ cls._instances[name] = cls(name)
32
+ return cls._instances[name]
33
+
34
+ @classmethod
35
+ def _patch_loggers(cls, get_logger_func: Callable) -> None:
36
+ # Patch current instances.
37
+ for name, instance in cls._instances.items():
38
+ instance._logger = get_logger_func(name)
39
+ # Future instances will be patched as well.
40
+ cls._default_logger = get_logger_func
41
+
42
+
43
+ def get_logger(name: str) -> _Logger:
44
+ """Create and return a new logger instance.
45
+
46
+ Parameters:
47
+ name: The logger name.
48
+
49
+ Returns:
50
+ The logger.
51
+ """
52
+ return _Logger.get(name)
53
+
54
+
55
+ def patch_loggers(get_logger_func: Callable[[str], Any]) -> None:
56
+ """Patch loggers.
57
+
58
+ We provide the `patch_loggers`function so dependant libraries
59
+ can patch loggers as they see fit.
60
+
61
+ For example, to fit in the MkDocs logging configuration
62
+ and prefix each log message with the module name:
63
+
64
+ ```python
65
+ import logging
66
+ from markdown_exec.logger import patch_loggers
67
+
68
+
69
+ class LoggerAdapter(logging.LoggerAdapter):
70
+ def __init__(self, prefix, logger):
71
+ super().__init__(logger, {})
72
+ self.prefix = prefix
73
+
74
+ def process(self, msg, kwargs):
75
+ return f"{self.prefix}: {msg}", kwargs
76
+
77
+
78
+ def get_logger(name):
79
+ logger = logging.getLogger(f"mkdocs.plugins.{name}")
80
+ return LoggerAdapter(name.split(".", 1)[0], logger)
81
+
82
+
83
+ patch_loggers(get_logger)
84
+ ```
85
+
86
+ Parameters:
87
+ get_logger_func: A function accepting a name as parameter and returning a logger.
88
+ """
89
+ _Logger._patch_loggers(get_logger_func)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from markdown import Markdown
9
+
10
+ from markdown_exec._internal.formatters.base import default_tabs
11
+ from markdown_exec._internal.formatters.bash import _format_bash
12
+ from markdown_exec._internal.formatters.console import _format_console
13
+ from markdown_exec._internal.formatters.markdown import _format_markdown
14
+ from markdown_exec._internal.formatters.pycon import _format_pycon
15
+ from markdown_exec._internal.formatters.pyodide import _format_pyodide
16
+ from markdown_exec._internal.formatters.python import _format_python
17
+ from markdown_exec._internal.formatters.sh import _format_sh
18
+ from markdown_exec._internal.formatters.tree import _format_tree
19
+
20
+ MARKDOWN_EXEC_AUTO = [lang.strip() for lang in os.getenv("MARKDOWN_EXEC_AUTO", "").split(",")]
21
+ """Languages to automatically execute."""
22
+
23
+ formatters = {
24
+ "bash": _format_bash,
25
+ "console": _format_console,
26
+ "md": _format_markdown,
27
+ "markdown": _format_markdown,
28
+ "py": _format_python,
29
+ "python": _format_python,
30
+ "pycon": _format_pycon,
31
+ "pyodide": _format_pyodide,
32
+ "sh": _format_sh,
33
+ "tree": _format_tree,
34
+ }
35
+ """Formatters for each language."""
36
+
37
+ # negative look behind: matches only if | (pipe) if not preceded by \ (backslash)
38
+ _tabs_re = re.compile(r"(?<!\\)\|")
39
+
40
+
41
+ def validator(
42
+ language: str,
43
+ inputs: dict[str, str],
44
+ options: dict[str, Any],
45
+ attrs: dict[str, Any], # noqa: ARG001
46
+ md: Markdown, # noqa: ARG001
47
+ ) -> bool:
48
+ """Validate code blocks inputs.
49
+
50
+ Parameters:
51
+ language: The code language, like python or bash.
52
+ inputs: The code block inputs, to be sorted into options and attrs.
53
+ options: The container for options.
54
+ attrs: The container for attrs:
55
+ md: The Markdown instance.
56
+
57
+ Returns:
58
+ Success or not.
59
+ """
60
+ exec_value = language in MARKDOWN_EXEC_AUTO or _to_bool(inputs.pop("exec", "no"))
61
+ if language not in {"tree", "pyodide"} and not exec_value:
62
+ return False
63
+ id_value = inputs.pop("id", "")
64
+ id_prefix_value = inputs.pop("idprefix", None)
65
+ html_value = _to_bool(inputs.pop("html", "no"))
66
+ source_value = inputs.pop("source", "")
67
+ result_value = inputs.pop("result", "")
68
+ returncode_value = int(inputs.pop("returncode", "0"))
69
+ session_value = inputs.pop("session", "")
70
+ update_toc_value = _to_bool(inputs.pop("updatetoc", "yes"))
71
+ tabs_value = inputs.pop("tabs", "|".join(default_tabs))
72
+ tabs = tuple(_tabs_re.split(tabs_value, maxsplit=1))
73
+ workdir_value = inputs.pop("workdir", None)
74
+ width_value = int(inputs.pop("width", "0"))
75
+ options["id"] = id_value
76
+ options["id_prefix"] = id_prefix_value
77
+ options["html"] = html_value
78
+ options["source"] = source_value
79
+ options["result"] = result_value
80
+ options["returncode"] = returncode_value
81
+ options["session"] = session_value
82
+ options["update_toc"] = update_toc_value
83
+ options["tabs"] = tabs
84
+ options["workdir"] = workdir_value
85
+ options["width"] = width_value
86
+ options["extra"] = inputs
87
+ return True
88
+
89
+
90
+ def formatter(
91
+ source: str,
92
+ language: str,
93
+ css_class: str, # noqa: ARG001
94
+ options: dict[str, Any],
95
+ md: Markdown,
96
+ classes: list[str] | None = None, # noqa: ARG001
97
+ id_value: str = "", # noqa: ARG001
98
+ attrs: dict[str, Any] | None = None, # noqa: ARG001
99
+ **kwargs: Any, # noqa: ARG001
100
+ ) -> str:
101
+ """Execute code and return HTML.
102
+
103
+ Parameters:
104
+ source: The code to execute.
105
+ language: The code language, like python or bash.
106
+ css_class: The CSS class to add to the HTML element.
107
+ options: The container for options.
108
+ attrs: The container for attrs:
109
+ md: The Markdown instance.
110
+ classes: Additional CSS classes.
111
+ id_value: An optional HTML id.
112
+ attrs: Additional attributes
113
+ **kwargs: Additional arguments passed to SuperFences default formatters.
114
+
115
+ Returns:
116
+ HTML contents.
117
+ """
118
+ fmt = formatters.get(language, lambda source, **kwargs: source)
119
+ return fmt(code=source, md=md, **options) # type: ignore[operator]
120
+
121
+
122
+ def _to_bool(value: str) -> bool:
123
+ return value.lower() not in {"", "no", "off", "false", "0"}
@@ -0,0 +1,143 @@
1
+ # This module contains an optional plugin for MkDocs.
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from mkdocs.config import config_options
11
+ from mkdocs.config.base import Config
12
+ from mkdocs.exceptions import PluginError
13
+ from mkdocs.plugins import BasePlugin
14
+ from mkdocs.utils import write_file
15
+
16
+ from markdown_exec._internal.logger import patch_loggers
17
+ from markdown_exec._internal.main import formatter, formatters, validator
18
+ from markdown_exec._internal.rendering import MarkdownConverter, markdown_config
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import MutableMapping
22
+
23
+ from jinja2 import Environment
24
+ from mkdocs.config.defaults import MkDocsConfig
25
+ from mkdocs.structure.files import Files
26
+
27
+ try:
28
+ __import__("pygments_ansi_color")
29
+ except ImportError:
30
+ _ansi_ok = False
31
+ else:
32
+ _ansi_ok = True
33
+
34
+
35
+ class _LoggerAdapter(logging.LoggerAdapter):
36
+ def __init__(self, prefix: str, logger: logging.Logger) -> None:
37
+ super().__init__(logger, {})
38
+ self.prefix = prefix
39
+
40
+ def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, MutableMapping[str, Any]]:
41
+ return f"{self.prefix}: {msg}", kwargs
42
+
43
+
44
+ def _get_logger(name: str) -> _LoggerAdapter:
45
+ logger = logging.getLogger(f"mkdocs.plugins.{name}")
46
+ return _LoggerAdapter(name.split(".", 1)[0], logger)
47
+
48
+
49
+ patch_loggers(_get_logger)
50
+
51
+
52
+ class MarkdownExecPluginConfig(Config):
53
+ """Configuration of the plugin (for `mkdocs.yml`)."""
54
+
55
+ ansi = config_options.Choice(("auto", "off", "required", True, False), default="auto")
56
+ """Whether the `ansi` extra is required when installing the package."""
57
+ languages = config_options.ListOfItems(
58
+ config_options.Choice(formatters.keys()),
59
+ default=list(formatters.keys()),
60
+ )
61
+ """Which languages to enabled the extension for."""
62
+
63
+
64
+ class MarkdownExecPlugin(BasePlugin[MarkdownExecPluginConfig]):
65
+ """MkDocs plugin to easily enable custom fences for code blocks execution."""
66
+
67
+ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
68
+ """Configure the plugin.
69
+
70
+ Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
71
+ In this hook, we add custom fences for all the supported languages.
72
+
73
+ We also save the Markdown extensions configuration
74
+ into [`markdown_config`][markdown_exec.markdown_config].
75
+
76
+ Arguments:
77
+ config: The MkDocs config object.
78
+
79
+ Returns:
80
+ The modified config.
81
+ """
82
+ if "pymdownx.superfences" not in config["markdown_extensions"]:
83
+ message = "The 'markdown-exec' plugin requires the 'pymdownx.superfences' Markdown extension to work."
84
+ raise PluginError(message)
85
+ if self.config.ansi in ("required", True) and not _ansi_ok:
86
+ raise PluginError(
87
+ "The configuration for the 'markdown-exec' plugin requires "
88
+ "that it is installed with the 'ansi' extra. "
89
+ "Install it with 'pip install markdown-exec[ansi]'.",
90
+ )
91
+ self.mkdocs_config_dir = os.getenv("MKDOCS_CONFIG_DIR")
92
+ os.environ["MKDOCS_CONFIG_DIR"] = os.path.dirname(config["config_file_path"])
93
+ self.languages = self.config.languages
94
+ mdx_configs = config.setdefault("mdx_configs", {})
95
+ superfences = mdx_configs.setdefault("pymdownx.superfences", {})
96
+ custom_fences = superfences.setdefault("custom_fences", [])
97
+ for language in self.languages:
98
+ custom_fences.append(
99
+ {
100
+ "name": language,
101
+ "class": language,
102
+ "validator": validator,
103
+ "format": formatter,
104
+ },
105
+ )
106
+ markdown_config.save(config.markdown_extensions, config.mdx_configs)
107
+ return config
108
+
109
+ def on_env(
110
+ self,
111
+ env: Environment,
112
+ *,
113
+ config: MkDocsConfig,
114
+ files: Files, # noqa: ARG002
115
+ ) -> Environment | None:
116
+ """Add assets to the environment."""
117
+ if self.config.ansi in ("required", True) or (self.config.ansi == "auto" and _ansi_ok):
118
+ self._add_css(config, "ansi.css")
119
+ if "pyodide" in self.languages:
120
+ self._add_css(config, "pyodide.css")
121
+ self._add_js(config, "pyodide.js")
122
+ return env
123
+
124
+ def on_post_build(self, *, config: MkDocsConfig) -> None: # noqa: ARG002
125
+ """Reset the plugin state."""
126
+ MarkdownConverter.counter = 0
127
+ markdown_config.reset()
128
+ if self.mkdocs_config_dir is None:
129
+ os.environ.pop("MKDOCS_CONFIG_DIR", None)
130
+ else:
131
+ os.environ["MKDOCS_CONFIG_DIR"] = self.mkdocs_config_dir
132
+
133
+ def _add_asset(self, config: MkDocsConfig, asset_file: str, asset_type: str) -> None:
134
+ asset_filename = f"assets/_markdown_exec_{asset_file}"
135
+ asset_content = Path(__file__).parent.parent.joinpath("assets", asset_file).read_text()
136
+ write_file(asset_content.encode("utf-8"), os.path.join(config.site_dir, asset_filename))
137
+ config[f"extra_{asset_type}"].insert(0, asset_filename)
138
+
139
+ def _add_css(self, config: MkDocsConfig, css_file: str) -> None:
140
+ self._add_asset(config, css_file, "css")
141
+
142
+ def _add_js(self, config: MkDocsConfig, js_file: str) -> None:
143
+ self._add_asset(config, js_file, "javascript")
@@ -0,0 +1,136 @@
1
+ # This module contains a Markdown extension
2
+ # allowing to integrate generated headings into the ToC.
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import re
8
+ from typing import TYPE_CHECKING
9
+ from xml.etree.ElementTree import Element
10
+
11
+ from markdown.treeprocessors import Treeprocessor
12
+ from markdown.util import HTML_PLACEHOLDER_RE
13
+
14
+ if TYPE_CHECKING:
15
+ from markdown import Markdown
16
+ from markupsafe import Markup
17
+
18
+
19
+ # code taken from mkdocstrings, credits to @oprypin
20
+ class IdPrependingTreeprocessor(Treeprocessor):
21
+ """Prepend the configured prefix to IDs of all HTML elements."""
22
+
23
+ name = "markdown_exec_ids"
24
+ """The name of the treeprocessor."""
25
+
26
+ def __init__(self, md: Markdown, id_prefix: str) -> None:
27
+ super().__init__(md)
28
+ self.id_prefix = id_prefix
29
+ """The prefix to prepend to IDs."""
30
+
31
+ def run(self, root: Element) -> None:
32
+ """Run the treeprocessor."""
33
+ if not self.id_prefix:
34
+ return
35
+ for el in root.iter():
36
+ id_attr = el.get("id")
37
+ if id_attr:
38
+ el.set("id", self.id_prefix + id_attr)
39
+
40
+ href_attr = el.get("href")
41
+ if href_attr and href_attr.startswith("#"):
42
+ el.set("href", "#" + self.id_prefix + href_attr[1:])
43
+
44
+ name_attr = el.get("name")
45
+ if name_attr:
46
+ el.set("name", self.id_prefix + name_attr)
47
+
48
+ if el.tag == "label":
49
+ for_attr = el.get("for")
50
+ if for_attr:
51
+ el.set("for", self.id_prefix + for_attr)
52
+
53
+
54
+ # code taken from mkdocstrings, credits to @oprypin
55
+ class HeadingReportingTreeprocessor(Treeprocessor):
56
+ """Records the heading elements encountered in the document."""
57
+
58
+ name = "markdown_exec_record_headings"
59
+ """The name of the treeprocessor."""
60
+ regex = re.compile("[Hh][1-6]")
61
+ """The regex to match heading tags."""
62
+
63
+ def __init__(self, md: Markdown, headings: list[Element]):
64
+ super().__init__(md)
65
+ self.headings = headings
66
+ """The list of heading elements."""
67
+
68
+ def run(self, root: Element) -> None:
69
+ """Run the treeprocessor."""
70
+ for el in root.iter():
71
+ if self.regex.fullmatch(el.tag):
72
+ el = copy.copy(el) # noqa: PLW2901
73
+ # 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML.
74
+ # Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension.
75
+ if len(el) > 0 and el[-1].get("class") == self.md.treeprocessors["toc"].permalink_class: # type: ignore[attr-defined]
76
+ del el[-1]
77
+ self.headings.append(el)
78
+
79
+
80
+ class InsertHeadings(Treeprocessor):
81
+ """Our headings insertor."""
82
+
83
+ name = "markdown_exec_insert_headings"
84
+ """The name of the treeprocessor."""
85
+
86
+ def __init__(self, md: Markdown):
87
+ """Initialize the object.
88
+
89
+ Arguments:
90
+ md: A `markdown.Markdown` instance.
91
+ """
92
+ super().__init__(md)
93
+ self.headings: dict[Markup, list[Element]] = {}
94
+ """The dictionary of headings."""
95
+
96
+ def run(self, root: Element) -> None:
97
+ """Run the treeprocessor."""
98
+ if not self.headings:
99
+ return
100
+
101
+ for el in root.iter():
102
+ match = HTML_PLACEHOLDER_RE.match(el.text or "")
103
+ if match:
104
+ counter = int(match.group(1))
105
+ markup: Markup = self.md.htmlStash.rawHtmlBlocks[counter] # type: ignore[assignment]
106
+ if headings := self.headings.get(markup):
107
+ div = Element("div", {"class": "markdown-exec"})
108
+ div.extend(headings)
109
+ el.append(div)
110
+
111
+
112
+ class RemoveHeadings(Treeprocessor):
113
+ """Our headings remover."""
114
+
115
+ name = "markdown_exec_remove_headings"
116
+ """The name of the treeprocessor."""
117
+
118
+ def run(self, root: Element) -> None:
119
+ """Run the treeprocessor."""
120
+ self._remove_duplicated_headings(root)
121
+
122
+ def _remove_duplicated_headings(self, parent: Element) -> None:
123
+ carry_text = ""
124
+ for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration.
125
+ if el.tag == "div" and el.get("class") == "markdown-exec":
126
+ # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML).
127
+ carry_text = (el.text or "") + carry_text
128
+ parent.remove(el)
129
+ else:
130
+ if carry_text:
131
+ el.tail = (el.tail or "") + carry_text
132
+ carry_text = ""
133
+ self._remove_duplicated_headings(el)
134
+
135
+ if carry_text:
136
+ parent.text = (parent.text or "") + carry_text