Convert RST to MyST Markdown

SkillFiles & storage

rst-to-myst is a skill that converts Ray documentation pages from reStructuredText (.rst) to MyST Markdown (.md). It is used when migrating existing files under doc/source/ to MyST, finishing a partial MyST migration of a directory, or when asked to convert or migrate a doc page to markdown. The conversion is faithful: it preserves rendered HTML and test coverage, following a detailed directive-mapping table while keeping labels, cross-references, and d

Available today. Use it from your connected AI after setup.

Have an existing .rst file or directory under doc/source/ that you want to convert to MyST Markdown.

Then ask your AI: use the Convert RST to MyST Markdown skill

What your AI can do with it

  • Converts existing .rst doc pages under doc/source/ to MyST .md
  • Maps RST directives to MyST equivalents using a detailed table
  • Preserves labels, cross-references, and sphinx-design tabs/dropdowns/card grids
  • Handles doctest/testcode and doc/BUILD.bazel doctest exclusions
  • Runs pre-flight checks that referenced labels, literalinclude, and autodoc targets resolve
  • Verifies via build/doctest runs and a rendered-diff comparison

Getting started

  1. Have an existing .rst file or directory under doc/source/ that you want to convert to MyST Markdown.
  2. Invoke the skill with the file(s) or directory as the argument.
  3. The skill performs pre-flight checks to ensure all referenced labels, literalinclude, and autodoc targets resolve.
  4. It converts the content following the RST-to-MyST directive mapping, preserving labels and cross-references.
  5. After conversion, run the build and doctest verification steps, and compare the rendered diff to confirm a faithful conversion.

What this skill tells your AI

The instructions your AI receives, as published by ray-project/ray in doc/.claude/skills/rst-to-myst/SKILL.md and read by ahel’s review.

MyST Markdown is the standard for new Ray doc pages — doc/.claude/CLAUDE.md declares it, and a lint check rejects newly-added .rst. This skill converts an existing .rst page (or a batch) to MyST .md faithfully: format only, preserving the rendered HTML and any test coverage.

The Ray docs build with fail_on_warning: true (.readthedocs.yaml), so most of a sloppy conversion doesn't render wrong — it fails the build. Most of this skill is about the handful of constructs that break the build or silently drop test coverage if mishandled.

A green build is necessary and not sufficient. A second, smaller class of mistake renders wrong and builds clean, with no warning anywhere: a lost page title, an image that changes markup, a directive whose nested RST degrades to visible text. Nothing in steps 1–3 of Verification can see any of it, because they all look at source or at reference resolution. Only the rendered diff in step 4 can. Run it.


When to use this skill

Use when:

  • Migrating one or more existing doc/source/**/*.rst files to MyST .md.
  • Finishing a partial MyST migration of a directory.

Not for:

  • Authoring a brand-new page — just write .md directly (no conversion needed).
  • Editing .rst content you're not converting (edits to existing .rst aren't lint-flagged).
  • Notebooks (.ipynb) — different workflow.
  • Bundling unrelated content rewrites — keep the diff a pure format conversion (see Golden rule).

Golden rule: faithful conversion

Convert the format, not the content. The rendered HTML should be byte-equivalent to the pre-conversion page, except for deliberate, called-out light cleanup (a dead link, a stale version ref). No restructuring, no rewording of sound content, no heading-level "fixes."

Why: the decisive regression check compares the PR's Read the Docs preview against /en/master (per doc/.claude/CLAUDE.md). A faithful conversion makes that diff empty and the PR trivially reviewable. Capitalization nits ("github"→"GitHub"), heading-case changes, and rewraps all add noise and invite scope debates — leave them unless explicitly asked.

Faithful does not mean byte-copying links. A few RST link forms render fine in RST but are wrong in MyST and fail fail_on_warning (see Hard rule 2). Translate them; don't transcribe them.


Procedure

1. Read the source(s) and the two style models

Read every .rst you're converting in full. Also read the canonical MyST examples in the same tree for house style: doc/source/ray-contribute/docs.md and doc/source/ray-contribute/agent-development.md (frontmatter, (label)=, {contents}, admonition and image conventions).

2. Pre-flight — verify every reference resolves before converting

