Repository: executablebooks/MyST-Parser
Pull request #498 (2022-01-09 16:55:22)

🔀 MERGE: Internal improvements to the code base
Internal improvements, that should mainly not be noticed by users


Patch:
diff --git a/docs/sphinx/use.md b/docs/sphinx/use.md
index 8b6df5d..1678d52 100644
--- a/docs/sphinx/use.md
+++ b/docs/sphinx/use.md
@@ -185,7 +185,7 @@ All myst-parser warnings are prepended by their type, e.g. to suppress:
 ```
 
 ```
-WARNING: Non-consecutive header level increase; 1 to 3 [myst.header]
+WARNING: Non-consecutive header level increase; H1 to H3 [myst.header]
 ```
 
 Add to your `conf.py`:
diff --git a/myst_parser/directives.py b/myst_parser/directives.py
index 5616cef..4cb9030 100644
--- a/myst_parser/directives.py
+++ b/myst_parser/directives.py
@@ -1,11 +1,14 @@
 """MyST specific directives"""
-from typing import List, Tuple
+from copy import copy
+from typing import List, Tuple, cast
 
 from docutils import nodes
 from docutils.parsers.rst import directives
 from sphinx.directives import SphinxDirective
 from sphinx.util.docutils import SphinxRole
 
+from myst_parser.mocking import MockState
+
 
 def align(argument):
     return directives.choice(argument, ("left", "center", "right"))
@@ -61,16 +64,20 @@ class FigureMarkdown(SphinxDirective):
         figclasses = self.options.pop("class", None)
         align = self.options.pop("align", None)
 
-        node = nodes.Element()
-        # TODO test that we are using myst parser
+        if not isinstance(self.state, MockState):
+            return [self.figure_error("Directive is only supported in myst parser")]
+        state = cast(MockState, self.state)
+
         # ensure html image enabled
-        myst_extensions = self.state._renderer.config.get("myst_extensions", set())
+        myst_extensions = copy(state._renderer.md_config.enable_extensions)
+        node = nodes.Element()
         try:
-            self.state._renderer.config.setdefault("myst_extensions", set())
-            self.state._renderer.config["myst_extensions"].add("html_image")
-            self.state.nested_parse(self.content, self.content_offset, node)
+            state._renderer.md_config.enable_extensions = list(
+                state._renderer.md_config.enable_extensions
+            ) + ["html_image"]
+            state.nested_parse(self.content, self.content_offset, node)
         finally:
-            self.state._renderer.config["myst_extensions"] = myst_extensions
+            state._renderer.md_config.enable_extensions = myst_extensions
 
         if not len(node.children) == 2:
             return [
diff --git a/myst_parser/docutils_renderer.py b/myst_parser/docutils_renderer.py
index 4c7340e..aac3655 100644
--- a/myst_parser/docutils_renderer.py
+++ b/myst_parser/docutils_renderer.py
@@ -8,6 +8,7 @@ from copy import deepcopy
 from datetime import date, datetime
 from types import ModuleType
 from typing import (
+    TYPE_CHECKING,
     Any,
     Dict,
     Iterator,
@@ -42,6 +43,7 @@ from markdown_it.renderer import RendererProtocol
 from markdown_it.token import Token
 from markdown_it.tree import SyntaxTreeNode
 
+from myst_parser.main import MdParserConfig
 from myst_parser.mocking import (
     MockIncludeDirective,
     MockingError,
@@ -54,6 +56,9 @@ from .html_to_nodes import html_to_nodes
 from .parse_directives import DirectiveParsingError, parse_directive_text
 from .utils import is_external_url
 
+if TYPE_CHECKING:
+    from sphinx.environment import BuildEnvironment
+
 
 def make_document(source_path="notset", parser_cls=RSTParser) -> nodes.document:
     """Create a new docutils document, with the parser classes' default settings."""
@@ -90,26 +95,47 @@ class DocutilsRenderer(RendererProtocol):
             if k.startswith("render_") and k != "render_children"
         }
 
+    def __getattr__(self, name: str):
+        """Warn when the renderer has not been setup yet."""
+        if name in (
+            "md_env",
+            "md_config",
+            "md_options",
+            "document",
+            "current_node",
+            "reporter",
+            "language_module_rst",
+            "_level_to_elem",
+        ):
+            raise AttributeError(
+                f"'{name}' attribute is not available until setup_render() is called"
+            )
+        raise AttributeError(
+            f"'{type(self).__name__}' object has no attribute '{name}'"
+        )
+
     def setup_render(
         self, options: Dict[str, Any], env: MutableMapping[str, Any]
     ) -> None:
         """Setup the renderer with per render variables."""
         self.md_env = env
-        self.config: Dict[str, Any] = options
-        self.document: nodes.document = self.config.get("document", make_document())
-        self.current_node: nodes.Element = self.config.get(
-            "current_node", self.document
-        )
+        self.md_options = options
+        self.md_config: MdParserConfig = options["myst_config"]
+        self.document: nodes.document = options.get("document", make_document())
+        self.current_node: nodes.Element = options.get("current_node", self.document)
         self.reporter: Reporter = self.document.reporter
         # note there are actually two possible language modules:
         # one from docutils.languages, and one from docutils.parsers.rst.languages
         self.language_module_rst: ModuleType = get_language_rst(
             self.document.settings.language_code
         )
-        self._level_to_elem: Dict[int, nodes.Element] = {0: self.document}
+        # a mapping of heading levels to its currently associated node
+        self._level_to_elem: Dict[int, Union[nodes.document, nodes.section]] = {
+            0: self.document
+        }
 
     @property
-    def sphinx_env(self) -> Optional[Any]:
+    def sphinx_env(self) -> Optional["BuildEnvironment"]:
         """Return the sphinx env, if using Sphinx."""
         try:
             return self.document.settings.env
@@ -131,7 +157,7 @@ class DocutilsRenderer(RendererProtocol):
         to handle suppressed warning types.
         """
         kwargs = {"line": line} if line is not None else {}
-        msg_node = self.reporter.warning(message, **kwargs)
+        msg_node = self.reporter.warning(f"{message} [{wtype}.{subtype}]", **kwargs)
         if append_to is not None:
             append_to.append(msg_node)
         return msg_node
@@ -200,9 +226,6 @@ class DocutilsRenderer(RendererProtocol):
                 append_to=self.document,
             )
 
-        if not self.config.get("output_footnotes", True):
-            return self.document
-
         # we don't use the foot_references stored in the env
         # since references within directives/roles will have been added after
         # those from the initial markdown parse
@@ -212,7 +235,7 @@ class DocutilsRenderer(RendererProtocol):
             if refnode["refname"] not in foot_refs:
                 foot_refs[refnode["refname"]] = True
 
-        if foot_refs and self.config.get("myst_footnote_transition", False):
+        if foot_refs and self.md_config.footnote_transition:
             self.current_node.append(nodes.transition(classes=["footnotes"]))
         for footref in foot_refs:
             foot_ref_tokens = self.md_env["foot_refs"].get(footref, [])
@@ -261,25 +284,36 @@ class DocutilsRenderer(RendererProtocol):
             substitution_node["names"].append(f"wordcount-{key}")
             self.document.note_substitution_def(substitution_node, f"wordcount-{key}")
 
-    def nested_render_text(self, text: str, lineno: int) -> None:
-        """Render unparsed text.
+    def nested_render_text(
+        self, text: str, lineno: int, inline: bool = False, allow_headings: bool = True
+    ) -> None:
+        """Render unparsed text (appending to the current node).
 
         :param text: the text to render
         :param lineno: the starting line number of the text, within the full source
+        :param inline: whether the text is inline or block
+        :param allow_headings: whether to allow headings in the text
         """
+        if inline:
+            tokens = self.md.parseInline(text, self.md_env)
+        else:
+            tokens = self.md.parse(text + "\n", self.md_env)
 
-        tokens = self.md.parse(text + "\n", self.md_env)
+        # remove front matter, if present, e.g. from included documents
+        if tokens and tokens[0].type == "front_matter":
+            tokens.pop(0)
 
         # update the line numbers
         for token in tokens:
             if token.map:
                 token.map = [token.map[0] + lineno, token.map[1] + lineno]
 
-        # remove front matter
-        if tokens and tokens[0].type == "front_matter":
-            tokens.pop(0)
-
-        self._render_tokens(tokens)
+        current_match_titles = self.md_env.get("match_titles", None)
+        try:
+            self.md_env["match_titles"] = allow_headings
+            self._render_tokens(tokens)
+        finally:
+            self.md_env["match_titles"] = current_match_titles
 
     @contextmanager
     def current_node_context(
@@ -294,6 +328,7 @@ class DocutilsRenderer(RendererProtocol):
         self.current_node = current_node
 
     def render_children(self, token: SyntaxTreeNode) -> None:
+        """Render the children of a token."""
         for child in token.children or []:
             if f"render_{child.type}" in self.rules:
                 self.rules[f"render_{child.type}"](child)
@@ -324,31 +359,35 @@ class DocutilsRenderer(RendererProtocol):
             for child in node.traverse():
                 self.add_line_and_source_path(child, token)
 
-    def is_section_level(self, level, section):
-        return self._level_to_elem.get(level, None) == section
-
-    def add_section(self, section, level):
+    def update_section_level_state(self, section: nodes.section, level: int) -> None:
+        """Update the section level state, with the new current section and level."""
+        # find the closest parent section
         parent_level = max(
             section_level
             for section_level in self._level_to_elem
             if level > section_level
         )
+        parent = self._level_to_elem[parent_level]
 
+        # if we are jumping up to a non-consecutive level,
+        # then warn about this, since this will not be propagated in the docutils AST
         if (level > parent_level) and (parent_level + 1 != level):
+            msg = f"Non-consecutive header level increase; H{parent_level} to H{level}"
+            if parent_level == 0:
+                msg = f"Document headings start at H{level}, not H1"
             self.create_warning(
-                "Non-consecutive header level increase; {} to {}".format(
-                    parent_level, level
-                ),
+                msg,
                 line=section.line,
                 subtype="header",
                 append_to=self.current_node,
             )
 
-        parent = self._level_to_elem[parent_level]
+        # append the new section to the parent
         parent.append(section)
+        # update the state for this section level
         self._level_to_elem[level] = section
 
-        # Prune level to limit
+        # Remove all descendant sections from the section level state
         self._level_to_elem = {
             section_level: section
             for section_level, section in self._level_to_elem.items()
@@ -485,9 +524,7 @@ class DocutilsRenderer(RendererProtocol):
                 lex_tokens = Lexer(
                     text,
                     lexer_name or "",
-                    "short"
-                    if self.config.get("myst_highlight_code_blocks", True)
-                    else "none",
+                    "short" if self.md_config.highlight_code_blocks else "none",
                 )
             except LexerError as err:
                 self.reporter.warning(
@@ -534,7 +571,7 @@ class DocutilsRenderer(RendererProtocol):
         info = token.info.strip() if token.info else token.info
         language = info.split()[0] if info else ""
 
-        if not self.config.get("commonmark_only", False) and language == "{eval-rst}":
+        if not self.md_config.commonmark_only and language == "{eval-rst}":
             # copy necessary elements (source, line no, env, reporter)
             newdoc = make_document()
             newdoc["source"] = self.document["source"]
@@ -550,7 +587,7 @@ class DocutilsRenderer(RendererProtocol):
             self.current_node.extend(newdoc[:])
             return
         elif (
-            not self.config.get("commonmark_only", False)
+            not self.md_config.commonmark_only
             and language.startswith("{")
             and language.endswith("}")
         ):
@@ -566,7 +603,7 @@ class DocutilsRenderer(RendererProtocol):
         node = self.create_highlighted_code_block(
             text,
             language,
-            number_lines=language in self.config.get("myst_number_code_blocks", ()),
+            number_lines=language in self.md_config.number_code_blocks,
             source=self.document["source"],
             line=token_line(token, 0) or None,
         )
@@ -578,10 +615,11 @@ class DocutilsRenderer(RendererProtocol):
         return (
             self.sphinx_env is not None
             and "myst_update_mathjax" in self.sphinx_env.config
-            and self.sphinx_env.config.myst_update_mathjax
+            and self.md_config.update_mathjax
         )
 
     def render_heading(self, token: SyntaxTreeNode) -> None:
+        """Render a heading, e.g. `# Heading`."""
 
         if self.md_env.get("match_titles", None) is False:
             # this can occur if a nested parse is performed by a directive
