diff --git a/.changelog/_unreleased.toml b/.changelog/_unreleased.toml index dcf1f154..a53c4414 100644 --- a/.changelog/_unreleased.toml +++ b/.changelog/_unreleased.toml @@ -25,3 +25,13 @@ type = "fix" description = "Fix `escape_except_blockquotes` option for greater than 9 blockquotes in a docstring" author = "@jackgerrits" pr = "https://github.com/NiklasRosenstein/pydoc-markdown/pull/317" + +[[entries]] +id = "f3ee1a25-3e4d-4917-b3b9-7ace18f6f9f0" +type = "improvement" +description = "Add option to escape { in markdown for compatibility with MDX >= 2" +author = "qsteenhuis@gmail.com" +pr = "https://github.com/NiklasRosenstein/pydoc-markdown/pull/326" +issues = [ + "https://github.com/nonprofittechy/pydoc-markdown/issues/325", +] diff --git a/pyproject.toml b/pyproject.toml index 5528a1f0..c1d0f1a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,7 @@ Homepage = "https://github.com/NiklasRosenstein/pydoc-markdown" [tool.poetry.dependencies] python = "^3.8" click = ">=7.1,<9.0" -"databind.core" = "^4.4.2" -"databind.json" = "^4.4.2" +databind = "^4.4.2" docspec = "^2.2.1" docspec-python = "^2.2.1" docstring-parser = "^0.11" diff --git a/src/pydoc_markdown/contrib/renderers/markdown.py b/src/pydoc_markdown/contrib/renderers/markdown.py index 2e478b08..012b0854 100644 --- a/src/pydoc_markdown/contrib/renderers/markdown.py +++ b/src/pydoc_markdown/contrib/renderers/markdown.py @@ -41,7 +41,7 @@ SourceLinker, ) from pydoc_markdown.util.docspec import ApiSuite, format_function_signature, is_method -from pydoc_markdown.util.misc import escape_except_blockquotes +from pydoc_markdown.util.misc import escape_except_blockquotes, escape_curly_brackets def dotted_name(obj: docspec.ApiObject) -> str: @@ -212,6 +212,9 @@ class MarkdownRenderer(Renderer, SinglePageRenderer, SingleObjectRenderer): #: Escape html in docstring. Default to False. escape_html_in_docstring: bool = False + #: Escape { and } in docstring. Default to False. + escape_curly_braces_in_docstring: bool = False + #: Render Novella `@anchor` tags before headings. render_novella_anchors: bool = False @@ -374,6 +377,8 @@ def _render_object(self, fp: t.TextIO, level: int, obj: docspec.ApiObject): if self.escape_html_in_docstring else obj.docstring.content ) + if self.escape_curly_braces_in_docstring: + docstring = escape_curly_brackets(docstring) lines = docstring.split("\n") if self.docstrings_as_blockquote: lines = ["> " + x for x in lines] diff --git a/src/pydoc_markdown/util/misc.py b/src/pydoc_markdown/util/misc.py index 8b98c044..5f65bd2a 100644 --- a/src/pydoc_markdown/util/misc.py +++ b/src/pydoc_markdown/util/misc.py @@ -26,3 +26,28 @@ def escape_except_blockquotes(string: str) -> str: escaped_string = escaped_string.replace(f"BLOCKQUOTE_TOKEN_{i}_END", match) return escaped_string + +def escape_curly_brackets(string: str) -> str: + """ + Escape curly brackets in a string, except those inside code blocks or inline code. + """ + + # Define regex patterns to match code blocks and inline code + single_quote_pattern = r"`[^`]*`" + triple_quote_pattern = r"```[\s\S]*?```" + + # Find all code blocks and inline code in the string + code_matches = re.findall(f"({triple_quote_pattern}|{single_quote_pattern})", string) + + # Replace all code blocks/inline code with placeholder tokens to preserve their contents + for i, match in enumerate(code_matches): + string = string.replace(match, f"CODE_TOKEN_{i}_END") + + # Escape curly brackets in the remaining string + escaped_string = string.replace("{", "\\{").replace("}", "\\}") + + # Replace the placeholder tokens with their original contents + for i, match in enumerate(code_matches): + escaped_string = escaped_string.replace(f"CODE_TOKEN_{i}_END", match) + + return escaped_string \ No newline at end of file diff --git a/src/pydoc_markdown/util/misc_test.py b/src/pydoc_markdown/util/misc_test.py index 40a05dd5..fb4b36a2 100644 --- a/src/pydoc_markdown/util/misc_test.py +++ b/src/pydoc_markdown/util/misc_test.py @@ -1,4 +1,4 @@ -from pydoc_markdown.util.misc import escape_except_blockquotes +from pydoc_markdown.util.misc import escape_except_blockquotes, escape_curly_brackets def test__escape_except_blockquotes() -> None: @@ -22,3 +22,52 @@ def test__escape_except_blockquotes() -> None: """ ) ) + + +def test__escape_curly_brackets() -> None: + # Test basic escaping + assert escape_curly_brackets("Hello {world}") == "Hello \\{world\\}" + + # Test escaping with inline code - curly brackets should NOT be escaped inside backticks + assert escape_curly_brackets("Use `{variable}` in your code") == "Use `{variable}` in your code" + + # Test escaping with code blocks - curly brackets should NOT be escaped inside triple backticks + code_block_input = """Here is an example: + +```python +def format_string(): + return f"Hello {name}!" +``` + +But outside code blocks, \\{these\\} should be escaped.""" + + expected_output = """Here is an example: + +```python +def format_string(): + return f"Hello {name}!" +``` + +But outside code blocks, \\\\\\{these\\\\\\} should be escaped.""" + + assert escape_curly_brackets(code_block_input) == expected_output + + # Test mixed case with both inline code and regular text + mixed_input = "Regular {bracket} and `code {bracket}` and more {regular}" + expected_mixed = "Regular \\{bracket\\} and `code {bracket}` and more \\{regular\\}" + assert escape_curly_brackets(mixed_input) == expected_mixed + + # Test multiple code blocks + multi_code_input = """First `{inline}` and then: +``` +{block_code} +``` +And {regular} text.""" + + expected_multi = """First `{inline}` and then: +``` +{block_code} +``` +And \\{regular\\} text.""" + + assert escape_curly_brackets(multi_code_input) == expected_multi