"""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 = [ '', "", '', ] for cell in header: out.append( '" ) out.extend(["", "", ""]) for row in body: out.append('') for cell in row: out.append( '" ) out.append("") out.extend(["", "
' f"{render_inline(cell)}
' f"{render_inline(cell)}
"]) return "\n".join(out) def render_callout(lines: list[str]) -> str: title = "" body = [] for line in lines: content = line[1:].strip() if content.startswith("**") and content.endswith("**") and not title: title = content.strip("*") elif content: body.append(content) title_html = ( f'{render_inline(title)}' if title else "" ) body_html = " ".join(render_inline(line) for line in body) return ( '
' f"{title_html}{body_html}
" ) def is_bullet_line(line: str) -> bool: return bool(re.match(r"^\s*-\s+", line)) def bullet_level(line: str) -> int: return len(re.match(r"^\s*", line).group(0)) // 2 def render_list(lines: list[str]) -> str: root: list[dict[str, object]] = [] stack: list[tuple[int, list[dict[str, object]]]] = [(-1, root)] last_item: dict[str, object] | None = None for line in lines: if is_bullet_line(line): level = bullet_level(line) content = re.sub(r"^\s*-\s+", "", line).strip() while stack[-1][0] >= level: stack.pop() item: dict[str, object] = { "content": content, "children": [], } stack[-1][1].append(item) stack.append((level, item["children"])) last_item = item elif line.strip() and last_item is not None: last_item["content"] = str(last_item["content"]) + " " + line.strip() def render_items(items: list[dict[str, object]], level: int = 0) -> str: margin = "1.1rem" if level == 0 else "1.35rem" bullet = "disc" if level == 0 else "circle" parts = [ f'") return "\n".join(parts) return render_items(root) def render_source(text: str) -> str: lines = text.splitlines() out = [ "", "", ] in_code = False code_lines: list[str] = [] i = 0 while i < len(lines): line = lines[i] if line.startswith("```"): if in_code: 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()