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 69ecc7928851 [fix](i18n) Preserve search identity and source fallback 
(#4534)
69ecc7928851 is described below

commit 69ecc79288512c3e2e665081e52bc44548383cd5
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Oct 27 19:32:01 2026 +0000

    [fix](i18n) Preserve search identity and source fallback (#4534)
    
    Switching a Japanese Dev search to English kept `ctx=docs/dev`, which
    the source-language search ignored and resolved to an empty root index.
    The reverse switch mixed Dev, stable, and community results. Normalize
    the logical scope between `ctx=docs&version=docs/dev/` and
    `ctx=docs/dev`, preserve the query, extra parameters and hash, and
    retain course/releases/community scope in English-source links.
    
    Managed locales do not index unsupported historical versions. Their
    documentation search now exposes an explicit English search link for the
    same version, and historical search URLs show the source-search choice
    instead of querying current translated indexes. Implicit source-language
    searches retain their native stable-version default across a language
    switch. English and Chinese also index the community context so its
    source links remain useful. Active/default version decisions come from
    native metadata and registered contexts.
    
    Source-language indexes now exclude pages explicitly marked
    non-indexable by the unified manifest. This removes English fallback
    bodies from Chinese search and keeps unlisted source pages out of the
    index. Chinese no-result pages offer the same version/context in the
    English source search.
    
    Validation:
    - 397 Node tests: 395 passed, 2 conditional skips; 27 workflow tests
    passed.
    - Real five-language Rspack fixture: 36 cases passed, including actual
    language-menu round trips, 3.x/2.1 source search and explicit
    source-only search pages in three managed locales, plugin source links,
    and Chinese fallback/source-index boundaries; 40 mobile retry-theme and
    96 source-link viewport/theme checks passed.
    - Manual content validation retains the existing four report-drift
    records; document prose, translation ownership, production versions and
    sidebars are unchanged.
    
    Self-review against AGENTS.md:
    
    | Checkpoint | Conclusion |
    | --- | --- |
    | Goal | Reproduced scope loss and false source-index boundaries are
    addressed; final production acceptance continues after merge. |
    | Focus | Search configuration, URL/state adapters, and their fixture
    dependencies; no prose backfill. |
    | Information architecture | Active versions, plugins, English fallback
    and translation ownership/denominators are preserved. |
    | Paths/navigation | Document routes and sidebars stay stable; legacy
    search representations normalize to the same version/context and
    preserve query extras/hash. |
    | Frontend | Source links are explicit, localized and keyboard
    accessible, with semantic theme tokens and viewport/theme coverage. |
    | Validation | Pure scope tests plus actual worker, source-index,
    language-menu and historical-version browser cases cover the boundaries;
    no full production build was run locally. |
    | Other quality | English fallbacks/unlisted source pages are excluded
    by manifest decisions; no credential or workflow permission expansion. |
    
    ---------
    
    Co-authored-by: morningman <[email protected]>
---
 docusaurus.config.js                               |   9 +-
 .../i18n/__tests__/fixtures/content-manifest.cjs   |   2 +-
 scripts/i18n/__tests__/locale-entry.test.cjs       |   3 +
 scripts/i18n/__tests__/marketing-pages.test.cjs    |   1 +
 scripts/i18n/__tests__/search-scopes.test.cjs      |  49 +++++++
 scripts/i18n/ci-plan.cjs                           |   2 +-
 scripts/i18n/search-plugin.cjs                     |  10 +-
 scripts/i18n/verify-search-transport.cjs           | 163 ++++++++++++++++++++-
 src/hooks/use-search-query.ts                      |  26 ++--
 src/theme/SearchBar/SearchBar.jsx                  |  27 +++-
 src/theme/SearchBar/SearchBar.module.css           |  16 ++
 src/theme/SearchPage/SearchPage.jsx                |  30 ++--
 src/utils/searchMessages.ts                        |  10 +-
 src/utils/searchScopes.js                          |  47 ++++++
 src/utils/useLanguageItems.ts                      |  14 +-
 translations/shared/registry.json                  |   3 +-
 16 files changed, 366 insertions(+), 46 deletions(-)

diff --git a/docusaurus.config.js b/docusaurus.config.js
index 5bb5d245c7e7..59a5f76aaed1 100644
--- a/docusaurus.config.js
+++ b/docusaurus.config.js
@@ -15,6 +15,9 @@ const {filterContentSitemap} = 
require('./scripts/i18n/sitemap.cjs');
 const contentTranslationPlugin = require('./scripts/i18n/content-plugin.cjs');
 const {translatedContentPages} = require('./scripts/i18n/source-groups.cjs');
 const searchPlugin = require('./scripts/i18n/search-plugin.cjs');
+const SOURCE_SEARCH_CONTEXTS = ['docs',
+    {path:'community',label:{en:'Community','zh-CN':'社区'}}, 'course',
+    {path:'releases',label:{en:'Release Notes','zh-CN':'发行说明'}}];
 const {enabledLocales, enabledLocaleRecords} = require('./config/i18n');
 const {managedLocales} = require('./scripts/i18n/state.cjs');
 const DEFAULT_LOCALE = 'en';
@@ -332,6 +335,8 @@ const config = {
     ],
     projectName: 'apache/doris-website', // Usually your repo name.
     customFields: {
+        searchContexts: {source:SOURCE_SEARCH_CONTEXTS.map(item=>typeof 
item==='string'?item:item.path),
+            managed:searchPlugin.contextsFor(__dirname)},
         // Real Slack invite link. `/slack` (src/pages/slack.tsx) records the
         // click in Matomo and then forwards visitors here, so every public
         // channel keeps linking to https://doris.apache.org/slack and this is
@@ -543,13 +548,13 @@ const config = {
                 highlightSearchTermsOnTargetPage: true,
                 // indexPages: true,
                 indexDocs: true,
-                docsRouteBasePath: ['docs', 'course', 'releases', 
'zh-CN/docs', 'zh-CN/course', 'zh-CN/releases'],
+                docsRouteBasePath: ['docs', 'community', 'course', 'releases', 
'zh-CN/docs', 'zh-CN/community', 'zh-CN/course', 'zh-CN/releases'],
                 indexBlog: false,
                 explicitSearchResultPath: true,
                 searchBarShortcut: true,
                 searchBarShortcutHint: true,
                 searchResultLimits: 100,
-                searchContextByPaths: ['docs', 'course', { path: 'releases', 
label: { en: 'Release Notes', 'zh-CN': '发行说明' } }],
+                searchContextByPaths: SOURCE_SEARCH_CONTEXTS,
                 useAllContextsWithNoSearchContext: false,
                 ignoreFiles: [/^docs\/(?:[^/]+\/)?key-features\//],
             },
diff --git a/scripts/i18n/__tests__/fixtures/content-manifest.cjs 
b/scripts/i18n/__tests__/fixtures/content-manifest.cjs
index b49fef951cac..3b06a33301b0 100644
--- a/scripts/i18n/__tests__/fixtures/content-manifest.cjs
+++ b/scripts/i18n/__tests__/fixtures/content-manifest.cjs
@@ -48,7 +48,7 @@ export default function Page(){return <LayoutNext 
title={title}><h1>{title}</h1>
         for (const record of states.pages.filter(record => record.locale === 
locale)) record.targetHash = digestFiles(root, record.targetFiles);
         states.pages.push({contentId: 'page:blocked', locale, state: 
'blocked', sourceFiles: ['src/pages/blocked.tsx'], targetFiles: []});
     }
-    for (const file of ['src/utils/useLocalizedRoute.ts', 
'src/utils/useLanguageItems.ts', 'src/utils/localePreference.ts', 
'src/utils/languageMessages.ts',
+    for (const file of ['src/utils/useLocalizedRoute.ts', 
'src/utils/useLanguageItems.ts', 'src/utils/searchScopes.js', 
'src/utils/localePreference.ts', 'src/utils/languageMessages.ts',
         'src/components/home-next/LocalizedLink.tsx', 
'src/components/home-next/PageContent.tsx', 
'src/components/home-next/PageContent.scss',
         'src/components/home-next/PageLanguageSwitcher.tsx', 
'src/components/home-next/PageLanguageSwitcher.scss',
         'src/components/brand-theme/BrandThemeSwitcher.tsx', 
'src/components/brand-theme/BrandThemeSwitcher.scss',
diff --git a/scripts/i18n/__tests__/locale-entry.test.cjs 
b/scripts/i18n/__tests__/locale-entry.test.cjs
index cf20051723ab..d01bdadab5ef 100644
--- a/scripts/i18n/__tests__/locale-entry.test.cjs
+++ b/scripts/i18n/__tests__/locale-entry.test.cjs
@@ -174,6 +174,9 @@ test('English-root menu links carry this manual choice even 
before hydration and
         './localePreference': {saveLocalePreference: locale => 
saved.push(locale)},
         './localizedRoute': routeModule.exports,
         './languageMessages': {languageMessages: () => ({note: 'English'})},
+        '@docusaurus/plugin-content-docs/client': {useAllDocsData:()=>({})},
+        './localization': 
{isTranslatedLocale:locale=>['ja','ko','ru'].includes(locale)},
+        './searchScopes': {convertSearchParams:()=>{throw new Error('Root 
links must not rewrite search scope');}},
     };
     const module = {exports: {}};
     new Function('require', 'module', 'exports', 
compile('useLanguageItems'))(name => {assert(Object.hasOwn(imports, name), 
name); return imports[name];}, module, module.exports);
diff --git a/scripts/i18n/__tests__/marketing-pages.test.cjs 
b/scripts/i18n/__tests__/marketing-pages.test.cjs
index 1094ce3db297..db477a29f0e7 100644
--- a/scripts/i18n/__tests__/marketing-pages.test.cjs
+++ b/scripts/i18n/__tests__/marketing-pages.test.cjs
@@ -74,6 +74,7 @@ function modules(locale, pathname, enabled = true, 
translatedPages = {ja: pagePa
         '@generated/docusaurus.config': config,
         '@docusaurus/Translate': {translate: ({message}) => message},
         '@docusaurus/useDocusaurusContext': () => ({i18n, siteConfig: config}),
+        '@docusaurus/plugin-content-docs/client': {useAllDocsData:()=>({})},
         '@docusaurus/useBaseUrl': source => source,
         '@docusaurus/useGlobalData': () => ({'doris-translation-status': 
{default: {
             alternates: {}, locales: enabled ? ['ja','ko','ru'] : [], 
prefixes: [`${prefix}/docs/dev`,`${prefix}/community`],
diff --git a/scripts/i18n/__tests__/search-scopes.test.cjs 
b/scripts/i18n/__tests__/search-scopes.test.cjs
new file mode 100644
index 000000000000..a1ac7e903f67
--- /dev/null
+++ b/scripts/i18n/__tests__/search-scopes.test.cjs
@@ -0,0 +1,49 @@
+const 
test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),ts=require('typescript');
+const 
{outputText}=ts.transpileModule(fs.readFileSync(path.resolve(__dirname,'../../../src/utils/searchScopes.js'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}});
+const moduleValue={exports:{}};new 
Function('exports',outputText)(moduleValue.exports);
+const 
{resolveSearchScope,convertSearchParams,docSearchSourceVersion}=moduleValue.exports;
+const 
versions=['dev','4.x','3.x','2.1'].map(name=>({path:'/docs/'+name,isLast:name==='4.x'}));
+const 
source={versions,contexts:['docs','community','course','releases'],managed:false};
+const 
managed={versions,contexts:['docs/dev','docs/4.x','community','course','releases'],managed:true};
+
+test('Dev and stable document scope survives both source/managed URL 
representations',()=>{
+    
assert.deepEqual(resolveSearchScope({context:'docs/dev'},source),{context:'docs',version:'docs/dev/',sourceOnlyVersion:null});
+    
assert.deepEqual(resolveSearchScope({context:'docs',version:'docs/dev/'},managed),{context:'docs/dev',version:'',sourceOnlyVersion:null});
+    
assert.equal(resolveSearchScope({context:'docs'},managed).context,'docs/4.x');
+    
assert.deepEqual(resolveSearchScope({context:'docs/4.x'},source),{context:'docs',version:'',sourceOnlyVersion:null});
+});
+test('old managed versions select explicit source search instead of current 
translated indexes',()=>{
+    for(const version of ['3.x','2.1']) {
+        
assert.deepEqual(resolveSearchScope({context:'docs',version:'docs/'+version+'/'},managed),
+            
{context:'',version:'docs/'+version+'/',sourceOnlyVersion:version});
+        
assert.equal(resolveSearchScope({context:'docs/'+version},managed).sourceOnlyVersion,version);
+        
assert.equal(resolveSearchScope({context:'docs/'+version},source).version,'docs/'+version+'/');
+        
assert.equal(docSearchSourceVersion('/ja/docs/'+version+'/intro/',versions.map(v=>({...v,path:'/ja'+v.path})),managed.contexts),version);
+    }
+    
assert.equal(docSearchSourceVersion('/ja/docs/dev/intro/',versions.map(v=>({...v,path:'/ja'+v.path})),managed.contexts),null);
+    
assert.equal(docSearchSourceVersion('/ja/community/intro/',versions.map(v=>({...v,path:'/ja'+v.path})),managed.contexts),null);
+});
+test('plugin contexts preserve their identity and cannot inherit a docs 
version',()=>{
+    for(const context of ['community','course','releases'])for(const options 
of [source,managed])
+        
assert.deepEqual(resolveSearchScope({context,version:'docs/3.x/'},options),{context,version:'',sourceOnlyVersion:null});
+});
+test('language conversion preserves the query and unrelated parameters through 
a round trip',()=>{
+    const original=new URLSearchParams({q:'Doris + 
query_vector',ctx:'docs/dev',probe:'/a?b=c'});
+    const 
english=convertSearchParams(original,source),japanese=convertSearchParams(english,managed);
+    
assert.equal(english.get('ctx'),'docs');assert.equal(english.get('version'),'docs/dev/');
+    
assert.equal(japanese.get('ctx'),'docs/dev');assert(!japanese.has('version'));
+    for(const value of 
[english,japanese]){assert.equal(value.get('q'),original.get('q'));assert.equal(value.get('probe'),original.get('probe'));}
+    assert.equal(original.get('ctx'),'docs/dev');
+});
+test('an implicit native default stays on the stable version when switching 
languages',()=>{
+    const target=convertSearchParams(new 
URLSearchParams({q:'Doris'}),managed,source);
+    assert.equal(target.get('ctx'),'docs/4.x');
+    assert.equal(target.get('q'),'Doris');assert(!target.has('version'));
+});
+test('latest version and unsupported status come from native metadata and 
registered contexts',()=>{
+    const 
futureVersions=[{path:'/site/ru/docs/dev'},{path:'/site/ru/docs/5.x',isLast:true},{path:'/site/ru/docs/4.x'}];
+    const 
options={managed:true,versions:futureVersions,contexts:['docs/dev','docs/5.x','community']};
+    
assert.equal(resolveSearchScope({context:'docs'},options).context,'docs/5.x');
+    
assert.equal(resolveSearchScope({context:'docs/4.x'},options).sourceOnlyVersion,'4.x');
+    
assert.equal(resolveSearchScope({context:'https://example.com'},source).context,'docs');
+});
diff --git a/scripts/i18n/ci-plan.cjs b/scripts/i18n/ci-plan.cjs
index 567c645dc2b8..44a1883820d8 100644
--- a/scripts/i18n/ci-plan.cjs
+++ b/scripts/i18n/ci-plan.cjs
@@ -70,7 +70,7 @@ 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)),
+            
/^(src\/theme\/(SearchBar|SearchPage)\/|src\/utils\/(fetchSearchIndex|searchMessages|searchScopes|useLanguageItems)\.[jt]sx?$|src\/hooks\/use-search-query\.ts$|docusaurus\.config\.js$|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)
diff --git a/scripts/i18n/search-plugin.cjs b/scripts/i18n/search-plugin.cjs
index 5de6e047d0d7..226d8e3a1ce9 100644
--- a/scripts/i18n/search-plugin.cjs
+++ b/scripts/i18n/search-plugin.cjs
@@ -162,7 +162,15 @@ function searchPlugin(context, options) {
     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)};
+        : buildData => {
+            const hidden = new Set((buildData.plugins ?? 
[]).flatMap(plugin=>plugin.content?.contentIdentity?.entries ?? [])
+                
.filter(page=>page.kind==='docs'&&page.siteLocale===locale&&page.indexable===false)
+                .map(page=>normalize(page.route)));
+            // Native source-language indexing keeps its version/context 
layout,
+            // but cannot advertise English fallbacks as Chinese translations.
+            return postBuildFactory(config,filename)({...buildData,
+                
routesPaths:buildData.routesPaths.filter(route=>!hidden.has(normalize(route)))});
+        }};
 }
 module.exports = searchPlugin;
 module.exports.validateOptions = nativeModule.validateOptions;
diff --git a/scripts/i18n/verify-search-transport.cjs 
b/scripts/i18n/verify-search-transport.cjs
index 28402e52d7d4..a1aba802013f 100644
--- a/scripts/i18n/verify-search-transport.cjs
+++ b/scripts/i18n/verify-search-transport.cjs
@@ -3,6 +3,8 @@ 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 {hash} = require('./state.cjs');
+const {structure} = require('./structure.cjs');
 const {createServer} = require('../publish/serve.cjs');
 const root = path.resolve(__dirname, '../..');
 const fixture = path.join(root, '.i18n-build/search-transport-fixture');
@@ -17,6 +19,31 @@ async function build() {
     
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');
+    
fs.cpSync(path.join(root,'src/theme/NavbarItem/LocaleDropdownNavbarItem'),path.join(fixture,'src/theme/NavbarItem/LocaleDropdownNavbarItem'),{recursive:true});
+    write('versions.json','["4.x","3.x","2.1"]');
+    write('docs/source-only.md','---\ntitle: Source only\n---\n\nDoris 
SourceOnlyFixtureKeyword.\n');
+    for(const version of ['3.x','2.1']) {
+        const body=`---\ntitle: Historical ${version}\n---\n\n## 
Usage\n\nDoris HistoricalFixtureSQL ${version}.\n`;
+        write(`versioned_docs/version-${version}/historical.md`,body);
+        write(`versioned_sidebars/version-${version}-sidebars.json`,'{}');
+        
write(`i18n/zh-CN/docusaurus-plugin-content-docs/version-${version}/historical.md`,body.replace('Historical','中文历史'));
+    }
+    for(const plugin of ['course','releases']) {
+        const 
sourceRoot=plugin==='course'?'course':'releasenotes',sourcePath=sourceRoot+'/sample.md';
+        const body=`---\ntitle: ${plugin} sample\n---\n\n## Usage\n\nDoris 
${plugin}FixtureSQL.\n`;
+        write(sourcePath,body);
+        for(const locale of ['zh-CN','ja','ko','ru']) {
+            const 
targetPath=`i18n/${locale}/docusaurus-plugin-content-docs-${plugin}/current/sample.md`;
+            const target=body.replace('Doris 
',`${{ja:'日本語',ko:'한국어',ru:'Русский','zh-CN':'中文'}[locale]} Doris `);
+            write(targetPath,target);
+            if(locale==='zh-CN')continue;
+            const 
stateFile=path.join(fixture,'translations/state',locale,'docs.json'),state=JSON.parse(fs.readFileSync(stateFile));
+            
state.pages.push({pluginId:plugin,version:'current',docId:'sample',locale,sourceLocale:'en',sourcePath,targetPath,
+                
sourceHash:hash(body),targetHash:hash(target),sourceTechnicalHash:(await 
structure(body,'.md')).technicalHash,
+                
sourceCommit:'a'.repeat(40),reviewStatus:'machine',ownership:'machine',translatedAt:'2026-10-28T00:00:00Z',glossaryVersion:'0'});
+            fs.writeFileSync(stateFile,JSON.stringify(state));
+        }
+    }
     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'),`
@@ -24,12 +51,19 @@ 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'))}));
+ for(const [id,folder] of 
[['course','course'],['releases','releasenotes']])config.plugins.splice(2,0,
+  
[require(${JSON.stringify(path.join(root,'scripts/i18n/docs-plugin.cjs'))}),{id,path:folder,routeBasePath:id,sidebarPath:false}]);
+ 
config.plugins.push(context=>({name:'fixture-search-identity',loadContent:()=>({contentIdentity:{reports:[],entries:
+  
context.i18n.locales.map(locale=>({contentId:'utility:search',kind:'utility',renderIntegrated:false,siteLocale:locale,
+   
contentLocale:locale,state:'source',fallback:false,indexable:false,route:(locale==='en'?'':'/'+locale)+'/search/',
+   
englishRoute:'/search/',englishPath:'src/theme/SearchPage/index.js',sourceHash:'fixture',targetHash:null,updatePolicy:'native-manual-docs'}))}})}));
  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'],
+  
docsRouteBasePath:['docs','community','course','releases'],searchContextByPaths:['docs','community','course','releases'],
   
useAllContextsWithNoSearchContext:false,highlightSearchTermsOnTargetPage:true}]];
- config.themeConfig = {navbar:{title:'Search 
fixture',items:[{type:'search',position:'right'}]}};
+ 
config.customFields.searchContexts={source:['docs','community','course','releases'],managed:require(${JSON.stringify(path.join(root,'scripts/i18n/search-plugin.cjs'))}).contextsFor(__dirname)};
+ config.themeConfig = {navbar:{title:'Search 
fixture',items:[{type:'localeDropdown',position:'right'},{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});
@@ -46,37 +80,60 @@ async function verify() {
     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();
+    const results=[],errors=[],contexts=new Set(),closingContexts=new 
WeakSet();
     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=>{
+            try {
             const url=new URL(route.request().url());
-            
if(!['doris.apache.org','cdnd.selectdb.com'].includes(url.hostname))return 
route.abort();
+            
if(!['doris.apache.org','cdnd.selectdb.com'].includes(url.hostname))return 
await 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':'*'}});
+                    return await 
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,
+            return await 
route.fulfill({response,headers:{...response.headers(),'content-security-policy':csp,
                 'access-control-allow-origin':'*'}});
+            } catch(error) {
+                const 
cancelled=closingContexts.has(ctx)||route.request().failure()?.errorText==='net::ERR_ABORTED';
+                if(!cancelled||!/Route is already 
handled|Target.*closed|Request context disposed/i.test(error.message))throw 
error;
+            }
         });
         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'});
+        closingContexts.add(ctx);
+        await ctx.unrouteAll({behavior:'ignoreErrors'});
         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"]');
+        await page.getByText('Search fixture',{exact:true}).first().waitFor();
     };
     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']) {
+            const 
manifest=JSON.parse(fs.readFileSync(path.join(fixture,'.i18n-build/content-manifests',locale+'.json')));
+            const hidden=new 
Set(manifest.pages.filter(page=>page.kind==='docs'&&!page.indexable).map(page=>page.route.replace(/\/$/,'')));
+            assert(hidden.size>0,'Source fixture must contain an 
unlisted/fallback boundary');
+            function walk(directory) {
+                for(const entry of 
fs.readdirSync(directory,{withFileTypes:true})) {
+                    const file=path.join(directory,entry.name);
+                    if(entry.isDirectory())walk(file);
+                    else if(/^search-index.*\.json$/.test(entry.name)) {
+                        for(const section of 
JSON.parse(fs.readFileSync(file,'utf8')))for(const doc of section.documents??[])
+                            
if(doc.u)assert(!hidden.has(doc.u.replace(/\/$/,'')),'Non-indexable source page 
entered search: '+doc.u);
+                    }
+                }
+            }
+            walk(locale==='en'?site:path.join(site,locale));
+        }
         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')));
@@ -100,6 +157,98 @@ async function verify() {
             assert(requests.some(r=>r.origin==='https://doris.apache.org'));
             results.push({fallbackToOrigin:true,requests});await close(ctx);
         }
+        for(const start of [
+            
{route:'/ja/search/?ctx=docs%2Fdev&q=Doris&probe=1#retained',target:'en-US',context:'docs',version:'docs/dev/'},
+            
{route:'/search/?ctx=docs&version=docs%2Fdev%2F&q=Doris&probe=1#retained',target:'ja',context:'docs/dev',version:''},
+            
{route:'/search/?q=Doris&probe=1#retained',target:'ja',context:'docs/4.x',version:'',expectedVersion:'4.x'},
+        ]) {
+            const {ctx,page}=await context();await open(page,start.route);
+            await page.locator('section article').first().waitFor();
+            const 
language=page.locator(`a.dropdown__link[lang="${start.target}"]`).first();
+            const target=new URL(await 
language.getAttribute('href'),'https://doris.apache.org');
+            assert.equal(target.searchParams.get('ctx'),start.context);
+            assert.equal(target.searchParams.get('version')??'',start.version);
+            
assert.equal(target.searchParams.get('q'),'Doris');assert.equal(target.searchParams.get('probe'),'1');assert.equal(target.hash,'#retained');
+            await page.locator('.navbar__item.dropdown').first().hover();await 
language.click();
+            await 
page.waitForFunction(value=>document.documentElement.lang===value,start.target);
+            await page.waitForSelector('html[data-has-hydrated="true"]');
+            await page.locator('section article').first().waitFor();
+            const links=await page.locator('section article 
a').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('href')));
+            
assert(links.every(link=>link.includes('/docs/'+(start.expectedVersion??'dev')+'/')),'Language
 switch lost version scope');
+            results.push({languageSwitch:start,links});await close(ctx);
+        }
+        for(const locale of ['ja','ko','ru']) for(const version of 
['3.x','2.1']) {
+            const {ctx,page,requests}=await context();await 
open(page,`/${locale}/docs/${version}/historical/`);
+            const 
link=page.locator('[data-source-search="'+version+'"]');await link.waitFor();
+            const target=new URL(await 
link.getAttribute('href'),'https://doris.apache.org');
+            
assert.equal(target.pathname,'/search/');assert.equal(target.searchParams.get('version'),'docs/'+version+'/');
+            assert.equal(await 
page.locator('input.navbar__search-input:visible').count(),0);
+            assert.equal(requests.length,0,'Unsupported version fetched a 
current translated index');
+            const sourceLinkThemes=[];
+            for(const width of [1440,390]) {
+                await page.setViewportSize({width,height:900});
+                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 box=await link.boundingBox();
+                    assert(box.x>=-1&&box.x+box.width<=width+1,'Source search 
link overflows');
+                    sourceLinkThemes.push({width,theme,box});
+                }
+            }
+            await 
page.keyboard.press(process.platform==='darwin'?'Meta+k':'Control+k');
+            assert(await link.evaluate(e=>e===document.activeElement));
+            await link.click();await 
page.waitForFunction(()=>document.documentElement.lang==='en-US');
+            await page.waitForSelector('html[data-has-hydrated="true"]');
+            const query=page.locator('input[name="q"]');await 
query.fill('HistoricalFixtureSQL');
+            await page.locator('section article').first().waitFor();
+            const links=await page.locator('section article 
a').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('href')));
+            assert(links.every(value=>value.includes('/docs/'+version+'/')));
+            
results.push({sourceVersion:{locale,version},sourceLinkThemes,links});await 
close(ctx);
+        }
+        for(const plugin of ['community','course','releases']) {
+            const {ctx,page}=await context();await 
open(page,`/ja/search/?ctx=${plugin}&q=NoMatchingFixtureValue#retained`);
+            const link=page.locator('.container.margin-vert--lg 
a[href*="/search/"]').first();await link.waitFor();
+            const target=new URL(await 
link.getAttribute('href'),'https://doris.apache.org');
+            
assert.equal(target.searchParams.get('ctx'),plugin);assert.equal(target.hash,'#retained');assert(!target.searchParams.has('version'));
+            await link.click();await 
page.waitForFunction(()=>document.documentElement.lang==='en-US');
+            await page.waitForSelector('html[data-has-hydrated="true"]');
+            await page.locator('input[name="q"]').fill('Doris');await 
page.locator('section article').first().waitFor();
+            const links=await page.locator('section article 
a').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('href')));
+            assert(links.every(value=>value.startsWith('/'+plugin+'/')));
+            results.push({sourcePlugin:plugin,links});await close(ctx);
+        }
+        for(const locale of ['ja','ko','ru']) for(const version of 
['3.x','2.1']) {
+            const {ctx,page,requests}=await context();
+            await 
open(page,`/${locale}/search/?ctx=docs&version=docs%2F${version}%2F&q=HistoricalFixtureSQL#retained`);
+            const note=page.locator('.container.margin-vert--lg 
[role="note"]');await note.waitFor();
+            assert.equal(await 
page.locator('#context-selector').inputValue(),'docs/'+version);
+            assert.equal(requests.length,0,'Historical search queried 
translated current indexes');
+            const source=note.locator('a'),url=new URL(await 
source.getAttribute('href'),'https://doris.apache.org');
+            
assert.equal(url.searchParams.get('version'),'docs/'+version+'/');assert.equal(url.hash,'#retained');
+            await source.click();await 
page.waitForFunction(()=>document.documentElement.lang==='en-US');
+            await page.waitForSelector('html[data-has-hydrated="true"]');await 
page.locator('section article').first().waitFor();
+            const links=await page.locator('section article 
a').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('href')));
+            assert(links.every(value=>value.includes('/docs/'+version+'/')));
+            results.push({historicalSearchPage:{locale,version},links});await 
close(ctx);
+        }
+        {
+            const {ctx,page}=await context();await 
open(page,'/zh-CN/search/?ctx=docs&version=docs%2Fdev%2F&q=SourceOnlyFixtureKeyword');
+            const link=page.locator('.container.margin-vert--lg 
a[hreflang="en"]');await link.waitFor();
+            const url=new URL(await 
link.getAttribute('href'),'https://doris.apache.org');assert.equal(url.pathname,'/search/');
+            assert.equal(url.searchParams.get('version'),'docs/dev/');await 
link.click();
+            await 
page.waitForFunction(()=>document.documentElement.lang==='en-US');await 
page.waitForSelector('html[data-has-hydrated="true"]');
+            await page.locator('section article').first().waitFor();
+            const links=await page.locator('section article 
a').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('href')));
+            
assert(links.some(value=>value.startsWith('/docs/dev/source-only/')));results.push({chineseFallbackSource:true,links});await
 close(ctx);
+        }
+        {
+            const {ctx,page}=await context();await 
open(page,'/ja/search/?ctx=docs&version=docs%2F3.x%2F&q=Doris#retained');
+            await page.locator('.container.margin-vert--lg 
[role="note"]').waitFor();
+            await page.locator('#context-selector').selectOption('docs/dev');
+            await page.locator('section article').first().waitFor();
+            assert(!new 
URL(page.url()).searchParams.has('version'));assert.equal(new 
URL(page.url()).hash,'#retained');
+            const links=await page.locator('section article 
a').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('href')));
+            
assert(links.every(value=>value.includes('/ja/docs/dev/')));results.push({historicalToSupported:true,links});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);
diff --git a/src/hooks/use-search-query.ts b/src/hooks/use-search-query.ts
index ed984ae57fb5..267d82d42395 100644
--- a/src/hooks/use-search-query.ts
+++ b/src/hooks/use-search-query.ts
@@ -7,19 +7,19 @@
 import { useHistory, useLocation } from "@docusaurus/router";
 import useIsBrowser from "@docusaurus/useIsBrowser";
 import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
-import { searchContextByPaths } from "../utils/proxiedGenerated";
+import { resolveSearchScope } from "../utils/searchScopes";
 const SEARCH_PARAM_QUERY = "q";
 const SEARCH_PARAM_CONTEXT = "ctx";
 const SEARCH_PARAM_VERSION = "version";
-function useSearchQuery() {
+function useSearchQuery(scopeOptions) {
     const isBrowser = useIsBrowser();
     const history = useHistory();
     const location = useLocation();
     const { siteConfig: { baseUrl }, } = useDocusaurusContext();
     const params = isBrowser ? new URLSearchParams(location.search) : null;
     const searchValue = params?.get(SEARCH_PARAM_QUERY) || "";
-    const searchContext = params?.get(SEARCH_PARAM_CONTEXT) || "";
-    const searchVersion = params?.get(SEARCH_PARAM_VERSION) || "";
+    const scope = 
resolveSearchScope({context:params?.get(SEARCH_PARAM_CONTEXT) || '',
+        version:params?.get(SEARCH_PARAM_VERSION) || ''}, scopeOptions);
     const getSearchParams = (searchValue) => {
         const searchParams = new URLSearchParams(location.search);
         if (searchValue) {
@@ -32,25 +32,27 @@ function useSearchQuery() {
     };
     return {
         searchValue,
-        searchContext: searchContext &&
-            Array.isArray(searchContextByPaths) &&
-            searchContextByPaths.some((item) => typeof item === "string"
-                ? item === searchContext
-                : item.path === searchContext)
-            ? searchContext
-            : "",
-        searchVersion,
+        searchContext:scope.context,
+        searchVersion:scope.version,
+        sourceOnlyVersion:scope.sourceOnlyVersion,
+        queryParams:params??new URLSearchParams(),
+        hash:location.hash,
         updateSearchPath: (searchValue) => {
             const searchParams = getSearchParams(searchValue);
             history.replace({
+                pathname:location.pathname,
                 search: searchParams.toString(),
+                hash:location.hash,
             });
         },
         updateSearchContext: (value) => {
             const searchParams = new URLSearchParams(location.search);
             searchParams.set(SEARCH_PARAM_CONTEXT, value);
+            searchParams.delete(SEARCH_PARAM_VERSION);
             history.replace({
+                pathname:location.pathname,
                 search: searchParams.toString(),
+                hash:location.hash,
             });
         },
         generateSearchPageLink: (searchValue) => {
diff --git a/src/theme/SearchBar/SearchBar.jsx 
b/src/theme/SearchBar/SearchBar.jsx
index 2f713672ccfa..441b1a488969 100644
--- a/src/theme/SearchBar/SearchBar.jsx
+++ b/src/theme/SearchBar/SearchBar.jsx
@@ -31,6 +31,7 @@ import {fetchContextIndexes, searchAcrossContexts} from 
'@site/src/utils/searchC
 import {isTranslatedLocale} from '@site/src/utils/localization';
 import {searchIndexBaseUrl} from '@site/src/utils/searchVersion';
 import {searchMessages} from '@site/src/utils/searchMessages';
+import {docSearchSourceVersion} from '@site/src/utils/searchScopes';
 
 const searchContextsByWorker = searchAcrossContexts(searchByWorker);
 
@@ -57,6 +58,8 @@ export default function SearchBar({ handleSearchBarToggle }) {
     } = useDocusaurusContext();
     const [isDocsPage] = useIsDocPage(false);
     const sourceVersions = useAllDocsData()[docsPluginIdForPreferredVersion ?? 
'default']?.versions ?? [];
+    const sourceOnlyVersion = isTranslatedLocale(currentLocale)
+        ? 
docSearchSourceVersion(location.pathname,sourceVersions,Array.isArray(searchContextByPaths)?searchContextByPaths:[])
 : null;
     const versionUrl = isTranslatedLocale(currentLocale) ? baseUrl
         : searchIndexBaseUrl(baseUrl, sourceVersions, {pathname: 
location.pathname});
 
@@ -65,6 +68,7 @@ export default function SearchBar({ handleSearchBarToggle }) {
 
     const history = useHistory();
     const searchBarRef = useRef(null);
+    const sourceSearchRef = useRef(null);
     const indexStateMap = useRef(new Map());
     // Should the input be focused after the index is loaded?
     const focusAfterIndexLoaded = useRef(false);
@@ -122,11 +126,11 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
             search.current?.autocomplete.destroy();
             search.current = null;
         };
-    }, [versionUrl, searchContext]);
+    }, [versionUrl, searchContext, sourceOnlyVersion]);
 
     const loadIndex = useCallback(
         async (forceLoad = false) => {
-            if (hidden || (indexStateMap.current.get(indexKey) && !forceLoad)) 
{
+            if (sourceOnlyVersion || hidden || 
(indexStateMap.current.get(indexKey) && !forceLoad)) {
                 // Do not load the index (again) if its already loaded or in 
the process of being loaded.
                 return;
             }
@@ -149,7 +153,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 setLoadError(true);
                 return;
             }
-            if (myToken !== loadToken.current) {
+            if (myToken !== loadToken.current || !searchBarRef.current) {
                 // Superseded by a newer loadIndex; don't mount a second 
autocomplete on the same input.
                 return;
             }
@@ -309,7 +313,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
                 input.focus();
             }
         },
-        [hidden, indexKey, searchContext, versionUrl, baseUrl, history],
+        [hidden, indexKey, searchContext, versionUrl, baseUrl, history, 
sourceOnlyVersion],
     );
     useEffect(() => {
         if (!Mark) {
@@ -384,6 +388,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
         const handleShortcut = event => {
             if ((isMac ? event.metaKey : event.ctrlKey) && (event.key === 'k' 
|| event.key === 'K')) {
                 event.preventDefault();
+                if(sourceOnlyVersion) 
{sourceSearchRef.current?.focus();return;}
                 searchBarRef.current?.focus();
                 onInputFocus();
             }
@@ -392,7 +397,7 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
         return () => {
             document.removeEventListener('keydown', handleShortcut);
         };
-    }, [isMac, onInputFocus]);
+    }, [isMac, onInputFocus, sourceOnlyVersion]);
     useEffect(() => {
         if (inputValue) {
             const inputDoms = 
document.getElementsByClassName('navbar__search-input');
@@ -426,6 +431,18 @@ export default function SearchBar({ handleSearchBarToggle 
}) {
         search.current?.autocomplete.setVal('');
     }, [location.pathname, location.search, location.hash, history]);
 
+    if(sourceOnlyVersion) {
+        const 
sourceBase=baseUrl.endsWith('/'+currentLocale+'/')?baseUrl.slice(0,-(currentLocale.length+1)):baseUrl;
+        const params=new 
URLSearchParams({ctx:'docs',version:'docs/'+sourceOnlyVersion+'/'});
+        if(inputValue)params.set('q',inputValue);
+        return <div 
className={clsx('navbar__search',styles.searchBarContainer)}>
+            <a ref={sourceSearchRef} className={styles.searchSourceLink} 
href={`${sourceBase}search/?${params}`} hrefLang="en"
+                title={searchMessages(currentLocale).sourceOnly} 
data-source-search={sourceOnlyVersion}>
+                {translate({id:'theme.SearchPage.searchSource',message:'Search 
the source documents'})} ({sourceOnlyVersion})
+            </a>
+        </div>;
+    }
+
     return (
         <div
             className={clsx('navbar__search', styles.searchBarContainer, {
diff --git a/src/theme/SearchBar/SearchBar.module.css 
b/src/theme/SearchBar/SearchBar.module.css
index e9c6a418aa85..f73862884f9b 100644
--- a/src/theme/SearchBar/SearchBar.module.css
+++ b/src/theme/SearchBar/SearchBar.module.css
@@ -45,6 +45,22 @@
     white-space: normal;
 }
 
+.searchSourceLink {
+    display: inline-flex;
+    align-items: center;
+    box-sizing: border-box;
+    max-width: 100%;
+    min-height: 2.5rem;
+    padding: 0.25rem 0.5rem;
+    border: 1px solid var(--brand-border-soft);
+    border-radius: 6px;
+    background: var(--brand-surface-note);
+    color: var(--brand-ink);
+    font-size: 0.875rem;
+    line-height: 1.2;
+    white-space: normal;
+}
+
 .searchBar .mobileDropdownMenu {
     left: 0 !important;
 }
diff --git a/src/theme/SearchPage/SearchPage.jsx 
b/src/theme/SearchPage/SearchPage.jsx
index 0530fe0cadaa..ddd2e487965b 100644
--- a/src/theme/SearchPage/SearchPage.jsx
+++ b/src/theme/SearchPage/SearchPage.jsx
@@ -25,6 +25,7 @@ import {fetchContextIndexes, searchAcrossContexts} from 
"../../utils/searchConte
 import {isTranslatedLocale} from "../../utils/localization";
 import {searchIndexBaseUrl} from "../../utils/searchVersion";
 import {searchMessages} from "../../utils/searchMessages";
+import {convertSearchParams} from "../../utils/searchScopes";
 const searchContextsByWorker = searchAcrossContexts(searchByWorker);
 export default function SearchPage() {
     return (<Layout>
@@ -32,16 +33,17 @@ export default function SearchPage() {
     </Layout>);
 }
 function SearchPageContent() {
-    const { siteConfig: { baseUrl }, i18n: { currentLocale }, } = 
useDocusaurusContext();
+    const { siteConfig: { baseUrl,customFields }, i18n: { 
currentLocale,defaultLocale }, } = useDocusaurusContext();
     const isBrowser = useIsBrowser();
     const { selectMessage } = usePluralForm();
-    const { searchValue, searchContext, searchVersion, updateSearchPath, 
updateSearchContext, } = useSearchQuery();
+    const sourceVersions = useAllDocsData()[docsPluginIdForPreferredVersion ?? 
'default']?.versions ?? [];
+    const { searchValue, searchContext, searchVersion, sourceOnlyVersion, 
updateSearchPath, updateSearchContext, queryParams, hash } = useSearchQuery({
+        
contexts:Array.isArray(searchContextByPaths)?searchContextByPaths:[],versions:sourceVersions,managed:isTranslatedLocale(currentLocale)});
     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});
     const pageTitle = useMemo(() => searchQuery
@@ -71,12 +73,14 @@ function SearchPageContent() {
         }
     }, [searchValue]);
     // The source index lives at the site root, so drop this locale's prefix.
-    const sourceBaseUrl = isTranslatedLocale(currentLocale)
+    const sourceBaseUrl = currentLocale!==defaultLocale
         ? baseUrl.replace(new 
RegExp(`${currentLocale.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/$`), '')
         : baseUrl;
-    const englishSearch = `${sourceBaseUrl}search/?${new URLSearchParams({q: 
searchQuery,
-        ...(searchContext?.startsWith('docs/') ? {ctx: 'docs'} : {}),
-        ...(searchContext === 'docs/dev' ? {version: 'docs/dev/'} : 
{})}).toString()}`;
+    const sourceParams=new URLSearchParams(queryParams);
+    if(searchQuery)sourceParams.set('q',searchQuery);else 
sourceParams.delete('q');
+    const sourceContexts=customFields?.searchContexts?.source ?? 
['docs','community','course','releases'];
+    const englishSearch = 
`${sourceBaseUrl}search/?${convertSearchParams(sourceParams,
+        
{contexts:sourceContexts,versions:sourceVersions,managed:false}).toString()}${hash}`;
     useEffect(() => {
         let active = true;
         setLoadError(false);
@@ -84,6 +88,7 @@ function SearchPageContent() {
         setSearchResults(undefined);
         async function doFetchIndexes() {
             try {
+                if(sourceOnlyVersion) 
{if(active)setSearchWorkerReady(true);return;}
                 if (!Array.isArray(searchContextByPaths) ||
                     searchContext ||
                     useAllContextsWithNoSearchContext) {
@@ -104,7 +109,7 @@ function SearchPageContent() {
             doFetchIndexes();
         }
         return () => {active = false;};
-    }, [isBrowser, searchContext, versionUrl, searchQuery, retry]);
+    }, [isBrowser, searchContext, versionUrl, searchQuery, retry, 
sourceOnlyVersion]);
     return (<React.Fragment>
       <Head>
         {/*
@@ -127,7 +132,8 @@ function SearchPageContent() {
             <input type="search" name="q" className={styles.searchQueryInput} 
aria-label={translate({id: 'theme.SearchBar.label', message: 'Search'})} 
onChange={handleSearchInputChange} value={searchQuery} autoComplete="off" 
autoFocus/>
           </div>
           {Array.isArray(searchContextByPaths) ? (<div className={clsx("col", 
"col--3", "padding-left--none", styles.searchContextColumn)}>
-              <select name="search-context" 
className={styles.searchContextInput} aria-label={translate({id: 
'site.search.context', message: 'Search context'})} id="context-selector" 
value={searchContext} onChange={(e) => updateSearchContext(e.target.value)}>
+              <select name="search-context" 
className={styles.searchContextInput} aria-label={translate({id: 
'site.search.context', message: 'Search context'})} id="context-selector" 
value={sourceOnlyVersion?'docs/'+sourceOnlyVersion:searchContext} onChange={(e) 
=> updateSearchContext(e.target.value)}>
+                {sourceOnlyVersion&&<option value={'docs/'+sourceOnlyVersion} 
disabled>{sourceOnlyVersion} · 
{searchMessages(currentLocale).sourceOnly}</option>}
                 {useAllContextsWithNoSearchContext && (<option value="">
                     {translate({
                     id: "theme.SearchPage.searchContext.everywhere",
@@ -144,6 +150,10 @@ function SearchPageContent() {
             </div>) : null}
         </div>
 
+        {sourceOnlyVersion&&<p 
role="note">{searchMessages(currentLocale).sourceOnly}{' '}
+            <a href={englishSearch} 
hrefLang="en">{translate({id:'theme.SearchPage.searchSource',message:'Search 
the source documents'})}</a>
+        </p>}
+
         {!searchWorkerReady && !loadError && searchQuery && (<div>
             <LoadingRing />
           </div>)}
@@ -167,7 +177,7 @@ function SearchPageContent() {
                     message: "No documents were found",
                     description: "The paragraph for empty search result",
                 })}
-              {isTranslatedLocale(currentLocale) && <> <a 
href={englishSearch}>{translate({id: 'theme.SearchPage.searchSource', message: 
'Search the source documents'})}</a></>}
+              {currentLocale!==defaultLocale && <> <a href={englishSearch} 
hrefLang="en">{translate({id: 'theme.SearchPage.searchSource', message: 'Search 
the source documents'})}</a></>}
             </p>) : (<p>
               ⚠️ The search index is only available when you run docusaurus
               build!
diff --git a/src/utils/searchMessages.ts b/src/utils/searchMessages.ts
index 97ac699b1b47..0ad0f72be7c1 100644
--- a/src/utils/searchMessages.ts
+++ b/src/utils/searchMessages.ts
@@ -1,8 +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: 'Повторить'},
+    en: {error: 'Search could not load.', retry: 'Retry', sourceOnly:'Search 
for this version is available in English.'},
+    'zh-CN': {error: '搜索加载失败。', retry: '重试', sourceOnly:'此版本可在英文文档中搜索。'},
+    ja: {error: '検索を読み込めませんでした。', retry: '再試行', 
sourceOnly:'このバージョンは英語のドキュメントで検索できます。'},
+    ko: {error: '검색을 불러오지 못했습니다.', retry: '다시 시도', sourceOnly:'이 버전은 영어 문서에서 
검색할 수 있습니다.'},
+    ru: {error: 'Не удалось загрузить поиск.', retry: 'Повторить', 
sourceOnly:'Поиск по этой версии доступен в английской документации.'},
 };
 export function searchMessages(locale: string) {return messages[locale] ?? 
messages.en;}
diff --git a/src/utils/searchScopes.js b/src/utils/searchScopes.js
new file mode 100644
index 000000000000..e23d2eb25e04
--- /dev/null
+++ b/src/utils/searchScopes.js
@@ -0,0 +1,47 @@
+const pathOf = item => typeof item === 'string' ? item : item.path;
+export const versionId = version => 
version?.path?.split('/').filter(Boolean).pop();
+export const defaultSearchVersion = versions => 
versionId(versions.find(version => version.isLast) ?? versions[0]) ?? 'dev';
+
+// Source-language indexes use ctx=docs plus a version path. Managed languages
+// use ctx=docs/<version>. Translate that logical scope, not just the URL 
prefix.
+export function resolveSearchScope({context = '', version = ''}, {contexts = 
[], versions = [], managed = false} = {}) {
+    const paths = contexts.map(pathOf), fallback = managed ? '' : paths[0] ?? 
'';
+    if (context && context !== 'docs' && !context.startsWith('docs/') && 
paths.includes(context))
+        return {context, version:'', sourceOnlyVersion:null};
+    const requested = context.startsWith('docs/') ? context.slice(5)
+        : version.match(/^\/?docs\/([^/]+)\/?$/)?.[1];
+    const known = versions.map(versionId);
+    if (context === 'docs' || context.startsWith('docs/') || requested) {
+        const selected = known.includes(requested) ? requested : 
defaultSearchVersion(versions);
+        if (managed) {
+            const target = 'docs/' + selected;
+            return paths.includes(target) ? 
{context:target,version:'',sourceOnlyVersion:null}
+                : 
{context:'',version:'docs/'+selected+'/',sourceOnlyVersion:selected};
+        }
+        return {context:paths.includes('docs')?'docs':fallback,
+            version:selected === defaultSearchVersion(versions) ? '' : 
'docs/'+selected+'/',sourceOnlyVersion:null};
+    }
+    return 
{context:paths.includes(context)?context:fallback,version:'',sourceOnlyVersion:null};
+}
+
+export function convertSearchParams(params, options, sourceOptions) {
+    const result = new URLSearchParams(params);
+    if(sourceOptions) {
+        const 
current=resolveSearchScope({context:result.get('ctx')??'',version:result.get('version')??''},sourceOptions);
+        if(current.sourceOnlyVersion)result.set('ctx','docs');
+        else if(current.context)result.set('ctx',current.context);else 
result.delete('ctx');
+        if(current.version)result.set('version',current.version);else 
result.delete('version');
+    }
+    const scope = 
resolveSearchScope({context:result.get('ctx')??'',version:result.get('version')??''},options);
+    const context = scope.sourceOnlyVersion ? 'docs' : scope.context;
+    if (context) result.set('ctx',context); else result.delete('ctx');
+    if (scope.version) result.set('version',scope.version); else 
result.delete('version');
+    return result;
+}
+
+export function docSearchSourceVersion(pathname, versions, contexts) {
+    const version = versions.filter(value => pathname === value.path || 
pathname.startsWith(value.path.replace(/\/$/,'')+'/'))
+        .sort((a,b)=>b.path.length-a.path.length)[0];
+    const id = versionId(version);
+    return id && !contexts.map(pathOf).includes('docs/'+id) ? id : null;
+}
diff --git a/src/utils/useLanguageItems.ts b/src/utils/useLanguageItems.ts
index e5fef775230e..b9fe1035d369 100644
--- a/src/utils/useLanguageItems.ts
+++ b/src/utils/useLanguageItems.ts
@@ -6,6 +6,9 @@ import {useLocalizedRoute} from './useLocalizedRoute';
 import {saveLocalePreference} from './localePreference';
 import {appendRouteSuffix, rootBaseUrl} from './localizedRoute';
 import {languageMessages} from './languageMessages';
+import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
+import {isTranslatedLocale} from './localization';
+import {convertSearchParams} from './searchScopes';
 
 export function useLanguageItems(queryString = '') {
     const {i18n: {locales, localeConfigs, currentLocale, defaultLocale}, 
siteConfig} = useDocusaurusContext();
@@ -13,13 +16,22 @@ export function useLanguageItems(queryString = '') {
     const browser = useIsBrowser();
     const page = useContentRouteData();
     const resolve = useLocalizedRoute();
+    const versions = useAllDocsData().default?.versions ?? [];
     return locales.map(locale => {
         const route = resolve(pathname, {locale, switchLocale: true});
         const root = rootBaseUrl(siteConfig.baseUrl, currentLocale, 
defaultLocale);
         // An English root has no locale prefix. Keep this explicit user action
         // authoritative even when storage is unavailable in this browser.
         const englishRoot = locale === defaultLocale && 
route.href.split(/[?#]/)[0].replace(/\/$/, '') === root.replace(/\/$/, '');
-        let href = appendRouteSuffix(route.href, browser ? search : '', 
browser ? hash : '', queryString);
+        const relative = pathname.startsWith(root) ? 
pathname.slice(root.length).replace(new 
RegExp(`^(?:${locales.filter(value=>value!==defaultLocale).join('|')})/`),'') : 
'';
+        const scopes = siteConfig.customFields?.searchContexts as 
{source?:string[];managed?:string[]} | undefined;
+        const contexts = isTranslatedLocale(locale) ? scopes?.managed : 
scopes?.source;
+        const sourceContexts = isTranslatedLocale(currentLocale) ? 
scopes?.managed : scopes?.source;
+        const suffix = browser && relative.replace(/\/$/,'') === 'search' && 
contexts
+            ? '?' + convertSearchParams(new 
URLSearchParams(search),{contexts,versions,managed:isTranslatedLocale(locale)},
+                
sourceContexts?{contexts:sourceContexts,versions,managed:isTranslatedLocale(currentLocale)}:undefined).toString()
+            : browser ? search : '';
+        let href = appendRouteSuffix(route.href, suffix, browser ? hash : '', 
queryString);
         if (englishRoot) href = appendRouteSuffix(href, '', '', 
`lang=${defaultLocale}`);
         return {locale, label: localeConfigs[locale].label, htmlLang: 
localeConfigs[locale].htmlLang,
             href,
diff --git a/translations/shared/registry.json 
b/translations/shared/registry.json
index 753300e52ef7..42969316ce52 100644
--- a/translations/shared/registry.json
+++ b/translations/shared/registry.json
@@ -67,6 +67,7 @@
     "src/utils/searchInput.js",
     "src/utils/searchMessages.ts",
     "src/utils/searchNormalization.js",
+    "src/utils/searchScopes.js",
     "src/utils/searchVersion.js",
     "src/utils/tokenize.ts",
     "src/utils/translation.ts",
@@ -174,7 +175,7 @@
     "theme.SearchPage.noResultsText": "No documents were found",
     "theme.SearchPage.searchSource": "Search the source documents"
   },
-  "sourceHash": 
"9032b3ab8106811aad12c49fddb2b59f83e80c7f8892f2e532db634f446e1595",
+  "sourceHash": 
"3de2fa30873950f25e9d3d9a4d333904c39dd0881ea9b4cf1dda98070e87d20d",
   "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