{
  "sha": "32b158f093c82339d3a3646b25c96674703bfbff",
  "redacted": false,
  "message": "fix: 全站移除中文字與中英交界半形空格 (#386)\n\n* fix: remove spaces around Chinese display text sitewide\n\n* test: align radar attribution with compact Chinese spacing",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "CHI HSUN YANG",
    "email": "125815713+nnneeeooo-nnneeeooo@users.noreply.github.com"
  },
  "committer": {
    "login": "web-flow",
    "name": "GitHub",
    "email": "noreply@github.com"
  },
  "created_at": "2026-10-24T14:04:14Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 1,
      "changes": 4
    },
    {
      "filename": "pipeline/text_spacing.py",
      "previous_filename": "",
      "status": "added",
      "additions": 119,
      "deletions": 0,
      "changes": 119
    },
    {
      "filename": "static/manual.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "static/text-spacing.js",
      "previous_filename": "",
      "status": "added",
      "additions": 79,
      "deletions": 0,
      "changes": 79
    },
    {
      "filename": "static/traffic-dashboard.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "templates/manual.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "templates/traffic-dashboard.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "templates/usage.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "tests/test_radar.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 4,
      "changes": 8
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 7,
      "deletions": 4,
      "changes": 11
    },
    {
      "filename": "tests/test_seo.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 4,
      "changes": 10
    },
    {
      "filename": "tests/test_text_spacing.py",
      "previous_filename": "",
      "status": "added",
      "additions": 45,
      "deletions": 0,
      "changes": 45
    },
    {
      "filename": "tests/test_text_spacing_client.js",
      "previous_filename": "",
      "status": "added",
      "additions": 13,
      "deletions": 0,
      "changes": 13
    },
    {
      "filename": "tests/test_usage.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 12,
      "deletions": 12,
      "changes": 24
    }
  ],
  "diff": "diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml\\nindex faa8bb127..222d7d976 100644\\n--- a/.github/workflows/hourly.yml\\n+++ b/.github/workflows/hourly.yml\\n@@ -76,6 +76,8 @@ jobs:\\n           python tests/test_provider_health.py\\n           python tests/test_free_provider_probe.py\\n           python tests/test_build_resilience.py\\n+          python tests/test_text_spacing.py\\n+          node tests/test_text_spacing_client.js\\n           python tests/test_home_sidebar.py\\n           python tests/test_search.py\\n           python tests/test_english_backfill.py\\ndiff --git a/pipeline/build.py b/pipeline/build.py\\nindex 9e1747347..8eaeb7064 100644\\n--- a/pipeline/build.py\\n+++ b/pipeline/build.py\\n@@ -58,6 +58,7 @@ from model_config import (\\n )\\n from image_captions import clean_description\\n from article_schema import normalize_article_record\\n+from text_spacing import compact_html\\n \\n _caption_cache = load_json(DATA_DIR / \"image-captions.json\", {})\\n IMAGE_CAPTIONS = (_caption_cache.get(\"images\", {})\\n@@ -93,6 +94,7 @@ def static_asset_version() -> str:\\n         \"pixel.css\",\\n         \"fusion-pixel-tw-v2026-10-16.woff2\",\\n         \"app.js\",\\n+        \"text-spacing.js\",\\n         \"search.js\",\\n         \"highlight.js\",\\n         \"skytical-logo.svg\",\\n@@ -3700,7 +3702,7 @@ def team_paragraph_parts(paragraph: str, artist_links: dict) -> list[dict]:\\n \\n \\n def render(env, template, out_rel, ctx):\\n-    html = env.get_template(template).render(**ctx)\\n+    html = compact_html(env.get_template(template).render(**ctx))\\n     out = SITE_DIR / out_rel\\n     out.parent.mkdir(parents=True, exist_ok=True)\\n     out.write_text(html, encoding=\"utf-8\", newline=\"\\n\")\\ndiff --git a/pipeline/text_spacing.py b/pipeline/text_spacing.py\\nnew file mode 100644\\nindex 000000000..598c3f05e\\n--- /dev/null\\n+++ b/pipeline/text_spacing.py\\n@@ -0,0 +1,119 @@\\n+\"\"\"Display-only Chinese spacing; preserve source records and HTML syntax.\"\"\"\\n+from __future__ import annotations\\n+\\n+import html\\n+import re\\n+from html.parser import HTMLParser\\n+\\n+CJK = r\"\\u2e80-\\u9fff\\uf900-\\ufaff\\uff01-\\uff60\\U00020000-\\U000323af\"\\n+SPACES = re.compile(rf\"(?<=[{CJK}]) +| +(?=[{CJK}])\")\\n+BLOCKS = set(\"html head body address article aside blockquote br button dd details dialog div dl dt fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6 header hr li main nav ol option p section summary table tbody td th thead tr ul\".split())\\n+SKIP = {\"script\", \"style\", \"pre\", \"code\", \"textarea\"}\\n+TEXT_ATTRS = {\"alt\", \"title\", \"placeholder\", \"aria-label\", \"aria-description\"}\\n+META_TEXT = {\"description\", \"og:title\", \"og:description\", \"og:image:alt\",\\n+             \"twitter:title\", \"twitter:description\", \"twitter:image:alt\"}\\n+ATTR = re.compile(r'''([\\w:-]+)(\\s*=\\s*)([\"'])(.*?)\\3''', re.DOTALL)\\n+\\n+\\n+def compact_text(text: str) -> str:\\n+    return SPACES.sub(\"\", text)\\n+\\n+\\n+class _DisplaySpacing(HTMLParser):\\n+    def __init__(self, source: str):\\n+        super().__init__(convert_charrefs=False)\\n+        self.source = source\\n+        self.lines = [0] + [m.end() for m in re.finditer(\"\\n\", source)]\\n+        self.edits = []\\n+        self.run = []\\n+        self.skipped = []\\n+\\n+    def source_offset(self):\\n+        line, col = self.getpos()\\n+        return self.lines[line - 1] + col\\n+\\n+    def flush(self):\\n+        text = \"\".join(value for _, _, value in self.run)\\n+        removed = set()\\n+        for match in SPACES.finditer(text):\\n+            removed.update(range(match.start(), match.end()))\\n+        cursor = 0\\n+        if removed:\\n+            for start, raw, value in self.run:\\n+                cleaned = \"\".join(c for i, c in enumerate(value)\\n+                                  if cursor + i not in removed)\\n+                if cleaned != value:\\n+                    # Entities are individual tokens; untouched ones stay verbatim.\\n+                    self.edits.append((start, start + len(raw), cleaned))\\n+                cursor += len(value)\\n+        self.run.clear()\\n+\\n+    def handle_starttag(self, tag, attrs):\\n+        if tag in BLOCKS or tag in SKIP:\\n+            self.flush()\\n+        if self.skipped:\\n+            if tag in SKIP:\\n+                self.skipped.append(tag)\\n+            return\\n+        raw = self.get_starttag_text()\\n+        values = dict(attrs)\\n+        meta_text = tag == \"meta\" and (\\n+            values.get(\"name\") or values.get(\"property\")) in META_TEXT\\n+\\n+        def replace(match):\\n+            name, equals, quote, value = match.groups()\\n+            if name.lower() not in TEXT_ATTRS and not (\\n+                    meta_text and name.lower() == \"content\"):\\n+                return match.group(0)\\n+            decoded = html.unescape(value)\\n+            cleaned = compact_text(decoded)\\n+            if cleaned == decoded:\\n+                return match.group(0)\\n+            return name + equals + quote + html.escape(cleaned, quote=True) + quote\\n+\\n+        cleaned = ATTR.sub(replace, raw)\\n+        if cleaned != raw:\\n+            start = self.source_offset()\\n+            self.edits.append((start, start + len(raw), cleaned))\\n+        if tag in SKIP:\\n+            self.skipped.append(tag)\\n+\\n+    def handle_startendtag(self, tag, attrs):\\n+        self.handle_starttag(tag, attrs)\\n+        if tag in SKIP and self.skipped:\\n+            self.skipped.pop()\\n+\\n+    def handle_endtag(self, tag):\\n+        if self.skipped:\\n+            if tag == self.skipped[-1]:\\n+                self.skipped.pop()\\n+            return\\n+        if tag in BLOCKS or tag in SKIP:\\n+            self.flush()\\n+\\n+    def handle_data(self, data):\\n+        if not self.skipped:\\n+            self.run.append((self.source_offset(), data, data))\\n+\\n+    def handle_entityref(self, name):\\n+        if not self.skipped:\\n+            raw = \"&\" + name + \";\"\\n+            self.run.append((self.source_offset(), raw, html.unescape(raw)))\\n+\\n+    def handle_charref(self, name):\\n+        self.handle_entityref(\"#\" + name)\\n+\\n+\\n+def compact_html(source: str) -> str:\\n+    parser = _DisplaySpacing(source)\\n+    parser.feed(source)\\n+    parser.close()\\n+    parser.flush()\\n+    # Apply non-overlapping edits without reserializing attributes or scripts.\\n+    chunks = []\\n+    cursor = 0\\n+    for start, end, replacement in sorted(parser.edits):\\n+        chunks.extend((source[cursor:start], replacement))\\n+        cursor = end\\n+    chunks.append(source[cursor:])\\n+    return \"\".join(chunks)\\ndiff --git a/static/manual.css b/static/manual.css\\nindex b164fecb2..4e7eb046e 100644\\n--- a/static/manual.css\\n+++ b/static/manual.css\\n@@ -371,3 +371,4 @@ button:disabled { opacity: .45; cursor: not-allowed; }\\n   .notice, .masthead { flex-direction: column; }\\n   .security-badge { align-self: flex-start; }\\n }\\n+html { text-autospace: no-autospace; }\\ndiff --git a/static/site.css b/static/site.css\\nindex aaff5057d..ce0598ab5 100644\\n--- a/static/site.css\\n+++ b/static/site.css\\n@@ -4,6 +4,7 @@\\n /* — from the design's helmet <style> block — */\\n /* boxed layout: the page sheet is centered, the body shows a darker gutter */\\n html, body { margin: 0; background: color-mix(in srgb, var(--color-text) 12%, var(--color-bg)); }\\n+html { text-autospace: no-autospace; }\\n body, h1, h2, h3, h4, h5, h6, .btn, .card-title, .nav-brand {\\n   font-family: \"Archivo\", \"Noto Sans TC\", system-ui, sans-serif;\\n }\\ndiff --git a/static/text-spacing.js b/static/text-spacing.js\\nnew file mode 100644\\nindex 000000000..ff040fefa\\n--- /dev/null\\n+++ b/static/text-spacing.js\\n@@ -0,0 +1,79 @@\\n+(function () {\\n+  \"use strict\";\\n+  var cjk = \"\\\\u2e80-\\\\u9fff\\\\uf900-\\\\ufaff\\\\uff01-\\\\uff60\\\\u{20000}-\\\\u{323af}\";\\n+  var spaces = new RegExp(\"(?<=[\" + cjk + \"]) +| +(?=[\" + cjk + \"])\", \"gu\");\\n+  var blocks = new Set(\"HTML HEAD BODY ADDRESS ARTICLE ASIDE BLOCKQUOTE BR BUTTON DD DETAILS DIALOG DIV DL DT FIELDSET FIGCAPTION FIGURE FOOTER FORM H1 H2 H3 H4 H5 H6 HEADER HR LI MAIN NAV OL OPTION P SECTION SUMMARY TABLE TBODY TD TH THEAD TR UL\".split(\" \"));\\n+  var skip = \"script,style,pre,code,textarea,[contenteditable]:not([contenteditable=false])\";\\n+  var attrs = [\"alt\", \"title\", \"placeholder\", \"aria-label\", \"aria-description\"];\\n+\\n+  function compact(text) { return text.replace(spaces, \"\"); }\\n+\\n+  function normalize(root) {\\n+    var run = [];\\n+    function flush() {\\n+      var text = run.map(function (node) { return node.data; }).join(\"\");\\n+      var removed = new Set();\\n+      text.replace(spaces, function (match, offset) {\\n+        for (var i = 0; i < match.length; i++) removed.add(offset + i);\\n+        return \"\";\\n+      });\\n+      var offset = 0;\\n+      if (removed.size) run.forEach(function (node) {\\n+        var original = node.data;\\n+        // String offsets and regex indices both use UTF-16 code units.\\n+        var clean = original.split(\"\").filter(function (_, index) {\\n+          return !removed.has(offset + index);\\n+        }).join(\"\");\\n+        offset += original.length;\\n+        if (clean !== original) node.data = clean;\\n+      });\\n+      run = [];\\n+    }\\n+    function visit(node) {\\n+      if (node.nodeType === 3) { run.push(node); return; }\\n+      if (node.nodeType !== 1) return;\\n+      if (node.matches(skip)) { flush(); return; }\\n+      var block = blocks.has(node.tagName);\\n+      if (block) flush();\\n+      attrs.forEach(function (name) {\\n+        if (!node.hasAttribute(name)) return;\\n+        var original = node.getAttribute(name), clean = compact(original);\\n+        if (clean !== original) node.setAttribute(name, clean);\\n+      });\\n+      Array.from(node.childNodes).forEach(visit);\\n+      if (block) flush();\\n+    }\\n+    visit(root);\\n+    flush();\\n+  }\\n+\\n+  if (typeof module !== \"undefined\" && module.exports) {\\n+    module.exports = { compact: compact, normalize: normalize };\\n+  }\\n+  if (typeof document === \"undefined\") return;\\n+  normalize(document.documentElement);\\n+  var pending = new Set(), scheduled = false;\\n+  new MutationObserver(function (records) {\\n+    records.forEach(function (record) {\\n+      var node = record.target.nodeType === 1 ? record.target : record.target.parentElement;\\n+      if (!node || node.closest(skip)) return;\\n+      while (node.parentElement && !blocks.has(node.tagName)) node = node.parentElement;\\n+      pending.add(node);\\n+    });\\n+    if (scheduled || !pending.size) return;\\n+    scheduled = true;\\n+    queueMicrotask(function () {\\n+      scheduled = false;\\n+      var roots = Array.from(pending);\\n+      pending.clear();\\n+      roots.forEach(function (root) {\\n+        if (root.isConnected && !roots.some(function (other) {\\n+          return other !== root && other.contains(root);\\n+        })) normalize(root);\\n+      });\\n+    });\\n+  }).observe(document.documentElement, {\\n+    childList: true, characterData: true, subtree: true,\\n+    attributes: true, attributeFilter: attrs\\n+  });\\n+}());\\ndiff --git a/static/traffic-dashboard.css b/static/traffic-dashboard.css\\nindex 7e0d208d5..480bee446 100644\\n--- a/static/traffic-dashboard.css\\n+++ b/static/traffic-dashboard.css\\n@@ -62,3 +62,4 @@ button.primary{background:var(--accent);color:#190f0c;border-color:var(--accent)\\n .footer a{white-space:nowrap}\\n @media(max-width:950px){.kpis{grid-template-columns:repeat(3,minmax(0,1fr))}.layout{grid-template-columns:1fr}}\\n @media(max-width:600px){.shell{width:calc(100% - 22px);padding-top:25px}.header{flex-direction:column}.setup-grid{grid-template-columns:1fr}.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}.setup,.panel{padding:17px}.footer{flex-direction:column}.header-actions{padding:0}}\\n+html { text-autospace: no-autospace; }\\ndiff --git a/templates/base.html b/templates/base.html\\nindex b0a562bd4..83210c08c 100644\\n--- a/templates/base.html\\n+++ b/templates/base.html\\n@@ -63,6 +63,7 @@ gtag('config', '{{ ga4_measurement_id }}', {\\n       title=\"{{ t.siteName }} — {{ t.latest }}\" href=\"{{ feed_url }}\">\\n {% block head_extra %}{% endblock %}\\n <script src=\"{{ base }}/assets/app.js?v={{ asset_version }}\" defer></script>\\n+<script src=\"{{ base }}/assets/text-spacing.js?v={{ asset_version }}\" defer></script>\\n </head>\\n <body>\\n <div class=\"page\">\\ndiff --git a/templates/manual.html b/templates/manual.html\\nindex 2de2fb113..756d057d6 100644\\n--- a/templates/manual.html\\n+++ b/templates/manual.html\\n@@ -456,6 +456,7 @@\\n     </details>\\n   </main>\\n   <script src=\"{{ base }}/assets/manual.js\" defer></script>\\n+  <script src=\"{{ base }}/assets/text-spacing.js\" defer></script>\\n   <script src=\"{{ base }}/assets/manual-image-relation.js?v=20260815-1\" defer></script>\\n   {% if copilot_enabled %}\\n   <script src=\"{{ base }}/assets/copilot.js?v=20260811-knowledge1\" defer></script>\\ndiff --git a/templates/traffic-dashboard.html b/templates/traffic-dashboard.html\\nindex 6a09e2969..6425a6eb0 100644\\n--- a/templates/traffic-dashboard.html\\n+++ b/templates/traffic-dashboard.html\\n@@ -11,6 +11,7 @@\\n <link rel=\"stylesheet\" href=\"{{ base }}/assets/traffic-dashboard.css\">\\n <script src=\"https://accounts.google.com/gsi/client\" async defer></script>\\n <script src=\"{{ base }}/assets/traffic-dashboard.js\" defer></script>\\n+<script src=\"{{ base }}/assets/text-spacing.js\" defer></script>\\n </head>\\n <body>\\n <main class=\"shell\" id=\"traffic-app\">\\ndiff --git a/templates/usage.html b/templates/usage.html\\nindex feb541c71..d786b83b8 100644\\n--- a/templates/usage.html\\n+++ b/templates/usage.html\\n@@ -7,6 +7,7 @@\\n <title>SKYTICAL — API 用量（私人）</title>\\n <link rel=\"icon\" href=\"{{ favicon }}\">\\n <style>\\n+  html { text-autospace: no-autospace; }\\n   :root { color-scheme: light dark; }\\n   body { margin:0; padding:20px 14px 60px; background:#171514; color:#f3f2f2;\\n          font-family:\"Archivo\",\"Noto Sans TC\",system-ui,sans-serif; }\\ndiff --git a/tests/test_radar.py b/tests/test_radar.py\\nindex 4add7cabb..41998862c 100644\\n--- a/tests/test_radar.py\\n+++ b/tests/test_radar.py\\n@@ -265,12 +265,12 @@ def main() -> None:\\n     assert \"if: needs.build.outputs.should_deploy == 'true'\" in radar_workflow\\n \\n     assert any(\\n-        \"本文由自動化系統彙整生成，內容以原始來源為準 • \"\\n+        \"本文由自動化系統彙整生成，內容以原始來源為準• \"\\n         \"Nemotron 3 Super\" in page\\n         for page in zh_articles\\n     )\\n     assert any(\\n-        \"本文由自動化系統彙整生成，內容以原始來源為準 • \"\\n+        \"本文由自動化系統彙整生成，內容以原始來源為準• \"\\n         \"Nemotron 3 Ultra\" in page\\n         for page in zh_articles\\n     )\\n@@ -279,8 +279,8 @@ def main() -> None:\\n         \"prevail • Nemotron 3 Super\" in page\\n         for page in en_articles\\n     )\\n-    assert not any(\"為準。 • \" in page for page in zh_articles)\\n-    assert all(\"AI 撰稿模型\" not in page for page in zh_articles)\\n+    assert not any(\"為準。• \" in page for page in zh_articles)\\n+    assert all(\"AI撰稿模型\" not in page for page in zh_articles)\\n     assert all(\"Drafted by\" not in page for page in en_articles)\\n     assert all(\"model-mark\" not in page for page in zh_articles + en_articles)\\n     print(\"test_radar: OK\")\\ndiff --git a/tests/test_search.py b/tests/test_search.py\\nindex 3f53d1c6b..7acd958fb 100644\\n--- a/tests/test_search.py\\n+++ b/tests/test_search.py\\n@@ -14,6 +14,7 @@ ROOT = Path(__file__).resolve().parent.parent\\n sys.path.insert(0, str(ROOT / \"pipeline\"))\\n \\n import build  # noqa: E402\\n+from text_spacing import compact_text\\n \\n \\n def sample_article(title: str, *, en_title: str = \"Airline search alias test\",\\n@@ -265,20 +266,22 @@ def main() -> None:\\n     assert \".header-search-form:focus-within\" in css\\n     assert \".header-search-input:not(:placeholder-shown) + .header-search-clear\" in css\\n     assert \".header-search-submit\" in css\\n-    assert \"航空公司 IATA／ICAO 代碼\" in zh\\n+    assert \"航空公司IATA／ICAO代碼\" in zh\\n     assert \"airline IATA/ICAO codes\" in en\\n     for markup, language in ((zh, \"zh\"), (en, \"en\")):\\n         header = BeautifulSoup(markup, \"html.parser\").select_one(\".search-header\")\\n         groups = header.select(\".search-help-group\")\\n         assert len(groups) == 3\\n         assert [group.select_one(\"dt\").text for group in groups] == [\\n-            group[\"label\"] for group in build.L[language][\"searchHelpGroups\"]]\\n+            compact_text(group[\"label\"])\\n+            for group in build.L[language][\"searchHelpGroups\"]]\\n         for group, expected in zip(groups, build.L[language][\"searchHelpGroups\"]):\\n             terms = group.select(\".search-help-term\")\\n             assert len(terms) == len(expected[\"terms\"])\\n-            assert all(term.text.startswith(label)\\n+            assert all(term.text.startswith(compact_text(label))\\n                        for term, label in zip(terms, expected[\"terms\"]))\\n-        assert header.select_one(\".search-help-note\").text == build.L[language][\"searchHelpNote\"]\\n+        assert header.select_one(\".search-help-note\").text == compact_text(\\n+            build.L[language][\"searchHelpNote\"])\\n         assert header.find(\"p\", string=build.L[language][\"searchSub\"]) is None\\n     assert \"data-airline-code-count-template\" in zh\\n     assert \"data-airline-code-empty-template\" in en\\ndiff --git a/tests/test_seo.py b/tests/test_seo.py\\nindex 6f8797930..304dfdb96 100644\\n--- a/tests/test_seo.py\\n+++ b/tests/test_seo.py\\n@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent\\n sys.path.insert(0, str(ROOT / \"pipeline\"))\\n \\n import build  # noqa: E402\\n+from text_spacing import compact_text\\n \\n \\n def _json_ld(html: str) -> list[dict]:\\n@@ -142,7 +143,7 @@ def main() -> None:\\n     assert '<meta property=\"og:image:height\" content=\"630\">' in home\\n     assert '<meta property=\"og:image:type\" content=\"image/png\">' in home\\n     assert '<link rel=\"alternate\" hreflang=\"zh-Hant-TW\"' in home\\n-    assert \"SKYTICAL 航空新聞｜臺灣與全球即時航空快訊\" in home\\n+    assert \"SKYTICAL航空新聞｜臺灣與全球即時航空快訊\" in home\\n     assert \"可追溯來源\" in home\\n     assert \"設計原型\" not in home and \"示意樣本\" not in home\\n     assert \"即時航班雷達\" in home\\n@@ -158,7 +159,7 @@ def main() -> None:\\n                     \"index.html\").read_text(encoding=\"utf-8\")\\n         assert 'rel=\"prev\" href=\"https://skytical.tech/news/\"' in page_two\\n         assert 'rel=\"next\" href=\"https://skytical.tech/news/page/3/\"' in page_two\\n-        assert \"第 2 / \" in page_two\\n+        assert \"第2 / \" in page_two\\n         assert page_two.count('class=\"feed-row\"') <= build.NEWS_PAGE_SIZE\\n     if expected_en_pages > 1:\\n         en_page_two = (ROOT / \"site\" / \"en\" / \"news\" / \"page\" / \"2\" /\\n@@ -193,7 +194,7 @@ def main() -> None:\\n     assert '<meta property=\"article:author\" content=\"SKYTICAL\">' in article\\n     assert f'<time datetime=\"{article_schema[\"datePublished\"]}\"' in article\\n     assert \"發布：\" in article and \"來源時間：\" in article\\n-    assert \"SKYTICAL 編輯系統\" in article\\n+    assert \"SKYTICAL編輯系統\" in article\\n     pictured_article = next(\\n         path.read_text(encoding=\"utf-8\") for path in article_paths\\n         if \"<figure\" in path.read_text(encoding=\"utf-8\")\\n@@ -281,7 +282,8 @@ def main() -> None:\\n             encoding=\"utf-8\"))[lang]\\n         for section, expected in zip(soup.select(\".team-chapter\"),\\n                                      team_config[\"sections\"]):\\n-            assert [p.get_text() for p in section.select(\"p\")] == expected[\"body\"]\\n+            assert [p.get_text() for p in section.select(\"p\")] == [\\n+                compact_text(text) for text in expected[\"body\"]]\\n         assert \"/assets/team-neo.jpg?v=\" in team\\n         nav = re.search(r'<nav class=\"site-nav\".*?</nav>', team,\\n                         flags=re.DOTALL).group(0)\\ndiff --git a/tests/test_text_spacing.py b/tests/test_text_spacing.py\\nnew file mode 100644\\nindex 000000000..e0d17e793\\n--- /dev/null\\n+++ b/tests/test_text_spacing.py\\n@@ -0,0 +1,45 @@\\n+\"\"\"Spacing at the HTML display boundary, including inline markup.\"\"\"\\n+import sys\\n+from pathlib import Path\\n+\\n+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / \"pipeline\"))\\n+from text_spacing import compact_html, compact_text\\n+\\n+\\n+def test_display_spacing():\\n+    cases = {\\n+        \"中 文 航 空\": \"中文航空\",\\n+        \"SKYTICAL 是 AI 航空新聞\": \"SKYTICAL是AI航空新聞\",\\n+        \"New York 的 Boeing 787 航機\": \"New York的Boeing 787航機\",\\n+        \"Latest News and Air France\": \"Latest News and Air France\",\\n+        \"中文 123 航班（ TPE ）\": \"中文123航班（TPE）\",\\n+        \"中文\\u00a0English\": \"中文\\u00a0English\",\\n+        \"𠀀 English\": \"𠀀English\",\\n+    }\\n+    for original, expected in cases.items():\\n+        assert compact_text(original) == expected, (original, compact_text(original))\\n+    original = '''<!DOCTYPE html>\\n+<meta name=\"description\" content=\"SKYTICAL 的 AI 新聞\">\\n+<p title='中文 &amp; English'>中文 <strong>AI</strong> 航空 <a href=\"/中文 path/\">New York</a> 新聞</p>\\n+<p>English </p><p>中文</p><p>中文&#32;<b>AI</b> 航空&nbsp;English &amp; More</p>\\n+<pre>中文 AI</pre><code>中文 AI</code><textarea>中文 AI</textarea>\\n+<script>const text = \"中文 AI\";</script><style>/* 中文 AI */</style>\\n+<!-- 中文 AI --><input placeholder=\"搜尋 SKYTICAL 新聞\" value=\"中文 AI\">\\n+'''\\n+    result = compact_html(original)\\n+    assert '<p title=\\'中文&amp; English\\'>中文<strong>AI</strong>航空<a href=\"/中文 path/\">New York</a>新聞</p>' in result\\n+    assert '<p>English </p><p>中文</p>' in result\\n+    assert '<p>中文<b>AI</b>航空&nbsp;English &amp; More</p>' in result\\n+    assert 'content=\"SKYTICAL的AI新聞\"' in result\\n+    assert 'placeholder=\"搜尋SKYTICAL新聞\" value=\"中文 AI\"' in result\\n+    for preserved in ('<pre>中文 AI</pre>', '<code>中文 AI</code>',\\n+                      '<textarea>中文 AI</textarea>',\\n+                      '<script>const text = \"中文 AI\";</script>',\\n+                      '<style>/* 中文 AI */</style>', '<!-- 中文 AI -->'):\\n+        assert preserved in result\\n+    assert compact_html(result) == result\\n+    print(\"test_text_spacing: OK\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    test_display_spacing()\\ndiff --git a/tests/test_text_spacing_client.js b/tests/test_text_spacing_client.js\\nnew file mode 100644\\nindex 000000000..fa7fcb3eb\\n--- /dev/null\\n+++ b/tests/test_text_spacing_client.js\\n@@ -0,0 +1,13 @@\\n+\"use strict\";\\n+const assert = require(\"assert\");\\n+const spacing = require(\"../static/text-spacing.js\");\\n+for (const [source, expected] of [\\n+  [\"中 文 航 空\", \"中文航空\"],\\n+  [\"SKYTICAL 是 AI 航空新聞\", \"SKYTICAL是AI航空新聞\"],\\n+  [\"New York 的 Boeing 787 航機\", \"New York的Boeing 787航機\"],\\n+  [\"Latest News and Air France\", \"Latest News and Air France\"],\\n+  [\"中文 123 航班（ TPE ）\", \"中文123航班（TPE）\"],\\n+  [\"中文\\u00a0English\", \"中文\\u00a0English\"],\\n+  [\"𠀀 English\", \"𠀀English\"]\\n+]) assert.strictEqual(spacing.compact(source), expected);\\n+console.log(\"test_text_spacing_client: OK\");\\ndiff --git a/tests/test_usage.py b/tests/test_usage.py\\nindex e5b432826..390b65e75 100644\\n--- a/tests/test_usage.py\\n+++ b/tests/test_usage.py\\n@@ -455,19 +455,19 @@ fixed_tpe_label = (\\n     f\"{fixed_tpe.strftime('%I:%M %p').lstrip('0')} TPE\"\\n )\\n check(\"page shows thirty-day history, fallback chain and final model\",\\n-      \"最近 30 天模型執行與失敗紀錄\" in page\\n+      \"最近30天模型執行與失敗紀錄\" in page\\n       and fixed_tpe_label in page\\n       and \"Nemotron 3 Ultra 550B A55B\" in page\\n-      and \"JSON 格式錯誤\" in page\\n+      and \"JSON格式錯誤\" in page\\n       and \"Gemini 3.6 Flash\" in page\\n-      and \"→ 改用\" in page)\\n+      and \"→改用\" in page)\\n check(\"page independently shows private workbench theoretical spend\",\\n-      \"私人補稿功能用的理論花費（最近 30 天）\" in page\\n+      \"私人補稿功能用的理論花費（最近30天）\" in page\\n       and \"完全手動撰稿\" in page\\n-      and \"實際新增 API 支出\" in page)\\n+      and \"實際新增API支出\" in page)\\n check(\"page shows stage duration and repair count\",\\n       \"Archive Context retrieval\" in page\\n-      and \"223.4 秒\" in page\\n+      and \"223.4秒\" in page\\n       and \"HTTP 2 · Repair 1\" in page)\\n check(\"dashboard sanitizes secrets and escapes HTML\",\\n       \"very-secret\" not in page\\n@@ -494,21 +494,21 @@ check(\"recent model statistics follow configured model priority\",\\n           \"nvidia:nvidia/nemotron-3-ultra-550b-a55b\",\\n           \"nvidia:nvidia/nemotron-3-super-120b-a12b\"])\\n check(\"page is noindex and shows totals\",\\n-      \"noindex\" in page and \"API 用量\" in page and \"$0\" in page)\\n+      \"noindex\" in page and \"API用量\" in page and \"$0\" in page)\\n check(\"page shows exact Codex GPT-5 task totals and cost semantics\",\\n-      \"GPT-5 網站製作累計（Codex）\" in page\\n+      \"GPT-5網站製作累計（Codex）\" in page\\n       and f\"{snapshot_total:,}\" in page\\n       and f\"{snapshot_entry['cachedInputTokens']:,}\" in page\\n-      and \"推理 tokens 已包含在輸出 tokens 內，不重複加總\" in page\\n-      and \"實際新增 API 支出\" in page)\\n+      and \"推理tokens已包含在輸出tokens內，不重複加總\" in page\\n+      and \"實際新增API支出\" in page)\\n check(\"page shows separate GPT-5.6 Sol non-API reference rows\",\\n-      \"方案內 GPT-5.6 Sol 用途與單價\" in page\\n+      \"方案內GPT-5.6 Sol用途與單價\" in page\\n       and \"新聞撰稿（手動補稿）\" in page\\n       and \"網站修改（Codex）\" in page\\n       and page.count(\"<td>$30.00</td>\") == 2\\n       and \"不重複計入任何總額\" in page)\\n check(\"page shows GPT-5 family official rates without adding them to totals\",\\n-      \"GPT-5 系列 API 理論牌價\" in page\\n+      \"GPT-5系列API理論牌價\" in page\\n       and \"GPT-5.6 Terra\" in page\\n       and \"GPT-5.4 Pro\" in page\\n       and \"GPT-5 nano\" in page"
}
