This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git


The following commit(s) were added to refs/heads/master by this push:
     new 759a81aadfe5 [fix](website) Recover search and bound publishing 
resources (#4533)
759a81aadfe5 is described below

commit 759a81aadfe5c9649e8b7eef8dab84dd6ee913d3
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Oct 27 16:46:47 2026 +0000

    [fix](website) Recover search and bound publishing resources (#4533)
    
    Documentation search can remain stuck while loading the 40–58 MB
    same-origin indexes, and a failed request poisons the worker cache. Load
    source-fingerprinted indexes through the existing asset CDN, fall back
    to the website with bounded requests, and offer a localized retry while
    preserving the first typed query. Worker scripts remain same-origin.
    Temporary redirects preserve the previous index URLs without duplicating
    their contents.
    
    Publishing now bounds Node/Terser/Rayon/SSG concurrency, reuses the Yarn
    download cache with a frozen install, removes duplicate generation, and
    shares compact HTML metadata across strict locale verification. Browser
    route payloads retain stable bytes across source commits; full manifests
    retain build provenance. UTF-8 client output and level-1 artifact
    compression reduce expansion and transfer overhead. Measurements include
    per-locale RSS, artifact categories, and exact public generated inputs.
    
    PR admission checks the complete diff, every content
    version/locale/plugin, and all changed file sizes with NUL-delimited
    filenames. The existing 1,024,000-byte size limit is unchanged.
    
    Validation:
    - 391 Node tests: 389 passed, 2 conditional Apache skips; 27 workflow
    tests passed.
    - Real five-language Rspack search fixture: 16 scenarios passed,
    including early input, CDN fallback, and retry in both search UIs; 40
    mobile theme/error-layout checks passed.
    - Configuration, manual-content digests, and source diff checks passed.
    The four existing community-report drift records remain visible.
    - CI retains representative validation for this PR; complete
    publication, resource headroom, and final public verification are part
    of T22 after merge. No full production site was compiled locally.
    
    Self-review against AGENTS.md:
    - Goal: recover search and address the measured publishing/gate
    problems; production memory/time improvements still require the real
    publishing measurement.
    - Scope: shared search, publication, and admission mechanisms; no prose
    backfill or translation ownership changes.
    - Information architecture: all active versions, locales, plugins,
    English fallback, and coverage denominators are preserved.
    - URLs/navigation: document routes and sidebars are unchanged; legacy
    index URLs have temporary aliases to the new files.
    - Frontend: retry is localized, keyboard accessible, and uses semantic
    theme tokens; the CDN connection is allowed explicitly by CSP.
    - Validation: actual worker-cache failure/retry, corrupted static-output
    rejection, filename/range gate regressions, numeric registry keys, and
    UTF-8 string values are covered.
    - Other correctness/maintenance: generated provenance stays in build
    reports; fixture selection is scoped to search changes, and
    fork/deployment credential boundaries are unchanged.
    
    Co-authored-by: morningman <[email protected]>
---
 .github/scripts/publish_metrics.py                 |  46 ++++++-
 .github/scripts/test_pr_gates.py                   | 108 +++++++++++++++
 .github/scripts/test_publish_metrics.py            |  31 +++++
 .github/scripts/test_review.py                     |   3 +-
 .github/workflows/build-check.yml                  |  14 +-
 .github/workflows/check-md-links.yml               |  42 +++---
 .github/workflows/check-pr-file-sizes.yml          |  22 +--
 .github/workflows/cron-deploy-website.yml          |  21 +--
 .github/workflows/manual-deploy-website.yml        |  22 +--
 config/custom-docusaurus-plugin.js                 |  22 ++-
 scripts/build-locales.cjs                          |  26 +++-
 scripts/check_move.js                              |  37 ++---
 scripts/i18n/__tests__/content-manifest.test.cjs   |   3 +
 scripts/i18n/__tests__/search-transport.test.cjs   | 100 ++++++++++++++
 scripts/i18n/__tests__/static-cache.test.cjs       |  41 ++++++
 scripts/i18n/ci-plan.cjs                           |   3 +
 scripts/i18n/content-manifest.cjs                  |   4 +-
 scripts/i18n/search-plugin.cjs                     |  38 +++--
 scripts/i18n/search-worker-loader.cjs              |  26 ++++
 scripts/i18n/verify-search-transport.cjs           | 153 +++++++++++++++++++++
 scripts/i18n/verify-static.cjs                     |  58 +++++---
 scripts/publish/__tests__/js-minimizer.test.cjs    |  23 ++++
 scripts/publish/__tests__/optimize.test.cjs        |  15 ++
 scripts/publish/__tests__/registry-loader.test.cjs |   2 +-
 scripts/publish/optimize.cjs                       |   8 ++
 src/theme/SearchBar/SearchBar.jsx                  |  93 ++++++++-----
 src/theme/SearchBar/SearchBar.module.css           |  33 +++++
 src/theme/SearchPage/SearchPage.jsx                |  49 ++++---
 src/utils/fetchSearchIndex.js                      |  32 +++++
 src/utils/searchMessages.ts                        |   8 ++
 src/utils/translation.ts                           |   1 -
 static/.htaccess                                   |   2 +-
 translations/shared/registry.json                  |   3 +-
 33 files changed, 903 insertions(+), 186 deletions(-)

diff --git a/.github/scripts/publish_metrics.py 
b/.github/scripts/publish_metrics.py
index 6d109319ad42..ca125062ee1d 100644
--- a/.github/scripts/publish_metrics.py
+++ b/.github/scripts/publish_metrics.py
@@ -3,6 +3,7 @@
 import argparse
 from datetime import datetime
 import hashlib
+import heapq
 import json
 import math
 import os
@@ -65,7 +66,8 @@ def start(job):
         "GITHUB_REPOSITORY", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", 
"GITHUB_SHA",
         "GITHUB_WORKFLOW_REF", "GITHUB_EVENT_NAME", "RUNNER_OS", "RUNNER_ARCH",
         "ImageOS", "ImageVersion", "DEPLOY_SOURCE_SHA", "DORIS_ENABLE_JA",
-        "DORIS_ENABLE_KO", "DORIS_ENABLE_RU", "DOCS_VERSIONS")}
+        "DORIS_ENABLE_KO", "DORIS_ENABLE_RU", "DOCS_VERSIONS", "NODE_OPTIONS",
+        "TERSER_PARALLEL", "RAYON_NUM_THREADS", "DOCUSAURUS_SSR_CONCURRENCY")}
     write_json(REPORTS / "context.json", {
         "schemaVersion": 1, "job": job, "startedAt": time.time(),
         "checkoutSha": git("rev-parse", "HEAD"), "context": context,
@@ -73,24 +75,51 @@ def start(job):
         "activeVersions": (["current", 
*json.loads(Path("versions.json").read_text())]
                            if job == "build" else None),
         "checkoutActiveVersions": ["current", 
*json.loads(Path("versions.json").read_text())],
-        "cachePolicy": ("fresh runner; no dependency cache; yarn cache clean 
before install"
+        "cachePolicy": ("Yarn download cache keyed by yarn.lock; frozen 
install with postinstall"
                         if job == "build" else "fresh runner; site downloaded 
from this run's artifact"),
         "initialResources": snapshot(),
     })
     launch_sampler()
 
 
-def site_inventory(site):
+def site_inventory(site, detailed=False):
     if not site.is_dir():
         return None
     files, size = 0, 0
+    extensions, directories, largest = {}, {}, []
     for root, _, names in os.walk(site):
         for name in names:
             item = Path(root) / name
             if item.is_file():
+                item_size = item.stat().st_size
                 files += 1
-                size += item.stat().st_size
-    return {"files": files, "bytes": size}
+                size += item_size
+                if detailed:
+                    relative = item.relative_to(site)
+                    directory = "/".join(relative.parts[:2] if 
relative.parts[0] == "assets" else relative.parts[:1])
+                    for table, key in [(extensions, item.suffix.lower() or 
"(none)"), (directories, directory)]:
+                        row = table.setdefault(key, {"files": 0, "bytes": 0})
+                        row["files"] += 1
+                        row["bytes"] += item_size
+                    largest.append({"path": relative.as_posix(), "bytes": 
item_size})
+    return {"files": files, "bytes": size, **({"byExtension": extensions, 
"byDirectory": directories,
+        "largestFiles": heapq.nlargest(20, largest, key=lambda row: 
row["bytes"])} if detailed else {})}
+
+
+def archive_generated_inputs():
+    # These are public generated build inputs, not arbitrary tracked files or
+    # environment snapshots. Preserve their exact bytes for reproduction.
+    result = {}
+    for name in ["scripts/last-update/data.json", 
"src/constant/github.data.ts", "src/generated/key-features.ts"]:
+        source = Path(name)
+        if not source.is_file() or source.is_symlink() or not 
source.resolve().is_relative_to(Path.cwd().resolve()):
+            continue
+        content = source.read_bytes()
+        target = REPORTS / "inputs" / name
+        target.parent.mkdir(parents=True, exist_ok=True)
+        target.write_bytes(content)
+        result[name] = {"bytes": len(content), "sha256": 
hashlib.sha256(content).hexdigest()}
+    return result
 
 
 def api(url):
@@ -145,7 +174,12 @@ def finish(budget):
     report["finishedAt"] = time.time()
     report["elapsedThroughReportSeconds"] = report["finishedAt"] - 
report["startedAt"]
     report["finalResources"] = snapshot()
-    report["site"] = site_inventory(Path("build"))
+    report["site"] = site_inventory(Path("build"), detailed=True)
+    if report["job"] == "build":
+        report["dependencyCacheHit"] = os.environ.get("DEPENDENCY_CACHE_HIT")
+        report["generatedInputs"] = archive_generated_inputs()
+        changes = subprocess.check_output(["git", "diff", "--name-only", "-z", 
"HEAD"]).decode().split("\0")
+        report["unarchivedTrackedChanges"] = [name for name in changes if name 
and name not in report["generatedInputs"]]
     if report["job"] == "deploy" and Path("build/versions.json").is_file():
         # Manual deploy code is checked out at the workflow commit, which
         # may have different versions from the protected source branch.
diff --git a/.github/scripts/test_pr_gates.py b/.github/scripts/test_pr_gates.py
new file mode 100644
index 000000000000..138f158a8abb
--- /dev/null
+++ b/.github/scripts/test_pr_gates.py
@@ -0,0 +1,108 @@
+import os
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def step_script(workflow, name):
+    source = (ROOT / '.github/workflows' / workflow).read_text()
+    match = re.search(r'^(\s*)- name: ' + re.escape(name) + r'\s*$', source, 
re.M)
+    assert match, name
+    lines = source[match.end():].splitlines()
+    for i, line in enumerate(lines):
+        if re.match(r'^\s*run: \|\s*$', line):
+            indent = len(line) - len(line.lstrip())
+            body = []
+            for value in lines[i + 1:]:
+                if value.strip() and len(value) - len(value.lstrip()) <= 
indent:
+                    break
+                body.append(value)
+            return textwrap.dedent('\n'.join(body))
+    raise AssertionError('Missing run block: ' + name)
+
+
+class PrGateRegression(unittest.TestCase):
+    def setUp(self):
+        self.temp = tempfile.TemporaryDirectory(prefix='doris-pr-gates-')
+        self.addCleanup(self.temp.cleanup)
+        self.root = Path(self.temp.name)
+        self.git('init', '-q', '--initial-branch=master')
+        self.git('config', 'user.name', 'Fixture')
+        self.git('config', 'user.email', '[email protected]')
+        self.git('commit', '--allow-empty', '-qm', 'base')
+        self.base = self.git('rev-parse', 'HEAD').strip()
+        self.env = {**os.environ, 'RUNNER_TEMP': str(self.root),
+                    'GITHUB_OUTPUT': str(self.root / 'outputs'), 'BASE_REF': 
'master'}
+        self.git('update-ref', 'refs/remotes/origin/master', self.base)
+
+    def git(self, *args):
+        return subprocess.check_output(['git', *args], cwd=self.root, 
text=True)
+
+    def write(self, name, value):
+        file = self.root / name
+        file.parent.mkdir(parents=True, exist_ok=True)
+        file.write_bytes(value if isinstance(value, bytes) else value.encode())
+
+    def commit(self):
+        self.git('add', '.')
+        self.git('commit', '-qm', 'change')
+
+    def run_step(self, workflow, name):
+        command = step_script(workflow, name).replace('${{ 
github.event.pull_request.base.sha }}', self.base)
+        return subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', command], 
cwd=self.root,
+                              env=self.env, text=True, capture_output=True)
+
+    def 
test_size_gate_checks_the_101st_file_and_preserves_boundary_and_exception(self):
+        for i in range(100):
+            self.write(f'a{i:03}.txt', 'small')
+        self.write('z large\n中文.bin', b'x' * 1024001)
+        self.write('scripts/last-update/data.json', b'x' * 1024001)
+        self.commit()
+        result = self.run_step('check-pr-file-sizes.yml', 'Get changed files 
in PR')
+        self.assertEqual(result.returncode, 0, result.stderr)
+        selected = (self.root / 'pr-size-files').read_bytes().split(b'\0')
+        self.assertIn('z large\n中文.bin'.encode(), selected)
+        result = self.run_step('check-pr-file-sizes.yml', 'Check file sizes')
+        self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+        self.assertIn('z large', result.stdout)
+        self.write('z large\n中文.bin', b'x' * 1024000)
+        result = self.run_step('check-pr-file-sizes.yml', 'Check file sizes')
+        self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+    def 
test_markdown_gate_selects_all_locales_versions_plugins_and_rename_destinations(self):
+        self.write('docs/old.md', '# Before')
+        self.commit()
+        self.base = self.git('rev-parse', 'HEAD').strip()
+        self.git('update-ref', 'refs/remotes/origin/master', self.base)
+        self.git('mv', 'docs/old.md', 'docs/new name.md')
+        paths = ['versioned_docs/version-4.x/topic.md', 'i18n/ru/русский.md',
+                 'i18n/ko/한국어.md', 'i18n/ja/日本語.md', 'i18n/zh-CN/中文.md',
+                 'community/topic.md', 'course/topic.mdx', 
'releasenotes/topic.md',
+                 'blog/topic.md', 'docs/new\nline.md']
+        for name in paths:
+            self.write(name, '# Topic')
+        self.commit()
+        result = self.run_step('check-md-links.yml', 'Collect added/modified 
Markdown files')
+        self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+        selected = {value.decode() for value in (self.root / 
'pr-markdown-files').read_bytes().split(b'\0') if value}
+        self.assertEqual(selected, set(paths + ['docs/new name.md']))
+        self.assertIn('has_files=true', (self.root / 'outputs').read_text())
+
+    def 
test_node_link_checker_checks_earlier_commits_and_paths_with_spaces(self):
+        self.write('versioned_docs/version-4.x/first topic.md', '# 
Topic\n\n[Broken](missing.md)\n')
+        self.commit()
+        self.write('later.txt', 'not Markdown')
+        self.commit()
+        result = subprocess.run(['node', str(ROOT / 'scripts/check_move.js'), 
'--base', self.base],
+                                cwd=self.root, text=True, capture_output=True)
+        self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
+        self.assertIn('missing.md', result.stderr)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/.github/scripts/test_publish_metrics.py 
b/.github/scripts/test_publish_metrics.py
index b775e7e4d52a..70f8b56e59ca 100644
--- a/.github/scripts/test_publish_metrics.py
+++ b/.github/scripts/test_publish_metrics.py
@@ -18,6 +18,37 @@ spec.loader.exec_module(metrics)
 
 
 class PublishMetricsTests(unittest.TestCase):
+    def 
test_generated_inputs_archive_is_limited_to_public_inputs_and_rejects_symlinks(self):
+        with tempfile.TemporaryDirectory() as directory:
+            previous = Path.cwd()
+            os.chdir(directory)
+            try:
+                source = Path('src/constant/github.data.ts')
+                source.parent.mkdir(parents=True)
+                source.write_text("export const STAR_COUNT = '16.1';")
+                Path('private.env').write_text('SECRET_MUST_NOT_APPEAR')
+                linked = Path('src/generated/key-features.ts')
+                linked.parent.mkdir(parents=True)
+                linked.symlink_to(Path('private.env').resolve())
+                result = metrics.archive_generated_inputs()
+                self.assertEqual(set(result), {'src/constant/github.data.ts'})
+                self.assertEqual((metrics.REPORTS / 'inputs' / 
source).read_bytes(), source.read_bytes())
+                self.assertNotIn('SECRET_MUST_NOT_APPEAR', json.dumps(result))
+            finally:
+                os.chdir(previous)
+
+    def 
test_detailed_inventory_retains_totals_and_reports_file_categories(self):
+        with tempfile.TemporaryDirectory() as directory:
+            site = Path(directory)
+            (site / 'assets/js').mkdir(parents=True)
+            (site / 'assets/js/main.js').write_bytes(b'12345')
+            (site / '.htaccess').write_bytes(b'abc')
+            detailed = metrics.site_inventory(site, detailed=True)
+            self.assertEqual({key: detailed[key] for key in ['files', 
'bytes']}, metrics.site_inventory(site))
+            self.assertEqual(detailed['byExtension']['.js'], {'files': 1, 
'bytes': 5})
+            self.assertEqual(detailed['byDirectory']['assets/js'], {'files': 
1, 'bytes': 5})
+            self.assertEqual(detailed['largestFiles'][0]['path'], 
'assets/js/main.js')
+
     def test_resource_summary_preserves_disk_and_memory_extrema(self):
         samples = [
             {"memory": {"MemAvailableKiB": 100}, "disks": {"/": {"usedBytes": 
800, "freeBytes": 200}}},
diff --git a/.github/scripts/test_review.py b/.github/scripts/test_review.py
index 1bbd324fbd55..8e0af1ba5834 100644
--- a/.github/scripts/test_review.py
+++ b/.github/scripts/test_review.py
@@ -241,6 +241,7 @@ class WorkflowTests(unittest.TestCase):
             head = git("rev-parse", "HEAD")
             git("merge", "--no-ff", "-qm", "PR merge snapshot", "master")
             merge = git("rev-parse", "HEAD")
+            git("update-ref", "refs/remotes/origin/master", git("rev-parse", 
"master"))
             refs = {"${{ github.sha }}": merge, "${{ 
github.event.pull_request.head.sha }}": head}
             git("checkout", "--detach", refs[checkout["with"]["ref"]])
             completed = subprocess.run(security["run"], cwd=root, shell=True,
@@ -250,7 +251,7 @@ class WorkflowTests(unittest.TestCase):
             # A clean merge has no combined diff; the link checker must still 
inspect PR files.
             (root / "scripts").symlink_to(ROOT / "scripts", 
target_is_directory=True)
             completed = subprocess.run(links["run"], cwd=root, shell=True, 
executable="/bin/bash",
-                                       env={**os.environ, **{key: refs[value]
+                                       env={**os.environ, "BASE_REF": 
"master", **{key: refs[value]
                                             for key, value in links.get("env", 
{}).items()}},
                                        capture_output=True, text=True)
             self.assertEqual(completed.returncode, 1, completed.stdout + 
completed.stderr)
diff --git a/.github/workflows/build-check.yml 
b/.github/workflows/build-check.yml
index d26daa2225be..f8fecc3c6f85 100644
--- a/.github/workflows/build-check.yml
+++ b/.github/workflows/build-check.yml
@@ -79,12 +79,9 @@ jobs:
                   
             
             - name: Run Node.js dead link checker
-              env:
-                PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
-                CI_SHA: ${{ github.sha }}
               run: |
-                # git show on a clean merge omits the PR's changed files.
-                node ./scripts/check_move.js "${PR_HEAD_SHA:-$CI_SHA}"
+                BASE_SHA=$(git merge-base "origin/$BASE_REF" HEAD)
+                node ./scripts/check_move.js --base "$BASE_SHA"
         
             - name: Detect changed doc versions and locales
               id: detect
@@ -166,7 +163,7 @@ jobs:
                   done
 
             - name: Install browser verification tools
-              if: steps.detect.outputs.validation_mode == 'full' || 
steps.detect.outputs.validation_mode == 'sample' || 
steps.detect.outputs.publish_fixture == 'true' || 
steps.detect.outputs.content_fixture == 'true' || 
steps.detect.outputs.sampling_fixture == 'true'
+              if: steps.detect.outputs.validation_mode == 'full' || 
steps.detect.outputs.validation_mode == 'sample' || 
steps.detect.outputs.publish_fixture == 'true' || 
steps.detect.outputs.content_fixture == 'true' || 
steps.detect.outputs.sampling_fixture == 'true' || 
steps.detect.outputs.search_fixture == 'true'
               run: |
                   DORIS_BROWSER_TOOLS="$RUNNER_TEMP/doris-browser-tools"
                   npm install --prefix "$DORIS_BROWSER_TOOLS" --no-save 
--no-package-lock [email protected]
@@ -177,6 +174,10 @@ jobs:
               if: steps.detect.outputs.sampling_fixture == 'true'
               run: node scripts/i18n/verify-ci-sampling-fixture.cjs --browser
 
+            - name: Verify search transport and recovery in Chromium
+              if: steps.detect.outputs.search_fixture == 'true'
+              run: node scripts/i18n/verify-search-transport.cjs --browser
+
             - name: Verify five-language publishing fixture in Chromium
               if: steps.detect.outputs.publish_fixture == 'true'
               env:
@@ -265,6 +266,7 @@ jobs:
                       .i18n-build/ci-sample/scopes/*.json
                       .i18n-build/ci-sample/verification.json
                       .i18n-build/ci-sampling-fixture-proof.json
+                      .i18n-build/search-transport-verification.json
                       .i18n-build/publish-result.json
                       .i18n-build/build-result.json
                       .i18n-build/content-fixture-verification.json
diff --git a/.github/workflows/check-md-links.yml 
b/.github/workflows/check-md-links.yml
index 8b9f8ea3422e..4c8b17fb10c0 100644
--- a/.github/workflows/check-md-links.yml
+++ b/.github/workflows/check-md-links.yml
@@ -29,23 +29,26 @@ jobs:
         env:
           BASE_REF: ${{ github.event.pull_request.base.ref }}
         run: |
-          # Only added (A) or modified (M) files in this PR — deletions and 
renames-source are skipped.
+          # Check added/modified files and rename destinations across all
+          # content plugins, versions and locales; deleted files are skipped.
           # github.event.pull_request.base.sha is the base branch tip from 
when the PR was opened,
           # not the current one, so diffing against it drags in every commit 
that landed on the base
           # branch since then. Resolve the merge base against the base branch 
as it is now: the
           # checkout is refs/pull/<n>/merge, so this leaves exactly the PR's 
own changes.
           BASE_SHA="$(git merge-base "origin/$BASE_REF" HEAD)"
           echo "Diffing against origin/$BASE_REF ($BASE_SHA)"
-          {
-            echo 'files<<EOF'
-            git diff --name-only --diff-filter=AM \
+          git diff --name-only --diff-filter=AMR -z \
               "$BASE_SHA" HEAD \
-              -- 'docs/**/*.md' 'docs/**/*.mdx' \
-                 'i18n/zh-CN/docusaurus-plugin-content-docs/current/**/*.md' \
-                 'i18n/zh-CN/docusaurus-plugin-content-docs/current/**/*.mdx' \
-                 'i18n/ja/**/*.md' 'i18n/ja/**/*.mdx'
-            echo EOF
-          } >> "$GITHUB_OUTPUT"
+              -- ':(glob)docs/**/*.md' ':(glob)docs/**/*.mdx' \
+                 ':(glob)versioned_docs/**/*.md' 
':(glob)versioned_docs/**/*.mdx' \
+                 ':(glob)i18n/**/*.md' ':(glob)i18n/**/*.mdx' \
+                 ':(glob)community/**/*.md' ':(glob)community/**/*.mdx' \
+                 ':(glob)course/**/*.md' ':(glob)course/**/*.mdx' \
+                 ':(glob)releasenotes/**/*.md' ':(glob)releasenotes/**/*.mdx' \
+                 ':(glob)blog/**/*.md' ':(glob)blog/**/*.mdx' > 
"$RUNNER_TEMP/pr-markdown-files"
+          if [ -s "$RUNNER_TEMP/pr-markdown-files" ]; then
+            echo 'has_files=true' >> "$GITHUB_OUTPUT"
+          fi
           {
             echo 'scripts<<EOF'
             git diff --name-only "$BASE_SHA" HEAD \
@@ -54,7 +57,7 @@ jobs:
           } >> "$GITHUB_OUTPUT"
 
       - name: Set up Python
-        if: steps.changed.outputs.files != '' || steps.changed.outputs.scripts 
!= ''
+        if: steps.changed.outputs.has_files == 'true' || 
steps.changed.outputs.scripts != ''
         uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 
v5
         with:
           python-version: '3.11'
@@ -64,22 +67,11 @@ jobs:
         run: python3 -m unittest scripts/__tests__/test_japanese_links.py
 
       - name: Check links in changed Markdown files
-        if: steps.changed.outputs.files != ''
-        env:
-          FILES: ${{ steps.changed.outputs.files }}
+        if: steps.changed.outputs.has_files == 'true'
         run: |
           set -u
-          if [ -z "$FILES" ]; then
-            echo "No Markdown files added or modified in this PR. Skipping."
-            exit 0
-          fi
-
-          echo "Markdown files to check:"
-          echo "$FILES"
-          echo "------------------------------------------------------------"
-
           FAILED=()
-          while IFS= read -r file; do
+          while IFS= read -r -d '' file; do
             [ -z "$file" ] && continue
             if [ ! -f "$file" ]; then
               echo "Skip (not found in working tree): $file"
@@ -90,7 +82,7 @@ jobs:
             if ! python3 scripts/check_md_links_single.py "$file" 
--no-external; then
               FAILED+=("$file")
             fi
-          done <<< "$FILES"
+          done < "$RUNNER_TEMP/pr-markdown-files"
 
           echo
           echo "============================================================"
diff --git a/.github/workflows/check-pr-file-sizes.yml 
b/.github/workflows/check-pr-file-sizes.yml
index 1a62304bee25..d519936837d9 100644
--- a/.github/workflows/check-pr-file-sizes.yml
+++ b/.github/workflows/check-pr-file-sizes.yml
@@ -27,24 +27,16 @@ jobs:
         run: git fetch --depth=1 --filter=blob:none origin ${{ 
github.event.pull_request.base.sha }}
 
       - name: Get changed files in PR
-        id: changed
         run: |
-          # Get list of files changed in the PR compared to base branch
-          # Use delimiter syntax for multi-line output
-          {
-            echo 'files<<EOF'
-            git diff --name-only ${{ github.event.pull_request.base.sha }} 
HEAD | head -100
-            echo EOF
-          } >> "$GITHUB_OUTPUT"
+          git diff --name-only --diff-filter=AMR -z \
+            ${{ github.event.pull_request.base.sha }} HEAD > 
"$RUNNER_TEMP/pr-size-files"
 
       - name: Check file sizes
-        env:
-          FILES: ${{ steps.changed.outputs.files }}
         run: |
-          MAX_SIZE=1024000  # 500KB in bytes
+          MAX_SIZE=1024000  # 1,000 KiB; preserve the existing byte limit.
           OVER_LIMIT=()
 
-          while IFS= read -r file; do
+          while IFS= read -r -d '' file; do
             [ -z "$file" ] && continue
             # Auto-generated last-update timestamp map (scripts/last-update/):
             # legitimately large and regenerated by tooling, not a 
hand-committed
@@ -57,10 +49,10 @@ jobs:
                 OVER_LIMIT+=("❌ $file (${size_kb}KB)")
               fi
             fi
-          done <<< "$FILES"
+          done < "$RUNNER_TEMP/pr-size-files"
 
           if [ ${#OVER_LIMIT[@]} -gt 0 ]; then
-            echo "❌ The following files exceed 500KB:"
+            echo "❌ The following files exceed 1,000 KiB:"
             for f in "${OVER_LIMIT[@]}"; do
               echo "  $f"
             done
@@ -69,4 +61,4 @@ jobs:
             exit 1
           fi
 
-          echo "✅ All files are under 500KB"
+          echo "✅ All checked files are at or below 1,000 KiB"
diff --git a/.github/workflows/cron-deploy-website.yml 
b/.github/workflows/cron-deploy-website.yml
index b79b81484e3b..44f0cd32a993 100644
--- a/.github/workflows/cron-deploy-website.yml
+++ b/.github/workflows/cron-deploy-website.yml
@@ -53,6 +53,12 @@ jobs:
         DORIS_ENABLE_JA: 'true'
         DORIS_ENABLE_KO: 'true'
         DORIS_ENABLE_RU: 'true'
+        NODE_OPTIONS: '--max-old-space-size=6144'
+        TERSER_PARALLEL: '2'
+        RAYON_NUM_THREADS: '2'
+        DOCUSAURUS_SSR_CONCURRENCY: '8'
+        DOCUSAURUS_PERF_LOGGER: 'true'
+        DORIS_BUILD_METRICS: 'true'
     steps:
         - name: Checkout
           # v4 (the pinned @master commit predates the `filter` input) with
@@ -94,9 +100,11 @@ jobs:
               fc-list :lang=zh
 
         - name: Use Node.js
+          id: node
           uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # 
v3
           with:
               node-version: 20
+              cache: yarn
 
         - name: Refresh docs last-update map
           # Recompute scripts/last-update/data.json from the history fetched
@@ -124,12 +132,9 @@ jobs:
               GITHUB_TOKEN: ${{ github.token }}
           run: |
               /usr/bin/time -v -o .i18n-build/publish-metrics/build-time.txt 
bash -e -o pipefail <<'BUILD'
-              npm install -g yarn
-              yarn cache clean
-              export NODE_OPTIONS=--max-old-space-size=8192
-              yarn
+              npm install -g [email protected]
+              yarn --frozen-lockfile
               node ./scripts/update_github_info.js
-              yarn key-features:generate
               PWA_SERVICE_WORKER_URL=https://doris.apache.org/sw.js yarn 
build:publish
               touch build/.dummy
               cp .asf-site.yaml ./build/.asf.yaml
@@ -146,9 +151,8 @@ jobs:
               node scripts/i18n/manual-report.cjs --coverage
               for LOCALE in $(node scripts/i18n/managed-locales.cjs); do
                   test -s "build/${LOCALE}/index.html"
-                  node scripts/i18n/verify-static.cjs \
-                    --site build --manifest 
".i18n-build/manifests/${LOCALE}.json"
               done
+              node scripts/i18n/verify-static.cjs --site build --all-managed
               VERIFY
 
         - name: Check file sizes for asf-site
@@ -175,7 +179,7 @@ jobs:
             name: site-build-${{ github.run_id }}-${{ github.run_attempt }}
             path: build
             include-hidden-files: true
-            compression-level: 0
+            compression-level: 1
             if-no-files-found: error
             retention-days: 1
 
@@ -185,6 +189,7 @@ jobs:
               GH_TOKEN: ${{ github.token }}
               SITE_ARTIFACT_ID: ${{ steps.site-artifact.outputs.artifact-id }}
               SITE_ARTIFACT_DIGEST: ${{ 
steps.site-artifact.outputs.artifact-digest }}
+              DEPENDENCY_CACHE_HIT: ${{ steps.node.outputs.cache-hit }}
           run: python3 .publish-tools/.github/scripts/publish_metrics.py 
finish --budget 180
 
         - name: Upload publishing measurements
diff --git a/.github/workflows/manual-deploy-website.yml 
b/.github/workflows/manual-deploy-website.yml
index 1c9000046ff5..b433d7b49e5f 100644
--- a/.github/workflows/manual-deploy-website.yml
+++ b/.github/workflows/manual-deploy-website.yml
@@ -48,6 +48,12 @@ jobs:
         DORIS_ENABLE_JA: 'true'
         DORIS_ENABLE_KO: 'true'
         DORIS_ENABLE_RU: 'true'
+        NODE_OPTIONS: '--max-old-space-size=6144'
+        TERSER_PARALLEL: '2'
+        RAYON_NUM_THREADS: '2'
+        DOCUSAURUS_SSR_CONCURRENCY: '8'
+        DOCUSAURUS_PERF_LOGGER: 'true'
+        DORIS_BUILD_METRICS: 'true'
     steps:
         - name: Checkout
           uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
@@ -57,6 +63,7 @@ jobs:
               # Sitemap lastmod comes from each file's last commit, so a
               # shallow clone would date every page to the same commit.
               fetch-depth: 0
+              filter: blob:none
 
         - name: Checkout trusted measurement code
           uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
@@ -86,9 +93,11 @@ jobs:
               fc-list :lang=zh
 
         - name: Use Node.js
+          id: node
           uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # 
v3
           with:
               node-version: 20
+              cache: yarn
 
         - name: Add Swap Space
           run: |
@@ -104,12 +113,9 @@ jobs:
               GITHUB_TOKEN: ${{ github.token }}
           run: |
               /usr/bin/time -v -o .i18n-build/publish-metrics/build-time.txt 
bash -e -o pipefail <<'BUILD'
-              npm install -g yarn
-              yarn cache clean
-              export NODE_OPTIONS=--max-old-space-size=8192
-              yarn
+              npm install -g [email protected]
+              yarn --frozen-lockfile
               node ./scripts/update_github_info.js
-              yarn key-features:generate
               PWA_SERVICE_WORKER_URL=https://doris.apache.org/sw.js yarn 
build:publish
               touch build/.dummy
               cp .asf-site.yaml ./build/.asf.yaml
@@ -126,9 +132,8 @@ jobs:
               node scripts/i18n/manual-report.cjs --coverage
               for LOCALE in $(node scripts/i18n/managed-locales.cjs); do
                   test -s "build/${LOCALE}/index.html"
-                  node scripts/i18n/verify-static.cjs \
-                    --site build --manifest 
".i18n-build/manifests/${LOCALE}.json"
               done
+              node scripts/i18n/verify-static.cjs --site build --all-managed
               VERIFY
 
         - name: Check file sizes for asf-site
@@ -147,7 +152,7 @@ jobs:
             name: site-build-${{ github.run_id }}-${{ github.run_attempt }}
             path: build
             include-hidden-files: true
-            compression-level: 0
+            compression-level: 1
             if-no-files-found: error
             retention-days: 1
 
@@ -157,6 +162,7 @@ jobs:
               GH_TOKEN: ${{ github.token }}
               SITE_ARTIFACT_ID: ${{ steps.site-artifact.outputs.artifact-id }}
               SITE_ARTIFACT_DIGEST: ${{ 
steps.site-artifact.outputs.artifact-digest }}
+              DEPENDENCY_CACHE_HIT: ${{ steps.node.outputs.cache-hit }}
           run: python3 .publish-tools/.github/scripts/publish_metrics.py 
finish --budget 180
 
         - name: Upload publishing measurements
diff --git a/config/custom-docusaurus-plugin.js 
b/config/custom-docusaurus-plugin.js
index 145ee51dc2a0..5de04235899f 100644
--- a/config/custom-docusaurus-plugin.js
+++ b/config/custom-docusaurus-plugin.js
@@ -20,7 +20,9 @@ function clientMinimizers(config) {
                 parse: {ecma: 2020},
                 compress: {ecma: 5},
                 mangle: {safari10: true},
-                format: {ecma: 5, comments: false, ascii_only: true},
+                // The site and assets are UTF-8. Keeping translated strings
+                // avoids expanding each CJK character into six ASCII bytes.
+                format: {ecma: 5, comments: false, ascii_only: false},
             },
         });
     });
@@ -38,13 +40,21 @@ module.exports = function (context, options) {
             const assetOrigin = getAssetOrigin();
             const generated = config.resolve?.alias?.['@generated'] ?? 
context.generatedFilesDir;
             const registry = typeof generated === 'string' ? 
path.join(generated, 'registry.js') : null;
+            const rules = [];
+            if (config.mode === 'production' && registry) rules.push({
+                enforce: 'pre',
+                test: file => path.resolve(file) === registry,
+                use: [{loader: 
require.resolve('../scripts/publish/registry-loader.cjs')}],
+            });
+            if (!isServer && config.mode === 'production') {
+                const worker = 
require.resolve('@yang1666204/docusaurus-search-local/dist/client/client/theme/worker.js');
+                rules.push({enforce: 'pre', test: file => path.resolve(file) 
=== worker,
+                    use: [{loader: 
require.resolve('../scripts/i18n/search-worker-loader.cjs'),
+                        options: {assetOrigin, siteOrigin: 
context.siteConfig.url ?? ''}}]});
+            }
 
             return {
-                ...(config.mode === 'production' && registry ? {module: 
{rules: [{
-                    enforce: 'pre',
-                    test: file => path.resolve(file) === registry,
-                    use: [{loader: 
require.resolve('../scripts/publish/registry-loader.cjs')}],
-                }]}} : {}),
+                ...(rules.length ? {module: {rules}} : {}),
                 ...(!isServer && config.mode === 'production' ? {
                     mergeStrategy: {'optimization.minimizer': 'replace'},
                     optimization: {minimizer: clientMinimizers(config)},
diff --git a/scripts/build-locales.cjs b/scripts/build-locales.cjs
index b6a9b477789b..74f62979392d 100644
--- a/scripts/build-locales.cjs
+++ b/scripts/build-locales.cjs
@@ -53,15 +53,33 @@ async function buildLocales({siteDir = process.cwd(), 
outDir = 'build', locales:
         const stop = signal => child.kill(signal);
         const term = () => stop('SIGTERM');
         const interrupt = () => stop('SIGINT');
+        const memory = process.env.DORIS_BUILD_METRICS === 'true' && 
process.platform === 'linux'
+            ? 
{sampleIntervalMs:1000,samples:0,peakRssKiB:0,minimumSystemAvailableKiB:null} : 
null;
+        const sample = () => {
+            try {
+                const rss = 
Number(fs.readFileSync(`/proc/${child.pid}/status`,'utf8').match(/^VmRSS:\s+(\d+)/m)?.[1]);
+                const available = 
Number(fs.readFileSync('/proc/meminfo','utf8').match(/^MemAvailable:\s+(\d+)/m)?.[1]);
+                if (rss > 0 && available > 0) {
+                    memory.samples++;
+                    memory.peakRssKiB = Math.max(memory.peakRssKiB,rss);
+                    memory.minimumSystemAvailableKiB = 
Math.min(memory.minimumSystemAvailableKiB ?? Infinity,available);
+                }
+            } catch {} // A child can exit between the timer and /proc read.
+        };
+        const sampler = memory ? setInterval(sample,1000) : null;
+        const cleanup = () => {
+            if (sampler) clearInterval(sampler);
+            process.removeListener('SIGTERM',term);
+            process.removeListener('SIGINT',interrupt);
+        };
         process.once('SIGTERM', term);
         process.once('SIGINT', interrupt);
-        child.once('error', reject);
+        child.once('error', error => {cleanup();reject(error);});
         child.once('exit', (code, signal) => {
-            process.removeListener('SIGTERM', term);
-            process.removeListener('SIGINT', interrupt);
+            cleanup();
             console.log(JSON.stringify({event:'locale_process_end', 
mode:args[0], locale:args[0] === 'plan' ? null : args[3], pid:child.pid, code, 
signal}));
             if (code !== 0) reject(new Error(`Locale ${args[3]} failed 
(${signal ?? code}); output was not published`));
-            else resolve({pid:child.pid});
+            else resolve({pid:child.pid,...(memory && {memory})});
         });
     }));
     try {
diff --git a/scripts/check_move.js b/scripts/check_move.js
index 011de09d081f..4291bac6f9f2 100644
--- a/scripts/check_move.js
+++ b/scripts/check_move.js
@@ -4,17 +4,18 @@
  */
 
 
-const { execSync } = require("child_process");
+const { execFileSync } = require("child_process");
 const fs = require("fs");
 const path = require("path");
 const { sourceForLocalized } = require("./i18n/paths.cjs");
 const { managedLocales } = require("./i18n/state.cjs");
 const translationLocales = new Set(managedLocales(path.resolve(__dirname, 
"..")));
 
-const commitHash = process.argv[2];
+const baseRef = process.argv[2] === '--base' ? process.argv[3] : null;
+const commitHash = baseRef ? null : process.argv[2];
 
-if (require.main === module && !commitHash) {
-  console.error("❌ Please provide the commit hash, such as: node 
check-dead-links.js <commit-hash>");
+if (require.main === module && (!commitHash && !baseRef || commitHash === 
'--base')) {
+  console.error("Provide a commit hash, or --base <merge-base> to check the 
complete PR diff.");
   process.exit(1);
 }
 
@@ -22,27 +23,11 @@ const linkRegex = /\[.*?\]\((.*?)\)/g;
 let hasBrokenLinks = false;
 
 // Get the modified or newly added .md/.mdx files in the commit
-function getModifiedMarkdownFiles(commit) {
-  const output = execSync(
-    `git show --name-status --diff-filter=AM ${commit} -- '*.md' '*.mdx'`,
-    { encoding: "utf-8", maxBuffer: 100 * 1024 * 1024 }
-  );
-  const lines = output.split("\n");
-  console.log('lines',lines);
-
-  const files = [];
-
-  for (const line of lines) {
-    const parts = line.trim().split(/\s+/);
-    if (parts.length === 2) {
-      const [status, filePath] = parts;
-      if ((status === "A" || status === "M") && (filePath.endsWith(".md") || 
filePath.endsWith(".mdx"))) {
-        files.push(filePath);
-      }
-    }
-  }
-
-  return files;
+function getModifiedMarkdownFiles(commit, base) {
+  const selection = base ? ['diff', '--name-only', '--diff-filter=AMR', '-z', 
base, 'HEAD']
+    : ['show', '--format=', '--name-only', '--diff-filter=AMR', '-z', commit];
+  return execFileSync('git', [...selection, '--', '*.md', '*.mdx'],
+    {encoding: 'utf8', maxBuffer: 100 * 1024 * 
1024}).split('\0').filter(Boolean);
 }
 
 // Checks if the link points to an existing local file
@@ -228,7 +213,7 @@ function checkFileLinks(filePath) {
 
 // Main function
 function main() {
-  const files = getModifiedMarkdownFiles(commitHash);
+  const files = getModifiedMarkdownFiles(commitHash, baseRef);
   if (files.length === 0) {
     console.log("✅ Unmodified Markdown files");
     return;
diff --git a/scripts/i18n/__tests__/content-manifest.test.cjs 
b/scripts/i18n/__tests__/content-manifest.test.cjs
index 686427d3efb3..73fa733db109 100644
--- a/scripts/i18n/__tests__/content-manifest.test.cjs
+++ b/scripts/i18n/__tests__/content-manifest.test.cjs
@@ -57,6 +57,9 @@ test('real native plugin output binds stable identities, 
routes and all four cov
         assert.equal(missing.navigationRoutes['zh-CN'], 
'/zh-CN/docs/dev/english-missing/');
         assert(!JSON.stringify(globalData).includes('sourceHash'));
         assert(!JSON.stringify(routeData(page)).includes('sourceHash'));
+        assert.deepEqual(routeData({...page, sourceSha:'a'.repeat(40)}),
+            routeData({...page, sourceSha:'b'.repeat(40)}), 'Source identity 
cannot invalidate unchanged browser routing data');
+        assert.equal(page.sourceSha, manifest.sourceSha, 'Full build 
provenance remains in the manifest');
         assert(Buffer.byteLength(JSON.stringify(routeData(page))) < 2048);
     }
     const buildOnly = await collect(root, 'en', {browserData: false});
diff --git a/scripts/i18n/__tests__/search-transport.test.cjs 
b/scripts/i18n/__tests__/search-transport.test.cjs
new file mode 100644
index 000000000000..4552404740e8
--- /dev/null
+++ b/scripts/i18n/__tests__/search-transport.test.cjs
@@ -0,0 +1,100 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const ts = require('typescript');
+const lunr = require('lunr');
+const {adaptSearchWorker} = require('../search-worker-loader.cjs');
+const {targetFingerprint} = require('../search-plugin.cjs');
+const root = path.resolve(__dirname, '../../..');
+function compile(source, imports = {}) {
+    const {outputText} = ts.transpileModule(source, {compilerOptions: {
+        module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, 
esModuleInterop: true,
+    }});
+    const module = {exports: {}};
+    new Function('require','module','exports','location',outputText)(name => {
+        if (Object.hasOwn(imports, name)) return imports[name];
+        return require(name);
+    },module,module.exports,{origin:'https://doris.apache.org'});
+    return module.exports;
+}
+const {fetchSearchIndex} = 
compile(fs.readFileSync(path.join(root,'src/utils/fetchSearchIndex.js'),'utf8'));
+const base = 
{origin:'https://doris.apache.org',siteOrigin:'https://doris.apache.org',assetOrigin:'https://cdnd.selectdb.com'};
+
+test('production search reads its configured CDN and preserves version, locale 
and fingerprint', async () => {
+    const requests=[],data=[{documents:[]}];
+    const result=await 
fetchSearchIndex('/ko/search-index-docs-dev-aabb.json?probe=1', {...base,
+        fetchImpl:async url=>{requests.push(url);return 
{ok:true,json:async()=>data};}});
+    assert.equal(result,data);
+    
assert.deepEqual(requests,['https://cdnd.selectdb.com/ko/search-index-docs-dev-aabb.json?probe=1']);
+});
+
+test('CDN HTTP, JSON and network failures fall back to the same-origin index', 
async () => {
+    for (const failure of ['http','json','network']) {
+        const requests=[];
+        const result=await 
fetchSearchIndex('/zh-CN/docs/dev/search-index-docs-aabb.json', {...base,
+            fetchImpl:async url=>{
+                requests.push(url);
+                if(requests.length===1) {
+                    if(failure==='network')throw new Error('offline');
+                    return 
{ok:failure!=='http',status:503,json:async()=>{throw new Error('bad JSON');}};
+                }
+                return {ok:true,json:async()=>[]};
+            }});
+        assert.deepEqual(result,[]);
+        
assert.deepEqual(requests,['https://cdnd.selectdb.com/zh-CN/docs/dev/search-index-docs-aabb.json','https://doris.apache.org/zh-CN/docs/dev/search-index-docs-aabb.json']);
+    }
+});
+
+test('local previews do not read production CDN data, and external version 
URLs fail closed', async () => {
+    const requests=[];
+    await fetchSearchIndex('/docs/dev/search-index-docs-aabb.json', 
{...base,origin:'http://127.0.0.1:3000',
+        fetchImpl:async url=>{requests.push(url);return 
{ok:true,json:async()=>[]};}});
+    
assert.deepEqual(requests,['http://127.0.0.1:3000/docs/dev/search-index-docs-aabb.json']);
+    await 
assert.rejects(fetchSearchIndex('https://example.com/search-index.json',base),/Unexpected
 search index origin/);
+});
+
+test('a stalled response is aborted before trying the other host', async () => 
{
+    const requests=[];
+    await fetchSearchIndex('/search-index-aabb.json', {...base,timeoutMs:5,
+        fetchImpl:async(url,{signal})=>{
+            requests.push(url);
+            if(requests.length===1)return new 
Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(new 
Error('aborted'))));
+            return {ok:true,json:async()=>[]};
+        }});
+    assert.equal(requests.length,2);
+});
+
+test('the actual worker discards rejected cache entries and reuses successful 
ones', async () => {
+    const 
source=fs.readFileSync(require.resolve('@yang1666204/docusaurus-search-local/dist/client/client/theme/worker.js'),'utf8');
+    const 
index=lunr(function(){this.ref('i');this.field('t');this.add({i:'1',t:'Doris'});});
+    const 
payload=[{documents:[{i:1,t:'Doris',u:'/docs/dev/intro/'}],index:index.toJSON()}];
+    let calls=0;
+    const {SearchWorker}=compile(adaptSearchWorker(source,base), {
+        
[require.resolve('../../../src/utils/fetchSearchIndex.js')]:{fetchSearchIndex:async()=>{if(++calls===1)throw
 new Error('offline');return payload;}},
+        comlink:{expose(){}},
+        
'../utils/proxiedGeneratedConstants':{searchIndexUrl:'search-index{dir}-aabb.json',language:['en']},
+        
'../utils/tokenize':{},'../utils/smartQueries':{},'../../shared/interfaces':{},
+        
'../utils/sortSearchResults':{},'../utils/processTreeStatusOfSearchResults':{},
+    });
+    const worker=new SearchWorker();
+    await assert.rejects(worker.fetchIndexes('/docs/dev/','docs'),/offline/);
+    await worker.fetchIndexes('/docs/dev/','docs');
+    await worker.fetchIndexes('/docs/dev/','docs');
+    assert.equal(calls,2);
+    await 
assert.rejects(worker.fetchIndexes('https://example.com/','docs'),/Unexpected 
version url/);
+    assert.throws(()=>adaptSearchWorker(source.replace('const cache = new 
Map();','const changed = 1;')),/Unexpected search worker/);
+});
+
+test('source-language search fingerprints include historical versions; managed 
fallback versions do not', t => {
+    const 
fixture=fs.mkdtempSync(path.join(os.tmpdir(),'doris-search-fingerprint-'));
+    t.after(()=>fs.rmSync(fixture,{recursive:true,force:true}));
+    fs.writeFileSync(path.join(fixture,'versions.json'),'["4.x","3.x","2.1"]');
+    
fs.mkdirSync(path.join(fixture,'versioned_docs/version-3.x'),{recursive:true});
+    const 
file=path.join(fixture,'versioned_docs/version-3.x/topic.md');fs.writeFileSync(file,'Before');
+    const 
english=targetFingerprint(fixture,'en',{allVersions:true}),japanese=targetFingerprint(fixture,'ja');
+    fs.writeFileSync(file,'After');
+    
assert.notEqual(targetFingerprint(fixture,'en',{allVersions:true}),english);
+    assert.equal(targetFingerprint(fixture,'ja'),japanese);
+});
diff --git a/scripts/i18n/__tests__/static-cache.test.cjs 
b/scripts/i18n/__tests__/static-cache.test.cjs
new file mode 100644
index 000000000000..b0456e1068e5
--- /dev/null
+++ b/scripts/i18n/__tests__/static-cache.test.cjs
@@ -0,0 +1,41 @@
+const test=require('node:test');
+const assert=require('node:assert/strict');
+const fs=require('node:fs'),path=require('node:path'),os=require('node:os');
+const cheerio=require('cheerio');
+const {verify,verifyAll}=require('../verify-static.cjs');
+
+function fixture(t) {
+    const root=fs.mkdtempSync(path.join(os.tmpdir(),'doris-static-cache-'));
+    t.after(()=>fs.rmSync(root,{recursive:true,force:true}));
+    const site=path.join(root,'build'),manifestDir=path.join(root,'manifests');
+    fs.mkdirSync(manifestDir);const 
alternates={en:'/topic/',ja:'/ja/topic/',ko:'/ko/topic/'};
+    const 
write=(file,text)=>{fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,text);};
+    for(const [locale,route]of Object.entries(alternates)) {
+        const links=Object.entries(alternates).map(([lang,url])=>`<link 
rel="alternate" hreflang="${lang==='en'?'en-US':lang}" 
href="https://doris.apache.org${url}";>`).join('');
+        write(path.join(site,route,'index.html'),`<html><head><link 
rel="canonical" 
href="https://doris.apache.org${route}";>${links}</head><body><div 
data-translation-status="current"></div><article class="theme-doc-markdown" 
lang="${locale}"><h2 id="usage">Usage</h2></article></body></html>`);
+        if(locale==='en')continue;
+        
write(path.join(site,locale,'sitemap.xml'),`<urlset><url><loc>https://doris.apache.org${route}</loc></url></urlset>`);
+        
write(path.join(site,locale,'search-index.json'),JSON.stringify([{documents:[{u:route}]}]));
+        
write(path.join(manifestDir,locale+'.json'),JSON.stringify({locale,pages:[{permalink:route,contentLocale:locale,
+            
status:'current',useTarget:true,indexable:true,englishPermalink:'/topic/',alternates}],reports:[]}));
+    }
+    return {site,manifestDir,locales:['ja','ko']};
+}
+test('sharing compact metadata preserves strict results while parsing fewer 
complete HTML documents',t=>{
+    const data=fixture(t),original=cheerio.load;let reads=0;
+    
cheerio.load=(source,...args)=>{if(String(source).includes('theme-doc-markdown'))reads++;return
 original(source,...args);};
+    try {
+        const 
separate=data.locales.map(locale=>({locale,...verify({site:data.site,manifest:path.join(data.manifestDir,locale+'.json')})}));
+        const before=reads;reads=0;
+        assert.deepEqual(verifyAll(data),separate);
+        assert(reads<before,`${reads} shared parses must be less than 
${before} separate parses`);
+    } finally {cheerio.load=original;}
+});
+test('each invocation rereads the artifact and rejects damaged heading IDs and 
duplicate alternates',t=>{
+    const data=fixture(t);verifyAll(data);
+    const 
file=path.join(data.site,'topic/index.html'),original=fs.readFileSync(file,'utf8');
+    fs.writeFileSync(file,original.replace('id="usage"','id="wrong"'));
+    assert.throws(()=>verifyAll(data),/Source heading IDs/);
+    const 
$=cheerio.load(original);$('head').append($('link[hreflang="ko"]').clone());fs.writeFileSync(file,$.html());
+    assert.throws(()=>verifyAll(data),/Duplicate hreflang/);
+});
diff --git a/scripts/i18n/ci-plan.cjs b/scripts/i18n/ci-plan.cjs
index 541cb5108edd..567c645dc2b8 100644
--- a/scripts/i18n/ci-plan.cjs
+++ b/scripts/i18n/ci-plan.cjs
@@ -69,6 +69,8 @@ function policy(files, {forceFull = false, forceSample = 
false} = {}) {
             ),
         runSamplingFixture: files.some(file =>
             
/^scripts\/(build-locales?\.cjs$|i18n\/(ci-sample|ci-blog-sample|build-cache|verify-ci-sampl(?:e|ing-fixture))\.cjs$)/.test(file)),
+        runSearchFixture: mode === 'full' || files.some(file =>
+            
/^(src\/theme\/(SearchBar|SearchPage)\/|src\/utils\/(fetchSearchIndex|searchMessages)\.[jt]s$|scripts\/i18n\/(search-plugin|search-worker-loader|verify-search-transport)\.cjs$)/.test(file)),
         runPublishFixture: checks.check_publish_fixture || mode === 'full',
         runTranslationTests: mode === 'full' || files.some(file =>
             runtime.test(file) || mechanism.test(file) || 
maintenanceConfig(file) || /^scripts\/(i18n\/|build-locales?\.cjs$)/.test(file)
@@ -183,6 +185,7 @@ if (require.main === module) {
                     content_fixture: result.runContentFixture,
                     publish_fixture: result.runPublishFixture,
                     sampling_fixture: result.runSamplingFixture,
+                    search_fixture: result.runSearchFixture,
                     translation_tests: result.runTranslationTests,
                     docs_report: result.runDocsReport,
                 })
diff --git a/scripts/i18n/content-manifest.cjs 
b/scripts/i18n/content-manifest.cjs
index 114cb7f7de21..c996f75ca0f8 100644
--- a/scripts/i18n/content-manifest.cjs
+++ b/scripts/i18n/content-manifest.cjs
@@ -80,7 +80,9 @@ function validateManifest(manifest) {
 
 // Consumers request one content identity; the full registry never enters 
globalData.
 function routeData(page) {
-    return {schemaVersion, sourceSha: page.sourceSha, contentId: 
page.contentId, kind: page.kind,
+    // Build identity stays in the full manifest. Unchanged browser routing 
data
+    // must keep stable bytes across source commits for content-addressed 
caches.
+    return {schemaVersion, contentId: page.contentId, kind: page.kind,
         siteLocale: page.siteLocale, contentLocale: page.contentLocale, 
fallback: page.fallback, renderIntegrated: page.renderIntegrated,
         indexable: page.indexable, ...(page.aggregate && {aggregate: true}),
         navigationRoutes: page.navigationRoutes, availableTranslations: 
page.availableTranslations};
diff --git a/scripts/i18n/search-plugin.cjs b/scripts/i18n/search-plugin.cjs
index 3ff07349b56b..5de6e047d0d7 100644
--- a/scripts/i18n/search-plugin.cjs
+++ b/scripts/i18n/search-plugin.cjs
@@ -12,9 +12,11 @@ const {processPluginOptions} = 
require('@yang1666204/docusaurus-search-local/dis
 const {processDocInfos} = 
require('@yang1666204/docusaurus-search-local/dist/server/server/utils/processDocInfos');
 const {scanDocuments} = 
require('@yang1666204/docusaurus-search-local/dist/server/server/utils/scanDocuments');
 const {buildIndex: buildNativeIndex} = 
require('@yang1666204/docusaurus-search-local/dist/server/server/utils/buildIndex');
+const {postBuildFactory} = 
require('@yang1666204/docusaurus-search-local/dist/server/server/utils/postBuildFactory');
 const {loadConfig, managedLocales} = require('./state.cjs');
 const {normalizeRussianSearch} = 
require('../../src/utils/searchNormalization.js');
 const {fallbackOnly} = require('./docs-language-policy.cjs');
+const {sourceSha} = require('./content-manifest.cjs');
 
 function contextsFor(root) {
     const versions = JSON.parse(fs.readFileSync(path.join(root, 
'versions.json'), 'utf8'));
@@ -52,7 +54,7 @@ function indexGroups(allDocuments, selected, contextNames = 
contexts) {
     groups.set('', allDocuments.map(documents => documents.filter(doc => 
!selected.has(normalize(doc.u)))));
     return groups;
 }
-function targetFingerprint(root, locale) {
+function targetFingerprint(root, locale, {allVersions = false} = {}) {
     const digester = createHash('sha256');
     digester.update(fs.readFileSync(__filename)); // Bust caches when indexing 
behavior changes.
     // The shared normalizer also changes indexed tokens independently of this 
file.
@@ -72,8 +74,16 @@ function targetFingerprint(root, locale) {
     scan(path.join(root,'i18n',locale));
     scan(path.join(root,'translations/state',locale));
     digester.update(fs.readFileSync(path.join(root, 'versions.json')));
-    for (const dir of ['docs', ...contextsFor(root).filter(context => 
context.startsWith('docs/') && context !== 'docs/dev')
-        .map(context => 
`versioned_docs/version-${context.slice('docs/'.length)}`), 
'community','course','releasenotes']) scan(path.join(root,dir));
+    const versions = JSON.parse(fs.readFileSync(path.join(root, 
'versions.json')))
+        .filter(version => allVersions || !fallbackOnly('default', version));
+    for (const dir of ['docs', ...versions.map(version => 
`versioned_docs/version-${version}`),
+        'community','course','releasenotes']) scan(path.join(root,dir));
+    for (const file of 
['docusaurus.config.js','config/i18n.js','translations/config.json',
+        
'sidebars.ts','sidebarsCommunity.json','sidebarsCourse.json','sidebarsReleases.json'])
 {
+        const target = path.join(root, file);
+        if (fs.existsSync(target)) 
{digester.update(file);digester.update(fs.readFileSync(target));}
+    }
+    digester.update(process.env.DOCS_VERSIONS ?? '');
     return digester.digest('hex').slice(0,16);
 }
 function indexFilename(context) {
@@ -127,23 +137,32 @@ async function buildLocalizedIndex(buildData, config, 
filename, locale, contextN
 }
 function searchPlugin(context, options) {
     const locale = context.i18n.currentLocale;
-    if (!managedLocales(context.siteDir, 
context.i18n.locales).includes(locale)) return native(context,options);
+    const managed = managedLocales(context.siteDir, 
context.i18n.locales).includes(locale);
     const contextNames = contextsFor(context.siteDir);
-    const localized = localizedOptions(options, locale, 
loadConfig(context.siteDir).locales[locale]?.searchContextLabels, contextNames);
+    const localized = managed ? localizedOptions(options, locale, 
loadConfig(context.siteDir).locales[locale]?.searchContextLabels, contextNames) 
: options;
     const plugin = native(context,localized);
     const config = processPluginOptions(localized,context);
     const constants = 
path.join(context.generatedFilesDir,'@yang1666204/docusaurus-search-local/default/generated-constants.js');
     const generated = fs.readFileSync(constants,'utf8');
-    const fingerprint = targetFingerprint(context.siteDir, locale);
+    const fingerprint = targetFingerprint(context.siteDir, locale, 
{allVersions: !managed});
+    const revision = sourceSha(context.siteDir);
+    let filename;
     const updated = generated.replace(/(export const searchIndexUrl = 
)("[^"\n]+")/,
         (_,prefix,value) => {
             const url = JSON.parse(value);
-            return prefix + JSON.stringify(`${url}${url.includes('?') ? '&' : 
'?'}${locale}=${fingerprint}`);
+            // A source-derived filename prevents a CDN from reusing an older
+            // index at the same path, even when it ignores query parameters.
+            const contentKey = 
createHash('sha256').update(url).update(fingerprint).update(revision).digest('hex').slice(0,16);
+            filename = config.hashed ? `search-index{dir}-${contentKey}.json` 
: url.split('?')[0];
+            return prefix + JSON.stringify(config.hashed ? filename
+                : `${url}${url.includes('?') ? '&' : 
'?'}${locale}=${fingerprint}`);
         });
     if (updated === generated) throw new Error('Could not fingerprint the 
localized search index');
     fs.writeFileSync(constants,updated);
-    const filename = indexFilename(context);
-    return {...plugin, postBuild: buildData => 
buildLocalizedIndex(buildData,config,filename,locale,contextNames)};
+    if (indexFilename(context) !== filename) throw new Error('Search index 
filename differs from its browser URL');
+    return {...plugin, postBuild: managed
+        ? buildData => 
buildLocalizedIndex(buildData,config,filename,locale,contextNames)
+        : postBuildFactory(config,filename)};
 }
 module.exports = searchPlugin;
 module.exports.validateOptions = nativeModule.validateOptions;
@@ -156,3 +175,4 @@ module.exports.indexGroups = indexGroups;
 module.exports.buildLocalizedIndex = buildLocalizedIndex;
 
 module.exports.buildLocalizedIndexes = buildLocalizedIndexes;
+module.exports.targetFingerprint = targetFingerprint;
diff --git a/scripts/i18n/search-worker-loader.cjs 
b/scripts/i18n/search-worker-loader.cjs
new file mode 100644
index 000000000000..8c852b4ed68b
--- /dev/null
+++ b/scripts/i18n/search-worker-loader.cjs
@@ -0,0 +1,26 @@
+const helper = require.resolve('../../src/utils/fetchSearchIndex.js');
+
+// Keep the pinned plugin's tokenizer, ranking and result shape. Adapt only
+// transport and its rejected-promise cache; fail closed on an upstream change.
+function adaptSearchWorker(source, options = {}) {
+    const replacements = [
+        ['const cache = new Map();',
+            `import {fetchSearchIndex} from ${JSON.stringify(helper)};\nconst 
cache = new Map();`],
+        ['promise = legacyFetchIndexes(baseUrl, searchContext);',
+            `promise = legacyFetchIndexes(baseUrl, searchContext).catch(error 
=> {\n` +
+            `                if (cache.get(cacheKey) === promise) 
cache.delete(cacheKey);\n` +
+            `                throw error;\n            });`],
+        ['const json = (await (await fetch(url)).json());',
+            `const json = await fetchSearchIndex(fullUrl, 
${JSON.stringify(options)});`],
+    ];
+    for (const [before, after] of replacements) {
+        if (source.split(before).length !== 2) throw new Error('Unexpected 
search worker format; revalidate the pinned search plugin');
+        source = source.replace(before, after);
+    }
+    return source;
+}
+module.exports = function searchWorkerLoader(source) {
+    this.cacheable?.();
+    return adaptSearchWorker(source, this.getOptions());
+};
+module.exports.adaptSearchWorker = adaptSearchWorker;
diff --git a/scripts/i18n/verify-search-transport.cjs 
b/scripts/i18n/verify-search-transport.cjs
new file mode 100644
index 000000000000..28402e52d7d4
--- /dev/null
+++ b/scripts/i18n/verify-search-transport.cjs
@@ -0,0 +1,153 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const {spawnSync, execFileSync} = require('node:child_process');
+const {createFixture} = require('./__tests__/fixtures/multi-locale.cjs');
+const {createServer} = require('../publish/serve.cjs');
+const root = path.resolve(__dirname, '../..');
+const fixture = path.join(root, '.i18n-build/search-transport-fixture');
+
+async function build() {
+    fs.rmSync(fixture, {recursive:true, force:true});
+    const {write} = await createFixture(fixture, {searchDocument:true});
+    for (const directory of 
['src/theme/SearchBar','src/theme/SearchPage','src/theme/LoadingRing',
+        'src/utils','src/hooks','src/constant','shared']) {
+        
fs.cpSync(path.join(root,directory),path.join(fixture,directory),{recursive:true});
+    }
+    
write('src/scss/_variables.scss',fs.readFileSync(path.join(root,'src/scss/_variables.scss'),'utf8'));
+    
write('static/images/search-icon.svg',fs.readFileSync(path.join(root,'static/images/search-icon.svg')));
+    write('src/search-fixture.scss','@use "./scss/variables";\n');
+    write('src/theme/Root.tsx',`import React, {useEffect} from 'react';
+export default function 
Root({children}){useEffect(()=>{document.documentElement.dataset.hasHydrated='true';},[]);return
 children;}\n`);
+    fs.appendFileSync(path.join(fixture,'docusaurus.config.js'),`
+const base = module.exports;
+module.exports = () => {const config = base();
+ config.future = {experimental_faster:true};
+ 
config.plugins.push('docusaurus-plugin-sass',require(${JSON.stringify(path.join(root,'config/custom-docusaurus-plugin.js'))}));
+ config.presets[0][1].theme = 
{customCss:[require.resolve('./src/search-fixture.scss')]};
+ config.themes = 
[[require(${JSON.stringify(path.join(root,'scripts/i18n/search-plugin.cjs'))}),{
+  
hashed:true,language:['en','zh'],indexDocs:true,indexBlog:false,indexPages:false,
+  
docsRouteBasePath:['docs','community'],searchContextByPaths:['docs','community'],
+  
useAllContextsWithNoSearchContext:false,highlightSearchTermsOnTargetPage:true}]];
+ config.themeConfig = {navbar:{title:'Search 
fixture',items:[{type:'search',position:'right'}]}};
+ return config;};\n`);
+    execFileSync('git',['add','.'],{cwd:fixture});
+    
execFileSync('git',['-c','user.name=Fixture','-c','[email protected]','commit','-qm','Search
 transport fixture'],{cwd:fixture});
+    const result = 
spawnSync(process.execPath,[path.join(root,'scripts/build-locales.cjs'),'--site-dir',fixture],
+        
{cwd:root,stdio:'inherit',env:{...process.env,DORIS_ASSET_ORIGIN:'https://cdnd.selectdb.com',
+            
NODE_OPTIONS:'--max-old-space-size=4096',TERSER_PARALLEL:'false'}});
+    assert.equal(result.status,0,'Search transport fixture build');
+}
+
+async function verify() {
+    const {chromium,request} = require('playwright');
+    const site = path.join(fixture,'build'),server = createServer(site);
+    await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
+    const localOrigin = `http://127.0.0.1:${server.address().port}`;
+    const browser = await chromium.launch({headless:true}),api=await 
request.newContext();
+    const 
csp=fs.readFileSync(path.join(root,'static/.htaccess'),'utf8').match(/Header 
set Content-Security-Policy "([^"]+)"/)[1];
+    const results=[],errors=[],contexts=new Set();
+    async function context(mode={}) {
+        const ctx=await 
browser.newContext({serviceWorkers:'block',viewport:{width:1440,height:900}}),requests=[];
+        contexts.add(ctx);
+        await ctx.route('**/*',async route=>{
+            const url=new URL(route.request().url());
+            
if(!['doris.apache.org','cdnd.selectdb.com'].includes(url.hostname))return 
route.abort();
+            if(/search-index.*\.json$/.test(url.pathname)) {
+                requests.push({url:url.href,origin:url.origin});
+                
if(mode.failBoth||mode.failCdn&&url.hostname==='cdnd.selectdb.com')
+                    return 
route.fulfill({status:503,body:'Unavailable',headers:{'access-control-allow-origin':'*'}});
+                if(mode.delay)await new 
Promise(resolve=>setTimeout(resolve,mode.delay));
+            }
+            const response=await api.get(localOrigin+url.pathname+url.search);
+            return 
route.fulfill({response,headers:{...response.headers(),'content-security-policy':csp,
+                'access-control-allow-origin':'*'}});
+        });
+        const page=await 
ctx.newPage();page.on('pageerror',error=>errors.push(error.message));
+        return {ctx,page,requests};
+    }
+    async function close(ctx) {
+        await ctx.unrouteAll({behavior:'wait'});
+        await ctx.close();contexts.delete(ctx);
+    }
+    const open=async(page,url)=>{
+        assert.equal((await 
page.goto('https://doris.apache.org'+url,{waitUntil:'domcontentloaded'})).status(),200);
+        await page.waitForSelector('html[data-has-hydrated="true"]');
+    };
+    const 
input=page=>page.locator('input.navbar__search-input:not([readonly]):visible').first();
+    const suggestions=page=>page.locator('[class*="suggestion_"]');
+    try {
+        for(const locale of ['en','zh-CN','ja','ko','ru']) {
+            const {ctx,page,requests}=await context({delay:1000});
+            const 
manifest=JSON.parse(fs.readFileSync(path.join(fixture,'.i18n-build/content-manifests',locale+'.json')));
+            const 
doc=manifest.pages.find(p=>p.kind==='docs'&&p.pluginId==='default'&&p.version==='current'&&p.docId==='shared');
+            assert(doc);await open(page,doc.route);
+            await input(page).fill('Doris');
+            await suggestions(page).first().waitFor();
+            assert.equal(await input(page).inputValue(),'Doris');
+            assert(requests.some(r=>r.origin==='https://cdnd.selectdb.com'));
+            
assert(requests.every(r=>/search-index.*-[a-f0-9]{16}\.json$/.test(new 
URL(r.url).pathname)));
+            const ax=await page.locator('.navbar__search').ariaSnapshot();
+            assert.match(ax,/combobox/);
+            results.push({locale,earlyQueryPreserved:true,results:await 
suggestions(page).count(),requests,accessibility:ax});
+            await close(ctx);
+        }
+        {
+            const {ctx,page,requests}=await context({failCdn:true});
+            await open(page,'/docs/dev/english-shared/');await 
input(page).fill('Doris');
+            await suggestions(page).first().waitFor();
+            assert(requests.some(r=>r.origin==='https://cdnd.selectdb.com'));
+            assert(requests.some(r=>r.origin==='https://doris.apache.org'));
+            results.push({fallbackToOrigin:true,requests});await close(ctx);
+        }
+        const retryLabels={en:'Retry','zh-CN':'重试',ja:'再試行',ko:'다시 
시도',ru:'Повторить'};
+        for(const locale of Object.keys(retryLabels)) for(const fullPage of 
[false,true]) {
+            const mode={failBoth:true},{ctx,page,requests}=await context(mode);
+            const prefix=locale==='en'?'':'/'+locale;
+            const 
manifest=JSON.parse(fs.readFileSync(path.join(fixture,'.i18n-build/content-manifests',locale+'.json')));
+            const 
doc=manifest.pages.find(p=>p.kind==='docs'&&p.pluginId==='default'&&p.version==='current'&&p.docId==='shared');
+            const 
searchContext=['ja','ko','ru'].includes(locale)?'docs/dev':'docs';
+            await 
open(page,fullPage?prefix+'/search/?q=Doris&ctx='+encodeURIComponent(searchContext)+'&version=docs/dev/':doc.route);
+            if(!fullPage)await input(page).fill('Doris');
+            const 
retryButton=(fullPage?page.locator('.container.margin-vert--lg'):page.locator('.navbar__search'))
+                .getByRole('button',{name:retryLabels[locale],exact:true});
+            await retryButton.waitFor();
+            const themeChecks=[];
+            if(!fullPage) {
+                await page.setViewportSize({width:390,height:844});
+                for(const theme of 
['doris','golden','blue','read','yellow-blue','purple','yellow-black','sky']) {
+                    await 
page.evaluate(value=>{document.documentElement.dataset.brandTheme=value;},theme);
+                    const status=page.locator('.navbar__search 
[role="status"]');
+                    const box=await status.boundingBox();
+                    const style=await 
status.evaluate(e=>({color:getComputedStyle(e).color,background:getComputedStyle(e).backgroundColor,
+                        scrollWidth:e.scrollWidth,clientWidth:e.clientWidth}));
+                    assert(box.x>=-1&&box.x+box.width<=391,'Mobile search 
retry overflows');
+                    assert(style.scrollWidth<=style.clientWidth+1);
+                    assert.notEqual(style.background,'rgba(0, 0, 0, 0)');
+                    themeChecks.push({theme,box,...style});
+                }
+            }
+            const failedRequests=requests.length;mode.failBoth=false;
+            await retryButton.click();
+            if(fullPage)await page.locator('section 
article').first().waitFor();
+            else {await suggestions(page).first().waitFor();assert.equal(await 
input(page).inputValue(),'Doris');}
+            await retryButton.waitFor({state:'detached'});
+            assert(requests.length>failedRequests,'Retry must discard the 
failed worker cache entry');
+            
results.push({locale,fullPage,recoversWithoutReload:true,themeChecks,requests});await
 close(ctx);
+        }
+        assert.deepEqual(errors,[]);
+        return {scope:'Small real five-language Rspack fixture only; not a 
public deployment',
+            
implementationSha:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(),results,errors,passed:true};
+    } finally {
+        for(const ctx of contexts)await close(ctx);
+        await browser.close();await 
api.dispose();server.closeAllConnections?.();await new 
Promise(resolve=>server.close(resolve));
+    }
+}
+async function main() {
+    if(!process.argv.includes('--verify-existing'))await build();
+    if(process.argv.includes('--browser')) {
+        const result=await 
verify();fs.writeFileSync(path.join(root,'.i18n-build/search-transport-verification.json'),JSON.stringify(result,null,2)+'\n');
+        
console.log(JSON.stringify({passed:result.passed,cases:result.results.length}));
+    }
+}
+if(require.main===module)main().catch(error=>{console.error(error);process.exitCode=1;});
diff --git a/scripts/i18n/verify-static.cjs b/scripts/i18n/verify-static.cjs
index cc8c2f433d4e..68389918b403 100644
--- a/scripts/i18n/verify-static.cjs
+++ b/scripts/i18n/verify-static.cjs
@@ -12,7 +12,7 @@ const htmlLangFor = locale => localeRegistry.find(item => 
item.code === locale)?
 // current locale itself still run; a counterpart page that the subset did not
 // build is reported as skipped instead of read. Publishing always verifies the
 // complete site, where every counterpart must be present.
-function verify({site, manifest, partial = false}) {
+function verify({site, manifest, partial = false, headCache = new Map()}) {
     const data = JSON.parse(fs.readFileSync(manifest, 'utf8'));
     const locale = data.locale;
     const contentFile = path.join(path.dirname(path.dirname(manifest)), 
'content-manifests', locale + '.json');
@@ -40,9 +40,25 @@ function verify({site, manifest, partial = false}) {
     readIndexes(path.join(site, locale));
     let verified = 0;
     let skipped = 0;
+    const headingIds = $ => $('.theme-doc-markdown h1,.theme-doc-markdown 
h2,.theme-doc-markdown h3,.theme-doc-markdown h4,.theme-doc-markdown 
h5,.theme-doc-markdown h6')
+        .map((i, node) => $(node).attr('id') ?? '').get();
+    function head(file, $) {
+        if (!headCache.has(file)) {
+            $ ??= cheerio.load(fs.readFileSync(file, 'utf8'));
+            // Retain only immutable metadata, not tens of thousands of DOMs.
+            const alternates = new Map();
+            for (const node of $('link[rel="alternate"][hreflang]').toArray()) 
{
+                const lang = $(node).attr('hreflang');
+                if (!alternates.has(lang)) alternates.set(lang, []);
+                alternates.get(lang).push($(node).attr('href'));
+            }
+            headCache.set(file, {headingIds:headingIds($), alternates});
+        }
+        return headCache.get(file);
+    }
     function counterpart(permalink, check) {
-        const file = path.join(site, permalink, 'index.html');
-        if (fs.existsSync(file)) return cheerio.load(fs.readFileSync(file, 
'utf8'));
+        const file = path.join(site, decodeURIComponent(permalink), 
'index.html');
+        if (fs.existsSync(file)) return head(file);
         assert(partial, `Missing counterpart page (${check}): ${permalink}`);
         skipped++;
         return null;
@@ -56,6 +72,7 @@ function verify({site, manifest, partial = false}) {
             continue;
         }
         const $ = cheerio.load(fs.readFileSync(filename, 'utf8'));
+        head(filename, $);
         const body = $('.theme-doc-markdown,.course-doc-content');
         assert.equal(body.attr('lang'), page.contentLocale, `Body language: 
${page.permalink}`);
         
assert.equal($('[data-translation-status]').attr('data-translation-status'), 
page.status, `Notice: ${page.permalink}`);
@@ -72,26 +89,24 @@ function verify({site, manifest, partial = false}) {
             for (const permalink of Object.values(alternateGroup)) {
                 const file = path.join(site, permalink, 'index.html');
                 if (fs.existsSync(file)) {
-                    const counterpart = cheerio.load(fs.readFileSync(file, 
'utf8'));
-                    
assert.equal(counterpart(`link[rel="alternate"][hreflang="${htmlLang}"]`).length,
 0,
+                    assert.equal(head(file).alternates.get(htmlLang)?.length 
?? 0, 0,
                         `Fallback/stale localized alternate in ${permalink}`);
                 }
             }
         } else {
             assert(!($('meta[name="robots"]').attr('content') ?? 
'').includes('noindex'), page.permalink);
             assert(sitemapPaths.has(page.permalink), `Current page missing 
from sitemap: ${page.permalink}`);
-            const ids = root => root('.theme-doc-markdown 
h1,.theme-doc-markdown h2,.theme-doc-markdown h3,.theme-doc-markdown 
h4,.theme-doc-markdown h5,.theme-doc-markdown h6').map((i, node) => 
root(node).attr('id') ?? '').get();
             const english = counterpart(page.englishPermalink, 'source heading 
IDs');
-            if (english) assert.deepEqual(ids($), ids(english), `Source 
heading IDs: ${page.permalink}`);
+            if (english) assert.deepEqual(headingIds($), english.headingIds, 
`Source heading IDs: ${page.permalink}`);
             for (const [lang, permalink] of Object.entries(alternateGroup)) {
                 const counterpart$ = counterpart(permalink, 'reciprocal 
alternates');
                 if (!counterpart$) continue;
-                const links = counterpart$('link[rel="alternate"][hreflang]');
                 for (const [otherLang, otherPath] of 
Object.entries(alternateGroup)) {
                     const otherHtmlLang = htmlLangFor(otherLang);
-                    
assert.equal(links.filter(`[hreflang="${otherHtmlLang}"]`).attr('href'), 
`https://doris.apache.org${otherPath}`,
+                    const links = counterpart$.alternates.get(otherHtmlLang) 
?? [];
+                    assert.equal(links[0], 
`https://doris.apache.org${otherPath}`,
                         `Non-reciprocal alternates ${lang} -> ${otherLang}: 
${permalink}`);
-                    
assert.equal(links.filter(`[hreflang="${otherHtmlLang}"]`).length, 1, 
`Duplicate hreflang: ${permalink}`);
+                    assert.equal(links.length, 1, `Duplicate hreflang: 
${permalink}`);
                 }
             }
         }
@@ -118,7 +133,7 @@ function verify({site, manifest, partial = false}) {
             
assert.equal($(`link[rel="alternate"][hreflang="${htmlLang}"]`).length, 0, 
`Ordinary fallback alternate: ${permalink}`);
             for (const sourceRoute of (decision ? 
Object.values(decision.navigationRoutes) : [source, `/zh-CN${source}`])) {
                 const source$ = counterpart(sourceRoute, 'ordinary fallback 
alternates');
-                if (source$) 
assert.equal(source$(`link[rel="alternate"][hreflang="${htmlLang}"]`).length, 0,
+                if (source$) 
assert.equal(source$.alternates.get(htmlLang)?.length ?? 0, 0,
                     `Source advertises ordinary fallback: ${sourceRoute}`);
             }
             ordinaryPages.fallback++;
@@ -132,13 +147,24 @@ function verify({site, manifest, partial = false}) {
             .map(status => [status, data.pages.filter(page => page.status === 
status).length])), reports: data.reports};
 }
 
+function verifyAll({site, manifestDir, locales}) {
+    // One immutable artifact per invocation. Never share this cache across 
runs.
+    const headCache = new Map();
+    return locales.map(locale => ({locale, ...verify({site,
+        manifest:path.join(manifestDir, locale + '.json'), headCache})}));
+}
+
 if (require.main === module) {
     const args = process.argv.slice(2);
     const option = name => args[args.indexOf(name) + 1];
-    const result = verify({site: path.resolve(option('--site')), manifest: 
path.resolve(option('--manifest')),
-        partial: args.includes('--partial')});
+    const site = path.resolve(option('--site'));
+    const result = args.includes('--all-managed') ? verifyAll({site,
+        manifestDir:path.resolve(args.includes('--manifest-dir') ? 
option('--manifest-dir') : '.i18n-build/manifests'),
+        locales:require('./state.cjs').managedLocales(process.cwd(), 
require('../../config/i18n').enabledLocales())})
+        : verify({site, manifest: path.resolve(option('--manifest')), partial: 
args.includes('--partial')});
     if (args.includes('--output')) fs.writeFileSync(option('--output'), 
JSON.stringify(result, null, 2) + '\n');
-    console.log(JSON.stringify({verified: result.verified, skipped: 
result.skipped, partial: result.partial,
-        states: result.states, ordinaryPages: result.ordinaryPages, reports: 
result.reports.length}));
+    const summary = row => 
({locale:row.locale,verified:row.verified,skipped:row.skipped,partial:row.partial,
+        
states:row.states,ordinaryPages:row.ordinaryPages,reports:row.reports.length});
+    console.log(JSON.stringify(Array.isArray(result) ? result.map(summary) : 
summary(result)));
 }
-module.exports = {verify};
+module.exports = {verify, verifyAll};
diff --git a/scripts/publish/__tests__/js-minimizer.test.cjs 
b/scripts/publish/__tests__/js-minimizer.test.cjs
index f88f9c4a0a63..177a14875b2e 100644
--- a/scripts/publish/__tests__/js-minimizer.test.cjs
+++ b/scripts/publish/__tests__/js-minimizer.test.cjs
@@ -67,3 +67,26 @@ test('the production bundler preserves numeric-looking route 
hashes as string ke
     new vm.Script(code).runInNewContext(context);
     assert.equal(JSON.stringify(context.window.registry), 
JSON.stringify(registry));
 });
+
+test('UTF-8 client strings remain exact without expanding translated prose to 
ASCII escapes', async t => {
+    const root = fs.mkdtempSync(path.join(os.tmpdir(), 
'doris-utf8-minifier-'));
+    t.after(() => fs.rmSync(root, {recursive: true, force: true}));
+    const values = {zh: '查询数据库', ja: 'データベースを検索', ko: '데이터베이스 검색',
+        ru: 'Поиск данных: ёж', special: '\u2028\u2029😀'};
+    fs.writeFileSync(path.join(root, 'entry.js'), 
`window.prose=${JSON.stringify(values)};`);
+    const config = {mode:'production',optimization:{minimizer:[new 
rspack.SwcJsMinimizerRspackPlugin()]}};
+    const optimization = plugin.configureWebpack(config, false).optimization;
+    const compiler = rspack({...config,context:root,entry:'./entry.js',
+        output:{path:path.join(root,'out'),filename:'main.js'},optimization});
+    const stats = await new 
Promise((resolve,reject)=>compiler.run((error,result)=>error?reject(error):resolve(result)));
+    await new 
Promise((resolve,reject)=>compiler.close(error=>error?reject(error):resolve()));
+    assert(!stats.hasErrors(),stats.toString({all:false,errors:true}));
+    const code = fs.readFileSync(path.join(root,'out/main.js'),'utf8');
+    const context = {window:{}};
+    new vm.Script(code).runInNewContext(context);
+    assert.equal(JSON.stringify(context.window.prose),JSON.stringify(values));
+    assert(code.includes(values.zh) && code.includes(values.ko));
+    const ascii = await require('terser').minify(code, 
{compress:false,mangle:false,
+        format:{ecma:5,comments:false,ascii_only:true}});
+    assert(Buffer.byteLength(code) < Buffer.byteLength(ascii.code));
+});
diff --git a/scripts/publish/__tests__/optimize.test.cjs 
b/scripts/publish/__tests__/optimize.test.cjs
index d96e69375ae7..b2407dafa878 100644
--- a/scripts/publish/__tests__/optimize.test.cjs
+++ b/scripts/publish/__tests__/optimize.test.cjs
@@ -70,6 +70,21 @@ test('hash filename collisions fail instead of silently 
mixing languages', t =>
     assert.throws(() => optimize({site,locales}),/filename collision/);
 });
 
+test('legacy search URLs retain locale, context and query without copying the 
index', async t => {
+    const {site,write} = fixture(t);
+    const target = 'ja/search-index-docs-dev-aabbccddeeff0011.json';
+    write(target, '[{"documents":[]}]');
+    optimize({site,locales});
+    assert(!fs.existsSync(path.join(site,'ja/search-index-docs-dev.json')));
+    const server = require('../serve.cjs').createServer(site);
+    await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
+    t.after(()=>new 
Promise(resolve=>{server.closeAllConnections?.();server.close(resolve);}));
+    const response = await 
fetch(`http://127.0.0.1:${server.address().port}/ja/search-index-docs-dev.json?old=1`,{redirect:'manual'});
+    assert.equal(response.status,302);
+    assert.equal(response.headers.get('location'),'/'+target+'?old=1');
+    require('../verify.cjs').verify(site);
+});
+
 test('retains distinct compiled URLs even when their binary contents match', t 
=> {
     const {site,write} = fixture(t);
     write('assets/shared/other-name.PNG',Buffer.alloc(65536,42));
diff --git a/scripts/publish/__tests__/registry-loader.test.cjs 
b/scripts/publish/__tests__/registry-loader.test.cjs
index c12b4bad553e..d64bd3e46464 100644
--- a/scripts/publish/__tests__/registry-loader.test.cjs
+++ b/scripts/publish/__tests__/registry-loader.test.cjs
@@ -54,7 +54,7 @@ for (const isServer of [true, false]) test(`production 
${isServer ? 'SSR' : 'cli
         optimization:{minimize:!isServer,minimizer:[new 
rspack.SwcJsMinimizerRspackPlugin()]}};
     const plugin = 
createPlugin({siteDir:root,generatedFilesDir:generated,siteConfig:{baseUrl:'/'}});
     const changes = plugin.configureWebpack(base,isServer);
-    assert.equal(changes.module.rules.length,1);
+    assert.equal(changes.module.rules.filter(rule => 
rule.test(path.join(generated,'registry.js'))).length,1);
     assert(changes.module.rules[0].test(path.join(generated,'registry.js')));
     assert(!changes.module.rules[0].test(path.join(root,'registry.js')));
     
assert.equal(plugin.configureWebpack({...base,mode:'development'},isServer).module,undefined);
diff --git a/scripts/publish/optimize.cjs b/scripts/publish/optimize.cjs
index d593430ba478..f07e3ccb5c94 100644
--- a/scripts/publish/optimize.cjs
+++ b/scripts/publish/optimize.cjs
@@ -50,6 +50,14 @@ function optimize({site, locales, reportFile}) {
     };
     let sharedFiles = 0, redirectPages = 0;
 
+    // Cached clients may still request the old query-versioned index URL.
+    // Preserve it with a temporary alias, without duplicating the large JSON.
+    for (const item of before) {
+        if 
(!/(?:^|\/)search-index(?:-[a-z0-9-]+)?-[a-f0-9]{16}\.json$/.test(item.relative))
 continue;
+        const legacy = item.relative.replace(/-[a-f0-9]{16}\.json$/, '.json');
+        add(url(legacy), url(item.relative), true, 302);
+    }
+
     // All webpack runtimes already reference the root pool, including lazy
     // chunks and same-origin search workers. Never rewrite a hashed bundle.
     for (const item of before) {
diff --git a/src/theme/SearchBar/SearchBar.jsx 
b/src/theme/SearchBar/SearchBar.jsx
index 9128bb7afc54..2f713672ccfa 100644
--- a/src/theme/SearchBar/SearchBar.jsx
+++ b/src/theme/SearchBar/SearchBar.jsx
@@ -19,7 +19,7 @@ import {
     useAllContextsWithNoSearchContext,
 } from '../../utils/proxiedGenerated';
 import LoadingRing from '../LoadingRing/LoadingRing';
-import { VERSIONS, DEFAULT_VERSION } from '@site/src/constant/version';
+import { VERSIONS } from '@site/src/constant/version';
 import styles from './SearchBar.module.css';
 import { normalizeContextByPath } from '../../utils/normalizeContextByPath';
 import { searchResultLimits } from "../../utils/proxiedGeneratedConstants";
@@ -30,6 +30,7 @@ import {searchWithLocaleFallback} from 
'@site/src/utils/searchInput';
 import {fetchContextIndexes, searchAcrossContexts} from 
'@site/src/utils/searchContexts';
 import {isTranslatedLocale} from '@site/src/utils/localization';
 import {searchIndexBaseUrl} from '@site/src/utils/searchVersion';
+import {searchMessages} from '@site/src/utils/searchMessages';
 
 const searchContextsByWorker = searchAcrossContexts(searchByWorker);
 
@@ -49,7 +50,6 @@ async function fetchAutoCompleteJS() {
 const SEARCH_PARAM_HIGHLIGHT = '_highlight';
 export default function SearchBar({ handleSearchBarToggle }) {
     const isBrowser = useIsBrowser();
-    const [curVersion, setCurVersion] = useState(DEFAULT_VERSION);
     const location = useLocation();
     const {
         siteConfig: { baseUrl },
@@ -69,6 +69,7 @@ export default function SearchBar({ handleSearchBarToggle }) {
     // Should the input be focused after the index is loaded?
     const focusAfterIndexLoaded = useRef(false);
     const [loading, setLoading] = useState(false);
+    const [loadError, setLoadError] = useState(false);
     const [inputChanged, setInputChanged] = useState(false);
     const [inputValue, setInputValue] = useState('');
     const search = useRef(null);
@@ -76,7 +77,6 @@ export default function SearchBar({ handleSearchBarToggle }) {
     // finished after a newer call started) can abort before wrapping the
     // input with a second autocomplete instance — see #3655 race.
     const loadToken = useRef(0);
-    const prevSearchContext = useRef('');
     const [searchContext, setSearchContext] = useState('');
     useEffect(() => {
         if (!Array.isArray(searchContextByPaths)) {
@@ -108,30 +108,47 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
         if (matchedPath) {
             nextSearchContext = matchedPath;
         }
-        if (prevSearchContext.current !== nextSearchContext) {
-            // Reset index state map once search context is changed.
-            indexStateMap.current.delete(nextSearchContext);
-            prevSearchContext.current = nextSearchContext;
-        }
         setSearchContext(nextSearchContext);
     }, [location.pathname, locales, currentLocale]);
     const hidden = !!hideSearchBarWithNoSearchContext && 
Array.isArray(searchContextByPaths) && searchContext === '';
+    const indexKey = `${versionUrl}:${searchContext}`;
+
+    useEffect(() => {
+        setLoadError(false);
+        setLoading(false);
+        return () => {
+            ++loadToken.current;
+            indexStateMap.current.clear();
+            search.current?.autocomplete.destroy();
+            search.current = null;
+        };
+    }, [versionUrl, searchContext]);
 
     const loadIndex = useCallback(
         async (forceLoad = false) => {
-            if (hidden || (indexStateMap.current.get(searchContext) && 
!forceLoad)) {
+            if (hidden || (indexStateMap.current.get(indexKey) && !forceLoad)) 
{
                 // Do not load the index (again) if its already loaded or in 
the process of being loaded.
                 return;
             }
             const myToken = ++loadToken.current;
-            indexStateMap.current.set(searchContext, 'loading');
+            indexStateMap.current.set(indexKey, 'loading');
             search.current?.autocomplete.destroy();
             search.current = null;
             setLoading(true);
-            const [autoComplete] = await Promise.all([
-                fetchAutoCompleteJS(),
-                fetchContextIndexes(fetchIndexesByWorker, versionUrl, 
searchContext),
-            ]);
+            setLoadError(false);
+            let autoComplete;
+            try {
+                [autoComplete] = await Promise.all([
+                    fetchAutoCompleteJS(),
+                    fetchContextIndexes(fetchIndexesByWorker, versionUrl, 
searchContext),
+                ]);
+            } catch {
+                if (myToken !== loadToken.current) return;
+                indexStateMap.current.delete(indexKey);
+                setLoading(false);
+                setLoadError(true);
+                return;
+            }
             if (myToken !== loadToken.current) {
                 // Superseded by a newer loadIndex; don't mount a second 
autocomplete on the same input.
                 return;
@@ -201,6 +218,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 });
                 return a;
             };
+            const pendingQuery = searchBarRef.current.value;
             search.current = autoComplete(
                 searchBarRef.current,
                 {
@@ -230,8 +248,15 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 [
                     {
                         source: debounce(async (input, callback) => {
-                            const result = await 
searchWithLocaleFallback(searchContextsByWorker, versionUrl, searchContext, 
input, searchResultLimits);
-                            callback(result);
+                            try {
+                                const result = await 
searchWithLocaleFallback(searchContextsByWorker, versionUrl, searchContext, 
input, searchResultLimits);
+                                callback(result);
+                            } catch {
+                                if (myToken !== loadToken.current) return;
+                                indexStateMap.current.delete(indexKey);
+                                setLoadError(true);
+                                callback([]);
+                            }
                         }, 300),
                         templates: {
                             suggestion: SuggestionTemplate,
@@ -268,7 +293,13 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 .on('autocomplete:closed', () => {
                     searchBarRef.current?.blur();
                 });
-            indexStateMap.current.set(searchContext, 'done');
+            // Autocomplete was not listening while its index loaded. Replay
+            // the current input so the user's first query needs no extra key.
+            if (pendingQuery) {
+                search.current.autocomplete.setVal('');
+                search.current.autocomplete.setVal(pendingQuery);
+            }
+            indexStateMap.current.set(indexKey, 'done');
             setLoading(false);
             if (focusAfterIndexLoaded.current) {
                 const input = searchBarRef.current;
@@ -278,7 +309,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 input.focus();
             }
         },
-        [hidden, searchContext, versionUrl, baseUrl, history],
+        [hidden, indexKey, searchContext, versionUrl, baseUrl, history],
     );
     useEffect(() => {
         if (!Mark) {
@@ -336,26 +367,15 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
     // Implement hint icons for the search shortcuts on mac and the rest 
operating systems.
     const isMac = isBrowser ? /mac/i.test(navigator.userAgentData?.platform ?? 
navigator.platform) : false;
 
-    useEffect(() => {
-        const pathHaveVer = VERSIONS.some(item => 
location.pathname.includes(item));
-        if (!pathHaveVer && curVersion !== '2.1') {
-            setCurVersion('2.1');
-        } else {
-            VERSIONS.forEach(item => {
-                if (location.pathname.includes(item) && item !== curVersion) {
-                    setCurVersion(item);
-                }
-            });
-        }
-    }, [location.pathname]);
     useEffect(() => {
         // On mount the page's search context is not resolved yet, and an empty
-        // one would load every context index. Leave that to focus or hover.
-        if (!searchContext && useAllContextsWithNoSearchContext) {
+        // one can load the wrong index or every index. Leave outside-context
+        // search to focus or hover, including the source-language search page.
+        if (!searchContext) {
             return;
         }
         loadIndex(true);
-    }, [curVersion]);
+    }, [loadIndex, searchContext]);
     useEffect(() => {
         if (!searchBarShortcut) {
             return;
@@ -413,6 +433,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 [styles.focused]: focused,
             })}
             hidden={hidden}
+            aria-busy={loading || undefined}
             // Manually make the search bar be LTR even if in RTL
             dir="ltr"
         >
@@ -428,6 +449,12 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 value={inputValue}
             />
             <LoadingRing className={styles.searchBarLoadingRing} />
+            {loadError && <div className={styles.searchLoadError} 
role="status">
+                <span>{searchMessages(currentLocale).error}</span>
+                <button type="button" onClick={() => loadIndex(true)}>
+                    {searchMessages(currentLocale).retry}
+                </button>
+            </div>}
             {searchBarShortcut &&
                 searchBarShortcutHint &&
                 (inputValue !== '' ? (
diff --git a/src/theme/SearchBar/SearchBar.module.css 
b/src/theme/SearchBar/SearchBar.module.css
index f39dc20019b3..e9c6a418aa85 100644
--- a/src/theme/SearchBar/SearchBar.module.css
+++ b/src/theme/SearchBar/SearchBar.module.css
@@ -12,6 +12,39 @@
     padding: var(--search-local-spacing, 12px);
 }
 
+.searchLoadError {
+    position: absolute;
+    z-index: 20;
+    top: 100%;
+    right: 0;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    max-width: min(22rem, calc(100vw - 2rem));
+    padding: 0.5rem 0.75rem;
+    border: 1px solid var(--brand-border-soft);
+    border-radius: 6px;
+    background: var(--brand-surface-note);
+    color: var(--brand-ink);
+    font-size: 0.875rem;
+}
+
+.searchLoadError button {
+    flex-shrink: 0;
+    border: 1px solid currentColor;
+    border-radius: 4px;
+    padding: 0.15rem 0.5rem;
+    background: transparent;
+    color: inherit;
+    font: inherit;
+    cursor: pointer;
+}
+
+.searchLoadError > span {
+    min-width: 0;
+    white-space: normal;
+}
+
 .searchBar .mobileDropdownMenu {
     left: 0 !important;
 }
diff --git a/src/theme/SearchPage/SearchPage.jsx 
b/src/theme/SearchPage/SearchPage.jsx
index 1c791614f61a..0530fe0cadaa 100644
--- a/src/theme/SearchPage/SearchPage.jsx
+++ b/src/theme/SearchPage/SearchPage.jsx
@@ -24,6 +24,7 @@ import {searchWithLocaleFallback} from 
"../../utils/searchInput";
 import {fetchContextIndexes, searchAcrossContexts} from 
"../../utils/searchContexts";
 import {isTranslatedLocale} from "../../utils/localization";
 import {searchIndexBaseUrl} from "../../utils/searchVersion";
+import {searchMessages} from "../../utils/searchMessages";
 const searchContextsByWorker = searchAcrossContexts(searchByWorker);
 export default function SearchPage() {
     return (<Layout>
@@ -37,6 +38,9 @@ function SearchPageContent() {
     const { searchValue, searchContext, searchVersion, updateSearchPath, 
updateSearchContext, } = useSearchQuery();
     const [searchQuery, setSearchQuery] = useState(searchValue);
     const [searchResults, setSearchResults] = useState();
+    const [searchWorkerReady, setSearchWorkerReady] = useState(false);
+    const [loadError, setLoadError] = useState(false);
+    const [retry, setRetry] = useState(0);
     const sourceVersions = useAllDocsData()[docsPluginIdForPreferredVersion ?? 
'default']?.versions ?? [];
     const versionUrl = isTranslatedLocale(currentLocale) ? baseUrl
         : searchIndexBaseUrl(baseUrl, sourceVersions, {versionPath: 
searchVersion});
@@ -55,18 +59,9 @@ function SearchPageContent() {
         }), [searchQuery]);
     useEffect(() => {
         updateSearchPath(searchQuery);
-        if (searchQuery) {
-            (async () => {
-                const results = await 
searchWithLocaleFallback(searchContextsByWorker, versionUrl, searchContext, 
searchQuery, searchResultLimits);
-                setSearchResults(results);
-            })();
-        }
-        else {
-            setSearchResults(undefined);
-        }
         // `updateSearchPath` should not be in the deps,
         // otherwise will cause call stack overflow.
-    }, [searchQuery, versionUrl, searchContext]);
+    }, [searchQuery]);
     const handleSearchInputChange = useCallback((e) => {
         setSearchQuery(e.target.value);
     }, []);
@@ -75,7 +70,6 @@ function SearchPageContent() {
             setSearchQuery(searchValue);
         }
     }, [searchValue]);
-    const [searchWorkerReady, setSearchWorkerReady] = useState(false);
     // The source index lives at the site root, so drop this locale's prefix.
     const sourceBaseUrl = isTranslatedLocale(currentLocale)
         ? baseUrl.replace(new 
RegExp(`${currentLocale.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/$`), '')
@@ -84,20 +78,33 @@ function SearchPageContent() {
         ...(searchContext?.startsWith('docs/') ? {ctx: 'docs'} : {}),
         ...(searchContext === 'docs/dev' ? {version: 'docs/dev/'} : 
{})}).toString()}`;
     useEffect(() => {
+        let active = true;
+        setLoadError(false);
+        setSearchWorkerReady(false);
+        setSearchResults(undefined);
         async function doFetchIndexes() {
-            if (!Array.isArray(searchContextByPaths) ||
-                searchContext ||
-                useAllContextsWithNoSearchContext) {
-                await fetchContextIndexes(fetchIndexesByWorker, versionUrl, 
searchContext);
+            try {
+                if (!Array.isArray(searchContextByPaths) ||
+                    searchContext ||
+                    useAllContextsWithNoSearchContext) {
+                    await fetchContextIndexes(fetchIndexesByWorker, 
versionUrl, searchContext);
+                }
+                if (searchQuery) {
+                    const results = await 
searchWithLocaleFallback(searchContextsByWorker, versionUrl, searchContext, 
searchQuery, searchResultLimits);
+                    if (active) setSearchResults(results);
+                }
+                if (active) setSearchWorkerReady(true);
+            } catch {
+                if (active) setLoadError(true);
             }
-            setSearchWorkerReady(true);
         }
         // The context comes from the query string, which is only read in the
         // browser; before that an empty one would load every context index.
         if (isBrowser) {
             doFetchIndexes();
         }
-    }, [isBrowser, searchContext, versionUrl]);
+        return () => {active = false;};
+    }, [isBrowser, searchContext, versionUrl, searchQuery, retry]);
     return (<React.Fragment>
       <Head>
         {/*
@@ -137,9 +144,15 @@ function SearchPageContent() {
             </div>) : null}
         </div>
 
-        {!searchWorkerReady && searchQuery && (<div>
+        {!searchWorkerReady && !loadError && searchQuery && (<div>
             <LoadingRing />
           </div>)}
+        {loadError && <p role="status">
+            {searchMessages(currentLocale).error}{' '}
+            <button type="button" className="button button--secondary 
button--sm" onClick={() => setRetry(value => value + 1)}>
+                {searchMessages(currentLocale).retry}
+            </button>
+        </p>}
 
         {searchResults &&
             (searchResults.length > 0 ? (<p>
diff --git a/src/utils/fetchSearchIndex.js b/src/utils/fetchSearchIndex.js
new file mode 100644
index 000000000000..7389b3e96636
--- /dev/null
+++ b/src/utils/fetchSearchIndex.js
@@ -0,0 +1,32 @@
+// Only production pages use the configured asset CDN. Local previews and
+// alternate hosts read their own index, just like their own same-origin 
worker.
+export async function fetchSearchIndex(url, {
+    assetOrigin = '', siteOrigin = '', origin = globalThis.location?.origin,
+    timeoutMs = 60000, fetchImpl = globalThis.fetch,
+} = {}) {
+    const source = new URL(url, origin);
+    if (source.origin !== origin) throw new Error('Unexpected search index 
origin');
+    const candidates = [source];
+    if (assetOrigin && siteOrigin && new URL(siteOrigin).origin === origin) {
+        const cdn = new URL(source.pathname + source.search, assetOrigin);
+        if (!['http:', 'https:'].includes(cdn.protocol)) throw new 
Error('Invalid search asset origin');
+        if (cdn.origin !== source.origin) candidates.unshift(cdn);
+    }
+    let failure;
+    for (const candidate of candidates) {
+        const controller = new AbortController();
+        const timeout = setTimeout(() => controller.abort(), timeoutMs);
+        try {
+            const response = await fetchImpl(candidate.href, {signal: 
controller.signal});
+            if (!response.ok) throw new Error(`Search index HTTP 
${response.status}`);
+            const data = await response.json();
+            if (!Array.isArray(data)) throw new Error('Invalid search index');
+            return data;
+        } catch (error) {
+            failure = error;
+        } finally {
+            clearTimeout(timeout);
+        }
+    }
+    throw failure;
+}
diff --git a/src/utils/searchMessages.ts b/src/utils/searchMessages.ts
new file mode 100644
index 000000000000..97ac699b1b47
--- /dev/null
+++ b/src/utils/searchMessages.ts
@@ -0,0 +1,8 @@
+const messages = {
+    en: {error: 'Search could not load.', retry: 'Retry'},
+    'zh-CN': {error: '搜索加载失败。', retry: '重试'},
+    ja: {error: '検索を読み込めませんでした。', retry: '再試行'},
+    ko: {error: '검색을 불러오지 못했습니다.', retry: '다시 시도'},
+    ru: {error: 'Не удалось загрузить поиск.', retry: 'Повторить'},
+};
+export function searchMessages(locale: string) {return messages[locale] ?? 
messages.en;}
diff --git a/src/utils/translation.ts b/src/utils/translation.ts
index aad55b6e93f1..bab809121edb 100644
--- a/src/utils/translation.ts
+++ b/src/utils/translation.ts
@@ -63,7 +63,6 @@ export type ContentRouteData = {
     aggregate?: boolean;
     schemaVersion: 1;
     renderIntegrated: boolean;
-    sourceSha: string;
     contentId: string;
     kind: 'docs' | 'page' | 'blog' | 'utility';
     siteLocale: string;
diff --git a/static/.htaccess b/static/.htaccess
index b4144a54009b..06d4949483ab 100644
--- a/static/.htaccess
+++ b/static/.htaccess
@@ -1,5 +1,5 @@
 <IfModule mod_headers.c>
-    Header set Content-Security-Policy "script-src 'self' 
https://cdnd.selectdb.com https://analytics.apache.org widget.kapa.ai 
www.google.com https://hcaptcha.com https://*.hcaptcha.com 
https://www.gstatic.com 'unsafe-inline' 'unsafe-eval'; connect-src 'self' 
https://analytics.apache.org proxy.kapa.ai 
kapa-widget-proxy-la7dkmplpq-uc.a.run.app metrics.kapa.ai https://hcaptcha.com 
https://*.hcaptcha.com www.google.com https://agent.velodb.io; frame-src 'self' 
www.google.com https://hcaptc [...]
+    Header set Content-Security-Policy "script-src 'self' 
https://cdnd.selectdb.com https://analytics.apache.org widget.kapa.ai 
www.google.com https://hcaptcha.com https://*.hcaptcha.com 
https://www.gstatic.com 'unsafe-inline' 'unsafe-eval'; connect-src 'self' 
https://cdnd.selectdb.com https://analytics.apache.org proxy.kapa.ai 
kapa-widget-proxy-la7dkmplpq-uc.a.run.app metrics.kapa.ai https://hcaptcha.com 
https://*.hcaptcha.com www.google.com https://agent.velodb.io; frame-src 'self' 
www [...]
 </IfModule>
 
 # Native and optimized redirect manifests retain only known historical aliases.
diff --git a/translations/shared/registry.json 
b/translations/shared/registry.json
index 1abdd72ebf85..753300e52ef7 100644
--- a/translations/shared/registry.json
+++ b/translations/shared/registry.json
@@ -65,6 +65,7 @@
     "src/utils/proxiedGeneratedConstants.ts",
     "src/utils/searchContexts.js",
     "src/utils/searchInput.js",
+    "src/utils/searchMessages.ts",
     "src/utils/searchNormalization.js",
     "src/utils/searchVersion.js",
     "src/utils/tokenize.ts",
@@ -173,7 +174,7 @@
     "theme.SearchPage.noResultsText": "No documents were found",
     "theme.SearchPage.searchSource": "Search the source documents"
   },
-  "sourceHash": 
"67a454376f885cd92b6784830b91fe6a8e495897a74bcd44bb9ca706445e78c1",
+  "sourceHash": 
"9032b3ab8106811aad12c49fddb2b59f83e80c7f8892f2e532db634f446e1595",
   "entryFiles": [
     "src/components/home-next/NavbarNext.tsx",
     "src/components/home-next/SlackCommunityNudge.tsx",


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to