A stale literalinclude path, autodoc symbol, or {ref} target turns into a build failure under fail_on_warning. Confirm each up front:

  • Labels this file defines — grep -nE '^\.\. _.*:' file.rst. You must preserve every one (Hard rule 1). Note them.
  • External callers of those labels — grep -rn '<label-name>' doc/source python rllib. Confirms they're load-bearing (and that you must not rename them).
  • literalinclude targets — the file exists; :lines:/:start-after:/:end-before: markers still resolve.
  • autodoc targets — every .. autofunction::/.. autoclass:: symbol imports.
  • Who references THIS file — grep the bare filename across all of doc/, e.g. grep -rn 'getting-involved' doc/source. Do not grep only the dir/stem.rst path: siblings link relatively ([text](./getting-involved.rst), (getting-involved.rst)), and those break silently when you rename the file. Classify each hit (see "Reference updates"); most are no-ops, but doc/BUILD.bazel, {include}, and any relative .rst link from another page are not. Widen the grep past doc/ for a landing or index page — a page can be consumed by a file outside the Sphinx build that a doc/-scoped grep never sees. rllib/index.rst was .. include::d by rllib/README.rst (a package README, not a doc page and unable to hold Markdown); converting the index left three stale include paths in an .rst file, found only by grepping the package dir and repo root (grep -rn '<stem>' rllib python . plus a scan for .. include::).

3. Convert using the mapping

Apply the table below construct-by-construct. Keep the source's prose line-wrapping in this pass, verbatim — it keeps the conversion diff line-aligned with the .rst, which is the only thing that lets a reviewer confirm at a glance that the words didn't change. Then apply the Hard rules and Construct notes.

Ray's .md prose is soft-wrapped, one line per paragraph and per list item, so a converted page shouldn't stay hard-wrapped. Reflow it as a second, whitespace-only commit in the same PR, using the ray-soft-wrap skill:

python3 doc/.claude/skills/ray-soft-wrap/scripts/softwrap.py <the new .md files>
python3 doc/.claude/skills/ray-soft-wrap/scripts/verify.py   <the new .md files>

Splitting it into two commits gets both properties: the conversion commit stays reviewable line-by-line against the .rst, and the reflow commit is one a reviewer can skim in seconds because verify.py proves it changed nothing but whitespace — non-whitespace bytes byte-identical, rendered HTML identical, transform idempotent.

verify.py's render check is CommonMark plus GFM tables, so it cannot see MyST-only constructs. It will pass a card grid whose ^^^ header separator got folded into the prose. softwrap.py protects ^^^ and +++ by construction, but when a page leans on a construct the oracle doesn't model, add a structural assertion of your own — for card grids, that the {grid-item-card}, ^^^, and +++ counts still match. The step-4 render diff is the backstop either way.

4. Update references that actually need it

Most don't (see checklist). The ones that do go in the same PR as the file they track.

5. Verify

Static checks → build (RtD) → doctest (if the file is doctest-tested) → regression vs /en/master. See "Verification".

6. Ship

git rm the .rst, add the .md. Commit, push, PR. For the OSS PR conventions (branch base, DCO sign-off, no internal ticket keys, etc.) follow the project's docs-PR workflow.


The mapping

