From 915b31a7c025e571ebdaf32ce125fcffe278a100 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 3 Nov 2026 22:45:44 +0100 Subject: [PATCH 01/10] Carry API operation deprecation onto the sidebar nav node `ApiOperation.Deprecated` is now the one fact that the page badge, the overview rows, the search exporter and the sidebar read. The renamed `IApiOperationNavigationModel` exposes it, and `NavigationRenderNode` projects it. The tree hash includes it only when set, so trees without a deprecated leaf keep their cached `nav-tree-` id. Co-Authored-By: Claude Opus 5.5 --- .../Export/OpenApiDocumentExporter.cs | 2 +- .../Landing/ApiOverviewRow.cs | 2 +- .../Operations/OperationCommonMark.cs | 6 +- .../Operations/OperationNavigationItem.cs | 10 +- .../Operations/OperationView.cshtml | 2 +- ...del.cs => IApiOperationNavigationModel.cs} | 7 +- .../Navigation/NavSide/NavSideExamples.cs | 12 ++- .../Navigation/NavigationRenderModel.cs | 14 +-- .../OperationDeprecatedProjectionTests.cs | 96 +++++++++++++++++++ .../OperationEndpointTests.cs | 4 +- 10 files changed, 135 insertions(+), 20 deletions(-) rename src/Elastic.Documentation.Navigation/{IHttpMethodNavigationModel.cs => IApiOperationNavigationModel.cs} (68%) create mode 100644 tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs diff --git a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs index 8160ae88b3..36bdca8bc9 100644 --- a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs +++ b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs @@ -167,7 +167,7 @@ public IEnumerable ConvertToDocuments(OpenApiDocument ope Method = method, Path = path.Key, OperationId = operationId, - Deprecated = operation.Value.Deprecated + Deprecated = apiOperation.Deprecated }; yield return new DocumentationDocument diff --git a/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs b/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs index b4d6671893..b31729e0ed 100644 --- a/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs +++ b/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs @@ -237,7 +237,7 @@ bool designSystem showTooltip: false, designSystem: designSystem ), - Deprecated = operation.Model.Operation.Deprecated, + Deprecated = operation.Model.Deprecated, DesignSystem = designSystem }; } diff --git a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs index 55350e46e1..0fcffbd3ef 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs @@ -19,7 +19,7 @@ public static string Write(ApiOperation apiOperation, OperationPageModel page, A var operation = apiOperation.Operation; var title = OpenApiExtensionReader.GetSummary(operation) ?? apiOperation.DisplayName; ApiCommonMark.Heading(markdown, 1, title); - WriteBadges(markdown, operation, page); + WriteBadges(markdown, apiOperation.Deprecated, page); WriteServers(markdown, page); WritePaths(markdown, page); WritePathParameters(markdown, page, apiBaseUrl); @@ -40,9 +40,9 @@ public static string Write(ApiOperation apiOperation, OperationPageModel page, A return markdown.ToString(); } - private static void WriteBadges(StringBuilder markdown, OpenApiOperation operation, OperationPageModel page) + private static void WriteBadges(StringBuilder markdown, bool deprecated, OperationPageModel page) { - if (operation.Deprecated) + if (deprecated) ApiCommonMark.Paragraph(markdown, "deprecated"); if (page.AuthBadge is { } auth) ApiCommonMark.Paragraph(markdown, $"Authentication: {auth.Summary}"); diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 76e7463e35..f745629997 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -19,11 +19,17 @@ public record ApiOperation( string Route, IOpenApiPathItem Path, string ApiName -) : IApiModel, IHttpMethodNavigationModel +) : IApiModel, IApiOperationNavigationModel { // The page header leads with the dominant method, which can come from the description's method listing // rather than the spec; the sidebar badge must agree with it. - string IHttpMethodNavigationModel.HttpMethod => OperationEndpoint.Build(this, Operation.Description, []).Rows[0].Method; + string IApiOperationNavigationModel.HttpMethod => OperationEndpoint.Build(this, Operation.Description, []).Rows[0].Method; + + /// + /// The one deprecation fact the page title badge, the overview rows, the sidebar row and the search index all read. + /// Collapsed siblings always agree on it, because operations that differ in lifecycle are never collapsed onto one page. + /// + public bool Deprecated => Operation.Deprecated; /// The title shown for the operation; an operationId used as the grouping key is shown as readable words. public string DisplayName => diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml index 4c334fd059..994abf3bb8 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -53,7 +53,7 @@ } - @await RenderPartialAsync(_TitleBadges.Create(new TitleBadgesModel(pageModel.Availability, operation.Deprecated, Model.BuildContext.Configuration.Features.NavigationPreviewEnabled, pageModel.AuthBadge?.Text, Model.AuthBadgeHref))) + @await RenderPartialAsync(_TitleBadges.Create(new TitleBadgesModel(pageModel.Availability, Model.Operation.Deprecated, Model.BuildContext.Configuration.Features.NavigationPreviewEnabled, pageModel.AuthBadge?.Text, Model.AuthBadgeHref)))
@if (!string.IsNullOrWhiteSpace(pageModel.DescriptionMarkdown) || pageModel.ExternalDocs is not null) diff --git a/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs b/src/Elastic.Documentation.Navigation/IApiOperationNavigationModel.cs similarity index 68% rename from src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs rename to src/Elastic.Documentation.Navigation/IApiOperationNavigationModel.cs index 897982a3e8..439e812162 100644 --- a/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs +++ b/src/Elastic.Documentation.Navigation/IApiOperationNavigationModel.cs @@ -5,10 +5,13 @@ namespace Elastic.Documentation.Navigation; /// -/// Navigation model for an API operation leaf that exposes its HTTP method for sidebar chrome. +/// Navigation model for an API operation leaf that exposes its HTTP method and deprecation for sidebar chrome. /// -public interface IHttpMethodNavigationModel : INavigationModel +public interface IApiOperationNavigationModel : INavigationModel { /// Lowercase HTTP method name (e.g. get, post). string HttpMethod { get; } + + /// True when the spec marks the operation deprecated. + bool Deprecated { get; } } diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs b/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs index 05d5939373..821ae338ba 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs @@ -9,7 +9,13 @@ namespace Elastic.Documentation.Site.Components.Navigation.NavSide; public static class NavSideExamples { - private static NavigationRenderNode Leaf(string title, string url, string? badge = null, string? method = null) => + private static NavigationRenderNode Leaf( + string title, + string url, + string? badge = null, + string? method = null, + bool deprecated = false + ) => new() { Kind = NavigationRenderNodeKind.Leaf, @@ -17,7 +23,8 @@ private static NavigationRenderNode Leaf(string title, string url, string? badge NavigationTitle = title, Url = url, Badge = badge, - HttpMethod = method + HttpMethod = method, + Deprecated = deprecated }; private static NavigationRenderNode Group(string id, string title, string url, params NavigationRenderNode[] children) => @@ -52,6 +59,7 @@ private static NavigationRenderNode Group(string id, string title, string url, p "#", Leaf("Search documents", "#", method: "post"), Leaf("Get document", "#", method: "get"), + Leaf("Get document source", "#", method: "get", deprecated: true), Leaf("Delete document", "#", method: "delete") ), Leaf("Query DSL", "#", badge: "ns"), diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs index b49a4748d1..d1073fe01e 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs @@ -32,7 +32,8 @@ public sealed record NavigationRenderNode public string? Badge { get; init; } /// Lowercase HTTP method for API operation leaves; drives the sidebar method glyph. public string? HttpMethod { get; init; } - /// True when the row groups several HTTP operations under one endpoint. + /// True for an API operation leaf the spec marks deprecated. + public bool Deprecated { get; init; } /// Only projected for nodes, where it drives the expand/collapse checkbox and its persisted state. public string? Id { get; init; } public bool ShowToggle { get; init; } @@ -396,16 +397,15 @@ private static NavigationRenderNode CreateNode(INodeNavigationItem { Model: IHttpMethodNavigationModel method } - ? method.HttpMethod - : null; + var operation = item is ILeafNavigationItem { Model: IApiOperationNavigationModel op } ? op : null; return new NavigationRenderNode { Kind = NavigationRenderNodeKind.Leaf, IsTopLevel = isTopLevel, NavigationTitle = navigationTitle, Badge = badge, - HttpMethod = httpMethod, + HttpMethod = operation?.HttpMethod, + Deprecated = operation?.Deprecated ?? false, Url = item.Url }; } @@ -469,9 +469,11 @@ private static void AppendNode(IncrementalHash hash, NavigationRenderNode node) Append(hash, node.Url); Append(hash, node.Id ?? string.Empty); AppendInt(hash, node.ShowToggle ? 1 : 0); - // Hashed only when set, so trees without an expanded group keep the hash, and so the tree id, they had before. + // Flags are hashed only when set, each with its own marker, so trees without them keep the hash, and so the tree id, they had before. if (node.Expanded) AppendInt(hash, 1); + if (node.Deprecated) + AppendInt(hash, 2); AppendNodes(hash, node.NavigationItems); } diff --git a/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs new file mode 100644 index 0000000000..42fc54ed05 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs @@ -0,0 +1,96 @@ +// 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.ApiExplorer.Export; +using Elastic.ApiExplorer.Operations; +using Elastic.Documentation; +using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Versions; + +namespace Elastic.ApiExplorer.Tests; + +[ClassDataSource(Shared = SharedType.PerClass)] +public class OperationDeprecatedProjectionTests(ApiExplorerFixture fixture) +{ + private const string SpecTemplate = + """ + { + "openapi":"3.0.3", "info":{"title":"Lifecycle","version":"1"}, + "paths":{"/thing":{"get":{"operationId":"thing-get","summary":"Get a thing",DEPRECATED"responses":{"200":{"description":"OK"}}}}} + } + """; + + [Test] + public void Deprecated_SpecFlag_ReadsThroughApiOperation() + { + Operation("docs-get-source").Model.Deprecated.Should().BeTrue(); + Operation("docs-get").Model.Deprecated.Should().BeFalse(); + } + + [Test] + public void CreateNavigation_CollapsedSiblings_AgreeOnDeprecated() + { + foreach (var navigation in fixture.Walk().OfType()) + navigation.Siblings.Should().OnlyContain(s => s.Deprecated == navigation.Model.Deprecated); + } + + [Test] + public void Create_DeprecatedOperation_ProjectsDeprecatedOntoTheLeaf() + { + var model = NavigationRenderModel.Create(fixture.Navigation, [fixture.Navigation], false, false, false); + var leaves = Flatten(model.Tree).ToArray(); + + var deprecated = leaves.Single(n => n.Url == Operation("docs-get-source").Url); + deprecated.Deprecated.Should().BeTrue(); + deprecated.HttpMethod.Should().Be("get"); + leaves.Single(n => n.Url == Operation("docs-get").Url).Deprecated.Should().BeFalse(); + } + + [Test] + public async Task Create_FlippingDeprecated_ChangesTheContentHash() + { + var current = await HashAsync(SpecTemplate.Replace("DEPRECATED", "", StringComparison.Ordinal)); + var currentAgain = await HashAsync(SpecTemplate.Replace("DEPRECATED", "", StringComparison.Ordinal)); + var deprecated = await HashAsync(SpecTemplate.Replace("DEPRECATED", "\"deprecated\":true,", StringComparison.Ordinal)); + + currentAgain.Should().Be(current); + deprecated.Should().NotBe(current); + } + + [Test] + public void ConvertToDocuments_DeprecatedOperation_SetsApiDeprecated() + { + var versions = new VersionsConfiguration + { + VersioningSystems = new Dictionary + { + [VersioningSystemId.Stack] = new VersioningSystem + { + Id = VersioningSystemId.Stack, + Base = new SemVersion(8, 0, 0), + Current = new SemVersion(99, 0, 0) + } + } + }; + var documents = new OpenApiDocumentExporter(versions).ConvertToDocuments(fixture.Document, "fixture").ToArray(); + + documents.Single(d => d.Api!.OperationId == "docs-get-source").Api!.Deprecated.Should().BeTrue(); + documents.Single(d => d.Api!.OperationId == "docs-get").Api!.Deprecated.Should().BeFalse(); + } + + private OperationNavigationItem Operation(string operationId) => + fixture.Walk().OfType().Single(n => n.Model.Operation.OperationId == operationId); + + private static IEnumerable Flatten(IEnumerable nodes) => + nodes.SelectMany(n => Flatten(n.NavigationItems).Prepend(n)); + + private static async Task HashAsync(string json) + { + var (generator, document) = await TestSpecs.CreateGeneratorWithSpecAsync(json); + var navigation = generator.CreateNavigation("lifecycle", document); + return NavigationRenderModel.Create(navigation, [navigation], false, false, false).ContentHash; + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs index 19a6df2c62..3b8906305f 100644 --- a/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs @@ -24,8 +24,8 @@ public void HttpMethod_ListingAddsDominantMethod_UsesDominant() }; var withoutListing = Op(HttpMethod.Get, "/_cluster/health"); - ((IHttpMethodNavigationModel)withListing).HttpMethod.Should().Be("post"); - ((IHttpMethodNavigationModel)withoutListing).HttpMethod.Should().Be("get"); + ((IApiOperationNavigationModel)withListing).HttpMethod.Should().Be("post"); + ((IApiOperationNavigationModel)withoutListing).HttpMethod.Should().Be("get"); } private static ApiOperation Op(HttpMethod method, string route, string? operationId = null, params string[] queryParameters) => From 0f3400969c59f7f204bc0714f40130d990bf1b20 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 3 Nov 2026 22:47:01 +0100 Subject: [PATCH 02/10] Strike through deprecated operations in the legacy API sidebar A reader now sees that an operation is deprecated before opening it. The row keeps its width: the title is struck through and dimmed like a deprecated property, the method chip turns grey, and a hidden word keeps the link name honest for screen readers. Rows that are not deprecated render the same markup as before. Co-Authored-By: Claude Opus 5.5 --- .../Assets/api-docs.css | 19 +++++++++++ .../Navigation/_TocTreeNav.cshtml | 3 +- .../Navigation/_TocTreeNavLegacy.cshtml | 3 +- .../ApiPagesNavRenderingTests.cs | 33 +++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 10701284ef..150632936e 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -994,6 +994,25 @@ border-color: var(--color-grey-100); } +#pages-nav a.nav-link--deprecated > span:first-child { + text-decoration: line-through; + opacity: 0.7; +} + +#pages-nav a.nav-link--deprecated .nav-v2-api-method { + background-color: var(--color-grey-20); + border-color: var(--color-grey-30); + color: var(--color-grey-100); +} + +#pages-nav a.nav-link--deprecated:hover .nav-v2-api-method { + border-color: var(--color-grey-60); +} + +#pages-nav a.nav-link--deprecated.current .nav-v2-api-method { + border-color: var(--color-grey-100); +} + .api-url { margin-left: calc(var(--spacing) * 2); min-width: 0; diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index b9f88c0306..0d1e05a599 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -9,8 +9,9 @@ else if (item.Kind == NavigationRenderNodeKind.Leaf) {
  • - + @item.NavigationTitle + @if (item.Deprecated) { Deprecated } @if (item.Badge is not null) { @item.Badge } @await RenderPartialAsync(_NavHttpMethodGlyph.Create(item)) diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml index 985fb487f0..6005206501 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml @@ -9,8 +9,9 @@ else if (item.Kind == NavigationRenderNodeKind.Leaf) {
  • - + @item.NavigationTitle + @if (item.Deprecated) { Deprecated } @if (item.Badge is not null) { @item.Badge } @await RenderPartialAsync(_NavHttpMethodGlyph.Create(item)) diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index 10369a9210..9156d5e1c7 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -238,6 +238,39 @@ public async Task Render_OperationRow_ShowsVerbTextAfterOperationName(string met link.Should().NotContain("aria-label"); link.Should().NotContain("icon-api-arrow"); link.Should().NotContain("icon-api-x"); + link.Should().NotContain("deprecated"); + } + + [Test] + public async Task Render_DeprecatedOperationRow_MarksTheLinkAndNamesItForScreenReaders() + { + NavigationRenderNode[] nodes = + [ + new() + { + Kind = NavigationRenderNodeKind.Leaf, + IsTopLevel = false, + NavigationTitle = "Get a document source", + Url = "/api/doc/elasticsearch/operation/operation-get-source", + HttpMethod = "get", + Deprecated = true + } + ]; + var ct = TestContext.Current!.Execution.CancellationToken; + + foreach (var html in new[] + { + await _TocTreeNav.Create(nodes).RenderAsync(cancellationToken: ct), + await _TocTreeNavLegacy.Create(nodes).RenderAsync(cancellationToken: ct) + }) + { + var link = OperationLink(html); + link.Should().MatchRegex("class=\"sidebar-link nav-link( nav-v2-link)? nav-link--deprecated\""); + var hidden = link.IndexOf("Deprecated", StringComparison.Ordinal); + hidden.Should().BeGreaterThan(link.IndexOf("Get a document source", StringComparison.Ordinal)); + hidden.Should().BeLessThan(link.IndexOf(">GET<", StringComparison.Ordinal)); + link.Should().NotContain("aria-label"); + } } [Test] From e5e5e713f3eb9ecb6257613b60baddd160384925 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 3 Nov 2026 22:48:46 +0100 Subject: [PATCH 03/10] Strike through deprecated operations in the design-system sidebar The design-system tree now marks a deprecated operation the same way as the legacy sidebar. One `nav-tree__link--deprecated` modifier strikes the title and greys the method badge through its tone tokens. A hidden word names the state for screen readers. Co-Authored-By: Claude Opus 5.5 --- .../Navigation/NavTree/_NavTreeItems.cshtml | 2 +- .../Navigation/NavTree/_NavTreeLabel.cshtml | 4 +++ .../Navigation/NavTree/nav-tree.css | 10 ++++++ .../DesignSystem/NavSideRenderingTests.cs | 34 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml index f2e1895664..a42ce194c5 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml @@ -18,7 +18,7 @@ } else { - + @await RenderPartialAsync(_NavTreeLabel.Create(item)) } diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml index 395a660a80..f305349b87 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml @@ -1,6 +1,10 @@ @inherits RazorSlice @using Elastic.Documentation.Site.Components.Navigation.NavTree @Model.NavigationTitle +@if (Model.Deprecated) +{ + Deprecated +} @if (Model.Badge is { } badge) { @await RenderPartialAsync(_Badge.Create(new BadgeModel { Text = badge, Tone = Tone.Info })) diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css index dcd9b83f2a..f301410b6b 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css @@ -176,6 +176,16 @@ color: var(--color-text-muted); } +.nav-tree__link--deprecated .nav-tree__text { + text-decoration: line-through; + opacity: 0.7; +} + +.nav-tree__link--deprecated .method { + --badge-tone: var(--color-text-muted); + --badge-surface: var(--color-surface-sunken); +} + .nav-tree__separator { height: var(--border-width); margin: var(--space-2) var(--space-3); diff --git a/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs b/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs index 276b790158..87ec6f2c80 100644 --- a/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs +++ b/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs @@ -49,4 +49,38 @@ public async Task Tree_MarksAbsoluteLinksAsExternal() html.Should().Contain("nav-tree__link--external").And.Contain("target=\"_blank\"").And.Contain("rel=\"noopener\""); } + + [Test] + public async Task Tree_DeprecatedOperation_DimsTheRowAndNamesItForScreenReaders() + { + var html = await RenderAsync(NavSideExamples.Navigation with { Tree = [Operation(deprecated: true)] }); + + html.Should().Contain("class=\"nav-tree__link nav-tree__link--deprecated\""); + var text = html.IndexOf("class=\"nav-tree__text\"", StringComparison.Ordinal); + var hidden = html.IndexOf("Deprecated", StringComparison.Ordinal); + var method = html.IndexOf("method method--sm", StringComparison.Ordinal); + hidden.Should().BeGreaterThan(text); + hidden.Should().BeLessThan(method); + html.Should().Contain("class=\"badge badge--info method method--sm\""); + } + + [Test] + public async Task Tree_OperationNotDeprecated_HasNoDeprecatedModifier() + { + var html = await RenderAsync(NavSideExamples.Navigation with { Tree = [Operation(deprecated: false)] }); + + html.Should().Contain("class=\"nav-tree__link\""); + html.Should().NotContain("deprecated").And.NotContain("Deprecated"); + } + + private static NavigationRenderNode Operation(bool deprecated) => + new() + { + Kind = NavigationRenderNodeKind.Leaf, + IsTopLevel = false, + NavigationTitle = "Get document source", + Url = "/api/doc/elasticsearch/operation/operation-get-source", + HttpMethod = "get", + Deprecated = deprecated + }; } From 4ee6b0cdc594e43355e272b56ef8b2f8f464e8aa Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 3 Nov 2026 22:49:42 +0100 Subject: [PATCH 04/10] Return the deprecated flag of API operations from navigation search The index already stores `api.deprecated`, but the `_source` filter left it out and the response had no field for it. Navigation search results now carry `deprecated`, which is false for docs pages, so the search card can mark a deprecated operation. Co-Authored-By: Claude Opus 5.5 --- .../DefaultSearchService.cs | 4 +- .../INavigationSearchService.cs | 3 + .../NavigationSearchService.cs | 1 + .../AutocompleteSourceIncludesTests.cs | 4 ++ .../NavigationSearchServiceTests.cs | 62 ++++++++++++++----- 5 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs index 578c14c05c..5c0eee9f8e 100644 --- a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs @@ -46,7 +46,9 @@ public partial class DefaultSearchService( "api.method", "api.path", "api.operation_id", - "api.routes" + "api.routes", + // optional, so the required-member test cannot catch its absence; without it every hit reads as current + "api.deprecated" ]; private static readonly string[] SearchSourceIncludes = diff --git a/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs b/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs index 0a31b72d48..8936d4c18d 100644 --- a/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs @@ -53,6 +53,9 @@ public record NavigationSearchResultItem /// The operation's routes with their methods; empty for a docs page. public ApiRouteEntry[] Routes { get; init; } = []; + + /// True when the result is an API operation the spec marks deprecated; false for a docs page. + public bool Deprecated { get; init; } public required string Type { get; init; } public required string Url { get; init; } public required string Title { get; init; } diff --git a/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs b/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs index 1c6b405f69..8c376f8263 100644 --- a/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs @@ -53,6 +53,7 @@ public async Task NavigationSearchAsync(NavigationSear Title = item.Title, Description = item.Description, Routes = item.Document.Api?.Routes ?? [], + Deprecated = item.Document.Api?.Deprecated ?? false, Properties = item .Properties .Select( diff --git a/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs b/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs index 3a324ee236..eab77f0f57 100644 --- a/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs @@ -30,4 +30,8 @@ public void AutocompleteSourceIncludes_CoverEveryRequiredApiMember() DefaultSearchService.AutocompleteSourceIncludes.Should().Contain(required); DefaultSearchService.AutocompleteSourceIncludes.Should().Contain("api.routes"); } + + [Test] + public void AutocompleteSourceIncludes_ContainTheDeprecatedFlag() => + DefaultSearchService.AutocompleteSourceIncludes.Should().Contain("api.deprecated"); } diff --git a/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs b/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs index 55d0648edf..ec1ced310b 100644 --- a/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs @@ -18,7 +18,6 @@ public class NavigationSearchServiceTests [Test] public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperationPage() { - var inner = A.Fake>(); var document = new DocumentationDocument { Title = "Search", @@ -46,6 +45,48 @@ public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperati Anchor = "req-knn", Description = "Approximate kNN" }; + + var result = await SearchAsync(document, [property]); + + result.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search"); + var linked = result.Properties.Single(); + linked.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search#req-knn"); + linked.Anchor.Should().Be("req-knn"); + linked.Location.Should().Be("request"); + result + .Routes + .Select(r => (r.Path, string.Join(",", r.Methods))) + .Should() + .Equal(("/{index}/_search", "POST,GET"), ("/_search", "POST,GET")); + result.Deprecated.Should().BeFalse(); + } + + [Test] + public async Task NavigationSearchAsync_DeprecatedOperation_FlagsTheResult() + { + var document = new DocumentationDocument + { + Title = "Get a document source", + SearchTitle = "Get a document source", + Path = "/docs/api/doc/elasticsearch/operation/operation-get-source", + Api = new ApiOperationMetadata + { + Endpoints = ["_source"], + Method = "GET", + Path = "/{index}/_source/{id}", + OperationId = "get-source", + Deprecated = true + } + }; + + var result = await SearchAsync(document, []); + + result.Deprecated.Should().BeTrue(); + } + + private static async Task SearchAsync(DocumentationDocument document, ApiPropertyEntry[] properties) + { + var inner = A.Fake>(); _ = A.CallTo( () => inner.AutocompleteAsync(A._, A._) ).Returns(new AutocompleteResponse @@ -55,10 +96,10 @@ public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperati new SearchResultItem { Document = document, - Title = "Search", + Title = document.Title, Description = "Run a search", Score = 5, - Properties = [property] + Properties = properties } ], TotalResults = 1, @@ -75,18 +116,7 @@ public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperati ); using var service = new NavigationSearchService(inner, accessor, NullLogger.Instance); - var response = await service.NavigationSearchAsync(new NavigationSearchRequest { Query = "knn", TypeFilter = "api" }); - - var result = response.Results.Single(); - result.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search"); - var linked = result.Properties.Single(); - linked.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search#req-knn"); - linked.Anchor.Should().Be("req-knn"); - linked.Location.Should().Be("request"); - result - .Routes - .Select(r => (r.Path, string.Join(",", r.Methods))) - .Should() - .Equal(("/{index}/_search", "POST,GET"), ("/_search", "POST,GET")); + var response = await service.NavigationSearchAsync(new NavigationSearchRequest { Query = "q", TypeFilter = "api" }); + return response.Results.Single(); } } From 4f712207f6ca4808e02bf9d6ca0d4a1c90eccceb Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Tue, 3 Nov 2026 22:50:33 +0100 Subject: [PATCH 05/10] Show a Deprecated badge on deprecated API operations in search A reader who finds a deprecated operation in search now sees the same red "Deprecated" badge that its page shows, before clicking through. The card reuses the global `.deprecated-badge` class. The schema keeps `deprecated` optional, so an API and a frontend deployed at different times still parse each other. Co-Authored-By: Claude Opus 5.5 --- .../SearchResultsList.test.tsx | 22 ++++++++++++++++++ .../NavigationSearch/SearchResultsList.tsx | 23 +++++++++++++++++-- .../useNavigationSearchQuery.test.ts | 5 ++++ .../useNavigationSearchQuery.ts | 1 + 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx index 06fa40e9c2..f508884d17 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx @@ -277,3 +277,25 @@ describe('property rows', () => { expect(navigationSearchStore.getState().selectedIndex).toBe(3) }) }) + +describe('deprecated operations', () => { + beforeEach(() => { + navigationSearchStore.getState().actions.setSearchTerm('search') + }) + + it('shows a Deprecated badge beside the title of a deprecated operation', () => { + const { container } = renderList({ + groups: groupSearchResults([{ ...operation, deprecated: true }]), + }) + const badges = container.querySelectorAll( + '.title-text .deprecated-badge' + ) + expect(badges).toHaveLength(1) + expect(badges[0]).toHaveTextContent('Deprecated') + }) + + it('shows no badge for an operation that is not deprecated', () => { + const { container } = renderList() + expect(container.querySelector('.deprecated-badge')).toBeNull() + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx index 84c3148797..1d697fb2f6 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx @@ -322,7 +322,7 @@ const SearchResultRow = ({ `} > - + <Title text={result.title} deprecated={result.deprecated} /> {route ? ( <RouteLine route={route} tokens={tokens} /> ) : ( @@ -415,7 +415,13 @@ const Breadcrumb = ({ items }: { items: string[] }) => { ) } -const Title = ({ text }: { text: string }) => { +const Title = ({ + text, + deprecated, +}: { + text: string + deprecated?: boolean +}) => { const { euiTheme } = useEuiTheme() return ( @@ -436,6 +442,19 @@ const Title = ({ text }: { text: string }) => { `} > <SanitizedHtmlContent htmlContent={text} /> + {deprecated && ( + <span + className="deprecated-badge" + css={css` + display: inline-block; + margin-left: ${euiTheme.size.s}; + vertical-align: middle; + line-height: 1; + `} + > + Deprecated + </span> + )} </div> ) } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts index d65732b222..f93dffafc2 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts @@ -20,6 +20,7 @@ describe('SearchResponse', () => { title: 'Bulk API', description: 'Index multiple documents', score: 0.9, + deprecated: true, parents: [ { url: '/docs/api', title: 'API Reference' }, { @@ -36,5 +37,9 @@ describe('SearchResponse', () => { }) expect(parsed.results.map((item) => item.type)).toEqual(['docs', 'api']) + expect(parsed.results.map((item) => item.deprecated)).toEqual([ + undefined, + true, + ]) }) }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts index e12f2a3e93..f27013b59f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts @@ -45,6 +45,7 @@ const SearchResultItem = z.object({ parents: z.array(SearchResultItemParent), properties: z.array(ApiPropertyResult).optional(), routes: z.array(ApiRoute).optional(), + deprecated: z.boolean().optional(), }) export type SearchResultItem = z.infer<typeof SearchResultItem> From da4b5d6cc93c574cc63053261d6210ea6f7b2d4d Mon Sep 17 00:00:00 2001 From: Jan Calanog <reakaleek@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:50:52 +0100 Subject: [PATCH 06/10] Document how deprecated API operations look in search and the sidebar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/data/openapi/api-explorer.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 446d9441df..e22f81cc94 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -18,7 +18,7 @@ The API title comes from `info.title` in the spec, so fix naming and description API search matches parameter and field names, including prefixes and qualified paths. The docs-wide search matches a field only by its exact name, and only for a word that contains `_` or `.`, so a plain word keeps ranking pages first. Direct operation and endpoint matches rank above field matches. An operation also matches the words of its operation id and path, split on dots, hyphens, underscores and case changes, and the name of its group, so `ml flush`, `flush ML job` and `_ml _flush` all find `POST /_ml/anomaly_detectors/{job_id}/_flush`. Combine an endpoint and a field, such as `_search knn`, to find the operation and its matching fields. The order does not matter: `knn /_search`, `/_search knn`, `knn search` and `search knn` read the same. A word with a leading slash, or an HTTP method word right before one, is always the endpoint; on its own a word such as `options` or `delete` may name a field. With two plain words the index tries both orders and keeps the one whose endpoint exists, so both halves must match. With three or more plain words the last word is the field. Each matching operation lists up to three matching fields beneath it, with their paths, types, and locations. Child rows match the field's own name; ancestor names and descriptions provide context without adding unrelated children. Select a field to open its anchor, including fields inside collapsed sections. Arrow keys move through operations and fields in order; Enter opens the selected row. -When a query names a path, such as `_search`, the result shows one route in place of the description: the method the page leads with and the path with the match highlighted. The route whose whole path is the query word wins, so `/_search` shows `/_search`; otherwise the operation's primary route shows when it contains the word, and an alternate route only when the primary does not. Every result uses the same bordered box, with matching fields grouped beneath the operation when present. Matches use the same yellow tint as the destination property's flash. Navigating to a field reveals it and flashes its name, including when search replaces the current page without a full reload. Each result's breadcrumb ends with the operation's group, the same group the page shows. +When a query names a path, such as `_search`, the result shows one route in place of the description: the method the page leads with and the path with the match highlighted. The route whose whole path is the query word wins, so `/_search` shows `/_search`; otherwise the operation's primary route shows when it contains the word, and an alternate route only when the primary does not. Every result uses the same bordered box, with matching fields grouped beneath the operation when present. Matches use the same yellow tint as the destination property's flash. Navigating to a field reveals it and flashes its name, including when search replaces the current page without a full reload. Each result's breadcrumb ends with the operation's group, the same group the page shows. A deprecated operation shows a red `Deprecated` badge beside its title in the results, the same badge as on its page. The assembler indexes fields from the same property trees used to render operation pages. Fields of a repeated shape appear under its first listing. On the existing semantic index, the indexer adds the nested `api.properties` mapping before incremental synchronization. Property changes participate in the document hash, so a field update is synchronized even when the operation description stays the same. @@ -466,6 +466,8 @@ Every endpoint with authentication has an `Auth` badge in the row under its path A spec that declares no `security` shows no badge. +The sidebar marks an operation that the spec sets `deprecated: true` on, in both the legacy and the design-system look. Its title is struck through and dimmed, and its method chip turns grey. Screen readers hear the word "Deprecated" after the title. The row keeps its width, so long titles wrap as before. + Each listed scheme is resolved against `components.securitySchemes`, in the order the spec lists them: | OpenAPI scheme | Label | From c3b150fc24410e63ae78863f345e9fc15956138f Mon Sep 17 00:00:00 2001 From: Jan Calanog <reakaleek@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:23:05 +0100 Subject: [PATCH 07/10] Render the search Deprecated badge with the operation page's title badge markup The search card reused the filled chip the explorer draws on deprecated parameters, so the same word looked different from the badge on the page it links to. The card now emits the title badge's own classes and inherits its outlined look from the same rules. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../NavigationSearch/SearchResultsList.test.tsx | 4 ++-- .../NavigationSearch/SearchResultsList.tsx | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx index f508884d17..1a721d48fa 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx @@ -288,7 +288,7 @@ describe('deprecated operations', () => { groups: groupSearchResults([{ ...operation, deprecated: true }]), }) const badges = container.querySelectorAll( - '.title-text .deprecated-badge' + '.title-text .api-title-badges .applicable-info.api-badge--danger' ) expect(badges).toHaveLength(1) expect(badges[0]).toHaveTextContent('Deprecated') @@ -296,6 +296,6 @@ describe('deprecated operations', () => { it('shows no badge for an operation that is not deprecated', () => { const { container } = renderList() - expect(container.querySelector('.deprecated-badge')).toBeNull() + expect(container.querySelector('.api-badge--danger')).toBeNull() }) }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx index 1d697fb2f6..1e85f81308 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx @@ -444,15 +444,22 @@ const Title = ({ <SanitizedHtmlContent htmlContent={text} /> {deprecated && ( <span - className="deprecated-badge" + className="api-title-badges" css={css` display: inline-block; margin-left: ${euiTheme.size.s}; vertical-align: middle; - line-height: 1; + font-weight: ${euiTheme.font.weight.regular}; + .applies { + margin-right: 0; + } `} > - Deprecated + <span className="applies applies-inline"> + <span className="applicable-info api-badge--danger"> + <span className="applicable-name">Deprecated</span> + </span> + </span> </span> )} </div> From 70587f3747a9a8701d7a6a908344797478d02592 Mon Sep 17 00:00:00 2001 From: Jan Calanog <reakaleek@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:23:31 +0100 Subject: [PATCH 08/10] Keep the sidebar example helper at four parameters and name the boolean arguments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../Navigation/NavSide/NavSideExamples.cs | 13 +++---------- .../OperationDeprecatedProjectionTests.cs | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs b/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs index 821ae338ba..531c8515bb 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs @@ -9,13 +9,7 @@ namespace Elastic.Documentation.Site.Components.Navigation.NavSide; public static class NavSideExamples { - private static NavigationRenderNode Leaf( - string title, - string url, - string? badge = null, - string? method = null, - bool deprecated = false - ) => + private static NavigationRenderNode Leaf(string title, string url, string? badge = null, string? method = null) => new() { Kind = NavigationRenderNodeKind.Leaf, @@ -23,8 +17,7 @@ private static NavigationRenderNode Leaf( NavigationTitle = title, Url = url, Badge = badge, - HttpMethod = method, - Deprecated = deprecated + HttpMethod = method }; private static NavigationRenderNode Group(string id, string title, string url, params NavigationRenderNode[] children) => @@ -59,7 +52,7 @@ private static NavigationRenderNode Group(string id, string title, string url, p "#", Leaf("Search documents", "#", method: "post"), Leaf("Get document", "#", method: "get"), - Leaf("Get document source", "#", method: "get", deprecated: true), + Leaf("Get document source", "#", method: "get") with { Deprecated = true }, Leaf("Delete document", "#", method: "delete") ), Leaf("Query DSL", "#", badge: "ns"), diff --git a/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs index 42fc54ed05..d668f53e63 100644 --- a/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs @@ -40,7 +40,13 @@ public void CreateNavigation_CollapsedSiblings_AgreeOnDeprecated() [Test] public void Create_DeprecatedOperation_ProjectsDeprecatedOntoTheLeaf() { - var model = NavigationRenderModel.Create(fixture.Navigation, [fixture.Navigation], false, false, false); + var model = NavigationRenderModel.Create( + fixture.Navigation, + [fixture.Navigation], + isUsingNavigationDropdown: false, + isPrimaryNavEnabled: false, + isGlobalAssemblyBuild: false + ); var leaves = Flatten(model.Tree).ToArray(); var deprecated = leaves.Single(n => n.Url == Operation("docs-get-source").Url); @@ -91,6 +97,12 @@ private static async Task<string> HashAsync(string json) { var (generator, document) = await TestSpecs.CreateGeneratorWithSpecAsync(json); var navigation = generator.CreateNavigation("lifecycle", document); - return NavigationRenderModel.Create(navigation, [navigation], false, false, false).ContentHash; + return NavigationRenderModel.Create( + navigation, + [navigation], + isUsingNavigationDropdown: false, + isPrimaryNavEnabled: false, + isGlobalAssemblyBuild: false + ).ContentHash; } } From 467f28c9c2ae4bd36f267d0dd44e914a47779292 Mon Sep 17 00:00:00 2001 From: Jan Calanog <reakaleek@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:40:44 +0100 Subject: [PATCH 09/10] Strike through deprecated operations in search and shrink the badge to the chip's height A search card now treats a deprecated operation like its sidebar row: the title is struck through and dimmed and the method chip turns grey. The page's outlined Deprecated badge stays beside the title, sized like the method chip so it sits inside the title line instead of stretching the row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- docs/data/openapi/api-explorer.md | 2 +- .../NavigationSearch/SearchResultsList.tsx | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index e22f81cc94..d9f99178fa 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -18,7 +18,7 @@ The API title comes from `info.title` in the spec, so fix naming and description API search matches parameter and field names, including prefixes and qualified paths. The docs-wide search matches a field only by its exact name, and only for a word that contains `_` or `.`, so a plain word keeps ranking pages first. Direct operation and endpoint matches rank above field matches. An operation also matches the words of its operation id and path, split on dots, hyphens, underscores and case changes, and the name of its group, so `ml flush`, `flush ML job` and `_ml _flush` all find `POST /_ml/anomaly_detectors/{job_id}/_flush`. Combine an endpoint and a field, such as `_search knn`, to find the operation and its matching fields. The order does not matter: `knn /_search`, `/_search knn`, `knn search` and `search knn` read the same. A word with a leading slash, or an HTTP method word right before one, is always the endpoint; on its own a word such as `options` or `delete` may name a field. With two plain words the index tries both orders and keeps the one whose endpoint exists, so both halves must match. With three or more plain words the last word is the field. Each matching operation lists up to three matching fields beneath it, with their paths, types, and locations. Child rows match the field's own name; ancestor names and descriptions provide context without adding unrelated children. Select a field to open its anchor, including fields inside collapsed sections. Arrow keys move through operations and fields in order; Enter opens the selected row. -When a query names a path, such as `_search`, the result shows one route in place of the description: the method the page leads with and the path with the match highlighted. The route whose whole path is the query word wins, so `/_search` shows `/_search`; otherwise the operation's primary route shows when it contains the word, and an alternate route only when the primary does not. Every result uses the same bordered box, with matching fields grouped beneath the operation when present. Matches use the same yellow tint as the destination property's flash. Navigating to a field reveals it and flashes its name, including when search replaces the current page without a full reload. Each result's breadcrumb ends with the operation's group, the same group the page shows. A deprecated operation shows a red `Deprecated` badge beside its title in the results, the same badge as on its page. +When a query names a path, such as `_search`, the result shows one route in place of the description: the method the page leads with and the path with the match highlighted. The route whose whole path is the query word wins, so `/_search` shows `/_search`; otherwise the operation's primary route shows when it contains the word, and an alternate route only when the primary does not. Every result uses the same bordered box, with matching fields grouped beneath the operation when present. Matches use the same yellow tint as the destination property's flash. Navigating to a field reveals it and flashes its name, including when search replaces the current page without a full reload. Each result's breadcrumb ends with the operation's group, the same group the page shows. A deprecated operation is struck through and dimmed in the results, its method chip turns grey as in the sidebar, and the same outlined `Deprecated` badge as on its page sits beside the title at the method chip's height. The assembler indexes fields from the same property trees used to render operation pages. Fields of a repeated shape appear under its first listing. On the existing semantic index, the indexer adds the nested `api.properties` mapping before incremental synchronization. Property changes participate in the document hash, so a field update is synchronized even when the operation description stays the same. diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx index 1e85f81308..2fe5a40281 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx @@ -312,6 +312,12 @@ const SearchResultRow = ({ } } } + + ${ + result.deprecated + ? `.route-line .api-method { background-color: var(--color-grey-20); border-color: var(--color-grey-30); color: var(--color-grey-100); }` + : '' + } `} > <div @@ -439,6 +445,12 @@ const Title = ({ font-weight: ${euiTheme.font.weight.bold}; color: ${euiTheme.colors.textParagraph}; } + + ${ + deprecated + ? `& > span:first-child { text-decoration: line-through; opacity: 0.7; }` + : '' + } `} > <SanitizedHtmlContent htmlContent={text} /> @@ -448,11 +460,24 @@ const Title = ({ css={css` display: inline-block; margin-left: ${euiTheme.size.s}; - vertical-align: middle; + vertical-align: baseline; font-weight: ${euiTheme.font.weight.regular}; .applies { margin-right: 0; } + .applicable-info { + display: inline-flex; + align-items: center; + height: calc( + ${euiTheme.size.base} + ${euiTheme.size.xxs} + ); + padding: 0 ${euiTheme.size.s}; + line-height: 1; + } + .applicable-name { + font-size: 10px; + line-height: 1; + } `} > <span className="applies applies-inline"> From 9667e9799100d37371974189ed4a5ab470d5e0f4 Mon Sep 17 00:00:00 2001 From: Jan Calanog <reakaleek@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:45:16 +0100 Subject: [PATCH 10/10] Centre the search Deprecated badge on the title line The badge wrapper inherited the title's 24px line height and aligned its content to the bottom of that box, so the badge sat 3.5px below the text centre. As an atomic inline with its own line height it centres within 1px and still stops the row's hover underline at the title text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --- .../NavigationSearch/SearchResultsList.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx index 2fe5a40281..c06c397b39 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx @@ -458,11 +458,17 @@ const Title = ({ <span className="api-title-badges" css={css` - display: inline-block; + /* An atomic inline, so the row's hover underline stops at the title text. Middle alignment lands 1px low beside the bold title. */ + display: inline-flex; + align-items: center; + position: relative; + top: -1px; margin-left: ${euiTheme.size.s}; - vertical-align: baseline; + vertical-align: middle; + line-height: 1; font-weight: ${euiTheme.font.weight.regular}; .applies { + display: inline-flex; margin-right: 0; } .applicable-info {