diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d50cb3e..6de0c3b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## Unreleased + +* ✨ Add `highlight_verbatim` option to pass highlighter output through verbatim, without the `
` wrapper, in [#256](https://github.com/executablebooks/markdown-it-py/issues/256)
+
 ## 4.2.0 - 2026-05-07
 
 * ✨ Add `make_fence_rule()` factory for configurable fence markers in [#394](https://github.com/executablebooks/markdown-it-py/pull/394)
diff --git a/docs/using.md b/docs/using.md
index 507f49c1..557cc7c9 100644
--- a/docs/using.md
+++ b/docs/using.md
@@ -303,6 +303,45 @@ def function(renderer, tokens, idx, options, env):
 
 +++
 
+### Code highlighting
+
+Fenced code blocks are rendered as `
...
` by default. +You can customize this with the `highlight` option, which should be a function +`(content, lang, attrs) -> str` returning escaped HTML: + +```python +from markdown_it import MarkdownIt + +def highlight(content, lang, attrs): + return f'{content}' + +md = MarkdownIt("commonmark", {"highlight": highlight}) +md.render("```python\nprint('hi')\n```") +``` + +If the highlighter returns a string starting with `...
`. + +If your highlighter produces a complete block of HTML that does not start +with `` wrapper, or a `
` with custom
+attributes), set the Python-only `highlight_verbatim` option to `True` to
+skip the `
` wrapper entirely and pass the highlighter output
+through verbatim (a trailing newline is added, as with the pre-continues
+heuristic):
+
+```python
+md = MarkdownIt("commonmark", {"highlight": highlight, "highlight_verbatim": True})
+md.render("```python\nprint('hi')\n```")
+```
+
+Note that `highlight_verbatim` only applies when the `highlight` function
+returns a non-empty string; if it returns an empty string (or `None`), the
+content is escaped and wrapped in `
` as usual.
+
++++
+
 You can inject render methods into the instantiated render class.
 
 ```{jupyter-execute}
diff --git a/markdown_it/renderer.py b/markdown_it/renderer.py
index f690b091..e7202339 100644
--- a/markdown_it/renderer.py
+++ b/markdown_it/renderer.py
@@ -277,9 +277,14 @@ def fence(
                 langAttrs = arr[1]
 
         if options.highlight:
-            highlighted = options.highlight(
-                token.content, langName, langAttrs
-            ) or escapeHtml(token.content)
+            highlighted = options.highlight(token.content, langName, langAttrs)
+            if highlighted:
+                if options.get("highlight_verbatim", False):
+                    # Pass the highlighter output through verbatim —
+                    # byte-exact, no wrapper, no added newline.
+                    return highlighted
+            else:
+                highlighted = escapeHtml(token.content)
         else:
             highlighted = escapeHtml(token.content)
 
diff --git a/markdown_it/utils.py b/markdown_it/utils.py
index 09e60163..cbd1d321 100644
--- a/markdown_it/utils.py
+++ b/markdown_it/utils.py
@@ -36,6 +36,20 @@ class OptionsType(TypedDict):
     """CSS language prefix for fenced blocks."""
     highlight: Callable[[str, str, str], str] | None
     """Highlighter function: (content, lang, attrs) -> str."""
+    highlight_verbatim: NotRequired[bool]
+    """Pass highlighter output through verbatim, without the ``
`` wrapper.
+
+    When ``True`` and the ``highlight`` function returns a non-empty string,
+    the returned HTML is used as-is (a trailing newline is added), instead of
+    being wrapped in ``
...
``. This is useful when the + highlighter produces a complete block of HTML (e.g. starting with a + ``
`` or a ``
`` with custom attributes) that should not be
+    wrapped again.
+
+    This is a Python only option. The default is ``False``, in which case the
+    output is only passed through verbatim if it already starts with `` MarkdownIt:
+    return MarkdownIt("commonmark", {"highlight": highlight, **options})
+
+
+def test_default_wraps_non_pre_output():
+    """Default behavior: highlighter output not starting with 
{content}
" + + md = _md(highlight) + assert md.render("```python\nhl\n```") == ( + "
hl\n
\n" + ) + + +def test_default_pre_continues_heuristic(): + """Default behavior: output starting with
{content}
" + + md = _md(highlight) + assert md.render("```python\nhl\n```") == "
hl\n
\n" + + +def test_verbatim_passes_through_non_pre_output(): + """With highlight_verbatim, non-
{content}"
+
+    md = _md(highlight, highlight_verbatim=True)
+    assert md.render("```python\nhl\n```") == "
hl\n
" + + +def test_verbatim_passes_through_pre_output(): + """With highlight_verbatim,
{content}
" + + md = _md(highlight, highlight_verbatim=True) + assert md.render("```python\nhl\n```") == "
hl\n
" + + +def test_verbatim_skips_lang_class_injection(): + """With highlight_verbatim, no language class is injected into the output.""" + + def highlight(content, lang, attrs): + assert lang == "python" + return f"
{content}
" + + md = _md(highlight, highlight_verbatim=True) + assert md.render("```python\nhl\n```") == "
hl\n
" + + +def test_verbatim_falls_back_when_highlighter_returns_empty(): + """Falsy highlighter output still falls back to the escaped
 wrapper."""
+
+    def highlight(content, lang, attrs):
+        return ""
+
+    md = _md(highlight, highlight_verbatim=True)
+    assert md.render("```python\nhl\n```") == (
+        '
hl\n
\n' + ) + + +def test_verbatim_without_highlighter_uses_default_wrapper(): + """Without a highlighter, highlight_verbatim has no effect.""" + + md = _md(None, highlight_verbatim=True) + assert md.render("```python\nhl\n```") == ( + '
hl\n
\n' + ) + + +def test_verbatim_can_be_set_after_construction(): + """The option can be toggled on the instance after construction.""" + + def highlight(content, lang, attrs): + return f"
{content}
" + + md = _md(highlight) + assert md.render("```\nhl\n```") == "
hl\n
\n" + md.options["highlight_verbatim"] = True + assert md.render("```\nhl\n```") == "
hl\n
" + md.options["highlight_verbatim"] = False + assert md.render("```\nhl\n```") == "
hl\n
\n" + + +def test_verbatim_preserves_absence_of_trailing_newline(): + """No trailing newline is added — verbatim is byte-exact. + + The pre-continues heuristic path keeps its historical ``+ "\\n"`` + (wrapped blocks are renderer-owned); the verbatim path returns the + highlighter's bytes untouched, newline or none. + """ + + def highlight(content, lang, attrs): + return "
hl
" # no trailing newline + + md = _md(highlight, highlight_verbatim=True) + assert md.render("```\nhl\n```") == "
hl
" + + +def test_verbatim_is_byte_exact(): + """Verbatim means byte-exact: render output == highlighter return. + + Regression for the trailing-newline defect (matrix-03): with + highlight_verbatim, the renderer must not append, strip, or alter + a single byte of the highlighter's return value — with or without + a trailing newline. + """ + + def hl_no_nl(content, lang, attrs): + return "
no-newline
" + + def hl_with_nl(content, lang, attrs): + return "
with-newline
\n" + + assert ( + _md(hl_no_nl, highlight_verbatim=True).render("```x\nc\n```") + == "
no-newline
" + ) + assert ( + _md(hl_with_nl, highlight_verbatim=True).render("```x\nc\n```") + == "
with-newline
\n" + )