From 97ae53296015c8fa181b935b36df94869a5ddcfb Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 17 Aug 2026 15:42:25 +0300 Subject: [PATCH 1/2] refactor(post-render): drop the doctest blockquote repair Doctest prompts are now fenced as Python before the docstring is parsed, so Pandoc never reads a leading `>` as a blockquote marker. The HTML pass that rebuilt those blockquotes into a code block had nothing left to repair; removing it leaves the rendered reference pages byte-identical. Add a render-level test that no doctest prompt reaches the qmd outside a code fence, covering prompts inside an Examples section and loose in the subject, consecutive prompt groups, a Google-style header under the numpy parser, and a class with its members. --- great_docs/assets/post-render.py | 55 -------- tests/renderer/test_doctest_normalization.py | 133 +++++++++++++++++++ 2 files changed, 133 insertions(+), 55 deletions(-) diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py index 16efaa2c..6cdf7a73 100644 --- a/great_docs/assets/post-render.py +++ b/great_docs/assets/post-render.py @@ -1802,58 +1802,6 @@ def _replace_toc(m): return html_content -def fix_doctest_blockquotes(html_content): - """ - Convert nested blockquotes from doctest `>>>` lines into code blocks. - - When the renderer produces an Example section with raw `>>>` lines in the `.qmd` file, - Quarto/Pandoc interprets the leading `>` characters as Markdown blockquote markers. A `>>>` - line becomes triple-nested `
` elements: - - ```html -
-
-
-

func(x) 'result'

-
-
-
- ``` - - This function detects that pattern inside Example/Examples doc-sections and replaces it with a - proper `
` block so the content renders in monospace as code.
-    """
-    # Match one or more triple-nested blockquote clusters inside an
-    # Example or Examples doc-section.
-    _SECTION_RE = re.compile(
-        r'(]*class="[^"]*doc-section-examples?[^"]*"[^>]*>\s*'
-        r"]*>.*?\s*)"
-        r"((?:\s*]*>\s*]*>\s*]*>"
-        r"\s*

.*?

\s*
\s*\s*\s*)+)", - re.DOTALL, - ) - - # Extract individual

