Repository: jupyter/nbconvert

Issue:
Issues with mistune V3.1.0
In the latest release of mistune (3.1.0), they have renamed all the "axt" typos [Link](https://github.com/lepture/mistune/pull/359)

This has broken nbconvert as in the dependencies, we have  "mistune>=2.0.3,<4". 

As a quick fix, we should probably cap the upper limit of the version in the dependencies whilst updating the MathBlock class to support the new convention.

For context, I have suggesed the mistune maintainers to add support for the old name convention as it is not a major version release. [Refer](https://github.com/lepture/mistune/pull/359#issuecomment-2565063802)

I'd be more than happy to take up the required changes based on the approach we want to take. 

Discussion:
krassowski: It looks like `test_minimum_versions` is already here? I can see it running: https://github.com/jupyter/nbconvert/actions/runs/12580785345/job/35063360841?pr=2199

https://github.com/jupyter/nbconvert/blob/6e5fdb31b77314a85f6ac199a92d4d1dc0f67f1d/.github/workflows/tests.yml#L104-L127
krassowski: Looks good to me:

![image](https://github.com/user-attachments/assets/aab42478-d981-419c-ae94-8be9400895b1)

fcollonval: Thanks @krassowski for pushing this. I arrived at the same conclusion. So I pushed a new commit to add a test for mistune 3.0 ... hopefully.

Patch:
diff --git a/docs/source/nbconvert_library.ipynb b/docs/source/nbconvert_library.ipynb
index dc9ea0e1..7757235a 100644
--- a/docs/source/nbconvert_library.ipynb
+++ b/docs/source/nbconvert_library.ipynb
@@ -486,7 +486,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "@damianavila wrote the Nikola Plugin to [write blog post as Notebooks](http://damianavila.github.io/blog/posts/one-line-deployment-of-your-site-to-gh-pages.html) and is developing a js-extension to publish notebooks via one click from the web app."
+    "@damianavila wrote the Nikola Plugin to [write blog post as Notebooks](https://damianavila.github.io/blog/posts/one-line-deployment-of-your-site-to-gh-pages) and is developing a js-extension to publish notebooks via one click from the web app."
    ]
   }
  ],
diff --git a/nbconvert/filters/markdown_mistune.py b/nbconvert/filters/markdown_mistune.py
index 02ab346e..fb882816 100644
--- a/nbconvert/filters/markdown_mistune.py
+++ b/nbconvert/filters/markdown_mistune.py
@@ -9,7 +9,7 @@ import base64
 import mimetypes
 import os
 from html import escape
-from typing import Any, Callable, Dict, Iterable, Match, Optional, Tuple
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterable, Match, Optional, Protocol, Tuple
 
 import bs4
 from pygments import highlight
@@ -20,6 +20,19 @@ from pygments.util import ClassNotFound
 
 from nbconvert.filters.strings import add_anchor
 
+if TYPE_CHECKING:
+    try:
+        from mistune.plugins import Plugin
+    except ImportError:
+
+        class Plugin(Protocol):  # type: ignore[no-redef]
+            """Mistune plugin interface."""
+
+            def __call__(self, markdown: "Markdown") -> None:
+                """Apply the plugin on the markdown document."""
+                ...
+
+
 try:  # for Mistune >= 3.0
     from mistune import (  # type:ignore[attr-defined]
         BlockParser,
@@ -32,6 +45,7 @@ try:  # for Mistune >= 3.0
     )
 
     MISTUNE_V3 = True
+    MISTUNE_V3_ATX = "atx_heading" in BlockParser.SPECIFICATION
 
 except ImportError:  # for Mistune >= 2.0
     import re
@@ -45,8 +59,9 @@ except ImportError:  # for Mistune >= 2.0
     )
 
     MISTUNE_V3 = False
+    MISTUNE_V3_ATX = False
 
-    def import_plugin(name: str) -> "MarkdownPlugin":  # type: ignore[misc]
+    def import_plugin(name: str) -> "Plugin":  # type: ignore[misc]
         """Simple implementation of Mistune V3's import_plugin for V2."""
         return PLUGINS[name]  # type: ignore[no-any-return]
 
