beets/docs/pluginlist.py
Sebastian Mohr b373b16ee9 The toctree for plugins now has the same sections as the index page.
Added an new page showing all plugins (including external ones) in
alphabetical order.
2025-07-17 16:48:19 +02:00

168 lines
6 KiB
Python

import os
from pathlib import Path
from docutils import nodes
from docutils.parsers.rst import Directive, directives
class PluginListDirective(Directive):
"""Directive to list all .rst files in a given folder.
Along with their top-level header, with optional extra user-defined entries.
Usage:
.. filelist::
:path: path/to/folder
:exclude: file1.rst, file2.rst
:extra:
overview.rst # file link with implicit title from filename
Overview: overview.rst # file link with custom title
:ref:`user-guide` # arbitrary reST reference
"""
has_content = False
required_arguments = 0
optional_arguments = 0
option_spec = {
"path": directives.unchanged_required,
"exclude": directives.unchanged,
"extra": directives.unchanged,
}
def _extract_title(self, path: Path):
"""Extract the first section title from an rst file."""
with open(path, encoding="utf-8") as f:
lines = [ln.rstrip() for ln in f]
for idx, line in enumerate(lines):
if not line:
continue
# Check for underline-style title
if idx + 1 < len(lines) and set(lines[idx + 1]) <= set(
"= - `:'~^_*+#<>"
):
underline = lines[idx + 1]
if len(underline) >= len(line):
return line
# Or overline/underline style
if idx >= 1 and set(lines[idx - 1]) <= set("= - `:'~^_*+#<>"):
overline = lines[idx - 1]
if len(overline) >= len(line):
return line
# Fallback: filename without extension
return os.path.splitext(os.path.basename(path))[0]
def _get_current_src(self) -> Path:
"""Get the current source file path."""
current_doc = self.state.document.current_source
if not current_doc:
raise ValueError("Current document source could not be determined.")
return Path(current_doc).resolve()
def run(self):
folder_option = self.options.get("path")
if not folder_option:
error = self.state_machine.reporter.error(
'The "path" option is required for the pluginlist directive.',
nodes.literal_block(self.block_text, self.block_text),
line=self.lineno,
)
return [error]
# Resolve folder path relative to current doc file
cur_path = self._get_current_src()
target_folder = cur_path.joinpath(folder_option).resolve().parent
if not os.path.isdir(target_folder):
error = self.state_machine.reporter.error(
f'Path "{folder_option}" resolved to "{target_folder}". '
"Could not found or is not a directory.",
nodes.literal_block(self.block_text, self.block_text),
line=self.lineno,
)
return [error]
excludes_raw = self.options.get("exclude", "")
excludes = [x.strip() for x in excludes_raw.split(",") if x.strip()]
# Find .rst files, excluding specified
files = [
f
for f in os.listdir(target_folder)
if f.endswith(".rst") and f not in excludes
]
refs = []
for filename in files:
# Reference to the rst file
refuri = (
os.path.splitext(os.path.join(folder_option, filename))[
0
].replace(os.sep, "/")
+ ".html"
)
# Title for the link
title = self._extract_title(target_folder.joinpath(filename))
ref = nodes.reference("", title, internal=True, refuri=refuri)
refs.append(ref)
# Extra entries into refs
extra_option = self.options.get("extra", "")
if extra_option:
from docutils.statemachine import ViewList
for line in extra_option.splitlines():
entry = line.strip()
if not entry:
continue
para = nodes.paragraph()
# If entry is pure reST (contains role/backticks and no file .rst)
if (
"`" in entry or entry.strip().startswith(":ref")
) and ".rst" not in entry:
vl = ViewList()
vl.append(entry, self.block_text)
self.state.nested_parse(vl, self.content_offset, para)
else:
# file link: either 'file.rst' or 'Title: file.rst'
if ":" in entry:
title, target = [p.strip() for p in entry.split(":", 1)]
else:
target = entry
title = Path(entry).stem
if target.endswith(".rst"):
title = self._extract_title(
target_folder.joinpath(target)
)
rel = Path(self.options["path"]) / target
refuri = str(rel.with_suffix(".html")).replace(
os.sep, "/"
)
ref = nodes.reference(
"", title, internal=True, refuri=refuri
)
para += ref
else:
# fallback parse
vl = ViewList()
vl.append(entry, self.block_text)
self.state.nested_parse(vl, self.content_offset, para)
refs.append(para)
# Sort refs
refs.sort(key=lambda x: x.astext().lower())
# Build bullet list of links
bullet_list = nodes.bullet_list()
for ref in refs:
item = nodes.list_item()
para = nodes.paragraph()
para += ref
item += para
bullet_list += item
return [bullet_list]