Files
lescarnetsdakkadien.netig.net/scripts/check-content.py
T

256 lines
7.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Vérifie que chaque fiche contient les champs prévus pour son modèle."""
import re
import sys
import tomllib
import unicodedata
from datetime import datetime
from pathlib import Path
from typing import Any
from _schema import (
CONTENT_SECTIONS,
LIST_FIELDS,
SECTION_FIELDS,
UPDATED_LINE_RE,
VOCABULARY_FIELDS,
)
ROOT_DIR = Path(__file__).resolve().parent.parent
CONTENT_DIR = ROOT_DIR / "src" / "content"
COMMON_FIELDS = {
"title": str,
"date": datetime,
"description": str,
"draft": bool,
}
MZL_FILENAME_RE = re.compile(r"mzl-([0-9]{3})")
class Validator:
def __init__(self) -> None:
self.errors = 0
def error(self, path: Path | None, message: str) -> None:
label = f"{path.relative_to(ROOT_DIR).as_posix()} : " if path else ""
print(f"ERREUR : {label}{message}", file=sys.stderr)
self.errors += 1
def finish(self) -> int:
print(f"\nRésultat : {self.errors} erreur(s).")
return 1 if self.errors else 0
def read_front_matter(
path: Path,
validator: Validator,
) -> tuple[dict[str, Any], str] | None:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
validator.error(path, f"lecture impossible ({exc})")
return None
if not lines or lines[0] != "+++":
validator.error(path, "front matter TOML initial absent")
return None
try:
closing_index = lines.index("+++", 1)
except ValueError:
validator.error(path, "délimiteur final +++ absent")
return None
source = "\n".join(lines[1:closing_index])
try:
return tomllib.loads(source), source
except tomllib.TOMLDecodeError as exc:
validator.error(path, f"front matter TOML invalide ({exc})")
return None
def check_filename(
path: Path,
section: str,
validator: Validator,
) -> None:
stem = path.stem
normalized = unicodedata.normalize("NFC", stem)
if stem != normalized:
validator.error(path, "nom de fichier non normalisé en Unicode NFC")
if section == "signes":
match = MZL_FILENAME_RE.fullmatch(stem)
if match is None or not 1 <= int(match.group(1)) <= 999:
validator.error(path, "un signe doit être nommé mzl-NNN.md (001 à 999)")
return
allow_leading_hyphen = section == "vocabulaire"
body = stem[1:] if stem.startswith("-") else stem
if stem.startswith("-") and not allow_leading_hyphen:
validator.error(
path,
"seul le vocabulaire accepte un nom commençant par un tiret",
)
return
if (
not body
or stem in {".", "..", "_index"}
or stem != stem.lower()
or stem.endswith("-")
or "--" in stem
or not all(character.isalnum() or character == "-" for character in body)
):
validator.error(
path,
"nom de fichier invalide : utiliser des lettres Unicode minuscules, "
"des chiffres et des tirets",
)
def check_type(
value: Any,
expected_type: type,
label: str,
path: Path,
validator: Validator,
) -> bool:
if isinstance(value, expected_type):
return True
validator.error(path, f"{label} doit être de type {expected_type.__name__}")
return False
def check_string_list(
value: Any,
label: str,
path: Path,
validator: Validator,
) -> bool:
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return True
validator.error(path, f"{label} doit être un tableau de chaînes")
return False
def check_fields(
metadata: dict[str, Any],
table_name: str,
fields: tuple[str, ...],
path: Path,
validator: Validator,
) -> None:
table = metadata.get(table_name)
if not isinstance(table, dict):
validator.error(path, f"{table_name} doit être une table TOML")
return
missing = [field for field in fields if field not in table]
if missing:
validator.error(path, f"champs {table_name} manquants : {', '.join(missing)}")
for field in fields:
if field not in table:
continue
label = f"{table_name}.{field}"
if field in LIST_FIELDS:
check_string_list(table[field], label, path, validator)
else:
check_type(table[field], str, label, path, validator)
def check_common_fields(
metadata: dict[str, Any],
front_matter: str,
path: Path,
validator: Validator,
) -> None:
for field, expected_type in COMMON_FIELDS.items():
if field not in metadata:
validator.error(path, f"champ manquant : {field}")
else:
check_type(metadata[field], expected_type, field, path, validator)
updated_lines = len(UPDATED_LINE_RE.findall(front_matter))
if updated_lines != 1:
validator.error(path, "la fiche doit contenir exactement une ligne updated")
if "updated" in metadata:
check_type(metadata["updated"], datetime, "updated", path, validator)
def check_vocabulary(
metadata: dict[str, Any],
path: Path,
validator: Validator,
) -> None:
taxonomies = metadata.get("taxonomies")
if not isinstance(taxonomies, dict):
validator.error(path, "taxonomies doit être une table TOML")
return
if "natures" not in taxonomies:
validator.error(path, "champ taxonomies manquant : natures")
return
natures = taxonomies["natures"]
if not check_string_list(natures, "taxonomies.natures", path, validator):
return
if len(natures) != 1:
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
return
nature = natures[0]
if nature not in VOCABULARY_FIELDS:
validator.error(path, f"nature grammaticale inconnue : {nature!r}")
return
check_fields(metadata, "extra", VOCABULARY_FIELDS[nature], path, validator)
def main() -> int:
validator = Validator()
if not CONTENT_DIR.is_dir():
validator.error(None, f"répertoire introuvable : {CONTENT_DIR}")
return validator.finish()
print("Vérification des fiches…", flush=True)
for path in sorted(CONTENT_DIR.rglob("*.md")):
relative_path = path.relative_to(CONTENT_DIR)
if path.name == "_index.md" or len(relative_path.parts) == 1:
continue
section = relative_path.parts[0]
if section not in CONTENT_SECTIONS:
validator.error(path, f"section inconnue : {section}")
continue
check_filename(path, section, validator)
result = read_front_matter(path, validator)
if result is None:
continue
metadata, front_matter = result
check_common_fields(metadata, front_matter, path, validator)
if section == "vocabulaire":
check_vocabulary(metadata, path, validator)
else:
for table_name, fields in SECTION_FIELDS[section].items():
check_fields(metadata, table_name, fields, path, validator)
return validator.finish()
if __name__ == "__main__":
raise SystemExit(main())