"""MkDocs hook for ADR page custom rendering.
Injects a custom header (dates, status badge, tier) and a hidden timeline
element into every ADR page. The companion ``adr-page.js`` script moves
the timeline into the left sidebar at runtime.
Also auto-generates the ADR inventory tables on the ``adr/index.md`` page
by scanning all ADR source files for their YAML front-matter.
"""
from __future__ import annotations
import logging
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any
import yaml
log = logging.getLogger("mkdocs.hooks.adr")
# ── status definitions ──────────────────────────────────────────────────
VALID_STATUSES = {
"draft",
"proposed",
"accepted",
"rejected",
"superseded",
"deprecated",
}
STATUS_LABELS = {
"draft": "Draft",
"proposed": "Proposed",
"accepted": "Accepted",
"rejected": "Rejected",
"superseded": "Superseded",
"deprecated": "Deprecated",
}
# ── SVG icons (Material Design, same as log4brains) ────────────────────
ICON_CALENDAR = (
''
)
ICON_PERSON = (
''
)
# ── regex to match the old inline metadata block ────────────────────────
# Matches lines like: **Status:** Accepted \n**Date:** ... up to the
# blank line before ## Context (or the next heading).
_OLD_META_RE = re.compile(
r"^(\*\*Status:\*\*.*?\n)" # first metadata line
r"((?:\*\*[A-Za-z()]+:\*\*.*?\n)*)" # subsequent metadata lines
r"\s*\n", # trailing blank line
re.MULTILINE,
)
# ── helpers ─────────────────────────────────────────────────────────────
def _normalise_date(raw: Any) -> str:
"""Return a YYYY-MM-DD string from various input types."""
if isinstance(raw, date):
return raw.isoformat()
if isinstance(raw, datetime):
return raw.date().isoformat()
return str(raw).strip()
def _format_date_display(iso: str) -> str:
"""Convert '2026-02-16' to 'Feb 16, 2026'."""
try:
d = datetime.strptime(iso, "%Y-%m-%d")
return d.strftime("%b %d, %Y")
except (ValueError, TypeError):
return iso
def _normalise_people(raw: Any) -> str:
"""Return a comma-separated string of people from various input."""
if raw is None:
return ""
if isinstance(raw, list):
return ", ".join(str(p) for p in raw)
return str(raw).strip()
def _status_key(status: str) -> str:
"""Lowercase status key for CSS class names."""
return status.strip().lower()
def _is_adr_page(page: Any) -> bool:
"""Return True if the page is an individual ADR (not the index)."""
src = page.file.src_path # e.g. "adr/ADR-001-layered-architecture.md"
return src.startswith("adr/ADR-") and src.endswith(".md") and "index" not in src
def _is_adr_index(page: Any) -> bool:
"""Return True if the page is the ADR index page."""
return page.file.src_path == "adr/index.md"
def _extract_adr_number(page: Any) -> str | None:
"""Extract '001' from 'adr/ADR-001-layered-architecture.md'."""
m = re.search(r"ADR-(\d+)", page.file.src_path)
return m.group(1) if m else None
# ── HTML builders ───────────────────────────────────────────────────────
def _build_status_badge(status: str, extra_class: str = "") -> str:
key = _status_key(status)
label = STATUS_LABELS.get(key, status)
cls = f"adr-status-badge adr-status-{key}"
if extra_class:
cls += f" {extra_class}"
return f'{label}'
def _resolve_adr_link(adr_number: int, files: Any) -> str | None:
"""Find the relative URL for an ADR by its number."""
prefix = f"adr/ADR-{adr_number:03d}"
for f in files:
if f.src_path.startswith(prefix) and f.src_path.endswith(".md"):
return f.src_path.replace("adr/", "", 1) # relative to current adr/ dir
return None
def _build_header(
earliest_date: str,
last_date: str,
last_status: str,
tier: int | None,
tier_title: str,
superseded_by: int | None,
authors: str,
files: Any,
) -> str:
"""Build the subtitle-style header injected after the H1 title.
Line 1: Tier + Drafted date (earliest entry in status_history)
Line 2: Current status badge + date (+ superseded-by link when applicable)
Line 3: Authors (optional, from explicit front-matter field)
"""
earliest_display = _format_date_display(earliest_date)
last_display = _format_date_display(last_date)
badge = _build_status_badge(last_status)
status_key = _status_key(last_status)
# ── line 1: tier + drafted date ─────────────────────────────────
tier_part = ""
if tier is not None and tier_title:
tier_part = (
f'Tier {tier}'
f'{tier_title}'
f' · '
)
line1 = (
f'
"
)
# ── line 2: current status + date (+ superseded-by link) ────────
superseded_part = ""
if superseded_by is not None and status_key == "superseded":
link = _resolve_adr_link(superseded_by, files)
if link:
superseded_part = (
f' — '
f'superseded by '
f'ADR-{superseded_by:03d}'
)
else:
superseded_part = (
f' '
f"— superseded by ADR-{superseded_by:03d}"
)
line2 = (
f'
'
f'Status: '
f"{badge}"
f' as of '
f'{last_display}'
f"{superseded_part}"
f"
"
)
# ── line 3: authors (optional) ──────────────────────────────────
line3 = ""
if authors:
line3 = (
f'
'
f'Authors: '
f'{authors}'
f"
"
)
return (
f'\n
\n'
f" {line1}\n"
f" {line2}\n"
f" {line3}\n"
f"
\n\n"
)
def _build_timeline(status_history: list) -> str:
"""Build the hidden timeline HTML that JS moves into the sidebar."""
entries_html = []
total = len(status_history)
for i, entry in enumerate(status_history):
entry_date = _normalise_date(entry[0])
entry_status = str(entry[1]).strip()
entry_people = _normalise_people(entry[2] if len(entry) > 2 else None)
status_key = _status_key(entry_status)
date_display = _format_date_display(entry_date)
is_last = i == total - 1
dot_cls = f"adr-timeline-dot adr-dot-{status_key}"
if is_last:
dot_cls += " adr-dot-current"
people_html = ""
if entry_people:
people_html = (
f'
{ICON_PERSON}{entry_people}
'
)
connector_line = '' if not is_last else ""
badge = _build_status_badge(entry_status)
entries_html.append(
f'
\n'
f'
\n'
f' {date_display}\n'
f"
\n"
f'
\n'
f' \n'
f" {connector_line}\n"
f"
\n"
f'
\n'
f" {badge}\n"
f" {people_html}\n"
f"
\n"
f"
"
)
inner = "\n".join(entries_html)
return (
f'\n
\n'
f"{inner}\n"
f"
\n\n"
)
# ── Generated tail sections (Related ADRs + Acceptance) ────────────────
# Regex to strip leftover ## Related ADRs / ## Acceptance from the body
# (defensive — the migration should have removed them already).
_RELATED_SECTION_RE = re.compile(r"\n## Related ADRs\s*\n.*", re.DOTALL)
_ACCEPTANCE_SECTION_RE = re.compile(r"\n## Acceptance\s*\n.*", re.DOTALL)
def _build_related_adrs_section(
related_adrs: list[dict],
files: Any,
) -> str:
"""Generate the ``## Related ADRs`` markdown section from front-matter.
Each entry in *related_adrs* has ``number``, ``title``, and
``relationship``. The ADR link is resolved via the MkDocs *files*
collection so that MkDocs can validate and rewrite the URL.
"""
if not related_adrs:
return ""
lines = [
"## Related ADRs",
"",
"| ADR | Title | Relationship |",
"|-----|-------|-------------|",
]
for entry in related_adrs:
num = entry.get("number")
title = entry.get("title", "")
relationship = entry.get("relationship", "")
if num is None:
continue
# Resolve the file link via the files collection
link_path = _resolve_adr_link(int(num), files)
if link_path:
adr_col = f"[ADR-{int(num):03d}]({link_path})"
else:
adr_col = f"ADR-{int(num):03d}"
lines.append(f"| {adr_col} | {title} | {relationship} |")
return "\n".join(lines) + "\n"
def _build_acceptance_section(acceptance: dict) -> str:
"""Generate the ``## Acceptance`` markdown section from front-matter.
*acceptance* contains ``votes_for``, ``votes_against``, and
``abstentions`` — each a list of ``{voter, comment}`` dicts.
"""
if not acceptance:
return ""
def _votes_table(entries: list[dict]) -> str:
rows = [
"| Voter | Comment |",
"|-------|---------|",
]
for e in entries:
voter = e.get("voter", "")
comment = e.get("comment", "")
rows.append(f"| {voter} | {comment} |")
return "\n".join(rows)
votes_for = acceptance.get("votes_for", [])
votes_against = acceptance.get("votes_against", [])
abstentions = acceptance.get("abstentions", [])
parts = [
"## Acceptance",
"",
"### Votes For",
"",
_votes_table(votes_for),
"",
f"**Total: {len(votes_for)}**",
"",
"### Votes Against",
"",
_votes_table(votes_against),
"",
f"**Total: {len(votes_against)}**",
"",
"### Abstentions",
"",
_votes_table(abstentions),
"",
f"**Total: {len(abstentions)}**",
]
return "\n".join(parts) + "\n"
# ── ADR inventory (for the index page) ─────────────────────────────────
_INVENTORY_PLACEHOLDER = ""
_TIMELINE_PLACEHOLDER = ""
def _collect_adr_inventory(files: Any) -> list[dict]:
"""Scan all ADR source files and collect front-matter metadata.
Returns a list of dicts sorted by ``adr_number``, each containing
``number``, ``title``, ``tier``, ``status``, ``status_badge`` (HTML),
``filename``, and ``status_history`` (the raw list of transitions).
"""
adrs: list[dict] = []
for f in files:
src = f.src_path
if not src.startswith("adr/ADR-") or not src.endswith(".md"):
continue
try:
content = Path(f.abs_src_path).read_text(encoding="utf-8")
except OSError:
continue
fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not fm_match:
continue
try:
meta = yaml.safe_load(fm_match.group(1)) or {}
except yaml.YAMLError:
continue
adr_number = meta.get("adr_number")
title = meta.get("title", "")
tier = meta.get("tier")
status_history = meta.get("status_history", [])
if adr_number is None or not status_history:
continue
last_status = str(status_history[-1][1]).strip()
# Relative link from adr/index.md to adr/ADR-NNN-xxx.md
filename = src.replace("adr/", "", 1)
adrs.append(
{
"number": int(adr_number),
"title": title,
"tier": tier,
"status": last_status,
"status_badge": _build_status_badge(last_status),
"filename": filename,
"status_history": status_history,
}
)
adrs.sort(key=lambda a: a["number"])
return adrs
def _generate_inventory_markdown(adrs: list[dict], tiers_config: dict) -> str:
"""Generate tier-grouped inventory tables as markdown.
Each tier produces an ``### Tier N — Title`` sub-heading followed by
the tier description and a table of ADRs with their number (linked),
title, and current status rendered as a coloured badge.
"""
# Group by tier
grouped: dict[int | None, list[dict]] = {}
for adr in adrs:
t = adr.get("tier")
grouped.setdefault(t, []).append(adr)
sections: list[str] = []
def _tier_table(tier_adrs: list[dict]) -> list[str]:
rows = [
"| ADR | Title | Status |",
"|-----|-------|--------|",
]
for adr in tier_adrs:
num_str = f"ADR-{adr['number']:03d}"
link = f"[{num_str}]({adr['filename']})"
rows.append(f"| {link} | {adr['title']} | {adr['status_badge']} |")
return rows
# Emit tiers in numeric order
for tier_num in sorted(t for t in grouped if t is not None):
tier_info = tiers_config.get(tier_num, {})
tier_title = (
tier_info.get("title", f"Tier {tier_num}")
if isinstance(tier_info, dict)
else str(tier_info)
)
tier_desc = (
tier_info.get("description", "") if isinstance(tier_info, dict) else ""
)
lines: list[str] = [f"### Tier {tier_num} — {tier_title}", ""]
if tier_desc:
lines += [tier_desc, ""]
lines += _tier_table(grouped[tier_num])
sections.append("\n".join(lines))
# Handle ADRs without a tier (should not happen, but be defensive)
if None in grouped:
lines = ["### Uncategorized", ""]
lines += _tier_table(grouped[None])
sections.append("\n".join(lines))
return "\n\n".join(sections)
# ── SVG icon for ADR link in combined timeline ─────────────────────────
ICON_ADR = (
''
)
def _md_to_url(filename: str) -> str:
"""Convert ``ADR-001-layered-architecture.md`` to a directory URL.
MkDocs builds each ``.md`` file into ``/index.html``, so
relative links in raw HTML must use ``/`` rather than the
source ``.md`` extension.
"""
if filename.endswith(".md"):
return filename[:-3] + "/"
return filename
def _build_combined_timeline(adrs: list[dict]) -> str:
"""Build a unified timeline of every status transition across all ADRs.
Entries are sorted chronologically (then by ADR number for ties).
Instead of the person column used on individual pages, each entry
shows a clickable link to the originating ADR.
"""
# Flatten every status_history entry with its owning ADR metadata.
flat: list[dict] = []
for adr in adrs:
num = adr["number"]
title = adr["title"]
url = _md_to_url(adr["filename"])
label = f"ADR-{num:03d}: {title}"
for entry in adr.get("status_history", []):
entry_date = _normalise_date(entry[0])
entry_status = str(entry[1]).strip()
flat.append(
{
"date_iso": entry_date,
"status": entry_status,
"adr_number": num,
"adr_label": label,
"adr_url": url,
}
)
# Sort chronologically, then by ADR number for same-date entries.
flat.sort(key=lambda e: (e["date_iso"], e["adr_number"]))
total = len(flat)
entries_html: list[str] = []
for i, item in enumerate(flat):
status_key = _status_key(item["status"])
date_display = _format_date_display(item["date_iso"])
is_last = i == total - 1
dot_cls = f"adr-timeline-dot adr-dot-{status_key}"
if is_last:
dot_cls += " adr-dot-current"
badge = _build_status_badge(item["status"])
adr_link_html = (
f'