blob: c0047cb017bcec9044baa0b9796fd11bcc43f08f [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import pathlib
import re
import sys
from typing import NamedTuple
class TocEntry(NamedTuple):
number: str
title: str
path: str
level: int
parent: str | None
class Navigation(NamedTuple):
up: tuple[str, str] | None
prev: tuple[str, str, str] | None
next: tuple[str, str, str] | None
children: list[TocEntry]
def parse_toc_line(line: str) -> TocEntry | None:
pattern = r"^(\s*)\*\s+`([^`]+)`\s+\[([^\]]+)\]\(([^)]+)\)"
match = re.match(pattern, line)
if not match:
return None
indent, number, title, path = match.groups()
level = len(indent) // 2
number_clean = number.strip()
number_no_dots = number_clean.rstrip(".")
parts = number_no_dots.split(".")
if level == 0:
parent = None
else:
parent = ".".join(parts[:-1])
return TocEntry(number_clean, title, path, level, parent)
def parse_toc(index_path: pathlib.Path) -> list[TocEntry]:
content = index_path.read_text(encoding="utf-8")
lines = content.splitlines()
in_toc = False
entries = []
for line in lines:
if line.strip() == "## Table of contents":
in_toc = True
continue
if in_toc:
if line.strip() and (not line.startswith("*")) and (not line.startswith(" ")):
break
entry = parse_toc_line(line)
if entry:
entries.append(entry)
return entries
def validate_files(docs_dir: pathlib.Path, entries: list[TocEntry]) -> None:
referenced_files = {entry.path + ".md" for entry in entries}
referenced_files.add("index.md")
existing_files = {f.name for f in docs_dir.glob("*.md")}
missing = referenced_files - existing_files
if missing:
print(f"Error: TOC references files that don't exist: {missing}", file=sys.stderr)
sys.exit(1)
unreferenced = existing_files - referenced_files
if unreferenced:
print(f"Error: Markdown files exist that are not in TOC: {unreferenced}", file=sys.stderr)
sys.exit(1)
def build_navigation(entries: list[TocEntry]) -> dict[str, Navigation]:
nav_map = {}
for i, entry in enumerate(entries):
if entry.level == 0:
up = ("Documentation", ".")
else:
parent_entry = next(e for e in entries if e.number.rstrip(".") == entry.parent)
up = (f"{parent_entry.number} {parent_entry.title}", parent_entry.path)
prev_entry = None
for j in range(i - 1, -1, -1):
if entries[j].level == entry.level:
prev_entry = entries[j]
break
next_entry = None
for j in range(i + 1, len(entries)):
if entries[j].level == entry.level:
next_entry = entries[j]
break
if prev_entry:
prev = (prev_entry.number, prev_entry.title, prev_entry.path)
else:
prev = None
if next_entry:
next_nav = (next_entry.number, next_entry.title, next_entry.path)
else:
next_nav = None
entry_number_no_dots = entry.number.rstrip(".")
children = [e for e in entries if e.parent == entry_number_no_dots]
nav_map[entry.path] = Navigation(up, prev, next_nav, children)
return nav_map
def extract_h2_headings(content: str) -> list[tuple[str, str]]:
lines = content.splitlines()
headings = []
for line in lines:
match = re.match(r"^##\s+(.+)$", line)
if match:
title = match.group(1)
anchor = title.lower()
anchor = re.sub(r"^\d+\.\s*", "", anchor)
anchor = re.sub(r"[^\w\s-]", "", anchor)
anchor = re.sub(r"[\s_]+", "-", anchor)
anchor = anchor.strip("-")
headings.append((title, anchor))
return headings
def generate_navigation_block(entry: TocEntry, nav: Navigation, h2_headings: list[tuple[str, str]]) -> str:
blocks = []
if nav.up is not None:
up_text = nav.up[0]
up_parts = up_text.split(" ", 1)
if (len(up_parts) == 2) and (up_parts[0].rstrip(".").replace(".", "").isdigit()):
blocks.append(f"**Up**: `{up_parts[0]}` [{up_parts[1]}]({nav.up[1]})")
else:
blocks.append(f"**Up**: [{up_text}]({nav.up[1]})")
blocks.append("")
if nav.prev is not None:
blocks.append(f"**Prev**: `{nav.prev[0]}` [{nav.prev[1]}]({nav.prev[2]})")
else:
blocks.append("**Prev**: (none)")
blocks.append("")
if nav.next is not None:
blocks.append(f"**Next**: `{nav.next[0]}` [{nav.next[1]}]({nav.next[2]})")
else:
blocks.append("**Next**: (none)")
blocks.append("")
if nav.children:
blocks.append("**Pages**:")
blocks.append("")
for child in nav.children:
indent = " " * (child.level - entry.level - 1)
blocks.append(f"{indent}* `{child.number}` [{child.title}]({child.path})")
blocks.append("")
if h2_headings:
blocks.append("**Sections**:")
blocks.append("")
for title, anchor in h2_headings:
blocks.append(f"* [{title}](#{anchor})")
blocks.append("")
return "\n".join(blocks)
def update_document(docs_dir: pathlib.Path, entry: TocEntry, nav: Navigation) -> None:
file_path = docs_dir / f"{entry.path}.md"
content = file_path.read_text(encoding="utf-8")
lines = content.splitlines()
h2_headings = extract_h2_headings(content)
first_h2_index = None
for i, line in enumerate(lines):
if re.match(r"^##\s+", line):
first_h2_index = i
break
if first_h2_index is None:
print(f"Warning: {entry.path}.md has no h2 sections", file=sys.stderr)
content_lines = lines
else:
content_lines = lines[first_h2_index:]
new_heading = f"# {entry.number} {entry.title}"
nav_block = generate_navigation_block(entry, nav, h2_headings)
new_lines = [new_heading, "", nav_block, *content_lines]
new_content = "\n".join(new_lines)
if not new_content.endswith("\n"):
new_content += "\n"
file_path.write_text(new_content, encoding="utf-8")
def main() -> None:
docs_dir = pathlib.Path("atr/docs")
index_path = docs_dir / "index.md"
if not index_path.exists():
print(f"Error: {index_path} not found", file=sys.stderr)
sys.exit(1)
entries = parse_toc(index_path)
if not entries:
print("Error: No TOC entries found in index.md", file=sys.stderr)
sys.exit(1)
validate_files(docs_dir, entries)
nav_map = build_navigation(entries)
for entry in entries:
update_document(docs_dir, entry, nav_map[entry.path])
print(f"Updated {len(entries)} documentation files")
if __name__ == "__main__":
main()