diff --git a/.github/workflows/assembler-preview.yml b/.github/workflows/assembler-preview.yml index 887290c5e5..58db8050db 100644 --- a/.github/workflows/assembler-preview.yml +++ b/.github/workflows/assembler-preview.yml @@ -101,6 +101,14 @@ jobs: yq -i ".environments.preview.path_prefix = \"${ASSEMBLER_PREVIEW_PATH_PREFIX}\"" config/assembler.yml dotnet run --project src/tooling/docs-builder -- assemble --skip-private-repositories --environment preview + # Temporary: remove this step after docs-infra serves the 404 page from each preview's path prefix. + - name: Publish temporary 404 experience preview + run: | + preview_root=".artifacts/assembly/${ASSEMBLER_PREVIEW_PATH_PREFIX}" + preview_path="${preview_root}/opentelemetry-collector" + mkdir -p "${preview_path}" + cp "${preview_root}/404/index.html" "${preview_path}/index.html" + - uses: elastic/docs-builder/.github/actions/aws-auth@main - name: Upload assembled docs to S3 diff --git a/config/assembler.yml b/config/assembler.yml index baefcb9bb7..5c7b2a1b89 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -34,6 +34,7 @@ environments: enabled: false feature_flags: SEARCH_OR_ASK_AI: true + RELATED_PAGES: true dev: uri: http://localhost:4000 content_source: current @@ -50,6 +51,7 @@ environments: enabled: false feature_flags: SEARCH_OR_ASK_AI: true + RELATED_PAGES: true air-gapped: uri: http://localhost:8080 path_prefix: docs diff --git a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs index bcea10422d..b99511bcc0 100644 --- a/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs +++ b/src/Elastic.Documentation.Configuration/Builder/FeatureFlags.cs @@ -32,6 +32,12 @@ public bool SearchOrAskAiEnabled set => _featureFlags["search-or-ask-ai"] = value; } + public bool RelatedPagesEnabled + { + get => IsEnabled("related-pages"); + set => _featureFlags["related-pages"] = value; + } + public bool StagingElasticNavEnabled { get => IsEnabled("staging-elastic-nav"); diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index 20e94dfebf..9bd0852204 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -55,6 +55,7 @@ import('./web-components/AskAi/AskAi') import('./web-components/VersionDropdown') import('./web-components/AppliesToPopover') import('./web-components/FullPageSearch/FullPageSearchComponent') +import('./web-components/RelatedPages/RelatedPagesComponent') import('./web-components/Diagnostics/DiagnosticsComponent') import('./web-components/StorybookStory/StorybookStoryComponent') diff --git a/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPages.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPages.test.tsx new file mode 100644 index 0000000000..58877f82b6 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPages.test.tsx @@ -0,0 +1,113 @@ +import * as logging from '../../telemetry/logging' +import { getRelatedPagesPath, RelatedPages } from './RelatedPages' +import { EuiProvider } from '@elastic/eui' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +jest.mock('../../telemetry/logging', () => ({ + logInfo: jest.fn(), +})) + +const fetchMock = jest.fn() + +const renderRelatedPages = () => + render( + + + + ) + +describe('RelatedPages', () => { + beforeEach(() => { + jest.clearAllMocks() + Object.defineProperty(global, 'fetch', { + configurable: true, + writable: true, + value: fetchMock, + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it.each([ + [ + '/elastic/docs-builder/docs/3655/opentelemetry-collector', + '/elastic/docs-builder/docs/3655', + '/opentelemetry-collector', + ], + ['/docs/missing-page', '/docs', '/missing-page'], + ['/docs-preview/missing-page', '/docs', '/docs-preview/missing-page'], + ['/missing-page', '', '/missing-page'], + ])( + 'normalizes %s relative to the configured root path', + (pathname, rootPath, expected) => { + expect(getRelatedPagesPath(pathname, rootPath)).toBe(expected) + } + ) + + it('renders related pages returned by the API and tracks clicks', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + query: 'index lifecycle management', + results: [ + { + url: '/docs/manage-data/lifecycle/index-lifecycle-management', + title: 'Manage the index lifecycle', + description: + 'Automate how indices are managed over time.', + parents: [ + { title: 'Manage data', url: '/docs/manage-data' }, + ], + }, + ], + }), + } as Response) + + renderRelatedPages() + + const link = await screen.findByRole('link', { + name: /manage the index lifecycle/i, + }) + expect(screen.getByText('You might be looking for')).toBeInTheDocument() + expect(global.fetch).toHaveBeenCalledWith( + '/docs/_api/v1/related-pages?path=%2Fdocs%2Fold%2Findex-lifecycle-management.html', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + + link.addEventListener('click', (event) => event.preventDefault()) + fireEvent.click(link, { button: 1 }) + + expect(logging.logInfo).toHaveBeenCalledWith( + '404 related page clicked', + { + '404.related_pages.result_url': + '/docs/manage-data/lifecycle/index-lifecycle-management', + '404.related_pages.result_position': 0, + } + ) + }) + + it('renders no section when the API has no confident suggestions', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ query: 'missing', results: [] }), + } as Response) + + renderRelatedPages() + + await waitFor(() => + expect( + screen.queryByText('Finding related pages') + ).not.toBeInTheDocument() + ) + expect( + screen.queryByText('You might be looking for') + ).not.toBeInTheDocument() + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPages.tsx b/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPages.tsx new file mode 100644 index 0000000000..6d87e24488 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPages.tsx @@ -0,0 +1,180 @@ +import { config } from '../../config' +import { logInfo } from '../../telemetry/logging' +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiPanel, + EuiText, + useEuiTheme, +} from '@elastic/eui' +import { css } from '@emotion/react' +import { useEffect, useState } from 'react' + +export interface RelatedPage { + url: string + title: string + description: string + parents: Array<{ title: string; url: string }> +} + +interface RelatedPagesResponse { + query: string + results: RelatedPage[] +} + +export const getRelatedPagesPath = ( + pathname = window.location.pathname, + rootPath = config.rootPath +) => { + const root = rootPath.replace(/\/$/, '') + if (!root || !pathname.startsWith(`${root}/`)) return pathname + + return pathname.slice(root.length) +} + +export const RelatedPages = ({ + path = getRelatedPagesPath(), +}: { + path?: string +}) => { + const { euiTheme } = useEuiTheme() + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const controller = new AbortController() + + const load = async () => { + try { + const response = await fetch( + `${config.apiBasePath}/v1/related-pages?path=${encodeURIComponent(path)}`, + { signal: controller.signal } + ) + if (!response.ok) return + + const data = (await response.json()) as RelatedPagesResponse + setResults(data.results) + logInfo('404 related pages loaded', { + '404.related_pages.results_count': data.results.length, + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') + return + } finally { + if (!controller.signal.aborted) setIsLoading(false) + } + } + + if (config.airGapped) { + setIsLoading(false) + return () => controller.abort() + } + + void load() + return () => controller.abort() + }, [path]) + + if (isLoading) { + return ( +
+ + Finding related pages +
+ ) + } + + if (results.length === 0) return null + + return ( +
+ + + + + {results.map((result, index) => ( + + + + logInfo('404 related page clicked', { + '404.related_pages.result_url': + result.url, + '404.related_pages.result_position': + index, + }) + } + css={css` + display: block; + color: inherit; + text-decoration: none; + + &:hover h3 { + color: ${euiTheme.colors.primary}; + text-decoration: underline; + } + `} + > + {result.parents.length > 0 && ( +
+ {result.parents + .map((parent) => parent.title) + .join(' › ')} +
+ )} + + + + + + +

{result.title}

+
+
+
+ {result.description && ( + +

+ {result.description} +

+
+ )} +
+
+
+ ))} +
+
+ ) +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPagesComponent.tsx b/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPagesComponent.tsx new file mode 100644 index 0000000000..5ed849a362 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/web-components/RelatedPages/RelatedPagesComponent.tsx @@ -0,0 +1,22 @@ +import { sharedQueryClient } from '../shared/queryClient' +import { RelatedPages } from './RelatedPages' +import { EuiProvider } from '@elastic/eui' +import r2wc from '@r2wc/react-to-web-component' +import { QueryClientProvider } from '@tanstack/react-query' +import { StrictMode } from 'react' + +const RelatedPagesWrapper = () => ( + + + + + + + +) + +customElements.define('related-pages', r2wc(RelatedPagesWrapper)) diff --git a/src/Elastic.Documentation.Site/synthetics/journeys/404-page.journey.ts b/src/Elastic.Documentation.Site/synthetics/journeys/404-page.journey.ts new file mode 100644 index 0000000000..c889eda33f --- /dev/null +++ b/src/Elastic.Documentation.Site/synthetics/journeys/404-page.journey.ts @@ -0,0 +1,22 @@ +import { journey, step, monitor, expect } from '@elastic/synthetics' + +journey('404 recovery page', ({ page, params }) => { + monitor.use({ + id: `elastic-co-docs-404-${params.environment}`, + schedule: params.environment === 'prod' ? 1 : 15, + tags: [`env:${params.environment}`], + }) + + const host = params.baseUrl + step('Open a missing documentation page', async () => { + const response = await page.goto( + `${host}/docs/this-page-does-not-exist-for-404-test`, + { waitUntil: 'domcontentloaded' } + ) + + expect(response?.status()).toBe(404) + await expect( + page.getByRole('heading', { name: 'Page not found' }) + ).toBeVisible() + }) +}) diff --git a/src/Elastic.Markdown/Layout/_NotFound.cshtml b/src/Elastic.Markdown/Layout/_NotFound.cshtml index f58a8a93b0..cee7995cae 100644 --- a/src/Elastic.Markdown/Layout/_NotFound.cshtml +++ b/src/Elastic.Markdown/Layout/_NotFound.cshtml @@ -1,11 +1,31 @@ @inherits RazorSlice -
- Page not found -

