From 5a8d568931d9b23ce0df1265d05259a7012081c9 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 14 Jun 2026 01:46:29 +0300 Subject: init: mostly vibed --- scripts/gen_bots.py | 219 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 scripts/gen_bots.py (limited to 'scripts') diff --git a/scripts/gen_bots.py b/scripts/gen_bots.py new file mode 100644 index 0000000..ddb2d54 --- /dev/null +++ b/scripts/gen_bots.py @@ -0,0 +1,219 @@ +#!/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"), + ("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]) -- cgit v1.2.3