完整差異
@@ -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