Python to Ruby
This commit is contained in:
1 parent
cf82698bef
commit
4c9e1f5f66
13 files changed
+360
-711
No files matched your search
+2
-2
@@ -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/
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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?$)")
|
|
||||||
@@ -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())
|
|
||||||
Executable
+144
@@ -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)
|
||||||
@@ -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())
|
|
||||||
Executable
+98
@@ -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
|
||||||
@@ -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*=.*$/
|
||||||
@@ -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())
|
|
||||||
Executable
+39
@@ -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
|
||||||
Reference in new issue
Block a user