2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

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.

SHA
f17b5452fb283bbfce5786728c2fc68eeea9be06
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (7)

  • config/changelog.json added · +351 / −0
  • pipeline/CONTRACTS.md modified · +11 / −0
  • pipeline/build.py modified · +103 / −1
  • static/site.css modified · +56 / −0
  • templates/base.html modified · +1 / −0
  • templates/changelog.html added · +52 / −0
  • tests/test_changelog.py added · +106 / −0

Full diff

@@ -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()

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.