Skip to main content

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)

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

MethodQuestion
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

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

  1. get_route_info() must return a RouteMetadata with a canonical_url that starts and ends with /.
  2. get_route_info() must set status to one of REACHABLE, ORPHAN_BUT_EXISTING, or IGNORED. Never return CONFLICT — that status is assigned later by _detect_collisions().
  3. get_nav_paths() returns paths relative to docs_root, using forward slashes, with no leading /.
  4. get_nav_paths() returns only .md files (other extensions are ignored by the orphan checker).
  5. is_locale_dir() must return False for the default locale. Only non-default locale directories should return True.
  6. All methods must be pure: same inputs always produce the same outputs. No I/O, no global-state mutation.
  7. resolve_asset() must never raise — return None on any failure.
  8. resolve_anchor() must never raise — return False on any failure. The anchors_cache argument is read-only; do not mutate it.
  9. has_engine_config() must never raise — return False on 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)
Next Steps

Connect adapter code to deployment truth:

  1. Register engine identity in project configuration via [build_context] engine (see Adapters & Engine Configuration).
  2. Validate adapter behavior under strict Sentinel policy: zenzic check all --engine myengine --strict. For run controls, see CLI Commands: Global flags.
  3. If your engine generates synthetic locale routes, explicitly map Ghost Route expectations against the VSM reference: Checks Reference — VSM.