#!/usr/bin/env python3 """ Replace ASCII-fallback German umlauts (ue, ae, oe, ss) with proper umlauts inside .md prose, while preserving: - fenced code blocks (``` ... ```) - inline code spans (` ... `) - markdown link targets ([label](path)) — only the (path) part is preserved - HTML comments () Usage: python3 bin/fix-md-umlauts.py [ ...] --dry-run print proposed changes per file (default: write in place) --check exit 1 if any file would change (CI mode) """ from __future__ import annotations import re import sys from pathlib import Path # Order matters: longer patterns first to prevent partial matches # (e.g. "aenderung" must be tried before "aender" so prose-ending stays clean). SUBSTITUTIONS: list[tuple[str, str]] = [ # ß / ss ("massgeblich", "maßgeblich"), ("Massgeblich", "Maßgeblich"), ("gemaess", "gemäß"), ("Gemaess", "Gemäß"), ("regelmaessig", "regelmäßig"), ("Regelmaessig", "Regelmäßig"), ("groesst", "größt"), ("Groesst", "Größt"), # ä – longer first ("aenderung", "änderung"), ("Aenderung", "Änderung"), ("aenderbar", "änderbar"), ("aendert", "ändert"), ("aendern", "ändern"), ("Aender", "Änder"), ("aender", "änder"), ("naechst", "nächst"), ("Naechst", "Nächst"), ("naemlich", "nämlich"), ("Naemlich", "Nämlich"), ("waehrend", "während"), ("Waehrend", "Während"), ("waehl", "wähl"), ("Waehl", "Wähl"), ("haeufig", "häufig"), ("Haeufig", "Häufig"), ("spaeter", "später"), ("Spaeter", "Später"), ("aufzaehl", "aufzähl"), ("Aufzaehl", "Aufzähl"), ("gefaehr", "gefähr"), ("Gefaehr", "Gefähr"), ("erlaeut", "erläut"), ("Erlaeut", "Erläut"), ("bestaetig", "bestätig"), ("Bestaetig", "Bestätig"), ("Geschaeft", "Geschäft"), ("geschaeft", "geschäft"), # ü – longer first ("ausfuehr", "ausführ"), ("Ausfuehr", "Ausführ"), ("durchfuehr", "durchführ"), ("Durchfuehr", "Durchführ"), ("einfuehr", "einführ"), ("Einfuehr", "Einführ"), ("fuehrun", "führun"), ("Fuehrun", "Führun"), ("fuehrt", "führt"), ("fuehren", "führen"), ("Fuehren", "Führen"), ("verfuegb", "verfügb"), ("Verfuegb", "Verfügb"), ("unterstuetz", "unterstütz"), ("Unterstuetz", "Unterstütz"), ("nuetzlich", "nützlich"), ("Nuetzlich", "Nützlich"), ("Schluess", "Schlüss"), ("schluess", "schlüss"), ("wuerde", "würde"), ("Wuerde", "Würde"), ("wuerden", "würden"), ("MUESSEN", "MÜSSEN"), ("muessen", "müssen"), ("Muessen", "Müssen"), ("muess", "müss"), ("Muess", "Müss"), ("KOENNEN", "KÖNNEN"), ("koennen", "können"), ("Koennen", "Können"), ("koenn", "könn"), ("Koenn", "Könn"), ("natuerli", "natürli"), ("Natuerli", "Natürli"), ("tatsaechli", "tatsächli"), ("Tatsaechli", "Tatsächli"), ("aehnlich", "ähnlich"), ("Aehnlich", "Ähnlich"), ("standardmaessig", "standardmäßig"), ("Standardmaessig", "Standardmäßig"), ("gleichmaessig", "gleichmäßig"), ("Gleichmaessig", "Gleichmäßig"), ("zugaengli", "zugängli"), ("Zugaengli", "Zugängli"), ("uebersicht", "übersicht"), ("Uebersicht", "Übersicht"), ("uebersetzun", "übersetzun"), ("Uebersetzun", "Übersetzun"), ("uebernahme", "übernahme"), ("Uebernahme", "Übernahme"), ("uebernehm", "übernehm"), ("Uebernehm", "Übernehm"), ("hoeher", "höher"), ("Hoeher", "Höher"), ("pruefen", "prüfen"), ("Pruefen", "Prüfen"), ("prueft", "prüft"), ("Prueft", "Prüft"), ("Pruefung", "Prüfung"), ("pruefung", "prüfung"), ("pruef", "prüf"), ("Pruef", "Prüf"), ("ueber", "über"), ("Ueber", "Über"), ("fuer", "für"), # ö ("oeffn", "öffn"), ("Oeffn", "Öffn"), ("loescht", "löscht"), ("Loescht", "Löscht"), ("loeschen", "löschen"), ("Loeschen", "Löschen"), ("loesung", "lösung"), ("Loesung", "Lösung"), ("moegli", "mögli"), ("Moegli", "Mögli"), ("moeglich", "möglich"), ("Moeglich", "Möglich"), ("noetig", "nötig"), ("Noetig", "Nötig"), ("hoer", "hör"), # gehör, höher ("Hoer", "Hör"), ] # Regex that splits a line into "preserve" tokens and replaceable text. # Order matters: longer / more specific tokens first. TOKENIZER = re.compile( r""" ( `[^`\n]*` ) # inline code: `...` | ( !?\[[^\]]*\]\([^)]*\) ) # full markdown link [label](target) | ( ) # HTML comment """, re.VERBOSE | re.DOTALL, ) def transform_text(text: str) -> str: """Apply substitutions to non-preserved spans only.""" for needle, replacement in SUBSTITUTIONS: text = text.replace(needle, replacement) return text def transform_line(line: str) -> str: """Tokenise a line into [preserved | text | preserved | text | ...] and replace only text segments.""" out: list[str] = [] pos = 0 for match in TOKENIZER.finditer(line): # Replaceable run before the preserved span if match.start() > pos: out.append(transform_text(line[pos : match.start()])) out.append(match.group(0)) # keep preserved span verbatim pos = match.end() # Tail after last preserved span if pos < len(line): out.append(transform_text(line[pos:])) return "".join(out) def transform_file(content: str) -> str: """Walk lines, skip fenced code blocks, transform the rest line-by-line.""" out_lines: list[str] = [] in_fence = False fence_marker = "" for line in content.splitlines(keepends=True): stripped = line.lstrip() if in_fence: out_lines.append(line) if stripped.startswith(fence_marker): in_fence = False fence_marker = "" continue # Detect fenced code start (``` or ~~~) if stripped.startswith("```"): in_fence = True fence_marker = "```" out_lines.append(line) continue if stripped.startswith("~~~"): in_fence = True fence_marker = "~~~" out_lines.append(line) continue out_lines.append(transform_line(line)) return "".join(out_lines) def main(argv: list[str]) -> int: args = list(argv[1:]) dry_run = False check = False while args and args[0].startswith("--"): flag = args.pop(0) if flag == "--dry-run": dry_run = True elif flag == "--check": check = True elif flag == "--": break else: print(f"Unknown flag: {flag}", file=sys.stderr) return 2 if not args: print(__doc__.strip(), file=sys.stderr) return 2 changed_files: list[Path] = [] for raw in args: path = Path(raw) if not path.is_file(): print(f"Skipping (not a file): {path}", file=sys.stderr) continue original = path.read_text(encoding="utf-8") transformed = transform_file(original) if transformed != original: changed_files.append(path) if dry_run or check: # Brief summary; full diff is what `git diff` shows after a real run. added = sum(1 for c in transformed if c in "äöüÄÖÜß") - sum( 1 for c in original if c in "äöüÄÖÜß" ) print(f"WOULD CHANGE {path} (+{added} umlauts)") else: path.write_text(transformed, encoding="utf-8") print(f"updated {path}") if check and changed_files: return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))