#!/usr/bin/env python3 import html import json import os import re import urllib.request from datetime import datetime, timezone MAX_ENTRIES = 20 ROOT_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) OUTPUT_FILE = os.path.join(ROOT_DIR, "announcements.json") DOCS_DIR = os.path.join(ROOT_DIR, "docs") HTML_FILE = os.path.join(DOCS_DIR, "index.html") SOURCE_REPO = os.environ.get("SOURCE_REPO", "AeroCore-IO/AeroCore-OS") # branch -> (prerelease_filter, channel) BRANCH_CONFIG = { "stable": (False, "rel"), "testing": (True, "beta"), "unstable": (True, "preview"), } def branch_config(branch): """Return (prerelease, channel) for a git branch, or raise ValueError.""" if branch not in BRANCH_CONFIG: raise ValueError(f"unknown branch: {branch!r} (expected one of {sorted(BRANCH_CONFIG)})") return BRANCH_CONFIG[branch] def select_releases(releases, prerelease, limit=MAX_ENTRIES): """Filter releases by prerelease flag, drop drafts, sort newest-first, cap at limit.""" kept = [ r for r in releases if bool(r.get("prerelease")) == prerelease and not r.get("draft") ] kept.sort(key=lambda r: r.get("published_at") or "", reverse=True) return kept[:limit] KEEP_SECTIONS = {"Major packages", "Commits"} def extract_sections(body): """Keep only allowlisted '### ' sections from a release body.""" lines = (body or "").split("\n") result = [] keeping = False for line in lines: m = re.match(r"^#{1,6}\s+(.*)", line.strip()) if m: keeping = m.group(1).strip() in KEEP_SECTIONS if keeping: result.append(line) return "\n".join(result).strip() def normalize_release_date(published_at): """Parse GitHub ISO-8601 UTC timestamp into (display_str, unix_ts).""" dt = datetime.strptime(published_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) return dt.strftime("%Y-%m-%d %H:%M"), int(dt.timestamp()) def release_to_entry(release, channel): """Convert a GitHub release dict into an announcement entry.""" body = extract_sections(release.get("body", "")) date_str, timestamp = normalize_release_date(release["published_at"]) return { "title": release.get("name") or "", "date": date_str, "timestamp": timestamp, "channel": channel, "lang": "english", "body": body, "bbcode_body": md_to_bbcode(body), "html_body": md_to_html(body), } def fetch_releases(repo, token, urlopen=None): """Fetch all releases for a repo (paginated), newest first. stdlib only.""" opener = urlopen or urllib.request.urlopen results = [] page = 1 while True: url = f"https://api.github.com/repos/{repo}/releases?per_page=100&page={page}" req = urllib.request.Request(url) req.add_header("Accept", "application/vnd.github+json") req.add_header("User-Agent", "aerocore-os-gamemode-news") if token: req.add_header("Authorization", f"Bearer {token}") with opener(req, timeout=30) as resp: batch = json.loads(resp.read().decode("utf-8")) if not batch: break results.extend(batch) if len(batch) < 100: break page += 1 return results def _is_table_row(line): """Check if a line is a Markdown table row (| ... | ... |).""" return bool(re.match(r"^\|.*\|$", line.strip())) def _is_table_separator(line): """Check if a line is a Markdown table separator (|---|---|).""" return bool(re.match(r"^\|[-:\s|]+\|$", line.strip())) def _parse_table_cells(line): """Extract cell contents from a Markdown table row.""" return [c.strip() for c in line.strip().strip("|").split("|")] def md_to_bbcode(text): """Convert Markdown to Steam BBCode.""" lines = text.split("\n") result = [] in_ul = False in_ol = False in_quote = False in_code_block = False in_table = False table_header_done = False for line in lines: stripped = line.strip() # Fenced code block if stripped.startswith("```"): if in_code_block: result.append("[/code]") in_code_block = False else: in_code_block = True result.append("[code]") continue if in_code_block: result.append(line.rstrip()) continue # Table if _is_table_row(stripped): if _is_table_separator(stripped): continue cells = _parse_table_cells(stripped) if not in_table: result.append("[table]") in_table = True table_header_done = False tag = "th" if not table_header_done else "td" row = "[tr]" + "".join(f"[{tag}]{c}[/{tag}]" for c in cells) + "[/tr]" result.append(row) if not table_header_done: table_header_done = True continue elif in_table: result.append("[/table]") in_table = False # Unordered list if re.match(r"^[-*]\s+", stripped): if in_ol: result.append("[/olist]") in_ol = False item = re.sub(r"^[-*]\s+", "", stripped) if not in_ul: result.append("[list]") in_ul = True result.append(f"[*] {item}") continue elif in_ul: result.append("[/list]") in_ul = False # Ordered list if re.match(r"^\d+\.\s+", stripped): if in_ul: result.append("[/list]") in_ul = False item = re.sub(r"^\d+\.\s+", "", stripped) if not in_ol: result.append("[olist]") in_ol = True result.append(f"[*] {item}") continue elif in_ol: result.append("[/olist]") in_ol = False # Blockquote if stripped.startswith("> "): content = stripped[2:] if not in_quote: result.append("[quote]") in_quote = True result.append(content) continue elif in_quote: result.append("[/quote]") in_quote = False # Horizontal rule if re.match(r"^[-*_]{3,}$", stripped): result.append("[hr][/hr]") continue # Heading m = re.match(r"^(#{1,3})\s+(.*)", stripped) if m: level = len(m.group(1)) tag = f"h{level}" result.append(f"[{tag}]{m.group(2)}[/{tag}]") continue result.append(stripped) if in_table: result.append("[/table]") if in_ul: result.append("[/list]") if in_ol: result.append("[/olist]") if in_quote: result.append("[/quote]") if in_code_block: result.append("[/code]") text = "\n".join(result) # Protect [*] list markers from being caught by italic regex text = text.replace("[*]", "\x00LI\x00") # Images before links (![alt](url) vs [text](url)) text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"[img]\2[/img]", text) text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"[url=\2]\1[/url]", text) text = re.sub(r"\*\*(.+?)\*\*", r"[b]\1[/b]", text) text = re.sub(r"__(.+?)__", r"[u]\1[/u]", text) text = re.sub(r"\*(.+?)\*", r"[i]\1[/i]", text) text = re.sub(r"~~(.+?)~~", r"[strike]\1[/strike]", text) text = re.sub(r"(?") in_code_block = False else: in_code_block = True result.append("
")
            continue
        if in_code_block:
            result.append(html.escape(line.rstrip()))
            continue

        # Table
        if _is_table_row(stripped):
            if _is_table_separator(stripped):
                continue
            cells = _parse_table_cells(stripped)
            if not in_table:
                result.append("")
                in_table = True
                table_header_done = False
            tag = "th" if not table_header_done else "td"
            row = "" + "".join(f"<{tag}>{html.escape(c)}" for c in cells) + ""
            result.append(row)
            if not table_header_done:
                table_header_done = True
            continue
        elif in_table:
            result.append("
") in_table = False # Unordered list if re.match(r"^[-*]\s+", stripped): if in_ol: result.append("") in_ol = False item = re.sub(r"^[-*]\s+", "", stripped) if not in_ul: result.append("
    ") in_ul = True result.append(f"
  • {html.escape(item)}
  • ") continue elif in_ul: result.append("
") in_ul = False # Ordered list if re.match(r"^\d+\.\s+", stripped): if in_ul: result.append("") in_ul = False item = re.sub(r"^\d+\.\s+", "", stripped) if not in_ol: result.append("
    ") in_ol = True result.append(f"
  1. {html.escape(item)}
  2. ") continue elif in_ol: result.append("
") in_ol = False # Blockquote if stripped.startswith("> "): content = stripped[2:] if not in_quote: result.append("
") in_quote = True result.append(html.escape(content)) continue elif in_quote: result.append("
") in_quote = False # Horizontal rule if re.match(r"^[-*_]{3,}$", stripped): result.append("
") continue # Heading m = re.match(r"^(#{1,3})\s+(.*)", stripped) if m: level = len(m.group(1)) result.append(f"{html.escape(m.group(2))}") continue if stripped: result.append(f"

{html.escape(stripped)}

") else: result.append("") if in_table: result.append("") if in_ul: result.append("") if in_ol: result.append("") if in_quote: result.append("") if in_code_block: result.append("
") text = "\n".join(result) # Images before links text = re.sub( r"!\[([^\]]*)\]\(([^)]+)\)", r'\1', text, ) text = re.sub( r"\[([^\]]+)\]\(([^)]+)\)", r'\1', text, ) text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"__(.+?)__", r"\1", text) text = re.sub(r"\*(.+?)\*", r"\1", text) text = re.sub(r"~~(.+?)~~", r"\1", text) text = re.sub(r"(?\1", text) # unescape HTML tags generated by inline formatting inside escaped

/

  • // text = re.sub( r"<(/?(?:strong|em|del|u|a|code|img|ul|ol|li|blockquote|pre|h[1-3]|hr|table|tr|td|th)(?:\s[^>]*)?)>", r"<\1>", text, ) return text def main(): branch = os.environ.get("RELEASE_BRANCH", "").strip() prerelease, channel = branch_config(branch) token = os.environ.get("GITHUB_TOKEN") or None releases = fetch_releases(SOURCE_REPO, token) if branch in ("testing", "unstable"): releases = [ release for release in releases if re.match(rf"^{branch}-\d", release.get("tag_name") or "") ] selected = select_releases(releases, prerelease, MAX_ENTRIES) entries = [release_to_entry(r, channel) for r in selected] out_file = os.path.normpath(OUTPUT_FILE) with open(out_file, "w", encoding="utf-8") as f: json.dump(entries, f, ensure_ascii=False, indent=2) f.write("\n") print(f"Generated {out_file}: {len(entries)} entries " f"from {len(releases)} releases (branch={branch}, channel={channel})") generate_html(entries) print(f"Generated {HTML_FILE}") def generate_html(entries): """Build a static HTML page from announcement entries.""" os.makedirs(DOCS_DIR, exist_ok=True) # Collect available languages and channels from data langs = sorted({e["lang"] for e in entries if e["lang"]}) data_channels = {e["channel"] for e in entries if e["channel"]} channels = [ch for ch in CHANNEL_LABELS if ch in data_channels] data_json = json.dumps(entries, ensure_ascii=False) with open(HTML_FILE, "w", encoding="utf-8") as f: f.write(_html_template(data_json, langs, channels)) CHANNEL_LABELS = { "rel": {"en": "Stable", "zh": "稳定版"}, "beta": {"en": "Beta", "zh": "测试版"}, "preview": {"en": "Preview", "zh": "预览版"}, "main": {"en": "Super Preview", "zh": "超级前瞻版"}, } CHANNEL_COLORS = { "rel": "#4caf50", "beta": "#ff9800", "preview": "#f44336", "main": "#e40336", } LANG_LABELS = { "schinese": "简体中文", "tchinese": "繁體中文", "english": "English", "japanese": "日本語", "koreana": "한국어", } def _html_template(data_json, langs, channels): # Build channel tab HTML channel_tabs = "" for ch in channels: label_en = CHANNEL_LABELS.get(ch, {}).get("en", ch) label_zh = CHANNEL_LABELS.get(ch, {}).get("zh", ch) color = CHANNEL_COLORS.get(ch, "#9e9e9e") channel_tabs += ( f'\n' ) # Build language option HTML lang_options = "" for lang in langs: label = LANG_LABELS.get(lang, lang) lang_options += ( f'\n' ) return f''' SkorionOS Updates

    SkorionOS Updates

    {channel_tabs}
    SkorionOS © 2026
    ''' if __name__ == "__main__": main()