| name | rst-to-myst |
| description | Convert Ray documentation pages from reStructuredText (.rst) to MyST Markdown (.md). Use when migrating existing files under doc/source/ to MyST, finishing a partial MyST migration of a directory, or when asked to convert/migrate a doc page to markdown. Covers the RST-to-MyST directive mapping, label and cross-reference preservation, sphinx-design tabs/dropdowns/card grids, doctest/testcode handling, the doc/BUILD.bazel doctest exclusions, and the build and doctest verification needed to land a clean docs PR. |
| user-invocable | true |
| argument-hint | <file(s) or directory under doc/source to convert> |
Convert RST to MyST Markdown
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.
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
| RST | MyST 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:: LANG | fenced ```LANG |
.. tab-set:: / .. tab-item:: T | ::::{tab-set} / (colon fences — see Construct notes) |
Hard rules (get these wrong → broken build or lost test coverage)
-
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.
-
Links — translate, don't transcribe. Three 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.
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. ::" → ) and replaces (no space) with . Reproduce the resulting prose, then put the block in a plain fence.
Reference updates — what changes, what doesn't
No-ops (don't touch / don't scope-creep):
- Toctree entries — already extensionless; Sphinx resolves to whichever source exists.
- Extensionless links to the converted doc (e.g.
[text](page) with no extension) — resolve fine.
.html URL references — doc/redirects/current.yaml, CONTRIBUTING.rst, semgrep/lint scripts, and absolute https://docs.ray.io/…/page.html#sec links point at HTML output, which is identical regardless of source format. (Only relative page.html#sec self-links need fixing — Hard rule 2.)
- External
{ref}/:ref: callers — safe as long as labels are preserved (Hard rule 1).
Do change (same PR as the file):
doc/BUILD.bazel — doctest exclude entries (Hard rule 6) and any explicit doc-code test target naming the .rst.
.. include:: / {include} directives pointing at a file you're converting (convert both).
- Relative
.rst links from sibling pages to the file you're renaming — found via the bare-stem grep. Point them at the new doc (extensionless or {doc}).
.claude/ path mentions of the file (e.g. CLAUDE.md, skill/rule files referencing …/development.rst). Re-grep .claude/ for the stem. These are tiny string edits and .claude/ isn't in .buildkite/test.rules.txt, so they don't pull extra CI suites.
Verification
-
Static checks on each new .md — frontmatter parses (skip this for include-only partials, which have none), backtick and ::: colon fences balance, no residual RST leaked outside fences, every label present, executed-directive counts match. Sketch:
import re, yaml
for f in FILES:
t = open(f).read()
if t.startswith('---\n'):
assert yaml.safe_load(t.split('---\n', 2)[1])['myst']['html_meta']['description']
L = t.splitlines()
assert sum(ln.lstrip().startswith('```') for ln in L) % 2 == 0, f"unbalanced ``` {f}"
assert sum(bool(re.match(r'^:{3,}\{', ln)) for ln in L) == sum(bool(re.match(r'^:{3,}\s*$', ln)) for ln in L), f"unbalanced colon fences {f}"
infence = False
for i, ln in enumerate(L, 1):
ln.lstrip().startswith(): infence = infence;
infence:
pat (, , , ):
re.search(pat, ln): ()
Verified Ray-specific facts (as of mid-2026)
doc/source/conf.py: default_role = "code"; myst_enable_extensions includes colon_fence but not linkify; myst_heading_anchors = 3 (so [text](#slug) resolves to any h1–h3 heading).
doc/BUILD.bazel main doctest( rule globs source/**/*.md + source/**/*.rst, with a per-file exclude list (e.g. ray-contribute/getting-involved.md, ray-contribute/testing-tips.md) and whole-subtree excludes for ray-core/, data/, rllib/, serve/, train/, tune/ (which have their own doctest rules).
pre-commit has no hook that lints doc/source/**/*.md outside doc/source/data/ (vale) — so pre-commit passing is not evidence the page is correct; the Sphinx build is.
sphinx_design==0.7.0 (doc/requirements-doc.txt) supports MyST first-class: its own docs are MyST and it ships a snippets/myst/ tree, and its directives register through app.add_directive, so MyST's {name} fence dispatch reaches them like any other directive.
doc/source/_ext/callouts.py defines callout and annotations, used by exactly one page (tune/index.md). Its <1>-to-① substitution happens in two independent places: _replace_numbers() for the annotation text, and the CalloutIncludePostTransform pass for the code in literal_blocks. Both work under MyST, because nested_parse still hands the directive a docutils StringList and StringList.replace() mutates in place. Don't "fix" _replace_numbers() on the strength of its discarded return value — the mutation already happened. Its content: str type hint is wrong, though, and passing it an actual str would silently no-op, since Python strings are immutable.
- The directory was the first batch fully migrated (precedent for every pattern above, including sphinx-design tabs/dropdowns in and the shared-include + partial handling in / ).
Gotchas
- Heading level surprises are usually faithful, not bugs. If a section renders one level deeper than feels right, the RST adornment order put it there. Reproduce it; don't fix it in a conversion PR.
- Two
# (H1) headings in one page is fine when the RST had two top-level (=) sections.
- Trailing whitespace inside code blocks can be dropped (invisible, no linter on these files) — don't preserve it deliberately.
- A converted file in the doctest exclude list but you forgot to update BUILD.bazel is a likely silent break: the build stays green, but the doctest target starts running blocks that were never meant to run. Always re-grep
doc/BUILD.bazel for the stem.
- A sibling's relative link to the file you're renaming (
[text](./page.rst)) is the easiest reference to miss — it has no dir/ prefix, so a path-scoped grep won't catch it. Grep the bare stem across all of doc/.
.html#anchor self-links and bare .md→.rst links are faithful-but-wrong: they render in RST but fail fail_on_warning in MyST. Translate them (Hard rule 2).
- An extensionless link to a doc that also carries a same-named label is ambiguous (
myst.xref_ambiguous), not missing — MyST can't tell the doc from the label. Use {doc} for whole-doc links so it always resolves to the document.
- Don't trust pre-commit's silence as a quality signal for these
.md files — it skips them.