Forge
pythondeeb25a2
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""Generate MkDocs ADR navigator pages grouped by lifecycle status.
4
5Reads docs/adr/*.md (line 3 **Статус:**), writes docs/site/adr-nav/ (RU) and docs/en/site/adr-nav/ (EN).
6Run before mkdocs build: python tools/gen_adr_pages.py
7"""
8
9from __future__ import annotations
10
11import re
12from dataclasses import dataclass
13from pathlib import Path
14
15ROOT = Path(__file__).resolve().parents[1]
16ADR_DIR = ROOT / "docs" / "adr"
17ADR_EN_DIR = ROOT / "docs" / "en" / "adr"
18OUT_RU = ROOT / "docs" / "site" / "adr-nav"
19OUT_EN = ROOT / "docs" / "en" / "site" / "adr-nav"
20
21SKIP_NAMES = {"README.md", "status-lifecycle.md"}
22
23STATUS_RE = re.compile(
24 r"^\*\*(?:Статус|Status):\*\*\s*(.+?)\s*$",
25 re.IGNORECASE,
26)
27TITLE_RE = re.compile(r"^#\s+ADR\s+(\d+):\s*(.+)$", re.IGNORECASE)
28TITLE_ALT_RE = re.compile(r"^#\s+(.+)$")
29
30BUCKETS = [
31 (
32 "proposed",
33 "Proposed",
34 "Proposed",
35 "Draft for discussion — not yet accepted.",
36 "Черновик на обсуждение — решение ещё не принято.",
37 ),
38 (
39 "accepted",
40 "Accepted",
41 "Accepted",
42 "Accepted as norm; implementation not complete or intentionally phased.",
43 "Принято как норма; внедрение в коде не завершено или намеренно растянуто.",
44 ),
45 (
46 "accepted-in-progress",
47 "Accepted · In progress",
48 "Accepted · In progress",
49 "Accepted; implementation in progress.",
50 "Принято; реализация идёт (явная пометка In progress).",
51 ),
52 (
53 "accepted-implemented",
54 "Accepted · Implemented",
55 "Accepted · Implemented",
56 "Accepted and main delivery is in the codebase.",
57 "Принято и основная поставка в коде выполнена.",
58 ),
59 (
60 "superseded",
61 "Superseded",
62 "Superseded",
63 "Replaced by another ADR — see link in the document.",
64 "Заменено другим ADR — см. ссылку в тексте.",
65 ),
66 (
67 "other",
68 "Deferred / Deprecated / other",
69 "Deferred / Deprecated / other",
70 "Deferred, deprecated, or non-standard status wording.",
71 "Отложено, устарело или нестандартная формулировка статуса.",
72 ),
73]
74
75
76@dataclass(frozen=True)
77class AdrRecord:
78 num: int
79 slug: str
80 title: str
81 status_raw: str
82 bucket: str
83
84
85def parse_title(lines: list[str], num: int, slug: str) -> str:
86 for line in lines[:8]:
87 m = TITLE_RE.match(line.strip())
88 if m and int(m.group(1)) == num:
89 return m.group(2).strip()
90 m2 = TITLE_ALT_RE.match(line.strip())
91 if m2 and line.startswith("# ADR"):
92 return m2.group(1).strip()
93 # fallback: slug
94 part = slug.split("-", 1)[-1] if "-" in slug else slug
95 return part.replace("-", " ").title()
96
97
98def classify(status_raw: str) -> str:
99 s = status_raw.strip()
100 low = s.lower()
101 if low.startswith("proposed"):
102 return "proposed"
103 if low.startswith("superseded"):
104 return "superseded"
105 if low.startswith("deprecated") or low.startswith("deferred"):
106 return "other"
107 if "superseded" in low:
108 return "superseded"
109 if low.startswith("accepted"):
110 if "implemented" in low or "implemented" in s:
111 return "accepted-implemented"
112 if "in progress" in low:
113 return "accepted-in-progress"
114 return "accepted"
115 return "other"
116
117
118def load_records(*, lang: str = "ru") -> list[AdrRecord]:
119 base = ADR_EN_DIR if lang == "en" else ADR_DIR
120 records: list[AdrRecord] = []
121 for path in sorted(ADR_DIR.glob("*.md")):
122 if path.name in SKIP_NAMES:
123 continue
124 m = re.match(r"^(\d+)-(.+)\.md$", path.name)
125 if not m:
126 continue
127 read_path = base / path.name if lang == "en" and (base / path.name).is_file() else path
128 num = int(m.group(1))
129 slug = path.name[:-3]
130 text = read_path.read_text(encoding="utf-8")
131 lines = text.splitlines()
132 status_raw = ""
133 for line in lines[:12]:
134 sm = STATUS_RE.match(line.strip())
135 if sm:
136 status_raw = sm.group(1).strip()
137 break
138 if not status_raw:
139 status_raw = "(no status line)"
140 records.append(
141 AdrRecord(
142 num=num,
143 slug=slug,
144 title=parse_title(lines, num, slug),
145 status_raw=status_raw,
146 bucket=classify(status_raw),
147 )
148 )
149 records.sort(key=lambda r: r.num)
150 return records
151
152
153def md_table(rows: list[AdrRecord], adr_prefix: str) -> str:
154 if not rows:
155 return "_No records._\n"
156 lines = [
157 "| ID | Title | Status (raw) |",
158 "|----|-------|----------------|",
159 ]
160 for r in rows:
161 link = f"[{r.num:04d}]({adr_prefix}{r.slug}.md)"
162 title = r.title.replace("|", "\\|")
163 raw = r.status_raw.replace("|", "\\|")
164 lines.append(f"| {link} | {title} | {raw} |")
165 return "\n".join(lines) + "\n"
166
167
168def write_bucket(
169 out_dir: Path,
170 bucket_id: str,
171 title_en: str,
172 title_ru: str,
173 blurb_en: str,
174 blurb_ru: str,
175 rows: list[AdrRecord],
176 *,
177 lang: str,
178) -> None:
179 adr_prefix = "../../adr/" if lang == "ru" else "../../adr/"
180 nav_prefix = "./" if lang == "ru" else "./"
181 if lang == "ru":
182 title = title_ru
183 page_blurb = blurb_ru
184 back = "[← Навигатор ADR](index.md)"
185 gen = "Сгенерировано `tools/gen_adr_pages.py`. Не редактировать вручную."
186 else:
187 title = title_en
188 page_blurb = blurb_en
189 back = "[← ADR navigator](index.md)"
190 gen = "Generated by `tools/gen_adr_pages.py`. Do not edit by hand."
191
192 body = md_table(rows, adr_prefix)
193 content = f"""---
194hide:
195 - toc
196---
197
198# {title}
199
200{page_blurb}
201
202{back}
203
204{body}
205
206---
207
208_{gen}_
209"""
210 out_dir.mkdir(parents=True, exist_ok=True)
211 (out_dir / f"{bucket_id}.md").write_text(content, encoding="utf-8")
212
213
214def write_index(out_dir: Path, records: list[AdrRecord], *, lang: str) -> None:
215 counts: dict[str, int] = {b[0]: 0 for b in BUCKETS}
216 for r in records:
217 counts[r.bucket] = counts.get(r.bucket, 0) + 1
218
219 if lang == "ru":
220 h1 = "Навигатор ADR по статусам"
221 intro = (
222 "Сгруппированный индекс архитектурных решений по [жизненному циклу](../../adr/status-lifecycle.md). "
223 "Полный табличный индекс и тематические кластеры — в [README ADR](../../adr/README.md)."
224 )
225 full = "[Полный индекс ADR](../../adr/README.md)"
226 life = "[Жизненный цикл статусов](../../adr/status-lifecycle.md)"
227 else:
228 h1 = "ADR navigator by status"
229 intro = (
230 "Architecture decisions grouped by [lifecycle status](../../adr/status-lifecycle.md). "
231 "Full index and topic clusters: [ADR README](../../adr/README.md)."
232 )
233 full = "[Full ADR index](../../adr/README.md)"
234 life = "[Status lifecycle](../../adr/status-lifecycle.md)"
235
236 lines = [f"# {h1}", "", intro, "", life + " · " + full, ""]
237 for bid, title_en, title_ru, _be, _br in BUCKETS:
238 label = title_ru if lang == "ru" else title_en
239 n = counts.get(bid, 0)
240 lines.append(f"- [{label}]({bid}.md) — **{n}**")
241 lines.extend(["", "---", "", "_Generated by `tools/gen_adr_pages.py`._", ""])
242
243 out_dir.mkdir(parents=True, exist_ok=True)
244 (out_dir / "index.md").write_text("\n".join(lines), encoding="utf-8")
245
246
247def main() -> None:
248 records_ru = load_records(lang="ru")
249 records_en = load_records(lang="en")
250 by_bucket_ru: dict[str, list[AdrRecord]] = {b[0]: [] for b in BUCKETS}
251 by_bucket_en: dict[str, list[AdrRecord]] = {b[0]: [] for b in BUCKETS}
252 for r in records_ru:
253 by_bucket_ru.setdefault(r.bucket, []).append(r)
254 for r in records_en:
255 by_bucket_en.setdefault(r.bucket, []).append(r)
256
257 for lang, out_dir, records, by_bucket in (
258 ("ru", OUT_RU, records_ru, by_bucket_ru),
259 ("en", OUT_EN, records_en, by_bucket_en),
260 ):
261 write_index(out_dir, records, lang=lang)
262 for bid, title_en, title_ru, blurb_en, blurb_ru in BUCKETS:
263 write_bucket(
264 out_dir,
265 bid,
266 title_en,
267 title_ru,
268 blurb_en,
269 blurb_ru,
270 by_bucket.get(bid, []),
271 lang=lang,
272 )
273
274 print(
275 f"OK: {len(records_ru)} RU / {len(records_en)} EN ADRs -> "
276 f"{OUT_RU.relative_to(ROOT)} + {OUT_EN.relative_to(ROOT)}"
277 )
278 for bid, *_ in BUCKETS:
279 print(f" {bid}: ru={len(by_bucket_ru.get(bid, []))} en={len(by_bucket_en.get(bid, []))}")
280
281
282if __name__ == "__main__":
283 main()
284
View only · write via MCP/CIDE