diff --git a/.gitignore b/.gitignore
index 817eebf7a..d9c77c745 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,8 +42,8 @@ bld/
# Visual Studio 2015/2017 cache/options directory
.vs/
-# Uncomment if you have tasks that create the project's static files in wwwroot
-#wwwroot/
+# Pagefind.Net.Frontend auto-extraction output (disabled via PagefindFrontendDisableExtract)
+wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
diff --git a/Directory.Build.props b/Directory.Build.props
index 3f89a0761..1e2d4f14b 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -23,6 +23,7 @@
enableenabletrue
+ true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index be5918099..0d0a5b39d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -45,6 +45,8 @@
+
+
diff --git a/NOTICE.txt b/NOTICE.txt
index 9547245cf..0a230b023 100644
--- a/NOTICE.txt
+++ b/NOTICE.txt
@@ -26,6 +26,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+License notice for Pagefind (v1.5.2)
+-----------------------------------
+Copyright 2022 Pagefind
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
License notice for Errata (v0.13.0)
------------------------------------
MIT License
diff --git a/docs/configure/content-set/index.md b/docs/configure/content-set/index.md
index a8fea4a31..a1e7e933c 100644
--- a/docs/configure/content-set/index.md
+++ b/docs/configure/content-set/index.md
@@ -13,6 +13,18 @@ A content set in `docs-builder` is equivalent to an AsciiDoc book. At this level
| **Content source files** --> A whole bunch of markup files as well as any other assets used in the docs (for example, images, videos, and diagrams). | **Markup**: AsciiDoc files **Assets**: Images, videos, and diagrams | **Markup**: MD files **Assets**: Images, videos, and diagrams |
| **Information architecture** --> A way to specify the order in which these text-based files should appear in the information architecture of the book. | `index.asciidoc` file (this can be spread across several AsciiDoc files, but generally starts with the index file specified in the `conf.yaml` file)) | `docset.yml` and/or `toc.yml` file(s) |
+## Static search
+
+Isolated builds and `docs-builder serve` automatically include a [Pagefind](https://pagefind.app) search index that runs entirely in the browser. No configuration is needed — the `pagefind` exporter is included by default.
+
+To use it with a static build, serve the output over HTTP:
+
+```sh
+python3 -m http.server --directory .artifacts/docs/html
+```
+
+Static search does not require a search API. It does not work when pages are opened directly with `file://`.
+
## Learn more
* [File structure](./file-structure.md).
diff --git a/src/Elastic.Codex/CodexGenerator.cs b/src/Elastic.Codex/CodexGenerator.cs
index 3e8126fe7..a6b9948e2 100644
--- a/src/Elastic.Codex/CodexGenerator.cs
+++ b/src/Elastic.Codex/CodexGenerator.cs
@@ -72,11 +72,9 @@ private async Task ExtractEmbeddedStaticResources(Cancel ctx)
{
_logger.LogInformation("Copying static files to codex output directory");
var assembly = typeof(EmbeddedOrPhysicalFileProvider).Assembly;
- var embeddedStaticFiles = assembly
- .GetManifestResourceNames()
- .ToList();
- foreach (var resourceName in embeddedStaticFiles)
+ foreach (var resourceName in assembly.GetManifestResourceNames()
+ .Where(r => r.StartsWith("Elastic.Documentation.Site._static.", StringComparison.Ordinal)))
{
await using var resourceStream = assembly.GetManifestResourceStream(resourceName);
if (resourceStream == null)
diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx
index 73a381ee0..3884c433d 100644
--- a/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx
+++ b/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx
@@ -72,9 +72,14 @@ export const DeploymentInfo = ({
diff --git a/src/Elastic.Markdown/Exporters/ExporterExtensions.cs b/src/Elastic.Markdown/Exporters/ExporterExtensions.cs
index a1e347412..16ac8b45f 100644
--- a/src/Elastic.Markdown/Exporters/ExporterExtensions.cs
+++ b/src/Elastic.Markdown/Exporters/ExporterExtensions.cs
@@ -5,6 +5,7 @@
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Markdown.Exporters.Elasticsearch;
+using Elastic.Markdown.Exporters.Pagefind;
using Microsoft.Extensions.Logging;
namespace Elastic.Markdown.Exporters;
@@ -27,6 +28,8 @@ public static IReadOnlyCollection CreateMarkdownExporters(
markdownExporters.Add(new ElasticsearchMarkdownExporter(logFactory, context.Collector, context.Endpoints, context));
if (exportOptions.Contains(Exporter.Okf))
markdownExporters.Add(new OkfMarkdownExporter());
+ if (exportOptions.Contains(Exporter.Pagefind))
+ markdownExporters.Add(new PagefindMarkdownExporter(logFactory));
return markdownExporters;
}
}
diff --git a/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs
new file mode 100644
index 000000000..b69ffbbf6
--- /dev/null
+++ b/src/Elastic.Markdown/Exporters/Pagefind/PagefindMarkdownExporter.cs
@@ -0,0 +1,136 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.IO.Abstractions;
+using System.Text;
+using System.Text.Json;
+using Elastic.Documentation;
+using Elastic.Documentation.Navigation;
+using Elastic.Markdown.IO;
+using Markdig.Syntax;
+using Microsoft.Extensions.Logging;
+using Pagefind.Net;
+using Pagefind.Net.Frontend;
+
+namespace Elastic.Markdown.Exporters.Pagefind;
+
+public sealed class PagefindMarkdownExporter(ILoggerFactory logFactory) : IMarkdownExporter
+{
+ private readonly ILogger _logger = logFactory.CreateLogger();
+
+ private PagefindIndex? _index;
+ private IFileSystem? _fileSystem;
+ private int _indexed;
+
+ public ValueTask StartAsync(Cancel ctx = default) => ValueTask.CompletedTask;
+
+ public ValueTask StopAsync(Cancel ctx = default) => ValueTask.CompletedTask;
+
+ public ValueTask ExportAsync(MarkdownExportFileContext fileContext, Cancel ctx)
+ {
+ // Pagefind static search is only relevant for isolated builds and serve mode;
+ // assembler and codex builds use the Elasticsearch-backed search API.
+ if (fileContext.BuildContext.BuildType != BuildType.Isolated)
+ return ValueTask.FromResult(true);
+
+ if (_index is null)
+ {
+ _fileSystem = fileContext.BuildContext.WriteFileSystem;
+ _index = new PagefindIndex(new PagefindIndexOptions { Language = "en" }, _fileSystem);
+ }
+
+ var file = fileContext.SourceFile;
+ var navigation = fileContext.PositionaNavigation;
+ var currentNavigation = navigation.GetNavigationFor(file);
+ var url = currentNavigation.Url;
+
+ if (url is "/docs" or "/docs/404")
+ return ValueTask.FromResult(true);
+
+ var h1 = fileContext.Document.Descendants().FirstOrDefault(h => h.Level == 1);
+ if (h1 is not null)
+ _ = fileContext.Document.Remove(h1);
+
+ var body = PlainTextExporter.ConvertToPlainText(fileContext.Document, fileContext.BuildContext);
+
+ var headings = fileContext.Document.Descendants()
+ .Select(h => h.GetData("header") as string ?? string.Empty)
+ .Where(text => !string.IsNullOrEmpty(text))
+ .ToArray();
+
+ var parents = navigation.GetParentsOfMarkdownFile(file).Reverse().ToArray();
+ var breadcrumbsMeta = BuildBreadcrumbsMeta(parents, fileContext.BuildContext.CanonicalBaseUrl);
+
+ var segments = new List();
+ if (!string.IsNullOrEmpty(file.Title))
+ segments.Add(new WeightedSegment(file.Title, Weight: 7));
+ foreach (var heading in headings)
+ segments.Add(new WeightedSegment(heading, Weight: 4));
+ if (!string.IsNullOrEmpty(body))
+ segments.Add(new WeightedSegment(body, Weight: 1));
+
+ var meta = new Dictionary { ["title"] = file.Title ?? url };
+ if (!string.IsNullOrEmpty(breadcrumbsMeta))
+ meta["breadcrumbs"] = breadcrumbsMeta;
+
+ try
+ {
+ _index.AddRecord(new PagefindRecord
+ {
+ Url = url,
+ Title = file.Title ?? url,
+ Content = body,
+ WeightedSegments = segments,
+ Meta = meta
+ });
+ _indexed++;
+ }
+ catch (PagefindIndexingException ex)
+ {
+ _logger.LogWarning(ex, "Failed to index {Url} for static search, skipping", ex.Url);
+ }
+
+ return ValueTask.FromResult(true);
+ }
+
+ public async ValueTask FinishExportAsync(IDirectoryInfo outputFolder, Cancel ctx)
+ {
+ if (_index is null || _indexed == 0)
+ {
+ _logger.LogInformation("No pages to index for static search");
+ return true;
+ }
+
+ _logger.LogInformation("Writing static search index for {Count} pages", _indexed);
+
+ var staticDir = Path.Combine(outputFolder.FullName, "_static");
+ await _index.WriteAsync(staticDir, ctx);
+ var pagefindDir = Path.Combine(staticDir, "pagefind");
+ _ = await PagefindFrontend.ExtractToAsync(
+ _fileSystem!, pagefindDir, force: false, ctx);
+
+ _logger.LogInformation("Generated static search index with {Count} pages", _indexed);
+ return true;
+ }
+
+ private static string BuildBreadcrumbsMeta(IReadOnlyList parents, Uri? canonicalBaseUrl)
+ {
+ if (parents.Count == 0)
+ return string.Empty;
+
+ var baseUrl = canonicalBaseUrl?.ToString().TrimEnd('/') ?? string.Empty;
+ var sb = new StringBuilder();
+ _ = sb.Append("{\"itemListElement\":[");
+ for (var i = 0; i < parents.Count; i++)
+ {
+ if (i > 0)
+ _ = sb.Append(',');
+ var title = JsonEncodedText.Encode(parents[i].NavigationTitle);
+ var itemUrl = JsonEncodedText.Encode($"{baseUrl}{parents[i].Url}");
+ _ = sb.Append($"{{\"name\":\"{title}\",\"item\":\"{itemUrl}\"}}");
+ }
+ _ = sb.Append("]}");
+ return sb.ToString();
+ }
+}
diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml
index 7eb53507e..f79b0b185 100644
--- a/src/Elastic.Markdown/Page/Index.cshtml
+++ b/src/Elastic.Markdown/Page/Index.cshtml
@@ -68,6 +68,8 @@
}
+
+
diff --git a/src/Elastic.Markdown/_Layout.cshtml b/src/Elastic.Markdown/_Layout.cshtml
index 39dc759b1..2494c2191 100644
--- a/src/Elastic.Markdown/_Layout.cshtml
+++ b/src/Elastic.Markdown/_Layout.cshtml
@@ -42,7 +42,7 @@
}
@await RenderPartialAsync(_Breadcrumbs.Create(Model))
-
+
@await RenderBodyAsync()
diff --git a/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs b/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs
index 99a5ae894..a2155af2f 100644
--- a/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs
+++ b/src/services/Elastic.Documentation.Assembler/Building/ExporterParser.cs
@@ -56,6 +56,9 @@ public bool TryParse(string raw, out IReadOnlySet result)
case "okf":
_ = set.Add(Exporter.Okf);
break;
+ case "pagefind":
+ _ = set.Add(Exporter.Pagefind);
+ break;
case "none":
break;
case "default":
@@ -68,7 +71,7 @@ public bool TryParse(string raw, out IReadOnlySet result)
break;
default:
throw new ArgumentException(
- $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, default, metadata, none.");
+ $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none.");
}
}
result = set;
diff --git a/src/services/Elastic.Documentation.Isolated/ExporterParser.cs b/src/services/Elastic.Documentation.Isolated/ExporterParser.cs
index 9992476e1..72c26b671 100644
--- a/src/services/Elastic.Documentation.Isolated/ExporterParser.cs
+++ b/src/services/Elastic.Documentation.Isolated/ExporterParser.cs
@@ -56,6 +56,9 @@ public bool TryParse(string raw, out IReadOnlySet result)
case "okf":
_ = set.Add(Exporter.Okf);
break;
+ case "pagefind":
+ _ = set.Add(Exporter.Pagefind);
+ break;
case "none":
break;
case "default":
@@ -68,7 +71,7 @@ public bool TryParse(string raw, out IReadOnlySet result)
break;
default:
throw new ArgumentException(
- $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, default, metadata, none.");
+ $"Unknown exporter '{token}'. Valid values: html, llm, es, config, links, state, redirects, okf, pagefind, default, metadata, none.");
}
}
result = set;
diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs
index 8ef88049c..395437a6f 100644
--- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs
+++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs
@@ -63,6 +63,7 @@ bool isWatchBuild
.AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error)
.AddFilter("Microsoft.AspNetCore.Routing.EndpointMiddleware", LogLevel.Warning)
.AddFilter("Microsoft.AspNetCore.Http.Result.ContentResult", LogLevel.Warning)
+ .AddFilter("Microsoft.AspNetCore.Http.Result.FileContentResult", LogLevel.Warning)
.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Information);
var collector = new LiveModeDiagnosticsCollector(logFactory);
@@ -157,8 +158,9 @@ private void SetUpRoutes()
{
FileProvider = new EmbeddedOrPhysicalFileProvider(Context),
RequestPath = "/_static"
- })
- .UseRouting();
+ });
+
+ _ = _webApplication.UseRouting();
_ = _webApplication.MapGet("/", (ReloadableGeneratorState holder, Cancel ctx) =>
ServeDocumentationFile(holder, "index", _writeFileSystem, ctx));
@@ -211,10 +213,40 @@ private void SetUpRoutes()
_ = _webApplication.MapGet("/_api/diagnostics/state", (InMemoryBuildState buildState) =>
Results.Json(buildState.GetCurrentState(), DiagnosticsJsonContext.Default.BuildEvent));
+ _ = _webApplication.MapGet("/_static/pagefind/{**path}", (string path, InMemoryBuildState buildState, ReloadableGeneratorState holder) =>
+ ServePagefindFile(path, buildState, holder));
+
_ = _webApplication.MapGet("{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) =>
ServeDocumentationFile(holder, slug, _writeFileSystem, ctx));
}
+ private static IResult ServePagefindFile(string path, InMemoryBuildState buildState, ReloadableGeneratorState holder)
+ {
+ var writeFs = buildState.WriteFileSystem;
+ if (writeFs is null)
+ return Results.NotFound();
+
+ var outputDir = holder.Generator.DocumentationSet.Context.OutputDirectory.FullName;
+ var filePath = Path.Combine(outputDir, "_static", "pagefind", path);
+ var fileInfo = writeFs.FileInfo.New(filePath);
+ if (!fileInfo.Exists)
+ return Results.NotFound();
+
+ var mimeType = Path.GetExtension(path) switch
+ {
+ ".js" => "application/javascript",
+ ".json" => "application/json",
+ ".pagefind" => "application/wasm",
+ ".pf_meta" => "application/octet-stream",
+ ".pf_index" => "application/octet-stream",
+ ".pf_fragment" => "application/octet-stream",
+ _ => "application/octet-stream"
+ };
+
+ var bytes = writeFs.File.ReadAllBytes(filePath);
+ return Results.Bytes(bytes, mimeType);
+ }
+
private static async Task WriteSSEEvent(HttpResponse response, string eventType, BuildEvent data, Cancel ct)
{
var json = JsonSerializer.Serialize(data, DiagnosticsJsonContext.Default.BuildEvent);
diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs
index 4f0c05c78..d882cd195 100644
--- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs
+++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs
@@ -60,8 +60,9 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont
// Reuse MockFileSystem across builds to benefit from caching.
// Initialized lazily on first ExecuteBuildAsync so we can scope it to the source path.
- private ScopedFileSystem? _writeFs;
+ // Exposed so the serve host can read pagefind index files written by the background build.
private string? _writeFsPath;
+ public ScopedFileSystem? WriteFileSystem { get; private set; }
// Broadcast: maintain list of connected client channels
private readonly Lock _clientsLock = new();
@@ -164,9 +165,9 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct)
var streamingCollector = new StreamingDiagnosticsCollector(_loggerFactory, this);
var readFs = FileSystemFactory.RealGitRootForPath(sourcePath);
- if (_writeFs is null || _writeFsPath != sourcePath)
+ if (WriteFileSystem is null || _writeFsPath != sourcePath)
{
- _writeFs = FileSystemFactory.InMemoryForPath(sourcePath);
+ WriteFileSystem = FileSystemFactory.InMemoryForPath(sourcePath);
_writeFsPath = sourcePath;
}
var service = new IsolatedBuildService(_loggerFactory, _configurationContext, new NullCoreService(), SystemEnvironmentVariables.Instance);
@@ -189,7 +190,7 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct)
SkipApi = true,
SkipCrossLinks = false
},
- _writeFs, // reuse MockFileSystem across builds for caching; initialized above
+ WriteFileSystem, // reuse MockFileSystem across builds for caching; initialized above
ct
);
diff --git a/tests/authoring/Generator/LinkReferenceFile.fs b/tests/authoring/Generator/LinkReferenceFile.fs
index dbcbb8748..3d04b3f5f 100644
--- a/tests/authoring/Generator/LinkReferenceFile.fs
+++ b/tests/authoring/Generator/LinkReferenceFile.fs
@@ -31,12 +31,12 @@ Through various means $$$including-this-inline-syntax$$$
Welcome
to this documentation