@@ -599,35 +637,35 @@ class DocutilsRenderer(RendererProtocol):
                 self.render_children(token)
             return
 
-        # Test if we're replacing a section level first
         level = int(token.tag[1])
-        if isinstance(self.current_node, nodes.section):
-            if self.is_section_level(level, self.current_node):
-                self.current_node = cast(nodes.Element, self.current_node.parent)
-
-        title_node = nodes.title(token.children[0].content if token.children else "")
-        self.add_line_and_source_path(title_node, token)
 
+        # create the section node
         new_section = nodes.section()
+        self.add_line_and_source_path(new_section, token)
+        # if a top level section,
+        # then add classes to set default mathjax processing to false
+        # we then turn it back on, on a per-node basis
         if level == 1 and self.blocks_mathjax_processing:
             new_section["classes"].extend(["tex2jax_ignore", "mathjax_ignore"])
-        self.add_line_and_source_path(new_section, token)
-        new_section.append(title_node)
 
-        self.add_section(new_section, level)
+        # update the state of the section levels
+        self.update_section_level_state(new_section, level)
 
-        self.current_node = title_node
-        self.render_children(token)
+        # create the title for this section
+        title_node = nodes.title(token.children[0].content if token.children else "")
+        self.add_line_and_source_path(title_node, token)
+        new_section.append(title_node)
+        # render the heading children into the title
+        with self.current_node_context(title_node):
+            self.render_children(token)
 
