docs: use stardoc proto output to generate markdown docs (#1629)

The template language Stardoc uses (Velocity) is niche and fairly
esoteric, and requires a lot of experimenting to understand how to
make it produce the desired output. In particular, it largely assumes
whitespace doesn't matter, which makes it a poor fit for generating
Markdown, where whitespace often does matter.

Instead, a small Python program is used to consume the binary proto
output of Stardoc, which converts it to Markdown. This also makes it
easier to customize the overall output and re-use code for the different
types of objects rendered.

The visible changes to the docs are:
  * Module extensions are now documented
  * Repository rules follow the style of the other generated docs
  * Fixed the rendering of pip_repository docs -- it had an h2
    section which broke the section grouping of the API objects.
  * Puts some padding between the border and content for text in
    params/attrs/fields listings.

Other notable changes:
  * Make RTD builds use bzlmod. This is necessary so that the pip
    extension can be documented. It loads
    `@pythons_hub//:interpreters.bzl`, but that repo is only created
    when bzlmod is enabled)

---------

Co-authored-by: Ignas Anikevicius <240938+aignas@users.noreply.github.com>
diff --git a/.bazelrc b/.bazelrc
index 52251b1..fd2e442 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -25,5 +25,8 @@
 common --noexperimental_enable_bzlmod
 
 # Additional config to use for readthedocs builds.
-# See .readthedocs.yml for additional flags
+# See .readthedocs.yml for additional flags that can only be determined from
+# the runtime environment.
 build:rtd --stamp
+# Some bzl files contain repos only available under bzlmod
+build:rtd --enable_bzlmod
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0b4a72..b032f4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,10 @@
 * (bzlmod pip.parse) Requirements files with duplicate entries for the same
   package (e.g. one for the package, one for an extra) now work.
 