content from triple-nested blockquotes - _BQ_TEXT_RE = re.compile( - r"]*>\s*]*>\s*]*>" - r"\s*

(.*?)

\s*\s*\s*", - re.DOTALL, - ) - - def _replace_section(m): - header = m.group(1) - bq_block = m.group(2) - lines = [] - for bq in _BQ_TEXT_RE.finditer(bq_block): - text = bq.group(1).strip() - # Reconstruct the doctest line with >>> prefix - lines.append(f">>> {text}") - code = "\n".join(lines) - return f"{header}
{code}
\n" - - return _SECTION_RE.sub(_replace_section, html_content) - - def fix_plain_doctest_code_blocks(html_content): """ Convert plain `
` blocks containing doctest `>>>` lines into properly highlighted
@@ -2104,9 +2052,6 @@ def fix_dataclass_attributes(content_str):
     # Translate renderer-rendered headings and Usage/Source labels
     content = translate_renderer_headings(content)
 
-    # Fix doctest >>> lines that Quarto rendered as nested blockquotes
-    content = fix_doctest_blockquotes(content)
-
     # Fix plain 
 blocks containing >>> doctest lines
     # (consecutive examples where only the first got a proper code fence)
     content = fix_plain_doctest_code_blocks(content)
diff --git a/tests/renderer/test_doctest_normalization.py b/tests/renderer/test_doctest_normalization.py
index 1356e8f5..7f09bbba 100644
--- a/tests/renderer/test_doctest_normalization.py
+++ b/tests/renderer/test_doctest_normalization.py
@@ -1,7 +1,11 @@
+import textwrap
+
 import griffe as gf
 import pytest
 
+from great_docs._apiref._tools import _render
 from great_docs._builtin.normalization._doctest import normalize_doctests
+from great_docs.hooks._object_resolved import emit_object_resolved
 
 
 def _function(text: str, parser: str) -> gf.Function:
@@ -52,3 +56,132 @@ def test_doctest_normalization_registers_on_import():
     from great_docs.hooks import _object_resolved
 
     assert normalize_doctests in _object_resolved.REGISTRY
+
+
+def _render_with_hooks(source: str, name: str) -> str:
+    """Render the named object of a source snippet to qmd, with the hooks applied"""
+    with gf.temporary_visited_package(
+        "package", {"__init__.py": textwrap.dedent(source)}, docstring_parser="numpy"
+    ) as package:
+        obj = emit_object_resolved(package[name])
+        assert obj is not None
+        for member in obj.members.values():
+            _ = emit_object_resolved(member)
+        return _render(obj)
+
+
+def _unfenced_prompts(qmd: str) -> list[str]:
+    """Return the doctest prompt lines of `qmd` that fall outside a code fence"""
+    outside: list[str] = []
+    in_fence = False
+    for line in qmd.split("\n"):
+        stripped = line.lstrip()
+        if stripped.startswith("```"):
+            in_fence = not in_fence
+        elif stripped.startswith(">>>") and not in_fence:
+            outside.append(line)
+    return outside
+
+
+@pytest.mark.parametrize(
+    ("name", "source"),
+    [
+        (
+            "prose_then_code",
+            '''
+            def prose_then_code():
+                """
+                Do a thing.
+
+                Examples
+                --------
+                Some explanatory prose.
+
+                >>> prose_then_code()
+                3
+                """
+            ''',
+        ),
+        (
+            "two_groups",
+            '''
+            def two_groups():
+                """
+                Do two things.
+
+                Examples
+                --------
+                >>> two_groups()
+                1
+
+                >>> two_groups()
+                2
+                """
+            ''',
+        ),
+        (
+            "no_section",
+            '''
+            def no_section():
+                """
+                Do an unsectioned thing.
+
+                >>> no_section()
+                3
+                """
+            ''',
+        ),
+        (
+            "google_header",
+            '''
+            def google_header():
+                """
+                Do a thing.
+
+                Example:
+                    >>> google_header()
+                    3
+                """
+            ''',
+        ),
+        (
+            "Widget",
+            '''
+            class Widget:
+                """
+                A widget.
+
+                Examples
+                --------
+                >>> Widget()
+                
+                """
+
+                def resize(self):
+                    """
+                    Resize the widget.
+
+                    Examples
+                    --------
+                    >>> for i in range(2):
+                    ...     print(i)
+                    0
+                    1
+                    """
+            ''',
+        ),
+    ],
+)
+def test_every_doctest_prompt_reaches_the_qmd_fenced(name: str, source: str):
+    """
+    No doctest prompt survives into the qmd outside a code fence
+
+    Pandoc reads a leading `>` as a blockquote marker, so an unfenced `>>>`
+    renders as three nested blockquotes instead of code. The prompts must be
+    fenced whatever their shape: inside an `Examples` section or loose in the
+    subject, one group or several, on a class or on its members.
+    """
+    qmd = _render_with_hooks(source, name)
+
+    assert ">>>" in qmd, "the sample rendered without its doctest at all"
+    assert _unfenced_prompts(qmd) == []

From 9563bee96c6fce817544e2a5b10f56f9e3160fbb Mon Sep 17 00:00:00 2001
From: Hassan Kibirige 
Date: Mon, 17 Aug 2026 16:39:15 +0300
Subject: [PATCH 2/2] refactor(post-render): drop the plain doctest
 re-highlighting

Doctest prompts are fenced as Python before the docstring is parsed, so
Quarto highlights every doctest block itself and never leaves one as an
unhighlighted code block. The HTML pass that re-highlighted those blocks
had no matching input; removing it leaves the rendered reference pages
byte-identical under the numpy, google and sphinx parsers.

Drop its tests, along with the Pygments namespace scaffolding that only
those tests needed. Widen the fence test to the shapes that motivated the
pass: a literal block, an indented group pair, a list item, and docstrings
written in Google and Sphinx style under their own parsers.
---
 great_docs/assets/post-render.py             |  99 ---------------
 tests/renderer/test_doctest_normalization.py | 119 ++++++++++++++++++-
 tests/test_post_render.py                    | 118 ------------------
 3 files changed, 114 insertions(+), 222 deletions(-)

diff --git a/great_docs/assets/post-render.py b/great_docs/assets/post-render.py
index 6cdf7a73..1d076603 100644
--- a/great_docs/assets/post-render.py
+++ b/great_docs/assets/post-render.py
@@ -1802,101 +1802,6 @@ def _replace_toc(m):
     return html_content
 
 
-def fix_plain_doctest_code_blocks(html_content):
-    """
-    Convert plain `
` blocks containing doctest `>>>` lines into properly highlighted
-    Python code blocks.
-
-    When the renderer renders consecutive doctest examples separated by blank lines, only the first
-    block gets a proper ```` ```python ```` fence. Subsequent blocks become 4-space-indented text in
-    the `.qmd` file, which Quarto renders as plain ``
`` without syntax highlighting.
-
-    This function finds those plain code blocks, re-highlights them with Pygments, and wraps them in
-    the same `sourceCode python` structure that Quarto uses for fenced code blocks.
-    """
-    # Match 
 blocks that contain >>> (i.e. >>>) but
-    # are NOT already inside a sourceCode div.  We look for 

-    # (no class) immediately, which distinguishes them from Quarto's
-    # 
 blocks.
-    _PLAIN_DOCTEST_RE = re.compile(
-        r"
(.*?)
", - re.DOTALL, - ) - - # Track a counter for generating unique cb IDs - _cb_counter = [0] - - def _find_max_cb_id(html): - """Find the highest existing cb ID to avoid collisions.""" - ids = re.findall(r'id="cb(\d+)"', html) - return max(int(i) for i in ids) if ids else 0 - - _cb_counter[0] = _find_max_cb_id(html_content) - - def _replace_plain_doctest(m): - code_html = m.group(1) - - # Only process blocks that contain doctest >>> markers - if ">>>" not in code_html: - return m.group(0) - - # Decode HTML entities to get plain text for Pygments - plain_text = code_html - plain_text = plain_text.replace("<", "<") - plain_text = plain_text.replace(">", ">") - plain_text = plain_text.replace("&", "&") - plain_text = plain_text.replace(""", '"') - plain_text = plain_text.replace("'", "'") - # Strip any existing HTML tags (unlikely but safe) - plain_text = re.sub(r"<[^>]+>", "", plain_text) - - # Highlight with Pygments - lexer = PythonLexer() - formatter = HtmlFormatter(nowrap=True, classprefix="") - highlighted = highlight(plain_text, lexer, formatter) - - # Map Pygments CSS classes to Quarto CSS classes - for pg_class, quarto_class in PYGMENTS_TO_QUARTO_CLASS.items(): - if quarto_class: - highlighted = highlighted.replace(f'class="{pg_class}"', f'class="{quarto_class}"') - else: - highlighted = re.sub( - rf'([^<]*)', - r"\1", - highlighted, - ) - - # Assign a unique cb ID - _cb_counter[0] += 1 - cb_id = f"cb{_cb_counter[0]}" - - # Wrap each line in a span with proper id for line linking - lines = highlighted.rstrip("\n").split("\n") - wrapped_lines = [] - for j, line in enumerate(lines, 1): - span_id = f"{cb_id}-{j}" - wrapped_lines.append( - f'' - f'' - f"{line}" - ) - highlighted = "\n".join(wrapped_lines) - - return ( - f'
' - f'" - f'
' - f'
'
-            f''
-            f"{highlighted}"
-            f"
" - ) - - return _PLAIN_DOCTEST_RE.sub(_replace_plain_doctest, html_content) - - def translate_rst_references(html_content): """ Convert RST citation references into a styled numbered list. @@ -2052,10 +1957,6 @@ def fix_dataclass_attributes(content_str): # Translate renderer-rendered headings and Usage/Source labels content = translate_renderer_headings(content) - # Fix plain
 blocks containing >>> doctest lines
-    # (consecutive examples where only the first got a proper code fence)
-    content = fix_plain_doctest_code_blocks(content)
-
     # Translate RST citation references (.. [1] ...)
     content = translate_rst_references(content)
 
diff --git a/tests/renderer/test_doctest_normalization.py b/tests/renderer/test_doctest_normalization.py
index 7f09bbba..f3ad7d88 100644
--- a/tests/renderer/test_doctest_normalization.py
+++ b/tests/renderer/test_doctest_normalization.py
@@ -58,10 +58,10 @@ def test_doctest_normalization_registers_on_import():
     assert normalize_doctests in _object_resolved.REGISTRY
 
 
-def _render_with_hooks(source: str, name: str) -> str:
+def _render_with_hooks(source: str, name: str, parser: str = "numpy") -> str:
     """Render the named object of a source snippet to qmd, with the hooks applied"""
     with gf.temporary_visited_package(
-        "package", {"__init__.py": textwrap.dedent(source)}, docstring_parser="numpy"
+        "package", {"__init__.py": textwrap.dedent(source)}, docstring_parser=parser
     ) as package:
         obj = emit_object_resolved(package[name])
         assert obj is not None
@@ -144,6 +144,57 @@ def google_header():
                 """
             ''',
         ),
+        (
+            "rst_literal",
+            '''
+            def rst_literal():
+                """
+                Do a thing.
+
+                Examples
+                --------
+                ::
+
+                    >>> rst_literal()
+                    3
+                """
+            ''',
+        ),
+        (
+            "indented_groups",
+            '''
+            def indented_groups():
+                """
+                Do two things.
+
+                Examples
+                --------
+                Indented block:
+
+                    >>> indented_groups()
+                    1
+
+                    >>> indented_groups()
+                    2
+                """
+            ''',
+        ),
+        (
+            "nested_in_list",
+            '''
+            def nested_in_list():
+                """
+                Do a listed thing.
+
+                Examples
+                --------
+                - First bullet:
+
+                  >>> nested_in_list()
+                  1
+                """
+            ''',
+        ),
         (
             "Widget",
             '''
@@ -176,12 +227,70 @@ def test_every_doctest_prompt_reaches_the_qmd_fenced(name: str, source: str):
     """
     No doctest prompt survives into the qmd outside a code fence
 
-    Pandoc reads a leading `>` as a blockquote marker, so an unfenced `>>>`
-    renders as three nested blockquotes instead of code. The prompts must be
+    An unfenced prompt reaches Quarto as markdown, where a leading `>` is a
+    blockquote marker and the block is left unhighlighted. The prompts must be
     fenced whatever their shape: inside an `Examples` section or loose in the
-    subject, one group or several, on a class or on its members.
+    subject, one group or several, indented under a literal block, a paragraph
+    or a list item, and on a class or on its members.
     """
     qmd = _render_with_hooks(source, name)
 
     assert ">>>" in qmd, "the sample rendered without its doctest at all"
     assert _unfenced_prompts(qmd) == []
+
+
+@pytest.mark.parametrize(
+    ("parser", "docstring"),
+    [
+        (
+            "google",
+            """
+            Do a thing.
+
+            Args:
+                value: The value.
+
+            Examples:
+                >>> convert(1)
+                1
+
+                >>> convert(2)
+                2
+            """,
+        ),
+        (
+            "sphinx",
+            """
+            Do a thing.
+
+            :param value: The value.
+
+            .. rubric:: Examples
+
+            >>> convert(1)
+            1
+
+            >>> convert(2)
+            2
+            """,
+        ),
+    ],
+)
+def test_native_dialect_doctests_reach_the_qmd_fenced(parser: str, docstring: str):
+    """
+    A docstring written in its configured dialect keeps its prompts fenced
+
+    The dialect decides how griffe splits the sections, so each one reaches the
+    fencing hook differently.
+    """
+    source = f'''
+    def convert(value):
+        """
+        {textwrap.indent(textwrap.dedent(docstring), " " * 8).strip()}
+        """
+    '''
+
+    qmd = _render_with_hooks(source, "convert", parser=parser)
+
+    assert ">>>" in qmd, "the sample rendered without its doctest at all"
+    assert _unfenced_prompts(qmd) == []
diff --git a/tests/test_post_render.py b/tests/test_post_render.py
index 5f92f5b9..b3eee978 100644
--- a/tests/test_post_render.py
+++ b/tests/test_post_render.py
@@ -28,10 +28,6 @@ def _get_functions():
 
     source = _SCRIPT.read_text()
 
-    from pygments import highlight as _highlight
-    from pygments.formatters import HtmlFormatter as _HtmlFormatter
-    from pygments.lexers import PythonLexer as _PythonLexer
-
     # Stub _t so translated labels fall back to English
     def _t(key: str, fallback: str | None = None) -> str:
         return fallback if fallback is not None else key
@@ -42,23 +38,12 @@ def _t(key: str, fallback: str | None = None) -> str:
         "os": _os,
         "re": _re,
         "__builtins__": __builtins__,
-        "highlight": _highlight,
-        "HtmlFormatter": _HtmlFormatter,
-        "PythonLexer": _PythonLexer,
         "_t": _t,
     }
 
-    # Extract PYGMENTS_TO_QUARTO_CLASS dict (needed by fix_plain_doctest_code_blocks)
-    cm_start = source.find("PYGMENTS_TO_QUARTO_CLASS = {")
-    if cm_start != -1:
-        cm_rest = source[cm_start:]
-        cm_end = cm_rest.find("}\n") + 2
-        exec(cm_rest[:cm_end], ns)
-
     # Extract function definitions by finding their source blocks
     funcs_to_extract = [
         "translate_sphinx_roles",
-        "fix_plain_doctest_code_blocks",
         "_postprocess_markdown_content",
     ]
 
@@ -87,14 +72,12 @@ def _t(key: str, fallback: str | None = None) -> str:
 
     return (
         ns["translate_sphinx_roles"],
-        ns["fix_plain_doctest_code_blocks"],
         ns["_postprocess_markdown_content"],
     )
 
 
 (
     translate_sphinx_roles,
-    fix_plain_doctest_code_blocks,
     postprocess_markdown_content,
 ) = _get_functions()
 
@@ -277,69 +260,6 @@ def test_type_role(self):
         assert result == "

Is int.

" -class TestFixPlainDoctestCodeBlocks: - """Tests for fix_plain_doctest_code_blocks (site 137 regression).""" - - def test_single_plain_doctest_converted(self): - """A plain
 block with >>> gets proper sourceCode styling."""
-        html = "
>>> foo(\"hello\")\n'world'
" - result = fix_plain_doctest_code_blocks(html) - assert 'class="sourceCode python' in result - assert 'class="code-copy-outer-scaffold"' in result - assert "
" not in result
-
-    def test_consecutive_plain_doctests_all_converted(self):
-        """Multiple consecutive plain doctest blocks are all converted."""
-        html = (
-            # First block (already styled by Quarto — should be left alone)
-            '
' - '
'
-            ''
-            '>>> schedule("cleanup")\n'
-            'True
\n' - # Second block (plain — should be converted) - '
>>> schedule("backup", delay=60.0)\n'
-            "True
\n" - # Third block (plain — should be converted) - '
>>> schedule("cleanup")\n'
-            "False
" - ) - result = fix_plain_doctest_code_blocks(html) - # The already-styled block should remain - assert result.count('class="sourceCode python') >= 3 - # No plain
 blocks should remain
-        assert "
" not in result
-
-    def test_plain_code_block_without_doctest_unchanged(self):
-        """A plain 
 without >>> is left as-is."""
-        html = "
just some plain text
" - result = fix_plain_doctest_code_blocks(html) - assert result == html - - def test_unique_cb_ids_no_collision(self): - """Generated cb IDs don't collide with existing ones.""" - html = ( - '
' - '
'
-            "existing
\n" - "
>>> first()\n1
\n" - "
>>> second()\n2
" - ) - result = fix_plain_doctest_code_blocks(html) - # Existing cb3 plus two new blocks should give cb4 and cb5 - assert 'id="cb4"' in result - assert 'id="cb5"' in result - - def test_html_entities_decoded_for_highlighting(self): - """HTML entities in the plain block are decoded before Pygments.""" - html = "
>>> x < 10 & y > 5\nTrue
" - result = fix_plain_doctest_code_blocks(html) - # Should be in a sourceCode block, not plain - assert 'class="sourceCode python' in result - # The original plain block should be gone - assert "
" not in result
-
-
 def _make_autolink(inventory):
     """Create an autolink function bound to a given inventory."""
     import re as _re
@@ -797,44 +717,6 @@ def test_multiple_codes_in_one_page(self):
         assert 'href="../reference/execute.html#pkg.execute"' in result
 
 
-class TestFixPlainDoctestGdCodeNav:
-    """Tests that fix_plain_doctest_code_blocks emits the gd-code-nav copy button."""
-
-    def test_converted_block_has_gd_code_nav(self):
-        """Converted doctest block should contain a gd-code-nav element."""
-        html = "
>>> foo()\n42
" - result = fix_plain_doctest_code_blocks(html) - assert 'class="gd-code-nav"' in result - - def test_converted_block_has_gd_code_copy_button(self): - """Converted block should have a gd-code-copy button inside the nav.""" - html = "
>>> bar(1, 2)\n3
" - result = fix_plain_doctest_code_blocks(html) - assert 'class="gd-code-copy"' in result - assert 'title="Copy to clipboard"' in result - - def test_no_legacy_code_copy_button(self): - """Converted block should NOT have the old code-copy-button class.""" - html = "
>>> baz()\nNone
" - result = fix_plain_doctest_code_blocks(html) - assert "code-copy-button" not in result - assert '' not in result - - def test_nav_is_inside_scaffold(self): - """gd-code-nav should be nested inside code-copy-outer-scaffold.""" - html = "
>>> x = 1\n
" - result = fix_plain_doctest_code_blocks(html) - scaffold_start = result.find('class="code-copy-outer-scaffold"') - nav_start = result.find('class="gd-code-nav"') - assert scaffold_start < nav_start - - def test_no_code_with_copy_class_on_pre(self): - """Converted
 should NOT have the Quarto 'code-with-copy' class."""
-        html = "
>>> hello()\n'world'
" - result = fix_plain_doctest_code_blocks(html) - assert "code-with-copy" not in result - - # --------------------------------------------------------------------------- # fix_script_paths — back-to-top.js path resolution # ---------------------------------------------------------------------------