Signalé sur /de/appareils/bornes/goecharger/ : la page était allemande sur deux
lignes et anglaise sur tout le reste. Le diagnostic a séparé deux choses que
j'avais mises dans le même sac le 2026-10-12.
LA LIGNE DE PARTAGE, écrite en tête du générateur :
• les LIBELLÉS QUE NOUS ÉCRIVONS (en-têtes de colonnes, titres de section,
oui/non, « Documentation fabricant ») → TRADUITS. Ils ne désignent rien à
l'écran, l'anglais n'y était qu'une friction. REF devient REF_I18N, trois
jeux de 20 libellés.
• le CONTENU VENANT DE NYMEA (noms de modèles, clés de registres, libellés de
champs comme « MAC address », unités) → VERBATIM, en anglais. Ce sont
exactement les chaînes que l'installateur lit DANS L'APP, qui tourne en
en_US. Les traduire rendrait la doc inutilisable pour retrouver un champ à
l'écran.
Et la pièce qui change l'expérience plus que la traduction elle-même : une note
`verbatim`, générée dans la langue de la page, en tête du bloc de référence.
Sans elle le mélange FR/EN passe pour une traduction bâclée ; avec elle c'est
une convention que le lecteur comprend.
L'injection devient sensible à la langue : `locale_of()` déduit la langue du
suffixe du fichier et `process()` rend le bloc en conséquence. Sans ça le même
bloc partait à l'identique dans les trois versions.
TAGLINES ET TITRES — i18n/devices.yaml, 52 entrées
Le tagline vivait en paragraphe libre, écrit UNE SEULE FOIS à la création de la
page : il restait donc en anglais sur les trois versions, y compris la
FRANÇAISE. Il entre dans le bloc badges, donc il est régénéré, donc traduisible
pour toujours. 52 taglines en fr et de ; l'anglais retombe sur l'amont.
Les traductions vivent dans le dépôt de DOC et pas dans les meta.json : trois
des cinq dépôts plugins sont des dépôts nymea amont que l'AGENTS.md interdit de
modifier. Repli sur l'amont pour toute entrée absente.
Deux titres s'affichaient en FRANÇAIS sous /de/ et /en/ — « Compteur ABB B2x »,
« Borne ABB Terra AC » : corrigés, dans la page et dans la nav.
Deux coquilles AMONT corrigées au passage, repérées en relisant les 57 entrées :
meta.json annonçait « Bestel » pour Vestel et « Easeex » pour Easee. Signalées
comme corrections, pas comme traductions, dans i18n/devices.yaml.
48 FICHES FR ÉTAIENT DANS UN ANCIEN FORMAT, ET MENTAIENT
Trouvé en vérifiant : 48 fiches FR n'avaient pas de marqueur `__badges_*__`.
Leurs badges étaient du HTML figé, écrit une fois — goecharger affichait
« NIGHTLY » alors que PORTING_STATUS dit `stable` depuis. Elles sont migrées au
format canonique : badges générés, donc exacts, plus titre localisé, tagline et
intertitre. Reste `hvac/homeconnect.md`, orpheline (plugin hors PORTING_STATUS).
NAV : UN CONSTAT QUI ÉVITE DE REFAIRE L'ESSAI
`literate-nav` ne lit que son `nav_file` : la substitution par suffixe de
mkdocs-static-i18n ne s'y applique PAS. Un `SUMMARY.de.md` généré n'était lu par
personne et la barre latérale allemande gardait les noms français. Testé, puis
retiré — et la raison est écrite dans le générateur pour que personne n'y
revienne. Les libellés de nav passent par `nav_translations` : 31 éléments
traduits par langue au lieu de 29.
RÉFÉRENCE API : LAISSÉE EN ANGLAIS, DÉLIBÉRÉMENT
Les 23 pages de l'API n'ont pas de variante par langue. Leur traduire les
libellés aurait fait voir du FRANÇAIS aux lecteurs /de/ et /en/ par le repli —
une régression. Créer 46 variantes pour traduire dix libellés aurait triplé
28 000 mots de contenu généré pour un gain nul : le reste de la page (noms de
méthodes, de champs, de types, descriptions) est l'API elle-même, et ce sont les
chaînes qu'on écrit dans ses requêtes. À la place, une note explique dans les
trois langues POURQUOI cette référence est en anglais.
Vérifié : build --strict sans avertissement, les deux générateurs rendent « Doc à
jour » en --check, et la fiche goecharger relue dans les trois langues.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
935 lines
40 KiB
Python
935 lines
40 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
gen_device_reference.py — génère la matrice de compatibilité, les sections de
|
||
référence des appareils et le fichier de nav literate-nav (SUMMARY.md) depuis
|
||
PORTING_STATUS.yaml + integrationplugin*.json + meta.json.
|
||
|
||
Deux modes de traitement des fiches :
|
||
- Injection : remplace le contenu entre marqueurs dans les fiches existantes.
|
||
- Création : crée entièrement les fiches et index inexistants.
|
||
|
||
Marqueurs gérés :
|
||
<!-- BEGIN GENERATED: integrationplugin<plugin>.json --> fiche appareil
|
||
<!-- BEGIN GENERATED: __matrix__ --> matrice compatibilité
|
||
<!-- BEGIN GENERATED: __index_<cat>__ --> index de catégorie
|
||
|
||
Usage :
|
||
python3 scripts/gen_device_reference.py --src .plugins-src --docs docs --lang fr
|
||
python3 scripts/gen_device_reference.py --src .plugins-src --docs docs --lang fr --check
|
||
"""
|
||
from __future__ import annotations
|
||
import argparse
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError:
|
||
sys.exit("PyYAML est requis : pip install PyYAML")
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# LA LIGNE DE PARTAGE : on traduit ce qu'on ÉCRIT, on garde ce que la MACHINE
|
||
# affiche
|
||
#
|
||
# Révisé le 2026-10-13. Avant, tout le bloc de référence était en anglais dans
|
||
# les trois langues — ce qui donnait une page allemande anglaise à 90 %, et
|
||
# ressemblait à une traduction bâclée plutôt qu'à un choix.
|
||
#
|
||
# Deux couches, deux traitements :
|
||
#
|
||
# • Les LIBELLÉS QUE NOUS ÉCRIVONS (en-têtes de colonnes, titres de section,
|
||
# « oui/non ») → TRADUITS. Ils ne désignent rien à l'écran, l'anglais n'y
|
||
# était qu'une friction.
|
||
#
|
||
# • Le CONTENU VENANT DE NYMEA (noms de modèles, clés de registres, libellés
|
||
# de champs comme « MAC address », unités) → VERBATIM, en anglais. Ce sont
|
||
# exactement les chaînes que l'installateur lit DANS L'APP, qui tourne en
|
||
# en_US. Les traduire rendrait la doc inutilisable pour retrouver un champ à
|
||
# l'écran. Mesuré, pas supposé : Integrations.GetThingClasses rend la MÊME
|
||
# charge utile au sha256 près (243 340 octets) pour fr_FR, de_DE et en_US,
|
||
# et nymea-plugins-translations n'est pas installé sur le parc.
|
||
#
|
||
# La note `verbatim` ci-dessous dit cette convention au lecteur, dans sa langue,
|
||
# en tête du bloc de référence — sans quoi le mélange passe pour un défaut.
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
INTERFACE_ROLE = {
|
||
"energymeter": "Energy meter",
|
||
"smartmeterconsumer": "Consumption meter",
|
||
"smartmeterproducer": "Production meter",
|
||
"evcharger": "EV charger",
|
||
"heatpump": "Heat pump",
|
||
"smartmeter": "Smart meter",
|
||
}
|
||
TECH_INTERFACES = {"connectable", "networkdevice", "wirelessconnectable"}
|
||
|
||
CREATE_METHOD = {
|
||
"discovery": "Auto-discovery",
|
||
"user": "Manual",
|
||
"auto": "Automatic",
|
||
}
|
||
|
||
# Libellés du bloc de référence, PAR LANGUE (cf. la ligne de partage ci-dessus).
|
||
# Les valeurs de cellules (INTERFACE_ROLE, CREATE_METHOD) restent en anglais :
|
||
# elles décrivent ce que nymea expose, elles ne sont pas des libellés à nous.
|
||
REF_I18N = {
|
||
"fr": {
|
||
"manufacturer": "**Fabricant :**",
|
||
"plugin": "**Plugin :**",
|
||
"models_h": "#### Modèles pris en charge",
|
||
"models_cols": ["Modèle", "Rôle", "Transport", "Ajout", "Grandeurs"],
|
||
"detail_h": "#### Détail par modèle",
|
||
"discovery_lbl": "_Paramètres de découverte :_",
|
||
"discovery_cols": ["Clé", "Libellé", "Type", "Plage", "Défaut"],
|
||
"settings_lbl": "_Réglages :_",
|
||
"settings_cols": ["Clé", "Libellé", "Type", "Plage", "Défaut", "Lecture seule"],
|
||
"states_lbl": "_Grandeurs mesurées :_",
|
||
"states_cols": ["Clé", "Grandeur", "Type", "Unité"],
|
||
"yes": "oui",
|
||
"no": "non",
|
||
"cat_cols": ["Appareil", "Protocole", "Canal", "Origine", "Stabilité"],
|
||
"cat_empty": "_Aucun appareil dans cette catégorie._\n",
|
||
"matrix_cols": ["Marque / Modèle", "Protocole", "Canal", "Origine", "Stabilité"],
|
||
"verbatim": "!!! info \"Libellés de l'application\"\n"
|
||
" Les clés et libellés ci-dessous sont ceux affichés par\n"
|
||
" l'application, **en anglais**. Ils sont laissés tels quels pour\n"
|
||
" que vous les retrouviez à l'identique à l'écran.\n",
|
||
"manual_lbl": "Documentation fabricant",
|
||
"manual_word": "Manuel",
|
||
"nightly": "_Prise en charge de **{name}** en cours d'intégration — canal nightly._\n"
|
||
"\n"
|
||
"_La documentation technique sera disponible dès que le plugin\n"
|
||
"atteindra le dépôt stable._\n",
|
||
},
|
||
"de": {
|
||
"manufacturer": "**Hersteller:**",
|
||
"plugin": "**Plugin:**",
|
||
"models_h": "#### Unterstützte Modelle",
|
||
"models_cols": ["Modell", "Rolle", "Transport", "Einbindung", "Messwerte"],
|
||
"detail_h": "#### Detail je Modell",
|
||
"discovery_lbl": "_Erkennungsparameter:_",
|
||
"discovery_cols": ["Schlüssel", "Bezeichnung", "Typ", "Bereich", "Standard"],
|
||
"settings_lbl": "_Einstellungen:_",
|
||
"settings_cols": ["Schlüssel", "Bezeichnung", "Typ", "Bereich", "Standard", "Nur lesen"],
|
||
"states_lbl": "_Gemessene Größen:_",
|
||
"states_cols": ["Schlüssel", "Größe", "Typ", "Einheit"],
|
||
"yes": "ja",
|
||
"no": "nein",
|
||
"cat_cols": ["Gerät", "Protokoll", "Kanal", "Herkunft", "Stabilität"],
|
||
"cat_empty": "_Kein Gerät in dieser Kategorie._\n",
|
||
"matrix_cols": ["Marke / Modell", "Protokoll", "Kanal", "Herkunft", "Stabilität"],
|
||
"verbatim": "!!! info \"Bezeichnungen der Anwendung\"\n"
|
||
" Die Schlüssel und Bezeichnungen unten sind die der Anwendung,\n"
|
||
" **auf Englisch**. Sie bleiben unverändert, damit Sie sie auf dem\n"
|
||
" Bildschirm wortgleich wiederfinden.\n",
|
||
"manual_lbl": "Herstellerdokumentation",
|
||
"manual_word": "Handbuch",
|
||
"nightly": "_Unterstützung für **{name}** wird gerade integriert — Kanal nightly._\n"
|
||
"\n"
|
||
"_Die technische Dokumentation folgt, sobald das Plugin das\n"
|
||
"stabile Repository erreicht._\n",
|
||
},
|
||
"en": {
|
||
"manufacturer": "**Manufacturer:**",
|
||
"plugin": "**Plugin:**",
|
||
"models_h": "#### Supported models",
|
||
"models_cols": ["Model", "Role", "Transport", "Pairing", "Quantities"],
|
||
"detail_h": "#### Per-model detail",
|
||
"discovery_lbl": "_Discovery parameters:_",
|
||
"discovery_cols": ["Key", "Label", "Type", "Range", "Default"],
|
||
"settings_lbl": "_Settings:_",
|
||
"settings_cols": ["Key", "Label", "Type", "Range", "Default", "Read-only"],
|
||
"states_lbl": "_Measured quantities:_",
|
||
"states_cols": ["Key", "Quantity", "Type", "Unit"],
|
||
"yes": "yes",
|
||
"no": "no",
|
||
"cat_cols": ["Device", "Protocol", "Channel", "Origin", "Stability"],
|
||
"cat_empty": "_No device in this category._\n",
|
||
"matrix_cols": ["Brand / Model", "Protocol", "Channel", "Origin", "Stability"],
|
||
"verbatim": "!!! info \"Application labels\"\n"
|
||
" The keys and labels below are the ones the application shows,\n"
|
||
" **in English**. They are left verbatim so that you find them\n"
|
||
" word for word on screen.\n",
|
||
"manual_lbl": "Manufacturer documentation",
|
||
"manual_word": "Manual",
|
||
"nightly": "_Support for **{name}** is being integrated — nightly channel._\n"
|
||
"\n"
|
||
"_Technical documentation will follow once the plugin reaches the\n"
|
||
"stable repository._\n",
|
||
},
|
||
}
|
||
|
||
CATEGORY_LABELS = {
|
||
"compteur": "Compteurs",
|
||
"irve": "Bornes de recharge",
|
||
"smartdevice": "SmartDevices",
|
||
"hvac": "HVAC",
|
||
"onduleur": "Onduleurs / PV",
|
||
"batterie": "Batteries / ESS",
|
||
"tarif": "Tarifs & prévisions",
|
||
"generic": "Types génériques",
|
||
}
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# PROSE PAR LANGUE — ce qui entoure le bloc de référence
|
||
#
|
||
# Le bloc de référence est en anglais une seule fois (REF ci-dessus) ; ce qui
|
||
# l'entoure — titre de catégorie, intertitre « Référence », mention de guide à
|
||
# venir — est de la PROSE et se traduit. Sans ça, chaque nouvel appareil ajouté
|
||
# réintroduirait du français dans /de/ et /en/ par le simple fait d'être créé.
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
LOCALES = ["fr", "de", "en"] # fr = langue par défaut (fichier sans suffixe)
|
||
DEFAULT_LOCALE = "fr"
|
||
|
||
SCAFFOLD = {
|
||
"fr": {"ref_h": "## Référence", "guide_todo": "_Guide d'installation à venir._"},
|
||
"de": {"ref_h": "## Referenz", "guide_todo": "_Installationsanleitung folgt._"},
|
||
"en": {"ref_h": "## Reference", "guide_todo": "_Installation guide coming soon._"},
|
||
}
|
||
|
||
CATEGORY_LABELS_I18N = {
|
||
"fr": CATEGORY_LABELS,
|
||
"de": {
|
||
"compteur": "Zähler", "irve": "Wallboxen", "smartdevice": "SmartDevices",
|
||
"hvac": "HVAC", "onduleur": "Wechselrichter / PV", "batterie": "Speicher / ESS",
|
||
"tarif": "Tarife & Prognosen", "generic": "Generische Typen",
|
||
},
|
||
"en": {
|
||
"compteur": "Meters", "irve": "EV chargers", "smartdevice": "SmartDevices",
|
||
"hvac": "HVAC", "onduleur": "Inverters / PV", "batterie": "Batteries / ESS",
|
||
"tarif": "Tariffs & forecasts", "generic": "Generic types",
|
||
},
|
||
}
|
||
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# TRADUCTIONS DES TITRES ET TAGLINES — côté DOC, pas côté amont
|
||
#
|
||
# Les `name` viennent de PORTING_STATUS.yaml et les `tagline` des meta.json des
|
||
# cinq dépôts plugins. Trois de ces cinq sont des dépôts nymea AMONT, que
|
||
# l'AGENTS.md du workspace interdit de modifier — les traductions ne peuvent donc
|
||
# pas y vivre. Elles vivent ici, dans `i18n/devices.yaml`, et le générateur
|
||
# retombe sur l'amont pour toute entrée absente.
|
||
#
|
||
# Forme : <plugin>:
|
||
# name: {fr: …, de: …, en: …}
|
||
# tagline: {fr: …, de: …, en: …}
|
||
# Toute langue omise retombe sur la source amont.
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
DEVICE_I18N: dict = {}
|
||
|
||
|
||
def load_device_i18n(root: Path) -> dict:
|
||
"""Charge i18n/devices.yaml. Absent = pas de traduction, on garde l'amont."""
|
||
path = root / "i18n" / "devices.yaml"
|
||
if not path.exists():
|
||
return {}
|
||
raw = path.read_text(encoding="utf-8").strip()
|
||
if not raw:
|
||
return {}
|
||
try:
|
||
return yaml.safe_load(raw) or {}
|
||
except yaml.YAMLError as exc:
|
||
sys.exit(f"ERREUR : i18n/devices.yaml non parsable ({exc})")
|
||
|
||
|
||
def i18n_field(plugin: str, field: str, locale: str) -> str | None:
|
||
"""Traduction d'un champ pour une langue, ou None s'il n'y en a pas."""
|
||
entry = DEVICE_I18N.get(plugin) or {}
|
||
val = (entry.get(field) or {}).get(locale)
|
||
return val.strip() if isinstance(val, str) and val.strip() else None
|
||
|
||
|
||
def locale_of(md: Path) -> str:
|
||
"""Langue d'un fichier d'après son suffixe : x.de.md → de, x.md → défaut.
|
||
|
||
C'est la pièce qui rend l'injection sensible à la langue : sans elle, le
|
||
même bloc généré partait à l'identique dans les trois versions.
|
||
"""
|
||
parts = md.name.split(".")
|
||
if len(parts) >= 3 and parts[-2] in REF_I18N:
|
||
return parts[-2]
|
||
return DEFAULT_LOCALE
|
||
|
||
|
||
def loc_name(stem: str, locale: str) -> str:
|
||
"""Nom de fichier mkdocs-static-i18n en mode `suffix` : `x.md`, `x.de.md`, `x.en.md`."""
|
||
return f"{stem}.md" if locale == DEFAULT_LOCALE else f"{stem}.{locale}.md"
|
||
|
||
|
||
CATEGORY_FOLDER = {
|
||
"compteur": "compteurs",
|
||
"irve": "bornes",
|
||
"smartdevice": "smart",
|
||
"hvac": "hvac",
|
||
"onduleur": "onduleurs",
|
||
"batterie": "batteries",
|
||
"tarif": "tarifs",
|
||
"generic": "generic",
|
||
}
|
||
|
||
CATEGORY_ORDER = [
|
||
"compteur", "irve", "smartdevice", "hvac", "onduleur", "batterie", "tarif", "generic",
|
||
]
|
||
|
||
CHANNEL_BADGES = {
|
||
"stable": '<span class="badge stable">STABLE</span>',
|
||
"testing": '<span class="badge testing">TESTING</span>',
|
||
"nightly": '<span class="badge nightly">NIGHTLY</span>',
|
||
}
|
||
|
||
STABILITY_BADGES = {
|
||
"consumer": '<span class="badge consumer">CONSUMER</span>',
|
||
"community": '<span class="badge community">COMMUNITY</span>',
|
||
"experimental": '<span class="badge experimental">EXPERIMENTAL</span>',
|
||
}
|
||
|
||
ORIGIN_BADGES = {
|
||
"nymea": '<span class="badge origin-nymea">NYMEA</span>',
|
||
"etm": '<span class="badge origin-etm">ETM</span>',
|
||
"tiers": '<span class="badge origin-tiers">TIERS</span>',
|
||
}
|
||
|
||
|
||
def resolve_origin(e: dict) -> str:
|
||
"""Détermine l'origine depuis le champ explicite `origin:` ou depuis `repo:`."""
|
||
if e.get("origin"):
|
||
return e["origin"]
|
||
repo = e.get("repo", "")
|
||
if repo.startswith("nymea-"):
|
||
return "nymea"
|
||
if repo.startswith("etm-"):
|
||
return "etm"
|
||
return "tiers"
|
||
|
||
def render_header_badges(e: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Badges d'en-tête (canal, origine, stabilité) PUIS le tagline.
|
||
|
||
Le tagline est entré dans ce bloc généré le 2026-10-13. Il vivait avant en
|
||
paragraphe libre, écrit une seule fois à la création de la page : il restait
|
||
donc dans la langue de l'amont (anglais) sur les trois versions — y compris
|
||
la française. Dans le bloc, il est régénéré, donc traduisible pour toujours.
|
||
"""
|
||
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
|
||
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "")
|
||
stability = meta.get("stability", "")
|
||
stability_badge = STABILITY_BADGES.get(stability, "") if stability else ""
|
||
parts = [b for b in [channel_badge, origin_badge, stability_badge] if b]
|
||
out = " ".join(parts) + "\n"
|
||
tagline = i18n_field(e.get("plugin", ""), "tagline", locale) or meta.get("tagline", "")
|
||
if tagline:
|
||
out += "\n" + tagline.strip() + "\n"
|
||
return out
|
||
|
||
|
||
MARKER_RE = re.compile(
|
||
r"(<!-- BEGIN GENERATED: (?P<key>[^\s]+) -->)(?P<body>.*?)(<!-- END GENERATED -->)",
|
||
re.DOTALL,
|
||
)
|
||
|
||
|
||
# ── Chargement ────────────────────────────────────────────────────────────────
|
||
|
||
def load_porting_status(root: Path) -> list:
|
||
path = root / "PORTING_STATUS.yaml"
|
||
if not path.exists():
|
||
sys.exit(f"ERREUR : PORTING_STATUS.yaml introuvable à {path}")
|
||
with open(path) as f:
|
||
return yaml.safe_load(f)
|
||
|
||
|
||
def load_plugins(src: Path) -> dict:
|
||
"""Charge tous les integrationplugin*.json trouvés récursivement.
|
||
|
||
Logue explicitement chaque fichier vide ou non parsable (ne plante pas).
|
||
"""
|
||
plugins: dict = {}
|
||
for f in sorted(src.rglob("integrationplugin*.json")):
|
||
if f.name in plugins:
|
||
continue
|
||
raw = f.read_text(encoding="utf-8").strip()
|
||
if not raw:
|
||
print(f" AVERTISSEMENT : {f} est vide — ignoré", file=sys.stderr)
|
||
continue
|
||
try:
|
||
plugins[f.name] = json.loads(raw)
|
||
except json.JSONDecodeError as exc:
|
||
print(f" AVERTISSEMENT : {f} non parsable ({exc}) — ignoré", file=sys.stderr)
|
||
return plugins
|
||
|
||
|
||
def load_meta(src: Path, plugin: str) -> dict:
|
||
"""Charge meta.json depuis le même dossier que integrationplugin<plugin>.json.
|
||
|
||
Logue explicitement si le fichier est vide ou non parsable.
|
||
"""
|
||
for f in sorted(src.rglob(f"integrationplugin{plugin}.json")):
|
||
meta_path = f.parent / "meta.json"
|
||
if not meta_path.exists():
|
||
return {}
|
||
raw = meta_path.read_text(encoding="utf-8").strip()
|
||
if not raw:
|
||
print(f" AVERTISSEMENT : {meta_path} est vide — meta ignoré", file=sys.stderr)
|
||
return {}
|
||
try:
|
||
return json.loads(raw)
|
||
except json.JSONDecodeError as exc:
|
||
print(f" AVERTISSEMENT : {meta_path} non parsable ({exc}) — meta ignoré",
|
||
file=sys.stderr)
|
||
return {}
|
||
return {}
|
||
|
||
|
||
# ── Libellé d'un appareil ────────────────────────────────────────────────────
|
||
|
||
def entry_name(e: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Titre : i18n/devices.yaml > PORTING_STATUS.name > meta.json.title > plugin.
|
||
|
||
La traduction passe AVANT PORTING_STATUS.name, sinon un nom français
|
||
(« Compteur ABB B2x ») s'afficherait tel quel sous /de/ et /en/.
|
||
"""
|
||
return (i18n_field(e.get("plugin", ""), "name", locale)
|
||
or e.get("name") or meta.get("title") or e.get("plugin", "?"))
|
||
|
||
|
||
def render_manual_url(e: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Section 'Documentation fabricant' si manual_url est présent. Libellé traduit."""
|
||
url = e.get("manual_url", "")
|
||
if not url:
|
||
return ""
|
||
R = REF_I18N[locale]
|
||
name = entry_name(e, meta, locale)
|
||
return f"\n**{R['manual_lbl']}** : [{R['manual_word']} {name}]({url})\n"
|
||
|
||
|
||
# ── Helpers JSON nymea ────────────────────────────────────────────────────────
|
||
|
||
def transport_of(tc: dict) -> str:
|
||
params = {p["name"] for p in tc.get("paramTypes", [])} | \
|
||
{p["name"] for p in tc.get("discoveryParamTypes", [])}
|
||
ifaces = set(tc.get("interfaces", []))
|
||
if "networkdevice" in ifaces or {"hostName", "address", "port", "macAddress"} & params:
|
||
return "Modbus TCP"
|
||
if {"modbusMasterUuid", "rtuMaster"} & params:
|
||
return "Modbus RTU"
|
||
return "—"
|
||
|
||
|
||
def roles(tc: dict) -> str:
|
||
r = [INTERFACE_ROLE.get(i, i) for i in tc.get("interfaces", []) if i not in TECH_INTERFACES]
|
||
return ", ".join(r) if r else "—"
|
||
|
||
|
||
def add_method(tc: dict) -> str:
|
||
return " / ".join(CREATE_METHOD.get(m, m) for m in tc.get("createMethods", [])) or "—"
|
||
|
||
|
||
def resolve_protocol(plugin_data: dict | None) -> str:
|
||
if not plugin_data:
|
||
return "—"
|
||
for v in plugin_data.get("vendors", []):
|
||
for tc in v.get("thingClasses", []):
|
||
t = transport_of(tc)
|
||
if t != "—":
|
||
return t
|
||
return "—"
|
||
|
||
|
||
# ── Rendu Markdown ────────────────────────────────────────────────────────────
|
||
|
||
def fmt_range(p: dict) -> str:
|
||
lo, hi = p.get("minValue"), p.get("maxValue")
|
||
if lo is not None and hi is not None:
|
||
return f"{lo}–{hi}"
|
||
return "—"
|
||
|
||
|
||
def fmt_default(p: dict) -> str:
|
||
d = p.get("defaultValue", "")
|
||
if d == "" or d is None:
|
||
return "—"
|
||
return f"`{d}`"
|
||
|
||
|
||
def md_table(headers: list, rows: list) -> str:
|
||
out = ["| " + " | ".join(headers) + " |",
|
||
"| " + " | ".join("---" for _ in headers) + " |"]
|
||
for r in rows:
|
||
out.append("| " + " | ".join(str(c) for c in r) + " |")
|
||
return "\n".join(out)
|
||
|
||
|
||
def render_params(tc: dict, locale: str = DEFAULT_LOCALE) -> list:
|
||
R = REF_I18N[locale]
|
||
lines = []
|
||
disc = tc.get("discoveryParamTypes", [])
|
||
if disc:
|
||
rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""),
|
||
fmt_range(p), fmt_default(p)] for p in disc]
|
||
lines.append(R["discovery_lbl"])
|
||
lines.append("")
|
||
lines.append(md_table(R["discovery_cols"], rows))
|
||
lines.append("")
|
||
settings = tc.get("paramTypes", [])
|
||
if settings:
|
||
rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""),
|
||
fmt_range(p), fmt_default(p),
|
||
R["yes"] if p.get("readOnly") else R["no"]] for p in settings]
|
||
lines.append(R["settings_lbl"])
|
||
lines.append("")
|
||
lines.append(md_table(R["settings_cols"], rows))
|
||
lines.append("")
|
||
return lines
|
||
|
||
|
||
def render_states(tc: dict, locale: str = DEFAULT_LOCALE) -> list:
|
||
R = REF_I18N[locale]
|
||
states = tc.get("stateTypes", [])
|
||
if not states:
|
||
return []
|
||
rows = [[f"`{s['name']}`", s.get("displayName", ""), s.get("type", ""),
|
||
s.get("unit") or "—"] for s in states]
|
||
return [md_table(R["states_cols"], rows), ""]
|
||
|
||
|
||
def render_plugin(plugin: dict, locale: str = DEFAULT_LOCALE) -> str:
|
||
R = REF_I18N[locale]
|
||
out = []
|
||
# La convention de verbatim, dite au lecteur dans sa langue, AVANT les
|
||
# tableaux : sans elle le mélange FR/EN passe pour une traduction bâclée.
|
||
out.append(R["verbatim"])
|
||
vendors = plugin.get("vendors", [])
|
||
vname = ", ".join(v.get("displayName", v.get("name", "")) for v in vendors)
|
||
out.append(f'{R["manufacturer"]} {vname} ')
|
||
out.append(f'{R["plugin"]} `{plugin.get("name", "")}`')
|
||
out.append("")
|
||
|
||
tcs = [(v, tc) for v in vendors for tc in v.get("thingClasses", [])]
|
||
rows = [[f"**{tc.get('displayName', tc.get('name'))}**",
|
||
roles(tc), transport_of(tc), add_method(tc),
|
||
str(len(tc.get("stateTypes", [])))]
|
||
for _, tc in tcs]
|
||
out.append(R["models_h"])
|
||
out.append(md_table(R["models_cols"], rows))
|
||
out.append("")
|
||
|
||
out.append(R["detail_h"])
|
||
for _, tc in tcs:
|
||
title = tc.get("displayName", tc.get("name"))
|
||
out.append(f'??? abstract "{title} — `{tc.get("name")}`"')
|
||
body = []
|
||
body += render_params(tc, locale)
|
||
if tc.get("stateTypes"):
|
||
body.append(R["states_lbl"])
|
||
body.append("")
|
||
body += render_states(tc, locale)
|
||
for ln in ("\n".join(body)).splitlines():
|
||
out.append(" " + ln if ln else "")
|
||
out.append("")
|
||
return "\n".join(out).rstrip() + "\n"
|
||
|
||
|
||
def render_nightly_stub(entry: dict, meta: dict, locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Contenu généré pour une entrée nightly dont le JSON est absent."""
|
||
name = entry_name(entry, meta, locale)
|
||
return REF_I18N[locale]["nightly"].format(name=name)
|
||
|
||
|
||
def render_category_index(cat: str, entries: list, plugins: dict, src: Path,
|
||
locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Tableau listant les appareils d'une catégorie, pour l'index.md auto-généré."""
|
||
R = REF_I18N[locale]
|
||
cat_entries = [e for e in entries if e.get("category") == cat and e.get("plugin")]
|
||
if not cat_entries:
|
||
return R["cat_empty"]
|
||
|
||
rows = []
|
||
for e in cat_entries:
|
||
fname = f"integrationplugin{e['plugin']}.json"
|
||
plugin_data = plugins.get(fname)
|
||
meta = load_meta(src, e["plugin"])
|
||
name = entry_name(e, meta, locale)
|
||
slug = e.get("slug") or e["plugin"]
|
||
protocol = resolve_protocol(plugin_data) if plugin_data else "—"
|
||
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
|
||
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "—")
|
||
stability = meta.get("stability", "")
|
||
stability_badge = STABILITY_BADGES.get(stability, "—") if stability else "—"
|
||
rows.append([f"[{name}]({slug}.md)", protocol, channel_badge, origin_badge,
|
||
stability_badge])
|
||
|
||
return md_table(R["cat_cols"], rows) + "\n"
|
||
|
||
|
||
def render_matrix(entries: list, plugins: dict, src: Path,
|
||
locale: str = DEFAULT_LOCALE) -> str:
|
||
R = REF_I18N[locale]
|
||
by_cat: dict = {}
|
||
for e in entries:
|
||
by_cat.setdefault(e.get("category", "autre"), []).append(e)
|
||
|
||
lines = []
|
||
ordered_cats = [c for c in CATEGORY_ORDER if c in by_cat]
|
||
ordered_cats += [c for c in by_cat if c not in CATEGORY_ORDER]
|
||
|
||
for cat in ordered_cats:
|
||
label = CATEGORY_LABELS.get(cat, cat.capitalize())
|
||
cat_rows = []
|
||
for e in by_cat[cat]:
|
||
fname = f"integrationplugin{e['plugin']}.json" if e.get("plugin") else None
|
||
plugin_data = plugins.get(fname) if fname else None
|
||
if e["channel"] == "nightly" and (not fname or fname not in plugins):
|
||
print(f" INFO matrice : {e.get('plugin', '?')} nightly sans JSON "
|
||
f"→ ligne stub dans la matrice", file=sys.stderr)
|
||
meta = load_meta(src, e["plugin"]) if e.get("plugin") else {}
|
||
name = entry_name(e, meta, locale)
|
||
protocol = resolve_protocol(plugin_data)
|
||
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
|
||
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "—")
|
||
stability = meta.get("stability", "")
|
||
stability_badge = STABILITY_BADGES.get(stability, "—") if stability else "—"
|
||
cat_rows.append(
|
||
f"| {name} | {protocol} | {channel_badge} | {origin_badge} | {stability_badge} |"
|
||
)
|
||
if not cat_rows:
|
||
continue
|
||
lines.append(f"## {label}\n")
|
||
lines.append("| " + " | ".join(R["matrix_cols"]) + " |")
|
||
lines.append("|---|---|---|---|---|")
|
||
lines.extend(cat_rows)
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ── SUMMARY.md pour mkdocs-literate-nav ──────────────────────────────────────
|
||
|
||
def generate_summary(entries: list, docs: Path, src: Path, plugins: dict,
|
||
check: bool, locale: str | None = None) -> int:
|
||
"""Écrit le SUMMARY.md d'une langue. Retourne 1 en mode --check si périmé.
|
||
|
||
`locale` vaut DEFAULT_LOCALE par défaut ; une autre langue écrit
|
||
`SUMMARY.<locale>.md`, que mkdocs-static-i18n substitue au moment de son
|
||
build. Sans ça, la barre latérale allemande afficherait les noms français
|
||
des appareils — c'est ce qu'elle faisait pour « Compteur ABB B2x ».
|
||
"""
|
||
locale = locale or DEFAULT_LOCALE
|
||
by_cat: dict = {}
|
||
for e in entries:
|
||
by_cat.setdefault(e.get("category", "autre"), []).append(e)
|
||
|
||
compat = {"fr": "Compatibilité", "de": "Kompatibilität", "en": "Compatibility"}[locale]
|
||
lines = [f"* [{compat}](compatibilite.md)"]
|
||
|
||
ordered_cats = [c for c in CATEGORY_ORDER if c in by_cat]
|
||
ordered_cats += [c for c in by_cat if c not in CATEGORY_ORDER]
|
||
|
||
for cat in ordered_cats:
|
||
label = CATEGORY_LABELS_I18N[locale].get(cat, cat.capitalize())
|
||
folder = CATEGORY_FOLDER.get(cat, cat)
|
||
cat_entries = []
|
||
for e in by_cat[cat]:
|
||
if not e.get("plugin"):
|
||
continue
|
||
meta = load_meta(src, e["plugin"])
|
||
name = entry_name(e, meta, locale)
|
||
slug = e.get("slug") or e["plugin"]
|
||
cat_entries.append(f" * [{name}]({folder}/{slug}.md)")
|
||
if cat_entries:
|
||
lines.append(f"* [{label}]({folder}/index.md)")
|
||
lines.extend(cat_entries)
|
||
|
||
new_content = "\n".join(lines) + "\n"
|
||
summary_path = docs / "appareils" / loc_name("SUMMARY", locale)
|
||
|
||
if check:
|
||
if not summary_path.exists() or summary_path.read_text(encoding="utf-8") != new_content:
|
||
print(f" SUMMARY.md périmé : {summary_path.relative_to(docs.parent)}")
|
||
return 1
|
||
return 0
|
||
|
||
summary_path.write_text(new_content, encoding="utf-8")
|
||
print(f"SUMMARY.md → {summary_path.relative_to(docs.parent)}")
|
||
return 0
|
||
|
||
|
||
# ── Validation ────────────────────────────────────────────────────────────────
|
||
|
||
def validate_entries(entries: list, plugins: dict, src: Path) -> None:
|
||
errors = []
|
||
for e in entries:
|
||
if not e.get("plugin"):
|
||
continue
|
||
fname = f"integrationplugin{e['plugin']}.json"
|
||
|
||
if e["channel"] != "nightly":
|
||
if fname not in plugins:
|
||
errors.append(
|
||
f" BLOQUANT : {fname} introuvable dans --src "
|
||
f"(channel={e['channel']}, plugin={e['plugin']})"
|
||
)
|
||
meta = load_meta(src, e["plugin"])
|
||
if not e.get("name") and not meta.get("title"):
|
||
errors.append(
|
||
f" BLOQUANT : libellé manquant pour plugin={e['plugin']} "
|
||
f"(ni PORTING_STATUS.name ni meta.json.title)"
|
||
)
|
||
|
||
if errors:
|
||
sys.exit("ERREURS BLOQUANTES :\n" + "\n".join(errors))
|
||
|
||
|
||
# ── Construction d'une page complète ─────────────────────────────────────────
|
||
|
||
def build_device_page(e: dict, plugin_data: dict | None, meta: dict,
|
||
locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Construit le contenu complet d'une fiche appareil générée.
|
||
|
||
`locale` ne change QUE la prose (SCAFFOLD) : le bloc de référence est
|
||
identique dans les trois langues, en anglais, par décision du 2026-10-12.
|
||
"""
|
||
sc = SCAFFOLD[locale]
|
||
title = entry_name(e, meta, locale)
|
||
plugin = e["plugin"]
|
||
fname = f"integrationplugin{plugin}.json"
|
||
|
||
badges_gen = render_header_badges(e, meta, locale)
|
||
badges_block = (
|
||
f"<!-- BEGIN GENERATED: __badges_{plugin}__ -->\n{badges_gen}\n<!-- END GENERATED -->"
|
||
)
|
||
|
||
if plugin_data:
|
||
gen = render_plugin(plugin_data, locale) + render_manual_url(e, meta, locale)
|
||
else:
|
||
print(f" INFO : {fname} absent (nightly) → page stub pour {title}",
|
||
file=sys.stderr)
|
||
gen = render_nightly_stub(e, meta, locale)
|
||
|
||
block = f"<!-- BEGIN GENERATED: {fname} -->\n{gen}\n<!-- END GENERATED -->"
|
||
|
||
# Le tagline vit désormais DANS le bloc badges (render_header_badges), donc
|
||
# il n'est plus recopié ici : sinon il apparaîtrait deux fois.
|
||
header_parts = [f"# {title}", "", badges_block]
|
||
header_parts += ["", sc["guide_todo"], "", sc["ref_h"], ""]
|
||
header = "\n".join(header_parts)
|
||
|
||
return header + "\n" + block + "\n"
|
||
|
||
|
||
def build_category_index_page(cat: str, entries: list, plugins: dict, src: Path,
|
||
locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Construit le contenu complet d'un index.md de catégorie."""
|
||
label = CATEGORY_LABELS_I18N[locale].get(cat, cat.capitalize())
|
||
gen = render_category_index(cat, entries, plugins, src, locale)
|
||
# Même format que process() : f"{m.group(1)}\n{gen}\n{m.group(4)}"
|
||
block = f"<!-- BEGIN GENERATED: __index_{cat}__ -->\n{gen}\n<!-- END GENERATED -->"
|
||
return f"---\nhide:\n - toc\n---\n\n# {label}\n\n{block}\n"
|
||
|
||
|
||
# ── Création des fichiers manquants ───────────────────────────────────────────
|
||
|
||
def ensure_device_pages(entries: list, plugins: dict, src: Path,
|
||
docs: Path, check: bool, locales: list | None = None) -> int:
|
||
"""Crée les fiches appareils inexistantes, dans CHAQUE langue.
|
||
|
||
Une fiche créée dans la seule langue par défaut réapparaîtrait en français
|
||
sous /de/ et /en/ par le repli `fallback_to_default` — d'où la boucle.
|
||
Une fiche déjà présente n'est jamais réécrite : la prose traduite à la main
|
||
survit aux régénérations.
|
||
"""
|
||
locales = locales or LOCALES
|
||
rc = 0
|
||
for e in entries:
|
||
if not e.get("plugin"):
|
||
continue
|
||
slug = e.get("slug") or e["plugin"]
|
||
folder = CATEGORY_FOLDER.get(e.get("category", ""), e.get("category", "autre"))
|
||
base = docs / "appareils" / folder
|
||
|
||
fname = f"integrationplugin{e['plugin']}.json"
|
||
plugin_data = plugins.get(fname)
|
||
meta = None
|
||
|
||
for locale in locales:
|
||
md_path = base / loc_name(slug, locale)
|
||
if md_path.exists():
|
||
continue
|
||
if meta is None:
|
||
meta = load_meta(src, e["plugin"])
|
||
content = build_device_page(e, plugin_data, meta, locale)
|
||
|
||
if check:
|
||
print(f" MANQUANT : {md_path.relative_to(docs.parent)}")
|
||
rc = 1
|
||
else:
|
||
md_path.parent.mkdir(parents=True, exist_ok=True)
|
||
md_path.write_text(content, encoding="utf-8")
|
||
print(f" CRÉÉ : {md_path.relative_to(docs.parent)}")
|
||
|
||
return rc
|
||
|
||
|
||
def ensure_category_indexes(entries: list, plugins: dict, src: Path,
|
||
docs: Path, check: bool,
|
||
locales: list | None = None) -> int:
|
||
"""Crée les index.md de catégorie inexistants, dans CHAQUE langue."""
|
||
locales = locales or LOCALES
|
||
cats_present = {e.get("category") for e in entries if e.get("category")}
|
||
rc = 0
|
||
for cat in sorted(cats_present):
|
||
folder = CATEGORY_FOLDER.get(cat, cat)
|
||
base = docs / "appareils" / folder
|
||
|
||
for locale in locales:
|
||
index_path = base / loc_name("index", locale)
|
||
if index_path.exists():
|
||
continue
|
||
|
||
content = build_category_index_page(cat, entries, plugins, src, locale)
|
||
|
||
if check:
|
||
print(f" MANQUANT : {index_path.relative_to(docs.parent)}")
|
||
rc = 1
|
||
else:
|
||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||
index_path.write_text(content, encoding="utf-8")
|
||
print(f" CRÉÉ : {index_path.relative_to(docs.parent)}")
|
||
|
||
return rc
|
||
|
||
|
||
# ── Traitement des fichiers MD (injection dans les marqueurs) ─────────────────
|
||
|
||
def process(docs: Path, entries: list, plugins: dict, src: Path, check: bool) -> int:
|
||
entry_by_plugin = {e["plugin"]: e for e in entries if e.get("plugin")}
|
||
changed = []
|
||
|
||
for md in sorted(docs.rglob("*.md")):
|
||
text = md.read_text(encoding="utf-8")
|
||
if "<!-- BEGIN GENERATED:" not in text:
|
||
continue
|
||
loc = locale_of(md)
|
||
|
||
def repl(m: re.Match) -> str:
|
||
key = m.group("key")
|
||
if key == "__matrix__":
|
||
gen = render_matrix(entries, plugins, src, loc)
|
||
elif key.startswith("__badges_"):
|
||
plugin_name = key.removeprefix("__badges_").removesuffix("__")
|
||
entry = entry_by_plugin.get(plugin_name)
|
||
if not entry:
|
||
print(f" ! {md.name}: clé '{key}' plugin '{plugin_name}' inconnu",
|
||
file=sys.stderr)
|
||
return m.group(0)
|
||
gen = render_header_badges(entry, load_meta(src, plugin_name), loc)
|
||
elif key.startswith("__index_"):
|
||
# key = "__index_<cat>__" → strip prefix "__index_" et suffix "__"
|
||
cat = key.removeprefix("__index_").removesuffix("__")
|
||
gen = render_category_index(cat, entries, plugins, src, loc)
|
||
elif key in plugins:
|
||
plugin_name = key.removeprefix("integrationplugin").removesuffix(".json")
|
||
entry = entry_by_plugin.get(plugin_name, {})
|
||
meta_e = load_meta(src, plugin_name) if entry else {}
|
||
gen = render_plugin(plugins[key], loc) + render_manual_url(entry, meta_e, loc)
|
||
else:
|
||
# Clé plugin absente : nightly sans JSON → stub ; sinon avertissement
|
||
plugin_name = key.removeprefix("integrationplugin").removesuffix(".json")
|
||
entry = entry_by_plugin.get(plugin_name)
|
||
if entry and entry.get("channel") == "nightly":
|
||
print(f" INFO : {key} absent (nightly) → stub dans {md.name}",
|
||
file=sys.stderr)
|
||
gen = render_nightly_stub(entry, load_meta(src, plugin_name), loc)
|
||
else:
|
||
print(f" ! {md.name}: clé '{key}' absente de --src", file=sys.stderr)
|
||
return m.group(0)
|
||
return f"{m.group(1)}\n{gen}\n{m.group(4)}"
|
||
|
||
new = MARKER_RE.sub(repl, text)
|
||
if new != text:
|
||
changed.append(md)
|
||
if not check:
|
||
md.write_text(new, encoding="utf-8")
|
||
|
||
if check:
|
||
if changed:
|
||
print("Doc PAS à jour :", ", ".join(str(c) for c in changed))
|
||
return 1
|
||
print("Doc à jour.")
|
||
return 0
|
||
print(f"Régénéré : {len(changed)} page(s).")
|
||
for c in changed:
|
||
print(f" - {c.relative_to(docs)}")
|
||
return 0
|
||
|
||
|
||
# ── Point d'entrée ────────────────────────────────────────────────────────────
|
||
|
||
def main() -> int:
|
||
global DEFAULT_LOCALE # `--lang` peut la redéfinir (voir plus bas)
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--src", default=".plugins-src", type=Path,
|
||
help="Dossier source des JSON (plat ou avec sous-dossiers repos)")
|
||
ap.add_argument("--docs", default="docs", type=Path)
|
||
# `--lang` existait depuis la CI mais n'était PAS lu (« Conservé pour
|
||
# compatibilité CI »). Il est devenu réel au passage multi-langue : il
|
||
# désigne la langue par défaut, celle dont les fichiers n'ont pas de suffixe.
|
||
ap.add_argument("--lang", default=DEFAULT_LOCALE, choices=LOCALES,
|
||
help=f"Langue par défaut, fichiers sans suffixe (défaut : {DEFAULT_LOCALE})")
|
||
ap.add_argument("--locales", default=",".join(LOCALES),
|
||
help=f"Langues à générer, séparées par des virgules (défaut : {','.join(LOCALES)})")
|
||
ap.add_argument("--check", action="store_true",
|
||
help="CI : exit 1 si la doc n'est pas à jour")
|
||
a = ap.parse_args()
|
||
|
||
src = a.src
|
||
|
||
DEFAULT_LOCALE = a.lang
|
||
locales = [x.strip() for x in a.locales.split(",") if x.strip()]
|
||
unknown = [x for x in locales if x not in SCAFFOLD]
|
||
if unknown:
|
||
print(f"ERREUR : langue(s) sans échafaudage SCAFFOLD : {', '.join(unknown)}",
|
||
file=sys.stderr)
|
||
return 2
|
||
if DEFAULT_LOCALE not in locales:
|
||
print(f"ERREUR : --lang {DEFAULT_LOCALE} absent de --locales {','.join(locales)}",
|
||
file=sys.stderr)
|
||
return 2
|
||
print(f"langues : {', '.join(locales)} · défaut (sans suffixe) : {DEFAULT_LOCALE}")
|
||
|
||
global DEVICE_I18N
|
||
DEVICE_I18N = load_device_i18n(Path.cwd())
|
||
if DEVICE_I18N:
|
||
print(f"i18n appareils : {len(DEVICE_I18N)} entrée(s) traduite(s)")
|
||
|
||
entries = load_porting_status(Path.cwd())
|
||
plugins = load_plugins(src)
|
||
|
||
if not plugins:
|
||
print(f"Aucun integrationplugin*.json trouvé dans {src}", file=sys.stderr)
|
||
return 2
|
||
print(f"{len(plugins)} plugin(s) chargé(s) : {', '.join(plugins)}")
|
||
|
||
validate_entries(entries, plugins, src)
|
||
|
||
rc_pages = ensure_device_pages(entries, plugins, src, a.docs, a.check, locales)
|
||
rc_indexes = ensure_category_indexes(entries, plugins, src, a.docs, a.check, locales)
|
||
# UN SEUL SUMMARY, dans la langue par défaut — et c'est un CONSTAT, pas un
|
||
# oubli. `literate-nav` ne lit que le `nav_file` configuré (`SUMMARY.md`) :
|
||
# la substitution par suffixe de mkdocs-static-i18n ne s'y applique PAS.
|
||
# Testé le 2026-10-13 : un `SUMMARY.de.md` généré n'était lu par personne et
|
||
# la barre latérale allemande gardait les noms français.
|
||
# Les libellés de nav se traduisent donc par `nav_translations` dans
|
||
# mkdocs.yml — c'est le mécanisme que le thème fournit pour ça.
|
||
rc_summary = generate_summary(entries, a.docs, src, plugins, a.check)
|
||
rc_process = process(a.docs, entries, plugins, src, a.check)
|
||
|
||
return max(rc_pages, rc_indexes, rc_summary, rc_process)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|