runv-server

server tooling for runv.club
Log | Files | Refs | README

build_wiki.py (8731B)


      1 #!/usr/bin/env python3
      2 """
      3 Gera HTML estático em site/public/wiki/ a partir dos .txt em site/wiki/.
      4 Executar localmente antes de site/genlanding.py. Não copia para o servidor
      5 por si — só o conteúdo de site/public/ é implantado.
      6 
      7 Apenas biblioteca padrão Python 3.
      8 """
      9 
     10 from __future__ import annotations
     11 
     12 import html
     13 import re
     14 import sys
     15 from pathlib import Path
     16 
     17 SCRIPT_DIR = Path(__file__).resolve().parent
     18 SITE_DIR = SCRIPT_DIR.parent
     19 ADMIN_DIR = SITE_DIR.parent / "scripts" / "admin"
     20 if str(ADMIN_DIR) not in sys.path:
     21     sys.path.insert(0, str(ADMIN_DIR))
     22 
     23 from admin_guard import ensure_admin_cli
     24 
     25 OUT_DIR = SITE_DIR / "public" / "wiki"
     26 SITEMAP_PATH = SITE_DIR / "public" / "sitemap.xml"
     27 
     28 TXT_GLOB = "[0-9][0-9]_*.txt"
     29 SLUG_RE = re.compile(r"^(\d+)_(.+)\.txt$")
     30 
     31 
     32 def eprint(*args: object) -> None:
     33     print(*args, file=sys.stderr)
     34 
     35 
     36 def is_heading_line(s: str) -> bool:
     37     s = s.strip()
     38     if not s or len(s) > 120:
     39         return False
     40     letters = [c for c in s if c.isalpha()]
     41     if not letters:
     42         return False
     43     return all(c.isupper() for c in letters)
     44 
     45 
     46 def paragraph_blocks(text: str) -> list[list[str]]:
     47     lines = text.strip().splitlines()
     48     blocks: list[list[str]] = []
     49     cur: list[str] = []
     50     for line in lines:
     51         if not line.strip():
     52             if cur:
     53                 blocks.append(cur)
     54                 cur = []
     55         else:
     56             cur.append(line.rstrip())
     57     if cur:
     58         blocks.append(cur)
     59     return blocks
     60 
     61 
     62 def block_to_html(block: list[str], *, is_first: bool) -> str:
     63     if len(block) == 1:
     64         line = block[0].strip()
     65         if is_first:
     66             return f'<h1 class="hero-title subpage-title wiki-page-title">{html.escape(line)}</h1>'
     67         if is_heading_line(line):
     68             return f"<h2>{html.escape(line)}</h2>"
     69         return f"<p>{html.escape(line)}</p>"
     70 
     71     stripped = [l.strip() for l in block if l.strip()]
     72     if stripped and all(
     73         s.startswith("- ") or s.startswith("– ") or s.startswith("— ") for s in stripped
     74     ):
     75         items = []
     76         for s in stripped:
     77             for prefix in ("- ", "– ", "— "):
     78                 if s.startswith(prefix):
     79                     items.append(s[len(prefix) :])
     80                     break
     81         lis = "".join(f"<li>{html.escape(i)}</li>" for i in items)
     82         return f"<ul>{lis}</ul>"
     83 
     84     inner = "<br>\n".join(html.escape(l) for l in block)
     85     return f"<p>{inner}</p>"
     86 
     87 
     88 def txt_to_article_body(raw: str) -> str:
     89     blocks = paragraph_blocks(raw)
     90     parts: list[str] = []
     91     for i, b in enumerate(blocks):
     92         parts.append(block_to_html(b, is_first=(i == 0)))
     93     return "\n\n".join(parts)
     94 
     95 
     96 def page_shell(
     97     *,
     98     title: str,
     99     description: str,
    100     body_main: str,
    101     nav_pages: list[tuple[str, str]],
    102     current_slug: str | None,
    103 ) -> str:
    104     nav_items = []
    105     for slug, label in nav_pages:
    106         if current_slug is not None and slug == current_slug:
    107             nav_items.append(
    108                 f'<span class="hero-nav-current" aria-current="page">{html.escape(label)}</span>'
    109             )
    110         else:
    111             href = "/wiki/" if slug == "index" else f"/wiki/{slug}.html"
    112             nav_items.append(f'<a href="{html.escape(href, quote=True)}">{html.escape(label)}</a>')
    113     nav_inner = '\n        <span class="hero-nav-sep" aria-hidden="true">·</span>\n        '.join(
    114         nav_items
    115     )
    116     return f"""<!DOCTYPE html>
    117 <html lang="pt-BR">
    118 <head>
    119   <meta charset="utf-8">
    120   <meta name="viewport" content="width=device-width, initial-scale=1">
    121   <title>{html.escape(title)}</title>
    122   <meta name="description" content="{html.escape(description)}">
    123   <link rel="preconnect" href="https://fonts.googleapis.com">
    124   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    125   <link href="https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@400;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
    126   <link rel="icon" href="/favicon.svg" type="image/svg+xml">
    127   <link rel="stylesheet" href="../assets/style.css">
    128 </head>
    129 <body>
    130   <div class="wrap">
    131     <nav class="top-nav"><a href="/">← runv.club</a></nav>
    132 
    133     <header>
    134       <p class="eyebrow">runv.club</p>
    135       <nav class="hero-nav wiki-hero-nav" aria-label="Páginas da wiki">
    136         <a href="/news/">Notícias</a>
    137         <span class="hero-nav-sep" aria-hidden="true">·</span>
    138         {nav_inner}
    139         <span class="hero-nav-sep" aria-hidden="true">·</span>
    140         <a href="/junte-se/">Junte-se</a>
    141       </nav>
    142     </header>
    143 
    144     <main class="section prose-block subpage-main wiki-main">
    145 {body_main}
    146     </main>
    147 
    148     <footer class="site-footer">
    149       <p>Administração: <a href="mailto:admin@runv.club">admin@runv.club</a><span class="footer-sep" aria-hidden="true"> · </span><a href="/faq/" class="footer-link-discrete">FAQ</a></p>
    150     </footer>
    151   </div>
    152 </body>
    153 </html>
    154 """
    155 
    156 
    157 LABELS: dict[str, str] = {
    158     "index": "Índice",
    159     "visao-geral": "Visão geral",
    160     "contas-e-acesso": "Contas e acesso",
    161     "regras-da-comunidade": "Regras",
    162     "punicoes-e-moderacao": "Punições",
    163     "privacidade-e-seguranca": "Privacidade",
    164     "faq": "FAQ wiki",
    165 }
    166 
    167 
    168 def slug_and_label(path: Path) -> tuple[str, str] | None:
    169     m = SLUG_RE.match(path.name)
    170     if not m:
    171         return None
    172     slug = m.group(2)
    173     label = LABELS.get(slug, slug.replace("-", " ").title())
    174     return slug, label
    175 
    176 
    177 def first_line_title(raw: str) -> str:
    178     for line in raw.strip().splitlines():
    179         t = line.strip()
    180         if t:
    181             return t[:70] + ("…" if len(t) > 70 else "")
    182     return "Wiki"
    183 
    184 
    185 def build_nav_order(paths: list[Path]) -> list[tuple[str, str]]:
    186     ordered: list[tuple[str, str]] = []
    187     for p in sorted(paths):
    188         sl = slug_and_label(p)
    189         if sl:
    190             ordered.append(sl)
    191     # Índice primeiro na nav
    192     idx = next((i for i, (s, _) in enumerate(ordered) if s == "index"), None)
    193     if idx is not None and idx > 0:
    194         ordered.insert(0, ordered.pop(idx))
    195     return ordered
    196 
    197 
    198 def patch_sitemap(wiki_urls: list[str]) -> None:
    199     if not SITEMAP_PATH.is_file():
    200         return
    201     text = SITEMAP_PATH.read_text(encoding="utf-8")
    202     marker_start = "  <!-- wiki:gerado -->"
    203     marker_end = "  <!-- /wiki:gerado -->"
    204     block_lines = [marker_start]
    205     for url in wiki_urls:
    206         block_lines.append("  <url>")
    207         block_lines.append(f"    <loc>{html.escape(url)}</loc>")
    208         block_lines.append("  </url>")
    209     block_lines.append(marker_end)
    210     new_block = "\n".join(block_lines) + "\n"
    211 
    212     if marker_start in text and marker_end in text:
    213         before, rest = text.split(marker_start, 1)
    214         _, after = rest.split(marker_end, 1)
    215         text = before + new_block + after.lstrip("\n")
    216     else:
    217         text = text.replace(
    218             "</urlset>",
    219             new_block + "</urlset>",
    220             1,
    221         )
    222     SITEMAP_PATH.write_text(text, encoding="utf-8")
    223 
    224 
    225 def main() -> int:
    226     ensure_admin_cli(script_name=Path(__file__).name)
    227     txt_files = sorted(SCRIPT_DIR.glob(TXT_GLOB))
    228     if not txt_files:
    229         eprint("Nenhum ficheiro", TXT_GLOB, "em", SCRIPT_DIR)
    230         return 1
    231 
    232     OUT_DIR.mkdir(parents=True, exist_ok=True)
    233     nav_pages = build_nav_order(txt_files)
    234 
    235     base_url = "https://runv.club"
    236     wiki_urls: list[str] = [f"{base_url}/wiki/"]
    237 
    238     for path in txt_files:
    239         sl = slug_and_label(path)
    240         if not sl:
    241             continue
    242         slug, _label = sl
    243         raw = path.read_text(encoding="utf-8")
    244         title_line = first_line_title(raw)
    245         article = txt_to_article_body(raw)
    246 
    247         if slug == "index":
    248             # Navegação já está em hero-nav; o corpo vem só de 01_index.txt.
    249             body_main = article
    250             out_name = "index.html"
    251             current = "index"
    252             desc = "Início da wiki runv.club: regras, contas, privacidade e FAQ."
    253         else:
    254             body_main = article
    255             out_name = f"{slug}.html"
    256             current = slug
    257             desc = f"{title_line} — wiki runv.club."
    258             wiki_urls.append(f"{base_url}/wiki/{slug}.html")
    259 
    260         full_title = f"{title_line} — Wiki runv.club" if slug != "index" else "Wiki — runv.club"
    261         html_out = page_shell(
    262             title=full_title,
    263             description=desc,
    264             body_main=body_main,
    265             nav_pages=nav_pages,
    266             current_slug=current,
    267         )
    268         (OUT_DIR / out_name).write_text(html_out, encoding="utf-8")
    269         print("Wrote", OUT_DIR / out_name)
    270 
    271     wiki_urls = sorted(set(wiki_urls))
    272     patch_sitemap(wiki_urls)
    273     print("Updated", SITEMAP_PATH)
    274     return 0
    275 
    276 
    277 if __name__ == "__main__":
    278     raise SystemExit(main())