docs: standardize and validate plugin READMEs

This commit is contained in:
Lemmy
2026-07-15 21:56:42 -04:00
parent 1148c2d698
commit c057379c91
17 changed files with 700 additions and 70 deletions
@@ -1,6 +1,7 @@
from __future__ import annotations
import importlib.util
import tempfile
import unittest
from pathlib import Path
@@ -103,5 +104,100 @@ class PluginConfigAccessorTests(unittest.TestCase):
self.assertEqual(validate_plugins.obsolete_config_accessors(source), [])
class ReadmeTests(unittest.TestCase):
MANIFEST = {
"id": "me/example",
"dependencies": ["example-cli"],
"setting": [{"key": "interval"}],
"widget": [{"id": "widget", "entry": "widget.luau"}],
"panel": [{"id": "panel", "entry": "panel.luau"}],
"launcher_provider": [
{"id": "search", "entry": "launcher.luau", "prefix": "ex"}
],
}
VALID_README = """# Example
Example provides a useful widget, panel, and launcher for demonstration purposes.
## Plugin
| Field | Value |
| --- | --- |
| ID | `me/example` |
| Entries | Widget: `widget`; panel: `panel`; launcher: `search` |
| Launcher Prefix | `/ex` |
## Requirements
Install `example-cli` on `PATH`.
## Usage
Add the widget, type `/ex`, or open the panel:
```sh
noctalia msg panel-toggle me/example:panel
```
## Settings
Configure the update interval in plugin settings.
"""
def validate_readme(self, contents: str) -> list[str]:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plugin_dir = root / "example"
plugin_dir.mkdir()
(plugin_dir / "README.md").write_text(contents, encoding="utf-8")
validator = validate_plugins.Validator(root)
validator.validate_readme(plugin_dir, self.MANIFEST)
return validator.errors
def test_accepts_official_plugin_readme_structure(self) -> None:
self.assertEqual(self.validate_readme(self.VALID_README), [])
def test_requires_core_sections_and_intro(self) -> None:
errors = self.validate_readme("# Example\n\nToo short.\n")
self.assertTrue(any("short introduction" in error for error in errors))
self.assertTrue(any("## Plugin" in error for error in errors))
self.assertTrue(any("## Usage" in error for error in errors))
def test_headings_inside_code_fences_do_not_satisfy_sections(self) -> None:
errors = self.validate_readme(
"# Example\n\nA sufficiently descriptive introduction for this example plugin.\n\n"
"```md\n## Plugin\n## Usage\n```\n"
)
self.assertTrue(any("## Plugin" in error for error in errors))
self.assertTrue(any("## Usage" in error for error in errors))
def test_derives_documented_values_from_manifest(self) -> None:
readme = self.VALID_README
replacements = {
"`me/example`": "`me/wrong`",
"`widget`": "`other-widget`",
"noctalia msg panel-toggle me/example:panel": "noctalia msg panel-toggle me/example:wrong",
"`/ex`": "`/wrong`",
"`example-cli`": "`other-cli`",
}
for old, new in replacements.items():
with self.subTest(missing=old):
errors = self.validate_readme(readme.replace(old, new))
self.assertTrue(errors)
def test_requires_conditional_sections(self) -> None:
without_requirements = self.VALID_README.replace(
"## Requirements\n\nInstall `example-cli` on `PATH`.\n\n", ""
)
without_settings = self.VALID_README.replace(
"## Settings\n\nConfigure the update interval in plugin settings.\n", ""
)
self.assertTrue(
any("## Requirements" in error for error in self.validate_readme(without_requirements))
)
self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings)))
if __name__ == "__main__":
unittest.main()
+150 -2
View File
@@ -151,6 +151,7 @@ HTML_RE = re.compile(
)
INLINE_CODE_RE = re.compile(r"(?<!`)(`+)(?!`)(.*?)(?<!`)\1(?!`)", re.DOTALL)
FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
ATX_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$")
OBSOLETE_CONFIG_ACCESSOR_RE = re.compile(
r"\b(barWidget|desktopWidget|panel|launcher)\s*\.\s*getConfig\b"
)
@@ -211,6 +212,50 @@ def raw_html_line(markdown: str) -> int | None:
return text.count("\n", 0, match.start()) + 1
def markdown_headings(markdown: str) -> list[tuple[int, str, int, int]]:
"""Return ATX headings outside fenced code as (level, title, start, end)."""
headings: list[tuple[int, str, int, int]] = []
fence_char = ""
fence_length = 0
offset = 0
for line in markdown.splitlines(keepends=True):
stripped = line.rstrip("\r\n")
if fence_char:
closing = rf"^ {{0,3}}{re.escape(fence_char)}{{{fence_length},}}\s*$"
if re.match(closing, stripped):
fence_char = ""
fence_length = 0
offset += len(line)
continue
opening = FENCE_OPEN_RE.match(line)
if opening:
fence = opening.group(1)
fence_char = fence[0]
fence_length = len(fence)
offset += len(line)
continue
match = ATX_HEADING_RE.match(stripped)
if match:
headings.append((len(match.group(1)), match.group(2).strip(), offset, offset + len(line)))
offset += len(line)
return headings
def section_body(markdown: str, headings: list[tuple[int, str, int, int]], index: int) -> str:
"""Return a heading's body, including nested subsections."""
level, _title, _start, body_start = headings[index]
body_end = len(markdown)
for next_level, _next_title, next_start, _next_end in headings[index + 1 :]:
if next_level <= level:
body_end = next_start
break
return markdown[body_start:body_end].strip()
def obsolete_config_accessors(source: str) -> list[tuple[str, int]]:
"""Find removed entry-specific getConfig aliases outside Luau comments and strings."""
visible = list(source)
@@ -924,7 +969,7 @@ class Validator:
f"Export one with {THUMBNAIL_GENERATOR_URL}",
)
def validate_readme(self, plugin_dir: Path) -> None:
def validate_readme(self, plugin_dir: Path, manifest: dict[str, Any]) -> None:
readme = plugin_dir / "README.md"
if not readme.is_file():
return
@@ -939,6 +984,109 @@ class Validator:
if line is not None:
self.add_error(readme, f"raw HTML on line {line} is not allowed; use Markdown instead")
headings = markdown_headings(contents)
h1_indexes = [index for index, heading in enumerate(headings) if heading[0] == 1]
if not h1_indexes:
self.add_error(readme, "missing a level-one plugin title ('# Plugin Name')")
else:
h1_index = h1_indexes[0]
intro_start = headings[h1_index][3]
intro_end = headings[h1_index + 1][2] if h1_index + 1 < len(headings) else len(contents)
intro = contents[intro_start:intro_end]
intro_words = re.findall(r"[A-Za-z0-9][A-Za-z0-9'_-]*", intro)
if len(intro_words) < 8:
self.add_error(readme, "add a short introduction below the title explaining what the plugin does")
h2_by_name = {
title.casefold(): index
for index, (level, title, _start, _end) in enumerate(headings)
if level == 2
}
for section in ("Plugin", "Usage"):
index = h2_by_name.get(section.casefold())
if index is None:
self.add_error(readme, f"missing required '## {section}' section")
elif not section_body(contents, headings, index):
self.add_error(readme, f"'## {section}' section must not be empty")
plugin_section_index = h2_by_name.get("plugin")
plugin_section = (
section_body(contents, headings, plugin_section_index)
if plugin_section_index is not None
else ""
)
plugin_id = manifest.get("id")
if not is_non_empty_string(plugin_id):
return
documented_id = f"`{plugin_id}`"
if documented_id not in plugin_section:
self.add_error(readme, f"Plugin section must document the manifest id as {documented_id}")
for entry_type in ENTRY_TYPES:
entries = manifest.get(entry_type, [])
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict) or not is_non_empty_string(entry.get("id")):
continue
entry_id = entry["id"]
documented_entry = f"`{entry_id}`"
if documented_entry not in plugin_section:
self.add_error(
readme,
f"Plugin section must document {entry_type} entry '{entry_id}' as {documented_entry}",
)
if entry_type == "panel":
command = f"noctalia msg panel-toggle {plugin_id}:{entry_id}"
if command not in contents:
self.add_error(
readme,
f"missing panel IPC command; add: {command}",
)
if entry_type == "launcher_provider" and is_non_empty_string(entry.get("prefix")):
prefix = f"`/{entry['prefix']}`"
if prefix not in plugin_section:
self.add_error(
readme,
f"missing launcher prefix {prefix} for entry '{entry_id}'",
)
dependencies = manifest.get("dependencies", [])
if isinstance(dependencies, list) and dependencies:
requirements_index = h2_by_name.get("requirements")
if requirements_index is None:
self.add_error(readme, "plugins with dependencies require a '## Requirements' section")
requirements = ""
else:
requirements = section_body(contents, headings, requirements_index)
if not requirements:
self.add_error(readme, "'## Requirements' section must not be empty")
for dependency in dependencies:
if is_non_empty_string(dependency) and f"`{dependency}`" not in requirements:
self.add_error(
readme,
f"Requirements must mention manifest dependency `{dependency}`",
)
has_settings = bool(manifest.get("setting"))
for entry_type in SETTING_OWNER_TYPES:
entries = manifest.get(entry_type, [])
if isinstance(entries, list) and any(
isinstance(entry, dict) and bool(entry.get("setting")) for entry in entries
):
has_settings = True
break
if has_settings:
settings_index = h2_by_name.get("settings")
if settings_index is None:
self.add_error(readme, "plugins with settings require a '## Settings' section")
elif not section_body(contents, headings, settings_index):
self.add_error(readme, "'## Settings' section must not be empty")
def validate_luau_api(self, plugin_dir: Path) -> None:
for source_path in sorted(plugin_dir.rglob("*.luau")):
try:
@@ -969,7 +1117,7 @@ class Validator:
self.validate_root_fields(manifest_path, manifest)
self.validate_required_files(manifest_path, plugin_dir)
self.validate_thumbnail(manifest_path, plugin_dir)
self.validate_readme(plugin_dir)
self.validate_readme(plugin_dir, manifest)
self.validate_luau_api(plugin_dir)
self.validate_no_symlinks(manifest_path, plugin_dir)