Files
breadcrumb-the-shire/bin/fix-md-umlauts.py
fs 545bcc789b docs: replace ASCII umlaut fallbacks with proper äöüß across all .md
Most German docs were written with `ue`/`ae`/`oe`/`ss` ASCII fallbacks
(`fuer`, `aenderung`, `koennen`, `gemaess`) — relic from older toolchain
constraints. Replaced 237 occurrences across 38 .md files with proper
umlauts and ß so the docs read naturally.

Substitutions are constrained to plain prose: fenced code blocks
(```...```), inline code spans (`...`), markdown link targets `[..](..)`,
and HTML comments are preserved verbatim. Path references like
`docs/howto-erste-aenderung.md` keep their original (umlaut-replaced)
filenames — only the surrounding prose is normalised.

Tool: bin/fix-md-umlauts.py (idempotent — no-op on already-clean files).
Re-runnable if ASCII fallbacks creep back in via copy-paste.

Doc-link-check + doc-drift-check stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:57:22 +02:00

263 lines
7.8 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <file.md> [<file.md> ...]
--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))