Scrivere un Adapter Zenzic
Questa guida spiega come creare un adapter di terze parti che insegna a Zenzic a comprendere il layout del progetto, la struttura di navigazione e le convenzioni i18n del tuo motore di documentazione — senza modificare Zenzic stesso.
Cos'è un Adapter?
Un adapter è una classe Python che soddisfa il protocollo BaseAdapter
(src/zenzic/core/adapters/_base.py). Lo scanner, il rilevatore di orfani e il
validatore di link di Zenzic parlano esclusivamente con questo protocollo — non
importano né chiamano mai codice specifico del motore direttamente.
Un adapter risponde a domande per ogni albero docs attraverso due superfici API:
Routing Metadata-Driven (preferito)
| Metodo | Domanda |
|---|---|
get_route_info(rel) | Qual è l'URL canonico, lo stato della route, lo slug e il flag proxy per questo file sorgente? Restituisce un'istanza RouteMetadata. |
Routing Legacy File-to-URL (compatibilità all'indietro)
| Metodo | Domanda |
|---|---|
map_url(rel) | Quale URL canonico produce questo file sorgente? |
classify_route(rel, nav_paths) | Questo file è REACHABLE, ORPHAN_BUT_EXISTING o IGNORED? |
I nuovi adapter dovrebbero implementare get_route_info(). I metodi legacy sono
mantenuti per compatibilità all'indietro — le loro implementazioni di default
delegano a get_route_info().
Metodi Comuni
| Metodo | Domanda |
|---|---|
is_locale_dir(part) | Questa directory è una locale non-default? |
resolve_asset(missing_abs, docs_root) | Esiste un fallback default-locale per questo asset mancante? |
resolve_anchor(resolved_file, anchor, anchors_cache, docs_root) | Questo anchor miss deve essere soppresso perché l'ancora esiste nel file default-locale equivalente? |
is_shadow_of_nav_page(rel, nav_paths) | Questo file è il mirror locale di una pagina nella nav? |
get_ignored_patterns() | Quali glob di filename deve saltare il controllo orfani? |
get_nav_paths() | Quali percorsi .md sono dichiarati nella nav di questo motore? |
has_engine_config() | È stato trovato un file di config del motore? (Controlla l'attivazione del controllo orfani.) |
Step 1 — Creare la Classe Adapter
# my_engine_adapter/adapter.py
from __future__ import annotations
from pathlib import Path
from typing import Any
from zenzic.core.adapters import RouteMetadata
from zenzic.models.vsm import RouteStatus
class MyEngineAdapter:
"""Adapter per i progetti di documentazione MyEngine."""
def __init__(
self,
config: dict[str, Any],
docs_root: Path,
) -> None:
self._docs_root = docs_root
self._config = config
# Estrai ciò che il formato config del tuo motore fornisce.
self._nav_paths: frozenset[str] = self._parse_nav()
# ── Protocollo BaseAdapter ─────────────────────────────────────────────
def is_locale_dir(self, part: str) -> bool:
"""Restituisce True quando *part* è una directory locale non-default.
Se il tuo motore non supporta i18n, restituisci sempre False.
"""
locales: list[str] = self._config.get("locales", [])
return part in locales
def resolve_asset(self, missing_abs: Path, docs_root: Path) -> Path | None:
"""Restituisce il percorso fallback default-locale per un asset locale mancante.
Se il tuo motore non supporta il fallback i18n per asset, restituisci sempre None.
"""
return None
def resolve_anchor(
self,
resolved_file: Path,
anchor: str,
anchors_cache: dict[Path, set[str]],
docs_root: Path,
) -> bool:
"""Restituisce True se un anchor miss su un file locale deve essere soppresso.
Viene chiamato quando un link punta a un'ancora heading che esiste nel
file default-locale ma non nella traduzione locale (perché le intestazioni
sono tradotte). Restituisci True per sopprimere il falso positivo.
Se il tuo motore non supporta i18n, restituisci sempre False.
"""
return False
def has_engine_config(self) -> bool:
"""Restituisce True quando un config del motore è stato trovato e caricato.
Quando False, il controllo orfani viene saltato — senza informazioni nav
non c'è un insieme di riferimento contro cui confrontare la lista dei file.
Restituisci True se il tuo adapter ha caricato con successo un file di config.
Restituisci False solo se non esiste alcun config del motore (modalità bare/vanilla).
"""
return bool(self._config)
def is_shadow_of_nav_page(self, rel: Path, nav_paths: frozenset[str]) -> bool:
"""Restituisce True quando *rel* è un mirror locale di una pagina nella nav.
Esempio: docs/fr/guide/index.md è shadow di guide/index.md.
Se il tuo motore non supporta i18n, restituisci sempre False.
"""
if not rel.parts or not self.is_locale_dir(rel.parts[0]):
return False
default_rel = Path(*rel.parts[1:]).as_posix()
return default_rel in nav_paths
def get_ignored_patterns(self) -> set[str]:
"""Restituisce i glob pattern per file che il controllo orfani deve saltare.
Per plugin i18n in modalità suffisso, restituisci pattern come {'*.fr.md', '*.it.md'}.
"""
return set()
def get_nav_paths(self) -> frozenset[str]:
"""Restituisce l'insieme dei percorsi .md nella nav del motore, relativi a docs_root."""
return self._nav_paths
# ── Routing Metadata-Driven ────────────────────────────────────────────
def get_route_info(self, rel: Path) -> RouteMetadata:
"""Restituisce i metadati di routing unificati per un file sorgente.
Questo singolo metodo sostituisce il double dispatch legacy
map_url() + classify_route(). Il builder VSM chiama questo metodo
per costruire RouteMetadata per ogni file sorgente in un unico passaggio.
"""
posix = rel.as_posix()
# Determina la raggiungibilità dalla config nav.
if posix in self._nav_paths:
status: RouteStatus = "REACHABLE"
else:
status = "ORPHAN_BUT_EXISTING"
# Calcola l'URL canonico (adatta alle regole di routing del tuo motore).
stem = rel.with_suffix("").as_posix()
canonical_url = f"/{stem}/"
return RouteMetadata(
canonical_url=canonical_url,
status=status,
)
# ── Helper privati ─────────────────────────────────────────────────────
def _parse_nav(self) -> frozenset[str]:
nav = self._config.get("nav", [])
paths: set[str] = set()
for entry in nav:
if isinstance(entry, str) and entry.endswith(".md"):
paths.add(entry.lstrip("/"))
return frozenset(paths)
Step 2 — Registrazione via Entry Point
Zenzic scopre gli adapter tramite il gruppo di entry-point zenzic.adapters.
Registra il tuo adapter nel pyproject.toml del tuo pacchetto:
[project.entry-points."zenzic.adapters"]
myengine = "my_engine_adapter.adapter:MyEngineAdapter"
La chiave (a sinistra di =) diventa il nome del motore che gli utenti
passano a --engine o impostano come engine in zenzic.toml:
# Nel zenzic.toml dell'utente
[build_context]
engine = "myengine"
Step 3 — Implementare il Factory Hook (Opzionale)
Di default, Zenzic istanzia il tuo adapter chiamando:
adapter_class(context, docs_root, config_dict)
dove context è un'istanza BuildContext e config_dict è il config del
motore parsato (o {} se la discovery è fallita).
Se il tuo adapter necessita di una signature del costruttore diversa, implementa
un classmethod from_repo(context, docs_root, repo_root) e Zenzic lo preferirà:
@classmethod
def from_repo(
cls,
context: "BuildContext",
docs_root: Path,
repo_root: Path,
) -> "MyEngineAdapter":
config_path = repo_root / "myengine.toml"
config = {}
if config_path.exists():
import tomllib
with config_path.open("rb") as f:
config = tomllib.load(f)
return cls(config, docs_root)
Step 4 — Validare con Zenzic
Dopo aver installato il tuo pacchetto (uv pip install -e . o pip install -e .),
verifica che l'adapter venga scoperto:
# Elenca tutti gli adapter installati
zenzic check orphans --engine myengine --help
# Esegui contro un vero albero docs
zenzic check orphans --engine myengine
zenzic check all --engine myengine
Step 5 — Le Regole Custom sono Indipendenti dal Motore
Le [[custom_rules]] in zenzic.toml operano sul sorgente Markdown grezzo e
sono completamente disaccoppiate dal layer adapter. Una regola che cerca DRAFT
si attiverà identicamente sia che l'adapter sia MkDocs, Zensical o il tuo
motore. Non è richiesto alcun lavoro aggiuntivo per rendere le regole custom
compatibili con un nuovo adapter.
Garanzie del Contratto Adapter
Il tuo adapter deve soddisfare queste invarianti, altrimenti lo scanner di Zenzic potrebbe produrre risultati errati:
get_route_info()deve restituire unRouteMetadataconcanonical_urlche inizia e termina con/.get_route_info()deve impostarestatusa uno traREACHABLE,ORPHAN_BUT_EXISTINGoIGNORED. Non restituire maiCONFLICT— quello stato viene assegnato successivamente da_detect_collisions().get_nav_paths()restituisce percorsi relativi adocs_root, usando slash in avanti, senza/iniziale.get_nav_paths()restituisce solo file.md(altre estensioni sono ignorate dal controllore orfani).is_locale_dir()deve restituireFalseper la locale default. Solo le directory di locale non-default devono restituireTrue.- Tutti i metodi devono essere puri: stessi input producono sempre gli stessi output. Nessun I/O, nessuna mutazione di stato globale.
resolve_asset()non deve mai sollevare eccezioni — restituisciNonein caso di errore.resolve_anchor()non deve mai sollevare eccezioni — restituisciFalsein caso di errore. L'argomentoanchors_cacheè in sola lettura; non mutarlo.has_engine_config()non deve mai sollevare eccezioni — restituisciFalsein caso di errore.
Testare il Tuo Adapter
Usa zenzic.core.adapters.BaseAdapter come target di tipizzazione nei tuoi
test per verificare la conformità al protocollo:
from zenzic.core.adapters import BaseAdapter
from my_engine_adapter.adapter import MyEngineAdapter
def test_satisfies_protocol() -> None:
adapter = MyEngineAdapter(config={}, docs_root=Path("/tmp/docs"))
assert isinstance(adapter, BaseAdapter)
def test_nav_paths_relative() -> None:
adapter = MyEngineAdapter(
config={"nav": ["index.md", "guide/setup.md"]},
docs_root=Path("/tmp/docs"),
)
paths = adapter.get_nav_paths()
assert "index.md" in paths
assert "guide/setup.md" in paths
assert all(not p.startswith("/") for p in paths)
Collega il codice dell'adapter alla verità operativa del progetto:
- Registra l'identità engine nella configurazione del progetto tramite
[build_context] engine(vedi Adapter e Configurazione del Motore). - Valida il comportamento dell'adapter in policy Sentinel strict:
zenzic check all --engine myengine --strict. Per i controlli di esecuzione, vedi Comandi CLI: Flag globali. - Se il tuo engine genera route locali sintetiche, mappa esplicitamente le aspettative Ghost Route rispetto al riferimento VSM: Riferimento Controlli — VSM.