{
  "sha": "97af30c555feea29e6933a71d52b46d47486aa81",
  "message": "搜尋結果開啟後標示關鍵字與別名 (#64)\n\n搜尋結果連結攜帶關鍵字，新聞正文依共用別名規則標示對應文字，播放三秒柔和動畫並支援減少動態效果。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-09T22:05:40Z",
  "files": [
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 17,
      "deletions": 1,
      "changes": 18
    },
    {
      "filename": "static/highlight.js",
      "previous_filename": "",
      "status": "added",
      "additions": 155,
      "deletions": 0,
      "changes": 155
    },
    {
      "filename": "static/search.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 16,
      "deletions": 6,
      "changes": 22
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 19,
      "deletions": 0,
      "changes": 19
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 9,
      "deletions": 3,
      "changes": 12
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 19,
      "deletions": 0,
      "changes": 19
    }
  ],
  "diff": "@@ -53,7 +53,7 @@\\n \\n def static_asset_version() -> str:\\n     digest = hashlib.sha256()\\n-    for name in (\"site.css\", \"app.js\", \"search.js\"):\\n+    for name in (\"site.css\", \"app.js\", \"search.js\", \"highlight.js\"):\\n         digest.update((STATIC_DIR / name).read_bytes())\\n     return digest.hexdigest()[:10]\\n \\n@@ -1107,6 +1107,14 @@ def search_index_payload(articles, generated_utc) -> dict:\\n     }\\n \\n \\n+def search_aliases_payload() -> dict:\\n+    \"\"\"Publish the sanitized alias map used by article highlighting.\"\"\"\\n+    return {\\n+        \"version\": 1,\\n+        \"groups\": [list(group) for group in load_search_alias_groups()],\\n+    }\\n+\\n+\\n def collect_agg_items(now):\\n     \"\"\"Aggregation fallback: when no LLM-written articles exist yet, surface\\n     the newest raw source headlines directly (free, no-API mode).\"\"\"\\n@@ -2289,6 +2297,10 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\\n         \"hreflang\": hreflang,\\n         \"home_url\": page_url(lang, \"\"),\\n         \"search_url\": page_url(lang, \"search/\"),\\n+        \"search_aliases_url\": (\\n+            f\"{BASE_PATH}/search-aliases.json\"\\n+            if BASE_PATH else \"/search-aliases.json\"\\n+        ),\\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@@ -2338,6 +2350,10 @@ def main() -> int:\\n         json.dumps(search_index_payload(articles, now), ensure_ascii=False,\\n                    separators=(\",\", \":\")),\\n         encoding=\"utf-8\", newline=\"\\n\")\\n+    (SITE_DIR / \"search-aliases.json\").write_text(\\n+        json.dumps(search_aliases_payload(), ensure_ascii=False,\\n+                   separators=(\",\", \":\")),\\n+        encoding=\"utf-8\", newline=\"\\n\")\\n \\n     env = Environment(\\n         loader=FileSystemLoader(str(TEMPLATES_DIR)),\\n@@ -0,0 +1,155 @@\\n+/* Highlight a search term (and configured aliases) in article body text. */\\n+(function () {\\n+  \"use strict\";\\n+\\n+  var root = document.querySelector(\"[data-article-body]\");\\n+  if (!root) return;\\n+\\n+  var query = \"\";\\n+  try {\\n+    query = (new URL(window.location.href).searchParams.get(\"highlight\") || \"\")\\n+      .replace(/\\s+/g, \" \")\\n+      .trim()\\n+      .slice(0, 120);\\n+  } catch (error) {\\n+    return;\\n+  }\\n+  if (!query) return;\\n+\\n+  function normalize(value) {\\n+    return String(value || \"\")\\n+      .normalize(\"NFKC\")\\n+      .toLocaleLowerCase()\\n+      .replace(/\\s+/g, \" \")\\n+      .trim();\\n+  }\\n+\\n+  function escapePattern(value) {\\n+    return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\\n+  }\\n+\\n+  function queryCandidates() {\\n+    var candidates = [query].concat(query.split(/\\s+/));\\n+    return new Set(candidates.map(normalize).filter(Boolean));\\n+  }\\n+\\n+  function highlightTerms(groups) {\\n+    var candidates = queryCandidates();\\n+    var terms = new Map();\\n+    [query].concat(query.split(/\\s+/)).forEach(function (term) {\\n+      var normalized = normalize(term);\\n+      if (normalized) terms.set(normalized, term);\\n+    });\\n+    groups.forEach(function (group) {\\n+      if (!Array.isArray(group)) return;\\n+      var matchesQuery = group.some(function (alias) {\\n+        return candidates.has(normalize(alias));\\n+      });\\n+      if (!matchesQuery) return;\\n+      group.forEach(function (alias) {\\n+        var normalized = normalize(alias);\\n+        if (normalized) terms.set(normalized, String(alias));\\n+      });\\n+    });\\n+    return Array.from(terms.values()).sort(function (left, right) {\\n+      return right.length - left.length;\\n+    });\\n+  }\\n+\\n+  function termPattern(term) {\\n+    var escaped = escapePattern(term);\\n+    if (/^[A-Za-z0-9]/.test(term)) escaped = \"(?<![A-Za-z0-9])\" + escaped;\\n+    if (/[A-Za-z0-9]$/.test(term)) escaped += \"(?![A-Za-z0-9])\";\\n+    return escaped;\\n+  }\\n+\\n+  function markMatches(terms) {\\n+    if (!terms.length) return [];\\n+    var pattern = new RegExp(terms.map(termPattern).join(\"|\"), \"giu\");\\n+    var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);\\n+    var nodes = [];\\n+    var current;\\n+    while ((current = walker.nextNode())) nodes.push(current);\\n+\\n+    var marks = [];\\n+    nodes.forEach(function (node) {\\n+      var text = node.nodeValue || \"\";\\n+      pattern.lastIndex = 0;\\n+      if (!pattern.test(text)) return;\\n+      pattern.lastIndex = 0;\\n+\\n+      var fragment = document.createDocumentFragment();\\n+      var cursor = 0;\\n+      var match;\\n+      while ((match = pattern.exec(text))) {\\n+        if (match.index > cursor) {\\n+          fragment.appendChild(document.createTextNode(text.slice(cursor, match.index)));\\n+        }\\n+        var mark = document.createElement(\"mark\");\\n+        mark.className = \"search-hit\";\\n+        mark.textContent = match[0];\\n+        fragment.appendChild(mark);\\n+        marks.push(mark);\\n+        cursor = match.index + match[0].length;\\n+      }\\n+      if (cursor < text.length) {\\n+        fragment.appendChild(document.createTextNode(text.slice(cursor)));\\n+      }\\n+      node.parentNode.replaceChild(fragment, node);\\n+    });\\n+    return marks;\\n+  }\\n+\\n+  function finish(groups) {\\n+    var marks = markMatches(highlightTerms(groups));\\n+    if (!marks.length) return;\\n+    var pulseStarted = false;\\n+    function startPulse() {\\n+      if (pulseStarted) return;\\n+      pulseStarted = true;\\n+      marks.forEach(function (mark) { mark.classList.add(\"search-hit-pulse\"); });\\n+      window.setTimeout(function () {\\n+        marks.forEach(function (mark) { mark.classList.remove(\"search-hit-pulse\"); });\\n+      }, 3000);\\n+    }\\n+\\n+    function revealFirstMark() {\\n+      marks[0].scrollIntoView({\\n+        behavior: window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\\n+          ? \"auto\" : \"smooth\",\\n+        block: \"center\"\\n+      });\\n+      startPulse();\\n+    }\\n+\\n+    var articlePage = root.closest(\".article-page\") || root;\\n+    var pendingImages = Array.prototype.filter.call(\\n+      articlePage.querySelectorAll(\"img\"),\\n+      function (image) { return !image.complete; }\\n+    );\\n+    if (pendingImages.length) {\\n+      var remaining = pendingImages.length;\\n+      pendingImages.forEach(function (image) {\\n+        function imageSettled() {\\n+          remaining -= 1;\\n+          if (remaining === 0) revealFirstMark();\\n+        }\\n+        image.addEventListener(\"load\", imageSettled, { once: true });\\n+        image.addEventListener(\"error\", imageSettled, { once: true });\\n+      });\\n+    }\\n+    window.setTimeout(revealFirstMark, 700);\\n+  }\\n+\\n+  fetch(root.dataset.searchAliasesUrl, { credentials: \"same-origin\" })\\n+    .then(function (response) {\\n+      if (!response.ok) throw new Error(\"HTTP \" + response.status);\\n+      return response.json();\\n+    })\\n+    .then(function (payload) {\\n+      finish(Array.isArray(payload.groups) ? payload.groups : []);\\n+    })\\n+    .catch(function () {\\n+      finish([]);\\n+    });\\n+})();\\n@@ -29,9 +29,17 @@\\n     return value[lang] || value[otherLang] || \"\";\\n   }\\n \\n-  function recordUrl(record) {\\n+  function recordUrl(record, highlightQuery) {\\n     var urls = record.url || {};\\n-    return urls[lang] || urls[otherLang] || \"#\";\\n+    var rawUrl = urls[lang] || urls[otherLang] || \"#\";\\n+    if (!highlightQuery || rawUrl === \"#\") return rawUrl;\\n+    try {\\n+      var url = new URL(rawUrl, window.location.href);\\n+      url.searchParams.set(\"highlight\", highlightQuery);\\n+      return url.pathname + url.search + url.hash;\\n+    } catch (error) {\\n+      return rawUrl;\\n+    }\\n   }\\n \\n   function updateQueryUrl(query) {\\n@@ -52,7 +60,7 @@\\n     return node;\\n   }\\n \\n-  function resultCard(record) {\\n+  function resultCard(record, highlightQuery) {\\n     var card = document.createElement(\"article\");\\n     card.className = \"search-result\";\\n \\n@@ -64,7 +72,7 @@\\n \\n     var link = document.createElement(\"a\");\\n     link.className = \"search-result-link\";\\n-    link.href = recordUrl(record);\\n+    link.href = recordUrl(record, highlightQuery);\\n     link.appendChild(textNode(\"h2\", \"\", localized(record, \"title\")));\\n     card.appendChild(link);\\n \\n@@ -75,7 +83,7 @@\\n     footer.className = \"search-result-footer\";\\n     footer.appendChild(textNode(\"span\", \"text-muted\", record.source || \"\"));\\n     var more = document.createElement(\"a\");\\n-    more.href = recordUrl(record);\\n+    more.href = recordUrl(record, highlightQuery);\\n     more.textContent = root.dataset.readMore;\\n     footer.appendChild(more);\\n     card.appendChild(footer);\\n@@ -130,7 +138,9 @@\\n     }\\n     status.textContent = root.dataset.countTemplate.replace(\"{count}\", String(matches.length));\\n     var fragment = document.createDocumentFragment();\\n-    matches.forEach(function (row) { fragment.appendChild(resultCard(row.record)); });\\n+    matches.forEach(function (row) {\\n+      fragment.appendChild(resultCard(row.record, query));\\n+    });\\n     results.appendChild(fragment);\\n   }\\n \\n@@ -238,6 +238,25 @@ a.flash-row:hover { color: inherit; }\\n .article-sources { margin-top: 28px; padding: 16px 20px 12px; border: 2px solid var(--color-divider); }\\n .article-source-row { display: flex; gap: 10px; align-items: baseline; padding: 4px 0; font-size: 13.5px; }\\n .article-source-row a { min-width: 0; overflow-wrap: anywhere; }\\n+.search-hit {\\n+  padding: 0 .08em; border-radius: 3px;\\n+  background: color-mix(in srgb, #f4d35e 38%, transparent);\\n+  color: inherit; box-decoration-break: clone; -webkit-box-decoration-break: clone;\\n+}\\n+.search-hit-pulse { animation: search-hit-pulse 750ms ease-in-out 4; }\\n+@keyframes search-hit-pulse {\\n+  0%, 100% {\\n+    background: color-mix(in srgb, #f4d35e 38%, transparent);\\n+    box-shadow: 0 0 0 0 transparent;\\n+  }\\n+  50% {\\n+    background: color-mix(in srgb, #f4d35e 58%, transparent);\\n+    box-shadow: 0 0 0 3px color-mix(in srgb, #f4d35e 13%, transparent);\\n+  }\\n+}\\n+@media (prefers-reduced-motion: reduce) {\\n+  .search-hit-pulse { animation: none; }\\n+}\\n \\n .is-hidden { display: none !important; }\\n .sr-only {\\n@@ -28,9 +28,12 @@ <h1 class=\"article-title\">{{ a.title }}</h1>\\n       </figcaption>\\n     </figure>\\n     {% endif %}\\n-    {% for p in a.body %}\\n-    <p style=\"font-size:16px;line-height:1.95;margin:0 0 18px;\">{{ p }}</p>\\n-    {% endfor %}\\n+    <div class=\"article-body\" data-article-body\\n+         data-search-aliases-url=\"{{ search_aliases_url }}\">\\n+      {% for p in a.body %}\\n+      <p style=\"font-size:16px;line-height:1.95;margin:0 0 18px;\">{{ p }}</p>\\n+      {% endfor %}\\n+    </div>\\n     <div class=\"article-sources\">\\n       <h6 style=\"margin:0 0 10px;\">{{ t.sourcesBox }}</h6>\\n       {% for s in a.sources %}\\n@@ -46,3 +49,6 @@ <h6 style=\"margin:0 0 10px;\">{{ t.sourcesBox }}</h6>\\n     </div>\\n   </div>\\n {% endblock %}\\n+{% block body_scripts %}\\n+<script src=\"{{ base }}/assets/highlight.js?v={{ asset_version }}\" defer></script>\\n+{% endblock %}\\n@@ -58,6 +58,8 @@ def main() -> None:\\n     assert build.main() == 0\\n     payload = json.loads(\\n         (ROOT / \"site\" / \"search-index.json\").read_text(encoding=\"utf-8\"))\\n+    alias_payload = json.loads(\\n+        (ROOT / \"site\" / \"search-aliases.json\").read_text(encoding=\"utf-8\"))\\n     assert payload[\"version\"] == 1\\n     assert payload[\"count\"] == len(payload[\"items\"]) == len(build.collect_articles())\\n     assert payload[\"items\"]\\n@@ -66,6 +68,8 @@ def main() -> None:\\n         \"中華航空\" in row[\"search\"] and \"華航\" in row[\"search\"]\\n         for row in payload[\"items\"]\\n     )\\n+    assert alias_payload[\"version\"] == 1\\n+    assert [\"中華航空\", \"華航\", \"China Airlines\"] in alias_payload[\"groups\"]\\n \\n     zh = (ROOT / \"site\" / \"search\" / \"index.html\").read_text(encoding=\"utf-8\")\\n     en = (ROOT / \"site\" / \"en\" / \"search\" / \"index.html\").read_text(\\n@@ -74,13 +78,18 @@ def main() -> None:\\n     article = next((ROOT / \"site\" / \"news\").glob(\"*/index.html\")).read_text(\\n         encoding=\"utf-8\")\\n     script = (ROOT / \"static\" / \"search.js\").read_text(encoding=\"utf-8\")\\n+    highlight_script = (ROOT / \"static\" / \"highlight.js\").read_text(\\n+        encoding=\"utf-8\")\\n     app_script = (ROOT / \"static\" / \"app.js\").read_text(encoding=\"utf-8\")\\n     css = (ROOT / \"static\" / \"site.css\").read_text(encoding=\"utf-8\")\\n \\n     assert 'id=\"news-search-input\"' in zh\\n     assert zh.count('id=\"news-search-form\"') == 1\\n     assert 'class=\"header-search-form\"' in home\\n     assert 'class=\"header-search-form\"' in article\\n+    assert 'data-article-body' in article\\n+    assert 'data-search-aliases-url=\"/avwire/search-aliases.json\"' in article\\n+    assert re.search(r'/avwire/assets/highlight\\.js\\?v=[0-9a-f]{10}', article)\\n     assert 'action=\"/avwire/search/\"' in home\\n     assert 'action=\"/avwire/en/search/\"' in en\\n     assert re.search(r'/avwire/assets/site\\.css\\?v=[0-9a-f]{10}', zh)\\n@@ -105,6 +114,16 @@ def main() -> None:\\n     assert \"replaceChildren\" in script\\n     assert \"innerHTML\" not in script\\n     assert \"URLSearchParams\" not in script  # URL.searchParams is used directly\\n+    assert 'url.searchParams.set(\"highlight\", highlightQuery)' in script\\n+    assert \"resultCard(row.record, query)\" in script\\n+    assert 'searchParams.get(\"highlight\")' in highlight_script\\n+    assert \"payload.groups\" in highlight_script\\n+    assert 'mark.className = \"search-hit\"' in highlight_script\\n+    assert 'mark.classList.add(\"search-hit-pulse\")' in highlight_script\\n+    assert 'mark.classList.remove(\"search-hit-pulse\")' in highlight_script\\n+    assert \", 3000);\" in highlight_script\\n+    assert \"prefers-reduced-motion: reduce\" in css\\n+    assert \"@keyframes search-hit-pulse\" in css\\n     assert 'headerSearchClear.addEventListener(\"click\"' in app_script\\n     assert 'headerSearchInput.value = \"\"' in app_script\\n     assert 'new Event(\"input\", { bubbles: true })' in app_script"
}
