#!/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 ( 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)}{tag}>" 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"
{html.escape(item)}
")
continue
elif in_ol:
result.append("")
in_ol = False
# Blockquote
if stripped.startswith("> "):
content = stripped[2:]
if not 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'',
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