#!/usr/bin/env python3 """Generate bot documentation HTML pages from docs/*.md.""" import os import re import sys BOTS = [ ("alfred", "Server management, bot control, file hosting, soju bouncer"), ("jeeves", "DJ queue, duck hunting, recipes, news, quotes, art"), ("osterman", "Channel moderation, auto-modes, idle tracking"), ("daffy", "Duck hunting game"), ("contessa", "Recipe suggestions"), ("shireen", "News headlines and weather"), ("daft", "YouTube playlist manager"), ("herald", "Owner-follow bot and keyword responder"), ("salvador", "Image-to-mIRC-art renderer"), ] STYLE = """\ @font-face { font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Regular.woff2); font-weight: normal; } @font-face { font-family: "Kawkab Mono"; src: url(/fonts/KawkabMono-Bold.woff2); font-weight: bold; } * { unicode-bidi: plaintext; box-sizing: border-box; } html { color: black; background-color: white; } body { font-family: "Kawkab Mono"; font-size: 16px; line-height: 1.4; margin: 0; padding: 4rem 0; min-height: 100%; overflow-wrap: break-word; } main, header, footer { max-width: 800px; margin-inline: auto; padding: 0 2rem; } h1, header, footer { text-align: center; } main { text-align: left; } p, h2, h3, h4 { margin: 1em 0 0 0; } table { margin: auto; border-collapse: collapse; } th, td { border: 1px solid; padding: 0.3em 0.8em; } pre { background: rgba(128,128,128,0.08); padding: 1em; overflow-x: auto; margin: 1em 0; } code { font-size: 85%; } header { margin-bottom: 1em; } footer { margin-top: 3em; } a { color: inherit; } @media (max-width: 600px) { body { font-size: 0.9em; } h1 { font-size: 1.8em; } } @media (max-width: 400px) { body { font-size: 0.8em; } h1 { font-size: 1.6em; } } @media (prefers-color-scheme: dark) { html { filter: invert(1); } img { filter: invert(1); } }""" FOOTER = "" def page(title, header_html, content_html): return f""" {title}
{header_html}
{content_html}
{FOOTER} """ def md_to_html(text): """Minimal markdown → HTML: headers, code blocks, inline code, bold, links, paragraphs.""" lines = text.splitlines() out = [] i = 0 while i < len(lines): line = lines[i] # fenced code block if line.strip().startswith("```"): lang = line.strip()[3:].strip() i += 1 code_lines = [] while i < len(lines) and not lines[i].strip().startswith("```"): code_lines.append(_esc(lines[i])) i += 1 out.append(f"
{chr(10).join(code_lines)}
") i += 1 continue # ATX headers m = re.match(r'^(#{1,4})\s+(.*)', line) if m: level = len(m.group(1)) out.append(f"{_inline(m.group(2))}") i += 1 continue # table row if '|' in line and line.strip().startswith('|'): table_lines = [] while i < len(lines) and '|' in lines[i] and lines[i].strip().startswith('|'): table_lines.append(lines[i]) i += 1 out.append(_table(table_lines)) continue # blank line if not line.strip(): i += 1 continue # paragraph para = [line] i += 1 while i < len(lines) and lines[i].strip() and not lines[i].startswith('#') and not lines[i].strip().startswith('```') and not ('|' in lines[i] and lines[i].strip().startswith('|')): para.append(lines[i]) i += 1 out.append(f"

{_inline(' '.join(para))}

") return "\n".join(out) def _table(rows): html = [""] for j, row in enumerate(rows): cells = [c.strip() for c in row.strip().strip('|').split('|')] if all(re.match(r'^[-: ]+$', c) for c in cells): continue tag = "th" if j == 0 else "td" html.append("" + "".join(f"<{tag}>{_inline(c)}" for c in cells) + "") html.append("
") return "\n".join(html) def _inline(text): text = _esc(text) # bold text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) # inline code text = re.sub(r'`([^`]+)`', r'\1', text) # links text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) return text def _esc(text): return text.replace("&", "&").replace("<", "<").replace(">", ">") def build(src_dir, out_dir): os.makedirs(out_dir, exist_ok=True) # Copy fonts fonts_src = os.path.join(src_dir, "fonts") fonts_dst = os.path.join(out_dir, "fonts") if os.path.isdir(fonts_src): os.makedirs(fonts_dst, exist_ok=True) for f in os.listdir(fonts_src): src = os.path.join(fonts_src, f) dst = os.path.join(fonts_dst, f) with open(src, "rb") as fh: data = fh.read() with open(dst, "wb") as fh: fh.write(data) # Per-bot pages for name, desc in BOTS: doc_path = os.path.join(src_dir, "docs", f"{name}.md") if not os.path.exists(doc_path): continue with open(doc_path) as f: md = f.read() content = md_to_html(md) html = page( f"{name} — irc.gumx.cc", f'

irc.gumx.cc / bots / {name}

', content, ) bot_dir = os.path.join(out_dir, name) os.makedirs(bot_dir, exist_ok=True) with open(os.path.join(bot_dir, "index.html"), "w") as f: f.write(html) # Index page rows = "\n".join( f'{name}{desc}' for name, desc in BOTS if os.path.exists(os.path.join(src_dir, "docs", f"{name}.md")) ) index_content = f"""

bots

{rows}
botpurpose

All bots run Sopel. Source: git.gumx.cc/irc-bots.

""" index_html = page( "irc bots — irc.gumx.cc", '

irc.gumx.cc / bots

', index_content, ) with open(os.path.join(out_dir, "index.html"), "w") as f: f.write(index_html) print(f"Generated {len(BOTS)} bot pages + index in {out_dir}") if __name__ == "__main__": if len(sys.argv) != 3: print(f"Usage: {sys.argv[0]} ", file=sys.stderr) sys.exit(1) build(sys.argv[1], sys.argv[2])