-        assert isinstance(self.current_node, nodes.title)
-        text = self.current_node.astext()
-        # if self.translate_section_name:
-        #     text = self.translate_section_name(text)
-        name = nodes.fully_normalize_name(text)
-        section = cast(nodes.section, self.current_node.parent)
-        section["names"].append(name)
-        self.document.note_implicit_target(section, section)
-        self.current_node = section
+        # create a target reference for the section, based on the heading text
+        name = nodes.fully_normalize_name(title_node.astext())
+        new_section["names"].append(name)
+        self.document.note_implicit_target(new_section, new_section)
+
+        # set the section as the current node for subsequent rendering
+        self.current_node = new_section
 
     def render_link(self, token: SyntaxTreeNode) -> None:
         """Parse `<http://link.com>` or `[text](link "title")` syntax to docutils AST:
@@ -642,14 +680,14 @@ class DocutilsRenderer(RendererProtocol):
         if token.markup == "autolink":
             return self.render_autolink(token)
 
-        if self.config.get("myst_all_links_external", False):
+        if self.md_config.all_links_external:
             return self.render_external_url(token)
 
         # Check for external URL
         url_scheme = urlparse(cast(str, token.attrGet("href") or "")).scheme
-        allowed_url_schemes = self.config.get("myst_url_schemes", None)
+        allowed_url_schemes = self.md_config.url_schemes
         if (allowed_url_schemes is None and url_scheme) or (
-            url_scheme in allowed_url_schemes
+            allowed_url_schemes is not None and url_scheme in allowed_url_schemes
         ):
             return self.render_external_url(token)
 
@@ -712,14 +750,14 @@ class DocutilsRenderer(RendererProtocol):
         self.add_line_and_source_path(img_node, token)
         destination = cast(str, token.attrGet("src") or "")
 
-        if self.config.get("relative-images", None) is not None and not is_external_url(
+        if self.md_env.get("relative-images", None) is not None and not is_external_url(
             destination, None, True
         ):
             # make the path relative to an "including" document
             # this is set when using the `relative-images` option of the MyST `include` directive
             destination = os.path.normpath(
                 os.path.join(
-                    self.config.get("relative-images", ""),
+                    self.md_env.get("relative-images", ""),
                     os.path.normpath(destination),
                 )
             )
@@ -784,7 +822,7 @@ class DocutilsRenderer(RendererProtocol):
         self.current_node.extend(
             html_meta_to_nodes(
                 {
-                    **self.config.get("myst_html_meta", {}),
+                    **self.md_config.html_meta,
                     **html_meta,
                 },
                 document=self.document,
@@ -837,26 +875,23 @@ class DocutilsRenderer(RendererProtocol):
         field_list.source, field_list.line = self.document["source"], line
 
         bibliofields = get_language(language_code).bibliographic_fields
-        state_machine = MockStateMachine(self, line)
-        state = MockState(self, state_machine, line)
 
         for key, value in data.items():
             if not isinstance(value, (str, int, float, date, datetime)):
                 value = json.dumps(value)
             value = str(value)
+            body = nodes.paragraph()
+            body.source, body.line = self.document["source"], line
             if key in bibliofields:
-                para_nodes, _ = state.inline_text(value, line)
+                with self.current_node_context(body):
+                    self.nested_render_text(value, line, inline=True)
             else:
-                para_nodes = [nodes.literal(value, value)]
-
-            body_children = [nodes.paragraph("", "", *para_nodes)]
-            body_children[0].source = self.document["source"]
-            body_children[0].line = 0
+                body += nodes.literal(value, value)
 
             field_node = nodes.field()
             field_node.source = value
             field_node += nodes.field_name(key, "", nodes.Text(key, key))
-            field_node += nodes.field_body(value, *body_children)
+            field_node += nodes.field_body(value, *[body])
             field_list += field_node
 
         return field_list
@@ -1252,8 +1287,8 @@ class DocutilsRenderer(RendererProtocol):
         position = token_line(token)
 
         # front-matter substitutions take priority over config ones
-        variable_context = {
-            **self.config.get("myst_substitutions", {}),
+        variable_context: Dict[str, Any] = {
+            **self.md_config.substitutions,
             **getattr(self.document, "fm_substitutions", {}),
         }
         if self.sphinx_env is not None:
@@ -1290,10 +1325,6 @@ class DocutilsRenderer(RendererProtocol):
             self.current_node += [error_msg]
             return
 
-        # parse rendered text
-        state_machine = MockStateMachine(self, position)
-        state = MockState(self, state_machine, position)
-
         # TODO improve error reporting;
         # at present, for a multi-line substitution,
         # an error may point to a line lower than the substitution
@@ -1302,23 +1333,14 @@ class DocutilsRenderer(RendererProtocol):
 
         # we record used references before nested parsing, then remove them after
         self.document.sub_references.update(references)
-
         try:
             if inline and not REGEX_DIRECTIVE_START.match(rendered):
-                sub_nodes, _ = state.inline_text(rendered, position)
+                self.nested_render_text(rendered, position, inline=True)
             else:
-                base_node = nodes.Element()
-                state.nested_parse(
-                    StringList(rendered.splitlines(), self.document["source"]),
-                    0,
-                    base_node,
-                )
-                sub_nodes = base_node.children
+                self.nested_render_text(rendered, position, allow_headings=False)
         finally:
             self.document.sub_references.difference_update(references)
 
-        self.current_node.extend(sub_nodes)
-
 
 def html_meta_to_nodes(
     data: Dict[str, Any], document: nodes.document, line: int, reporter: Reporter
diff --git a/myst_parser/html_to_nodes.py b/myst_parser/html_to_nodes.py
index 1576a21..d6bd7b0 100644
--- a/myst_parser/html_to_nodes.py
+++ b/myst_parser/html_to_nodes.py
@@ -35,10 +35,8 @@ def html_to_nodes(
     text: str, line_number: int, renderer: "DocutilsRenderer"
 ) -> List[nodes.Element]:
     """Convert HTML to docutils nodes."""
-    enable_html_img = "html_image" in renderer.config.get("myst_extensions", [])
-    enable_html_admonition = "html_admonition" in renderer.config.get(
-        "myst_extensions", []
-    )
+    enable_html_img = "html_image" in renderer.md_config.enable_extensions
+    enable_html_admonition = "html_admonition" in renderer.md_config.enable_extensions
 
     if not (enable_html_img or enable_html_admonition):
         return default_html(text, renderer.document["source"], line_number)
diff --git a/myst_parser/main.py b/myst_parser/main.py
index 30f98cc..5994855 100644
--- a/myst_parser/main.py
+++ b/myst_parser/main.py
@@ -40,7 +40,7 @@ class MdParserConfig:
         validator=instance_of(bool),
         metadata={"help": "Use strict CommonMark parser"},
     )
-    enable_extensions: Iterable[str] = attr.ib(
+    enable_extensions: Sequence[str] = attr.ib(
         factory=lambda: ["dollarmath"], metadata={"help": "Enable extensions"}
     )
 
@@ -232,7 +232,7 @@ def create_md_parser(
         md = MarkdownIt("commonmark", renderer_cls=renderer).use(
             wordcount_plugin, per_minute=config.words_per_minute
         )
-        md.options.update({"commonmark_only": True})
+        md.options.update({"myst_config": config})
         return md
 
     md = (
@@ -291,22 +291,9 @@ def create_md_parser(
 
     md.options.update(
         {
-            # standard options
             "typographer": typographer,
             "linkify": "linkify" in config.enable_extensions,
-            # myst options
-            "commonmark_only": False,
-            "myst_extensions": set(
-                list(config.enable_extensions)
-                + (["heading_anchors"] if config.heading_anchors is not None else [])
-            ),
-            "myst_all_links_external": config.all_links_external,
-            "myst_url_schemes": config.url_schemes,
-            "myst_substitutions": config.substitutions,
-            "myst_html_meta": config.html_meta,
-            "myst_footnote_transition": config.footnote_transition,
-            "myst_number_code_blocks": config.number_code_blocks,
-            "myst_highlight_code_blocks": config.highlight_code_blocks,
+            "myst_config": config,
         }
     )
 
diff --git a/myst_parser/mocking.py b/myst_parser/mocking.py
index b562e14..68a1414 100644
--- a/myst_parser/mocking.py
+++ b/myst_parser/mocking.py
@@ -144,17 +144,13 @@ class MockState:
             since nested heading would break the document structure)
         """
         sm_match_titles = self.state_machine.match_titles
