Passa al contenuto principale

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)

MetodoDomanda
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)

MetodoDomanda
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

MetodoDomanda
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:

  1. get_route_info() deve restituire un RouteMetadata con canonical_url che inizia e termina con /.
  2. get_route_info() deve impostare status a uno tra REACHABLE, ORPHAN_BUT_EXISTING o IGNORED. Non restituire mai CONFLICT — quello stato viene assegnato successivamente da _detect_collisions().
  3. get_nav_paths() restituisce percorsi relativi a docs_root, usando slash in avanti, senza / iniziale.
  4. get_nav_paths() restituisce solo file .md (altre estensioni sono ignorate dal controllore orfani).
  5. is_locale_dir() deve restituire False per la locale default. Solo le directory di locale non-default devono restituire True.
  6. Tutti i metodi devono essere puri: stessi input producono sempre gli stessi output. Nessun I/O, nessuna mutazione di stato globale.
  7. resolve_asset() non deve mai sollevare eccezioni — restituisci None in caso di errore.
  8. resolve_anchor() non deve mai sollevare eccezioni — restituisci False in caso di errore. L'argomento anchors_cache è in sola lettura; non mutarlo.
  9. has_engine_config() non deve mai sollevare eccezioni — restituisci False in 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)
Passaggi Successivi

Collega il codice dell'adapter alla verità operativa del progetto:

  1. Registra l'identità engine nella configurazione del progetto tramite [build_context] engine (vedi Adapter e Configurazione del Motore).
  2. 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.
  3. Se il tuo engine genera route locali sintetiche, mappa esplicitamente le aspettative Ghost Route rispetto al riferimento VSM: Riferimento Controlli — VSM.