diff --git a/src/markdown_exec/markdown_helpers.py b/src/markdown_exec/markdown_helpers.py deleted file mode 100644 index 4e3bfb0..0000000 --- a/src/markdown_exec/markdown_helpers.py +++ /dev/null @@ -1,37 +0,0 @@ -"""This module contains helpers to generate Markdown contents.""" - -from __future__ import annotations - -from textwrap import indent - - -def code_block(language: str, source: str, **options: str) -> str: - """Format source as a code block. - - Parameters: - language: The code block language. - source: The source code to format. - **options: Additional options passed from the source, to add back to the generated code block. - - Returns: - The formatted code block. - """ - opts = " ".join(f'{opt_name}="{opt_value}"' for opt_name, opt_value in options.items()) - return f"```{language} {opts}\n{source}\n```" - - -def tabbed(*tabs: tuple[str, str]) -> str: - """Format tabs using `pymdownx.tabbed` extension. - - Parameters: - *tabs: Tuples of strings: title and text. - - Returns: - The formatted tabs. - """ - parts = [] - for title, text in tabs: - parts.append(f'=== "{title}"') - parts.append(indent(text, prefix=" " * 4)) - parts.append("") - return "\n".join(parts) diff --git a/src/markdown_exec/python.py b/src/markdown_exec/python.py index 08c57c3..045f139 100644 --- a/src/markdown_exec/python.py +++ b/src/markdown_exec/python.py @@ -7,7 +7,7 @@ from markdown.core import Markdown from markupsafe import Markup -from markdown_exec.markdown_helpers import code_block, tabbed +from markdown_exec.rendering import code_block, markdown, tabbed md_copy = None @@ -63,10 +63,8 @@ def exec_python( # noqa: WPS231 Returns: HTML contents. """ - global md_copy # noqa: WPS420 - if md_copy is None: - md_copy = Markdown() # noqa: WPS442 - md_copy.registerExtensions(md.registeredExtensions, {}) + markdown.mimic(md) + if isolate: exec_source = f"def _function():\n{indent(source, prefix=' ' * 4)}\n_function()\n" else: @@ -90,4 +88,4 @@ def exec_python( # noqa: WPS231 output = tabbed(("Source", source_block), ("Result", output)) elif show_source == "tabbed-right": output = tabbed(("Result", output), ("Source", source_block)) - return Markup(md_copy.convert(output)) + return markdown.convert(output) diff --git a/src/markdown_exec/rendering.py b/src/markdown_exec/rendering.py new file mode 100644 index 0000000..f1e3840 --- /dev/null +++ b/src/markdown_exec/rendering.py @@ -0,0 +1,119 @@ +"""Markdown extensions and helpers.""" + +from __future__ import annotations + +from textwrap import indent +from xml.etree.ElementTree import Element + +from markdown import Markdown +from markdown.treeprocessors import Treeprocessor +from markupsafe import Markup + + +def code_block(language: str, code: str, **options: str) -> str: + """Format code as a code block. + + Parameters: + language: The code block language. + code: The source code to format. + **options: Additional options passed from the source, to add back to the generated code block. + + Returns: + The formatted code block. + """ + opts = " ".join(f'{opt_name}="{opt_value}"' for opt_name, opt_value in options.items()) + return f"```{language} {opts}\n{code}\n```" + + +def tabbed(*tabs: tuple[str, str]) -> str: + """Format tabs using `pymdownx.tabbed` extension. + + Parameters: + *tabs: Tuples of strings: title and text. + + Returns: + The formatted tabs. + """ + parts = [] + for title, text in tabs: + title = title.replace("\\|", "|").strip() + parts.append(f'=== "{title}"') + parts.append(indent(text, prefix=" " * 4)) + parts.append("") + return "\n".join(parts) + + +# code taken from mkdocstrings, credits to @oprypin +class _IdPrependingTreeprocessor(Treeprocessor): + """Prepend the configured prefix to IDs of all HTML elements.""" + + name = "markdown_exec_ids" + + def __init__(self, md: Markdown, id_prefix: str): # noqa: D107 + super().__init__(md) + self.id_prefix = id_prefix + + def run(self, root: Element): # noqa: D102,WPS231 + if not self.id_prefix: + return + for el in root.iter(): + id_attr = el.get("id") + if id_attr: + el.set("id", self.id_prefix + id_attr) + + href_attr = el.get("href") + if href_attr and href_attr.startswith("#"): + el.set("href", "#" + self.id_prefix + href_attr[1:]) + + name_attr = el.get("name") + if name_attr: + el.set("name", self.id_prefix + name_attr) + + if el.tag == "label": + for_attr = el.get("for") + if for_attr: + el.set("for", self.id_prefix + for_attr) + + +class _MarkdownConverter: + """Helper class to avoid breaking the original Markdown instance state.""" + + def __init__(self) -> None: # noqa: D107 + self.md: Markdown = None + self.counter: int = 0 + + def mimic(self, md: Markdown) -> None: + """Mimic the passed Markdown instance by registering the same extensions. + + Parameters: + md: A Markdown instance. + """ + if self.md is None: + self.md = Markdown() # noqa: WPS442 + self.md.registerExtensions(md.registeredExtensions + ["pymdownx.extra"], {}) + self.md.treeprocessors.register( + _IdPrependingTreeprocessor(md, ""), + _IdPrependingTreeprocessor.name, + priority=4, # right after 'toc' (needed because that extension adds ids to headers) + ) + + def convert(self, text: str) -> Markup: + """Convert Markdown text to safe HTML. + + Parameters: + text: Markdown text. + + Returns: + Safe HTML. + """ + self.md.treeprocessors[_IdPrependingTreeprocessor.name].id_prefix = f"exec-{self.counter}--" + self.counter += 1 + + try: # noqa: WPS501 + return Markup(self.md.convert(text)) + finally: + self.md.treeprocessors[_IdPrependingTreeprocessor.name].id_prefix = "" + + +# provide a singleton +markdown = _MarkdownConverter()