Writing a Zenzic Adapter
This guide explains how to create a third-party adapter that teaches Zenzic to understand your documentation engine's project layout, navigation structure, and i18n conventions — without modifying Zenzic itself.
What Is an Adapter?
An adapter is a Python class that satisfies the BaseAdapter protocol
(src/zenzic/core/adapters/_base.py). Zenzic's
scanner, orphan detector, and link validator talk exclusively to this protocol —
they never import or call engine-specific code directly.
An adapter answers questions for each docs tree through two API surfaces:
Metadata-Driven Routing (preferred)
| Method | Question |
|---|---|
get_route_info(rel) | What is the canonical URL, route status, slug, and proxy flag for this source file? Returns a RouteMetadata instance. |
Legacy File-to-URL Routing (backward compatible)
| Method | Question |
|---|---|
map_url(rel) | What canonical URL does this source file produce? |
classify_route(rel, nav_paths) | Is this file REACHABLE, ORPHAN_BUT_EXISTING, or IGNORED? |
New adapters should implement get_route_info(). The legacy methods are
retained for backward compatibility — their default implementations delegate
to get_route_info().
Common Methods
| Method | Question |
|---|---|
is_locale_dir(part) | Is this top-level directory a non-default locale? |
resolve_asset(missing_abs, docs_root) | Does a default-locale fallback exist for this missing asset? |
resolve_anchor(resolved_file, anchor, anchors_cache, docs_root) | Should this anchor miss be suppressed because the anchor exists in the default-locale equivalent? |
is_shadow_of_nav_page(rel, nav_paths) | Is this file a locale mirror of a nav-listed page? |
get_ignored_patterns() | Which filename globs should the orphan check skip? |
get_nav_paths() | Which .md paths are listed in this engine's nav config? |
has_engine_config() | Was a build-engine config file found on disk? (Controls orphan check activation.) |
Step 1 — Create the Adapter Class
# 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 for MyEngine documentation projects."""
def __init__(
self,
config: dict[str, Any],
docs_root: Path,
) -> None:
self._docs_root = docs_root
self._config = config
# Extract whatever your engine's config format provides.
self._nav_paths: frozenset[str] = self._parse_nav()
# ── BaseAdapter protocol ───────────────────────────────────────────────
def is_locale_dir(self, part: str) -> bool:
"""Return True when *part* is a non-default locale directory.
If your engine does not support i18n, always return False.
"""
locales: list[str] = self._config.get("locales", [])
return part in locales
def resolve_asset(self, missing_abs: Path, docs_root: Path) -> Path | None:
"""Return the default-locale fallback path for a missing locale asset.
If your engine does not support i18n asset fallback, always return None.
"""
return None
def resolve_anchor(
self,
resolved_file: Path,
anchor: str,
anchors_cache: dict[Path, set[str]],
docs_root: Path,
) -> bool:
"""Return True if an anchor miss on a locale file should be suppressed.
Called when a link points to a heading anchor that exists in the
default-locale file but not in the locale translation (because
headings are translated). Return True to suppress the false positive.
If your engine does not support i18n, always return False.
"""
return False
def has_engine_config(self) -> bool:
"""Return True when a build-engine config was found and loaded.
When False, the orphan check is skipped — with no nav information
there is no reference set to compare the file list against.
Return True if your adapter successfully loaded a config file.
Return False only if no engine config exists (bare/vanilla mode).
"""
return bool(self._config)
def is_shadow_of_nav_page(self, rel: Path, nav_paths: frozenset[str]) -> bool:
"""Return True when *rel* is a locale mirror of a nav-listed page.
Example: docs/fr/guide/index.md shadows guide/index.md.
If your engine does not support i18n, always return 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]:
"""Return glob patterns for files the orphan check should skip.
For suffix-mode i18n plugins, return patterns like {'*.fr.md', '*.it.md'}.
"""
return set()
def get_nav_paths(self) -> frozenset[str]:
"""Return the set of .md paths listed in the engine's nav, relative to docs_root."""
return self._nav_paths
# ── Metadata-Driven Routing ────────────────────────────────────────────
def get_route_info(self, rel: Path) -> RouteMetadata:
"""Return unified routing metadata for a source file.
This single method replaces the legacy map_url() + classify_route()
double dispatch. The VSM builder calls this to construct
RouteMetadata for every source file in a single pass.
"""
posix = rel.as_posix()
# Determine reachability from nav config.
if posix in self._nav_paths:
status: RouteStatus = "REACHABLE"
else:
status = "ORPHAN_BUT_EXISTING"
# Compute canonical URL (adjust to your engine's routing rules).
stem = rel.with_suffix("").as_posix()
canonical_url = f"/{stem}/"
return RouteMetadata(
canonical_url=canonical_url,
status=status,
)
# ── Private helpers ────────────────────────────────────────────────────
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 — Register via Entry Points
Zenzic discovers adapters through the zenzic.adapters entry-point group.
Register your adapter in your package's pyproject.toml:
[project.entry-points."zenzic.adapters"]
myengine = "my_engine_adapter.adapter:MyEngineAdapter"
The key (left of =) becomes the engine name users pass to --engine or
set as engine in zenzic.toml:
# In the user's zenzic.toml
[build_context]
engine = "myengine"
Step 3 — Implement the Factory Hook (Optional)
By default, Zenzic instantiates your adapter by calling:
adapter_class(context, docs_root, config_dict)
where context is a BuildContext instance and config_dict is the parsed
engine config (or {} if discovery failed).
If your adapter needs a different constructor signature, implement a
from_repo(context, docs_root, repo_root) classmethod and Zenzic will prefer it:
@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 — Validate with Zenzic
After installing your package (uv pip install -e . or pip install -e .),
verify the adapter is discovered:
# List all installed adapters
zenzic check orphans --engine myengine --help
# Run against a real docs tree
zenzic check orphans --engine myengine
zenzic check all --engine myengine
Step 5 — Custom Rules Are Engine-Independent
[[custom_rules]] in zenzic.toml run on raw Markdown source and are
completely decoupled from the adapter layer. A rule that searches for DRAFT
will fire identically whether the adapter is MkDocs, Zensical, or your own
engine. No extra work is required to make custom rules compatible with a new
adapter.
Adapter Contract Guarantees
Your adapter must satisfy these invariants, or Zenzic's scanner may produce incorrect results:
get_route_info()must return aRouteMetadatawith acanonical_urlthat starts and ends with/.get_route_info()must setstatusto one ofREACHABLE,ORPHAN_BUT_EXISTING, orIGNORED. Never returnCONFLICT— that status is assigned later by_detect_collisions().get_nav_paths()returns paths relative todocs_root, using forward slashes, with no leading/.get_nav_paths()returns only.mdfiles (other extensions are ignored by the orphan checker).is_locale_dir()must returnFalsefor the default locale. Only non-default locale directories should returnTrue.- All methods must be pure: same inputs always produce the same outputs. No I/O, no global-state mutation.
resolve_asset()must never raise — returnNoneon any failure.resolve_anchor()must never raise — returnFalseon any failure. Theanchors_cacheargument is read-only; do not mutate it.has_engine_config()must never raise — returnFalseon any failure.
Testing Your Adapter
Use zenzic.core.adapters.BaseAdapter as the typing target in your tests to
verify protocol compliance:
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)
Connect adapter code to deployment truth:
- Register engine identity in project configuration via
[build_context] engine(see Adapters & Engine Configuration). - Validate adapter behavior under strict Sentinel policy:
zenzic check all --engine myengine --strict. For run controls, see CLI Commands: Global flags. - If your engine generates synthetic locale routes, explicitly map Ghost Route expectations against the VSM reference: Checks Reference — VSM.