ci(validate): detect and deny HTML in README
This commit is contained in:
@@ -93,6 +93,19 @@ SETTING_FIELDS = {
|
||||
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,})")
|
||||
|
||||
|
||||
def is_non_empty_string(value: Any) -> bool:
|
||||
return isinstance(value, str) and value.strip() != ""
|
||||
@@ -113,6 +126,42 @@ def rel(root: Path, path: Path) -> str:
|
||||
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 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.
|
||||
|
||||
@@ -749,6 +798,21 @@ class Validator:
|
||||
f"Export one with {THUMBNAIL_GENERATOR_URL}",
|
||||
)
|
||||
|
||||
def validate_readme(self, plugin_dir: Path) -> 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")
|
||||
|
||||
def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None:
|
||||
for path in plugin_dir.rglob("*"):
|
||||
if path.is_symlink():
|
||||
@@ -765,6 +829,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_no_symlinks(manifest_path, plugin_dir)
|
||||
|
||||
if "setting" in manifest:
|
||||
|
||||
Reference in New Issue
Block a user