diff --git a/.dockerignore b/.dockerignore index 5bb421d..d2818ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,8 @@ * !Dockerfile !scripts/ -!scripts/check-content.py -!scripts/_schema.py +!scripts/check-content.rb +!scripts/schema.rb !src/ !src/** src/public/ diff --git a/.gitignore b/.gitignore index 751f0eb..adfed5c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,3 @@ # Site généré par Zola src/public/ - -# Caches Python -__pycache__/ -*.py[cod] diff --git a/Dockerfile b/Dockerfile index f1dab5c..6196b8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM python:3.13-alpine AS validator +FROM ruby:3.4.11-alpine3.24 AS validator WORKDIR /project -COPY scripts/check-content.py scripts/_schema.py ./scripts/ +COPY scripts/check-content.rb scripts/schema.rb ./scripts/ COPY src ./src -RUN python3 scripts/check-content.py +RUN ruby scripts/check-content.rb FROM ghcr.io/getzola/zola:v0.23.6 AS build WORKDIR /site diff --git a/GUIDE_REDACTION.md b/GUIDE_REDACTION.md index 31b223f..c2fb59a 100644 --- a/GUIDE_REDACTION.md +++ b/GUIDE_REDACTION.md @@ -27,15 +27,15 @@ Les fichiers `_index.md` définissent les sections et restent maintenus manuelle Les commandes publiques sont : -- `new-content.py` : création d’un brouillon ; -- `update-content.py` : mise à jour de l’horodatage `updated` ; -- `check-content.py` : validation des noms et de la structure des fiches. +- `new-content.rb` : création d’un brouillon ; +- `update-content.rb` : mise à jour de l’horodatage `updated` ; +- `check-content.rb` : validation des noms et de la structure des fiches. -`_schema.py` est leur module commun et ne se lance pas directement. +`schema.rb` est leur module commun et ne se lance pas directement. ## Cycle de rédaction -1. créer un brouillon avec `new-content.py` ; +1. créer un brouillon avec `new-content.rb` ; 2. compléter les données et le corps Markdown ; 3. contrôler son rendu avec les brouillons ; 4. passer `draft` à `false` lorsqu’il est prêt ; @@ -51,7 +51,7 @@ zola --root src serve --drafts Avant publication : ```sh -./scripts/check-content.py +./scripts/check-content.rb zola --root src check --drafts zola --root src build --drafts zola --root src build @@ -64,19 +64,19 @@ zola --root src build Syntaxe générale : ```sh -./scripts/new-content.py SECTION IDENTIFIANT [--nature NATURE] [--title TITRE] +./scripts/new-content.rb SECTION IDENTIFIANT [--nature NATURE] [--title TITRE] ``` Exemples : ```sh -./scripts/new-content.py vocabulaire šarrum --nature nom -./scripts/new-content.py vocabulaire epēšum --nature verbe -./scripts/new-content.py vocabulaire --nature particule -- -ma -./scripts/new-content.py signes 113 --title BE -./scripts/new-content.py grammaire suffixes-pronominaux -./scripts/new-content.py textes lettre-au-marchand -./scripts/new-content.py articles nouvel-article +./scripts/new-content.rb vocabulaire šarrum --nature nom +./scripts/new-content.rb vocabulaire epēšum --nature verbe +./scripts/new-content.rb vocabulaire --nature particule -- -ma +./scripts/new-content.rb signes 113 --title BE +./scripts/new-content.rb grammaire suffixes-pronominaux +./scripts/new-content.rb textes lettre-au-marchand +./scripts/new-content.rb articles nouvel-article ``` Pour le vocabulaire, les natures reconnues sont : @@ -86,7 +86,7 @@ nom, adjectif, verbe, pronom, préposition, adverbe, conjonction, particule, numéral ``` -Dans un terminal interactif, l’omission de `--nature` ouvre un menu. Dans un contexte non interactif, l’option est obligatoire. +Pour une fiche de vocabulaire, `--nature` est obligatoire. Ce choix explicite garde le générateur simple et prévisible. Le générateur : @@ -169,6 +169,8 @@ themes = ["morphologie-nominale"] genres = ["inscription-commémorative"] ``` +Conserver la syntaxe simple générée pour les tableaux de taxonomie : une seule ligne, avec les chaînes entre guillemets doubles. Le validateur minimal lit notamment `natures` sous cette forme (`natures = ["nom"]`). + Les templates rétablissent les espaces à l’affichage, tandis que les URL restent sans espace : ```text @@ -201,13 +203,13 @@ draft = true Pour actualiser une fiche : ```sh -./scripts/update-content.py src/content/vocabulaire/bītum.md +./scripts/update-content.rb src/content/vocabulaire/bītum.md ``` La forme relative à `src/content/` est également acceptée : ```sh -./scripts/update-content.py vocabulaire/bītum.md +./scripts/update-content.rb vocabulaire/bītum.md ``` Le script refuse les index, les pages autonomes et les chemins extérieurs aux cinq sections. Il remplace l’unique ligne `updated` par l’heure courante : @@ -314,7 +316,7 @@ Le corps Markdown reste libre. Rubriques possibles : Commande : ```sh -./scripts/new-content.py signes 113 --title BE +./scripts/new-content.rb signes 113 --title BE ``` Champs propres : @@ -411,8 +413,8 @@ Ils sont ajoutés lorsqu’ils apportent un contexte réel : occurrence dans un Un bloc délimité par trois accents graves et associé à une langue reçoit automatiquement la coloration syntaxique : ````markdown -```python -print("šulmu") +```ruby +puts "šulmu" ``` ```` @@ -420,45 +422,22 @@ Zola génère `giallo.css`, tandis que le HTML rendu utilise les classes corresp ## Ce que contrôle le validateur -`check-content.py` contrôle uniquement les fiches des cinq sections. Il ignore les fichiers `_index.md` et les pages placées directement dans `src/content/`. +`check-content.rb` contrôle uniquement les fiches des cinq sections. Il ignore les fichiers `_index.md` et les pages placées directement dans `src/content/`. Pour chaque fiche, il vérifie : - le nom de fichier en Unicode NFC ; - les identifiants en minuscules avec lettres Unicode, chiffres et tirets ; - la forme `mzl-NNN.md` pour les signes ; -- la lecture en UTF-8 ; - la présence d’un front matter délimité par `+++` ; -- la validité du TOML ; +- la présence des champs communs `title`, `date`, `description` et `draft` ; - exactement une ligne `updated`, commentée ou active ; -- `title` et `description` de type chaîne ; -- `date` de type horodatage TOML ; -- `draft` de type booléen ; -- `updated`, lorsqu’il est actif, de type horodatage TOML. +- pour le vocabulaire, une nature grammaticale reconnue et les champs `[extra]` attendus ; +- pour les autres sections, les champs propres à leur modèle. -Pour le vocabulaire, il vérifie également : +Le validateur Ruby reste volontairement simple : il ne réimplémente pas un parseur TOML et ne contrôle pas les types de toutes les valeurs. **Zola reste responsable de la syntaxe TOML, des liens, des taxonomies et des templates.** Cette séparation évite toute dépendance Ruby supplémentaire. -- exactement une nature grammaticale reconnue ; -- tous les champs `[extra]` prévus pour cette nature ; -- des chaînes pour les champs simples ; -- un tableau de chaînes pour `logograms`. - -Pour les autres sections, il vérifie : - -- `banner`, `banner_alt` et `banner_credit` pour les articles ; -- `themes` pour la grammaire ; -- `sign`, `mzl` et `lectures` pour les signes ; -- `genres` pour les textes. - -Par convention, les termes composés de `themes` et `genres` utilisent des tirets. Cette convention relève de la rédaction et n’est pas contrôlée par le validateur. - -Le validateur accepte les chaînes et tableaux vides. Il ne contrôle pas : - -- la qualité ou l’exactitude linguistique des valeurs ; -- les champs supplémentaires ; -- la correspondance entre le numéro MZL du front matter et le nom du fichier. - -Zola reste responsable des liens, taxonomies et templates. Le générateur et le validateur appliquent les mêmes conventions de nommage ; le générateur reste responsable de produire des squelettes complets. +Le générateur et le validateur appliquent les mêmes conventions de nommage. Le générateur produit des squelettes complets ; le validateur vérifie surtout qu’aucun champ structurel n’a disparu. ## Vérification avant publication @@ -469,5 +448,5 @@ Avant de publier ou déployer : - utiliser `draft = false` uniquement pour un contenu prêt ; - utiliser des tirets dans les thèmes et genres composés ; - éviter les rubriques vides et les textes d’attente ; -- lancer `update-content.py` seulement après une modification substantielle ; +- lancer `update-content.rb` seulement après une modification substantielle ; - exécuter le validateur, `zola check`, la construction avec brouillons, puis la construction publique. diff --git a/README.md b/README.md index 9bc9dea..ff94fa1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ La page d’accueil présente le dernier article et les trois fiches les plus r Pour le développement local : -- Python 3.11 ou ultérieur ; +- Ruby 3.3 ou ultérieur ; - Zola 0.23.6 ; - Git, si le dépôt est versionné avec Git. @@ -36,7 +36,7 @@ Toutes les commandes suivantes sont exécutées depuis la racine du dépôt. Valider la structure des fiches : ```sh -./scripts/check-content.py +./scripts/check-content.rb ``` Vérifier le chargement du site et ses liens, brouillons compris : @@ -81,11 +81,11 @@ zola --root src serve --drafts Les contenus sont stockés dans `src/content/`. Trois commandes accompagnent leur rédaction : -- `new-content.py` crée un brouillon complet pour une section donnée ; -- `update-content.py` actualise son horodatage `updated` ; -- `check-content.py` vérifie les noms de fichiers ainsi que la présence et le type des champs prévus. +- `new-content.rb` crée un brouillon complet pour une section donnée ; +- `update-content.rb` actualise son horodatage `updated` ; +- `check-content.rb` vérifie les noms de fichiers et la présence des champs prévus. -Le module `_schema.py` centralise les modèles utilisés par le générateur et le validateur ; il n’est pas exécuté directement. +Le module `schema.rb` centralise les modèles utilisés par le générateur et le validateur ; il n’est pas exécuté directement. Les commandes, conventions et modèles de front matter sont détaillés dans le [guide de rédaction](GUIDE_REDACTION.md). @@ -128,7 +128,7 @@ Les commandes, conventions et modèles de front matter sont détaillés dans le L’image est construite en trois étapes : -1. validation des fiches avec Python ; +1. validation des fiches avec Ruby ; 2. génération du site avec Zola 0.23.6 ; 3. copie des seuls fichiers publics dans static-web-server 2.44.0. diff --git a/scripts/_schema.py b/scripts/_schema.py deleted file mode 100644 index 6336365..0000000 --- a/scripts/_schema.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Schémas et règles structurelles communs aux scripts de fiches.""" - -import re - -SECTION_ALIASES = { - "article": "articles", - "articles": "articles", - "grammaire": "grammaire", - "signe": "signes", - "signes": "signes", - "texte": "textes", - "textes": "textes", - "vocabulaire": "vocabulaire", -} - -VOCABULARY_FIELDS = { - "nom": ("meaning", "logograms", "gender", "bound", "plural"), - "adjectif": ( - "meaning", - "logograms", - "bound", - "feminine", - "masculine_plural", - "feminine_plural", - "predicative", - ), - "verbe": ( - "meaning", - "logograms", - "root", - "stem", - "verb_class", - "vowel_class", - "preterite", - "durative", - "perfect", - "imperative", - "participle", - "verbal_adjective", - ), - "pronom": ( - "meaning", - "logograms", - "pronoun_type", - "person", - "gender", - "number", - ), - "préposition": ("meaning", "logograms", "governs"), - "adverbe": ("meaning", "logograms", "function"), - "conjonction": ("meaning", "logograms", "function"), - "particule": ("meaning", "logograms", "function"), - "numéral": ( - "meaning", - "logograms", - "numeral_type", - "value", - "gender", - "feminine", - ), -} - -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?$)") diff --git a/scripts/check-content.py b/scripts/check-content.py deleted file mode 100755 index a6a8cbb..0000000 --- a/scripts/check-content.py +++ /dev/null @@ -1,255 +0,0 @@ -#!/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()) diff --git a/scripts/check-content.rb b/scripts/check-content.rb new file mode 100755 index 0000000..f7f3c57 --- /dev/null +++ b/scripts/check-content.rb @@ -0,0 +1,144 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require_relative "schema" + +ROOT = File.expand_path("..", __dir__) +CONTENT = File.join(ROOT, "src", "content") + + +def error(path, message) + label = path ? "#{path.delete_prefix(ROOT + "/")} : " : "" + warn "ERREUR : #{label}#{message}" + $errors += 1 +end + +$errors = 0 + +if ARGV.any? + warn "Usage : #{File.basename($PROGRAM_NAME)}" + exit 1 +end + +def front_matter(path) + text = File.read(path, encoding: "UTF-8") + match = text.match(/\A\+\+\+\r?\n(.*?)\r?\n\+\+\+(?:\r?\n|\z)/m) + unless match + error(path, "front matter délimité par +++ absent") + return nil + end + match[1] +rescue StandardError => e + error(path, "lecture impossible (#{e.message})") + nil +end + +def fields_by_table(source) + tables = Hash.new { |hash, key| hash[key] = {} } + table = "root" + + source.each_line do |line| + line = line.strip + next if line.empty? || line.start_with?("#") + + if (match = line.match(/\A\[([^\]]+)\]\z/)) + table = match[1] + elsif (match = line.match(/\A([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)\z/)) + tables[table][match[1]] = match[2] + end + end + + tables +end + +def check_filename(path, section) + stem = File.basename(path, ".md") + error(path, "nom de fichier non normalisé en Unicode NFC") if stem != stem.unicode_normalize(:nfc) + + if section == "signes" + match = stem.match(/\Amzl-([0-9]{3})\z/) + error(path, "un signe doit être nommé mzl-NNN.md (001 à 999)") unless match && match[1].to_i.between?(1, 999) + return + end + + leading_hyphen = stem.start_with?("-") + body = leading_hyphen ? stem[1..] : stem + if leading_hyphen && section != "vocabulaire" + error(path, "seul le vocabulaire accepte un nom commençant par un tiret") + return + end + + valid = body && !body.empty? && + stem != "_index" && + stem == stem.downcase && + !stem.end_with?("-") && + !stem.include?("--") && + body.match?(/\A[\p{Alnum}-]+\z/u) + + error(path, "nom de fichier invalide : utiliser des lettres Unicode minuscules, des chiffres et des tirets") unless valid +end + +def require_fields(path, tables, table, fields) + missing = fields.reject { |field| tables.fetch(table, {}).key?(field) } + error(path, "champs #{table} manquants : #{missing.join(', ')}") unless missing.empty? +end + +unless Dir.exist?(CONTENT) + error(nil, "répertoire introuvable : #{CONTENT}") +else + puts "Vérification des fiches…" + Dir.glob(File.join(CONTENT, "**", "*.md")).sort.each do |path| + relative = path.delete_prefix(CONTENT + "/") + parts = relative.split("/") + next if File.basename(path) == "_index.md" || parts.length == 1 + + section = parts.first + unless CONTENT_SECTIONS.include?(section) + error(path, "section inconnue : #{section}") + next + end + + check_filename(path, section) + source = front_matter(path) + next unless source + + tables = fields_by_table(source) + require_fields(path, tables, "root", %w[title date description draft]) + error(path, "la fiche doit contenir exactement une ligne updated") unless source.scan(UPDATED_RE).length == 1 + + if section == "vocabulaire" + value = tables.dig("taxonomies", "natures") + unless value + error(path, "champ taxonomies manquant : natures") + next + end + + begin + natures = JSON.parse(value) + rescue JSON::ParserError + natures = nil + end + + unless natures.is_a?(Array) && natures.length == 1 && natures.first.is_a?(String) + error(path, "taxonomies.natures doit contenir exactement une nature") + next + end + + nature = natures.first + fields = VOCABULARY_FIELDS[nature] + if fields + require_fields(path, tables, "extra", fields) + else + error(path, "nature grammaticale inconnue : #{nature.inspect}") + end + else + SECTION_FIELDS.fetch(section).each do |table, fields| + require_fields(path, tables, table, fields) + end + end + end +end + +puts "\nRésultat : #{$errors} erreur(s)." +exit($errors.zero? ? 0 : 1) diff --git a/scripts/new-content.py b/scripts/new-content.py deleted file mode 100755 index 2aec104..0000000 --- a/scripts/new-content.py +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env python3 -"""Crée un brouillon Markdown adapté à une section du site.""" - -import argparse -import json -import re -import sys -import unicodedata -from datetime import datetime -from pathlib import Path -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from _schema import LIST_FIELDS, SECTION_ALIASES, SECTION_FIELDS, VOCABULARY_FIELDS - -ROOT_DIR = Path(__file__).resolve().parent.parent -CONTENT_DIR = ROOT_DIR / "src" / "content" -MZL_RE = re.compile(r"(?:mzl-)?([0-9]{1,3})") - -FieldValue = str | list[str] - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="Créer un brouillon Markdown horodaté et prêt à compléter." - ) - parser.add_argument( - "section", - choices=sorted(SECTION_ALIASES), - help="section de destination", - ) - parser.add_argument( - "identifiant", - help="nom du fichier sans .md ; pour un signe, numéro MZL ou mzl-XXX", - ) - parser.add_argument( - "--title", - help="titre initial ; déduit de l’identifiant par défaut", - ) - parser.add_argument( - "--nature", - choices=tuple(VOCABULARY_FIELDS), - help="nature grammaticale d’une fiche de vocabulaire", - ) - return parser.parse_args() - - -def normalize(value: str) -> str: - return unicodedata.normalize("NFC", value.strip()) - - -def validate_identifier(identifier: str, allow_leading_hyphen: bool) -> str: - value = normalize(identifier) - body = value[1:] if value.startswith("-") else value - - 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") - - return value - - -def parse_mzl(identifier: str) -> tuple[str, str]: - 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"{int(match.group(1)):03d}" - return f"mzl-{mzl}", mzl - - -def choose_nature(nature: str | None) -> str: - if nature: - return nature - if not sys.stdin.isatty(): - raise ValueError("--nature est requis pour une fiche de vocabulaire") - - choices = tuple(VOCABULARY_FIELDS) - print("Nature grammaticale :") - for index, choice in enumerate(choices, start=1): - print(f" {index}. {choice}") - - try: - answer = input("Choix : ").strip() - except (EOFError, KeyboardInterrupt) as exc: - raise ValueError("sélection interrompue") from exc - - if not answer.isdigit() or not 1 <= int(answer) <= len(choices): - raise ValueError("nature grammaticale invalide") - return choices[int(answer) - 1] - - -def title_from_identifier(identifier: str) -> str: - title = identifier.replace("-", " ") - return title[:1].upper() + title[1:] - - -def empty_fields(fields: tuple[str, ...]) -> dict[str, FieldValue]: - return {field: [] if field in LIST_FIELDS else "" for field in fields} - - -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]}, - } - - 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_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", - ] - - 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() - ) - - lines.extend(("+++", "")) - return "\n".join(lines) - - -def main() -> int: - args = parse_args() - section = SECTION_ALIASES[args.section] - - try: - if section != "vocabulaire" and args.nature is not None: - raise ValueError("--nature est réservé aux fiches de vocabulaire") - - 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 "" - else: - filename = validate_identifier( - args.identifiant, - allow_leading_hyphen=section == "vocabulaire", - ) - if section == "vocabulaire": - nature = choose_nature(args.nature) - title = custom_title if custom_title is not None else filename - else: - title = ( - custom_title - if custom_title is not None - else title_from_identifier(filename) - ) - - 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: - with destination.open("x", encoding="utf-8") as stream: - stream.write(content) - except FileExistsError: - print( - f"Erreur : le fichier existe déjà : {destination.relative_to(ROOT_DIR)}", - file=sys.stderr, - ) - return 1 - except OSError as exc: - print(f"Erreur : impossible de créer la fiche ({exc})", file=sys.stderr) - return 1 - - print(f"Brouillon créé : {destination.relative_to(ROOT_DIR)}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/new-content.rb b/scripts/new-content.rb new file mode 100755 index 0000000..bb3e742 --- /dev/null +++ b/scripts/new-content.rb @@ -0,0 +1,98 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "optparse" +require "time" +require_relative "schema" + +ENV["TZ"] = "Europe/Paris" +ROOT = File.expand_path("..", __dir__) +CONTENT = File.join(ROOT, "src", "content") + +options = {} +parser = OptionParser.new do |opts| + opts.banner = "Usage: #{File.basename($PROGRAM_NAME)} SECTION IDENTIFIANT [options]" + opts.on("--nature NATURE", "nature d’une fiche de vocabulaire") { |value| options[:nature] = value } + opts.on("--title TITRE", "titre initial") { |value| options[:title] = value } +end + +begin + parser.parse! + raise "SECTION et IDENTIFIANT sont requis, sans argument supplémentaire" unless ARGV.length == 2 + section_name, identifier = ARGV + + section = SECTION_ALIASES[section_name] + raise "section inconnue : #{section_name}" unless section + + normalize = ->(value) { value.strip.unicode_normalize(:nfc) } + identifier = normalize.call(identifier) + + if section == "signes" + match = identifier.match(/\A(?:mzl-)?([0-9]{1,3})\z/) + raise "le numéro MZL doit être compris entre 1 et 999" unless match && match[1].to_i.between?(1, 999) + mzl = format("%03d", match[1].to_i) + filename = "mzl-#{mzl}" + else + leading_hyphen = identifier.start_with?("-") + body = leading_hyphen ? identifier[1..] : identifier + raise "seul le vocabulaire accepte un identifiant commençant par un tiret" if leading_hyphen && section != "vocabulaire" + valid = body && !body.empty? && identifier != "_index" && identifier == identifier.downcase && + !identifier.end_with?("-") && !identifier.include?("--") && body.match?(/\A[\p{Alnum}-]+\z/u) + raise "identifiant de fichier invalide" unless valid + filename = identifier + end + + nature = options[:nature]&.unicode_normalize(:nfc) + if section == "vocabulaire" + raise "--nature est requis pour le vocabulaire" unless nature + raise "nature grammaticale inconnue : #{nature}" unless VOCABULARY_FIELDS.key?(nature) + elsif nature + raise "--nature est réservé au vocabulaire" + end + + title = if options[:title] + normalize.call(options[:title]) + elsif section == "signes" + "" + elsif section == "vocabulaire" + filename + else + filename.tr("-", " ").sub(/\A./) { |char| char.upcase } + end + + tables = if section == "vocabulaire" + { + "extra" => VOCABULARY_FIELDS.fetch(nature).to_h { |field| [field, LIST_FIELDS.include?(field) ? [] : ""] }, + "taxonomies" => { "natures" => [nature] } + } + else + SECTION_FIELDS.fetch(section).transform_values do |fields| + fields.to_h { |field| [field, LIST_FIELDS.include?(field) ? [] : ""] } + end + end + tables["extra"]["mzl"] = mzl if section == "signes" + + lines = [ + "+++", + "title = #{JSON.generate(title)}", + "date = #{Time.now.iso8601}", + "# updated = YYYY-MM-DDTHH:MM:SS+HH:MM", + 'description = ""', + "draft = true" + ] + + tables.each do |table, fields| + lines << "" + lines << "[#{table}]" + fields.each { |field, value| lines << "#{field} = #{JSON.generate(value)}" } + end + lines += ["+++", ""] + + destination = File.join(CONTENT, section, "#{filename}.md") + File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |file| file.write(lines.join("\n")) } + puts "Brouillon créé : #{destination.delete_prefix(ROOT + '/')}" +rescue OptionParser::ParseError, RuntimeError, Errno::EEXIST, SystemCallError => e + warn "Erreur : #{e.message}" + exit 1 +end diff --git a/scripts/schema.rb b/scripts/schema.rb new file mode 100644 index 0000000..0d37496 --- /dev/null +++ b/scripts/schema.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +SECTION_ALIASES = { + "article" => "articles", + "articles" => "articles", + "grammaire" => "grammaire", + "signe" => "signes", + "signes" => "signes", + "texte" => "textes", + "textes" => "textes", + "vocabulaire" => "vocabulaire" +}.freeze + +VOCABULARY_FIELDS = { + "nom" => %w[meaning logograms gender bound plural], + "adjectif" => %w[meaning logograms bound feminine masculine_plural feminine_plural predicative], + "verbe" => %w[meaning logograms root stem verb_class vowel_class preterite durative perfect imperative participle verbal_adjective], + "pronom" => %w[meaning logograms pronoun_type person gender number], + "préposition" => %w[meaning logograms governs], + "adverbe" => %w[meaning logograms function], + "conjonction" => %w[meaning logograms function], + "particule" => %w[meaning logograms function], + "numéral" => %w[meaning logograms numeral_type value gender feminine] +}.freeze + +SECTION_FIELDS = { + "articles" => { "extra" => %w[banner banner_alt banner_credit] }, + "grammaire" => { "taxonomies" => %w[themes] }, + "signes" => { + "extra" => %w[sign mzl], + "taxonomies" => %w[lectures] + }, + "textes" => { "taxonomies" => %w[genres] } +}.freeze + +LIST_FIELDS = %w[logograms lectures themes genres].freeze +CONTENT_SECTIONS = (SECTION_FIELDS.keys + ["vocabulaire"]).freeze +UPDATED_RE = /^(?:#\s*)?updated\s*=.*$/ diff --git a/scripts/update-content.py b/scripts/update-content.py deleted file mode 100755 index 863f8aa..0000000 --- a/scripts/update-content.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 -"""Horodate la dernière modification substantielle d’une fiche.""" - -import argparse -import re -import sys -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() - -FRONT_MATTER_RE = re.compile( - r"\A\+\+\+\r?\n(?P.*?)(?:\r?\n\+\+\+(?=\r?\n|\Z))", - re.DOTALL, -) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "fiche", - help="chemin depuis la racine du dépôt ou depuis src/content/", - ) - return parser.parse_args() - - -def resolve_path(value: str) -> Path: - path = Path(value).expanduser() - - if not path.is_absolute(): - path = ROOT / path if path.parts[:2] == ("src", "content") else CONTENT / path - - path = path.resolve() - - if not path.is_relative_to(CONTENT): - raise ValueError("la fiche doit se trouver dans src/content/") - if not path.is_file(): - raise ValueError(f"fichier introuvable : {path}") - 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 - - -def update_timestamp(path: Path) -> str: - text = path.read_bytes().decode("utf-8") - front_matter = FRONT_MATTER_RE.match(text) - - if front_matter is None: - raise ValueError("front matter TOML délimité par +++ absent") - - matches = list(UPDATED_LINE_RE.finditer(front_matter.group("body"))) - if len(matches) != 1: - raise ValueError("la fiche doit contenir exactement une ligne updated") - - timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat(timespec="seconds") - - match = matches[0] - offset = front_matter.start("body") - start = offset + match.start() - end = offset + match.end() - - path.write_bytes( - (text[:start] + f"updated = {timestamp}" + text[end:]).encode("utf-8") - ) - return timestamp - - -def main() -> int: - args = parse_args() - - try: - path = resolve_path(args.fiche) - timestamp = update_timestamp(path) - except (OSError, UnicodeError, ValueError, ZoneInfoNotFoundError) as exc: - print(f"Erreur : {exc}", file=sys.stderr) - return 1 - - print(f"Fiche mise à jour : {path.relative_to(ROOT)}") - print(f"updated = {timestamp}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/update-content.rb b/scripts/update-content.rb new file mode 100755 index 0000000..c9338da --- /dev/null +++ b/scripts/update-content.rb @@ -0,0 +1,39 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "time" +require_relative "schema" + +ENV["TZ"] = "Europe/Paris" +ROOT = File.expand_path("..", __dir__) +CONTENT = File.join(ROOT, "src", "content") + +begin + raise "un seul chemin de fiche est requis" unless ARGV.length == 1 + value = ARGV.first + base = value.start_with?("src/content/") ? ROOT : CONTENT + path = File.realpath(File.expand_path(value, base)) + relative = path.delete_prefix(CONTENT + "/") + parts = relative.split("/") + + valid = path.start_with?(CONTENT + "/") && File.file?(path) && File.extname(path) == ".md" && + File.basename(path) != "_index.md" && parts.length > 1 && CONTENT_SECTIONS.include?(parts.first) + raise "le chemin doit désigner une fiche d’une section connue" unless valid + + text = File.read(path, encoding: "UTF-8") + front = text.match(/\A\+\+\+\r?\n(.*?)\r?\n\+\+\+(?:\r?\n|\z)/m) + raise "front matter TOML délimité par +++ absent" unless front + + matches = front[1].to_enum(:scan, UPDATED_RE).map { Regexp.last_match } + raise "la fiche doit contenir exactement une ligne updated" unless matches.length == 1 + + timestamp = Time.now.iso8601 + body = front[1].sub(UPDATED_RE, "updated = #{timestamp}") + File.write(path, text.sub(front[1], body), encoding: "UTF-8") + + puts "Fiche mise à jour : #{path.delete_prefix(ROOT + '/')}" + puts "updated = #{timestamp}" +rescue StandardError => e + warn "Erreur : #{e.message}" + exit 1 +end