Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change Log

## Unreleased

* ✨ Add `highlight_verbatim` option to pass highlighter output through verbatim, without the `<pre><code>` 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)
Expand Down
39 changes: 39 additions & 0 deletions docs/using.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,45 @@ def function(renderer, tokens, idx, options, env):

+++

### Code highlighting

Fenced code blocks are rendered as `<pre><code>...</code></pre>` 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'<span class="hl">{content}</span>'

md = MarkdownIt("commonmark", {"highlight": highlight})
md.render("```python\nprint('hi')\n```")
```

If the highlighter returns a string starting with `<pre`, it is assumed to
already be a complete block and is passed through verbatim (the
"pre-continues" heuristic). Otherwise the returned HTML is wrapped in
`<pre><code>...</code></pre>`.

If your highlighter produces a complete block of HTML that does not start
with `<pre` (for example a `<div>` wrapper, or a `<pre>` with custom
attributes), set the Python-only `highlight_verbatim` option to `True` to
skip the `<pre><code>` 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 `<pre><code>` as usual.

+++

You can inject render methods into the instantiated render class.

```{jupyter-execute}
Expand Down
11 changes: 8 additions & 3 deletions markdown_it/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions markdown_it/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<pre><code>`` 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 ``<pre><code>...</code></pre>``. This is useful when the
highlighter produces a complete block of HTML (e.g. starting with a
``<div>`` or a ``<pre>`` 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 ``<pre``
(the "pre-continues" heuristic).
"""
store_labels: NotRequired[bool]
"""Store link label in link/image token's metadata (under Token.meta['label']).

Expand Down
142 changes: 142 additions & 0 deletions tests/test_api/test_highlight_verbatim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Tests for the ``highlight_verbatim`` option (markdown-it-py#256)."""

from __future__ import annotations

from collections.abc import Callable
from typing import Any

from markdown_it import MarkdownIt


def _md(
highlight: Callable[[str, str, str], str] | None = None, **options: Any
) -> MarkdownIt:
return MarkdownIt("commonmark", {"highlight": highlight, **options})


def test_default_wraps_non_pre_output():
"""Default behavior: highlighter output not starting with <pre is wrapped."""

def highlight(content, lang, attrs):
return f"<div class='hl'>{content}</div>"

md = _md(highlight)
assert md.render("```python\nhl\n```") == (
"<pre><code class=\"language-python\"><div class='hl'>hl\n</div></code></pre>\n"
)


def test_default_pre_continues_heuristic():
"""Default behavior: output starting with <pre is passed through verbatim."""

def highlight(content, lang, attrs):
return f"<pre class='hl'>{content}</pre>"

md = _md(highlight)
assert md.render("```python\nhl\n```") == "<pre class='hl'>hl\n</pre>\n"


def test_verbatim_passes_through_non_pre_output():
"""With highlight_verbatim, non-<pre output is passed through unwrapped."""

def highlight(content, lang, attrs):
return f"<div class='hl'>{content}</div>"

md = _md(highlight, highlight_verbatim=True)
assert md.render("```python\nhl\n```") == "<div class='hl'>hl\n</div>"


def test_verbatim_passes_through_pre_output():
"""With highlight_verbatim, <pre output is passed through as before."""

def highlight(content, lang, attrs):
return f"<pre class='hl'>{content}</pre>"

md = _md(highlight, highlight_verbatim=True)
assert md.render("```python\nhl\n```") == "<pre class='hl'>hl\n</pre>"


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"<div>{content}</div>"

md = _md(highlight, highlight_verbatim=True)
assert md.render("```python\nhl\n```") == "<div>hl\n</div>"


def test_verbatim_falls_back_when_highlighter_returns_empty():
"""Falsy highlighter output still falls back to the escaped <pre><code> wrapper."""

def highlight(content, lang, attrs):
return ""

md = _md(highlight, highlight_verbatim=True)
assert md.render("```python\nhl\n```") == (
'<pre><code class="language-python">hl\n</code></pre>\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```") == (
'<pre><code class="language-python">hl\n</code></pre>\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"<div>{content}</div>"

md = _md(highlight)
assert md.render("```\nhl\n```") == "<pre><code><div>hl\n</div></code></pre>\n"
md.options["highlight_verbatim"] = True
assert md.render("```\nhl\n```") == "<div>hl\n</div>"
md.options["highlight_verbatim"] = False
assert md.render("```\nhl\n```") == "<pre><code><div>hl\n</div></code></pre>\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 "<div>hl</div>" # no trailing newline

md = _md(highlight, highlight_verbatim=True)
assert md.render("```\nhl\n```") == "<div>hl</div>"


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 "<div>no-newline</div>"

def hl_with_nl(content, lang, attrs):
return "<div>with-newline</div>\n"

assert (
_md(hl_no_nl, highlight_verbatim=True).render("```x\nc\n```")
== "<div>no-newline</div>"
)
assert (
_md(hl_with_nl, highlight_verbatim=True).render("```x\nc\n```")
== "<div>with-newline</div>\n"
)