The page you are looking for could not be found.

- - Go back to docs home - - - - -
+
+
+
+

404

+

Page not found

+ @if (Model.Features.RelatedPagesEnabled) + { +

+ The page might have moved or the link might be outdated. + Try one of the suggested pages below. +

+ } +
+ + @if (Model.Features.RelatedPagesEnabled) + { +
+ +
+ + } +
+
diff --git a/src/api/Elastic.Documentation.Api/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api/MappingsExtensions.cs index 8c7a906551..f509760194 100644 --- a/src/api/Elastic.Documentation.Api/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api/MappingsExtensions.cs @@ -23,6 +23,7 @@ public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) MapAskAiEndpoint(group); MapNavigationSearch(group); MapFullSearch(group); + MapRelatedPages(group); MapChanges(group); } @@ -156,6 +157,23 @@ Cancel ctx }); } + private static void MapRelatedPages(IEndpointRouteBuilder group) => + group.MapGet("/related-pages", + async ( + [FromQuery(Name = "path")] string path, + IRelatedPagesService relatedPagesService, + Cancel ctx + ) => + { + if (string.IsNullOrWhiteSpace(path)) + return Results.BadRequest("The path query parameter is required."); + if (path.Length > RelatedPagesQuery.MaximumPathLength) + return Results.BadRequest($"The path query parameter must not exceed {RelatedPagesQuery.MaximumPathLength} characters."); + + var response = await relatedPagesService.GetRelatedPagesAsync(path, ctx); + return Results.Ok(response); + }); + private static void MapChanges(IEndpointRouteBuilder group) => group.MapGet("/changes", async ( diff --git a/src/api/Elastic.Documentation.Api/SerializationContext.cs b/src/api/Elastic.Documentation.Api/SerializationContext.cs index d5474683b9..c2a606cee9 100644 --- a/src/api/Elastic.Documentation.Api/SerializationContext.cs +++ b/src/api/Elastic.Documentation.Api/SerializationContext.cs @@ -31,6 +31,9 @@ public record OutputMessage(string Role, MessagePart[] Parts, string FinishReaso [JsonSerializable(typeof(FullSearchRequest))] [JsonSerializable(typeof(FullSearchResponse))] [JsonSerializable(typeof(FullSearchAggregations))] +[JsonSerializable(typeof(RelatedPagesResponse))] +[JsonSerializable(typeof(RelatedPage))] +[JsonSerializable(typeof(RelatedPageParent))] [JsonSerializable(typeof(ChangesResponse))] [JsonSerializable(typeof(ChangedPageDto))] diff --git a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchRequest.cs b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchRequest.cs index 2dfe794dfe..038a7cf752 100644 --- a/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchRequest.cs +++ b/src/services/search/Elastic.Documentation.Search.Contract/Search/SearchRequest.cs @@ -32,6 +32,9 @@ public record SearchRequest public SortMode SortBy { get; init; } = SortMode.Relevance; public bool IncludeHighlighting { get; init; } = true; + /// Run the hybrid semantic query even when the query does not match the normal natural-language heuristic. + public bool ForceSemantic { get; init; } + /// /// Diagnostic probe bitmask — see for bit definitions. /// When null (the default) the normal production query is built verbatim. diff --git a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs index 51eedda178..31e5bce212 100644 --- a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs @@ -156,7 +156,7 @@ public async Task> AutocompleteAsync(Autocomplet public async Task> SearchAsync(SearchRequest request, CancellationToken ct = default) { - var isSemantic = searchConfig.SemanticEnabled && IsSemanticQuery(request.Query); + var isSemantic = searchConfig.SemanticEnabled && (request.ForceSemantic || IsSemanticQuery(request.Query)); var lexicalQuery = SearchQueryBuilder.BuildLexicalQuery( request.Query, @@ -168,7 +168,8 @@ public async Task> AutocompleteAsync(Autocomplet ? new BoolQuery { Should = [lexicalQuery, SearchQueryBuilder.BuildSemanticQuery(request.Query)], - MinimumShouldMatch = 1 + MinimumShouldMatch = 1, + Filter = [SearchQueryBuilder.DocumentFilter] } : lexicalQuery; diff --git a/src/services/search/Elastic.Documentation.Search/FullSearchService.cs b/src/services/search/Elastic.Documentation.Search/FullSearchService.cs index ff14b9c5f0..99ac2bcb58 100644 --- a/src/services/search/Elastic.Documentation.Search/FullSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/FullSearchService.cs @@ -43,7 +43,8 @@ public async Task SearchAsync(FullSearchRequest request, Can "alpha" => SortMode.Alpha, _ => SortMode.Relevance }, - IncludeHighlighting = request.IncludeHighlighting + IncludeHighlighting = request.IncludeHighlighting, + ForceSemantic = request.ForceSemantic }, ctx); } catch (TransportException ex) when (IsTransient(ex)) diff --git a/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs b/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs index 59af09bd69..a36e9ead70 100644 --- a/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/IFullSearchService.cs @@ -28,6 +28,7 @@ public record FullSearchRequest public string? VersionFilter { get; init; } // "9.0+" | "8.19" | "7.17" public string SortBy { get; init; } = "relevance"; // relevance | recent | alpha public bool IncludeHighlighting { get; init; } = true; + public bool ForceSemantic { get; init; } } /// diff --git a/src/services/search/Elastic.Documentation.Search/IRelatedPagesService.cs b/src/services/search/Elastic.Documentation.Search/IRelatedPagesService.cs new file mode 100644 index 0000000000..109bd7e37a --- /dev/null +++ b/src/services/search/Elastic.Documentation.Search/IRelatedPagesService.cs @@ -0,0 +1,30 @@ +// 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 + +namespace Elastic.Documentation.Search; + +public interface IRelatedPagesService +{ + Task GetRelatedPagesAsync(string path, Cancel ctx = default); +} + +public record RelatedPagesResponse +{ + public required string Query { get; init; } + public required IReadOnlyList Results { get; init; } +} + +public record RelatedPage +{ + public required string Url { get; init; } + public required string Title { get; init; } + public required string Description { get; init; } + public required RelatedPageParent[] Parents { get; init; } +} + +public record RelatedPageParent +{ + public required string Title { get; init; } + public required string Url { get; init; } +} diff --git a/src/services/search/Elastic.Documentation.Search/RelatedPagesQuery.cs b/src/services/search/Elastic.Documentation.Search/RelatedPagesQuery.cs new file mode 100644 index 0000000000..33bd74d636 --- /dev/null +++ b/src/services/search/Elastic.Documentation.Search/RelatedPagesQuery.cs @@ -0,0 +1,63 @@ +// 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.Collections.Frozen; +using System.Text.RegularExpressions; + +namespace Elastic.Documentation.Search; + +public static partial class RelatedPagesQuery +{ + public const int MaximumPathLength = 2048; + private const int MaximumQueryTerms = 12; + + private static readonly FrozenSet IgnoredTerms = FrozenSet.ToFrozenSet( + [ + "docs", "doc", "guide", "guides", "current", "latest", "html", "htm", + "en", "en-us", "de", "fr", "ja", "ko", "zh", "es", "pt" + ], StringComparer.OrdinalIgnoreCase); + + public static string FromPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + var value = path; + if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + value = uri.AbsolutePath; + + try + { + value = Uri.UnescapeDataString(value); + } + catch (UriFormatException) + { + // Keep the original path when malformed escape sequences cannot be decoded. + } + + value = TrailingIndexRegex().Replace(value, string.Empty); + + var terms = value + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(segment => ExtensionRegex().Replace(segment, string.Empty)) + .Where(segment => !IgnoredTerms.Contains(segment) && !VersionRegex().IsMatch(segment)) + .SelectMany(segment => TokenRegex().Matches(segment).Select(m => m.Value.ToLowerInvariant())) + .Distinct(StringComparer.OrdinalIgnoreCase) + .TakeLast(MaximumQueryTerms); + + return string.Join(' ', terms); + } + + [GeneratedRegex(@"[\p{L}\p{N}]+", RegexOptions.CultureInvariant)] + private static partial Regex TokenRegex(); + + [GeneratedRegex(@"(?:^|/)index(?:\.html?)?/?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex TrailingIndexRegex(); + + [GeneratedRegex(@"\.html?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ExtensionRegex(); + + [GeneratedRegex(@"^v?\d+(?:\.\d+)*$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex VersionRegex(); +} diff --git a/src/services/search/Elastic.Documentation.Search/RelatedPagesService.cs b/src/services/search/Elastic.Documentation.Search/RelatedPagesService.cs new file mode 100644 index 0000000000..489bd95618 --- /dev/null +++ b/src/services/search/Elastic.Documentation.Search/RelatedPagesService.cs @@ -0,0 +1,52 @@ +// 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 + +namespace Elastic.Documentation.Search; + +public class RelatedPagesService(IFullSearchService searchService) : IRelatedPagesService +{ + private const int ResultCount = 5; + + public async Task GetRelatedPagesAsync(string path, Cancel ctx = default) + { + var query = RelatedPagesQuery.FromPath(path); + if (query.Length == 0) + return new RelatedPagesResponse { Query = query, Results = [] }; + + var response = await searchService.SearchAsync(new FullSearchRequest + { + Query = query, + PageSize = ResultCount + 1, + IncludeHighlighting = false, + ForceSemantic = true + }, ctx); + + var normalizedPath = NormalizePath(path); + var results = response.Results + .Where(result => !string.Equals(NormalizePath(result.Url), normalizedPath, StringComparison.OrdinalIgnoreCase)) + .Where(result => result.Score > 0) + .Take(ResultCount) + .Select(result => new RelatedPage + { + Url = result.Url, + Title = result.Title, + Description = result.AiShortSummary ?? result.Description, + Parents = result.Parents.Select(parent => new RelatedPageParent + { + Title = parent.Title, + Url = parent.Url + }).ToArray() + }) + .ToArray(); + + return new RelatedPagesResponse { Query = query, Results = results }; + } + + private static string NormalizePath(string path) + { + if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + path = uri.AbsolutePath; + return path.TrimEnd('/'); + } +} diff --git a/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs b/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs index 383649eabb..9766ecf92c 100644 --- a/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs +++ b/src/services/search/Elastic.Documentation.Search/ServicesExtension.cs @@ -58,6 +58,7 @@ public static IServiceCollection AddSearchServices(this IServiceCollection servi // Docs-specific adapters preserve the existing API/MCP wire format. _ = services.AddScoped(); _ = services.AddScoped(); + _ = services.AddScoped(); logger?.LogInformation("Full search services registered with hybrid RRF support"); // Changes feed (cursor-paginated changes since a given date) diff --git a/src/tooling/docs-builder/Http/StaticWebHost.cs b/src/tooling/docs-builder/Http/StaticWebHost.cs index 5b4b1e620c..9a15d864ca 100644 --- a/src/tooling/docs-builder/Http/StaticWebHost.cs +++ b/src/tooling/docs-builder/Http/StaticWebHost.cs @@ -103,13 +103,12 @@ private Task ServeRootIndex(Cancel _) return Task.FromResult(Results.Redirect("docs")); } - private async Task ServeDocumentationFile(string slug, Cancel _) + private async Task ServeDocumentationFile(string slug, Cancel ctx) { // from the injected top level navigation which expects us to run on elastic.co if (slug.StartsWith("static-res/")) return Results.NotFound(); - await Task.CompletedTask; var contentRoot = Path.GetFullPath(_contentRoot); var localPath = Path.GetFullPath(Path.Join(contentRoot, slug.Replace('/', Path.DirectorySeparatorChar))); if (!localPath.StartsWith(contentRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal)) @@ -141,6 +140,30 @@ private async Task ServeDocumentationFile(string slug, Cancel _) return Results.File(fileInfo.FullName, mimetype); } + return await ServeNotFoundPage(slug, contentRoot, ctx); + } + + private static async Task ServeNotFoundPage(string slug, string contentRoot, Cancel ctx) + { + var extension = Path.GetExtension(slug); + if (extension.Length > 0 && extension is not ".html" and not ".md") + return Results.NotFound(); + + var firstSegment = slug.Split('/', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + string[] candidates = firstSegment is null + ? [Path.Join(contentRoot, "404", "index.html")] + : [ + Path.Join(contentRoot, firstSegment, "404", "index.html"), + Path.Join(contentRoot, "404", "index.html") + ]; + + foreach (var candidate in candidates) + { + if (!File.Exists(candidate)) + continue; + var content = await File.ReadAllTextAsync(candidate, ctx); + return Results.Content(content, "text/html", statusCode: StatusCodes.Status404NotFound); + } return Results.NotFound(); } diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/RelatedPagesQueryTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/RelatedPagesQueryTests.cs new file mode 100644 index 0000000000..2fd79004a6 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/RelatedPagesQueryTests.cs @@ -0,0 +1,28 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Search; + +namespace Elastic.Documentation.Api.Infrastructure.Tests.Adapters.Search; + +public class RelatedPagesQueryTests +{ + [Theory] + [InlineData("/guide/en/elasticsearch/reference/current/index-lifecycle-management.html", "elasticsearch reference index lifecycle management")] + [InlineData("https://www.elastic.co/docs/8.18/deploy-manage/snapshots/index.html", "deploy manage snapshots")] + [InlineData("/docs/explore-analyze/query-filter/languages/es%7Cql", "explore analyze query filter languages es ql")] + [InlineData("/docs/", "")] + public void FromPath_NormalizesLegacyUrl_ExpectedQuery(string path, string expected) => + RelatedPagesQuery.FromPath(path).Should().Be(expected); + + [Fact] + public void FromPath_LongPath_LimitsQueryTerms() + { + var path = "/one/two/three/four/five/six/seven/eight/nine/ten/eleven/twelve/thirteen/fourteen"; + + RelatedPagesQuery.FromPath(path).Split(' ').Should().HaveCount(12); + RelatedPagesQuery.FromPath(path).Should().StartWith("three"); + } +} diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/RelatedPagesServiceTests.cs b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/RelatedPagesServiceTests.cs new file mode 100644 index 0000000000..e446118a83 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Adapters/Search/RelatedPagesServiceTests.cs @@ -0,0 +1,62 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Search; +using FakeItEasy; + +namespace Elastic.Documentation.Api.Infrastructure.Tests.Adapters.Search; + +public class RelatedPagesServiceTests +{ + [Fact] + public async Task GetRelatedPagesAsync_MissingPath_ForcesSemanticSearchAndExcludesSamePath() + { + var search = A.Fake(); + A.CallTo(() => search.SearchAsync(A._, A._)) + .Returns(new FullSearchResponse + { + Results = + [ + Result("/docs/deploy-manage/index-lifecycle-management", "Missing page", 12), + Result("/docs/manage-data/lifecycle/index-lifecycle-management", "Manage index lifecycle", 10) + ], + TotalResults = 2, + PageNumber = 1, + PageSize = 6 + }); + var service = new RelatedPagesService(search); + + var response = await service.GetRelatedPagesAsync( + "/docs/deploy-manage/index-lifecycle-management", TestContext.Current.CancellationToken); + + A.CallTo(() => search.SearchAsync( + A.That.Matches(request => + request.ForceSemantic && !request.IncludeHighlighting && request.PageSize == 6), + A._)).MustHaveHappenedOnceExactly(); + response.Results.Should().ContainSingle().Which.Title.Should().Be("Manage index lifecycle"); + } + + [Fact] + public async Task GetRelatedPagesAsync_UnusablePath_DoesNotSearch() + { + var search = A.Fake(); + var service = new RelatedPagesService(search); + + var response = await service.GetRelatedPagesAsync("/docs/", TestContext.Current.CancellationToken); + + response.Results.Should().BeEmpty(); + A.CallTo(() => search.SearchAsync(A._, A._)).MustNotHaveHappened(); + } + + private static FullSearchResultItem Result(string url, string title, float score) => new() + { + Type = "doc", + Url = url, + Title = title, + Description = $"Description for {title}", + Parents = [], + Score = score + }; +} diff --git a/tests/Elastic.Documentation.Api.Tests/RelatedPagesEndpointTests.cs b/tests/Elastic.Documentation.Api.Tests/RelatedPagesEndpointTests.cs new file mode 100644 index 0000000000..43f14882d0 --- /dev/null +++ b/tests/Elastic.Documentation.Api.Tests/RelatedPagesEndpointTests.cs @@ -0,0 +1,62 @@ +// 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.Net; +using AwesomeAssertions; +using Elastic.Documentation.Api.Tests.Fixtures; +using Elastic.Documentation.Search; +using FakeItEasy; + +namespace Elastic.Documentation.Api.Tests; + +public class RelatedPagesEndpointTests +{ + [Fact] + public async Task RelatedPages_ValidPath_ReturnsSuggestions() + { + var service = A.Fake(); + A.CallTo(() => service.GetRelatedPagesAsync("/docs/old-page", A._)) + .Returns(new RelatedPagesResponse + { + Query = "old page", + Results = + [ + new RelatedPage + { + Url = "/docs/new-page", + Title = "New page", + Description = "The replacement page.", + Parents = [] + } + ] + }); + using var factory = ApiWebApplicationFactory.WithMockedServices( + services => services.Replace(service)); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync( + "/docs/_api/v1/related-pages?path=%2Fdocs%2Fold-page", TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var json = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + json.Should().Contain("\"query\":\"old page\""); + json.Should().Contain("\"url\":\"/docs/new-page\""); + } + + [Fact] + public async Task RelatedPages_OversizedPath_ReturnsBadRequest() + { + var service = A.Fake(); + using var factory = ApiWebApplicationFactory.WithMockedServices( + services => services.Replace(service)); + using var client = factory.CreateClient(); + var path = new string('a', RelatedPagesQuery.MaximumPathLength + 1); + + using var response = await client.GetAsync( + $"/docs/_api/v1/related-pages?path={path}", TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + A.CallTo(() => service.GetRelatedPagesAsync(A._, A._)).MustNotHaveHappened(); + } +} diff --git a/tests/Elastic.Markdown.Tests/DocSet/NotFoundPageTests.cs b/tests/Elastic.Markdown.Tests/DocSet/NotFoundPageTests.cs new file mode 100644 index 0000000000..55375eae7d --- /dev/null +++ b/tests/Elastic.Markdown.Tests/DocSet/NotFoundPageTests.cs @@ -0,0 +1,37 @@ +// 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 AwesomeAssertions; + +namespace Elastic.Markdown.Tests.DocSet; + +public class NotFoundPageTests(ITestOutputHelper output) : NavigationTestsBase(output) +{ + [Fact] + public async Task RenderLayout_NotFoundPage_IncludesRecoveryOptions() + { + var notFound = Set.MarkdownFiles.Single(file => file.RelativePath.EndsWith("404.md", StringComparison.Ordinal)); + Configuration!.Features.RelatedPagesEnabled = true; + + var rendered = await Generator.RenderLayout(notFound, TestContext.Current.CancellationToken); + + rendered.Html.Should().Contain("Page not found"); + rendered.Html.Should().NotContain(""); + rendered.Html.Should().Contain("Go to docs home"); + rendered.Html.Should().NotContain("Open full search"); + } + + [Fact] + public async Task RenderLayout_RelatedPagesDisabled_OmitsRecoveryOptions() + { + var notFound = Set.MarkdownFiles.Single(file => file.RelativePath.EndsWith("404.md", StringComparison.Ordinal)); + + var rendered = await Generator.RenderLayout(notFound, TestContext.Current.CancellationToken); + + rendered.Html.Should().NotContain(""); + rendered.Html.Should().NotContain("The page might have moved"); + rendered.Html.Should().NotContain("Go to docs home"); + } +}