+### Added
+
+* (docs) bzlmod extensions are now documented on rules-python.readthedocs.io
+
 [0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
 
 ## [0.27.0] - 2023-11-16
diff --git a/MODULE.bazel b/MODULE.bazel
index 9aaeaf6..f53815c 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -56,12 +56,12 @@
 register_toolchains("@pythons_hub//:all")
 
 # ===== DEV ONLY SETUP =====
-docs_pip = use_extension(
+dev_pip = use_extension(
     "//python/extensions:pip.bzl",
     "pip",
     dev_dependency = True,
 )
-docs_pip.parse(
+dev_pip.parse(
     experimental_requirement_cycles = {
         "sphinx": [
             "sphinx",
@@ -72,7 +72,7 @@
             "sphinxcontrib-applehelp",
         ],
     },
-    hub_name = "docs_deps",
+    hub_name = "dev_pip",
     python_version = "3.11",
     requirements_darwin = "//docs/sphinx:requirements_darwin.txt",
     requirements_lock = "//docs/sphinx:requirements_linux.txt",
diff --git a/WORKSPACE b/WORKSPACE
index 074a7b9..b8e778e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -109,7 +109,7 @@
 # Install sphinx for doc generation.
 
 pip_parse(
-    name = "docs_deps",
+    name = "dev_pip",
     experimental_requirement_cycles = {
         "sphinx": [
             "sphinx",
@@ -126,7 +126,7 @@
     requirements_lock = "//docs/sphinx:requirements_linux.txt",
 )
 
-load("@docs_deps//:requirements.bzl", docs_install_deps = "install_deps")
+load("@dev_pip//:requirements.bzl", docs_install_deps = "install_deps")
 
 docs_install_deps()
 
@@ -140,3 +140,9 @@
         "https://files.pythonhosted.org/packages/50/67/3e966d99a07d60a21a21d7ec016e9e4c2642a86fea251ec68677daf71d4d/numpy-1.25.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
     ],
 )
+
+# rules_proto expects //external:python_headers to point at the python headers.
+bind(
+    name = "python_headers",
+    actual = "//python/cc:current_py_cc_headers",
+)
diff --git a/docs/sphinx/BUILD.bazel b/docs/sphinx/BUILD.bazel
index 95b3cfa..4c14aee 100644
--- a/docs/sphinx/BUILD.bazel
+++ b/docs/sphinx/BUILD.bazel
@@ -12,8 +12,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-load("@docs_deps//:requirements.bzl", "requirement")
-load("@rules_python//python:pip.bzl", "compile_pip_requirements")
+load("@dev_pip//:requirements.bzl", "requirement")
+load("//python:pip.bzl", "compile_pip_requirements")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")  # buildifier: disable=bzl-visibility
+load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER")  # buildifier: disable=bzl-visibility
 load("//sphinxdocs:readthedocs.bzl", "readthedocs_install")
 load("//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs", "sphinx_inventory")
 load("//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardocs")
@@ -83,7 +85,13 @@
         "api/entry_points/py_console_script_binary.md": "//python/entry_points:py_console_script_binary_bzl",
         "api/packaging.md": "//python:packaging_bzl",
         "api/pip.md": "//python:pip_bzl",
-    },
+    } | ({
+        # Bazel 6 + Stardoc isn't able to parse something about the python bzlmod extension
+        "api/extensions/python.md": "//python/extensions:python_bzl",
+    } if IS_BAZEL_7_OR_HIGHER else {}) | ({
+        # This depends on @pythons_hub, which is only created under bzlmod
+        "api/extensions/pip.md": "//python/extensions:pip_bzl",
+    } if BZLMOD_ENABLED else {}),
     footer = "_stardoc_footer.md",
     tags = ["docs"],
     target_compatible_with = _TARGET_COMPATIBLE_WITH,
diff --git a/docs/sphinx/_stardoc_footer.md b/docs/sphinx/_stardoc_footer.md
index 65d74f4..7aa33f7 100644
--- a/docs/sphinx/_stardoc_footer.md
+++ b/docs/sphinx/_stardoc_footer.md
@@ -7,6 +7,8 @@
 [`Label`]: https://bazel.build/rules/lib/Label
 [`list`]: https://bazel.build/rules/lib/list
 [`str`]: https://bazel.build/rules/lib/string
+[str]: https://bazel.build/rules/lib/string
+[`int`]: https://bazel.build/rules/lib/int
 [`struct`]: https://bazel.build/rules/lib/builtins/struct
 [`Target`]: https://bazel.build/rules/lib/Target
 [target-name]: https://bazel.build/concepts/labels#target-names
diff --git a/docs/sphinx/_static/css/custom.css b/docs/sphinx/_static/css/custom.css
index c97d2f5..4b073d4 100644
--- a/docs/sphinx/_static/css/custom.css
+++ b/docs/sphinx/_static/css/custom.css
@@ -12,8 +12,17 @@
   border-bottom: thin solid grey;
   padding-left: 0.5ex;
 }
+.starlark-object h3 {
+  background-color: #e7f2fa;
+  padding-left: 0.5ex;
+}
 
-.starlark-object>p, .starlark-object>dl {
+.starlark-module-extension-tag-class h3 {
+  background-color: #add8e6;
+  padding-left: 0.5ex;
+}
+
+.starlark-object>p, .starlark-object>dl, .starlark-object>section>* {
   /* Prevent the words from touching the border line */
   padding-left: 0.5ex;
 }
diff --git a/docs/sphinx/pyproject.toml b/docs/sphinx/pyproject.toml
index 02e0f36..d36c9f2 100644
--- a/docs/sphinx/pyproject.toml
+++ b/docs/sphinx/pyproject.toml
@@ -9,4 +9,5 @@
     "myst-parser",
     "sphinx_rtd_theme",
     "readthedocs-sphinx-ext",
+    "absl-py",
 ]
diff --git a/docs/sphinx/readthedocs_build.sh b/docs/sphinx/readthedocs_build.sh
index e6908a3..c611b7c 100755
--- a/docs/sphinx/readthedocs_build.sh
+++ b/docs/sphinx/readthedocs_build.sh
@@ -14,6 +14,7 @@
 
 set -x
 bazel run \
+  --config=rtd \
   "--//sphinxdocs:extra_defines=version=$READTHEDOCS_VERSION" \
   "${extra_env[@]}" \
   //docs/sphinx:readthedocs_install
diff --git a/docs/sphinx/requirements_linux.txt b/docs/sphinx/requirements_linux.txt
index 429ddd4..85c61f3 100644
--- a/docs/sphinx/requirements_linux.txt
+++ b/docs/sphinx/requirements_linux.txt
@@ -4,6 +4,10 @@
 #
 #    bazel run //docs/sphinx:requirements.update
 #
+absl-py==2.0.0 \
+    --hash=sha256:9a28abb62774ae4e8edbe2dd4c49ffcd45a6a848952a5eccc6a49f3f0fc1e2f3 \
+    --hash=sha256:d9690211c5fcfefcdd1a45470ac2b5c5acd45241c3af71eed96bc5441746c0d5
+    # via rules-python-docs (docs/sphinx/pyproject.toml)
 alabaster==0.7.13 \
     --hash=sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3 \
     --hash=sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index 480b193..1ab59d5 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -196,6 +196,7 @@
     srcs = ["repositories.bzl"],
     deps = [
         ":versions_bzl",
+        "//python/pip_install:repositories_bzl",
         "//python/private:auth_bzl",
         "//python/private:bazel_tools_bzl",
         "//python/private:bzlmod_enabled_bzl",
diff --git a/python/extensions/BUILD.bazel b/python/extensions/BUILD.bazel
index 4be3e37..88e3984 100644
--- a/python/extensions/BUILD.bazel
+++ b/python/extensions/BUILD.bazel
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
 package(default_visibility = ["//visibility:public"])
 
 licenses(["notice"])
@@ -21,3 +23,17 @@
     srcs = glob(["**"]),
     visibility = ["//python:__pkg__"],
 )
+
+bzl_library(
+    name = "pip_bzl",
+    srcs = ["pip.bzl"],
+    visibility = ["//:__subpackages__"],
+    deps = ["//python/private/bzlmod:pip_bzl"],
+)
+
+bzl_library(
+    name = "python_bzl",
+    srcs = ["python.bzl"],
+    visibility = ["//:__subpackages__"],
+    deps = ["//python/private/bzlmod:python_bzl"],
+)
diff --git a/python/pip_install/pip_repository.bzl b/python/pip_install/pip_repository.bzl
index a02aecc..dca36ce 100644
--- a/python/pip_install/pip_repository.bzl
+++ b/python/pip_install/pip_repository.bzl
@@ -678,7 +678,7 @@
 )
 ```
 
-## Vendoring the requirements.bzl file
+### Vendoring the requirements.bzl file
 
 In some cases you may not want to generate the requirements.bzl file as a repository rule
 while Bazel is fetching dependencies. For example, if you produce a reusable Bazel module
diff --git a/python/private/bzlmod/BUILD.bazel b/python/private/bzlmod/BUILD.bazel
index fc8449e..a312922 100644
--- a/python/private/bzlmod/BUILD.bazel
+++ b/python/private/bzlmod/BUILD.bazel
@@ -13,8 +13,9 @@
 # limitations under the License.
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
 
-package(default_visibility = ["//visibility:private"])
+package(default_visibility = ["//:__subpackages__"])
 
 licenses(["notice"])
 
@@ -25,6 +26,28 @@
 )
 
 bzl_library(
+    name = "pip_bzl",
+    srcs = ["pip.bzl"],
+    deps = [
+        ":pip_repository_bzl",
+        "//python/pip_install:pip_repository_bzl",
+        "//python/pip_install:requirements_parser_bzl",
+        "//python/private:full_version_bzl",
+        "//python/private:normalize_name_bzl",
+        "//python/private:parse_whl_name_bzl",
+        "//python/private:version_label_bzl",
+        ":bazel_features_bzl",
+    ] + [
+        "@pythons_hub//:interpreters_bzl",
+    ] if BZLMOD_ENABLED else [],
+)
+
+bzl_library(
+    name = "bazel_features_bzl",
+    srcs = ["@bazel_features//:bzl_files"] if BZLMOD_ENABLED else [],
+)
+
+bzl_library(
     name = "pip_repository_bzl",
     srcs = ["pip_repository.bzl"],
     visibility = ["//:__subpackages__"],
@@ -33,3 +56,23 @@
         "//python/private:text_util_bzl",
     ],
 )
+
+bzl_library(
+    name = "python_bzl",
+    srcs = ["python.bzl"],
+    deps = [
+        ":pythons_hub_bzl",
+        "//python:repositories_bzl",
+        "//python/private:toolchains_repo_bzl",
+    ],
+)
+
+bzl_library(
+    name = "pythons_hub_bzl",
+    srcs = ["pythons_hub.bzl"],
+    deps = [
+        "//python:versions_bzl",
+        "//python/private:full_version_bzl",
+        "//python/private:toolchains_repo_bzl",
+    ],
+)
diff --git a/python/private/bzlmod/pythons_hub.bzl b/python/private/bzlmod/pythons_hub.bzl
index f36ce45..5f536f3 100644
--- a/python/private/bzlmod/pythons_hub.bzl
+++ b/python/private/bzlmod/pythons_hub.bzl
@@ -29,7 +29,19 @@
         fail("expected at least one list")
     return len({len(length): None for length in lists}) == 1
 
-def _python_toolchain_build_file_content(
+_HUB_BUILD_FILE_TEMPLATE = """\
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+bzl_library(
+    name = "interpreters_bzl",
+    srcs = ["interpreters.bzl"],
+    visibility = ["@rules_python//:__subpackages__"],
+)
+
+{toolchains}
+"""
+
+def _hub_build_file_content(
         prefixes,
         python_versions,
         set_python_version_constraints,
@@ -48,7 +60,7 @@
 
     # Iterate over the length of python_versions and call
     # build the toolchain content by calling python_toolchain_build_file_content
-    return "\n".join([python_toolchain_build_file_content(
+    toolchains = "\n".join([python_toolchain_build_file_content(
         prefix = prefixes[i],
         python_version = full_version(python_versions[i]),
         set_python_version_constraint = set_python_version_constraints[i],
@@ -56,7 +68,9 @@
         rules_python = rules_python,
     ) for i in range(len(python_versions))])
 
-_build_file_for_hub_template = """
+    return _HUB_BUILD_FILE_TEMPLATE.format(toolchains = toolchains)
+
+_interpreters_bzl_template = """
 INTERPRETER_LABELS = {{
 {interpreter_labels}
 }}
@@ -72,7 +86,7 @@
     # write them to the BUILD file.
     rctx.file(
         "BUILD.bazel",
-        _python_toolchain_build_file_content(
+        _hub_build_file_content(
             rctx.attr.toolchain_prefixes,
             rctx.attr.toolchain_python_versions,
             rctx.attr.toolchain_set_python_version_constraints,
@@ -97,7 +111,7 @@
 
     rctx.file(
         "interpreters.bzl",
-        _build_file_for_hub_template.format(
+        _interpreters_bzl_template.format(
             interpreter_labels = interpreter_labels,
             default_python_version = rctx.attr.default_python_version,
         ),
diff --git a/sphinxdocs/private/BUILD.bazel b/sphinxdocs/private/BUILD.bazel
index a8701d9..01758b3 100644
--- a/sphinxdocs/private/BUILD.bazel
+++ b/sphinxdocs/private/BUILD.bazel
@@ -13,7 +13,9 @@
 # limitations under the License.
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//python:proto.bzl", "py_proto_library")
 load("//python:py_binary.bzl", "py_binary")
+load("//python:py_library.bzl", "py_library")
 
 package(
     default_visibility = ["//sphinxdocs:__subpackages__"],
@@ -70,3 +72,28 @@
     # Only public because it's an implicit attribute
     visibility = ["//:__subpackages__"],
 )
+
+py_binary(
+    name = "proto_to_markdown",
+    srcs = ["proto_to_markdown.py"],
+    # Only public because it's an implicit attribute
+    visibility = ["//:__subpackages__"],
+    deps = [":proto_to_markdown_lib"],
+)
+
+py_library(
+    name = "proto_to_markdown_lib",
+    srcs = ["proto_to_markdown.py"],
+    # Only public because it's an implicit attribute
+    visibility = ["//:__subpackages__"],
+    deps = [
+        ":stardoc_output_proto_py_pb2",
+    ],
+)
+
+py_proto_library(
+    name = "stardoc_output_proto_py_pb2",
+    deps = [
+        "@io_bazel_stardoc//stardoc/proto:stardoc_output_proto",
+    ],
+)
diff --git a/sphinxdocs/private/func_template.vm b/sphinxdocs/private/func_template.vm
deleted file mode 100644
index 81dd203..0000000
--- a/sphinxdocs/private/func_template.vm
+++ /dev/null
@@ -1,57 +0,0 @@
-#set( $nl = "
-" )
-#set( $fn = $funcInfo.functionName)
-#set( $fnl = $fn.replaceAll("[.]", "_").toLowerCase())
-{.starlark-object}
-#[[##]]# $fn
-
-#set( $hasParams = false)
-{.starlark-signature}
-${funcInfo.functionName}(## Comment to consume newline
-#foreach ($param in $funcInfo.getParameterList())
-#if($param.name != "self")
-#set( $hasParams = true)
-[${param.name}](#${fnl}_${param.name})## Comment to consume newline
-#if(!$param.getDefaultValue().isEmpty())
-=$param.getDefaultValue()#end#if($foreach.hasNext),
-#end
-#end
-#end
-)
-
-${funcInfo.docString}
-
-#if ($hasParams)
-{#${fnl}_parameters}
-**PARAMETERS** [¶](#${fnl}_parameters){.headerlink}
-
-#foreach ($param in $funcInfo.getParameterList())
-#if($param.name != "self")
-#set($link = $fnl + "_" + $param.name)
-#if($foreach.first)
-{.params-box}
-#end
-## The .span wrapper is necessary so the trailing colon doesn't wrap
-:[${param.name}[¶](#$link){.headerlink}]{.span}:
-  {#$link}
-#if(!$param.getDefaultValue().isEmpty())  (_default `${param.getDefaultValue()}`_) #end
-#if(!$param.docString.isEmpty())
-  $param.docString.replaceAll("$nl", "$nl  ")
-#else
-  _undocumented_
-#end
-#end
-#end
-#end
-#if (!$funcInfo.getReturn().docString.isEmpty())
-
-{#${fnl}_returns}
-RETURNS [¶](#${fnl}_returns){.headerlink}
-: ${funcInfo.getReturn().docString.replaceAll("$nl", "$nl  ")}
-#end
-#if (!$funcInfo.getDeprecated().docString.isEmpty())
-
-**DEPRECATED**
-
-${funcInfo.getDeprecated().docString}
-#end
diff --git a/sphinxdocs/private/header_template.vm b/sphinxdocs/private/header_template.vm
deleted file mode 100644
index 81496ff..0000000
--- a/sphinxdocs/private/header_template.vm
+++ /dev/null
@@ -1,3 +0,0 @@
-# %%BZL_LOAD_PATH%%
-
-$moduleDocstring
diff --git a/sphinxdocs/private/proto_to_markdown.py b/sphinxdocs/private/proto_to_markdown.py
new file mode 100644
index 0000000..18d4e1e
--- /dev/null
+++ b/sphinxdocs/private/proto_to_markdown.py
@@ -0,0 +1,488 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import argparse
+import io
+import itertools
+import pathlib
+import sys
+import textwrap
+from typing import Callable, TextIO, TypeVar
+
+from stardoc.proto import stardoc_output_pb2
+
+_AttributeType = stardoc_output_pb2.AttributeType
+
+_T = TypeVar("_T")
+
+
+def _anchor_id(text: str) -> str:
+    # MyST/Sphinx's markdown processing doesn't like dots in anchor ids.
+    return "#" + text.replace(".", "_").lower()
+
+
+# Create block attribute line.
+# See https://myst-parser.readthedocs.io/en/latest/syntax/optional.html#block-attributes
+def _block_attrs(*attrs: str) -> str:
+    return "{" + " ".join(attrs) + "}\n"
+
+
+def _link(display: str, link: str = "", *, ref: str = "", classes: str = "") -> str:
+    if ref:
+        ref = f"[{ref}]"
+    if link:
+        link = f"({link})"
+    if classes:
+        classes = "{" + classes + "}"
+    return f"[{display}]{ref}{link}{classes}"
+
+
+def _span(display: str, classes: str = ".span") -> str:
+    return f"[{display}]{{" + classes + "}"
+
+
+def _link_here_icon(anchor: str) -> str:
+    # The headerlink class activates some special logic to show/hide
+    # text upon mouse-over; it's how headings show a clickable link.
+    return _link("¶", anchor, classes=".headerlink")
+
+
+def _inline_anchor(anchor: str) -> str:
+    return _span("", anchor)
+
+
+def _indent_block_text(text: str) -> str:
+    return text.strip().replace("\n", "\n  ")
+
+
+def _join_csv_and(values: list[str]) -> str:
+    if len(values) == 1:
+        return values[0]
+
+    values = list(values)
+    values[-1] = "and " + values[-1]
+    return ", ".join(values)
+
+
+def _position_iter(values: list[_T]) -> tuple[bool, bool, _T]:
+    for i, value in enumerate(values):
+        yield i == 0, i == len(values) - 1, value
+
+
+class _MySTRenderer:
+    def __init__(
+        self,
+        module: stardoc_output_pb2.ModuleInfo,
+        out_stream: TextIO,
+        public_load_path: str,
+    ):
+        self._module = module
+        self._out_stream = out_stream
+        self._public_load_path = public_load_path
+
+    def render(self):
+        self._render_module(self._module)
+
+    def _render_module(self, module: stardoc_output_pb2.ModuleInfo):
+        if self._public_load_path:
+            bzl_path = self._public_load_path
+        else:
+            bzl_path = "//" + self._module.file.split("//")[1]
+        self._write(
+            f"# {bzl_path}\n",
+            "\n",
+            module.module_docstring.strip(),
+            "\n\n",
+        )
+
+        # Sort the objects by name
+        objects = itertools.chain(
+            ((r.rule_name, r, self._render_rule) for r in module.rule_info),
+            ((p.provider_name, p, self._render_provider) for p in module.provider_info),
+            ((f.function_name, f, self._render_func) for f in module.func_info),
+            ((a.aspect_name, a, self._render_aspect) for a in module.aspect_info),
+            (
+                (m.extension_name, m, self._render_module_extension)
+                for m in module.module_extension_info
+            ),
+            (
+                (r.rule_name, r, self._render_repository_rule)
+                for r in module.repository_rule_info
+            ),
+        )
+
+        objects = sorted(objects, key=lambda v: v[0].lower())
+
+        for _, obj, func in objects:
+            func(obj)
+            self._write("\n")
+
+    def _render_aspect(self, aspect: stardoc_output_pb2.AspectInfo):
+        aspect_anchor = _anchor_id(aspect.aspect_name)
+        self._write(
+            _block_attrs(".starlark-object"),
+            f"## {aspect.aspect_name}\n\n",
+            "_Propagates on attributes:_ ",  # todo add link here
+            ", ".join(sorted(f"`{attr}`" for attr in aspect.aspect_attribute)),
+            "\n\n",
+            aspect.doc_string.strip(),
+            "\n\n",
+        )
+
+        if aspect.attribute:
+            self._render_attributes(aspect_anchor, aspect.attribute)
+        self._write("\n")
+
+    def _render_module_extension(self, mod_ext: stardoc_output_pb2.ModuleExtensionInfo):
+        self._write(
+            _block_attrs(".starlark-object"),
+            f"## {mod_ext.extension_name}\n\n",
+        )
+
+        self._write(mod_ext.doc_string.strip(), "\n\n")
+
+        mod_ext_anchor = _anchor_id(mod_ext.extension_name)
+        for tag in mod_ext.tag_class:
+            tag_name = f"{mod_ext.extension_name}.{tag.tag_name}"
+            tag_anchor = f"{mod_ext_anchor}_{tag.tag_name}"
+            self._write(
+                _block_attrs(".starlark-module-extension-tag-class"),
+                f"### {tag_name}\n\n",
+            )
+            self._render_signature(
+                tag_name,
+                tag_anchor,
+                tag.attribute,
+                get_name=lambda a: a.name,
+                get_default=lambda a: a.default_value,
+            )
+
+            self._write(tag.doc_string.strip(), "\n\n")
+            self._render_attributes(tag_anchor, tag.attribute)
+            self._write("\n")
+
+    def _render_repository_rule(self, repo_rule: stardoc_output_pb2.RepositoryRuleInfo):
+        self._write(
+            _block_attrs(".starlark-object"),
+            f"## {repo_rule.rule_name}\n\n",
+        )
+        repo_anchor = _anchor_id(repo_rule.rule_name)
+        self._render_signature(
+            repo_rule.rule_name,
+            repo_anchor,
+            repo_rule.attribute,
+            get_name=lambda a: a.name,
+            get_default=lambda a: a.default_value,
+        )
+        self._write(repo_rule.doc_string.strip(), "\n\n")
+        if repo_rule.attribute:
+            self._render_attributes(repo_anchor, repo_rule.attribute)
+        if repo_rule.environ:
+            self._write(
+                "**ENVIRONMENT VARIABLES** ",
+                _link_here_icon(repo_anchor + "_env"),
+                "\n",
+            )
+            for name in sorted(repo_rule.environ):
+                self._write(f"* `{name}`\n")
+        self._write("\n")
+
+    def _render_rule(self, rule: stardoc_output_pb2.RuleInfo):
+        rule_name = rule.rule_name
+        rule_anchor = _anchor_id(rule_name)
+        self._write(
+            _block_attrs(".starlark-object"),
+            f"## {rule_name}\n\n",
+        )
+
+        self._render_signature(
+            rule_name,
+            rule_anchor,
+            rule.attribute,
+            get_name=lambda r: r.name,
+            get_default=lambda r: r.default_value,
+        )
+
+        self._write(rule.doc_string.strip(), "\n\n")
+
+        if len(rule.advertised_providers.provider_name) == 0:
+            self._write("_Provides_: no providers advertised.")
+        else:
+            self._write(
+                "_Provides_: ",
+                ", ".join(rule.advertised_providers.provider_name),
+            )
+        self._write("\n\n")
+
+        if rule.attribute:
+            self._render_attributes(rule_anchor, rule.attribute)
+
+    def _rule_attr_type_string(self, attr: stardoc_output_pb2.AttributeInfo) -> str:
+        if attr.type == _AttributeType.NAME:
+            return _link("Name", ref="target-name")
+        elif attr.type == _AttributeType.INT:
+            return _link("int", ref="int")
+        elif attr.type == _AttributeType.LABEL:
+            return _link("label", ref="attr-label")
+        elif attr.type == _AttributeType.STRING:
+            return _link("string", ref="str")
+        elif attr.type == _AttributeType.STRING_LIST:
+            return "list of " + _link("string", ref="str")
+        elif attr.type == _AttributeType.INT_LIST:
+            return "list of " + _link("int", ref="int")
+        elif attr.type == _AttributeType.LABEL_LIST:
+            return "list of " + _link("label", ref="attr-label") + "s"
+        elif attr.type == _AttributeType.BOOLEAN:
+            return _link("bool", ref="bool")
+        elif attr.type == _AttributeType.LABEL_STRING_DICT:
+            return "dict of {key} to {value}".format(
+                key=_link("label", ref="attr-label"), value=_link("string", ref="str")
+            )
+        elif attr.type == _AttributeType.STRING_DICT:
+            return "dict of {key} to {value}".format(
+                key=_link("string", ref="str"), value=_link("string", ref="str")
+            )
+        elif attr.type == _AttributeType.STRING_LIST_DICT:
+            return "dict of {key} to list of {value}".format(
+                key=_link("string", ref="str"), value=_link("string", ref="str")
+            )
+        elif attr.type == _AttributeType.OUTPUT:
+            return _link("label", ref="attr-label")
+        elif attr.type == _AttributeType.OUTPUT_LIST:
+            return "list of " + _link("label", ref="attr-label")
+        else:
+            # If we get here, it means the value was unknown for some reason.
+            # Rather than error, give some somewhat understandable value.
+            return _AttributeType.Name(attr.type)
+
+    def _render_func(self, func: stardoc_output_pb2.StarlarkFunctionInfo):
+        func_name = func.function_name
+        func_anchor = _anchor_id(func_name)
+        self._write(
+            _block_attrs(".starlark-object"),
+            f"## {func_name}\n\n",
+        )
+
+        parameters = [param for param in func.parameter if param.name != "self"]
+
+        self._render_signature(
+            func_name,
+            func_anchor,
+            parameters,
+            get_name=lambda p: p.name,
+            get_default=lambda p: p.default_value,
+        )
+
+        self._write(func.doc_string.strip(), "\n\n")
+
+        if parameters:
+            self._write(
+                _block_attrs(f"{func_anchor}_parameters"),
+                "**PARAMETERS** ",
+                _link_here_icon(f"{func_anchor}_parameters"),
+                "\n\n",
+            )
+            entries = []
+            for param in parameters:
+                entries.append(
+                    [
+                        f"{func_anchor}_{param.name}",
+                        param.name,
+                        f"(_default `{param.default_value}`_) "
+                        if param.default_value
+                        else "",
+                        param.doc_string if param.doc_string else "_undocumented_",
+                    ]
+                )
+            self._render_field_list(entries)
+
+        if getattr(func, "return").doc_string:
+            return_doc = _indent_block_text(getattr(func, "return").doc_string)
+            self._write(
+                _block_attrs(f"{func_anchor}_returns"),
+                "RETURNS",
+                _link_here_icon(func_anchor + "_returns"),
+                "\n",
+                ": ",
+                return_doc,
+                "\n",
+            )
+        if func.deprecated.doc_string:
+            self._write(
+                "\n\n**DEPRECATED**\n\n", func.deprecated.doc_string.strip(), "\n"
+            )
+
+    def _render_provider(self, provider: stardoc_output_pb2.ProviderInfo):
+        self._write(
+            _block_attrs(".starlark-object"),
+            f"## {provider.provider_name}\n\n",
+        )
+
+        provider_anchor = _anchor_id(provider.provider_name)
+        self._render_signature(
+            provider.provider_name,
+            provider_anchor,
+            provider.field_info,
+            get_name=lambda f: f.name,
+        )
+
+        self._write(provider.doc_string.strip(), "\n\n")
+
+        if provider.field_info:
+            self._write(
+                _block_attrs(provider_anchor),
+                "**FIELDS** ",
+                _link_here_icon(provider_anchor + "_fields"),
+                "\n",
+                "\n",
+            )
+            entries = []
+            for field in provider.field_info:
+                entries.append(
+                    [
+                        f"{provider_anchor}_{field.name}",
+                        field.name,
+                        field.doc_string,
+                    ]
+                )
+            self._render_field_list(entries)
+
+    def _render_attributes(
+        self, base_anchor: str, attributes: list[stardoc_output_pb2.AttributeInfo]
+    ):
+        self._write(
+            _block_attrs(f"{base_anchor}_attributes"),
+            "**ATTRIBUTES** ",
+            _link_here_icon(f"{base_anchor}_attributes"),
+            "\n",
+        )
+        entries = []
+        for attr in attributes:
+            anchor = f"{base_anchor}_{attr.name}"
+            required = "required" if attr.mandatory else "optional"
+            attr_type = self._rule_attr_type_string(attr)
+            default = f", default `{attr.default_value}`" if attr.default_value else ""
+            providers_parts = []
+            if attr.provider_name_group:
+                providers_parts.append("\n\n_Required providers_: ")
+            if len(attr.provider_name_group) == 1:
+                provider_group = attr.provider_name_group[0]
+                if len(provider_group.provider_name) == 1:
+                    providers_parts.append(provider_group.provider_name[0])
+                else:
+                    providers_parts.extend(
+                        ["all of ", _join_csv_and(provider_group.provider_name)]
+                    )
+            elif len(attr.provider_name_group) > 1:
+                providers_parts.append("any of \n")
+                for group in attr.provider_name_group:
+                    providers_parts.extend(["* ", _join_csv_and(group.provider_name)])
+            if providers_parts:
+                providers_parts.append("\n")
+
+            entries.append(
+                [
+                    anchor,
+                    attr.name,
+                    f"_({required} {attr_type}{default})_\n",
+                    attr.doc_string,
+                    *providers_parts,
+                ]
+            )
+        self._render_field_list(entries)
+
+    def _render_signature(
+        self,
+        name: str,
+        base_anchor: str,
+        parameters: list[_T],
+        *,
+        get_name: Callable[_T, str],
+        get_default: Callable[_T, str] = lambda v: None,
+    ):
+        self._write(_block_attrs(".starlark-signature"), name, "(")
+        for _, is_last, param in _position_iter(parameters):
+            param_name = get_name(param)
+            self._write(_link(param_name, f"{base_anchor}_{param_name}"))
+            default_value = get_default(param)
+            if default_value:
+                self._write(f"={default_value}")
+            if not is_last:
+                self._write(",\n")
+        self._write(")\n\n")
+
+    def _render_field_list(self, entries: list[list[str]]):
+        """Render a list of field lists.
+
+        Args:
+            entries: list of field list entries. Each element is 3
+                pieces: an anchor, field description, and one or more
+                text strings for the body of the field list entry.
+        """
+        for anchor, description, *body_pieces in entries:
+            body_pieces = [_block_attrs(anchor), *body_pieces]
+            self._write(
+                ":",
+                _span(description + _link_here_icon(anchor)),
+                ":\n  ",
+                # The text has to be indented to be associated with the block correctly.
+                "".join(body_pieces).strip().replace("\n", "\n  "),
+                "\n",
+            )
+        # Ensure there is an empty line after the field list, otherwise
+        # the next line of content will fold into the field list
+        self._write("\n")
+
+    def _write(self, *lines: str):
+        self._out_stream.writelines(lines)
+
+
+def _convert(
+    *,
+    proto: pathlib.Path,
+    output: pathlib.Path,
+    footer: pathlib.Path,
+    public_load_path: str,
+):
+    if footer:
+        footer_content = footer.read_text()
+
+    module = stardoc_output_pb2.ModuleInfo.FromString(proto.read_bytes())
+    with output.open("wt", encoding="utf8") as out_stream:
+        _MySTRenderer(module, out_stream, public_load_path).render()
+        out_stream.write(footer_content)
+
+
+def _create_parser():
+    parser = argparse.ArgumentParser(fromfile_prefix_chars="@")
+    parser.add_argument("--footer", dest="footer", type=pathlib.Path)
+    parser.add_argument("--proto", dest="proto", type=pathlib.Path)
+    parser.add_argument("--output", dest="output", type=pathlib.Path)
+    parser.add_argument("--public-load-path", dest="public_load_path")
+    return parser
+
+
+def main(args):
+    options = _create_parser().parse_args(args)
+    _convert(
+        proto=options.proto,
+        output=options.output,
+        footer=options.footer,
+        public_load_path=options.public_load_path,
+    )
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))
diff --git a/sphinxdocs/private/provider_template.vm b/sphinxdocs/private/provider_template.vm
deleted file mode 100644
index 49ae894..0000000
--- a/sphinxdocs/private/provider_template.vm
+++ /dev/null
@@ -1,30 +0,0 @@
-#set( $nl = "
-" )
-#set( $pn = $providerInfo.providerName)
-#set( $pnl = $pn.replaceAll("[.]", "_").toLowerCase())
-{.starlark-object}
-#[[##]]# ${providerName}
-
-#set( $hasFields = false)
-{.starlark-signature}
-${providerInfo.providerName}(## Comment to consume newline
-#foreach ($field in $providerInfo.getFieldInfoList())
-#set( $hasFields = true)
-[${field.name}](#${pnl}_${field.name})## Comment to consume newline
-#if($foreach.hasNext),
-#end
-#end
-)
-
-$providerInfo.docString
-
-#if ($hasFields)
-{#${pnl}_fields}
-**FIELDS** [¶](#${pnl}_fields){.headerlink}
-
-#foreach ($field in $providerInfo.getFieldInfoList())
-#set($link = $pnl + "_" + $field.name)
-:[${field.name}[¶](#$link){.headerlink}]{.span}: []{#$link}
-  $field.docString.replaceAll("$nl", "$nl  ")
-#end
-#end
diff --git a/sphinxdocs/private/rule_template.vm b/sphinxdocs/private/rule_template.vm
deleted file mode 100644
index d91bad2..0000000
--- a/sphinxdocs/private/rule_template.vm
+++ /dev/null
@@ -1,48 +0,0 @@
-#set( $nl = "
-" )
-#set( $rn = $ruleInfo.ruleName)
-#set( $rnl = $rn.replaceAll("[.]", "_").toLowerCase())
-{.starlark-object}
-#[[##]]# $ruleName
-
-#set( $hasAttrs = false)
-{.starlark-signature}
-${ruleInfo.ruleName}(## Comment to consume newline
-#foreach ($attr in $ruleInfo.getAttributeList())
-#set( $hasAttrs = true)
-[${attr.name}](#${rnl}_${attr.name})## Comment to consume newline
-#if(!$attr.getDefaultValue().isEmpty())
-=$attr.getDefaultValue()#end#if($foreach.hasNext),
-#end
-#end
-)
-
-$ruleInfo.docString
-
-#if ($hasAttrs)
-{#${rnl}_attributes}
-**ATTRIBUTES** [¶](#${rnl}_attributes){.headerlink}
-
-#foreach ($attr in $ruleInfo.getAttributeList())
-#set($link = $rnl + "_" + $attr.name)
-#if($attr.mandatory)
-#set($opt = "required")
-#else
-#set($opt = "optional")
-#end
-#if($attr.type == "NAME")
-#set($type = "[Name][target-name]")
-#elseif($attr.type == "LABEL_LIST")
-#set($type = "list of [label][attr-label]s")
-#end
-#if(!$attr.getDefaultValue().isEmpty())
-#set($default = ", default `" + $attr.getDefaultValue() + "`")
-#else
-#set($default = "")
-#end
-:[${attr.name}[¶](#$link){.headerlink}]{.span}: []{#$link}
-  _($opt $type$default)_
-  $attr.docString.replaceAll("$nl", "$nl  ")
-
-#end
-#end
diff --git a/sphinxdocs/private/sphinx_stardoc.bzl b/sphinxdocs/private/sphinx_stardoc.bzl
index 1371d90..810dca3 100644
--- a/sphinxdocs/private/sphinx_stardoc.bzl
+++ b/sphinxdocs/private/sphinx_stardoc.bzl
@@ -19,11 +19,6 @@
 load("@io_bazel_stardoc//stardoc:stardoc.bzl", "stardoc")
 load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs")  # buildifier: disable=bzl-visibility
 
-_FUNC_TEMPLATE = Label("//sphinxdocs/private:func_template.vm")
-_HEADER_TEMPLATE = Label("//sphinxdocs/private:header_template.vm")
-_RULE_TEMPLATE = Label("//sphinxdocs/private:rule_template.vm")
-_PROVIDER_TEMPLATE = Label("//sphinxdocs/private:provider_template.vm")
-
 def sphinx_stardocs(name, docs, footer = None, **kwargs):
     """Generate Sphinx-friendly Markdown docs using Stardoc for bzl libraries.
 
@@ -83,58 +78,62 @@
     )
 
 def _sphinx_stardoc(*, name, out, footer = None, public_load_path = None, **kwargs):
-    if footer:
-        stardoc_name = "_{}_stardoc".format(name.lstrip("_"))
-        stardoc_out = "_{}_stardoc.out".format(name.lstrip("_"))
-    else:
-        stardoc_name = name
-        stardoc_out = out
+    stardoc_name = "_{}_stardoc".format(name.lstrip("_"))
+    stardoc_pb = stardoc_name + ".binaryproto"
 
     if not public_load_path:
         public_load_path = str(kwargs["input"])
 
-    header_name = "_{}_header".format(name.lstrip("_"))
-    _expand_stardoc_template(
-        name = header_name,
-        template = _HEADER_TEMPLATE,
-        substitutions = {
-            "%%BZL_LOAD_PATH%%": public_load_path,
-        },
-    )
-
     stardoc(
         name = stardoc_name,
-        func_template = _FUNC_TEMPLATE,
-        header_template = header_name,
-        rule_template = _RULE_TEMPLATE,
-        provider_template = _PROVIDER_TEMPLATE,
-        out = stardoc_out,
+        out = stardoc_pb,
+        format = "proto",
         **kwargs
     )
 
-    if footer:
-        native.genrule(
-            name = name,
-            srcs = [stardoc_out, footer],
-            outs = [out],
-            cmd = "cat $(SRCS) > $(OUTS)",
-            message = "SphinxStardoc: Adding footer to {}".format(name),
-            **copy_propagating_kwargs(kwargs)
-        )
-
-def _expand_stardoc_template_impl(ctx):
-    out = ctx.actions.declare_file(ctx.label.name + ".vm")
-    ctx.actions.expand_template(
-        template = ctx.file.template,
+    _stardoc_proto_to_markdown(
+        name = name,
+        src = stardoc_pb,
         output = out,
-        substitutions = ctx.attr.substitutions,
+        footer = footer,
+        public_load_path = public_load_path,
     )
-    return [DefaultInfo(files = depset([out]))]
 
-_expand_stardoc_template = rule(
-    implementation = _expand_stardoc_template_impl,
+def _stardoc_proto_to_markdown_impl(ctx):
+    args = ctx.actions.args()
+    args.use_param_file("@%s")
+    args.set_param_file_format("multiline")
+
+    inputs = [ctx.file.src]
+    args.add("--proto", ctx.file.src)
+    args.add("--output", ctx.outputs.output)
+
+    if ctx.file.footer:
+        args.add("--footer", ctx.file.footer)
+        inputs.append(ctx.file.footer)
+    if ctx.attr.public_load_path:
+        args.add("--public-load-path={}".format(ctx.attr.public_load_path))
+
+    ctx.actions.run(
+        executable = ctx.executable._proto_to_markdown,
+        arguments = [args],
+        inputs = inputs,
+        outputs = [ctx.outputs.output],
+        mnemonic = "SphinxStardocProtoToMd",
+        progress_message = "SphinxStardoc: converting proto to markdown: %{input} -> %{output}",
+    )
+
+_stardoc_proto_to_markdown = rule(
+    implementation = _stardoc_proto_to_markdown_impl,
     attrs = {
-        "substitutions": attr.string_dict(),
-        "template": attr.label(allow_single_file = True),
+        "footer": attr.label(allow_single_file = True),
+        "output": attr.output(mandatory = True),
+        "public_load_path": attr.string(),
+        "src": attr.label(allow_single_file = True, mandatory = True),
+        "_proto_to_markdown": attr.label(
+            default = "//sphinxdocs/private:proto_to_markdown",
+            executable = True,
+            cfg = "exec",
+        ),
     },
 )
diff --git a/sphinxdocs/tests/BUILD.bazel b/sphinxdocs/tests/BUILD.bazel
new file mode 100644
index 0000000..4101095
--- /dev/null
+++ b/sphinxdocs/tests/BUILD.bazel
@@ -0,0 +1,13 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/sphinxdocs/tests/proto_to_markdown/BUILD.bazel b/sphinxdocs/tests/proto_to_markdown/BUILD.bazel
new file mode 100644
index 0000000..2964785
--- /dev/null
+++ b/sphinxdocs/tests/proto_to_markdown/BUILD.bazel
@@ -0,0 +1,24 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load("//python:py_test.bzl", "py_test")
+
+py_test(
+    name = "proto_to_markdown_test",
+    srcs = ["proto_to_markdown_test.py"],
+    deps = [
+        "//sphinxdocs/private:proto_to_markdown_lib",
+        "@dev_pip//absl_py",
+    ],
+)
diff --git a/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py b/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py
new file mode 100644
index 0000000..2f5b22e
--- /dev/null
+++ b/sphinxdocs/tests/proto_to_markdown/proto_to_markdown_test.py
@@ -0,0 +1,203 @@
+# Copyright 2023 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import io
+import re
+
+from absl.testing import absltest
+from google.protobuf import text_format
+from stardoc.proto import stardoc_output_pb2
+
+from sphinxdocs.private import proto_to_markdown
+
+_EVERYTHING_MODULE = """\
+module_docstring: "MODULE_DOC_STRING"
+file: "@repo//pkg:foo.bzl"
+
+rule_info: {
+  rule_name: "rule_1"
+  doc_string: "RULE_1_DOC_STRING"
+  attribute: {
+    name: "rule_1_attr_1",
+    doc_string: "RULE_1_ATTR_1_DOC_STRING"
+    type: STRING
+    default_value: "RULE_1_ATTR_1_DEFAULT_VALUE"
+  }
+}
+provider_info: {
+  provider_name: "ProviderAlpha"
+  doc_string: "PROVIDER_ALPHA_DOC_STRING"
+  field_info: {
+    name: "ProviderAlpha_field_a"
+    doc_string: "PROVIDER_ALPHA_FIELD_A_DOC_STRING"
+  }
+}
+func_info: {
+  function_name: "function_1"
+  doc_string: "FUNCTION_1_DOC_STRING"
+  parameter: {
+    name: "function_1_param_a"
+    doc_string: "FUNCTION_1_PARAM_A_DOC_STRING"
+    default_value: "FUNCTION_1_PARAM_A_DEFAULT_VALUE"
+  }
+  return: {
+    doc_string: "FUNCTION_1_RETURN_DOC_STRING"
+  }
+  deprecated: {
+    doc_string: "FUNCTION_1_DEPRECATED_DOC_STRING"
+  }
+}
+aspect_info: {
+  aspect_name: "aspect_1"
+  doc_string: "ASPECT_1_DOC_STRING"
+  aspect_attribute: "aspect_1_aspect_attribute_a"
+  attribute: {
+    name: "aspect_1_attribute_a",
+    doc_string: "ASPECT_1_ATTRIBUTE_A_DOC_STRING"
+    type: INT
+    default_value: "694638"
+  }
+}
+module_extension_info: {
+  extension_name: "bzlmod_ext"
+  doc_string: "BZLMOD_EXT_DOC_STRING"
+  tag_class: {
+    tag_name: "bzlmod_ext_tag_a"
+    doc_string: "BZLMOD_EXT_TAG_A_DOC_STRING"
+    attribute: {
+      name: "bzlmod_ext_tag_a_attribute_1",
+      doc_string: "BZLMOD_EXT_TAG_A_ATTRIBUTE_1_DOC_STRING"
+      type: STRING_LIST
+      default_value: "[BZLMOD_EXT_TAG_A_ATTRIBUTE_1_DEFAULT_VALUE]"
+    }
+  }
+}
+repository_rule_info: {
+  rule_name: "repository_rule",
+  doc_string: "REPOSITORY_RULE_DOC_STRING"
+  attribute: {
+    name: "repository_rule_attribute_a",
+    doc_string: "REPOSITORY_RULE_ATTRIBUTE_A_DOC_STRING"
+    type: BOOLEAN
+    default_value: "True"
+  }
+  environ: "ENV_VAR_A"
+}
+"""
+
+
+class ProtoToMarkdownTest(absltest.TestCase):
+    def setUp(self):
+        super().setUp()
+        self.stream = io.StringIO()
+
+    def _render(self, module_text):
+        renderer = proto_to_markdown._MySTRenderer(
+            module=text_format.Parse(module_text, stardoc_output_pb2.ModuleInfo()),
+            out_stream=self.stream,
+            public_load_path="",
+        )
+        renderer.render()
+        return self.stream.getvalue()
+
+    def test_basic_rendering_everything(self):
+        actual = self._render(_EVERYTHING_MODULE)
+
+        self.assertRegex(actual, "# //pkg:foo.bzl")
+        self.assertRegex(actual, "MODULE_DOC_STRING")
+
+        self.assertRegex(actual, "## rule_1.*")
+        self.assertRegex(actual, "RULE_1_DOC_STRING")
+        self.assertRegex(actual, "rule_1_attr_1")
+        self.assertRegex(actual, "RULE_1_ATTR_1_DOC_STRING")
+        self.assertRegex(actual, "RULE_1_ATTR_1_DEFAULT_VALUE")
+
+        self.assertRegex(actual, "## ProviderAlpha")
+        self.assertRegex(actual, "PROVIDER_ALPHA_DOC_STRING")
+        self.assertRegex(actual, "ProviderAlpha_field_a")
+        self.assertRegex(actual, "PROVIDER_ALPHA_FIELD_A_DOC_STRING")
+
+        self.assertRegex(actual, "## function_1")
+        self.assertRegex(actual, "FUNCTION_1_DOC_STRING")
+        self.assertRegex(actual, "function_1_param_a")
+        self.assertRegex(actual, "FUNCTION_1_PARAM_A_DOC_STRING")
+        self.assertRegex(actual, "FUNCTION_1_PARAM_A_DEFAULT_VALUE")
+        self.assertRegex(actual, "FUNCTION_1_RETURN_DOC_STRING")
+        self.assertRegex(actual, "FUNCTION_1_DEPRECATED_DOC_STRING")
+
+        self.assertRegex(actual, "## aspect_1")
+        self.assertRegex(actual, "ASPECT_1_DOC_STRING")
+        self.assertRegex(actual, "aspect_1_aspect_attribute_a")
+        self.assertRegex(actual, "aspect_1_attribute_a")
+        self.assertRegex(actual, "ASPECT_1_ATTRIBUTE_A_DOC_STRING")
+        self.assertRegex(actual, "694638")
+
+        self.assertRegex(actual, "## bzlmod_ext")
+        self.assertRegex(actual, "BZLMOD_EXT_DOC_STRING")
+        self.assertRegex(actual, "### bzlmod_ext.bzlmod_ext_tag_a")
+        self.assertRegex(actual, "BZLMOD_EXT_TAG_A_DOC_STRING")
+        self.assertRegex(actual, "bzlmod_ext_tag_a_attribute_1")
+        self.assertRegex(actual, "BZLMOD_EXT_TAG_A_ATTRIBUTE_1_DOC_STRING")
+        self.assertRegex(actual, "BZLMOD_EXT_TAG_A_ATTRIBUTE_1_DEFAULT_VALUE")
+
+        self.assertRegex(actual, "## repository_rule")
+        self.assertRegex(actual, "REPOSITORY_RULE_DOC_STRING")
+        self.assertRegex(actual, "repository_rule_attribute_a")
+        self.assertRegex(actual, "REPOSITORY_RULE_ATTRIBUTE_A_DOC_STRING")
+        self.assertRegex(actual, "repository_rule_attribute_a.*=.*True")
+        self.assertRegex(actual, "ENV_VAR_A")
+
+    def test_render_signature(self):
+        actual = self._render(
+            """\
+file: "@repo//pkg:foo.bzl"
+func_info: {
+  function_name: "func"
+  parameter: {
+    name: "param_with_default"
+    default_value: "DEFAULT"
+  }
+  parameter: {
+    name: "param_without_default"
+  }
+  parameter: {
+    name: "last_param"
+  }
+}
+        """
+        )
+        self.assertIn("[param_with_default](#func_param_with_default)=DEFAULT,", actual)
+        self.assertIn("[param_without_default](#func_param_without_default),", actual)
+
+    def test_render_field_list(self):
+        actual = self._render(
+            """\
+file: "@repo//pkg:foo.bzl"
+func_info: {
+  function_name: "func"
+  parameter: {
+    name: "param"
+    default_value: "DEFAULT"
+  }
+}
+"""
+        )
+        self.assertRegex(
+            actual, re.compile("^:.*param.*¶.*headerlink.*:\n", re.MULTILINE)
+        )
+        self.assertRegex(actual, re.compile("^  .*#func_param", re.MULTILINE))
+
+
+if __name__ == "__main__":
+    absltest.main()