-        render_match_titles = self._renderer.md_env.get("match_titles", None)
-        self.state_machine.match_titles = self._renderer.md_env[
-            "match_titles"
-        ] = match_titles
-
         with self._renderer.current_node_context(node):
             self._renderer.nested_render_text(
-                "\n".join(block), self._lineno + input_offset
+                "\n".join(block),
+                self._lineno + input_offset,
+                allow_headings=match_titles,
             )
         self.state_machine.match_titles = sm_match_titles
-        self._renderer.md_env["match_titles"] = render_match_titles
 
     def parse_target(self, block, block_text, lineno: int):
         """
@@ -174,33 +170,14 @@ class MockState:
     ) -> Tuple[List[nodes.Element], List[nodes.Element]]:
         """Parse text with only inline rules.
 
-        :return: (list of nodes, list of messages)
-
+        :returns: (list of nodes, list of messages)
         """
+        container = nodes.Element()
+        with self._renderer.current_node_context(container):
+            self._renderer.nested_render_text(text, lineno, inline=True)
+
         # TODO return messages?
-        messages: List[nodes.Element] = []
-        paragraph = nodes.paragraph("")
-
-        tokens = self._renderer.md.parseInline(text, self._renderer.md_env)
-        for token in tokens:
-            if token.map:
-                token.map = [token.map[0] + lineno, token.map[1] + lineno]
-
-        # here we instantiate a new renderer,
-        # so that the nested parse does not effect the current renderer,
-        # but we use the same env, so that link references, etc
-        # are added to the global parse.
-        nested_renderer = self._renderer.__class__(self._renderer.md)
-        options = {k: v for k, v in self._renderer.config.items()}
-        options.update(
-            {
-                "document": self.document,
-                "current_node": paragraph,
-                "output_footnotes": False,
-            }
-        )
-        nested_renderer.render(tokens, options, self._renderer.md_env)
-        return paragraph.children, messages
+        return container.children, []
 
     # U+2014 is an em-dash:
     attribution_pattern = re.compile("^((?:---?(?!-)|\u2014) *)(.+)")
@@ -464,21 +441,23 @@ class MockIncludeDirective:
             self.renderer.reporter.source = str(path)
             self.renderer.reporter.get_source_and_line = lambda l: (str(path), l)
             if "relative-images" in self.options:
-                self.renderer.config["relative-images"] = os.path.relpath(
+                self.renderer.md_env["relative-images"] = os.path.relpath(
                     path.parent, source_dir
                 )
             if "relative-docs" in self.options:
-                self.renderer.config["relative-docs"] = (
+                self.renderer.md_env["relative-docs"] = (
                     self.options["relative-docs"],
                     source_dir,
                     path.parent,
                 )
-            self.renderer.nested_render_text(file_content, startline + 1)
+            self.renderer.nested_render_text(
+                file_content, startline + 1, allow_headings=True
+            )
         finally:
             self.renderer.document["source"] = source
             self.renderer.reporter.source = rsource
-            self.renderer.config.pop("relative-images", None)
-            self.renderer.config.pop("relative-docs", None)
+            self.renderer.md_env.pop("relative-images", None)
+            self.renderer.md_env.pop("relative-docs", None)
             if line_func is not None:
                 self.renderer.reporter.get_source_and_line = line_func
             else:
diff --git a/myst_parser/sphinx_renderer.py b/myst_parser/sphinx_renderer.py
index cecbc90..5b3a175 100644
--- a/myst_parser/sphinx_renderer.py
+++ b/myst_parser/sphinx_renderer.py
@@ -74,7 +74,7 @@ class SphinxRenderer(DocutilsRenderer):
 
         # make the path relative to an "including" document
         # this is set when using the `relative-docs` option of the MyST `include` directive
-        relative_include = self.config.get("relative-docs", None)
+        relative_include = self.md_env.get("relative-docs", None)
         if relative_include is not None and destination.startswith(relative_include[0]):
             source_dir, include_dir = relative_include[1:]
             destination = os.path.relpath(


Test patch:
diff --git a/tests/test_html/test_html_to_nodes.py b/tests/test_html/test_html_to_nodes.py
index 6238430..c43d0a1 100644
--- a/tests/test_html/test_html_to_nodes.py
+++ b/tests/test_html/test_html_to_nodes.py
@@ -3,9 +3,10 @@ from unittest.mock import Mock
 
 import pytest
 from docutils import nodes
-from markdown_it.utils import read_fixture_file
+from pytest_param_files import with_parameters
 
 from myst_parser.html_to_nodes import html_to_nodes
+from myst_parser.main import MdParserConfig
 
 FIXTURE_PATH = Path(__file__).parent
 
@@ -18,7 +19,7 @@ def mock_renderer():
         return [node]
 
     return Mock(
-        config={"myst_extensions": ["html_image", "html_admonition"]},
+        md_config=MdParserConfig(enable_extensions=["html_image", "html_admonition"]),
         document={"source": "source"},
         reporter=Mock(
             warning=Mock(return_value=nodes.system_message("warning")),
@@ -28,18 +29,8 @@ def mock_renderer():
     )
 
 
-@pytest.mark.parametrize(
-    "line,title,text,expected",
-    read_fixture_file(FIXTURE_PATH / "html_to_nodes.md"),
-    ids=[
-        f"{i[0]}-{i[1]}" for i in read_fixture_file(FIXTURE_PATH / "html_to_nodes.md")
-    ],
-)
-def test_html_to_nodes(line, title, text, expected, mock_renderer):
+@with_parameters(FIXTURE_PATH / "html_to_nodes.md")
+def test_html_to_nodes(file_params, mock_renderer):
     output = nodes.container()
-    output += html_to_nodes(text, line_number=0, renderer=mock_renderer)
-    try:
-        assert output.pformat().rstrip() == expected.rstrip()
-    except AssertionError:
-        print(output.pformat())
-        raise
+    output += html_to_nodes(file_params.content, line_number=0, renderer=mock_renderer)
+    file_params.assert_expected(output.pformat(), rstrip=True)
diff --git a/tests/test_html/test_parse_html.py b/tests/test_html/test_parse_html.py
index ff9bbaf..a977541 100644
--- a/tests/test_html/test_parse_html.py
+++ b/tests/test_html/test_parse_html.py
@@ -1,41 +1,24 @@
 from pathlib import Path
 
-import pytest
-from markdown_it.utils import read_fixture_file
+from pytest_param_files import with_parameters
 
 from myst_parser.parse_html import tokenize_html
 
 FIXTURE_PATH = Path(__file__).parent
 
 
-@pytest.mark.parametrize(
-    "line,title,text,expected",
-    read_fixture_file(FIXTURE_PATH / "html_ast.md"),
-    ids=[f"{i[0]}-{i[1]}" for i in read_fixture_file(FIXTURE_PATH / "html_ast.md")],
-)
-def test_html_ast(line, title, text, expected):
-    tokens = "\n".join(repr(t) for t in tokenize_html(text).walk(include_self=True))
-    try:
-        assert tokens.rstrip() == expected.rstrip()
-    except AssertionError:
-        print(tokens)
-        raise
-
-
-@pytest.mark.parametrize(
-    "line,title,text,expected",
-    read_fixture_file(FIXTURE_PATH / "html_round_trip.md"),
-    ids=[
-        f"{i[0]}-{i[1]}" for i in read_fixture_file(FIXTURE_PATH / "html_round_trip.md")
-    ],
-)
-def test_html_round_trip(line, title, text, expected):
-    ast = tokenize_html(text)
-    try:
-        assert str(ast).rstrip() == expected.rstrip()
-    except AssertionError:
-        print(str(ast))
-        raise
+@with_parameters(FIXTURE_PATH / "html_ast.md")
+def test_html_ast(file_params):
+    tokens = "\n".join(
+        repr(t) for t in tokenize_html(file_params.content).walk(include_self=True)
+    )
+    file_params.assert_expected(tokens, rstrip=True)
+
+
+@with_parameters(FIXTURE_PATH / "html_round_trip.md")
+def test_html_round_trip(file_params):
+    ast = tokenize_html(file_params.content)
+    file_params.assert_expected(str(ast), rstrip=True)
 
 
 def test_render_overrides():
diff --git a/tests/test_renderers/fixtures/reporter_warnings.md b/tests/test_renderers/fixtures/reporter_warnings.md
index 3cbe211..22b5332 100644
--- a/tests/test_renderers/fixtures/reporter_warnings.md
+++ b/tests/test_renderers/fixtures/reporter_warnings.md
@@ -3,7 +3,7 @@ Duplicate Reference definitions:
 [a]: b
 [a]: c
 .
-<string>:2: (WARNING/2) Duplicate reference definition: A
+<string>:2: (WARNING/2) Duplicate reference definition: A [myst.ref]
 .
 
 Missing Reference:
@@ -77,12 +77,19 @@ x
 <string>:2: (ERROR/3) Invalid context: the "date" directive can only be used within a substitution definition.
 .
 
+Do not start headings at H1:
+.
+## title 1
+.
+<string>:1: (WARNING/2) Document headings start at H2, not H1 [myst.header]
+.
+
 Non-consecutive headings:
 .
 # title 1
 ### title 3
 .
-<string>:2: (WARNING/2) Non-consecutive header level increase; 1 to 3
+<string>:2: (WARNING/2) Non-consecutive header level increase; H1 to H3 [myst.header]
 .
 
 multiple footnote definitions
@@ -92,7 +99,7 @@ multiple footnote definitions
 [^a]: definition 1
 [^a]: definition 2
 .
-<string>:: (WARNING/2) Multiple footnote definitions found for label: 'a'
+<string>:: (WARNING/2) Multiple footnote definitions found for label: 'a' [myst.footnote]
 .
 
 Warnings in eval-rst
@@ -136,7 +143,7 @@ header nested in admonition
 # Header
 ```
 .
-<string>:2: (WARNING/2) Disallowed nested header found, converting to rubric
+<string>:2: (WARNING/2) Disallowed nested header found, converting to rubric [myst.nested_header]
 .
 
 nested parse warning