#!/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 : fiche appareil matrice compatibilité 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 : : # 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": 'STABLE', "testing": 'TESTING', "nightly": 'NIGHTLY', } STABILITY_BADGES = { "consumer": 'CONSUMER', "community": 'COMMUNITY', "experimental": 'EXPERIMENTAL', } ORIGIN_BADGES = { "nymea": 'NYMEA', "etm": 'ETM', "tiers": 'TIERS', } 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"()(?P.*?)()", 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.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..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"\n{badges_gen}\n" ) 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"\n{gen}\n" # 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"\n{gen}\n" 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 "