diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx
index b4a65df6b0..6a61547317 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx
@@ -1,5 +1,8 @@
+import { config } from '../../config'
import '../../eui-icons-cache'
+import { ModalSearch } from '../ModalSearch/ModalSearch'
import { useHtmxContainer } from '../shared/htmx/useHtmxContainer'
+import { sharedQueryClient } from '../shared/queryClient'
import { DeploymentInfo, headerButtonCss } from './DeploymentInfo'
import {
EuiHeader,
@@ -10,6 +13,7 @@ import {
} from '@elastic/eui'
import { css } from '@emotion/react'
import r2wc from '@r2wc/react-to-web-component'
+import { QueryClientProvider } from '@tanstack/react-query'
import { useRef } from 'react'
interface Props {
@@ -169,6 +173,28 @@ export const Header = ({
{
items: [logoSection],
},
+ ...(config.buildType === 'isolated'
+ ? [
+ {
+ items: [
+
+
+
+
+ ,
+ ],
+ },
+ ]
+ : []),
...(!airGapped
? [
{
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.test.tsx
index 815c66fd36..f8691ec242 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.test.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.test.tsx
@@ -71,4 +71,16 @@ describe('ModalSearch', () => {
screen.queryByRole('button', { name: 'Close search modal' })
).not.toBeInTheDocument()
})
+
+ it('does not offer Ask AI in an isolated build', () => {
+ renderModalSearch()
+
+ act(() => {
+ modalSearchStore.getState().actions.openModal()
+ modalSearchStore.getState().actions.setSearchTerm('logging')
+ })
+
+ expect(screen.queryByText('Ask AI Assistant')).not.toBeInTheDocument()
+ expect(screen.queryByText('Tell me more about')).not.toBeInTheDocument()
+ })
})
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.tsx b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.tsx
index 9a3168e12e..72c3a30c27 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearch.tsx
@@ -1,3 +1,4 @@
+import { config } from '../../config'
import '../../eui-icons-cache'
import { ElasticAiAssistantButton } from '../AskAi/ElasticAiAssistantButton'
import { InfoBanner } from '../AskAi/InfoBanner'
@@ -209,7 +210,7 @@ const ModalSearchContent = ({
{}
- {searchTerm && (
+ {config.buildType !== 'isolated' && searchTerm && (
)}
-
+ {config.buildType !== 'isolated' && }
)
@@ -311,6 +312,10 @@ const ModalSearchTrigger = ({
color: ${euiTheme.colors.textDisabled};
cursor: pointer;
+ @media (max-width: 768px) {
+ padding-right: ${euiTheme.size.s};
+ }
+
&:hover {
border-color: ${euiTheme.colors.borderBasePlain};
}
@@ -344,6 +349,9 @@ const ModalSearchTrigger = ({
right: ${euiTheme.size.m};
color: ${euiTheme.colors.textDisabled};
font-size: ${euiTheme.font.scale.s * euiTheme.base}px;
+ @media (max-width: 768px) {
+ display: none;
+ }
`}
>
{isMac ? '⌘K' : 'Ctrl+K'}
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearchResultsList.tsx
index bbec8f86d3..7008f5374e 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearchResultsList.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/ModalSearchResultsList.tsx
@@ -177,6 +177,9 @@ const ModalSearchResultRow = ({
if (config.buildType === 'codex') {
return result.parents.map((p) => p.title)
}
+ if (config.buildType === 'isolated') {
+ return result.parents.slice(1).map((p) => p.title)
+ }
const typePrefix = 'Docs'
return [typePrefix, ...result.parents.slice(1).map((p) => p.title)]
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/pagefind.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/pagefind.test.ts
new file mode 100644
index 0000000000..c696819fdf
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/pagefind.test.ts
@@ -0,0 +1,96 @@
+import { createPagefindOptions, mapPagefindResults } from './pagefind'
+
+describe('createPagefindOptions', () => {
+ it('does not prepend the deployment root to indexed URLs', () => {
+ expect(
+ createPagefindOptions('/elastic/docs-builder/pull/3709')
+ ).toEqual({
+ baseUrl: '/',
+ basePath: '/elastic/docs-builder/pull/3709/_static/pagefind/',
+ })
+ })
+})
+
+describe('mapPagefindResults', () => {
+ it('maps matching pages into modal results', () => {
+ const results = mapPagefindResults([
+ {
+ score: 0.9,
+ data: {
+ url: '/guide/',
+ excerpt: 'Guide excerpt',
+ meta: {
+ title: 'Guide',
+ breadcrumbs: JSON.stringify({
+ itemListElement: [
+ {
+ name: 'Docs',
+ item: 'https://example.com/',
+ },
+ {
+ name: 'Syntax guide',
+ item: 'https://example.com/syntax',
+ },
+ {
+ name: 'Mermaid diagrams',
+ },
+ ],
+ }),
+ },
+ },
+ },
+ ])
+
+ expect(results).toEqual([
+ {
+ type: 'docs',
+ url: '/guide/',
+ title: 'Guide',
+ description: 'Guide excerpt',
+ score: 0.9,
+ parents: [
+ {
+ title: 'Docs',
+ url: 'https://example.com/',
+ },
+ {
+ title: 'Syntax guide',
+ url: 'https://example.com/syntax',
+ },
+ ],
+ },
+ ])
+ })
+
+ it('falls back to page metadata when no sections are returned', () => {
+ const [result] = mapPagefindResults([
+ {
+ score: 0.5,
+ data: {
+ url: '/guide/',
+ excerpt: 'Guide excerpt',
+ meta: { title: 'Guide' },
+ },
+ },
+ ])
+
+ expect(result.title).toBe('Guide')
+ expect(result.description).toBe('Guide excerpt')
+ })
+
+ it('falls back to the page URL when title metadata is missing', () => {
+ const [result] = mapPagefindResults([
+ {
+ score: 0.5,
+ data: {
+ url: '/guide/',
+ excerpt: 'Guide excerpt',
+ meta: {},
+ },
+ },
+ ])
+
+ expect(result.title).toBe('/guide/')
+ expect(result.url).toBe('/guide/')
+ })
+})
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/pagefind.ts b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/pagefind.ts
new file mode 100644
index 0000000000..59b3099f4c
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/pagefind.ts
@@ -0,0 +1,122 @@
+import { config } from '../../config'
+
+interface PagefindResultData {
+ url: string
+ excerpt: string
+ meta: {
+ title?: string
+ breadcrumbs?: string
+ }
+}
+
+interface PagefindRawResult {
+ score: number
+ data: () => Promise
+}
+
+interface PagefindSearch {
+ results: PagefindRawResult[]
+}
+
+interface PagefindApi {
+ options: (options: PagefindOptions) => Promise
+ init: () => Promise
+ search: (query: string) => Promise
+}
+
+interface PagefindOptions {
+ baseUrl: string
+ basePath: string
+}
+
+interface StaticSearchResult {
+ type: 'docs'
+ url: string
+ title: string
+ description: string
+ score: number
+ parents: Array<{ url: string; title: string }>
+}
+
+interface PagefindLoadedResult {
+ score: number
+ data: PagefindResultData
+}
+
+interface StructuredBreadcrumbs {
+ itemListElement?: Array<{
+ name?: string
+ item?: string
+ }>
+}
+
+let pagefindPromise: Promise | undefined
+
+const rootPath = config.rootPath.replace(/\/$/, '')
+
+export const createPagefindOptions = (rootPath: string): PagefindOptions => ({
+ // Indexed URLs already include the deployment root path.
+ baseUrl: '/',
+ basePath: `${rootPath}/_static/pagefind/`,
+})
+
+const loadPagefind = () => {
+ pagefindPromise ??= (async () => {
+ try {
+ const moduleUrl = `${rootPath}/_static/pagefind/pagefind.js`
+ const pagefind = (await import(moduleUrl)) as PagefindApi
+ await pagefind.options(createPagefindOptions(rootPath))
+ await pagefind.init()
+ return pagefind
+ } catch (error) {
+ pagefindPromise = undefined
+ throw error
+ }
+ })()
+ return pagefindPromise
+}
+
+export const searchPagefind = async (
+ query: string
+): Promise => {
+ const pagefind = await loadPagefind()
+ const search = await pagefind.search(query)
+ if (!search) return []
+
+ const pages = await Promise.all(
+ search.results.slice(0, 20).map(async (result) => ({
+ score: result.score,
+ data: await result.data(),
+ }))
+ )
+
+ return mapPagefindResults(pages)
+}
+
+export const mapPagefindResults = (
+ pages: PagefindLoadedResult[]
+): StaticSearchResult[] =>
+ pages.map(({ score, data }) => ({
+ type: 'docs' as const,
+ url: data.url,
+ title: data.meta.title || data.url,
+ description: data.excerpt,
+ score,
+ parents: parseBreadcrumbs(data.meta.breadcrumbs),
+ }))
+
+const parseBreadcrumbs = (value?: string) => {
+ if (!value) return []
+
+ try {
+ const breadcrumbs = JSON.parse(value) as StructuredBreadcrumbs
+ return (breadcrumbs.itemListElement ?? [])
+ .filter(
+ (item): item is { name: string; item: string } =>
+ !!item.name && !!item.item
+ )
+ .map(({ name, item }) => ({ title: name, url: item }))
+ } catch {
+ return []
+ }
+}
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/useModalSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/useModalSearchQuery.ts
index d6422f389f..1ca23e633b 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/useModalSearchQuery.ts
+++ b/src/Elastic.Documentation.Site/Assets/web-components/ModalSearch/useModalSearchQuery.ts
@@ -21,6 +21,7 @@ import {
} from '../shared/errorHandling'
import { ApiError } from '../shared/errorHandling'
import { usePageNumber, useSearchTerm } from './modalSearch.store'
+import { searchPagefind } from './pagefind'
import {
keepPreviousData,
useQuery,
@@ -85,12 +86,14 @@ export const useModalSearchQuery = () => {
!!trimmedSearchTerm &&
trimmedSearchTerm.length >= 1 &&
!isCooldownActive &&
- !awaitingNewInput
+ !awaitingNewInput &&
+ true
const query = useQuery({
queryKey: [
'modal-search',
{
+ buildType: config.buildType,
searchTerm: debouncedSearchTerm.toLowerCase(),
pageNumber,
},
@@ -106,6 +109,20 @@ export const useModalSearchQuery = () => {
})
}
+ if (config.buildType === 'isolated') {
+ const results = await searchPagefind(debouncedSearchTerm)
+ if (signal.aborted)
+ throw new DOMException('Aborted', 'AbortError')
+
+ return SearchResponse.parse({
+ results,
+ totalResults: results.length,
+ pageCount: results.length > 0 ? 1 : 0,
+ pageNumber: 1,
+ pageSize: 20,
+ })
+ }
+
return traceSpan('modal_search', async (span) => {
span.setAttribute(
ATTR_NAVIGATION_SEARCH_QUERY,
@@ -167,6 +184,7 @@ export const useModalSearchQuery = () => {
queryKey: [
'modal-search',
{
+ buildType: config.buildType,
searchTerm: debouncedSearchTerm.toLowerCase(),
pageNumber,
},
diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs
index 2705824838..7bbc4db766 100644
--- a/src/Elastic.Documentation.Site/_ViewModels.cs
+++ b/src/Elastic.Documentation.Site/_ViewModels.cs
@@ -22,7 +22,14 @@ public static class GlobalSections
}
/// Configuration injected into the frontend for build-type-specific behavior (OTEL, HTMX).
-public record FrontendConfig(string BuildType, string ServiceName, bool TelemetryEnabled, string RootPath, string ApiBasePath, bool AirGapped = false);
+public record FrontendConfig(
+ string BuildType,
+ string ServiceName,
+ bool TelemetryEnabled,
+ string RootPath,
+ string ApiBasePath,
+ bool AirGapped = false
+);
/// Single breadcrumb item for the codex sub-header.
public record CodexBreadcrumb(string Title, string? Url);
diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json
index 6667ccad98..079708b80a 100644
--- a/src/Elastic.Documentation.Site/package-lock.json
+++ b/src/Elastic.Documentation.Site/package-lock.json
@@ -23964,9 +23964,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -23984,9 +23981,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -24004,9 +23998,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -24024,9 +24015,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
diff --git a/src/Elastic.Documentation.Tooling/Exporter.cs b/src/Elastic.Documentation.Tooling/Exporter.cs
index ac2113ca9a..54b151e0cf 100644
--- a/src/Elastic.Documentation.Tooling/Exporter.cs
+++ b/src/Elastic.Documentation.Tooling/Exporter.cs
@@ -16,12 +16,13 @@ public enum Exporter
DocumentationState,
LinkMetadata,
Redirects,
- Okf
+ Okf,
+ Pagefind
}
public static class ExportOptions
{
- public static HashSet Default { get; } = [Exporter.Html, Exporter.LLMText, Exporter.Configuration, Exporter.DocumentationState, Exporter.LinkMetadata, Exporter.Redirects];
+ public static HashSet Default { get; } = [Exporter.Html, Exporter.LLMText, Exporter.Configuration, Exporter.DocumentationState, Exporter.LinkMetadata, Exporter.Redirects, Exporter.Pagefind];
public static HashSet MetadataOnly { get; } = [Exporter.Configuration, Exporter.DocumentationState, Exporter.LinkMetadata, Exporter.Redirects];
///
@@ -29,5 +30,5 @@ public static class ExportOptions
/// (and emits diagnostics) without running LLM export, config copy, link-index writes,
/// or redirect generation — none of which are meaningful in an in-memory build.
///
- public static HashSet Validation { get; } = [Exporter.Html];
+ public static HashSet Validation { get; } = [Exporter.Html, Exporter.Pagefind];
}
diff --git a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs
index f26c6f02f5..883b56d777 100644
--- a/src/Elastic.Documentation.Tooling/FileSystemFactory.cs
+++ b/src/Elastic.Documentation.Tooling/FileSystemFactory.cs
@@ -21,7 +21,7 @@ public static class FileSystemFactory
[Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName])
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" }
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" }
};
// Write options: same scope roots but no .git — nothing in the build output
@@ -31,7 +31,7 @@ public static class FileSystemFactory
[Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName])
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state" },
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" },
AllowedSpecialFolders = AllowedSpecialFolder.Temp
};
@@ -87,7 +87,7 @@ public static ScopedFileSystem InMemoryForPath(string? path)
[Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName, root])
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" }
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" }
});
}
@@ -129,7 +129,7 @@ public static ScopedFileSystem ScopeCurrentWorkingDirectory(IFileSystem inner, I
return new ScopedFileSystem(inner, new ScopedFileSystemOptions(roots)
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" }
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" }
});
}
@@ -160,7 +160,7 @@ private static ScopedFileSystemOptions BuildWriteOptions(IFileSystem inner, para
return new ScopedFileSystemOptions([.. allRoots])
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state" },
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".doc.state", ".pagefind-net-frontend-version" },
AllowedSpecialFolders = AllowedSpecialFolder.Temp
};
}
@@ -184,7 +184,7 @@ public static ScopedFileSystem ScopeSourceDirectory(IFileSystem inner, string so
new(inner, new ScopedFileSystemOptions([sourceRoot, Paths.ApplicationData.FullName])
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" }
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" }
});
///
@@ -230,7 +230,7 @@ public static ScopedFileSystem RealGitRootForPath(string? path)
return new ScopedFileSystem(new FileSystem(), new ScopedFileSystemOptions([.. roots])
{
AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" },
- AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" }
+ AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state", ".pagefind-net-frontend-version" }
});
}
diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs
index cdca65b33a..ca25f93b09 100644
--- a/src/Elastic.Markdown/DocumentationGenerator.cs
+++ b/src/Elastic.Markdown/DocumentationGenerator.cs
@@ -287,10 +287,8 @@ private async Task ExtractEmbeddedStaticResources(Cancel ctx)
_logger.LogInformation($"Copying static files to output directory");
var assembly = typeof(EmbeddedOrPhysicalFileProvider).Assembly;
- var embeddedStaticFiles = assembly
- .GetManifestResourceNames()
- .ToList();
- foreach (var a in embeddedStaticFiles)
+ foreach (var a in assembly.GetManifestResourceNames()
+ .Where(r => r.StartsWith("Elastic.Documentation.Site._static.", StringComparison.Ordinal)))
{
await using var resourceStream = assembly.GetManifestResourceStream(a);
if (resourceStream == null)
diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj
index c3b583fa07..ade126da05 100644
--- a/src/Elastic.Markdown/Elastic.Markdown.csproj
+++ b/src/Elastic.Markdown/Elastic.Markdown.csproj
@@ -30,6 +30,8 @@
+
+