c65e8a5285
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / lint (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 59s
CI / security (pull_request) Successful in 59s
CI / unit_tests (pull_request) Successful in 3m9s
CI / integration_tests (pull_request) Successful in 3m34s
CI / e2e_tests (pull_request) Successful in 3m54s
CI / docker (pull_request) Successful in 55s
CI / coverage (pull_request) Successful in 6m1s
CI / build (push) Successful in 15s
CI / lint (push) Successful in 16s
CI / quality (push) Successful in 27s
CI / typecheck (push) Successful in 38s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 48s
CI / unit_tests (push) Successful in 3m14s
CI / integration_tests (push) Successful in 3m30s
CI / docker (push) Successful in 56s
CI / e2e_tests (push) Successful in 4m43s
CI / coverage (push) Successful in 6m1s
CI / benchmark-publish (push) Successful in 19m50s
CI / benchmark-regression (pull_request) Successful in 37m17s
Implement cloud resource types (aws, gcp, azure) with credential fields, region/tenant metadata, and stubbed sandbox strategies. Credential resolution uses environment variables and profile names with no secrets logged. Key changes: - Add CloudResourceHandler with aws/gcp/azure type definitions - Add credential resolution from env vars and profile names - Add stubbed sandbox strategies (validate config, raise NotImplementedError) - Register cloud types in bootstrap_builtin_types - Credential masking via existing redaction patterns - Add Behave BDD tests, Robot integration tests, ASV benchmarks ISSUES CLOSED: #343
724 lines
24 KiB
Python
724 lines
24 KiB
Python
"""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."""
|
|
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">'
|
|
f"— 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
|
|
h1_match = re.match(r"(#\s+.+?\n)", markdown)
|
|
generated_h1 = "" # leave existing H1 or use empty
|
|
|
|
# ── 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
|