Docs: Restyled ADR pages
CI / lint (push) Successful in 15s
CI / build (push) Successful in 16s
CI / quality (push) Successful in 20s
CI / security (push) Successful in 35s
CI / typecheck (push) Successful in 42s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m38s
CI / integration_tests (push) Successful in 3m11s
CI / docker (push) Successful in 39s
CI / coverage (push) Successful in 4m58s
CI / benchmark-publish (push) Successful in 17m32s

This commit is contained in:
2026-03-10 00:08:11 -04:00
parent 01a2a03e2e
commit c2db74ba81
49 changed files with 3402 additions and 1946 deletions
+724
View File
@@ -0,0 +1,724 @@
"""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 = (
'<svg class="adr-icon" viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2'
"L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2z"
'm3 18H5V8h14v11z"/></svg>'
)
ICON_PERSON = (
'<svg class="adr-icon" viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 '
'4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>'
)
# ── 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'<span class="{cls}">{label}</span>'
def _resolve_adr_link(adr_number: int, files: Any) -> str | None:
"""Find the relative URL for an ADR by its number, using the MkDocs files collection."""
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'<span class="adr-tier-badge">Tier {tier}</span>'
f'<span class="adr-tier-title">{tier_title}</span>'
f'<span class="adr-label"> · </span>'
)
line1 = (
f'<div class="adr-header-line">'
f"{tier_part}"
f'<span class="adr-label">Drafted:</span> '
f'<span class="adr-value">{earliest_display}</span>'
f"</div>"
)
# ── 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' <span class="adr-label">—</span> '
f'<span class="adr-label">superseded by</span> '
f'<a href="{link}">ADR-{superseded_by:03d}</a>'
)
else:
superseded_part = f' <span class="adr-label">— superseded by ADR-{superseded_by:03d}</span>'
line2 = (
f'<div class="adr-header-line">'
f'<span class="adr-label">Status:</span> '
f"{badge}"
f'<span class="adr-label"> as of </span>'
f'<span class="adr-value">{last_display}</span>'
f"{superseded_part}"
f"</div>"
)
# ── line 3: authors (optional) ──────────────────────────────────
line3 = ""
if authors:
line3 = (
f'<div class="adr-header-line">'
f'<span class="adr-label">Authors:</span> '
f'<span class="adr-value">{authors}</span>'
f"</div>"
)
return (
f'\n<div class="adr-header" markdown="0">\n'
f" {line1}\n"
f" {line2}\n"
f" {line3}\n"
f"</div>\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'<div class="adr-timeline-person">{ICON_PERSON}{entry_people}</div>'
)
connector_line = '<div class="adr-timeline-line"></div>' if not is_last else ""
badge = _build_status_badge(entry_status)
entries_html.append(
f'<div class="adr-timeline-entry">\n'
f' <div class="adr-timeline-date-col">\n'
f' <span class="adr-timeline-date">{date_display}</span>\n'
f" </div>\n"
f' <div class="adr-timeline-connector">\n'
f' <div class="{dot_cls}"></div>\n'
f" {connector_line}\n"
f" </div>\n"
f' <div class="adr-timeline-content">\n'
f" {badge}\n"
f" {people_html}\n"
f" </div>\n"
f"</div>"
)
inner = "\n".join(entries_html)
return (
f'\n<div class="adr-timeline" style="display:none" markdown="0">\n'
f"{inner}\n"
f"</div>\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 = "<!-- ADR_INVENTORY -->"
_TIMELINE_PLACEHOLDER = "<!-- ADR_TIMELINE -->"
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 = (
'<svg class="adr-icon" viewBox="0 0 24 24" aria-hidden="true">'
'<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 '
"2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 "
'9H13z"/></svg>'
)
def _md_to_url(filename: str) -> str:
"""Convert ``ADR-001-layered-architecture.md`` to a directory URL.
MkDocs builds each ``.md`` file into ``<stem>/index.html``, so
relative links in raw HTML must use ``<stem>/`` 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'<div class="adr-timeline-adr-link">'
f'{ICON_ADR}<a href="{item["adr_url"]}">{item["adr_label"]}</a>'
f"</div>"
)
connector_line = '<div class="adr-timeline-line"></div>' if not is_last else ""
entries_html.append(
f'<div class="adr-timeline-entry">\n'
f' <div class="adr-timeline-date-col">\n'
f' <span class="adr-timeline-date">{date_display}</span>\n'
f" </div>\n"
f' <div class="adr-timeline-connector">\n'
f' <div class="{dot_cls}"></div>\n'
f" {connector_line}\n"
f" </div>\n"
f' <div class="adr-timeline-content">\n'
f" {badge}\n"
f" {adr_link_html}\n"
f" </div>\n"
f"</div>"
)
inner = "\n".join(entries_html)
return (
f'\n<div class="adr-timeline adr-combined-timeline" markdown="0">\n'
f"{inner}\n"
f"</div>\n\n"
)
# ── MkDocs hook entry-point ────────────────────────────────────────────
def on_page_markdown(
markdown: str,
page: Any,
config: Any,
files: Any,
**kwargs: Any,
) -> str:
"""Transform ADR page markdown.
- **Index page** (``adr/index.md``): replaces the ``<!-- ADR_INVENTORY -->``
placeholder with auto-generated tier-grouped tables.
- **Individual ADR pages**: strips old metadata, injects header + timeline.
"""
# ── ADR index: auto-generate inventory + combined timeline ─────
if _is_adr_index(page):
needs_inventory = _INVENTORY_PLACEHOLDER in markdown
needs_timeline = _TIMELINE_PLACEHOLDER in markdown
if needs_inventory or needs_timeline:
tiers_config = config.get("extra", {}).get("adr_tiers", {})
adrs = _collect_adr_inventory(files)
if needs_inventory:
inventory = _generate_inventory_markdown(adrs, tiers_config)
markdown = markdown.replace(_INVENTORY_PLACEHOLDER, inventory)
if needs_timeline:
timeline = _build_combined_timeline(adrs)
markdown = markdown.replace(_TIMELINE_PLACEHOLDER, timeline)
return markdown
if not _is_adr_page(page):
return markdown
meta = page.meta or {}
status_history = meta.get("status_history")
if not status_history or not isinstance(status_history, list):
log.warning(
"ADR %s has no valid status_history in front-matter", page.file.src_path
)
return markdown
# ── derive dates and status ─────────────────────────────────────
# Earliest date = first entry in the status history
earliest_date = _normalise_date(status_history[0][0])
last_entry = status_history[-1]
last_date = _normalise_date(last_entry[0])
last_status = str(last_entry[1]).strip()
# ── authors (explicit optional front-matter field) ──────────────
authors = _normalise_people(meta.get("authors"))
# ── tier info ───────────────────────────────────────────────────
tier = meta.get("tier")
tier_title = ""
if tier is not None:
tiers_config = config.get("extra", {}).get("adr_tiers", {})
tier_info = tiers_config.get(tier, {})
if isinstance(tier_info, dict):
tier_title = tier_info.get("title", "")
# ── superseded_by ───────────────────────────────────────────────
superseded_by = meta.get("superseded_by")
# ── strip old inline metadata block ─────────────────────────────
markdown = _OLD_META_RE.sub("", markdown, count=1)
# ── generate the H1 from adr_number + title front-matter ────────
adr_number = meta.get("adr_number")
adr_title = meta.get("title")
if adr_number is not None and adr_title:
# Remove any existing H1 line from the markdown body
markdown = re.sub(r"^#\s+.+?\n+", "", markdown, count=1)
generated_h1 = f"# ADR-{int(adr_number):03d}: {adr_title}\n"
else:
# Fallback: keep whatever H1 already exists
generated_h1 = ""
h1_match = re.match(r"(#\s+.+?\n)", markdown)
if h1_match:
generated_h1 = "" # leave it in the markdown
else:
generated_h1 = ""
# ── build header + timeline ─────────────────────────────────────
header_html = _build_header(
earliest_date,
last_date,
last_status,
tier,
tier_title,
superseded_by,
authors,
files,
)
timeline_html = _build_timeline(status_history)
if adr_number is not None and adr_title:
# H1 was stripped; prepend generated H1 + header + timeline
markdown = generated_h1 + "\n" + header_html + timeline_html + markdown
else:
# Insert after the existing H1 line
h1_match = re.match(r"(#\s+.+?\n)", markdown)
if h1_match:
insert_pos = h1_match.end()
markdown = (
markdown[:insert_pos]
+ "\n"
+ header_html
+ timeline_html
+ markdown[insert_pos:]
)
else:
markdown = header_html + timeline_html + markdown
# ── generate Related ADRs + Acceptance from front-matter ────────
# Defensively strip any leftover hand-written sections first.
markdown = _RELATED_SECTION_RE.sub("", markdown)
markdown = _ACCEPTANCE_SECTION_RE.sub("", markdown)
markdown = markdown.rstrip() + "\n"
related_adrs = meta.get("related_adrs", [])
acceptance = meta.get("acceptance", {})
tail_sections: list[str] = []
if related_adrs:
tail_sections.append(_build_related_adrs_section(related_adrs, files))
if acceptance:
tail_sections.append(_build_acceptance_section(acceptance))
if tail_sections:
markdown += "\n" + "\n".join(tail_sections)
return markdown