Meilleurs scripts
This commit is contained in:
+17
-2
@@ -1,4 +1,6 @@
|
||||
"""Définitions communes au générateur et au validateur de contenus."""
|
||||
"""Schémas et règles structurelles communs aux scripts de fiches."""
|
||||
|
||||
import re
|
||||
|
||||
SECTION_ALIASES = {
|
||||
"article": "articles",
|
||||
@@ -58,4 +60,17 @@ VOCABULARY_FIELDS = {
|
||||
),
|
||||
}
|
||||
|
||||
KNOWN_SECTIONS = frozenset(SECTION_ALIASES.values())
|
||||
SECTION_FIELDS = {
|
||||
"articles": {"extra": ("banner", "banner_alt", "banner_credit")},
|
||||
"grammaire": {"taxonomies": ("themes",)},
|
||||
"signes": {
|
||||
"extra": ("sign", "mzl"),
|
||||
"taxonomies": ("lectures",),
|
||||
},
|
||||
"textes": {"taxonomies": ("genres",)},
|
||||
}
|
||||
|
||||
LIST_FIELDS = frozenset({"logograms", "lectures", "themes", "genres"})
|
||||
|
||||
CONTENT_SECTIONS = frozenset(SECTION_FIELDS) | {"vocabulaire"}
|
||||
UPDATED_LINE_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
|
||||
|
||||
+112
-198
@@ -1,21 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vérifie la structure des contenus, le vocabulaire et les signes."""
|
||||
"""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 KNOWN_SECTIONS, VOCABULARY_FIELDS
|
||||
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"
|
||||
|
||||
MZL_FILENAME_RE = re.compile(r"mzl-([0-9]{3})\.md")
|
||||
MZL_VALUE_RE = re.compile(r"[0-9]{3}")
|
||||
COMMON_FIELDS = {
|
||||
"title": str,
|
||||
"date": datetime,
|
||||
"description": str,
|
||||
"draft": bool,
|
||||
}
|
||||
|
||||
|
||||
|
||||
class Validator:
|
||||
@@ -23,10 +32,7 @@ class Validator:
|
||||
self.errors = 0
|
||||
|
||||
def error(self, path: Path | None, message: str) -> None:
|
||||
if path is None:
|
||||
label = ""
|
||||
else:
|
||||
label = f"{path.relative_to(ROOT_DIR).as_posix()} : "
|
||||
label = f"{path.relative_to(ROOT_DIR).as_posix()} : " if path else ""
|
||||
print(f"ERREUR : {label}{message}", file=sys.stderr)
|
||||
self.errors += 1
|
||||
|
||||
@@ -35,24 +41,16 @@ class Validator:
|
||||
return 1 if self.errors else 0
|
||||
|
||||
|
||||
def is_non_empty_string(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def read_front_matter(path: Path, validator: Validator) -> dict[str, Any] | None:
|
||||
def read_front_matter(
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> tuple[dict[str, Any], str] | None:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
validator.error(path, f"fichier non valide en UTF-8 ({exc})")
|
||||
return None
|
||||
except OSError as exc:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeError) as exc:
|
||||
validator.error(path, f"lecture impossible ({exc})")
|
||||
return None
|
||||
|
||||
if not unicodedata.is_normalized("NFC", text):
|
||||
validator.error(path, "contenu non normalisé en Unicode NFC")
|
||||
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0] != "+++":
|
||||
validator.error(path, "front matter TOML initial absent")
|
||||
return None
|
||||
@@ -63,231 +61,147 @@ def read_front_matter(path: Path, validator: Validator) -> dict[str, Any] | None
|
||||
validator.error(path, "délimiteur final +++ absent")
|
||||
return None
|
||||
|
||||
source = "\n".join(lines[1:closing_index])
|
||||
try:
|
||||
return tomllib.loads("\n".join(lines[1:closing_index]))
|
||||
return tomllib.loads(source), source
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
validator.error(path, f"front matter TOML invalide ({exc})")
|
||||
return None
|
||||
|
||||
|
||||
def require_table(
|
||||
metadata: dict[str, Any],
|
||||
key: str,
|
||||
def check_type(
|
||||
value: Any,
|
||||
expected_type: type,
|
||||
label: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> dict[str, Any] | None:
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, dict):
|
||||
validator.error(path, f"{key} doit être une table TOML")
|
||||
return None
|
||||
return value
|
||||
) -> bool:
|
||||
if isinstance(value, expected_type):
|
||||
return True
|
||||
|
||||
validator.error(path, f"{label} doit être de type {expected_type.__name__}")
|
||||
return False
|
||||
|
||||
|
||||
def require_string(
|
||||
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],
|
||||
key: str,
|
||||
table_name: str,
|
||||
fields: tuple[str, ...],
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> None:
|
||||
if not is_non_empty_string(metadata.get(key)):
|
||||
validator.error(path, f"{key} doit être une chaîne non vide")
|
||||
|
||||
|
||||
def require_timestamp(
|
||||
metadata: dict[str, Any],
|
||||
key: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
*,
|
||||
optional: bool = False,
|
||||
) -> None:
|
||||
value = metadata.get(key)
|
||||
if optional and value is None:
|
||||
table = metadata.get(table_name)
|
||||
if not isinstance(table, dict):
|
||||
validator.error(path, f"{table_name} doit être une table TOML")
|
||||
return
|
||||
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
|
||||
validator.error(
|
||||
path,
|
||||
f"{key} doit être un horodatage TOML avec fuseau horaire",
|
||||
)
|
||||
|
||||
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_structure(
|
||||
path: Path,
|
||||
metadata: dict[str, Any],
|
||||
validator: Validator,
|
||||
) -> tuple[str, bool] | None:
|
||||
relative_path = path.relative_to(CONTENT_DIR)
|
||||
section = relative_path.parts[0] if len(relative_path.parts) > 1 else ""
|
||||
|
||||
if not unicodedata.is_normalized("NFC", relative_path.as_posix()):
|
||||
validator.error(path, "chemin non normalisé en Unicode NFC")
|
||||
|
||||
if section and section not in KNOWN_SECTIONS:
|
||||
validator.error(path, f"section inconnue « {section} »")
|
||||
|
||||
if path.name == "_index.md":
|
||||
require_string(metadata, "title", path, validator)
|
||||
return None
|
||||
|
||||
draft = metadata.get("draft")
|
||||
if not isinstance(draft, bool):
|
||||
validator.error(path, "draft doit être un booléen")
|
||||
return None
|
||||
|
||||
if draft:
|
||||
return section, True
|
||||
|
||||
require_string(metadata, "title", path, validator)
|
||||
if section:
|
||||
require_timestamp(metadata, "date", path, validator)
|
||||
require_timestamp(metadata, "updated", path, validator, optional=True)
|
||||
return section, False
|
||||
|
||||
|
||||
def vocabulary_nature(
|
||||
def check_common_fields(
|
||||
metadata: dict[str, Any],
|
||||
front_matter: str,
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> str | None:
|
||||
taxonomies = require_table(metadata, "taxonomies", path, validator)
|
||||
if taxonomies is None:
|
||||
return None
|
||||
) -> 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)
|
||||
|
||||
natures = taxonomies.get("natures")
|
||||
if not isinstance(natures, list) or len(natures) != 1:
|
||||
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
|
||||
return None
|
||||
updated_lines = len(UPDATED_LINE_RE.findall(front_matter))
|
||||
if updated_lines != 1:
|
||||
validator.error(path, "la fiche doit contenir exactement une ligne updated")
|
||||
|
||||
nature = natures[0]
|
||||
if not isinstance(nature, str):
|
||||
validator.error(path, "taxonomies.natures doit contenir une chaîne")
|
||||
return None
|
||||
|
||||
if nature not in VOCABULARY_FIELDS:
|
||||
validator.error(path, f"nature grammaticale inconnue « {nature} »")
|
||||
return None
|
||||
|
||||
return nature
|
||||
if "updated" in metadata:
|
||||
check_type(metadata["updated"], datetime, "updated", path, validator)
|
||||
|
||||
|
||||
def check_vocabulary(
|
||||
path: Path,
|
||||
metadata: dict[str, Any],
|
||||
path: Path,
|
||||
validator: Validator,
|
||||
) -> None:
|
||||
nature = vocabulary_nature(metadata, path, validator)
|
||||
extra = require_table(metadata, "extra", path, validator)
|
||||
if nature is None or extra is None:
|
||||
taxonomies = metadata.get("taxonomies")
|
||||
if not isinstance(taxonomies, dict):
|
||||
validator.error(path, "taxonomies doit être une table TOML")
|
||||
return
|
||||
|
||||
expected_fields = VOCABULARY_FIELDS[nature]
|
||||
expected_keys = set(expected_fields)
|
||||
actual_keys = set(extra)
|
||||
|
||||
missing_fields = [field for field in expected_fields if field not in actual_keys]
|
||||
if missing_fields:
|
||||
validator.error(
|
||||
path,
|
||||
"champs extra manquants pour "
|
||||
f"la nature « {nature} » : {', '.join(missing_fields)}",
|
||||
)
|
||||
|
||||
unexpected_fields = sorted(actual_keys - expected_keys)
|
||||
if unexpected_fields:
|
||||
validator.error(
|
||||
path,
|
||||
"champs extra inattendus pour "
|
||||
f"la nature « {nature} » : {', '.join(unexpected_fields)}",
|
||||
)
|
||||
|
||||
for field in expected_fields:
|
||||
if field not in extra:
|
||||
continue
|
||||
|
||||
value = extra[field]
|
||||
if field == "logograms":
|
||||
if not isinstance(value, list) or not all(
|
||||
is_non_empty_string(item) for item in value
|
||||
):
|
||||
validator.error(
|
||||
path,
|
||||
"extra.logograms doit être un tableau de chaînes non vides",
|
||||
)
|
||||
elif not isinstance(value, str):
|
||||
validator.error(path, f"extra.{field} doit être une chaîne")
|
||||
|
||||
required_fields = ("meaning", "stem") if nature == "verbe" else ("meaning",)
|
||||
for field in required_fields:
|
||||
value = extra.get(field)
|
||||
if isinstance(value, str) and not value.strip():
|
||||
validator.error(path, f"extra.{field} doit être une chaîne non vide")
|
||||
|
||||
|
||||
def filename_mzl(path: Path, validator: Validator) -> str | None:
|
||||
match = MZL_FILENAME_RE.fullmatch(path.name)
|
||||
if match is None:
|
||||
validator.error(path, "le nom du fichier doit suivre le format mzl-XXX.md")
|
||||
return None
|
||||
|
||||
value = match.group(1)
|
||||
if int(value) == 0:
|
||||
validator.error(path, "le numéro MZL doit être compris entre 001 et 999")
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def check_sign(
|
||||
path: Path,
|
||||
metadata: dict[str, Any],
|
||||
expected_mzl: str | None,
|
||||
validator: Validator,
|
||||
) -> None:
|
||||
extra = require_table(metadata, "extra", path, validator)
|
||||
if extra is None:
|
||||
if "natures" not in taxonomies:
|
||||
validator.error(path, "champ taxonomies manquant : natures")
|
||||
return
|
||||
|
||||
require_string(extra, "sign", path, validator)
|
||||
natures = taxonomies["natures"]
|
||||
if not check_string_list(natures, "taxonomies.natures", path, validator):
|
||||
return
|
||||
|
||||
declared_mzl = extra.get("mzl")
|
||||
if not isinstance(declared_mzl, str) or MZL_VALUE_RE.fullmatch(declared_mzl) is None:
|
||||
validator.error(path, "extra.mzl doit être une chaîne de trois chiffres ASCII")
|
||||
if len(natures) != 1:
|
||||
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
|
||||
return
|
||||
if int(declared_mzl) == 0:
|
||||
validator.error(path, "extra.mzl doit être compris entre 001 et 999")
|
||||
|
||||
nature = natures[0]
|
||||
if nature not in VOCABULARY_FIELDS:
|
||||
validator.error(path, f"nature grammaticale inconnue : {nature!r}")
|
||||
return
|
||||
if expected_mzl is not None and declared_mzl != expected_mzl:
|
||||
validator.error(
|
||||
path,
|
||||
f'extra.mzl vaut "{declared_mzl}", mais le fichier indique MZL {expected_mzl}',
|
||||
)
|
||||
|
||||
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 contenus…", flush=True)
|
||||
print("Vérification des fiches…", flush=True)
|
||||
for path in sorted(CONTENT_DIR.rglob("*.md")):
|
||||
metadata = read_front_matter(path, validator)
|
||||
if metadata is None:
|
||||
relative_path = path.relative_to(CONTENT_DIR)
|
||||
if path.name == "_index.md" or len(relative_path.parts) == 1:
|
||||
continue
|
||||
|
||||
structure = check_structure(path, metadata, validator)
|
||||
if structure is None:
|
||||
section = relative_path.parts[0]
|
||||
if section not in CONTENT_SECTIONS:
|
||||
validator.error(path, f"section inconnue : {section}")
|
||||
continue
|
||||
|
||||
section, is_draft = structure
|
||||
expected_mzl = filename_mzl(path, validator) if section == "signes" else None
|
||||
if is_draft:
|
||||
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(path, metadata, validator)
|
||||
elif section == "signes":
|
||||
check_sign(path, metadata, expected_mzl, validator)
|
||||
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()
|
||||
|
||||
|
||||
+76
-130
@@ -10,14 +10,14 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from _schema import SECTION_ALIASES, VOCABULARY_FIELDS
|
||||
from _schema import LIST_FIELDS, SECTION_ALIASES, SECTION_FIELDS, VOCABULARY_FIELDS
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
CONTENT_DIR = ROOT_DIR / "src" / "content"
|
||||
TIMEZONE_NAME = "Europe/Paris"
|
||||
|
||||
MZL_RE = re.compile(r"(?:mzl-)?([0-9]{1,3})")
|
||||
|
||||
FieldValue = str | list[str]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -48,55 +48,33 @@ def normalize(value: str) -> str:
|
||||
return unicodedata.normalize("NFC", value.strip())
|
||||
|
||||
|
||||
def toml_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def current_timestamp() -> str:
|
||||
try:
|
||||
timezone = ZoneInfo(TIMEZONE_NAME)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
f"fuseau {TIMEZONE_NAME} indisponible ; installer les données de fuseaux horaires"
|
||||
) from exc
|
||||
return datetime.now(timezone).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def validate_identifier(identifier: str, *, allow_leading_hyphen: bool = False) -> str:
|
||||
"""Valide un identifiant déjà écrit sous sa forme de nom de fichier."""
|
||||
def validate_identifier(identifier: str, allow_leading_hyphen: bool) -> str:
|
||||
value = normalize(identifier)
|
||||
if value.startswith("-") and not allow_leading_hyphen:
|
||||
raise ValueError("seul le vocabulaire accepte un identifiant commençant par un tiret")
|
||||
|
||||
body = value[1:] if value.startswith("-") else value
|
||||
if not body or value in {".", "..", "_index"}:
|
||||
|
||||
if value.startswith("-") and not allow_leading_hyphen:
|
||||
raise ValueError(
|
||||
"seul le vocabulaire accepte un identifiant commençant par un tiret"
|
||||
)
|
||||
if (
|
||||
not body
|
||||
or value in {".", "..", "_index"}
|
||||
or value != value.lower()
|
||||
or value.endswith("-")
|
||||
or "--" in value
|
||||
or not all(character.isalnum() or character == "-" for character in body)
|
||||
):
|
||||
raise ValueError("identifiant de fichier invalide")
|
||||
if value != value.lower():
|
||||
raise ValueError("l’identifiant doit être écrit en minuscules")
|
||||
if value.endswith("-") or "--" in value:
|
||||
raise ValueError(
|
||||
"l’identifiant ne doit pas finir par un tiret ni en contenir deux de suite"
|
||||
)
|
||||
if not all(character.isalnum() or character == "-" for character in body):
|
||||
raise ValueError(
|
||||
"l’identifiant ne doit contenir que des lettres, des chiffres et des tirets"
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def parse_mzl(identifier: str) -> tuple[str, str]:
|
||||
value = normalize(identifier)
|
||||
match = MZL_RE.fullmatch(value)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
"un signe doit être identifié par un numéro MZL de un à trois chiffres ASCII"
|
||||
)
|
||||
|
||||
number = int(match.group(1))
|
||||
if not 1 <= number <= 999:
|
||||
match = MZL_RE.fullmatch(normalize(identifier))
|
||||
if match is None or not 1 <= int(match.group(1)) <= 999:
|
||||
raise ValueError("le numéro MZL doit être compris entre 1 et 999")
|
||||
|
||||
mzl = f"{number:03d}"
|
||||
mzl = f"{int(match.group(1)):03d}"
|
||||
return f"mzl-{mzl}", mzl
|
||||
|
||||
|
||||
@@ -126,88 +104,55 @@ def title_from_identifier(identifier: str) -> str:
|
||||
return title[:1].upper() + title[1:]
|
||||
|
||||
|
||||
def build_vocabulary(title: str, timestamp: str, nature: str) -> str:
|
||||
fields = "\n".join(
|
||||
f"{field} = []" if field == "logograms" else f'{field} = ""'
|
||||
for field in VOCABULARY_FIELDS[nature]
|
||||
)
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[extra]
|
||||
{fields}
|
||||
|
||||
[taxonomies]
|
||||
natures = [{toml_string(nature)}]
|
||||
+++
|
||||
"""
|
||||
def empty_fields(fields: tuple[str, ...]) -> dict[str, FieldValue]:
|
||||
return {field: [] if field in LIST_FIELDS else "" for field in fields}
|
||||
|
||||
|
||||
def build_sign(title: str, timestamp: str, mzl: str) -> str:
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
def content_tables(
|
||||
section: str,
|
||||
nature: str | None = None,
|
||||
mzl: str | None = None,
|
||||
) -> dict[str, dict[str, FieldValue]]:
|
||||
if section == "vocabulaire":
|
||||
assert nature is not None
|
||||
return {
|
||||
"extra": empty_fields(VOCABULARY_FIELDS[nature]),
|
||||
"taxonomies": {"natures": [nature]},
|
||||
}
|
||||
|
||||
[extra]
|
||||
sign = ""
|
||||
mzl = "{mzl}"
|
||||
|
||||
[taxonomies]
|
||||
lectures = []
|
||||
+++
|
||||
"""
|
||||
tables = {
|
||||
table: empty_fields(fields)
|
||||
for table, fields in SECTION_FIELDS[section].items()
|
||||
}
|
||||
if section == "signes":
|
||||
assert mzl is not None
|
||||
tables["extra"]["mzl"] = mzl
|
||||
return tables
|
||||
|
||||
|
||||
def build_page(section: str, title: str, timestamp: str) -> str:
|
||||
if section == "articles":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
def build_content(
|
||||
title: str,
|
||||
timestamp: str,
|
||||
tables: dict[str, dict[str, FieldValue]],
|
||||
) -> str:
|
||||
lines = [
|
||||
"+++",
|
||||
f"title = {json.dumps(title, ensure_ascii=False)}",
|
||||
f"date = {timestamp}",
|
||||
"# updated = YYYY-MM-DDTHH:MM:SS+HH:MM",
|
||||
'description = ""',
|
||||
"draft = true",
|
||||
]
|
||||
|
||||
[extra]
|
||||
banner = ""
|
||||
banner_alt = ""
|
||||
banner_credit = ""
|
||||
+++
|
||||
"""
|
||||
for table, fields in tables.items():
|
||||
lines.extend(("", f"[{table}]"))
|
||||
lines.extend(
|
||||
f"{field} = {json.dumps(value, ensure_ascii=False)}"
|
||||
for field, value in fields.items()
|
||||
)
|
||||
|
||||
if section == "grammaire":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[taxonomies]
|
||||
themes = []
|
||||
+++
|
||||
"""
|
||||
|
||||
if section == "textes":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[taxonomies]
|
||||
genres = []
|
||||
+++
|
||||
"""
|
||||
|
||||
raise ValueError(f"section non prise en charge : {section}")
|
||||
lines.extend(("+++", ""))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -218,13 +163,13 @@ def main() -> int:
|
||||
if section != "vocabulaire" and args.nature is not None:
|
||||
raise ValueError("--nature est réservé aux fiches de vocabulaire")
|
||||
|
||||
timestamp = current_timestamp()
|
||||
custom_title = normalize(args.title) if args.title is not None else None
|
||||
nature = None
|
||||
mzl = None
|
||||
|
||||
if section == "signes":
|
||||
filename, mzl = parse_mzl(args.identifiant)
|
||||
title = custom_title if custom_title is not None else ""
|
||||
content = build_sign(title, timestamp, mzl)
|
||||
else:
|
||||
filename = validate_identifier(
|
||||
args.identifiant,
|
||||
@@ -233,21 +178,27 @@ def main() -> int:
|
||||
if section == "vocabulaire":
|
||||
nature = choose_nature(args.nature)
|
||||
title = custom_title if custom_title is not None else filename
|
||||
content = build_vocabulary(title, timestamp, nature)
|
||||
else:
|
||||
title = (
|
||||
custom_title
|
||||
if custom_title is not None
|
||||
else title_from_identifier(filename)
|
||||
)
|
||||
content = build_page(section, title, timestamp)
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
|
||||
timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
content = build_content(
|
||||
title,
|
||||
timestamp,
|
||||
content_tables(section, nature, mzl),
|
||||
)
|
||||
except (ValueError, ZoneInfoNotFoundError) as exc:
|
||||
print(f"Erreur : {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
destination = CONTENT_DIR / section / f"{filename}.md"
|
||||
try:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with destination.open("x", encoding="utf-8") as stream:
|
||||
stream.write(content)
|
||||
except FileExistsError:
|
||||
@@ -257,15 +208,10 @@ def main() -> int:
|
||||
)
|
||||
return 1
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"Erreur : impossible de créer {destination.relative_to(ROOT_DIR)} ({exc})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Erreur : impossible de créer la fiche ({exc})", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Brouillon créé : {destination.relative_to(ROOT_DIR)}")
|
||||
print("Compléter la fiche, passer draft à false, puis lancer :")
|
||||
print(" ./scripts/check-content.py")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from _schema import CONTENT_SECTIONS, UPDATED_LINE_RE
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONTENT = (ROOT / "src" / "content").resolve()
|
||||
|
||||
@@ -15,7 +17,6 @@ FRONT_MATTER_RE = re.compile(
|
||||
r"\A\+\+\+\r?\n(?P<body>.*?)(?:\r?\n\+\+\+(?=\r?\n|\Z))",
|
||||
re.DOTALL,
|
||||
)
|
||||
UPDATED_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -39,8 +40,14 @@ def resolve_path(value: str) -> Path:
|
||||
raise ValueError("la fiche doit se trouver dans src/content/")
|
||||
if not path.is_file():
|
||||
raise ValueError(f"fichier introuvable : {path}")
|
||||
if path.suffix != ".md" or path.name == "_index.md":
|
||||
raise ValueError("le chemin doit désigner une fiche Markdown, pas un index")
|
||||
relative_path = path.relative_to(CONTENT)
|
||||
if (
|
||||
path.suffix != ".md"
|
||||
or path.name == "_index.md"
|
||||
or len(relative_path.parts) == 1
|
||||
or relative_path.parts[0] not in CONTENT_SECTIONS
|
||||
):
|
||||
raise ValueError("le chemin doit désigner une fiche d’une section connue")
|
||||
|
||||
return path
|
||||
|
||||
@@ -52,7 +59,7 @@ def update_timestamp(path: Path) -> str:
|
||||
if front_matter is None:
|
||||
raise ValueError("front matter TOML délimité par +++ absent")
|
||||
|
||||
matches = list(UPDATED_RE.finditer(front_matter.group("body")))
|
||||
matches = list(UPDATED_LINE_RE.finditer(front_matter.group("body")))
|
||||
if len(matches) != 1:
|
||||
raise ValueError("la fiche doit contenir exactement une ligne updated")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user