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

-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