356 lines
11 KiB
Python
356 lines
11 KiB
Python
"""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'<a href="{html.escape(match.group(2), quote=True)}">{html.escape(match.group(1))}</a>'
|
|
)
|
|
return f"@@LINK{len(placeholders) - 1}@@"
|
|
|
|
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", keep_link, text)
|
|
text = html.escape(text)
|
|
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", 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 = [
|
|
'<table style="width:100%; border-collapse:collapse; margin:1rem 0 1.5rem 0; font-size:0.95rem;">',
|
|
"<thead>",
|
|
'<tr style="background:#eef3ff;">',
|
|
]
|
|
for cell in header:
|
|
out.append(
|
|
'<th style="text-align:left; padding:0.55rem 0.7rem; '
|
|
'border-bottom:2px solid #4051b5;">'
|
|
f"{render_inline(cell)}</th>"
|
|
)
|
|
out.extend(["</tr>", "</thead>", "<tbody>"])
|
|
for row in body:
|
|
out.append('<tr style="border-bottom:1px solid #e0e0e0;">')
|
|
for cell in row:
|
|
out.append(
|
|
'<td style="vertical-align:top; padding:0.5rem 0.7rem;">'
|
|
f"{render_inline(cell)}</td>"
|
|
)
|
|
out.append("</tr>")
|
|
out.extend(["</tbody>", "</table>"])
|
|
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'<strong style="display:block; margin-bottom:0.35rem;">{render_inline(title)}</strong>'
|
|
if title
|
|
else ""
|
|
)
|
|
body_html = " ".join(render_inline(line) for line in body)
|
|
return (
|
|
'<div style="border-left:0.28rem solid #4051b5; background:#f5f7ff; '
|
|
'padding:0.85rem 1rem; margin:1rem 0 1.4rem 0; border-radius:0.35rem;">'
|
|
f"{title_html}<span>{body_html}</span></div>"
|
|
)
|
|
|
|
|
|
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'<ul style="margin:0.65rem 0 1rem {margin}; padding-left:1rem; list-style-type:{bullet};">'
|
|
]
|
|
for item in items:
|
|
children = item["children"]
|
|
parts.append(
|
|
'<li style="margin:0.38rem 0;">'
|
|
f"{render_inline(str(item['content']))}"
|
|
)
|
|
if children:
|
|
parts.append(render_items(children, level + 1))
|
|
parts.append("</li>")
|
|
parts.append("</ul>")
|
|
return "\n".join(parts)
|
|
|
|
return render_items(root)
|
|
|
|
|
|
def render_source(text: str) -> str:
|
|
lines = text.splitlines()
|
|
out = [
|
|
"<!-- Generated from docs_source/business by docs/tools/render_business_docs.py. -->",
|
|
"",
|
|
]
|
|
in_code = False
|
|
code_lines: list[str] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
if line.startswith("```"):
|
|
if in_code:
|
|
out.append(
|
|
'<pre style="background:#263238; color:#eef7ff; padding:1rem; '
|
|
'border-radius:0.35rem; overflow:auto;"><code>'
|
|
+ html.escape("\n".join(code_lines))
|
|
+ "</code></pre>"
|
|
)
|
|
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()
|