RSTMyST Markdown
.. meta:: / :description:YAML frontmatter myst:\n html_meta:\n description: "…"
.. _label: above a heading(label)= on its own line, blank line, then the heading
==== / ---- underline# / ## … — level by order of appearance, see Hard rule 3
literal (double backtick)`code` (single backtick)
`text` (single backtick)`code` — see Construct notes; the rendered <code> loses a code class that carries no styling
`text <url>`_ / `text <url>`__[text](url)
bare URL https://…<https://…> (angle-bracket autolink — linkify is off)
same-page section link `text <page.html#sec>`_[text](#sec) (fragment) — never keep the .html# URL; see Hard rule 2
:ref:`text <label>`{ref}`text <label>`
:doc:`text <path>`{doc}`text <path>`
.. note:: / .. tip:: / .. warning:::::{note} / :::{tip} / :::{warning} (colon fence)
.. code-block:: LANG / .. code:: LANGfenced ```LANG
.. tab-set:: / .. tab-item:: T::::{tab-set} / :::{tab-item} T (colon fences — see Construct notes)
.. dropdown:: T (:open:):::{dropdown} T with :open: on the next line
.. grid:: 1 2 2 2 (+opts)::::{grid} 1 2 2 2 (colon fence, more colons than the cards it holds)
.. grid-item-card:: / .. grid-item:::::{grid-item-card} / :::{grid-item} — keep ^^^ and +++ on their own lines
.. button-ref:: target / .. button-link:: url```{button-ref} target / ```{button-link} url, options as :key: val, blank line, then the label
.. div:: classes:::{div} classes (sphinx-design; a bare .. div:: takes no argument)
.. testcode:: / .. testoutput:: / .. doctest::```{testcode} / {testoutput} / {doctest} — only for real, executed blocks; see Hard rule 4
.. literalinclude:: P (+opts)```{literalinclude} P with each option as a :key: val line
.. autofunction:: / .. autoclass::wrap in ```{eval-rst} … ``` (keep any adjacent .. _label: inside the same block)
.. list-table:: (+opts)```{list-table} (keep the * - / - body; don't reflow to a Markdown table)
.. contents:: :local:```{contents} with :local:
.. toctree::```{toctree} — entries stay extensionless
.. include:: f.rst (you're converting f)```{include} f.md (convert the included file in the same PR)
.. include:: _shared.rst (shared partial, stays .rst)problematic — see Hard rule 8. MyST surfaces an included .rst partial's directives and comments as literal text, not parsed RST. If the partial renders nothing (all-comment), drop the include.
.. image:: URL```{image} URL — not ![](URL); see Hard rule 5
.. figure:: P (+ caption)```{figure} P with options as :key: val lines, blank line, then the caption
.. title:: Tno MyST equivalent — see Hard rule 5
:: literal blocka plain ``` fence (no language) — see Construct notes
auto-lettered list a. / b. / c.numbered 1. / 2. / 3. — MyST/CommonMark has no alpha lists

Hard rules (get these wrong → broken build or lost test coverage)

  1. Preserve every label name exactly. .. _name: → (name)= (own line, blank line, then the heading it labeled). External {ref}/:ref: callers resolve by name and are format-agnostic, so an unchanged label keeps working from .rst and .md callers alike. A renamed or dropped label breaks every caller. Labels sitting directly above an autodoc directive stay inside the {eval-rst} block as RST (.. _name: next to .. autofunction::); targets created inside eval-rst still register globally. A label directly above a non-heading directive (e.g. a .. warning::) becomes (name)= immediately before the converted :::{warning} — it still anchors.

  2. Links — translate, don't transcribe. Four RST link forms need real translation; left as-is they emit a myst.xref_* warning (→ build failure):

    • Whole-doc links should use the {doc}`text <doc>` role — it resolves to the document and is never ambiguous. A bare [text](sibling.rst) (or [text](sibling.md) pointing at an .rst source) emits myst.xref_missing. An extensionless [text](sibling) works only if the target doc has no same-named label; if it does (e.g. a page carrying both the doc name getting-involved and a (getting-involved)= label), the bare link is ambiguous and emits myst.xref_ambiguous. So just use {doc}. This bites in both directions: a converted file linking to a still-.rst sibling, and an already-.md sibling whose link to the file you renamed now points at a dead .rst. (Re-check the bare-stem grep from pre-flight.)
    • Same-page section links written as a raw page.html#section URL must become a #section fragment ([text](#section)), resolved via myst_heading_anchors. The .html# URL renders in RST but MyST treats it as a cross-reference target and can't find it.
    • Scheme-less bare-domain targets — an RST link whose target has no URL scheme (`PyTorch <pytorch.org>`__, `gymnasium <gymnasium.farama.org>`__). Transcribed faithfully to [PyTorch](pytorch.org), MyST reads the scheme-less target as a cross-reference, not a URL, and emits myst.xref_missing. RST rendered it as a (relative, usually broken) link, so the page looked fine on the old site — the MyST build fails. Add the scheme: [PyTorch](https://pytorch.org). This one transcribes cleanly and slips through review: it was latent in an already-merged conversion (multi-agent-envs.md, ray-project/ray#66062) and silently red-built the stack until the RtD preview caught it.
    • {ref} links are exempt (resolve by label, not path). Extensionless links and toctree entries are exempt (Sphinx resolves to whichever source exists).
    • These myst.xref_* classes and their fixes are also encoded as machine-readable rules in sphinx-fix/rules.yaml — the canonical category→fix table the sphinx-fix skill uses to diagnose a failing build. It's one shared source; keep the two in sync.
  3. Heading levels are assigned by ORDER OF FIRST APPEARANCE of each underline style — not by the character. The same - underline can be ## in one file and ### in another, depending on what appeared before it. Overline+underline is a distinct style from underline-only. Walk the file top to bottom, assign level 1 to the first style seen, level 2 to the next new style, and so on; reproduce that exactly. Do not "fix" surprising nesting (e.g. a section that lands one level too deep) — that's restructuring and changes anchors. When unsure, check the live /en/master render of the page and match it.

  4. doctest/testcode: literal-vs-executed. A meta-doc that demonstrates testcode often contains two kinds of blocks:

    • Illustrative — shown as syntax to copy. In RST they follow a :: and are indented (a literal_block). Convert to a plain ``` fence (no language). These render but are never executed. Leaving the RST directive text (.. testcode::) as literal content inside the fence is correct and faithful.
    • Real — actually run and rendered. In RST they're column-0 .. testcode:: / .. doctest:: directives. Convert to {testcode} / {doctest} / {testoutput} fences. Decide per block. An illustrative block converted to a directive will execute and fail; a real block left as a plain fence silently loses CI coverage. After converting, count the executed directives and confirm the number matches the original's real blocks. (Note: a {testcode} in a doctest-excluded file still renders but doesn't run — see Hard rule 6.)
  5. Page identity — the title and the images. Three constructs change the rendered page while leaving the build green and emitting no warning. All three were caught by the render diff (Verification step 4) after a clean fail_on_warning build, not before it.

    • .. title:: has no MyST equivalent, and it does not work inside {eval-rst}. The docutils directive sets document['title'], which TitleCollector reads for the <title> tag; under MyST that assignment does not reach the real document. A page whose title came from .. title:: silently renders as <no title>. If the page has a heading, delete the directive and let the heading carry the title. If it has none, add an H1 with the same text: env.titles ends up identical, and a page with no heading is almost always one whose body a custom template overrides anyway, so the H1 never renders. Check the template before assuming that.
    • .. image:: is not ![](). An RST .. image:: with no :alt: takes its alt text from the URI and emits a bare <img> at block level. Markdown ![](path) emits alt="" wrapped in a <p>. Use ```{image} path to keep both. ![alt](path) is right only when you're supplying real alt text, which is a content change — call it out.
    • A caption-less .. figure:: is still a <figure>. Converting it to an image of either form drops the <figure> wrapper and its alignment class. Keep ```{figure}.
  6. doc/BUILD.bazel doctest exclusions. The main doctest( rule globs source/**/*.md and source/**/*.rst with a per-file exclude list. If a file you convert is named in that exclude list, rewrite its entry from .rst to .md in the same PR. Otherwise the *.md glob pulls the newly-converted file into doctest, and blocks that were excluded for a reason (e.g. ray.init(...) with no import ray) execute and fail. Conversely, a file that is included (not excluded) stays tested as .md — that's when Hard rule 4 matters most.

  7. An apostrophe in a heading silently changes its anchor. docutils slugifies What's Ray Core? to what-s-ray-core; MyST drops the apostrophe and produces whats-ray-core. The build stays green, nothing warns, and any external link to the old anchor dies. Roughly 17 headings across 15 of the still-unconverted files are affected. The rule: if the heading already carries an explicit label, that label is the anchor callers should be using, and you add nothing. Only when the heading is bare do you add a compat target carrying the old docutils slug — (what-s-next)= above ## What's next?. Never put two targets on one heading. Either way the section id and the headerlink href still change, so treat this as a known, explainable render diff rather than a regression to chase.

  8. Shared includes, substitutions, and raw-HTML images — three traps that build green and render wrong.

    • A shared .rst partial does not include cleanly into MyST. MyST's {include} of a .rst file surfaces the raw content: RST directives and comments render as literal visible text, not parsed RST. A bare {include} and :parser: rst both do it (the latter as a code block), through a green build — so a commented-out partial, which renders nothing on the RST pages, dumps its raw text onto every including page. If the partial renders nothing, drop the include (the page loses nothing); if it carries active content, convert it to .md and include the .md, or inline it. Verified against _includes/rllib/new_api_stack.rst (myst-parser 5.1.0).
    • RST substitutions (|name|) have no MyST equivalent here — the substitution extension is off. A .. |name| image:: definition plus a |name| use does not resolve, and wrapping both in one {eval-rst} block does not save it: docutils raises Undefined substitution referenced (a build error), because eval-rst's nested parse never runs the substitution transform, even with the definition in the same block. Drop the substitution and inline each use as an <img> tag (the html_image extension is on).
    • A raw <img> is only processed inside MyST-parsed content. In prose or a {list-table} cell, Sphinx processes the tag, copies the image to _images/, and rewrites the src. In a raw HTML block — a hand-written <table> you reached for to get colspan — the <img src> passes through verbatim and 404s, again through a green build. So a substitution-driven icon/sigil table becomes a {list-table} with <img> cells (accepting that list-table can't colspan), never a raw HTML <table>.

Construct notes

  • default_role = "code" (doc/source/conf.py): an RST single-backtick already renders as inline code, so single-backtick → single-backtick is the right conversion. It is not byte-identical, though: the RST form emits <code class="code docutils literal notranslate"> and the Markdown form drops the code class. That class carries no styling in Ray's CSS or in pydata-sphinx-theme, and every already-converted page in the tree renders without it, so plain backticks are the house choice and render_diff.py filters this difference by default. Use the {code}`x` role only if you need a byte-identical diff for some other reason.

  • Admonitions: prefer colon fences :::{note} … ::: (the colon_fence MyST extension is on). They nest a ``` code fence cleanly without backtick-counting. Backtick ```{note} also works for simple admonitions with no nested fence. A one-line RST admonition (.. note:: text) becomes :::{note} / text / :::.

  • sphinx-design tab-set / tab-item / dropdown: use colon fences, not backtick fences — ::::{tab-set} › :::{tab-item} Label › ```code ```. The outer fence needs more colons than the one it contains (4 vs 3), and colon fences nest cleanly around backtick code fences, so you avoid backtick-counting entirely. Put directive options (:open:, :sync:, …) on their own line right after the opener. (Confirmed against Ray's RtD build.)

  • linkify is OFF (not in myst_enable_extensions). A bare URL will not autolink — wrap it as <https://…> to preserve the hyperlink. This includes URLs in parentheses like Bazel 7.5.0 (https://…) → (<https://…>).

  • The :: literal-block marker: docutils drops " ::" when it's preceded by whitespace ("…sessions. ::" → "…sessions.") and replaces "x::" (no space) with "x:". Reproduce the resulting prose, then put the block in a plain ``` fence.

  • sphinx-design card grids convert to native MyST — and everything nested inside them has to convert too. A grid of grid-item-cards becomes colon fences, widest on the outside: ::::{grid} 1 2 2 2 › :::{grid-item-card} › a ```{button-ref} backtick fence. Add a colon level for each extra layer (ray-libraries.md runs :::::{grid} › ::::{grid-item-card} › :::{div}). The ^^^ header and +++ footer separators need no translation at all: sphinx-design matches them with REGEX_HEADER/REGEX_FOOTER and splits them out of the raw content lines before anything parses them, so they're format-agnostic.

    The trap is nested_parse. GridDirective, GridItemCardDirective, div, and Ray's own callout/annotations all call self.state.nested_parse, which under MyST parses their content as Markdown. RST left inside a native card doesn't error — it renders as literal text, through a green fail_on_warning build. So a card's nested button-ref, button-link, image, and figure all have to become fences in the same pass, and the render diff (Verification step 4) is the only check that will catch it if one doesn't. This is the nested_parse degradation referenced in step 4.

    Four already-Markdown pages predate this and wrap their whole grid in {eval-rst} (cluster/vms/index.md, cluster/kubernetes/index.md, ray-overview/index.md, serve/index.md). Don't copy that pattern into a new conversion; native is the house choice as of batch 1.

  • list-table: keep the directive (```{list-table}), move options to :key: val lines, and de-indent the * - / - body to column 0. Don't convert it into a native Markdown table.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
44k
Forks
8k
Last commit
Oct 2026

Questions

What kind of tool is rst-to-myst?
It is a skill that guides an AI agent through converting Ray documentation pages from reStructuredText (.rst) to MyST Markdown (.md).
When should I use this skill?
Use it when migrating existing files under doc/source/ to MyST, finishing a partial MyST migration of a directory, or when asked to convert or migrate a doc page to markdown.
Advanced
Item type
skill
Key
rst-to-myst
Source
github.com/ray-project/ray