Source code for click_extra.rst_to_myst

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Convert `sphinx-apidoc` RST output to MyST markdown.

```{note}
The converter handles only the narrow RST subset that `sphinx-apidoc`
generates: section headings (title + underline), `automodule` directives
with indented options, and structural headers like `Submodules`.

Autodoc directives cannot be used as native MyST directives because they
perform internal rST nested parsing that requires an rST parser context
only ``{eval-rst}`` provides. See [MyST-Parser #587](https://github.com/executablebooks/MyST-Parser/issues/587).
```
"""

from __future__ import annotations

import logging
import re
from pathlib import Path

#: RST underline characters mapped to Markdown heading levels.
_RST_UNDERLINE_LEVELS: dict[str, str] = {
    "=": "#",
    "-": "##",
    "~": "###",
    "^": "####",
}


def _clean_heading(title: str) -> str:
    """Normalize an RST heading for markdown.

    Strips RST-specific backslash escapes (e.g. `\\_` used to prevent
    reference interpretation) and wraps qualified Python identifiers in
    backticks so they render as code.

    ```{note}
    `sphinx-apidoc` produces headings like
    `weather\\_station\\_manager.managers.apm module`.  The backslash
    escapes are necessary in RST but meaningless in markdown, where they
    cause a tug-of-war with `mdformat` (which strips them on every
    reformat pass).  Wrapping the identifier in backticks makes the
    escaping moot and produces cleaner output.
    ```
    """
    # Strip RST backslash-escapes (\_  β†’  _).
    title = title.replace("\\_", "_")
    # Wrap Python identifiers in backticks when followed by "module" or "package".
    return re.sub(r"^([\w.]+) (module|package)$", r"`\1` \2", title)


[docs] def convert_apidoc_rst_to_myst(content: str) -> str: """Convert `sphinx-apidoc` RST to MyST markdown with ``{eval-rst}`` blocks. :param content: RST content produced by `sphinx-apidoc`. :return: Equivalent MyST markdown. """ lines = content.splitlines() result: list[str] = [] i = 0 while i < len(lines): # Section header: a text line followed by a line of identical underline chars. if ( i + 1 < len(lines) and lines[i + 1] and lines[i + 1][0] in _RST_UNDERLINE_LEVELS and re.fullmatch(re.escape(lines[i + 1][0]) + r"+", lines[i + 1]) ): level = _RST_UNDERLINE_LEVELS[lines[i + 1][0]] title = _clean_heading(lines[i]) result.append(f"{level} {title}") i += 2 continue # Directive block: starts with ".. directive::" and includes indented body. if lines[i].startswith(".. "): block = [lines[i]] i += 1 while i < len(lines): if lines[i].startswith(" "): block.append(lines[i]) i += 1 elif not lines[i].strip(): # Blank line: include if the next line is still indented. if i + 1 < len(lines) and lines[i + 1].startswith(" "): block.append(lines[i]) i += 1 else: break else: break result.append("```{eval-rst}") result.extend(block) result.append("```") continue result.append(lines[i]) i += 1 return "\n".join(result) + "\n"
[docs] def convert_rst_files_in_directory(directory: Path) -> list[Path]: """Convert `sphinx-apidoc` RST files to MyST markdown in the given directory. For each `.rst` file containing `.. automodule::` directives: - If a `.md` file with the same stem exists, delete the `.rst` (the existing markdown takes precedence). - Otherwise, convert the RST content to MyST and write a `.md` file, then delete the `.rst`. :param directory: Directory to scan for `.rst` files. :return: List of newly created `.md` file paths. """ converted: list[Path] = [] for rst_path in sorted(directory.glob("*.rst")): content = rst_path.read_text(encoding="utf-8") # Only process files that contain automodule directives (sphinx-apidoc stubs). if ".. automodule::" not in content: logging.debug(f"Skipping {rst_path.name}: no automodule directive found.") continue md_path = rst_path.with_suffix(".md") if md_path.exists(): logging.info( f"Deleting {rst_path.name}: MyST equivalent {md_path.name} exists." ) rst_path.unlink() continue myst_content = convert_apidoc_rst_to_myst(content) md_path.write_text(myst_content, encoding="utf-8") rst_path.unlink() converted.append(md_path) logging.info(f"Converted {rst_path.name} β†’ {md_path.name}") return converted