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

搜尋結果開啟後標示關鍵字與別名 (#64) 搜尋結果連結攜帶關鍵字,新聞正文依共用別名規則標示對應文字,播放三秒柔和動畫並支援減少動態效果。

SHA
97af30c555feea29e6933a71d52b46d47486aa81
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (6)

  • pipeline/build.py modified · +17 / −1
  • static/highlight.js added · +155 / −0
  • static/search.js modified · +16 / −6
  • static/site.css modified · +19 / −0
  • templates/article.html modified · +9 / −3
  • tests/test_search.py modified · +19 / −0

Full 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

You found a secret theme!

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