sphinxdocs: add docs; support sources from other directories (#2128)

Documents how to use Sphinx syntax when writing docs. There's a variety
of features the `sphinx_bzl` plugin enables, but without docs, they're
somewhat hard to discover and figure out how to use.

Because sphinxdocs is almost entirely separate, adding its docs under
`//sphinxdocs/docs` is a more natural fit. Unfortunately, this became
very verbose, repetitive, and tedious, for two reasons:
1. The only way `sphinx_docs` could accept files from other directories
was using the `rename_srcs` arg and manually renaming files one-by-one.
2. Similarly, `sphinx_stardocs` required a one-by-one mapping of each
bzl file to its output file, which then had to be repeated in
`rename_srcs`.

To fix (1), the `sphinx_docs.deps` attribute and `sphinx_docs_library`
rule are added. The library targets collect files, and `sphinx_docs`
moves then into the final Sphinx sources directory.

To fix (2), the `sphinx_stardoc.srcs` attribute is added, which accepts
`bzl_library` targets. I noticed that, in almost all cases, the output
name was simply the input name with the `.md` extension, so the rule now
does that by default. For special cases, the `sphinx_stardoc` (singular)
rule can be called directly.

Also:
* Adds `bzl:rule` as a cross reference lookup role
* Removes some defunct stuff relating to the stardoc template files that
aren't used anymore.
* Disables warnings from the autosectionlabel extension. These were
spamming warnings because CHANGELOG.md has many headers with the same
name.
* Adds more entries to bazel inventory (all of native and ctx)
diff --git a/sphinxdocs/BUILD.bazel b/sphinxdocs/BUILD.bazel
index 6cb69ba..9ad1e1e 100644
--- a/sphinxdocs/BUILD.bazel
+++ b/sphinxdocs/BUILD.bazel
@@ -48,6 +48,12 @@
 )
 
 bzl_library(
+    name = "sphinx_docs_library_bzl",
+    srcs = ["sphinx_docs_library.bzl"],
+    deps = ["//sphinxdocs/private:sphinx_docs_library_macro_bzl"],
+)
+
+bzl_library(
     name = "sphinx_stardoc_bzl",
     srcs = ["sphinx_stardoc.bzl"],
     deps = ["//sphinxdocs/private:sphinx_stardoc_bzl"],
diff --git a/sphinxdocs/docs/BUILD.bazel b/sphinxdocs/docs/BUILD.bazel
new file mode 100644
index 0000000..a85155b
--- /dev/null
+++ b/sphinxdocs/docs/BUILD.bazel
@@ -0,0 +1,55 @@
+load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER")  # buildifier: disable=bzl-visibility
+load("//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library")
+load("//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardocs")
+
+package(default_visibility = ["//:__subpackages__"])
+
+# We only build for Linux and Mac because:
+# 1. The actual doc process only runs on Linux
+# 2. Mac is a common development platform, and is close enough to Linux
+#    it's feasible to make work.
+# Making CI happy under Windows is too much of a headache, though, so we don't
+# bother with that.
+_TARGET_COMPATIBLE_WITH = select({
+    "@platforms//os:linux": [],
+    "@platforms//os:macos": [],
+    "//conditions:default": ["@platforms//:incompatible"],
+}) if IS_BAZEL_7_OR_HIGHER else ["@platforms//:incompatible"]
+
+sphinx_docs_library(
+    name = "docs_lib",
+    deps = [
+        ":artisian_api_docs",
+        ":artisian_docs",
+        ":bzl_docs",
+    ],
+)
+
+sphinx_docs_library(
+    name = "artisian_docs",
+    srcs = glob(
+        ["**/*.md"],
+        exclude = ["api/**"],
+    ),
+    prefix = "sphinxdocs/",
+)
+
+sphinx_docs_library(
+    name = "artisian_api_docs",
+    srcs = glob(
+        ["api/**/*.md"],
+    ),
+)
+
+sphinx_stardocs(
+    name = "bzl_docs",
+    srcs = [
+        "//sphinxdocs:readthedocs_bzl",
+        "//sphinxdocs:sphinx_bzl",
+        "//sphinxdocs:sphinx_docs_library_bzl",
+        "//sphinxdocs:sphinx_stardoc_bzl",
+        "//sphinxdocs/private:sphinx_docs_library_bzl",
+    ],
+    prefix = "api/",
+    target_compatible_with = _TARGET_COMPATIBLE_WITH,
+)
diff --git a/sphinxdocs/docs/api/sphinxdocs/index.md b/sphinxdocs/docs/api/sphinxdocs/index.md
new file mode 100644
index 0000000..bd4e9b6
--- /dev/null
+++ b/sphinxdocs/docs/api/sphinxdocs/index.md
@@ -0,0 +1,29 @@
+:::{bzl:currentfile} //sphinxdocs:BUILD.bazel
+:::
+
+# //sphinxdocs
+
+:::{bzl:flag} extra_defines
+Additional `-D` values to add to every Sphinx build.
+
+This is a list flag. Multiple uses are accumulated.
+
+This is most useful for overriding e.g. the version when performing
+release builds.
+:::
+
+:::{bzl:flag} extra_env
+Additional environment variables to for every Sphinx build.
+
+This is a list flag. Multiple uses are accumulated. Values are `key=value`
+format.
+:::
+
+:::{bzl:flag} quiet
+Whether to add the `-q` arg to Sphinx invocations.
+
+This is a boolean flag.
+
+This is useful for debugging invocations or developing extensions. The Sphinx
+`-q` flag causes sphinx to produce additional output on stdout.
+:::
diff --git a/sphinxdocs/docs/api/sphinxdocs/inventories/index.md b/sphinxdocs/docs/api/sphinxdocs/inventories/index.md
new file mode 100644
index 0000000..a03645e
--- /dev/null
+++ b/sphinxdocs/docs/api/sphinxdocs/inventories/index.md
@@ -0,0 +1,11 @@
+:::{bzl:currentfile} //sphinxdocs/inventories:BUILD.bazel
+:::
+
+# //sphinxdocs/inventories
+
+:::{bzl:target} bazel_inventory
+A Sphinx inventory of Bazel objects.
+
+By including this target in your Sphinx build and enabling intersphinx, cross
+references to builtin Bazel objects can be written.
+:::
diff --git a/sphinxdocs/docs/index.md b/sphinxdocs/docs/index.md
new file mode 100644
index 0000000..ac857d6
--- /dev/null
+++ b/sphinxdocs/docs/index.md
@@ -0,0 +1,20 @@
+# Docgen using Sphinx with Bazel
+
+The `sphinxdocs` project allows using Bazel to run Sphinx to generate
+documentation. It comes with:
+
+* Rules for running Sphinx
+* Rules for generating documentation for Starlark code.
+* A Sphinx plugin for documenting Starlark and Bazel objects.
+* Rules for readthedocs build integration.
+
+While it is primarily oriented towards docgen for Starlark code, the core of it
+is agnostic as to what is being documented.
+
+
+```{toctree}
+:hidden:
+
+starlark-docgen
+sphinx-bzl
+```
diff --git a/sphinxdocs/docs/sphinx-bzl.md b/sphinxdocs/docs/sphinx-bzl.md
new file mode 100644
index 0000000..c6dc430
--- /dev/null
+++ b/sphinxdocs/docs/sphinx-bzl.md
@@ -0,0 +1,203 @@
+# Bazel plugin for Sphinx
+
+The `sphinx_bzl` Python package is a Sphinx plugin that defines a custom domain
+("bzl") in the Sphinx system. This provides first-class integration with Sphinx
+and allows code comments to provide rich information and allows manually writing
+docs for objects that aren't directly representable in bzl source code. For
+example, the fields of a provider can use `:type:` to indicate the type of a
+field, or manually written docs can use the `{bzl:target}` directive to document
+a well known target.
+
+## Configuring Sphinx
+
+To enable the plugin in Sphinx, depend on
+`@rules_python//sphinxdocs/src/sphinx_bzl` and enable it in `conf.py`:
+
+```
+extensions = [
+    "sphinx_bzl.bzl",
+]
+```
+
+## Brief introduction to Sphinx terminology
+
+To aid understanding how to write docs, lets define a few common terms:
+
+* **Role**: A role is the "bzl:obj" part when writing ``{bzl:obj}`ref` ``.
+  Roles mark inline text as needing special processing. There's generally
+  two types of processing: creating cross references, or role-specific custom
+  rendering. For example `{bzl:obj}` will create a cross references, while
+  `{bzl:default-value}` indicates the default value of an argument.
+* **Directive**: A directive is indicated with `:::` and allows defining an
+  entire object and its parts. For example, to describe a function and its
+  arguments, the `:::{bzl:function}` directive is used.
+* **Directive Option**: A directive option is the "type" part when writing
+  `:type:` within a directive. Directive options are how directives are told
+  the meaning of certain values, such as the type of a provider field. Depending
+  on the object being documented, a directive option may be used instead of
+  special role to indicate semantic values.
+
+Most often, you'll be using roles to refer other objects or indicate special
+values in doc strings. For directives, you're likely to only use them when
+manually writing docs to document flags, targets, or other objects that
+`sphinx_stardoc` generates for you.
+
+## MyST vs RST
+
+By default, Sphinx uses ReStructured Text (RST) syntax for its documents.
+Unfortunately, RST syntax is very different than the popular Markdown syntax. To
+bridge the gap, MyST translates Markdown-style syntax into the RST equivalents.
+This allows easily using Markdown in bzl files.
+
+While MyST isn't required for the core `sphinx_bzl` plugin to work, this
+document uses MyST syntax because `sphinx_stardoc` bzl doc gen rule requires
+MyST.
+
+## Type expressions
+
+Several roles or fields accept type expressions. Type expressions use
+Python-style annotation syntax to describe data types. For example `None | list[str]`
+describes a type of "None or a list of strings". Each component of the
+expression is parsed and cross reference to its associated type definition.
+
+## Cross references
+
+In brief, to reference bzl objects, use the `bzl:obj` role and use the
+Bazel label string you would use to refer to the object in Bazel (using `%` to
+denote names within a file). For example, to unambiguously refer to `py_binary`:
+
+```
+{bzl:obj}`@rules_python//python:py_binary.bzl%py_binary`
+```
+
+The above is pretty long, so shorter names are also supported, and `sphinx_bzl`
+will try to find something that matches. Additionally, in `.bzl` code, the
+`bzl:` prefix is set as the default. The above can then be shortened to:
+
+```
+{obj}`py_binary`
+```
+
+The text that is displayed by be customized by putting the reference string in
+chevrons (`<>`):
+
+```
+{obj}`the binary rule <py_binary>`
+```
+
+Finally, specific types of objects (rules, functions, providers, etc) can be
+specified to help disambiguate short names:
+
+```
+{function}`py_binary`  # Refers to the wrapping macro
+{rule}`py_binary`  # Refers to the underlying rule
+```
+
+Those are the basics of cross referencing. Sphinx has several additional
+syntaxes for finding and referencing objects; see
+[the MyST docs for supported
+syntaxes](https://myst-parser.readthedocs.io/en/latest/syntax/cross-referencing.html#reference-roles)
+
+
+### Cross reference roles
+
+A cross reference role is the `obj` portion of `{bzl:obj}`. It affects what is
+searched and matched. Supported cross reference roles are:
+
+* `{bzl:arg}`: Refer to a function argument.
+* `{bzl:attr}`: Refer to a rule attribute.
+* `{bzl:obj}`: Refer to any type of Bazel object
+* `{bzl:rule}`: Refer to a rule.
+* `{bzl:target}`: Refer to a target.
+* `{bzl:type}`: Refer to a type or type expression; can also be used in argument
+  documentation.
+
+## Special roles
+
+There are several special roles that can be used to annotate parts of objects,
+such as the type of arguments or their default values.
+
+### Role bzl:default-value
+
+Indicate the default value for a function argument or rule attribute. Use it in
+the Args doc of a function or the doc text of an attribute.
+
+```
+def func(arg=1):
+   """Do stuff
+
+   Args:
+     foo: {default-value}`1` the arg
+
+my_rule = rule(attrs = {
+    "foo": attr.string(doc="{default-value}`bar`)
+})
+
+```
+
+### Role bzl:return-type
+
+Indicates the return type for a function. Use it in the Returns doc of a
+function.
+
+```
+def func():
+    """Do stuff
+
+    Returns:
+      {return-type}`int`
+    """
+    return 1
+```
+
+### Role bzl:type
+
+Indicates the type of an argument for a function. Use it in the Args doc of
+a function.
+
+```
+def func(arg):
+    """Do stuff
+
+    Args:
+      arg: {type}`int`
+    """
+    print(arg + 1)
+```
+
+## Directives
+
+Most directives are automatically generated by `sphinx_stardoc`. Here, we only
+document ones that must be manually written.
+
+To write a directive, a line starts with 3 to 6 colons (`:`), followed by the
+directive name in braces (`{}`), and eventually ended by the same number of
+colons on their own line. For example:
+
+```
+:::{bzl:target} //my:target
+
+Doc about target
+:::
+```
+
+### Directive bzl:currentfile
+
+This directive indicates the Bazel file that objects defined in the current
+documentation file are in. This is required for any page that defines Bazel
+objects.
+
+### Directive bzl:target
+
+Documents a target. It takes no directive options
+
+```
+:::{bzl:target} //foo:target
+
+My docs
+:::
+```
+
+### Directive bzl:flag
+
+Documents a flag. It has the same format as `bzl:target`
diff --git a/sphinxdocs/docs/starlark-docgen.md b/sphinxdocs/docs/starlark-docgen.md
new file mode 100644
index 0000000..d131607
--- /dev/null
+++ b/sphinxdocs/docs/starlark-docgen.md
@@ -0,0 +1,75 @@
+# Starlark docgen
+
+Using the `sphinx_stardoc` rule, API documentation can be generated from bzl
+source code. This rule requires both MyST-based markdown and the `sphinx_bzl`
+Sphinx extension are enabled. This allows source code to use Markdown and
+Sphinx syntax to create rich documentation with cross references, types, and
+more.
+
+
+## Configuring Sphinx
+
+While the `sphinx_stardoc` rule doesn't require Sphinx itself, the source
+it generates requires some additional Sphinx plugins and config settings.
+
+When defining the `sphinx_build_binary` target, also depend on:
+* `@rules_python//sphinxdocs/src/sphinx_bzl:sphinx_bzl`
+* `myst_parser` (e.g. `@pypi//myst_parser`)
+* `typing_extensions` (e.g. `@pypi//myst_parser`)
+
+```
+sphinx_build_binary(
+    name = "sphinx-build",
+    deps = [
+        "@rules_python//sphinxdocs/src/sphinx_bzl",
+        "@pypi//myst_parser",
+        "@pypi//typing_extensions",
+        ...
+    ]
+)
+```
+
+In `conf.py`, enable the `sphinx_bzl` extension, `myst_parser` extension,
+and the `colon_fence` MyST extension.
+
+```
+extensions = [
+    "myst_parser",
+    "sphinx_bzl.bzl",
+]
+
+myst_enable_extensions = [
+    "colon_fence",
+]
+```
+
+## Generating docs from bzl files
+
+To convert the bzl code to Sphinx doc sources, `sphinx_stardocs` is the primary
+rule to do so. It takes a list of `bzl_library` targets or files and generates docs for
+each. When a `bzl_library` target is passed, the `bzl_library.srcs` value can only
+have a single file.
+
+Example:
+
+```
+sphinx_stardocs(
+    name = "my_docs",
+    srcs = [
+      ":binary_bzl",
+      ":library_bzl",
+    ]
+)
+
+bzl_library(
+   name = "binary_bzl",
+   srcs = ["binary.bzl"],
+   deps = ...
+)
+
+bzl_library(
+   name = "library_bzl",
+   srcs = ["library.bzl"],
+   deps = ...
+)
+```
diff --git a/sphinxdocs/inventories/bazel_inventory.txt b/sphinxdocs/inventories/bazel_inventory.txt
index a7f0222..445f0f7 100644
--- a/sphinxdocs/inventories/bazel_inventory.txt
+++ b/sphinxdocs/inventories/bazel_inventory.txt
@@ -1,16 +1,50 @@
 # Sphinx inventory version 2
 # Project: Bazel
-# Version: 7.0.0
+# Version: 7.3.0
 # The remainder of this file is compressed using zlib
 Action bzl:type 1 rules/lib/Action -
 File bzl:type 1 rules/lib/File -
 Label bzl:type 1 rules/lib/Label -
 Target bzl:type 1 rules/lib/builtins/Target -
 bool bzl:type 1 rules/lib/bool -
+ctx.actions bzl:obj 1 rules/lib/builtins/ctx#actions -
+ctx.aspect_ids bzl:obj 1 rules/lib/builtins/ctx#aspect_ids -
+ctx.attr bzl:obj 1 rules/lib/builtins/ctx#attr -
+ctx.bin_dir bzl:obj 1 rules/lib/builtins/ctx#bin_dir -
+ctx.build_file_path bzl:obj 1 rules/lib/builtins/ctx#build_file_path -
+ctx.build_setting_value bzl:obj 1 rules/lib/builtins/ctx#build_setting_value -
+ctx.configuration bzl:obj 1 rules/lib/builtins/ctx#configuration -
+ctx.coverage_instrumented bzl:function 1 rules/lib/builtins/ctx#coverage_instrumented -
+ctx.created_actions bzl:function 1 rules/lib/builtins/ctx#created_actions -
+ctx.disabled_features bzl:obj 1 rules/lib/builtins/ctx#disabled_features -
+ctx.exec_groups bzl:obj 1 rules/lib/builtins/ctx#exec_groups -
+ctx.executable bzl:obj 1 rules/lib/builtins/ctx#executable -
+ctx.expand_location bzl:function 1 rules/lib/builtins/ctx#expand_location -
+ctx.expand_location bzl:function 1 rules/lib/builtins/ctx#expand_location - -
+ctx.expand_make_variables bzl:function 1 rules/lib/builtins/ctx#expand_make_variables -
+ctx.features bzl:obj 1 rules/lib/builtins/ctx#features -
+ctx.file bzl:obj 1 rules/lib/builtins/ctx#file -
+ctx.files bzl:obj 1 rules/lib/builtins/ctx#files -
+ctx.fragments bzl:obj 1 rules/lib/builtins/ctx#fragments -
+ctx.genfiles_dir bzl:obj 1 rules/lib/builtins/ctx#genfiles_dir -
+ctx.info_file bzl:obj 1 rules/lib/builtins/ctx#info_file -
+ctx.label bzl:obj 1 rules/lib/builtins/ctx#label -
+ctx.outputs bzl:obj 1 rules/lib/builtins/ctx#outputs -
+ctx.resolve_command bzl:function 1 rules/lib/builtins/ctx#resolve_command -
+ctx.resolve_tools bzl:function 1 rules/lib/builtins/ctx#resolve_tools -
+ctx.rule bzl:obj 1 rules/lib/builtins/ctx#rule -
+ctx.runfiles bzl:function 1 rules/lib/builtins/ctx#runfiles -
+ctx.split_attr bzl:obj 1 rules/lib/builtins/ctx#split_attr -
+ctx.super bzl:obj 1 rules/lib/builtins/ctx#super -
+ctx.target_platform_has_constraint bzl:function 1 rules/lib/builtins/ctx#target_platform_has_constraint -
+ctx.toolchains bzl:obj 1 rules/lib/builtins/ctx#toolchains -
+ctx.var bzl:obj 1 rules/lib/builtins/ctx#var -
+ctx.version_file bzl:obj 1 rules/lib/builtins/ctx#version_file -
+ctx.workspace_name bzl:obj 1 rules/lib/builtins/ctx#workspace_name -
 int bzl:type 1 rules/lib/int -
 depset bzl:type 1 rules/lib/depset -
 dict bzl:type 1 rules/lib/dict -
-label bzl:doc 1 concepts/labels -
+label bzl:type 1 concepts/labels -
 attr.bool bzl:type 1 rules/lib/toplevel/attr#bool -
 attr.int bzl:type 1 rules/lib/toplevel/attr#int -
 attr.label bzl:type 1 rules/lib/toplevel/attr#label -
@@ -18,7 +52,17 @@
 attr.string bzl:type 1 rules/lib/toplevel/attr#string -
 attr.string_list bzl:type 1 rules/lib/toplevel/attr#string_list -
 list bzl:type 1 rules/lib/list -
-python bzl:doc 1 reference/be/python -
+native.existing_rule bzl:function 1 rules/lib/toplevel/native#existing_rule -
+native.existing_rules bzl:function 1 rules/lib/toplevel/native#existing_rules -
+native.exports_files bzl:function 1 rules/lib/toplevel/native#exports_files -
+native.glob bzl:function 1 rules/lib/toplevel/native#glob -
+native.module_name bzl:function 1 rules/lib/toplevel/native#module_name -
+native.module_version bzl:function 1 rules/lib/toplevel/native#module_version -
+native.package_group bzl:function 1 rules/lib/toplevel/native#package_group -
+native.package_name bzl:function 1 rules/lib/toplevel/native#package_name -
+native.package_relative_label bzl:function 1 rules/lib/toplevel/native#package_relative_label -
+native.repo_name bzl:function 1 rules/lib/toplevel/native#repo_name -
+native.repository_name bzl:function 1 rules/lib/toplevel/native#repository_name -
 str bzl:type 1 rules/lib/string -
 struct bzl:type 1 rules/lib/builtins/struct -
 Name bzl:type 1 concepts/labels#target-names -
diff --git a/sphinxdocs/private/BUILD.bazel b/sphinxdocs/private/BUILD.bazel
index ec6a945..d91e048 100644
--- a/sphinxdocs/private/BUILD.bazel
+++ b/sphinxdocs/private/BUILD.bazel
@@ -26,11 +26,7 @@
 # referenced by the //sphinxdocs macros.
 exports_files(
     [
-        "func_template.vm",
-        "header_template.vm",
-        "provider_template.vm",
         "readthedocs_install.py",
-        "rule_template.vm",
         "sphinx_build.py",
         "sphinx_server.py",
     ],
@@ -38,13 +34,36 @@
 )
 
 bzl_library(
+    name = "sphinx_docs_library_macro_bzl",
+    srcs = ["sphinx_docs_library_macro.bzl"],
+    deps = [
+        ":sphinx_docs_library_bzl",
+        "//python/private:util_bzl",
+    ],
+)
+
+bzl_library(
+    name = "sphinx_docs_library_bzl",
+    srcs = ["sphinx_docs_library.bzl"],
+    deps = [":sphinx_docs_library_info_bzl"],
+)
+
+bzl_library(
+    name = "sphinx_docs_library_info_bzl",
+    srcs = ["sphinx_docs_library_info.bzl"],
+)
+
+bzl_library(
     name = "sphinx_bzl",
     srcs = ["sphinx.bzl"],
     deps = [
+        ":sphinx_docs_library_info_bzl",
         "//python:py_binary_bzl",
+        "@bazel_skylib//:bzl_library",
         "@bazel_skylib//lib:paths",
         "@bazel_skylib//lib:types",
         "@bazel_skylib//rules:build_test",
+        "@bazel_skylib//rules:common_settings",
         "@io_bazel_stardoc//stardoc:stardoc_lib",
     ],
 )
@@ -53,7 +72,11 @@
     name = "sphinx_stardoc_bzl",
     srcs = ["sphinx_stardoc.bzl"],
     deps = [
+        ":sphinx_docs_library_macro_bzl",
         "//python/private:util_bzl",
+        "//sphinxdocs:sphinx_bzl",
+        "@bazel_skylib//:bzl_library",
+        "@bazel_skylib//lib:paths",
         "@bazel_skylib//lib:types",
         "@bazel_skylib//rules:build_test",
         "@io_bazel_stardoc//stardoc:stardoc_lib",
diff --git a/sphinxdocs/private/readthedocs.bzl b/sphinxdocs/private/readthedocs.bzl
index ee8e7aa..a62c51b 100644
--- a/sphinxdocs/private/readthedocs.bzl
+++ b/sphinxdocs/private/readthedocs.bzl
@@ -27,11 +27,11 @@
     for more information.
 
     Args:
-        name: (str) name of the installer
-        docs: (label list) list of targets that generate directories to copy
+        name: {type}`Name` name of the installer
+        docs: {type}`list[label]` list of targets that generate directories to copy
             into the directories readthedocs expects final output in. This
-            is typically a single `sphinx_stardocs` target.
-        **kwargs: (dict) additional kwargs to pass onto the installer
+            is typically a single {obj}`sphinx_stardocs` target.
+        **kwargs: {type}`dict` additional kwargs to pass onto the installer
     """
     add_tag(kwargs, "@rules_python//sphinxdocs:readthedocs_install")
     py_binary(
diff --git a/sphinxdocs/private/sphinx.bzl b/sphinxdocs/private/sphinx.bzl
index a5ac831..a198291 100644
--- a/sphinxdocs/private/sphinx.bzl
+++ b/sphinxdocs/private/sphinx.bzl
@@ -15,13 +15,26 @@
 """Implementation of sphinx rules."""
 
 load("@bazel_skylib//lib:paths.bzl", "paths")
+load("@bazel_skylib//rules:build_test.bzl", "build_test")
 load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
 load("//python:py_binary.bzl", "py_binary")
 load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs")  # buildifier: disable=bzl-visibility
+load(":sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo")
 
 _SPHINX_BUILD_MAIN_SRC = Label("//sphinxdocs/private:sphinx_build.py")
 _SPHINX_SERVE_MAIN_SRC = Label("//sphinxdocs/private:sphinx_server.py")
 
+_SphinxSourceTreeInfo = provider(
+    doc = "Information about source tree for Sphinx to build.",
+    fields = {
+        "source_root": """
+:type: str
+
+Path of the root directory for the source files (which are in DefaultInfo.files)
+""",
+    },
+)
+
 def sphinx_build_binary(name, py_binary_rule = py_binary, **kwargs):
     """Create an executable with the sphinx-build command line interface.
 
@@ -29,13 +42,13 @@
     needs at runtime.
 
     Args:
-        name: (str) name of the target. The name "sphinx-build" is the
+        name: {type}`str` name of the target. The name "sphinx-build" is the
             conventional name to match what Sphinx itself uses.
-        py_binary_rule: (optional callable) A `py_binary` compatible callable
+        py_binary_rule: {type}`callable` A `py_binary` compatible callable
             for creating the target. If not set, the regular `py_binary`
             rule is used. This allows using the version-aware rules, or
             other alternative implementations.
-        **kwargs: Additional kwargs to pass onto `py_binary`. The `srcs` and
+        **kwargs: {type}`dict` Additional kwargs to pass onto `py_binary`. The `srcs` and
             `main` attributes must not be specified.
     """
     add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_build_binary")
@@ -50,6 +63,7 @@
         name,
         *,
         srcs = [],
+        deps = [],
         renamed_srcs = {},
         sphinx,
         config,
@@ -61,53 +75,61 @@
     """Generate docs using Sphinx.
 
     This generates three public targets:
-        * `<name>`: The output of this target is a directory for each
-          format Sphinx creates. This target also has a separate output
-          group for each format. e.g. `--output_group=html` will only build
-          the "html" format files.
-        * `<name>_define`: A multi-string flag to add additional `-D`
-          arguments to the Sphinx invocation. This is useful for overriding
-          the version information in the config file for builds.
-        * `<name>.serve`: A binary that locally serves the HTML output. This
-          allows previewing docs during development.
+    * `<name>`: The output of this target is a directory for each
+      format Sphinx creates. This target also has a separate output
+      group for each format. e.g. `--output_group=html` will only build
+      the "html" format files.
+    * `<name>_define`: A multi-string flag to add additional `-D`
+      arguments to the Sphinx invocation. This is useful for overriding
+      the version information in the config file for builds.
+    * `<name>.serve`: A binary that locally serves the HTML output. This
+      allows previewing docs during development.
 
     Args:
-        name: (str) name of the docs rule.
-        srcs: (label list) The source files for Sphinx to process.
-        renamed_srcs: (label_keyed_string_dict) Doc source files for Sphinx that
+        name: {type}`Name` name of the docs rule.
+        srcs: {type}`list[label]` The source files for Sphinx to process.
+        deps: {type}`list[label]` of {obj}`sphinx_docs_library` targets.
+        renamed_srcs: {type}`dict[label, dict]` Doc source files for Sphinx that
             are renamed. This is typically used for files elsewhere, such as top
             level files in the repo.
-        sphinx: (label) the Sphinx tool to use for building
+        sphinx: {type}`label` the Sphinx tool to use for building
             documentation. Because Sphinx supports various plugins, you must
             construct your own binary with the necessary dependencies. The
-            `sphinx_build_binary` rule can be used to define such a binary, but
+            {obj}`sphinx_build_binary` rule can be used to define such a binary, but
             any executable supporting the `sphinx-build` command line interface
             can be used (typically some `py_binary` program).
-        config: (label) the Sphinx config file (`conf.py`) to use.
+        config: {type}`label` the Sphinx config file (`conf.py`) to use.
         formats: (list of str) the formats (`-b` flag) to generate documentation
             in. Each format will become an output group.
-        strip_prefix: (str) A prefix to remove from the file paths of the
-            source files. e.g., given `//docs:foo.md`, stripping `docs/`
-            makes Sphinx see `foo.md` in its generated source directory.
-        extra_opts: (list[str]) Additional options to pass onto Sphinx building.
+        strip_prefix: {type}`str` A prefix to remove from the file paths of the
+            source files. e.g., given `//docs:foo.md`, stripping `docs/` makes
+            Sphinx see `foo.md` in its generated source directory. If not
+            specified, then {any}`native.package_name` is used.
+        extra_opts: {type}`list[str]` Additional options to pass onto Sphinx building.
             On each provided option, a location expansion is performed.
-            See `ctx.expand_location()`.
-        tools: (list[label]) Additional tools that are used by Sphinx and its plugins.
+            See {any}`ctx.expand_location`.
+        tools: {type}`list[label]` Additional tools that are used by Sphinx and its plugins.
             This just makes the tools available during Sphinx execution. To locate
-            them, use `extra_opts` and `$(location)`.
-        **kwargs: (dict) Common attributes to pass onto rules.
+            them, use {obj}`extra_opts` and `$(location)`.
+        **kwargs: {type}`dict` Common attributes to pass onto rules.
     """
     add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_docs")
     common_kwargs = copy_propagating_kwargs(kwargs)
 
+    _sphinx_source_tree(
+        name = name + "/_sources",
+        srcs = srcs,
+        deps = deps,
+        renamed_srcs = renamed_srcs,
+        config = config,
+        strip_prefix = strip_prefix,
+        **common_kwargs
+    )
     _sphinx_docs(
         name = name,
-        srcs = srcs,
-        renamed_srcs = renamed_srcs,
         sphinx = sphinx,
-        config = config,
         formats = formats,
-        strip_prefix = strip_prefix,
+        source_tree = name + "/_sources",
         extra_opts = extra_opts,
         tools = tools,
         **kwargs
@@ -132,8 +154,15 @@
         **common_kwargs
     )
 
+    build_test(
+        name = name + "_build_test",
+        targets = [name],
+        **kwargs  # kwargs used to pick up target_compatible_with
+    )
+
 def _sphinx_docs_impl(ctx):
-    source_dir_path, _, inputs = _create_sphinx_source_tree(ctx)
+    source_dir_path = ctx.attr.source_tree[_SphinxSourceTreeInfo].source_root
+    inputs = ctx.attr.source_tree[DefaultInfo].files
 
     outputs = {}
     for format in ctx.attr.formats:
@@ -156,21 +185,14 @@
 _sphinx_docs = rule(
     implementation = _sphinx_docs_impl,
     attrs = {
-        "config": attr.label(
-            allow_single_file = True,
-            mandatory = True,
-            doc = "Config file for Sphinx",
-        ),
         "extra_opts": attr.string_list(
             doc = "Additional options to pass onto Sphinx. These are added after " +
                   "other options, but before the source/output args.",
         ),
         "formats": attr.string_list(doc = "Output formats for Sphinx to create."),
-        "renamed_srcs": attr.label_keyed_string_dict(
-            allow_files = True,
-            doc = "Doc source files for Sphinx that are renamed. This is " +
-                  "typically used for files elsewhere, such as top level " +
-                  "files in the repo.",
+        "source_tree": attr.label(
+            doc = "Directory of files for Sphinx to process.",
+            providers = [_SphinxSourceTreeInfo],
         ),
         "sphinx": attr.label(
             executable = True,
@@ -178,11 +200,6 @@
             mandatory = True,
             doc = "Sphinx binary to generate documentation.",
         ),
-        "srcs": attr.label_list(
-            allow_files = True,
-            doc = "Doc source files for Sphinx.",
-        ),
-        "strip_prefix": attr.string(doc = "Prefix to remove from input file paths."),
         "tools": attr.label_list(
             cfg = "exec",
             doc = "Additional tools that are used by Sphinx and its plugins.",
@@ -193,55 +210,6 @@
     },
 )
 
-def _create_sphinx_source_tree(ctx):
-    # Sphinx only accepts a single directory to read its doc sources from.
-    # Because plain files and generated files are in different directories,
-    # we need to merge the two into a single directory.
-    source_prefix = paths.join(ctx.label.name, "_sources")
-    sphinx_source_files = []
-
-    def _symlink_source(orig):
-        source_rel_path = orig.short_path
-        if source_rel_path.startswith(ctx.attr.strip_prefix):
-            source_rel_path = source_rel_path[len(ctx.attr.strip_prefix):]
-
-        sphinx_source = ctx.actions.declare_file(paths.join(source_prefix, source_rel_path))
-        ctx.actions.symlink(
-            output = sphinx_source,
-            target_file = orig,
-            progress_message = "Symlinking Sphinx source %{input} to %{output}",
-        )
-        sphinx_source_files.append(sphinx_source)
-        return sphinx_source
-
-    # Though Sphinx has a -c flag, we move the config file into the sources
-    # directory to make the config more intuitive because some configuration
-    # options are relative to the config location, not the sources directory.
-    source_conf_file = _symlink_source(ctx.file.config)
-    sphinx_source_dir_path = paths.dirname(source_conf_file.path)
-
-    for orig_file in ctx.files.srcs:
-        _symlink_source(orig_file)
-
-    for src_target, dest in ctx.attr.renamed_srcs.items():
-        src_files = src_target.files.to_list()
-        if len(src_files) != 1:
-            fail("A single file must be specified to be renamed. Target {} " +
-                 "generate {} files: {}".format(
-                     src_target,
-                     len(src_files),
-                     src_files,
-                 ))
-        sphinx_src = ctx.actions.declare_file(paths.join(source_prefix, dest))
-        ctx.actions.symlink(
-            output = sphinx_src,
-            target_file = src_files[0],
-            progress_message = "Symlinking (renamed) Sphinx source %{input} to %{output}",
-        )
-        sphinx_source_files.append(sphinx_src)
-
-    return sphinx_source_dir_path, source_conf_file, sphinx_source_files
-
 def _run_sphinx(ctx, format, source_path, inputs, output_prefix):
     output_dir = ctx.actions.declare_directory(paths.join(output_prefix, format))
 
@@ -281,6 +249,93 @@
     )
     return output_dir
 
+def _sphinx_source_tree_impl(ctx):
+    # Sphinx only accepts a single directory to read its doc sources from.
+    # Because plain files and generated files are in different directories,
+    # we need to merge the two into a single directory.
+    source_prefix = ctx.label.name
+    sphinx_source_files = []
+
+    # Materialize a file under the `_sources` dir
+    def _relocate(source_file, dest_path = None):
+        if not dest_path:
+            dest_path = source_file.short_path.removeprefix(ctx.attr.strip_prefix)
+        dest_file = ctx.actions.declare_file(paths.join(source_prefix, dest_path))
+        ctx.actions.symlink(
+            output = dest_file,
+            target_file = source_file,
+            progress_message = "Symlinking Sphinx source %{input} to %{output}",
+        )
+        sphinx_source_files.append(dest_file)
+        return dest_file
+
+    # Though Sphinx has a -c flag, we move the config file into the sources
+    # directory to make the config more intuitive because some configuration
+    # options are relative to the config location, not the sources directory.
+    source_conf_file = _relocate(ctx.file.config)
+    sphinx_source_dir_path = paths.dirname(source_conf_file.path)
+
+    for src in ctx.attr.srcs:
+        if SphinxDocsLibraryInfo in src:
+            fail((
+                "In attribute srcs: target {src} is misplaced here: " +
+                "sphinx_docs_library targets belong in the deps attribute."
+            ).format(src = src))
+
+    for orig_file in ctx.files.srcs:
+        _relocate(orig_file)
+
+    for src_target, dest in ctx.attr.renamed_srcs.items():
+        src_files = src_target.files.to_list()
+        if len(src_files) != 1:
+            fail("A single file must be specified to be renamed. Target {} " +
+                 "generate {} files: {}".format(
+                     src_target,
+                     len(src_files),
+                     src_files,
+                 ))
+        _relocate(src_files[0], dest)
+
+    for t in ctx.attr.deps:
+        info = t[SphinxDocsLibraryInfo]
+        for entry in info.transitive.to_list():
+            for original in entry.files:
+                new_path = entry.prefix + original.short_path.removeprefix(entry.strip_prefix)
+                _relocate(original, new_path)
+
+    return [
+        DefaultInfo(
+            files = depset(sphinx_source_files),
+        ),
+        _SphinxSourceTreeInfo(
+            source_root = sphinx_source_dir_path,
+        ),
+    ]
+
+_sphinx_source_tree = rule(
+    implementation = _sphinx_source_tree_impl,
+    attrs = {
+        "config": attr.label(
+            allow_single_file = True,
+            mandatory = True,
+            doc = "Config file for Sphinx",
+        ),
+        "deps": attr.label_list(
+            providers = [SphinxDocsLibraryInfo],
+        ),
+        "renamed_srcs": attr.label_keyed_string_dict(
+            allow_files = True,
+            doc = "Doc source files for Sphinx that are renamed. This is " +
+                  "typically used for files elsewhere, such as top level " +
+                  "files in the repo.",
+        ),
+        "srcs": attr.label_list(
+            allow_files = True,
+            doc = "Doc source files for Sphinx.",
+        ),
+        "strip_prefix": attr.string(doc = "Prefix to remove from input file paths."),
+    },
+)
 _FlagInfo = provider(
     doc = "Provider for a flag value",
     fields = ["value"],
@@ -294,7 +349,7 @@
     build_setting = config.string_list(flag = True, repeatable = True),
 )
 
-def sphinx_inventory(name, src, **kwargs):
+def sphinx_inventory(*, name, src, **kwargs):
     """Creates a compressed inventory file from an uncompressed on.
 
     The Sphinx inventory format isn't formally documented, but is understood
@@ -324,11 +379,14 @@
       * `display name` is a string. It can contain spaces, or simply be
         the value `-` to indicate it is the same as `name`
 
+    :::{seealso}
+    {bzl:obj}`//sphinxdocs/inventories` for inventories of Bazel objects.
+    :::
 
     Args:
-        name: [`target-name`] name of the target.
-        src: [`label`] Uncompressed inventory text file.
-        **kwargs: additional kwargs of common attributes.
+        name: {type}`Name` name of the target.
+        src: {type}`label` Uncompressed inventory text file.
+        **kwargs: {type}`dict` additional kwargs of common attributes.
     """
     _sphinx_inventory(name = name, src = src, **kwargs)
 
diff --git a/sphinxdocs/private/sphinx_docs_library.bzl b/sphinxdocs/private/sphinx_docs_library.bzl
new file mode 100644
index 0000000..076ed72
--- /dev/null
+++ b/sphinxdocs/private/sphinx_docs_library.bzl
@@ -0,0 +1,51 @@
+"""Implementation of sphinx_docs_library."""
+
+load(":sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo")
+
+def _sphinx_docs_library_impl(ctx):
+    strip_prefix = ctx.attr.strip_prefix or (ctx.label.package + "/")
+    direct_entries = []
+    if ctx.files.srcs:
+        entry = struct(
+            strip_prefix = strip_prefix,
+            prefix = ctx.attr.prefix,
+            files = ctx.files.srcs,
+        )
+        direct_entries.append(entry)
+
+    return [
+        SphinxDocsLibraryInfo(
+            strip_prefix = strip_prefix,
+            prefix = ctx.attr.prefix,
+            files = ctx.files.srcs,
+            transitive = depset(
+                direct = direct_entries,
+                transitive = [t[SphinxDocsLibraryInfo].transitive for t in ctx.attr.deps],
+            ),
+        ),
+        DefaultInfo(
+            files = depset(ctx.files.srcs),
+        ),
+    ]
+
+sphinx_docs_library = rule(
+    implementation = _sphinx_docs_library_impl,
+    attrs = {
+        "deps": attr.label_list(
+            doc = """
+Additional `sphinx_docs_library` targets to include. They do not have the
+`prefix` and `strip_prefix` attributes applied to them.""",
+            providers = [SphinxDocsLibraryInfo],
+        ),
+        "prefix": attr.string(
+            doc = "Prefix to prepend to file paths. Added after `strip_prefix` is removed.",
+        ),
+        "srcs": attr.label_list(
+            allow_files = True,
+            doc = "Files that are part of the library.",
+        ),
+        "strip_prefix": attr.string(
+            doc = "Prefix to remove from file paths. Removed before `prefix` is prepended.",
+        ),
+    },
+)
diff --git a/sphinxdocs/private/sphinx_docs_library_info.bzl b/sphinxdocs/private/sphinx_docs_library_info.bzl
new file mode 100644
index 0000000..de40d8d
--- /dev/null
+++ b/sphinxdocs/private/sphinx_docs_library_info.bzl
@@ -0,0 +1,30 @@
+"""Provider for collecting doc files as libraries."""
+
+SphinxDocsLibraryInfo = provider(
+    doc = "Information about a collection of doc files.",
+    fields = {
+        "files": """
+:type: depset[File]
+
+The documentation files for the library.
+""",
+        "prefix": """
+:type: str
+
+Prefix to prepend to file paths in `files`. It is added after `strip_prefix`
+is removed.
+""",
+        "strip_prefix": """
+:type: str
+
+Prefix to remove from file paths in `files`. It is removed before `prefix`
+is prepended.
+""",
+        "transitive": """
+:type: depset[struct]
+
+Depset of transitive library information. Each entry in the depset is a struct
+with fields matching the fields of this provider.
+""",
+    },
+)
diff --git a/sphinxdocs/private/sphinx_docs_library_macro.bzl b/sphinxdocs/private/sphinx_docs_library_macro.bzl
new file mode 100644
index 0000000..095b376
--- /dev/null
+++ b/sphinxdocs/private/sphinx_docs_library_macro.bzl
@@ -0,0 +1,13 @@
+"""Implementation of sphinx_docs_library macro."""
+
+load("//python/private:util.bzl", "add_tag")  # buildifier: disable=bzl-visibility
+load(":sphinx_docs_library.bzl", _sphinx_docs_library = "sphinx_docs_library")
+
+def sphinx_docs_library(**kwargs):
+    """Collection of doc files for use by `sphinx_docs`.
+
+    Args:
+        **kwargs: Args passed onto underlying {bzl:rule}`sphinx_docs_library` rule
+    """
+    add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_docs_library")
+    _sphinx_docs_library(**kwargs)
diff --git a/sphinxdocs/private/sphinx_stardoc.bzl b/sphinxdocs/private/sphinx_stardoc.bzl
index e2b1756..d5869b0 100644
--- a/sphinxdocs/private/sphinx_stardoc.bzl
+++ b/sphinxdocs/private/sphinx_stardoc.bzl
@@ -14,12 +14,34 @@
 
 """Rules to generate Sphinx-compatible documentation for bzl files."""
 
+load("@bazel_skylib//:bzl_library.bzl", "StarlarkLibraryInfo")
+load("@bazel_skylib//lib:paths.bzl", "paths")
 load("@bazel_skylib//lib:types.bzl", "types")
 load("@bazel_skylib//rules:build_test.bzl", "build_test")
 load("@io_bazel_stardoc//stardoc:stardoc.bzl", "stardoc")
 load("//python/private:util.bzl", "add_tag", "copy_propagating_kwargs")  # buildifier: disable=bzl-visibility
+load("//sphinxdocs/private:sphinx_docs_library_macro.bzl", "sphinx_docs_library")
 
-def sphinx_stardocs(name, docs, **kwargs):
+_StardocInputHelperInfo = provider(
+    doc = "Extracts the single source file from a bzl library.",
+    fields = {
+        "file": """
+:type: File
+
+The sole output file from the wrapped target.
+""",
+    },
+)
+
+def sphinx_stardocs(
+        *,
+        name,
+        srcs = [],
+        deps = [],
+        docs = {},
+        prefix = None,
+        strip_prefix = None,
+        **kwargs):
     """Generate Sphinx-friendly Markdown docs using Stardoc for bzl libraries.
 
     A `build_test` for the docs is also generated to ensure Stardoc is able
@@ -28,8 +50,12 @@
     NOTE: This generates MyST-flavored Markdown.
 
     Args:
-        name: `str`, the name of the resulting file group with the generated docs.
-        docs: `dict[str output, source]` of the bzl files to generate documentation
+        name: {type}`Name`, the name of the resulting file group with the generated docs.
+        srcs: {type}`list[label]` Each source is either the bzl file to process
+            or a `bzl_library` target with one source file of the bzl file to
+            process.
+        deps: {type}`list[label]` Targets that provide files loaded by `src`
+        docs: {type}`dict[str, str|dict]` of the bzl files to generate documentation
             for. The `output` key is the path of the output filename, e.g.,
             `foo/bar.md`. The `source` values can be either of:
             * A `str` label that points to a `bzl_library` target. The target
@@ -39,10 +65,17 @@
             * A `dict` with keys `input` and `dep`. The `input` key is a string
               label to the bzl file to generate docs for. The `dep` key is a
               string label to a `bzl_library` providing the necessary dependencies.
+        prefix: {type}`str` Prefix to add to the output file path. It is prepended
+            after `strip_prefix` is removed.
+        strip_prefix: {type}`str | None` Prefix to remove from the input file path;
+            it is removed before `prefix` is prepended. If not specified, then
+            {any}`native.package_name` is used.
         **kwargs: Additional kwargs to pass onto each `sphinx_stardoc` target
     """
+    internal_name = "_{}".format(name)
     add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_stardocs")
     common_kwargs = copy_propagating_kwargs(kwargs)
+    common_kwargs["target_compatible_with"] = kwargs.get("target_compatible_with")
 
     stardocs = []
     for out_name, entry in docs.items():
@@ -51,50 +84,165 @@
 
         if types.is_string(entry):
             stardoc_kwargs["deps"] = [entry]
-            stardoc_kwargs["input"] = entry.replace("_bzl", ".bzl")
+            stardoc_kwargs["src"] = entry.replace("_bzl", ".bzl")
         else:
             stardoc_kwargs.update(entry)
+
+            # input is accepted for backwards compatiblity. Remove when ready.
+            if "src" not in stardoc_kwargs and "input" in stardoc_kwargs:
+                stardoc_kwargs["src"] = stardoc_kwargs.pop("input")
             stardoc_kwargs["deps"] = [stardoc_kwargs.pop("dep")]
 
-        doc_name = "_{}_{}".format(name.lstrip("_"), out_name.replace("/", "_"))
-        _sphinx_stardoc(
+        doc_name = "{}_{}".format(internal_name, _name_from_label(out_name))
+        sphinx_stardoc(
             name = doc_name,
-            out = out_name,
+            output = out_name,
+            create_test = False,
             **stardoc_kwargs
         )
         stardocs.append(doc_name)
 
-    native.filegroup(
+    for label in srcs:
+        doc_name = "{}_{}".format(internal_name, _name_from_label(label))
+        sphinx_stardoc(
+            name = doc_name,
+            src = label,
+            # NOTE: We set prefix/strip_prefix here instead of
+            # on the sphinx_docs_library so that building the
+            # target produces markdown files in the expected location, which
+            # is convenient.
+            prefix = prefix,
+            strip_prefix = strip_prefix,
+            deps = deps,
+            create_test = False,
+            **common_kwargs
+        )
+        stardocs.append(doc_name)
+
+    sphinx_docs_library(
         name = name,
-        srcs = stardocs,
+        deps = stardocs,
         **common_kwargs
     )
-    build_test(
-        name = name + "_build_test",
-        targets = stardocs,
+    if stardocs:
+        build_test(
+            name = name + "_build_test",
+            targets = stardocs,
+            **common_kwargs
+        )
+
+def sphinx_stardoc(
+        name,
+        src,
+        deps = [],
+        public_load_path = None,
+        prefix = None,
+        strip_prefix = None,
+        create_test = True,
+        output = None,
+        **kwargs):
+    """Generate Sphinx-friendly Markdown for a single bzl file.
+
+    Args:
+        name: {type}`Name` name for the target.
+        src: {type}`label` The bzl file to process, or a `bzl_library`
+            target with one source file of the bzl file to process.
+        deps: {type}`list[label]` Targets that provide files loaded by `src`
+        public_load_path: {type}`str | None` override the file name that
+            is reported as the file being.
+        prefix: {type}`str | None` prefix to add to the output file path
+        strip_prefix: {type}`str | None` Prefix to remove from the input file path.
+            If not specified, then {any}`native.package_name` is used.
+        create_test: {type}`bool` True if a test should be defined to verify the
+            docs are buildable, False if not.
+        output: {type}`str | None` Optional explicit output file to use. If
+            not set, the output name will be derived from `src`.
+        **kwargs: {type}`dict` common args passed onto rules.
+    """
+    internal_name = "_{}".format(name.lstrip("_"))
+    add_tag(kwargs, "@rules_python//sphinxdocs:sphinx_stardoc")
+    common_kwargs = copy_propagating_kwargs(kwargs)
+    common_kwargs["target_compatible_with"] = kwargs.get("target_compatible_with")
+
+    input_helper_name = internal_name + ".primary_bzl_src"
+    _stardoc_input_helper(
+        name = input_helper_name,
+        target = src,
         **common_kwargs
     )
 
-def _sphinx_stardoc(*, name, out, public_load_path = None, **kwargs):
-    stardoc_name = "_{}_stardoc".format(name.lstrip("_"))
+    stardoc_name = internal_name + "_stardoc"
+
+    # NOTE: The .binaryproto suffix is an optimization. It makes the stardoc()
+    # call avoid performing a copy of the output to the desired name.
     stardoc_pb = stardoc_name + ".binaryproto"
 
-    if not public_load_path:
-        public_load_path = str(kwargs["input"])
-
     stardoc(
         name = stardoc_name,
+        input = input_helper_name,
         out = stardoc_pb,
         format = "proto",
-        **kwargs
+        deps = [src] + deps,
+        **common_kwargs
     )
 
+    pb2md_name = internal_name + "_pb2md"
     _stardoc_proto_to_markdown(
-        name = name,
+        name = pb2md_name,
         src = stardoc_pb,
-        output = out,
+        output = output,
+        output_name_from = input_helper_name if not output else None,
         public_load_path = public_load_path,
+        strip_prefix = strip_prefix,
+        prefix = prefix,
+        **common_kwargs
     )
+    sphinx_docs_library(
+        name = name,
+        srcs = [pb2md_name],
+        **common_kwargs
+    )
+    if create_test:
+        build_test(
+            name = name + "_build_test",
+            targets = [name],
+            **common_kwargs
+        )
+
+def _stardoc_input_helper_impl(ctx):
+    target = ctx.attr.target
+    if StarlarkLibraryInfo in target:
+        files = ctx.attr.target[StarlarkLibraryInfo].srcs
+    else:
+        files = target[DefaultInfo].files.to_list()
+
+    if len(files) == 0:
+        fail("Target {} produces no files, but must produce exactly 1 file".format(
+            ctx.attr.target.label,
+        ))
+    elif len(files) == 1:
+        primary = files[0]
+    else:
+        fail("Target {} produces {} files, but must produce exactly 1 file.".format(
+            ctx.attr.target.label,
+            len(files),
+        ))
+
+    return [
+        DefaultInfo(
+            files = depset([primary]),
+        ),
+        _StardocInputHelperInfo(
+            file = primary,
+        ),
+    ]
+
+_stardoc_input_helper = rule(
+    implementation = _stardoc_input_helper_impl,
+    attrs = {
+        "target": attr.label(allow_files = True),
+    },
+)
 
 def _stardoc_proto_to_markdown_impl(ctx):
     args = ctx.actions.args()
@@ -103,7 +251,16 @@
 
     inputs = [ctx.file.src]
     args.add("--proto", ctx.file.src)
-    args.add("--output", ctx.outputs.output)
+
+    if not ctx.outputs.output:
+        output_name = ctx.attr.output_name_from[_StardocInputHelperInfo].file.short_path
+        output_name = paths.replace_extension(output_name, ".md")
+        output_name = ctx.attr.prefix + output_name.removeprefix(ctx.attr.strip_prefix)
+        output = ctx.actions.declare_file(output_name)
+    else:
+        output = ctx.outputs.output
+
+    args.add("--output", output)
 
     if ctx.attr.public_load_path:
         args.add("--public-load-path={}".format(ctx.attr.public_load_path))
@@ -112,17 +269,23 @@
         executable = ctx.executable._proto_to_markdown,
         arguments = [args],
         inputs = inputs,
-        outputs = [ctx.outputs.output],
+        outputs = [output],
         mnemonic = "SphinxStardocProtoToMd",
         progress_message = "SphinxStardoc: converting proto to markdown: %{input} -> %{output}",
     )
+    return [DefaultInfo(
+        files = depset([output]),
+    )]
 
 _stardoc_proto_to_markdown = rule(
     implementation = _stardoc_proto_to_markdown_impl,
     attrs = {
-        "output": attr.output(mandatory = True),
+        "output": attr.output(mandatory = False),
+        "output_name_from": attr.label(),
+        "prefix": attr.string(),
         "public_load_path": attr.string(),
         "src": attr.label(allow_single_file = True, mandatory = True),
+        "strip_prefix": attr.string(),
         "_proto_to_markdown": attr.label(
             default = "//sphinxdocs/private:proto_to_markdown",
             executable = True,
@@ -130,3 +293,7 @@
         ),
     },
 )
+
+def _name_from_label(label):
+    label = label.lstrip("/").lstrip(":").replace(":", "/")
+    return label
diff --git a/sphinxdocs/sphinx.bzl b/sphinxdocs/sphinx.bzl
index d9385bd..3c9dc6b 100644
--- a/sphinxdocs/sphinx.bzl
+++ b/sphinxdocs/sphinx.bzl
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""# Rules to generate Sphinx documentation.
+"""Rules to generate Sphinx documentation.
 
 The general usage of the Sphinx rules requires two pieces:
 
diff --git a/sphinxdocs/sphinx_docs_library.bzl b/sphinxdocs/sphinx_docs_library.bzl
new file mode 100644
index 0000000..e864329
--- /dev/null
+++ b/sphinxdocs/sphinx_docs_library.bzl
@@ -0,0 +1,5 @@
+"""Library-like rule to collect docs."""
+
+load("//sphinxdocs/private:sphinx_docs_library_macro.bzl", _sphinx_docs_library = "sphinx_docs_library")
+
+sphinx_docs_library = _sphinx_docs_library
diff --git a/sphinxdocs/sphinx_stardoc.bzl b/sphinxdocs/sphinx_stardoc.bzl
index 623bc64..9913964 100644
--- a/sphinxdocs/sphinx_stardoc.bzl
+++ b/sphinxdocs/sphinx_stardoc.bzl
@@ -14,6 +14,7 @@
 
 """Rules to generate Sphinx-compatible documentation for bzl files."""
 
-load("//sphinxdocs/private:sphinx_stardoc.bzl", _sphinx_stardocs = "sphinx_stardocs")
+load("//sphinxdocs/private:sphinx_stardoc.bzl", _sphinx_stardoc = "sphinx_stardoc", _sphinx_stardocs = "sphinx_stardocs")
 
 sphinx_stardocs = _sphinx_stardocs
+sphinx_stardoc = _sphinx_stardoc
diff --git a/sphinxdocs/src/sphinx_bzl/bzl.py b/sphinxdocs/src/sphinx_bzl/bzl.py
index be38d8a..d09914b 100644
--- a/sphinxdocs/src/sphinx_bzl/bzl.py
+++ b/sphinxdocs/src/sphinx_bzl/bzl.py
@@ -1413,6 +1413,7 @@
         "obj": roles.XRefRole(),
         "required-providers": _RequiredProvidersRole(),
         "return-type": _ReturnTypeRole(),
+        "rule": roles.XRefRole(),
         "target": roles.XRefRole(),
         "type": _TypeRole(),
     }
diff --git a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel
index b141e5f..e2837ff 100644
--- a/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel
+++ b/sphinxdocs/tests/sphinx_stardoc/BUILD.bazel
@@ -1,7 +1,19 @@
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
 load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER")  # buildifier: disable=bzl-visibility
 load("//sphinxdocs:sphinx.bzl", "sphinx_build_binary", "sphinx_docs")
-load("//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardocs")
+load("//sphinxdocs:sphinx_stardoc.bzl", "sphinx_stardoc", "sphinx_stardocs")
+
+# We only build for Linux and Mac because:
+# 1. The actual doc process only runs on Linux
+# 2. Mac is a common development platform, and is close enough to Linux
+#    it's feasible to make work.
+# Making CI happy under Windows is too much of a headache, though, so we don't
+# bother with that.
+_TARGET_COMPATIBLE_WITH = select({
+    "@platforms//os:linux": [],
+    "@platforms//os:macos": [],
+    "//conditions:default": ["@platforms//:incompatible"],
+}) if IS_BAZEL_7_OR_HIGHER else ["@platforms//:incompatible"]
 
 sphinx_docs(
     name = "docs",
@@ -9,7 +21,7 @@
         include = [
             "*.md",
         ],
-    ) + [":bzl_docs"],
+    ),
     config = "conf.py",
     formats = [
         "html",
@@ -19,37 +31,48 @@
     },
     sphinx = ":sphinx-build",
     strip_prefix = package_name() + "/",
-    # We only develop the docs using Linux/Mac, and there are deps that
-    # don't work for Windows, so just skip Windows.
-    target_compatible_with = select({
-        "@platforms//os:linux": [],
-        "@platforms//os:macos": [],
-        "//conditions:default": ["@platforms//:incompatible"],
-    }) if IS_BAZEL_7_OR_HIGHER else ["@platforms//:incompatible"],
+    target_compatible_with = _TARGET_COMPATIBLE_WITH,
+    deps = [
+        ":bzl_function",
+        ":bzl_providers",
+        ":simple_bzl_docs",
+    ],
 )
 
 sphinx_stardocs(
-    name = "bzl_docs",
-    docs = {
-        "bzl_function.md": dict(
-            dep = ":all_bzl",
-            input = "//sphinxdocs/tests/sphinx_stardoc:bzl_function.bzl",
-        ),
-        "bzl_providers.md": dict(
-            dep = ":all_bzl",
-            input = "//sphinxdocs/tests/sphinx_stardoc:bzl_providers.bzl",
-        ),
-        "bzl_rule.md": dict(
-            dep = ":all_bzl",
-            input = "//sphinxdocs/tests/sphinx_stardoc:bzl_rule.bzl",
-        ),
-    },
-    target_compatible_with = [] if IS_BAZEL_7_OR_HIGHER else ["@platforms//:incompatible"],
+    name = "simple_bzl_docs",
+    srcs = [":bzl_rule_bzl"],
+    target_compatible_with = _TARGET_COMPATIBLE_WITH,
+)
+
+sphinx_stardoc(
+    name = "bzl_function",
+    src = ":bzl_function.bzl",
+    target_compatible_with = _TARGET_COMPATIBLE_WITH,
+    deps = [":func_and_providers_bzl"],
+)
+
+sphinx_stardoc(
+    name = "bzl_providers",
+    src = ":bzl_providers.bzl",
+    prefix = "addprefix_",
+    target_compatible_with = _TARGET_COMPATIBLE_WITH,
+    deps = [":func_and_providers_bzl"],
+)
+
+# A bzl_library with multiple sources
+bzl_library(
+    name = "func_and_providers_bzl",
+    srcs = [
+        "bzl_function.bzl",
+        "bzl_providers.bzl",
+    ],
 )
 
 bzl_library(
-    name = "all_bzl",
-    srcs = glob(["*.bzl"]),
+    name = "bzl_rule_bzl",
+    srcs = ["bzl_rule.bzl"],
+    deps = [":func_and_providers_bzl"],
 )
 
 sphinx_build_binary(