"""Render purchase_trade business docs for the MkDocs wiki.
The files in ``docs_source/business`` are the source of truth. The generated
files are written back to ``docs/business`` because the wiki already points to
``docs``.
"""
from __future__ import annotations
import argparse
import html
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUSINESS = ROOT / "business"
SOURCE = ROOT.parent / "docs_source" / "business"
MATERIAL_ICON = re.compile(r":material-[a-z0-9-]+:\s*")
def clean_inline(text: str) -> str:
text = MATERIAL_ICON.sub("", text)
text = re.sub(r"\s{2,}", " ", text)
return text.strip()
def split_table_row(line: str) -> list[str]:
return [cell.strip() for cell in line.strip().strip("|").split("|")]
def is_separator_row(line: str) -> bool:
cells = split_table_row(line)
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells)
def normalize_table(lines: list[str], index: int) -> tuple[list[str], int]:
rows = []
while index < len(lines) and lines[index].lstrip().startswith("|"):
rows.append(lines[index])
index += 1
if len(rows) < 2 or not is_separator_row(rows[1]):
return rows, index
table = [split_table_row(row) for row in rows]
header = [clean_inline(cell) for cell in table[0]]
drop_first = header and header[0].lower() in {
"repere",
"repère",
"marker",
"etape",
"étape",
"step",
}
if drop_first:
table = [row[1:] for row in table]
cleaned = []
for row_index, row in enumerate(table):
if row_index == 1 and is_separator_row("| " + " | ".join(row) + " |"):
row = ["---"] * len(table[0])
cleaned.append("| " + " | ".join(clean_inline(cell) for cell in row) + " |")
return cleaned, index
def normalize_source(text: str) -> str:
lines = text.splitlines()
out: list[str] = []
i = 0
in_code = False
while i < len(lines):
line = lines[i]
if line.startswith("```"):
in_code = not in_code
out.append(line)
i += 1
continue
if in_code:
out.append(line)
i += 1
continue
if line.startswith("!!! "):
match = re.match(r"!!!\s+\w+\s+\"(.+)\"", line)
title = clean_inline(match.group(1) if match else "Note")
out.append(f"> **{title}**")
i += 1
while i < len(lines) and (
lines[i].startswith(" ") or not lines[i].strip()
):
if lines[i].strip():
out.append("> " + clean_inline(lines[i].strip()))
else:
out.append(">")
i += 1
continue
if line.lstrip().startswith("|"):
table, i = normalize_table(lines, i)
if len(table) > 1 and is_separator_row(table[1]):
header_len = len(split_table_row(table[0]))
table[1] = "| " + " | ".join(["---"] * header_len) + " |"
out.extend(table)
continue
if not line.strip():
out.append("")
elif line.startswith("#"):
out.append(clean_inline(line))
else:
leading = re.match(r"^\s*", line).group(0)
out.append(leading + clean_inline(line.strip()))
i += 1
return "\n".join(out).strip() + "\n"
def render_inline(text: str) -> str:
placeholders: list[str] = []
def keep_link(match: re.Match[str]) -> str:
placeholders.append(
f'{html.escape(match.group(1))}'
)
return f"@@LINK{len(placeholders) - 1}@@"
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", keep_link, text)
text = html.escape(text)
text = re.sub(r"`([^`]+)`", r"\1", text)
for idx, value in enumerate(placeholders):
text = text.replace(f"@@LINK{idx}@@", value)
return text
def render_table(rows: list[str]) -> str:
parsed = [split_table_row(row) for row in rows]
header = parsed[0]
body = parsed[2:]
out = [
'
| ' f"{render_inline(cell)} | " ) out.extend(["
|---|
| ' f"{render_inline(cell)} | " ) out.append("
'
+ html.escape("\n".join(code_lines))
+ ""
)
code_lines = []
in_code = False
else:
in_code = True
i += 1
continue
if in_code:
code_lines.append(line)
i += 1
continue
if line.startswith(">"):
block = []
while i < len(lines) and lines[i].startswith(">"):
if not lines[i][1:].strip():
i += 1
break
block.append(lines[i])
i += 1
out.append(render_callout(block))
out.append("")
continue
if line.lstrip().startswith("|"):
table = []
while i < len(lines) and lines[i].lstrip().startswith("|"):
table.append(lines[i])
i += 1
if len(table) > 1 and is_separator_row(table[1]):
out.append(render_table(table))
else:
out.extend(table)
continue
if is_bullet_line(line):
list_lines = []
while i < len(lines) and (
is_bullet_line(lines[i])
or (
lines[i].startswith(" ")
and lines[i].strip()
and not lines[i].lstrip().startswith("|")
)
):
list_lines.append(lines[i])
i += 1
out.append(render_list(list_lines))
continue
out.append(line)
i += 1
return "\n".join(out).strip() + "\n"
def source_files() -> list[Path]:
return sorted(path for path in SOURCE.rglob("*.md") if path.is_file())
def render_all(normalize: bool = False, check: bool = False) -> bool:
SOURCE.mkdir(parents=True, exist_ok=True)
ok = True
for source in source_files():
target = BUSINESS / source.relative_to(SOURCE)
target.parent.mkdir(parents=True, exist_ok=True)
if normalize:
source.write_text(normalize_source(source.read_text(encoding="utf-8")), encoding="utf-8")
rendered = render_source(source.read_text(encoding="utf-8"))
if check:
if not target.exists() or target.read_text(encoding="utf-8") != rendered:
print(f"Outdated generated doc: {target.relative_to(ROOT.parent)}")
ok = False
else:
target.write_text(rendered, encoding="utf-8")
return ok
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--normalize-source",
action="store_true",
help="Clean existing source files from wiki-only syntax before rendering.",
)
parser.add_argument(
"--check",
action="store_true",
help="Fail if generated wiki files are not up to date.",
)
args = parser.parse_args()
if args.normalize_source and args.check:
parser.error("--normalize-source cannot be used with --check")
if not render_all(normalize=args.normalize_source, check=args.check):
sys.exit(1)
if __name__ == "__main__":
main()