Python to Ruby

This commit is contained in:
julien committed 2026-09-30 14:27:05 +02:00
1 parent cf82698bef
commit 4c9e1f5f66
13 files changed
+360 -711

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
* *
!Dockerfile !Dockerfile
!scripts/ !scripts/
!scripts/check-content.py !scripts/check-content.rb
!scripts/_schema.py !scripts/schema.rb
!src/ !src/
!src/** !src/**
src/public/ src/public/
-4
View File
@@ -2,7 +2,3 @@
# Site généré par Zola # Site généré par Zola
src/public/ src/public/
# Caches Python
__pycache__/
*.py[cod]
+3 -3
View File
@@ -1,8 +1,8 @@
FROM python:3.13-alpine AS validator FROM ruby:3.4.11-alpine3.24 AS validator
WORKDIR /project WORKDIR /project
COPY scripts/check-content.py scripts/_schema.py ./scripts/ COPY scripts/check-content.rb scripts/schema.rb ./scripts/
COPY src ./src 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 FROM ghcr.io/getzola/zola:v0.23.6 AS build
WORKDIR /site WORKDIR /site
+29 -50
View File
@@ -27,15 +27,15 @@ Les fichiers `_index.md` définissent les sections et restent maintenus manuelle
Les commandes publiques sont : Les commandes publiques sont :
- `new-content.py` : création d’un brouillon ; - `new-content.rb` : création d’un brouillon ;
- `update-content.py` : mise à jour de l’horodatage `updated` ; - `update-content.rb` : mise à jour de l’horodatage `updated` ;
- `check-content.py` : validation des noms et de la structure des fiches. - `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 ## 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 ; 2. compléter les données et le corps Markdown ;
3. contrôler son rendu avec les brouillons ; 3. contrôler son rendu avec les brouillons ;
4. passer `draft` à `false` lorsqu’il est prêt ; 4. passer `draft` à `false` lorsqu’il est prêt ;
@@ -51,7 +51,7 @@ zola --root src serve --drafts
Avant publication : Avant publication :
```sh ```sh
./scripts/check-content.py ./scripts/check-content.rb
zola --root src check --drafts zola --root src check --drafts
zola --root src build --drafts zola --root src build --drafts
zola --root src build zola --root src build
@@ -64,19 +64,19 @@ zola --root src build
Syntaxe générale : Syntaxe générale :
```sh ```sh
./scripts/new-content.py SECTION IDENTIFIANT [--nature NATURE] [--title TITRE] ./scripts/new-content.rb SECTION IDENTIFIANT [--nature NATURE] [--title TITRE]
``` ```
Exemples : Exemples :
```sh ```sh
./scripts/new-content.py vocabulaire šarrum --nature nom ./scripts/new-content.rb vocabulaire šarrum --nature nom
./scripts/new-content.py vocabulaire epēšum --nature verbe ./scripts/new-content.rb vocabulaire epēšum --nature verbe
./scripts/new-content.py vocabulaire --nature particule -- -ma ./scripts/new-content.rb vocabulaire --nature particule -- -ma
./scripts/new-content.py signes 113 --title BE ./scripts/new-content.rb signes 113 --title BE
./scripts/new-content.py grammaire suffixes-pronominaux ./scripts/new-content.rb grammaire suffixes-pronominaux
./scripts/new-content.py textes lettre-au-marchand ./scripts/new-content.rb textes lettre-au-marchand
./scripts/new-content.py articles nouvel-article ./scripts/new-content.rb articles nouvel-article
``` ```
Pour le vocabulaire, les natures reconnues sont : Pour le vocabulaire, les natures reconnues sont :
@@ -86,7 +86,7 @@ nom, adjectif, verbe, pronom, préposition,
adverbe, conjonction, particule, numéral 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 : Le générateur :
@@ -169,6 +169,8 @@ themes = ["morphologie-nominale"]
genres = ["inscription-commémorative"] 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 : Les templates rétablissent les espaces à l’affichage, tandis que les URL restent sans espace :
```text ```text
@@ -201,13 +203,13 @@ draft = true
Pour actualiser une fiche : Pour actualiser une fiche :
```sh ```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 : La forme relative à `src/content/` est également acceptée :
```sh ```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 : 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 : Commande :
```sh ```sh
./scripts/new-content.py signes 113 --title BE ./scripts/new-content.rb signes 113 --title BE
``` ```
Champs propres : 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 : Un bloc délimité par trois accents graves et associé à une langue reçoit automatiquement la coloration syntaxique :
````markdown ````markdown
```python ```ruby
print("šulmu") 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 ## 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 : Pour chaque fiche, il vérifie :
- le nom de fichier en Unicode NFC ; - le nom de fichier en Unicode NFC ;
- les identifiants en minuscules avec lettres Unicode, chiffres et tirets ; - les identifiants en minuscules avec lettres Unicode, chiffres et tirets ;
- la forme `mzl-NNN.md` pour les signes ; - la forme `mzl-NNN.md` pour les signes ;
- la lecture en UTF-8 ;
- la présence d’un front matter délimité par `+++` ; - 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 ; - exactement une ligne `updated`, commentée ou active ;
- `title` et `description` de type chaîne ; - pour le vocabulaire, une nature grammaticale reconnue et les champs `[extra]` attendus ;
- `date` de type horodatage TOML ; - pour les autres sections, les champs propres à leur modèle.
- `draft` de type booléen ;
- `updated`, lorsqu’il est actif, de type horodatage TOML.
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 ; 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.
- 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.
## Vérification avant publication ## Vérification avant publication
@@ -469,5 +448,5 @@ Avant de publier ou déployer :
- utiliser `draft = false` uniquement pour un contenu prêt ; - utiliser `draft = false` uniquement pour un contenu prêt ;
- utiliser des tirets dans les thèmes et genres composés ; - utiliser des tirets dans les thèmes et genres composés ;
- éviter les rubriques vides et les textes d’attente ; - é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. - exécuter le validateur, `zola check`, la construction avec brouillons, puis la construction publique.
+7 -7
View File
@@ -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 : Pour le développement local :
- Python 3.11 ou ultérieur ; - Ruby 3.3 ou ultérieur ;
- Zola 0.23.6 ; - Zola 0.23.6 ;
- Git, si le dépôt est versionné avec Git. - 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 : Valider la structure des fiches :
```sh ```sh
./scripts/check-content.py ./scripts/check-content.rb
``` ```
Vérifier le chargement du site et ses liens, brouillons compris : 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 : 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 ; - `new-content.rb` crée un brouillon complet pour une section donnée ;
- `update-content.py` actualise son horodatage `updated` ; - `update-content.rb` 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. - `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). 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 : 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 ; 2. génération du site avec Zola 0.23.6 ;
3. copie des seuls fichiers publics dans static-web-server 2.44.0. 3. copie des seuls fichiers publics dans static-web-server 2.44.0.
-76
View File
@@ -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?$)")
-255
View File
@@ -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())
+144
View File
@@ -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)
-219
View File
@@ -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())
+98
View File
@@ -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
+38
View File
@@ -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*=.*$/
-95
View File
@@ -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<body>.*?)(?:\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())
+39
View File
@@ -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