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 estende la classe base astratta BaseAdapter
(src/zenzic/core/adapters/_base.py). Lo scanner, il rilevatore di orfani e il
validatore di link di Zenzic parlano esclusivamente con questa interfaccia — non
importano né chiamano mai codice specifico del motore direttamente.
Un adapter risponde a domande per ogni albero docs attraverso una singola superficie API:
Routing Metadata-Driven
| 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. |
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.core.adapters._base import BaseAdapter
from zenzic.models.vsm import RouteStatus
class MyEngineAdapter(BaseAdapter):
"""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
def get_metadata_files(self) -> frozenset[str]:
"""Restituisce i file metadata del motore da ignorare nei finding."""
return frozenset({"myengine.toml"})
def provides_index(self, directory_path: Path) -> bool:
"""Restituisce se questo motore espone una pagina index per la directory."""
index_rel = (directory_path / "index.md").as_posix().lstrip("/")
return index_rel in self._nav_paths
def get_extra_content_roots(self, repo_root: Path) -> list[Path]:
"""Restituisce radici markdown aggiuntive fuori da docs_root."""
return []
def get_locale_source_roots(self, repo_root: Path) -> list[tuple[Path, str]]:
"""Restituisce le radici locale come tuple (root_path, locale_label)."""
return []
def get_absolute_url_prefixes(self, repo_root: Path | None = None) -> list[str]:
"""Restituisce i prefissi URL assoluti considerati proprietà del progetto."""
return []
# ── Routing Metadata-Driven ────────────────────────────────────────────
def get_route_info(self, rel: Path) -> RouteMetadata:
"""Restituisce i metadati di routing unificati per un file sorgente.
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)
dove context è un'istanza BuildContext.
Se il tuo adapter richiede un caricamento config legato al repository,
implementa from_repo(context, docs_root, repo_root) e carica lì il config engine.
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)
Esempi di Implementazione Concreti: Docusaurus vs. Standalone
Per vedere come questi metodi dell'adapter sono implementati in pratica, ecco un confronto tra il DocusaurusAdapter (completamente consapevole del motore) e lo StandaloneAdapter (senza presupposti sul motore).
provides_index() — Questa directory ha una pagina di destinazione?
Il Core chiama provides_index(directory_path) una volta per directory durante il rilevamento delle pagine orfane. Risponde alla domanda: "Il motore genererà un indice navigabile per questa directory, in modo che i file al suo interno non siano strutturalmente orfani?"
DocusaurusAdapter.provides_index() — piena consapevolezza del motore:
def provides_index(self, directory_path: Path) -> bool:
# Physical index files — Docusaurus serves these directly.
index_files = ("index.md", "index.mdx", "README.md", "README.mdx")
if any((directory_path / f).exists() for f in index_files):
return True
# _category_.json with "generated-index" link — Docusaurus auto-generates
# a category landing page even without a physical index file.
category_json = directory_path / "_category_.json"
if category_json.exists():
try:
import json as _json
data = _json.loads(category_json.read_text(encoding="utf-8"))
link = data.get("link", {})
return isinstance(link, dict) and link.get("type") == "generated-index"
except Exception:
return True # conservative: assume it provides an index
return False
StandaloneAdapter.provides_index() — zero presupposti sul motore:
def provides_index(self, directory_path: Path) -> bool:
# No engine config — only a plain index.md signals a landing page.
return (directory_path / "index.md").exists()
Differenza chiave: DocusaurusAdapter conosce _category_.json e README.mdx perché sono convenzioni di Docusaurus. StandaloneAdapter non fa presupposti — riconosce solo la convenzione universale index.md.
get_nav_paths() — Quali file sono individuabili?
get_nav_paths() restituisce l'insieme dei percorsi file raggiungibili tramite l'interfaccia utente di navigazione del sito. Un file assente da questo insieme è un candidato per Z402 (ORPHAN_BUT_EXISTING).
DocusaurusAdapter.get_nav_paths() — aggregazione da tre sorgenti:
def get_nav_paths(self) -> frozenset[str]:
if self._sidebar_path is not None:
sidebar_paths = _parse_sidebars(self._sidebar_path, self._docs_root)
if sidebar_paths is not None:
# Explicit sidebar: merge with navbar paths.
# A file is REACHABLE if it appears in the sidebar OR the navbar.
return sidebar_paths | self._navbar_paths
# Autogenerated or no sidebar: all files are already REACHABLE.
return frozenset()
self._navbar_paths è popolato da _parse_config_navigation() da docusaurus.config.* — estrae i percorsi URL to: e gli attributi docId: dagli elementi navbar e footer. Un file collegato solo nel footer è comunque considerato individuabile (Legge di Individuabilità UX, Regola R21).
StandaloneAdapter.get_nav_paths() — intenzionalmente vuoto:
def get_nav_paths(self) -> frozenset[str]:
"""Empty frozenset — no engine config means no declared nav."""
return frozenset()
Quando get_nav_paths() restituisce un frozenset vuoto, get_route_info() tratta tutti i file come REACHABLE. Questo è intenzionale: in modalità Standalone non c'è alcun contratto di navigazione, quindi il rilevamento delle pagine orfane (Z402) è disabilitato.
_classify_route() — Classificazione interna delle route
_classify_route(rel, nav_paths) è un helper privato che get_route_info() chiama internamente per mappare il percorso di un file sorgente al suo stato della route.
DocusaurusAdapter._classify_route() — quattro regole di classificazione:
def _classify_route(self, rel: Path, nav_paths: frozenset[str]) -> RouteStatus:
# Rule 1: Private/meta files (e.g. _category_.json) → IGNORED
non_sentinel_parts = [p for p in rel.parts if p != "_version_"]
if any(part.startswith("_") for part in non_sentinel_parts):
return "IGNORED"
# Version Ghost Routes: files under _version_/<label>/ are always REACHABLE.
if len(rel.parts) >= 2 and rel.parts[0] == "_version_":
return "REACHABLE"
# Rule 2: No explicit nav → autogenerated sidebar → all REACHABLE
if not nav_paths:
return "REACHABLE"
# Rule 3: Explicit nav match (sidebar or navbar)
if rel.as_posix() in nav_paths:
return "REACHABLE"
# Locale shadows inherit nav membership
if self.is_shadow_of_nav_page(rel, nav_paths):
return "REACHABLE"
# Ghost Routes: locale entry points (e.g. it/index.mdx)
if rel.name in ("index.md", "index.mdx") and len(rel.parts) == 2:
if rel.parts[0] in self._locale_dirs:
return "REACHABLE"
# Rule 4: File exists but is not discoverable via any UI entry point
return "ORPHAN_BUT_EXISTING"
StandaloneAdapter.get_route_info() — sempre raggiungibile (nessun helper _classify_route()):
def get_route_info(self, rel: Path) -> RouteMetadata:
"""Return route metadata derived purely from the filesystem.
StandaloneAdapter has no engine config, no nav, no slug support.
Every file is ``REACHABLE`` with a filesystem-derived URL.
"""
from zenzic.core.adapters._base import RouteMetadata
return RouteMetadata(
canonical_url=self._map_url(rel),
status="REACHABLE",
)
Il contrasto è evidente. DocusaurusAdapter implementa una cascata di priorità a quattro regole in _classify_route(), che get_route_info() chiama internamente. StandaloneAdapter non ha l'helper _classify_route() — get_route_info() restituisce direttamente REACHABLE perché senza motore non c'è alcun contratto di navigazione.
get_link_scheme_bypasses() — Schemi URI specifici del motore
La Regola R21 (Sovranità del Protocollo) impone che il Core non inserisca mai a livello di codice i nomi dei motori nella logica di validazione. Gli schemi URI specifici del motore sono dichiarati dall'adapter e interrogati dal Core.
DocusaurusAdapter.get_link_scheme_bypasses():
def get_link_scheme_bypasses(self) -> frozenset[str]:
# Docusaurus uses pathname:/// links to reference static/ files that
# bypass the React router. The leading / is a URI convention artifact,
# not a server-absolute path — suppress Z105 for these links.
return frozenset({"pathname"})
StandaloneAdapter.get_link_scheme_bypasses():
def get_link_scheme_bypasses(self) -> frozenset[str]:
"""Standalone projects have no engine-specific link-scheme bypass."""
return frozenset()
Quando un link come pathname:///assets/brand.html viene incontrato in un progetto Docusaurus, il Core controlla adapter.get_link_scheme_bypasses(). Trova "pathname" nell'insieme restituito e salta il controllo Z105 (percorso assoluto). In un progetto Standalone, lo stesso link attiva Z105 — correttamente, perché pathname:/// è una via di fuga specifica di Docusaurus senza alcun significato in un progetto Markdown generico.
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 Zenzic 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: Meccaniche di Base — VSM.