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.) |
provides_index(directory_path) | Questa directory ha una landing page fornita dall'engine? (Controlla l'emissione di MISSING_DIRECTORY_INDEX.) |
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/standalone).
"""
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.
Step 6 — Dichiarare i Bypass per gli Schemi Link (Opzionale)
Se il tuo engine usa uno schema URI non standard per i link interni, implementa
get_link_scheme_bypasses() per comunicare al Core quali nomi di schema esentare
dal controllo Z105 e dall'errore schema-sconosciuto (Regola R21 — Sovranità del
Protocollo):
def get_link_scheme_bypasses(self) -> frozenset[str]:
"""Restituisce i nomi di schema URI che l'engine usa legittimamente.
Il validator aggiunge ``<schema>:`` alla sua lista di skip per ogni nome
restituito, sopprimendo sia l'avviso schema-sconosciuto sia il controllo
Z105 per gli URL che usano quello schema.
Restituisci ``frozenset()`` se l'engine non ha bypass per schemi link.
"""
return frozenset()
La maggior parte degli engine restituisce frozenset(). Il DocusaurusAdapter
built-in restituisce frozenset({"pathname"}) perché Docusaurus usa link
pathname:/// per riferimenti agli asset statici che bypassano il React router
— il / iniziale nel componente path è un artefatto della convenzione URI, non
un percorso assoluto del server.
Il Core non codifica mai nomi di engine. Il comportamento engine-specifico è
dichiarato nell'adapter e interrogato dal Core tramite questo metodo. Aggiungere
un nuovo adapter che necessita di un bypass per lo schema dei link richiede
zero modifiche a validator.py.
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, usandoslash in avanti, senza
/iniziale. -
get_nav_paths()restituisce solo file.md(altre estensioni sono ignoratedal controllore orfani).
-
is_locale_dir()deve restituireFalseper la locale default. Solole directory di locale non-default devono restituire
True. -
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'argomento
anchors_cacheè in sola lettura; non mutarlo. -
has_engine_config()non deve mai sollevare eccezioni — restituisciFalsein caso di errore. -
provides_index(directory_path)è l'unico metodo che può eseguire I/O.Viene chiamato una volta per directory durante la fase di discovery — mai all'interno dei loop critici per-link o per-file — quindi una singola chiamata
Path.exists()è accettabile. RestituisciTruese il tuo engine genererà una landing page per la directory (es. tramiteindex.md,README.md, o una voce di configurazione dinamica come_category_.jsoncon"link": {"type": "generated-index"}). Non sollevare mai eccezioni — restituisciFalsein caso di errore I/O. -
get_link_scheme_bypasses()deve restituire unfrozenset[str]di nomi dischema (senza i due punti finali) — mai
None, mai sollevare eccezioni. Restituiscifrozenset()se l'engine non ha requisiti di bypass per gli schemi link.
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 -
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.