Add complete site changelog and footer link (#17)
Publish the bilingual curated Git history with validated commit links and a language-correct footer button on every page.
完整差異
@@ -0,0 +1,351 @@\n+{\n+ "schemaVersion": 1,\n+ "repository": "https://github.com/nnneeeooo-nnneeeooo/avwire",\n+ "historyStart": "2026-07-26",\n+ "updatedThrough": "2026-07-28",\n+ "entries": [\n+ {\n+ "date": "2026-07-28",\n+ "kind": "feature",\n+ "commit": null,\n+ "zh": "新增雙語網站更新紀錄頁與全站頁尾入口",\n+ "en": "Added a bilingual site changelog and a footer entry point on every page"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "fix",\n+ "commit": "3d06ac0de74de8ac9f4b7fea8e8952fed695ad72",\n+ "zh": "修正民航財報因次要國防內容而誤分類為軍事新聞",\n+ "en": "Fixed civil-company results being misclassified as military because of secondary defence content"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "feature",\n+ "commit": "c2170da78841897571b9c13353f890b40b071631",\n+ "zh": "加入飛機與發動機製造商官方消息來源",\n+ "en": "Added official aircraft and engine manufacturer news sources"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "system",\n+ "commit": "616fa8c191dd31db313c4fc24f380b22f565db17",\n+ "zh": "調整撰稿模型署名並在用量頁分列 GPT-5.6 Sol 理論成本",\n+ "en": "Refined writer credits and separated GPT-5.6 Sol reference costs on the usage page"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "editorial",\n+ "commit": "51d2778c0400bf491a8fc94c4e31dd92834a5c42",\n+ "zh": "人工覆核並發布瑞航 LX16 班戈煙霧改降事件",\n+ "en": "Manually reviewed and published the SWISS LX16 Bangor smoke diversion"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "design",\n+ "commit": "0e1f146504babf44d1323e06074ded9b9ca2e86b",\n+ "zh": "簡化文章文末的自動生成聲明與模型署名",\n+ "en": "Simplified the automated-generation notice and model credit in article footers"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "feature",\n+ "commit": "d4ce44d46577bc5a9ae728bf7926e46a2e7129ca",\n+ "zh": "撰稿流程加入可追溯的受控歷史脈絡與短訊降級",\n+ "en": "Added traceable verified historical context and controlled brief fallback to drafting"\n+ },\n+ {\n+ "date": "2026-07-28",\n+ "kind": "feature",\n+ "commit": "155cb67854fd3ae8aaab70ee98ed24f64f73d45e",\n+ "zh": "航班雷達加入 ICAO/IATA 呼號切換與起迄航點",\n+ "en": "Added ICAO/IATA callsign switching and origin-destination routes to flight radar"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "1089c36017707b5e8458f8063ce70b30b4b91af1",\n+ "zh": "新增台灣民航 ADS-B 航班雷達",\n+ "en": "Added the Taiwan civil ADS-B flight radar"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "62f691b95e5a01f55f05b5fd3290fa10df670a10",\n+ "zh": "排除與航空或運輸無直接關係的軍事新聞",\n+ "en": "Filtered military stories without a direct aviation or transport subject"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "9883b8bb937428b9254c729d6fa6bdbc08eb4753",\n+ "zh": "加強離題新聞過濾並支援文章封存",\n+ "en": "Strengthened off-topic filtering and added article archiving"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "dbbb0472e8aa507b232eb214225aa0cad5dea29e",\n+ "zh": "加入完整新聞稿最低字數與段落要求",\n+ "en": "Enforced minimum length and paragraph requirements for full articles"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "e7f04f4d2f37d3d0414c2b81a073f37e42db06aa",\n+ "zh": "修正 Gatwick 的台灣慣用譯名",\n+ "en": "Corrected the Taiwan rendering of Gatwick"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "867faafe27770f851b703ea1c2249ae8d06df1db",\n+ "zh": "新增軍事新聞分類與前端標籤",\n+ "en": "Added the military news category and front-end label"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "883d47b7d80e3d181c7b00f2f62601e951d5110e",\n+ "zh": "每小時建置前同步最新快報資料,避免部署競態覆蓋",\n+ "en": "Synced current briefing data before hourly builds to prevent deployment races"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "design",\n+ "commit": "ffe2f63113f1697931e685bcae93781d2d6c66a0",\n+ "zh": "快報來源異常改用讀者可理解的中性說明",\n+ "en": "Replaced quota-oriented briefing errors with neutral reader-facing outage copy"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "e3c6b6a954684780aff9e05dfc8b08fd01bb1078",\n+ "zh": "在渲染階段同步執行快報與正式新聞的分流規則",\n+ "en": "Enforced briefing-versus-formal-article separation during rendering"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "b309c745abd04e031cc4db5e5698242152d9cc60",\n+ "zh": "快報改為獨立條列速報,不再鏡像正式新聞",\n+ "en": "Made briefings standalone bulletins instead of mirrors of formal articles"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "bd65912044d9c89e91884b2033896a093e5f3dfd",\n+ "zh": "共機艦系列加入本站已驗證統計作為比較脈絡",\n+ "en": "Added verified AVWIRE statistics as comparison context for PLA activity reports"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "design",\n+ "commit": "8fb37161b09fbe42fa5b76e725593b68173cb344",\n+ "zh": "首頁 LIVE 快訊跑馬燈可直接開啟對應新聞",\n+ "en": "Made every LIVE ticker item link directly to its story"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "d157938c8b1357901ae3607aa47982b7068144d9",\n+ "zh": "僅有短摘要的來源改作發現索引,不直接寫成完整新聞",\n+ "en": "Turned excerpt-only feeds into discovery indexes instead of full-article evidence"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "ca02039bd967e5f171183f38a1bfda588224b44b",\n+ "zh": "新增杜撰偵測防線並誠實處理資訊不足的衍生報導",\n+ "en": "Added a fabrication tripwire and safer handling of thin derivative reports"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "c89ebbe19866e0acbbe653bf96f177bbbf213ab4",\n+ "zh": "加入機器強制的專有名詞台灣譯名表",\n+ "en": "Added a machine-enforced glossary for Taiwan proper-name renderings"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "1440c88c94e3d3fd7084fedd03dd78084a5cf286",\n+ "zh": "搜尋型快報失敗紀錄保留回應內容以利除錯",\n+ "en": "Included response bodies in grounded-briefing failure diagnostics"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "2d31eaf309de8a3726fe96030646554d1f074069",\n+ "zh": "避免空白搜尋模型環境變數清除預設模型名稱",\n+ "en": "Prevented an empty grounded-model setting from erasing the default model ID"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "3c569d96f84dd2b35e8486c13f6f967e3e0dc25d",\n+ "zh": "快報 Beta 加入經核准的搜尋型資料掃描",\n+ "en": "Added the approved grounded-search sweep to briefing Beta"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "9e13218902589b08ad93116d8dca2fc261bf4580",\n+ "zh": "快報升級為指定版型並納入台灣軍航與交通來源",\n+ "en": "Upgraded briefings to the requested format with Taiwan military-air and transport sources"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "7f4234edf5a42e6e85b6180a29cb007c4075b698",\n+ "zh": "稀有航機觀測可通過機器審核後自動發布",\n+ "en": "Allowed rare-aircraft observations to auto-publish through a machine gate"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "design",\n+ "commit": "a7e8d762dff5ad4d64996183307e17dd5734b019",\n+ "zh": "新聞照片改為完整比例、全彩顯示並標示主體",\n+ "en": "Changed news photos to full-frame colour display with subject labels"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "c207832fa560f119bcaa45a3e0040917f5d97315",\n+ "zh": "照片配對加入機場別名與主管機關備援",\n+ "en": "Added airport aliases and agency fallback to photo matching"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "7969b78c9ec99a472e62c21785cbbca56b490b06",\n+ "zh": "新增不公開連結的 API 用量與理論成本儀表板",\n+ "en": "Added a private API-usage and theoretical-cost dashboard"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "e568151159066eee529e4201baeb22be237072c0",\n+ "zh": "官方網頁全文可進入撰稿流程以支援完整新聞",\n+ "en": "Fed official-page full text into drafting to support full-length articles"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "e817e0b43b3fdf43d1ce5fad41023e52e1440213",\n+ "zh": "以免費無金鑰來源自動配對可授權新聞照片",\n+ "en": "Added free keyless matching of reusable news photos"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "54cc76080f33b526718d025ad1c5852309811c59",\n+ "zh": "新增依臺北固定時段產生的每日三次航空與交通快報",\n+ "en": "Added three daily air-and-transport briefings on fixed Taipei windows"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "6dc975bce396593d514966c5fd21e0c90191b5b7",\n+ "zh": "以完整編輯原則重寫方法論頁",\n+ "en": "Rewrote the methodology page with the full editorial charter"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "design",\n+ "commit": "6661e3dd0ccc9af247c1d9f27f795ab260437d87",\n+ "zh": "文章文末開始標示實際撰稿模型",\n+ "en": "Started crediting the actual drafting model in article footers"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "f2a0da18d7de53380a2715f194289c86a077019b",\n+ "zh": "加入台灣稀有航機抵達偵測與來源文字修正",\n+ "en": "Added Taiwan rare-aircraft arrival detection and source-copy correction"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "467952288fff0c71ffd70b3cadcb3214e22b8b17",\n+ "zh": "修正模型推理設定批次的 12 項對抗審查問題",\n+ "en": "Fixed all 12 adversarial-review findings in the reasoning-profile batch"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "system",\n+ "commit": "09ee870a83354273e10b4bdf2f4ad02f5e98a715",\n+ "zh": "加入模型分級推理設定、DeepSeek V4 Pro 備援與可觀測性",\n+ "en": "Added per-model reasoning profiles, a DeepSeek V4 Pro slot and improved observability"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "fix",\n+ "commit": "68142fffc729e4b98fbc6a6e5e4ec3f37b45238f",\n+ "zh": "完整性檢查淘汰的內容不再占用每輪撰稿上限",\n+ "en": "Stopped completeness-check rejections from consuming the per-run drafting cap"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "system",\n+ "commit": "7251de3c986e1b501af2dc66caa19b2073d2f62a",\n+ "zh": "新聞新鮮度時間窗改為可調整設定",\n+ "en": "Made the news freshness window configurable"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "editorial",\n+ "commit": "8984f056b3da29b59dc31ac7990b7aafbd4a29f9",\n+ "zh": "撰稿流程開始消化編輯拒絕結果並預先檢查資料完整性",\n+ "en": "Added editorial-rejection consumption and a code-level completeness pre-check"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "system",\n+ "commit": "af752fc79dcaa64c8f79c5176ade3723e937ef75",\n+ "zh": "在 CI 中對應既有 NVIDIA NIM 金鑰名稱",\n+ "en": "Mapped the owner-named NVIDIA NIM secret in CI"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "system",\n+ "commit": "56059538a166460009ff9d32aa7bfc8dd21ce8b2",\n+ "zh": "依 API 政策建立模型層級備援路由",\n+ "en": "Added model-level failover routing under the configured API policy"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "feature",\n+ "commit": "17124bbc9be731d4556850e196d8dd1905a2309b",\n+ "zh": "撰稿系統升級為多供應商模型與編輯驗證引擎 v2",\n+ "en": "Upgraded drafting to a multi-provider model and editorial verification engine v2"\n+ },\n+ {\n+ "date": "2026-07-27",\n+ "kind": "design",\n+ "commit": "0f78821364783e21c35ddc00a926c6444d85bb9d",\n+ "zh": "網站改為置中 1280px 內容版面與側邊留白",\n+ "en": "Changed the site to a centred 1280px content sheet with side gutters"\n+ },\n+ {\n+ "date": "2026-07-26",\n+ "kind": "system",\n+ "commit": "c6907c960518d5980fc0340b01ce4f1968ff49da",\n+ "zh": "加入免費無 API 標題聚合模式並完成 18 項審查修正",\n+ "en": "Added free no-API headline aggregation and resolved 18 review findings"\n+ },\n+ {\n+ "date": "2026-07-26",\n+ "kind": "system",\n+ "commit": "75ce323e9e3c05054599e9e710b8e515c2e04774",\n+ "zh": "匯入首批真實抓取資料並完成空狀態網站建置",\n+ "en": "Seeded the first real fetched data and completed the empty-state site build"\n+ },\n+ {\n+ "date": "2026-07-26",\n+ "kind": "system",\n+ "commit": "f60f59f7844d75447706038d5ad6a2f57c7b71fa",\n+ "zh": "建立 AVWIRE:抓取、去重、撰稿、建置管線與每小時自動部署",\n+ "en": "Created AVWIRE with fetch, dedupe, drafting, build and hourly deployment pipelines"\n+ }\n+ ]\n+}\n@@ -18,6 +18,14 @@ A stage must NEVER crash the pipeline because one input is missing or one\n source failed: degrade gracefully, log to stdout, exit 0. Non-zero exit is\n reserved for programming errors.\n \n+`config/changelog.json` is a checked-in, bilingual history of meaningful\n+site changes. It is curated from the GitHub `main` history and deliberately\n+omits automated `hourly update`, briefing and flight-state data commits.\n+Every historical row carries a full 40-character commit SHA; build.py derives\n+the public GitHub URL from that validated SHA rather than accepting arbitrary\n+links from the file. The one current changelog-page row may use `null` until\n+it has a stable historical commit.\n+\n ## data/raw/<source_key>.json (written by fetch.py, read by dedupe.py)\n \n ```jsonc\n@@ -268,6 +276,7 @@ site/\n ├─ incidents/index.html + en/incidents/\n ├─ sources/index.html + en/sources/\n ├─ about/index.html + en/about/\n+├─ changelog/index.html + en/changelog/\n ├─ assets/modernist.css copied from static/\n ├─ assets/site.css\n ├─ assets/app.js\n@@ -276,6 +285,8 @@ site/\n ```\n \n All internal links are prefixed with `common.BASE_PATH` (default `/avwire`).\n+Every page rendered through `base.html` includes a language-correct footer\n+button linking to its changelog page.\n \n ## data/briefings/ (written by briefing.py, read by build.py)\n \n@@ -3,7 +3,8 @@\n Reads (all optional, degrades gracefully): data/articles/*.json, flashes.json,\n incidents.json, sources.json, stats.json. Writes the site/ tree per\n CONTRACTS.md: zh at /, en under /en/, one page per article ever published,\n-incidents / sources / about pages, copied assets, 404.html and .nojekyll.\n+incidents / sources / methodology / changelog pages, copied assets, 404.html\n+and .nojekyll.\n A single bad input never crashes the build: bad entries are skipped with a\n one-line log to stdout and the script exits 0.\n """\n@@ -84,8 +85,19 @@\n "thName": "來源", "thKind": "類型", "thCover": "涵蓋範圍", "thFmt": "格式",\n "thFreq": "頻率", "thLast": "最近抓取", "thState": "狀態",\n "aboutKicker": "Methodology", "aboutTitle": "方法論",\n+ "changeKicker": "Site Changelog", "changeTitle": "網站更新紀錄",\n+ "changeSub": "依 GitHub 主分支提交紀錄整理,涵蓋 AVWIRE 建站至最近一次網站功能變更。",\n+ "changeNotice": "每小時新聞、每日快報與航機狀態等自動資料更新不列入,避免機器提交淹沒真正的網站變更。",\n+ "changeCount": "筆網站變更", "changeStart": "紀錄起點",\n+ "changeLatest": "更新至", "changeSource": "查看完整 GitHub 提交紀錄",\n+ "changeCommit": "提交",\n+ "changeKinds": {\n+ "feature": "功能", "fix": "修正", "system": "系統",\n+ "editorial": "編輯", "design": "介面",\n+ },\n "footerAbout": "全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源,自動撰寫並發布,每篇文末標註原始出處。本頁為設計原型,內容為示意樣本。",\n "footerSources": "資料來源 Data Sources",\n+ "footerChangelog": "網站更新紀錄",\n "nav": ["最新", "快報", "航班雷達", "事故資料庫", "來源", "方法論"],\n "cats": ["全部", "事故", "法規", "商業", "營運", "軍事"],\n "radarKicker": "Taiwan Flight Radar",\n@@ -159,8 +171,20 @@\n "thName": "Source", "thKind": "Kind", "thCover": "Coverage", "thFmt": "Format",\n "thFreq": "Interval", "thLast": "Last fetch", "thState": "Status",\n "aboutKicker": "Methodology", "aboutTitle": "Methodology",\n+ "changeKicker": "Site Changelog", "changeTitle": "Site changelog",\n+ "changeSub": "Compiled from the GitHub main-branch history, covering AVWIRE from its creation through the latest site change.",\n+ "changeNotice": "Automated hourly news, daily briefing and flight-state data commits are omitted so operational refreshes do not bury real site changes.",\n+ "changeCount": "site changes", "changeStart": "History starts",\n+ "changeLatest": "Updated through",\n+ "changeSource": "View the complete GitHub commit history",\n+ "changeCommit": "commit",\n+ "changeKinds": {\n+ "feature": "Feature", "fix": "Fix", "system": "System",\n+ "editorial": "Editorial", "design": "Design",\n+ },\n "footerAbout": "A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.",\n "footerSources": "Data Sources",\n+ "footerChangelog": "Site changelog",\n "nav": ["Latest", "Briefings", "Flight Radar", "Incident DB", "Sources",\n "Methodology"],\n "cats": ["All", "Safety", "Regulation", "Business", "Operations",\n@@ -1392,6 +1416,70 @@ def non_api_reference_rows(prices: dict):\n return rows\n \n \n+_CHANGELOG_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}\Z")\n+_CHANGELOG_COMMIT_RE = re.compile(r"[0-9a-f]{40}\Z")\n+_CHANGELOG_KINDS = {"feature", "fix", "system", "editorial", "design"}\n+_CHANGELOG_REPOSITORY = "https://github.com/nnneeeooo-nnneeeooo/avwire"\n+\n+\n+def changelog_view(raw, lang: str, labels: dict) -> dict:\n+ """Validate checked-in changelog data and prepare date-grouped rows.\n+\n+ URLs are derived from validated full commit SHAs instead of accepting\n+ arbitrary href values from the data file.\n+ """\n+ if not isinstance(raw, dict):\n+ raw = {}\n+ rows = []\n+ seen_commits = set()\n+ for order, item in enumerate(raw.get("entries") or []):\n+ if not isinstance(item, dict):\n+ continue\n+ date = str(item.get("date") or "")\n+ kind = str(item.get("kind") or "")\n+ title = str(item.get(lang) or "").strip()\n+ commit = item.get("commit")\n+ if (not _CHANGELOG_DATE_RE.fullmatch(date)\n+ or kind not in _CHANGELOG_KINDS or not title):\n+ continue\n+ if commit is not None:\n+ commit = str(commit).lower()\n+ if (not _CHANGELOG_COMMIT_RE.fullmatch(commit)\n+ or commit in seen_commits):\n+ continue\n+ seen_commits.add(commit)\n+ rows.append({\n+ "date": date,\n+ "kind": kind,\n+ "kind_label": str(labels.get(kind) or kind),\n+ "title": title,\n+ "commit": commit,\n+ "short_commit": commit[:7] if commit else "",\n+ "url": (f"{_CHANGELOG_REPOSITORY}/commit/{commit}"\n+ if commit else ""),\n+ "order": order,\n+ })\n+ rows.sort(key=lambda row: (row["date"], -row["order"]), reverse=True)\n+ groups = []\n+ for row in rows:\n+ if not groups or groups[-1]["date"] != row["date"]:\n+ groups.append({"date": row["date"], "entries": []})\n+ groups[-1]["entries"].append(row)\n+ history_start = str(raw.get("historyStart") or "")\n+ updated_through = str(raw.get("updatedThrough") or "")\n+ if not _CHANGELOG_DATE_RE.fullmatch(history_start):\n+ history_start = groups[-1]["date"] if groups else "—"\n+ if not _CHANGELOG_DATE_RE.fullmatch(updated_through):\n+ updated_through = groups[0]["date"] if groups else "—"\n+ return {\n+ "groups": groups,\n+ "count": len(rows),\n+ "history_start": history_start,\n+ "updated_through": updated_through,\n+ "repository_url": f"{_CHANGELOG_REPOSITORY}/commits/main/",\n+ }\n+\n+\n def render_usage_dashboard(env, build) -> int:\n """Private dashboard at /u/<token>/ - only when the secret is set."""\n token = os.environ.get("AVWIRE_USAGE_TOKEN", "").strip()\n@@ -1444,6 +1532,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\n "hreflang": hreflang,\n "home_url": page_url(lang, ""),\n "radar_url": page_url(lang, "radar/"),\n+ "changelog_url": page_url(lang, "changelog/"),\n "nav": [{"label": t["nav"][i], "url": page_url(lang, s), "active": page == p}\n for i, (p, s) in enumerate(nav_defs)],\n "ticker_items": ticker or [], "build": build,\n@@ -1474,6 +1563,9 @@ def main() -> int:\n DATA_DIR / "briefings" / "index.json", {}).get("briefings") or [])\n if isinstance(r, dict)]\n latest_row = latest_briefing(brief_rows)\n+ changelog_raw = load_json(\n+ Path(__file__).resolve().parent.parent\n+ / "config" / "changelog.json", {})\n \n # clean output tree\n if SITE_DIR.exists():\n@@ -1658,6 +1750,16 @@ def main() -> int:\n render(env, "about.html", rel_path(lang, "about/index.html"), ctx)\n pages += 1\n \n+ ctx = base_ctx(\n+ lang, "changelog", "changelog/",\n+ title=f"{t['siteName']} — {t['changeTitle']}",\n+ description=t["changeSub"], ticker=ticker, build=build)\n+ ctx.update(changelog=changelog_view(\n+ changelog_raw, lang, t["changeKinds"]))\n+ render(env, "changelog.html",\n+ rel_path(lang, "changelog/index.html"), ctx)\n+ pages += 1\n+\n pages += render_usage_dashboard(env, build)\n \n # 404 (zh chrome, bilingual body), served by GitHub Pages from the root\n@@ -237,6 +237,54 @@ a.flash-row:hover { color: inherit; }\n padding: 24px 20px 32px;\n display: grid; grid-template-columns: 1fr 1.3fr; gap: 32px;\n }\n+.footer-changelog-button { margin-top: 14px; font-size: 12px; }\n+\n+/* — site changelog — */\n+.changelog-page {\n+ max-width: 960px; margin: 0 auto; padding: 32px 20px 72px;\n+}\n+.changelog-page > h1 {\n+ margin: 7px 0 10px; font-size: clamp(32px, 5vw, 52px);\n+}\n+.changelog-sub { max-width: 720px; font-size: 15px; line-height: 1.75; }\n+.changelog-summary {\n+ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));\n+ border: 2px solid var(--color-divider); margin: 24px 0 16px;\n+}\n+.changelog-summary > div { padding: 16px 18px; }\n+.changelog-summary > div + div { border-left: 1px solid var(--color-divider); }\n+.changelog-summary strong { display: block; font-size: 20px; }\n+.changelog-summary span {\n+ display: block; margin-top: 3px; color: color-mix(in srgb, var(--color-text) 55%, transparent);\n+ font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase;\n+}\n+.changelog-notice {\n+ border-left: 5px solid var(--color-accent); background: var(--color-surface);\n+ padding: 13px 16px; font-size: 12.5px; line-height: 1.7;\n+}\n+.changelog-notice p { margin: 0 0 3px; }\n+.changelog-history { margin-top: 34px; }\n+.changelog-group {\n+ display: grid; grid-template-columns: 112px minmax(0, 1fr);\n+ gap: 22px; padding: 24px 0; border-top: 2px solid var(--color-divider);\n+}\n+.changelog-group > time {\n+ font-weight: 800; font-size: 14px; letter-spacing: .04em;\n+}\n+.changelog-entry {\n+ display: flex; justify-content: space-between; gap: 20px;\n+ padding: 0 0 17px; margin: 0 0 17px;\n+ border-bottom: 1px solid var(--color-divider);\n+}\n+.changelog-entry:last-child { margin-bottom: 0; }\n+.changelog-entry-copy { min-width: 0; }\n+.changelog-entry h2 {\n+ margin: 7px 0 0; font-size: 16px; line-height: 1.45;\n+}\n+.changelog-commit {\n+ flex-shrink: 0; align-self: center; font-size: 11px; font-weight: 800;\n+ letter-spacing: .04em; text-decoration: none;\n+}\n \n /* — responsive fallback: the design is desktop-first; below 1100px the\n three-column home grid simply stacks. — */\n@@ -253,6 +301,7 @@ a.flash-row:hover { color: inherit; }\n }\n .radar-layout { grid-template-columns: minmax(0, 1fr) 280px; }\n .radar-notes { grid-template-columns: 1fr; gap: 8px; }\n+ .changelog-group { grid-template-columns: 96px minmax(0, 1fr); }\n }\n \n @media (max-width: 640px) {\n@@ -276,4 +325,11 @@ a.flash-row:hover { color: inherit; }\n .seg-cat .seg-btn:nth-child(n + 4) {\n border-top: 1px solid var(--color-divider);\n }\n+ .changelog-page { padding: 24px 14px 48px; }\n+ .changelog-summary { grid-template-columns: 1fr; }\n+ .changelog-summary > div + div {\n+ border-left: 0; border-top: 1px solid var(--color-divider);\n+ }\n+ .changelog-group { grid-template-columns: 1fr; gap: 14px; }\n+ .changelog-entry { align-items: flex-start; }\n }\n@@ -86,6 +86,7 @@\n <div style="font-weight:800;font-size:16px;">AVWIRE <span style="color:var(--color-accent);">{{ t.brandZh }}</span></div>\n <p class="text-muted" style="font-size:12.5px;line-height:1.8;margin-top:8px;">{{ t.footerAbout }}</p>\n <p class="text-muted" style="font-size:11.5px;letter-spacing:.04em;margin:8px 0 0;">{{ t.lastBuild }} {{ build.stamp }} UTC</p>\n+ <a class="btn btn-secondary footer-changelog-button" href="{{ changelog_url }}">{{ t.footerChangelog }} →</a>\n </div>\n <div>\n <h6 style="margin:0 0 10px;">{{ t.footerSources }}</h6>\n@@ -0,0 +1,52 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <main class="changelog-page">\n+ <span class="card-kicker">{{ t.changeKicker }}</span>\n+ <h1>{{ t.changeTitle }}</h1>\n+ <p class="text-muted changelog-sub">{{ t.changeSub }}</p>\n+\n+ <div class="changelog-summary" aria-label="{{ t.changeTitle }}">\n+ <div>\n+ <strong>{{ changelog.count }}</strong>\n+ <span>{{ t.changeCount }}</span>\n+ </div>\n+ <div>\n+ <strong>{{ changelog.history_start }}</strong>\n+ <span>{{ t.changeStart }}</span>\n+ </div>\n+ <div>\n+ <strong>{{ changelog.updated_through }}</strong>\n+ <span>{{ t.changeLatest }}</span>\n+ </div>\n+ </div>\n+\n+ <div class="changelog-notice">\n+ <p>{{ t.changeNotice }}</p>\n+ <a href="{{ changelog.repository_url }}" target="_blank" rel="noopener">{{ t.changeSource }} ↗</a>\n+ </div>\n+\n+ <div class="changelog-history">\n+ {% for group in changelog.groups %}\n+ <section class="changelog-group" aria-labelledby="changelog-{{ group.date }}">\n+ <time id="changelog-{{ group.date }}" datetime="{{ group.date }}">{{ group.date }}</time>\n+ <div class="changelog-entries">\n+ {% for entry in group.entries %}\n+ <article class="changelog-entry">\n+ <div class="changelog-entry-copy">\n+ <span class="tag tag-neutral">{{ entry.kind_label }}</span>\n+ <h2>{{ entry.title }}</h2>\n+ </div>\n+ {% if entry.url %}\n+ <a class="changelog-commit" href="{{ entry.url }}" target="_blank" rel="noopener"\n+ aria-label="{{ t.changeCommit }} {{ entry.short_commit }}">\n+ {{ entry.short_commit }} ↗\n+ </a>\n+ {% endif %}\n+ </article>\n+ {% endfor %}\n+ </div>\n+ </section>\n+ {% endfor %}\n+ </div>\n+ </main>\n+{% endblock %}\n@@ -0,0 +1,106 @@\n+"""Offline checks for the bilingual, source-traceable site changelog."""\n+from __future__ import annotations\n+\n+import json\n+import re\n+import sys\n+from pathlib import Path\n+\n+from jinja2 import Environment, FileSystemLoader, StrictUndefined\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import build # noqa: E402\n+\n+\n+def main() -> None:\n+ raw = json.loads(\n+ (ROOT / "config" / "changelog.json").read_text(encoding="utf-8"))\n+ assert raw["schemaVersion"] == 1\n+ assert raw["historyStart"] == "2026-07-26"\n+ assert raw["updatedThrough"] == "2026-07-28"\n+ assert len(raw["entries"]) == 49\n+\n+ historical = [row["commit"] for row in raw["entries"]\n+ if row["commit"] is not None]\n+ assert len(historical) == 48\n+ assert len(set(historical)) == len(historical)\n+ assert all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in historical)\n+ assert sum(row["commit"] is None for row in raw["entries"]) == 1\n+\n+ dates = [row["date"] for row in raw["entries"]]\n+ assert dates == sorted(dates, reverse=True)\n+ assert {row["kind"] for row in raw["entries"]} \\n+ <= build._CHANGELOG_KINDS\n+ assert all(row["zh"].strip() and row["en"].strip()\n+ for row in raw["entries"])\n+\n+ zh = build.changelog_view(\n+ raw, "zh", build.L["zh"]["changeKinds"])\n+ en = build.changelog_view(\n+ raw, "en", build.L["en"]["changeKinds"])\n+ assert zh["count"] == en["count"] == 49\n+ assert [group["date"] for group in zh["groups"]] \\n+ == ["2026-07-28", "2026-07-27", "2026-07-26"]\n+ assert zh["groups"][0]["entries"][0]["title"] \\n+ == "新增雙語網站更新紀錄頁與全站頁尾入口"\n+ assert en["groups"][-1]["entries"][-1]["title"].startswith(\n+ "Created AVWIRE")\n+ linked = [entry for group in en["groups"] for entry in group["entries"]\n+ if entry["commit"]]\n+ assert all(entry["url"] ==\n+ f"{build._CHANGELOG_REPOSITORY}/commit/{entry['commit']}"\n+ for entry in linked)\n+\n+ malformed = {\n+ "entries": [\n+ {"date": "not-a-date", "kind": "feature", "commit": None,\n+ "zh": "bad", "en": "bad"},\n+ {"date": "2026-07-28", "kind": "unknown", "commit": None,\n+ "zh": "bad", "en": "bad"},\n+ {"date": "2026-07-28", "kind": "feature",\n+ "commit": "javascript:alert(1)", "zh": "bad", "en": "bad"},\n+ {"date": "2026-07-28", "kind": "feature",\n+ "commit": historical[0], "zh": "ok", "en": "ok"},\n+ {"date": "2026-07-28", "kind": "feature",\n+ "commit": historical[0], "zh": "duplicate", "en": "duplicate"},\n+ ]\n+ }\n+ safe = build.changelog_view(\n+ malformed, "zh", build.L["zh"]["changeKinds"])\n+ assert safe["count"] == 1\n+ assert safe["groups"][0]["entries"][0]["title"] == "ok"\n+\n+ env = Environment(\n+ loader=FileSystemLoader(str(ROOT / "templates")),\n+ autoescape=True,\n+ undefined=StrictUndefined,\n+ trim_blocks=True,\n+ lstrip_blocks=True,\n+ )\n+ fake_build = {\n+ "date": "2026-07-28 TUE", "utc_hm": "14:00",\n+ "tpe_hm": "22:00", "stamp": "2026-07-28 14:00",\n+ }\n+ for lang, view in (("zh", zh), ("en", en)):\n+ t = build.L[lang]\n+ ctx = build.base_ctx(\n+ lang, "changelog", "changelog/",\n+ title=f"{t['siteName']} — {t['changeTitle']}",\n+ description=t["changeSub"], ticker=[], build=fake_build)\n+ ctx["changelog"] = view\n+ html = env.get_template("changelog.html").render(**ctx)\n+ expected_path = ("/avwire/changelog/" if lang == "zh"\n+ else "/avwire/en/changelog/")\n+ assert f'href="{expected_path}"' in html\n+ assert t["footerChangelog"] in html\n+ assert t["changeNotice"] in html\n+ assert len(re.findall(r'class="changelog-entry"', html)) == 49\n+ assert "javascript:alert" not in html\n+\n+ print("test_changelog: OK")\n+\n+\n+if __name__ == "__main__":\n+ main()