@@ -75,8 +90,10 @@ if MISTUNE_V3:  # Parsers for Mistune >= 3.0.0
         is ignored here.
         """
 
-        AXT_HEADING_WITHOUT_LEADING_SPACES = (
-            r"^ {0,3}(?P<axt_1>#{1,6})(?!#+)(?P<axt_2>[ \t]*(.*?)?)$"
+        ATX_HEADING_WITHOUT_LEADING_SPACES = (
+            r"^ {0,3}(?P<atx_1>#{1,6})(?!#+)(?P<atx_2>[ \t]*(.*?)?)$"
+            if MISTUNE_V3_ATX
+            else r"^ {0,3}(?P<axt_1>#{1,6})(?!#+)(?P<axt_2>[ \t]*(.*?)?)$"
         )
 
         MULTILINE_MATH = _dotall(
@@ -92,12 +109,14 @@ if MISTUNE_V3:  # Parsers for Mistune >= 3.0.0
 
         SPECIFICATION = {
             **BlockParser.SPECIFICATION,
-            "axt_heading": AXT_HEADING_WITHOUT_LEADING_SPACES,
+            (
+                "atx_heading" if MISTUNE_V3_ATX else "axt_heading"
+            ): ATX_HEADING_WITHOUT_LEADING_SPACES,
             "multiline_math": MULTILINE_MATH,
         }
 
         # Multiline math must be searched before other rules
-        DEFAULT_RULES: Tuple[str, ...] = ("multiline_math", *BlockParser.DEFAULT_RULES)  # type: ignore[assignment]
+        DEFAULT_RULES: ClassVar[Iterable[str]] = ("multiline_math", *BlockParser.DEFAULT_RULES)  # type: ignore[assignment]
 
         def parse_multiline_math(self, m: Match[str], state: BlockState) -> int:
             """Send mutiline math as a single paragraph to MathInlineParser."""
@@ -139,7 +158,7 @@ if MISTUNE_V3:  # Parsers for Mistune >= 3.0.0
         }
 
         # Block math must be matched first, and all math must come before text
-        DEFAULT_RULES: Tuple[str, ...] = (
+        DEFAULT_RULES: ClassVar[Iterable[str]] = (
             "block_math_tex",
             "block_math_latex",
             "inline_math_tex",
@@ -442,10 +461,6 @@ class IPythonRenderer(HTMLRenderer):
         return str(parsed_html)
 
 
-# Represents an already imported plugin for Mistune
-MarkdownPlugin = Callable[[Markdown], None]
-
-
 class MarkdownWithMath(Markdown):
     """Markdown text with math enabled."""
 
@@ -464,7 +479,7 @@ class MarkdownWithMath(Markdown):
         renderer: HTMLRenderer,
         block: Optional[BlockParser] = None,
         inline: Optional[InlineParser] = None,
-        plugins: Optional[Iterable[MarkdownPlugin]] = None,
+        plugins: Optional[Iterable["Plugin"]] = None,
     ):
         """Initialize the parser."""
         if block is None:


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 0b652494..1b8b661f 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -126,6 +126,28 @@ jobs:
           PIP_CONSTRAINT= hatch env run -e test -- pip install 'pip>=24.2'
           xvfb-run --auto-servernum hatch run test:nowarn || xvfb-run --auto-servernum hatch run test:nowarn --lf
 
+  test_mistune_30:
+    name: Test Mistune 3.0
+    timeout-minutes: 20
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Base Setup
+        uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
+      - name: Install dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get install texlive-plain-generic inkscape texlive-xetex latexmk
+          sudo apt-get install xvfb x11-utils libxkbcommon-x11-0  libxcb-xinerama0 python3-pyqt5
+
+          # pandoc is not up to date in the ubuntu repos, so we install directly
+          wget https://github.com/jgm/pandoc/releases/download/2.9.2.1/pandoc-2.9.2.1-1-amd64.deb && sudo dpkg -i pandoc-2.9.2.1-1-amd64.deb
+
+      - name: Run tests
+        run: |
+          hatch env run -e test -- pip install 'mistune~=3.0.0'
+          xvfb-run --auto-servernum hatch run test:nowarn || xvfb-run --auto-servernum hatch run test:nowarn --lf
+
   test_prereleases:
     name: Test Prereleases
     runs-on: ubuntu-latest