Files
community-plugins/.github/workflows/validate-plugins.py
T

1259 lines
48 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
from typing import Any
DEFAULT_ROOT = Path(__file__).resolve().parents[2]
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
LAUNCHER_PREFIX_RE = re.compile(r"^[a-z]+$")
DESCRIPTION_MAX_CHARS = 120
ALLOWED_TAGS = {
"ai",
"animation",
"arch",
"audio",
"bar",
"clock",
"countdown",
"demo",
"debian",
"desktop",
"development",
"emoticon",
"fedora",
"fun",
"gaming",
"gentoo",
"hardware",
"hyprland",
"indicator",
"labwc",
"language",
"launcher",
"mangowc",
"media",
"music",
"network",
"niri",
"nixos",
"opensuse",
"panel",
"privacy",
"productivity",
"recording",
"service",
"shortcut",
"sway",
"system",
"theming",
"time",
"utility",
"video",
"void",
"wallpaper",
}
# An id segment must be a lowercase flat identifier: the part after the "/" is also the
# plugin's directory here, its export directory on disk, and its slug on the website.
ID_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
# Names the website reserves for its own routes; a plugin folder cannot take one.
RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "assets"}
# The store flattens every plugin's translations under its id and rejects keys that are not
# lowercase. A dotted label_key is a path of segments; each segment allows a-z, 0-9, dashes,
# and underscores, but an underscore may not lead a segment. Uppercase (e.g. "zh-Hans") is out.
TRANSLATION_KEY_SEGMENT_RE = re.compile(r"[a-z0-9-][a-z0-9_-]*")
# Rule for a dotted path, as written in a manifest label_key/description_key.
TRANSLATION_KEY_RULE = (
"keys must be lowercase and contain only a-z, 0-9, dots, dashes, and non-leading underscores"
)
# Rule for a single object key inside translations/en.json. The dot is a path separator, so it
# cannot appear inside a key: the i18n platform expands "a.b" into nested objects on the next
# sync, and a flat dotted key would silently churn. Nest with objects instead.
TRANSLATION_SEGMENT_RULE = (
"each translations/en.json key must be a single lowercase segment (a-z, 0-9, dashes, "
"non-leading underscores) with no dots; express nesting with objects, not dotted keys"
)
# Files every published plugin ships: the site renders the README as the plugin page and
# the thumbnail as its card, and the English catalog backs every label_key.
REQUIRED_PLUGIN_FILES = ("README.md", "thumbnail.webp", "translations/en.json")
THUMBNAIL_MAX_BYTES = 512 * 1024
# The store and the website lay out plugin cards on a fixed 16:9 grid, so the thumbnail
# generator exports exactly this.
THUMBNAIL_SIZE = (960, 540)
THUMBNAIL_GENERATOR_URL = "https://assets.noctalia.dev/plugins/thumbnail-generator.html"
WEBP_MAGIC_PREFIX = b"RIFF"
WEBP_MAGIC_FORMAT = b"WEBP"
# Enough of the file to cover the RIFF header plus the first chunk header and the widest
# dimension field of any WebP variant.
WEBP_HEADER_BYTES = 32
ROOT_STRING_FIELDS = (
"id",
"name",
"version",
"author",
"license",
"icon",
"description",
)
ROOT_ARRAY_FIELDS = ("dependencies", "tags")
ENTRY_TYPES = ("widget", "panel", "shortcut", "desktop_widget", "launcher_provider", "service")
SETTING_OWNER_TYPES = ("widget", "panel", "desktop_widget", "launcher_provider")
SETTING_TYPES = {"string", "string_list", "bool", "glyph", "select", "folder", "file", "int", "color"}
PANEL_PLACEMENTS = {"attached", "floating"}
PANEL_POSITIONS = {
"auto",
"center",
"top_left",
"top_center",
"top_right",
"center_left",
"center_right",
"bottom_left",
"bottom_center",
"bottom_right",
}
ROOT_FIELDS = set(ROOT_STRING_FIELDS) | set(ROOT_ARRAY_FIELDS) | set(ENTRY_TYPES) | {
"setting",
"deprecated",
"plugin_api",
}
BASE_ENTRY_FIELDS = {"id", "entry"}
ENTRY_FIELDS = {
"widget": BASE_ENTRY_FIELDS | {"setting"},
"panel": BASE_ENTRY_FIELDS | {"setting", "width", "height", "placement", "position", "open_near_click"},
"desktop_widget": BASE_ENTRY_FIELDS | {"setting"},
"service": BASE_ENTRY_FIELDS,
"shortcut": BASE_ENTRY_FIELDS,
"launcher_provider": BASE_ENTRY_FIELDS
| {"prefix", "glyph", "include_in_global_search", "debounce_ms", "setting", "category"},
}
CATEGORY_FIELDS = {"label", "glyph"}
SETTING_FIELDS = {
"key",
"type",
"label_key",
"description_key",
"default",
"options",
"min",
"max",
"step",
"visible_when",
"advanced",
}
OPTION_FIELDS = {"value", "label_key"}
VISIBLE_WHEN_FIELDS = {"key", "values"}
# Raw HTML is not supported on plugin pages. Markdown autolinks such as
# <https://example.com> do not match this expression.
HTML_RE = re.compile(
r"<!--|<\?|<!\[CDATA\[|<![A-Z]|"
r"</[A-Za-z][A-Za-z0-9-]*\s*>|"
r"<[A-Za-z][A-Za-z0-9-]*"
r"(?:\s+[A-Za-z_:][A-Za-z0-9_.:-]*"
r"(?:\s*=\s*(?:[^\s\"'=<>`]+|'[^']*'|\"[^\"]*\"))?)*\s*/?>",
re.DOTALL,
)
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"
)
def is_non_empty_string(value: Any) -> bool:
return isinstance(value, str) and value.strip() != ""
def is_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def is_number(value: Any) -> bool:
return isinstance(value, int | float) and not isinstance(value, bool)
def rel(root: Path, path: Path) -> str:
try:
return path.relative_to(root).as_posix()
except ValueError:
return path.as_posix()
def raw_html_line(markdown: str) -> int | None:
"""Return the first line containing raw HTML outside Markdown code, if any."""
visible: list[str] = []
fence_char = ""
fence_length = 0
for line in markdown.splitlines(keepends=True):
if fence_char:
closing = rf"^ {{0,3}}{re.escape(fence_char)}{{{fence_length},}}\s*$"
if re.match(closing, line.rstrip("\r\n")):
fence_char = ""
fence_length = 0
visible.append("\n" if line.endswith(("\n", "\r")) else "")
continue
opening = FENCE_OPEN_RE.match(line)
if opening:
fence = opening.group(1)
fence_char = fence[0]
fence_length = len(fence)
visible.append("\n" if line.endswith(("\n", "\r")) else "")
continue
visible.append(line)
text = "".join(visible)
text = INLINE_CODE_RE.sub(
lambda match: "".join("\n" if char == "\n" else " " for char in match.group(0)),
text,
)
match = HTML_RE.search(text)
if match is None:
return 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)
length = len(source)
index = 0
def mask(start: int, end: int) -> None:
for offset in range(start, end):
if visible[offset] not in "\r\n":
visible[offset] = " "
while index < length:
if source.startswith("--[[", index):
end = source.find("]]", index + 4)
end = length if end == -1 else end + 2
mask(index, end)
index = end
continue
if source.startswith("--", index):
end = source.find("\n", index + 2)
end = length if end == -1 else end
mask(index, end)
index = end
continue
if source.startswith("[[", index):
end = source.find("]]", index + 2)
end = length if end == -1 else end + 2
mask(index, end)
index = end
continue
if source[index] in "\"'":
quote = source[index]
end = index + 1
while end < length:
if source[end] == "\\" and end + 1 < length:
end += 2
continue
end += 1
if source[end - 1] == quote:
break
mask(index, end)
index = end
continue
index += 1
code = "".join(visible)
return [
(f"{match.group(1)}.getConfig", code.count("\n", 0, match.start()) + 1)
for match in OBSOLETE_CONFIG_ACCESSOR_RE.finditer(code)
]
def webp_dimensions(header: bytes) -> tuple[int, int] | None:
"""Width and height from a WebP header, or None if it is not one we can read.
The three container variants each store the size differently, and the generator's
output is only one of them, so all three are handled.
"""
if len(header) < 16 or header[:4] != WEBP_MAGIC_PREFIX or header[8:12] != WEBP_MAGIC_FORMAT:
return None
fourcc = header[12:16]
payload = header[20:]
if fourcc == b"VP8X":
# Extended: 4 bytes of flags, then canvas width and height as 24-bit values,
# each stored as size minus one.
if len(payload) < 10:
return None
width = int.from_bytes(payload[4:7], "little") + 1
height = int.from_bytes(payload[7:10], "little") + 1
return width, height
if fourcc == b"VP8 ":
# Lossy: a 3-byte frame tag, the start code, then two 14-bit dimensions.
if len(payload) < 10 or payload[3:6] != b"\x9d\x01\x2a":
return None
width = int.from_bytes(payload[6:8], "little") & 0x3FFF
height = int.from_bytes(payload[8:10], "little") & 0x3FFF
return width, height
if fourcc == b"VP8L":
# Lossless: a signature byte, then both dimensions minus one packed into 28 bits.
if len(payload) < 5 or payload[0] != 0x2F:
return None
bits = int.from_bytes(payload[1:5], "little")
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
return width, height
return None
def is_valid_key_segment(segment: str) -> bool:
"""True if a single translation key segment is lowercase and dot-free."""
return TRANSLATION_KEY_SEGMENT_RE.fullmatch(segment) is not None
def is_valid_translation_key(key: str) -> bool:
"""True if a dotted label_key path obeys the store's lowercase key rule."""
return all(is_valid_key_segment(part) for part in key.split("."))
def invalid_translation_keys(node: Any, prefix: str = "") -> list[str]:
"""Return dotted paths in a translations tree whose own object key breaks the segment rule.
Each object key must be one segment: a dot inside a key is rejected because the i18n
platform expands it into nested objects, so a flat "a.b" key is normalized away on sync.
"""
invalid: list[str] = []
if not isinstance(node, dict):
return invalid
for key, value in node.items():
path = f"{prefix}.{key}" if prefix else key
if not isinstance(key, str) or not is_valid_key_segment(key):
invalid.append(path)
invalid.extend(invalid_translation_keys(value, path))
return invalid
def has_key_path(data: Any, dotted_key: str) -> bool:
if isinstance(data, dict) and dotted_key in data:
return True
current = data
for part in dotted_key.split("."):
if not isinstance(current, dict) or part not in current:
return False
current = current[part]
return True
class Validator:
def __init__(self, root: Path) -> None:
self.root = root.resolve()
self.errors: list[str] = []
def add_error(self, path: Path, message: str) -> None:
self.errors.append(f"{rel(self.root, path)}: {message}")
def add_context_error(self, path: Path, context: str, message: str) -> None:
self.add_error(path, f"{context}: {message}")
def load_manifest(self, path: Path) -> dict[str, Any] | None:
try:
with path.open("rb") as handle:
manifest = tomllib.load(handle)
except tomllib.TOMLDecodeError as error:
self.add_error(path, f"invalid TOML: {error}")
return None
if not isinstance(manifest, dict):
self.add_error(path, "expected a TOML table")
return None
return manifest
def load_english_translations(self, plugin_dir: Path) -> Any | None:
path = plugin_dir / "translations" / "en.json"
if not path.exists():
return None
try:
with path.open("r", encoding="utf-8") as handle:
translations = json.load(handle)
except json.JSONDecodeError as error:
self.add_error(path, f"invalid JSON: {error}")
return None
if not isinstance(translations, dict):
self.add_error(path, "expected a JSON object")
return None
return translations
def validate_translation_key(
self,
manifest_path: Path,
translations: Any | None,
context: str,
field: str,
value: Any,
) -> None:
if not is_non_empty_string(value):
self.add_context_error(manifest_path, context, f"{field} must be a non-empty string")
return
if not is_valid_translation_key(value):
self.add_context_error(
manifest_path,
context,
f"{field} '{value}' is not a valid translation key: {TRANSLATION_KEY_RULE}",
)
return
if translations is None:
self.add_context_error(
manifest_path,
context,
f"{field} references '{value}', but translations/en.json is missing or invalid",
)
return
if not has_key_path(translations, value):
self.add_context_error(
manifest_path,
context,
f"{field} references missing translations/en.json key '{value}'",
)
def validate_string_list(
self,
manifest_path: Path,
context: str,
field: str,
value: Any,
*,
allow_empty: bool,
) -> None:
if not isinstance(value, list):
self.add_context_error(manifest_path, context, f"{field} must be a list of strings")
return
if not allow_empty and not value:
self.add_context_error(manifest_path, context, f"{field} must not be empty")
seen: set[str] = set()
for index, item in enumerate(value):
if not is_non_empty_string(item):
self.add_context_error(
manifest_path,
context,
f"{field}[{index}] must be a non-empty string",
)
continue
if item in seen:
self.add_context_error(manifest_path, context, f"{field} contains duplicate '{item}'")
seen.add(item)
def validate_tags(self, manifest_path: Path, value: Any) -> None:
self.validate_string_list(manifest_path, "root", "tags", value, allow_empty=False)
if not isinstance(value, list):
return
for index, tag in enumerate(value):
if is_non_empty_string(tag) and tag not in ALLOWED_TAGS:
self.add_context_error(
manifest_path,
"root",
f"tags[{index}] '{tag}' is not an allowed tag",
)
def validate_description(self, manifest_path: Path, value: Any) -> None:
if not is_non_empty_string(value):
return
length = len(value)
if length > DESCRIPTION_MAX_CHARS:
self.add_error(
manifest_path,
f"root field 'description' is {length} characters; "
f"keep catalog descriptions at or below {DESCRIPTION_MAX_CHARS}",
)
def validate_root_fields(self, manifest_path: Path, manifest: dict[str, Any]) -> None:
unknown = sorted(set(manifest) - ROOT_FIELDS)
for field in unknown:
self.add_error(manifest_path, f"unknown root field '{field}'")
for field in ROOT_STRING_FIELDS:
if field not in manifest:
self.add_error(manifest_path, f"missing required root field '{field}'")
elif not is_non_empty_string(manifest[field]):
self.add_error(manifest_path, f"root field '{field}' must be a non-empty string")
for field in ROOT_ARRAY_FIELDS:
if field not in manifest:
self.add_error(manifest_path, f"missing required root field '{field}'")
if "dependencies" in manifest:
self.validate_string_list(
manifest_path,
"root",
"dependencies",
manifest["dependencies"],
allow_empty=True,
)
if "tags" in manifest:
self.validate_tags(manifest_path, manifest["tags"])
if "description" in manifest:
self.validate_description(manifest_path, manifest["description"])
if "deprecated" in manifest and not isinstance(manifest["deprecated"], bool):
self.add_error(manifest_path, "root field 'deprecated' must be a bool")
plugin_api = manifest.get("plugin_api")
if "plugin_api" not in manifest:
self.add_error(manifest_path, "missing required root field 'plugin_api'")
elif not is_int(plugin_api) or plugin_api <= 0:
self.add_error(manifest_path, "root field 'plugin_api' must be a positive integer")
# A plugin id is "<author>/<plugin>", and the part after the "/" is the directory
# it lives in - so a folder name is taken once for the whole repo.
folder = manifest_path.parent.name
plugin_id = manifest.get("id")
if is_non_empty_string(plugin_id):
author, _, name = plugin_id.partition("/")
if name != folder:
self.add_error(manifest_path, f"id must be '<author>/{folder}', matching the directory name")
for label, segment in (("author", author), ("plugin", name)):
if not ID_SEGMENT_RE.fullmatch(segment):
self.add_error(
manifest_path,
f"id {label} '{segment}' must be lowercase and match {ID_SEGMENT_RE.pattern}",
)
if folder in RESERVED_NAMES:
self.add_error(manifest_path, f"'{folder}' is a reserved name and cannot be a plugin directory")
version = manifest.get("version")
if is_non_empty_string(version) and not SEMVER_RE.fullmatch(version):
self.add_error(manifest_path, "root field 'version' must use MAJOR.MINOR.PATCH")
def validate_entry_path(self, manifest_path: Path, context: str, plugin_dir: Path, value: Any) -> None:
if not is_non_empty_string(value):
self.add_context_error(manifest_path, context, "entry must be a non-empty string")
return
raw_path = Path(value)
if raw_path.is_absolute() or ".." in raw_path.parts:
self.add_context_error(manifest_path, context, "entry must stay inside the plugin directory")
return
entry_path = plugin_dir / raw_path
try:
entry_path.resolve().relative_to(plugin_dir.resolve())
except ValueError:
self.add_context_error(manifest_path, context, "entry must stay inside the plugin directory")
return
if not entry_path.is_file():
self.add_context_error(
manifest_path,
context,
f"entry file '{value}' does not exist",
)
def validate_launcher_fields(self, manifest_path: Path, context: str, entry: dict[str, Any]) -> None:
for field in ("prefix", "glyph"):
if field in entry and not is_non_empty_string(entry[field]):
self.add_context_error(manifest_path, context, f"{field} must be a non-empty string")
prefix = entry.get("prefix")
if is_non_empty_string(prefix) and not LAUNCHER_PREFIX_RE.fullmatch(prefix):
self.add_context_error(
manifest_path,
context,
"prefix must contain only lowercase letters (a-z), without a leading symbol",
)
if "include_in_global_search" in entry and not isinstance(entry["include_in_global_search"], bool):
self.add_context_error(
manifest_path,
context,
"include_in_global_search must be a bool",
)
if "debounce_ms" in entry:
debounce_ms = entry["debounce_ms"]
if not is_int(debounce_ms) or debounce_ms < 0:
self.add_context_error(
manifest_path,
context,
"debounce_ms must be a non-negative integer",
)
if "category" in entry:
self.validate_launcher_categories(manifest_path, f"{context}.category", entry["category"])
def validate_launcher_categories(self, manifest_path: Path, context: str, categories: Any) -> None:
if not isinstance(categories, list):
self.add_context_error(manifest_path, context, "must be an array of tables")
return
if not categories:
self.add_context_error(manifest_path, context, "must not be empty")
seen_labels: set[str] = set()
for index, category in enumerate(categories):
category_context = f"{context}[{index}]"
if not isinstance(category, dict):
self.add_context_error(manifest_path, category_context, "must be a table")
continue
unknown = sorted(set(category) - CATEGORY_FIELDS)
for field in unknown:
self.add_context_error(manifest_path, category_context, f"unknown field '{field}'")
label = category.get("label")
if not is_non_empty_string(label):
self.add_context_error(manifest_path, category_context, "label must be a non-empty string")
elif label in seen_labels:
self.add_context_error(manifest_path, category_context, f"duplicate category label '{label}'")
else:
seen_labels.add(label)
if not is_non_empty_string(category.get("glyph")):
self.add_context_error(manifest_path, category_context, "glyph must be a non-empty string")
def validate_panel_fields(self, manifest_path: Path, context: str, entry: dict[str, Any]) -> None:
# Mirrors the shell parser: a positive number (logical px) or the
# literal string "fill" (span the output's available extent; requires
# floating placement).
uses_fill = False
for field in ("width", "height"):
if field not in entry:
continue
value = entry[field]
if isinstance(value, str):
if value != "fill":
self.add_context_error(manifest_path, context, f'{field} must be a positive number or "fill"')
else:
uses_fill = True
elif not is_number(value) or value <= 0:
self.add_context_error(manifest_path, context, f'{field} must be a positive number or "fill"')
if uses_fill and entry.get("placement", "floating") != "floating":
self.add_context_error(manifest_path, context, 'width/height "fill" requires placement = "floating"')
if "placement" in entry:
placement = entry["placement"]
if not is_non_empty_string(placement):
self.add_context_error(manifest_path, context, "placement must be a non-empty string")
elif placement not in PANEL_PLACEMENTS:
valid = ", ".join(sorted(PANEL_PLACEMENTS))
self.add_context_error(manifest_path, context, f"placement must be one of: {valid}")
if "position" in entry:
position = entry["position"]
if not is_non_empty_string(position):
self.add_context_error(manifest_path, context, "position must be a non-empty string")
elif position not in PANEL_POSITIONS:
valid = ", ".join(sorted(PANEL_POSITIONS))
self.add_context_error(manifest_path, context, f"position must be one of: {valid}")
if "open_near_click" in entry and not isinstance(entry["open_near_click"], bool):
self.add_context_error(manifest_path, context, "open_near_click must be a bool")
def validate_entries(
self,
manifest_path: Path,
manifest: dict[str, Any],
translations: Any | None,
) -> None:
plugin_dir = manifest_path.parent
entry_count = 0
seen_ids: dict[str, str] = {}
for entry_type in ENTRY_TYPES:
entries = manifest.get(entry_type, [])
if not isinstance(entries, list):
self.add_error(manifest_path, f"'{entry_type}' must be an array of tables")
continue
for index, entry in enumerate(entries):
entry_count += 1
context = f"{entry_type}[{index}]"
if not isinstance(entry, dict):
self.add_context_error(manifest_path, context, "must be a table")
continue
unknown = sorted(set(entry) - ENTRY_FIELDS[entry_type])
for field in unknown:
self.add_context_error(manifest_path, context, f"unknown field '{field}'")
entry_id = entry.get("id")
if not is_non_empty_string(entry_id):
self.add_context_error(manifest_path, context, "id must be a non-empty string")
elif entry_id in seen_ids:
self.add_context_error(
manifest_path,
context,
f"id '{entry_id}' is already used by {seen_ids[entry_id]}",
)
else:
seen_ids[entry_id] = context
self.validate_entry_path(manifest_path, context, plugin_dir, entry.get("entry"))
if entry_type == "launcher_provider":
self.validate_launcher_fields(manifest_path, context, entry)
if entry_type == "panel":
self.validate_panel_fields(manifest_path, context, entry)
if entry_type in SETTING_OWNER_TYPES and "setting" in entry:
self.validate_settings(
manifest_path,
translations,
entry["setting"],
f"{context}.setting",
)
if entry_count == 0:
self.add_error(
manifest_path,
"must define at least one entry: widget, panel, shortcut, desktop_widget, launcher_provider, or service",
)
def validate_default(
self,
manifest_path: Path,
context: str,
setting_type: str,
setting: dict[str, Any],
option_values: list[str],
) -> None:
if "default" not in setting:
if setting_type not in {"folder", "file"}:
self.add_context_error(manifest_path, context, "default is required")
return
default = setting["default"]
if setting_type in {"string", "folder", "file"} and not isinstance(default, str):
self.add_context_error(manifest_path, context, "default must be a string")
elif setting_type in {"glyph", "color"} and not is_non_empty_string(default):
self.add_context_error(manifest_path, context, "default must be a non-empty string")
elif setting_type == "string_list":
self.validate_string_list(manifest_path, context, "default", default, allow_empty=True)
elif setting_type == "bool" and not isinstance(default, bool):
self.add_context_error(manifest_path, context, "default must be a bool")
elif setting_type == "int" and not is_int(default):
self.add_context_error(manifest_path, context, "default must be an integer")
elif setting_type == "select":
if not is_non_empty_string(default):
self.add_context_error(manifest_path, context, "default must be a non-empty string")
elif default not in option_values:
self.add_context_error(manifest_path, context, "default must match one of the option values")
def validate_options(
self,
manifest_path: Path,
translations: Any | None,
context: str,
setting: dict[str, Any],
) -> list[str]:
options = setting.get("options")
setting_type = setting.get("type")
if options is None:
if setting_type == "select":
self.add_context_error(manifest_path, context, "select settings require options")
return []
if setting_type != "select":
self.add_context_error(manifest_path, context, "options is only valid for select settings")
if not isinstance(options, list):
self.add_context_error(manifest_path, context, "options must be a list of tables")
return []
if not options:
self.add_context_error(manifest_path, context, "options must not be empty")
values: list[str] = []
seen: set[str] = set()
for index, option in enumerate(options):
option_context = f"{context}.options[{index}]"
if not isinstance(option, dict):
self.add_context_error(manifest_path, option_context, "must be a table")
continue
unknown = sorted(set(option) - OPTION_FIELDS)
for field in unknown:
self.add_context_error(manifest_path, option_context, f"unknown field '{field}'")
value = option.get("value")
if not is_non_empty_string(value):
self.add_context_error(manifest_path, option_context, "value must be a non-empty string")
else:
values.append(value)
if value in seen:
self.add_context_error(
manifest_path,
option_context,
f"duplicate option value '{value}'",
)
seen.add(value)
self.validate_translation_key(
manifest_path,
translations,
option_context,
"label_key",
option.get("label_key"),
)
return values
def validate_int_bounds(self, manifest_path: Path, context: str, setting: dict[str, Any]) -> None:
setting_type = setting.get("type")
bound_values: dict[str, int] = {}
for field in ("min", "max", "step"):
if field not in setting:
continue
value = setting[field]
if setting_type != "int":
self.add_context_error(manifest_path, context, f"{field} is only valid for int settings")
elif not is_int(value):
self.add_context_error(manifest_path, context, f"{field} must be an integer")
else:
bound_values[field] = value
if "min" in bound_values and "max" in bound_values and bound_values["min"] > bound_values["max"]:
self.add_context_error(manifest_path, context, "min must be less than or equal to max")
if "step" in bound_values and bound_values["step"] <= 0:
self.add_context_error(manifest_path, context, "step must be greater than zero")
default = setting.get("default")
if setting_type == "int" and is_int(default):
if "min" in bound_values and default < bound_values["min"]:
self.add_context_error(manifest_path, context, "default must be greater than or equal to min")
if "max" in bound_values and default > bound_values["max"]:
self.add_context_error(manifest_path, context, "default must be less than or equal to max")
def validate_visible_when(self, manifest_path: Path, context: str, value: Any) -> None:
if value is None:
return
if not isinstance(value, dict):
self.add_context_error(manifest_path, context, "visible_when must be a table")
return
unknown = sorted(set(value) - VISIBLE_WHEN_FIELDS)
for field in unknown:
self.add_context_error(manifest_path, context, f"visible_when has unknown field '{field}'")
if not is_non_empty_string(value.get("key")):
self.add_context_error(manifest_path, context, "visible_when.key must be a non-empty string")
values = value.get("values")
if not isinstance(values, list) or not values:
self.add_context_error(
manifest_path,
context,
"visible_when.values must be a non-empty list of strings",
)
return
for index, item in enumerate(values):
if not is_non_empty_string(item):
self.add_context_error(
manifest_path,
context,
f"visible_when.values[{index}] must be a non-empty string",
)
def validate_settings(
self,
manifest_path: Path,
translations: Any | None,
settings: Any,
context_prefix: str,
) -> None:
if not isinstance(settings, list):
self.add_context_error(manifest_path, context_prefix, "must be an array of tables")
return
seen_keys: set[str] = set()
for index, setting in enumerate(settings):
context = f"{context_prefix}[{index}]"
if not isinstance(setting, dict):
self.add_context_error(manifest_path, context, "must be a table")
continue
unknown = sorted(set(setting) - SETTING_FIELDS)
for field in unknown:
self.add_context_error(manifest_path, context, f"unknown field '{field}'")
key = setting.get("key")
if not is_non_empty_string(key):
self.add_context_error(manifest_path, context, "key must be a non-empty string")
elif key in seen_keys:
self.add_context_error(manifest_path, context, f"duplicate setting key '{key}'")
else:
seen_keys.add(key)
setting_type = setting.get("type")
if not is_non_empty_string(setting_type):
self.add_context_error(manifest_path, context, "type must be a non-empty string")
setting_type = ""
elif setting_type not in SETTING_TYPES:
self.add_context_error(manifest_path, context, f"unsupported setting type '{setting_type}'")
self.validate_translation_key(
manifest_path,
translations,
context,
"label_key",
setting.get("label_key"),
)
if "description_key" in setting:
self.validate_translation_key(
manifest_path,
translations,
context,
"description_key",
setting.get("description_key"),
)
option_values = self.validate_options(manifest_path, translations, context, setting)
if setting_type in SETTING_TYPES:
self.validate_default(manifest_path, context, setting_type, setting, option_values)
self.validate_int_bounds(manifest_path, context, setting)
self.validate_visible_when(manifest_path, context, setting.get("visible_when"))
if "advanced" in setting and not isinstance(setting["advanced"], bool):
self.add_context_error(manifest_path, context, "advanced must be a bool")
def validate_translation_keys(self, plugin_dir: Path, translations: Any | None) -> None:
# The store rejects the whole plugin if any translation key is not lowercase, so catch
# bad keys (e.g. "zh-Hans") here even when no label_key references them.
if not isinstance(translations, dict):
return
invalid = invalid_translation_keys(translations)
if invalid:
path = plugin_dir / "translations" / "en.json"
self.add_error(
path,
f"invalid translation key format: {', '.join(invalid)}; {TRANSLATION_SEGMENT_RULE}",
)
def validate_required_files(self, manifest_path: Path, plugin_dir: Path) -> None:
for required in REQUIRED_PLUGIN_FILES:
if not (plugin_dir / required).is_file():
self.add_error(manifest_path, f"missing required file '{required}'")
def validate_thumbnail(self, manifest_path: Path, plugin_dir: Path) -> None:
thumbnail = plugin_dir / "thumbnail.webp"
if not thumbnail.is_file():
return
size = thumbnail.stat().st_size
if size > THUMBNAIL_MAX_BYTES:
self.add_error(
manifest_path,
f"thumbnail.webp is {size} bytes; keep it under {THUMBNAIL_MAX_BYTES}",
)
with thumbnail.open("rb") as handle:
header = handle.read(WEBP_HEADER_BYTES)
if header[:4] != WEBP_MAGIC_PREFIX or header[8:12] != WEBP_MAGIC_FORMAT:
self.add_error(manifest_path, "thumbnail.webp is not a WebP image")
return
dimensions = webp_dimensions(header)
expected = f"{THUMBNAIL_SIZE[0]}x{THUMBNAIL_SIZE[1]}"
if dimensions is None:
self.add_error(
manifest_path,
f"thumbnail.webp dimensions could not be read; export a {expected} WebP "
f"with {THUMBNAIL_GENERATOR_URL}",
)
return
if dimensions != THUMBNAIL_SIZE:
self.add_error(
manifest_path,
f"thumbnail.webp is {dimensions[0]}x{dimensions[1]}; it must be {expected}. "
f"Export one with {THUMBNAIL_GENERATOR_URL}",
)
def validate_readme(self, plugin_dir: Path, manifest: dict[str, Any]) -> None:
readme = plugin_dir / "README.md"
if not readme.is_file():
return
try:
contents = readme.read_text(encoding="utf-8")
except UnicodeDecodeError:
self.add_error(readme, "must be UTF-8 text")
return
line = raw_html_line(contents)
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:
source = source_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
self.add_error(source_path, "must be UTF-8 text")
continue
for accessor, line in obsolete_config_accessors(source):
self.add_error(
source_path,
f"'{accessor}' on line {line} was removed; use noctalia.getConfig",
)
def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None:
for path in plugin_dir.rglob("*"):
if path.is_symlink():
self.add_error(manifest_path, f"'{rel(self.root, path)}' is a symlink; plugins ship real files")
def validate_manifest(self, manifest_path: Path) -> None:
manifest = self.load_manifest(manifest_path)
if manifest is None:
return
plugin_dir = manifest_path.parent
translations = self.load_english_translations(plugin_dir)
self.validate_root_fields(manifest_path, manifest)
self.validate_translation_keys(plugin_dir, translations)
self.validate_required_files(manifest_path, plugin_dir)
self.validate_thumbnail(manifest_path, plugin_dir)
self.validate_readme(plugin_dir, manifest)
self.validate_luau_api(plugin_dir)
self.validate_no_symlinks(manifest_path, plugin_dir)
if "setting" in manifest:
self.validate_settings(manifest_path, translations, manifest["setting"], "setting")
self.validate_entries(manifest_path, manifest, translations)
def validate_layout(self) -> None:
# Every plugin is one top-level directory. A manifest anywhere else (repo root, or
# nested deeper) is never loaded by the shell, so fail loudly.
for manifest_path in self.root.rglob("plugin.toml"):
if ".git" in manifest_path.parts:
continue
depth = len(manifest_path.relative_to(self.root).parts)
if depth != 2:
self.add_error(manifest_path, "plugins live at <plugin>/plugin.toml, one directory per plugin")
def validate(self) -> int:
self.validate_layout()
manifests = sorted(self.root.glob("*/plugin.toml"))
for manifest_path in manifests:
self.validate_manifest(manifest_path)
if self.errors:
for error in self.errors:
print(f"error: {error}", file=sys.stderr)
return 1
print(f"Validated {len(manifests)} plugin manifest(s).")
return 0
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate community Noctalia plugin manifests.")
parser.add_argument(
"--root",
type=Path,
default=DEFAULT_ROOT,
help="Repository root to validate. Defaults to the current repository.",
)
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
return Validator(args.root).validate()
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))