From a07c7d87638bf113dba7ac0ff6ce699048b50ebb Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Mon, 19 Oct 2026 17:05:44 +0200 Subject: [PATCH 1/4] Show a readable title for API operations that fall back to the operationId A sidebar link titled `get-actions-connector-oauth-callback-script` is hard to scan. The fallback now replaces hyphens and underscores with spaces and capitalises the first letter. Co-Authored-By: Claude Sonnet 5.5 --- .../Model/OpenApiExtensionReader.cs | 10 ++++++++++ .../Navigation/ApiNavigationBuilder.cs | 7 ++++--- .../DashboardOpenApiNavigationTests.cs | 4 ++-- .../Elastic.ApiExplorer.Tests/OperationSummaryTests.cs | 8 ++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs index c329d83da9..a483581d77 100644 --- a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs +++ b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs @@ -43,6 +43,16 @@ public static (string? Namespace, string? ApiName) GetNamespaceAndApiName(OpenAp public static string? GetSummary(OpenApiOperation operation) => string.IsNullOrWhiteSpace(operation.Summary) ? null : operation.Summary.Trim(); + /// Turns a kebab- or snake-case operationId into a readable title ("get-actions-connector" becomes "Get actions connector"); null when blank. + public static string? HumanizeOperationId(string? operationId) + { + if (string.IsNullOrWhiteSpace(operationId)) + return null; + + var text = operationId.Trim().Replace('-', ' ').Replace('_', ' '); + return char.ToUpperInvariant(text[0]) + text[1..]; + } + /// Whether the operation is marked x-beta: true. public static bool IsBeta(OpenApiOperation operation) => operation.Extensions?.TryGetValue("x-beta", out var betaValue) == true diff --git a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs index 98b4dd08a3..3a98390d1a 100644 --- a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs +++ b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs @@ -55,7 +55,10 @@ public LandingNavigationItem CreateNavigation( // Fall back to the operationId, then a deterministic route:method key; Guid.NewGuid() made grouping keys // (and thus navigation titles) change on every build. var apiString = ns is null - ? api ?? OpenApiExtensionReader.GetSummary(op.Value) ?? NullIfBlank(op.Value.OperationId) ?? $"{pair.Path.Key}:{op.Key}" + ? api + ?? OpenApiExtensionReader.GetSummary(op.Value) + ?? OpenApiExtensionReader.HumanizeOperationId(op.Value.OperationId) + ?? $"{pair.Path.Key}:{op.Key}" : $"{ns}.{api}"; return new { Classification = tagClassification, Api = apiString, Tag = tag, pair.Path, pair.Operation }; }) @@ -394,8 +397,6 @@ private string ResolveTagClassification(string? tag, XTagGroups? xTagGroups, Has return UnknownTagGroupName; } - private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; - private static List GetOrderedClassificationKeys(XTagGroups xTagGroups, HashSet presentClassifications) { var ordered = new List(); diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index dbb3dd1131..1315374876 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -45,7 +45,7 @@ public async Task CreateNavigation_SingleTagOpenApiSpec_HasSidebarItems() } [Test] - public async Task CreateNavigation_BlankSummary_TitlesOperationByOperationId() + public async Task CreateNavigation_BlankSummary_TitlesOperationByHumanizedOperationId() { var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); var context = new BuildContext( @@ -61,7 +61,7 @@ public async Task CreateNavigation_BlankSummary_TitlesOperationByOperationId() var navigation = generator.CreateNavigation("blank", doc); var titles = Flatten(navigation.NavigationItems).OfType().Select(o => o.NavigationTitle).ToArray(); - titles.Should().ContainSingle().Which.Should().Be("get-script"); + titles.Should().ContainSingle().Which.Should().Be("Get script"); } private static IEnumerable Flatten(IEnumerable items) diff --git a/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs index f83e594f11..07b175c31a 100644 --- a/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs @@ -21,4 +21,12 @@ public void GetSummary_BlankOrPadded_ReturnsNullOrTrimmed(string? summary, strin OpenApiExtensionReader.GetSummary(operation).Should().Be(expected); } + + [Test] + [Arguments(null, null)] + [Arguments(" ", null)] + [Arguments("get-actions-connector-oauth-callback-script", "Get actions connector oauth callback script")] + [Arguments("async_search_delete", "Async search delete")] + public void HumanizeOperationId_KebabOrSnakeCase_ReturnsReadableTitle(string? operationId, string? expected) => + OpenApiExtensionReader.HumanizeOperationId(operationId).Should().Be(expected); } From 90947d4bb9d37e6029d9a11d89d7ca96d97ae439 Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Mon, 19 Oct 2026 17:08:00 +0200 Subject: [PATCH 2/4] Reuse the existing slug title-caser for operationId titles The navigation title reader already turns kebab-case names into titles. The operationId fallback now shares that code instead of a second helper. Co-Authored-By: Claude Sonnet 5.5 --- .../Model/OpenApiExtensionReader.cs | 10 ---------- .../Navigation/ApiNavigationBuilder.cs | 5 ++++- .../MarkdownNavigationTitleReader.cs | 20 +++++++++++-------- .../DashboardOpenApiNavigationTests.cs | 2 +- .../OperationSummaryTests.cs | 8 -------- 5 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs index a483581d77..c329d83da9 100644 --- a/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs +++ b/src/Elastic.ApiExplorer/Model/OpenApiExtensionReader.cs @@ -43,16 +43,6 @@ public static (string? Namespace, string? ApiName) GetNamespaceAndApiName(OpenAp public static string? GetSummary(OpenApiOperation operation) => string.IsNullOrWhiteSpace(operation.Summary) ? null : operation.Summary.Trim(); - /// Turns a kebab- or snake-case operationId into a readable title ("get-actions-connector" becomes "Get actions connector"); null when blank. - public static string? HumanizeOperationId(string? operationId) - { - if (string.IsNullOrWhiteSpace(operationId)) - return null; - - var text = operationId.Trim().Replace('-', ' ').Replace('_', ' '); - return char.ToUpperInvariant(text[0]) + text[1..]; - } - /// Whether the operation is marked x-beta: true. public static bool IsBeta(OpenApiOperation operation) => operation.Extensions?.TryGetValue("x-beta", out var betaValue) == true diff --git a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs index 3a98390d1a..8ae408e25d 100644 --- a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs +++ b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs @@ -57,7 +57,7 @@ public LandingNavigationItem CreateNavigation( var apiString = ns is null ? api ?? OpenApiExtensionReader.GetSummary(op.Value) - ?? OpenApiExtensionReader.HumanizeOperationId(op.Value.OperationId) + ?? OperationIdTitle(op.Value.OperationId) ?? $"{pair.Path.Key}:{op.Key}" : $"{ns}.{api}"; return new { Classification = tagClassification, Api = apiString, Tag = tag, pair.Path, pair.Operation }; @@ -397,6 +397,9 @@ private string ResolveTagClassification(string? tag, XTagGroups? xTagGroups, Has return UnknownTagGroupName; } + private static string? OperationIdTitle(string? operationId) => + string.IsNullOrWhiteSpace(operationId) ? null : MarkdownNavigationTitleReader.TitleCaseFromSlug(operationId.Trim()); + private static List GetOrderedClassificationKeys(XTagGroups xTagGroups, HashSet presentClassifications) { var ordered = new List(); diff --git a/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs b/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs index 15094962d2..88e178763d 100644 --- a/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs +++ b/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs @@ -57,12 +57,16 @@ public static string GetNavigationTitle(IFileSystem readFileSystem, IFileInfo ma } /// Converts kebab-case/snake_case file names to a title-cased navigation title. - internal static string TitleCaseFromFileName(string fileName) => - Path - .GetFileNameWithoutExtension(fileName) - .Replace('-', ' ') - .Replace('_', ' ') - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(word => char.ToUpper(word[0]) + word[1..].ToLower()) - .Aggregate((current, next) => $"{current} {next}"); + internal static string TitleCaseFromFileName(string fileName) => TitleCaseFromSlug(Path.GetFileNameWithoutExtension(fileName)); + + /// Converts a kebab-case/snake_case identifier to a title-cased string. + internal static string TitleCaseFromSlug(string slug) => + string.Join( + ' ', + slug + .Replace('-', ' ') + .Replace('_', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(word => char.ToUpper(word[0]) + word[1..].ToLower()) + ); } diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index 1315374876..7524b54a4f 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -61,7 +61,7 @@ public async Task CreateNavigation_BlankSummary_TitlesOperationByHumanizedOperat var navigation = generator.CreateNavigation("blank", doc); var titles = Flatten(navigation.NavigationItems).OfType().Select(o => o.NavigationTitle).ToArray(); - titles.Should().ContainSingle().Which.Should().Be("Get script"); + titles.Should().ContainSingle().Which.Should().Be("Get Script"); } private static IEnumerable Flatten(IEnumerable items) diff --git a/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs index 07b175c31a..f83e594f11 100644 --- a/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OperationSummaryTests.cs @@ -21,12 +21,4 @@ public void GetSummary_BlankOrPadded_ReturnsNullOrTrimmed(string? summary, strin OpenApiExtensionReader.GetSummary(operation).Should().Be(expected); } - - [Test] - [Arguments(null, null)] - [Arguments(" ", null)] - [Arguments("get-actions-connector-oauth-callback-script", "Get actions connector oauth callback script")] - [Arguments("async_search_delete", "Async search delete")] - public void HumanizeOperationId_KebabOrSnakeCase_ReturnsReadableTitle(string? operationId, string? expected) => - OpenApiExtensionReader.HumanizeOperationId(operationId).Should().Be(expected); } From 0f4cec920bc8e8a3ed549df9b117ad0c1ae3bd1d Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Mon, 19 Oct 2026 17:10:43 +0200 Subject: [PATCH 3/4] Keep the raw operationId as the endpoint grouping key Humanized titles of distinct ids such as `get-script` and `get_script` are equal, so grouping by title could merge two unrelated operations into one page. The grouping key stays the raw id and only the displayed title is humanized. Co-Authored-By: Claude Sonnet 5.5 --- .../Navigation/ApiNavigationBuilder.cs | 9 +++----- .../Operations/OperationCommonMark.cs | 2 +- .../Operations/OperationNavigationItem.cs | 9 +++++++- .../DashboardOpenApiNavigationTests.cs | 23 +++++++++++++++++++ .../colliding-operation-ids-openapi.json | 22 ++++++++++++++++++ 5 files changed, 57 insertions(+), 8 deletions(-) create mode 100644 tests/Elastic.ApiExplorer.Tests/TestData/colliding-operation-ids-openapi.json diff --git a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs index 8ae408e25d..ea662b333f 100644 --- a/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs +++ b/src/Elastic.ApiExplorer/Navigation/ApiNavigationBuilder.cs @@ -55,10 +55,7 @@ public LandingNavigationItem CreateNavigation( // Fall back to the operationId, then a deterministic route:method key; Guid.NewGuid() made grouping keys // (and thus navigation titles) change on every build. var apiString = ns is null - ? api - ?? OpenApiExtensionReader.GetSummary(op.Value) - ?? OperationIdTitle(op.Value.OperationId) - ?? $"{pair.Path.Key}:{op.Key}" + ? api ?? OpenApiExtensionReader.GetSummary(op.Value) ?? NullIfBlank(op.Value.OperationId) ?? $"{pair.Path.Key}:{op.Key}" : $"{ns}.{api}"; return new { Classification = tagClassification, Api = apiString, Tag = tag, pair.Path, pair.Operation }; }) @@ -397,8 +394,8 @@ private string ResolveTagClassification(string? tag, XTagGroups? xTagGroups, Has return UnknownTagGroupName; } - private static string? OperationIdTitle(string? operationId) => - string.IsNullOrWhiteSpace(operationId) ? null : MarkdownNavigationTitleReader.TitleCaseFromSlug(operationId.Trim()); + // The raw operationId stays the grouping key: humanized titles of distinct ids (get-script, get_script) can collide. + private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; private static List GetOrderedClassificationKeys(XTagGroups xTagGroups, HashSet presentClassifications) { diff --git a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs index cc3b4f071b..03cd57eb5b 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs @@ -17,7 +17,7 @@ public static string Write(ApiOperation apiOperation, OperationPageModel page, A var markdown = new StringBuilder(); var apiBaseUrl = context.CurrentNavigation.NavigationRoot.Url; var operation = apiOperation.Operation; - var title = OpenApiExtensionReader.GetSummary(operation) ?? apiOperation.ApiName; + var title = OpenApiExtensionReader.GetSummary(operation) ?? apiOperation.DisplayName; ApiCommonMark.Heading(markdown, 1, title); WriteBadges(markdown, operation, page); WriteServers(markdown, page); diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 6b4f5e8b1d..ee7aaeea73 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -6,6 +6,7 @@ using Elastic.ApiExplorer.Infrastructure; using Elastic.ApiExplorer.Landing; using Elastic.ApiExplorer.Model; +using Elastic.ApiExplorer.Navigation; using Elastic.Documentation.Extensions; using Elastic.Documentation.Navigation; using Microsoft.OpenApi; @@ -25,6 +26,12 @@ string ApiName // rather than the spec; the sidebar badge must agree with it. string IHttpMethodNavigationModel.HttpMethod => OperationEndpoint.Build(this, Operation.Description, []).Rows[0].Method; + /// The title shown for the operation; an operationId used as the grouping key is shown as readable words. + public string DisplayName => + !string.IsNullOrWhiteSpace(Operation.OperationId) && string.Equals(ApiName, Operation.OperationId, StringComparison.Ordinal) + ? MarkdownNavigationTitleReader.TitleCaseFromSlug(ApiName) + : ApiName; + /// /// The operation's parameters and the path item's, which apply to every operation on the path unless the operation /// declares one of the same name and location itself. @@ -70,7 +77,7 @@ public OperationNavigationItem( { NavigationRoot = root; Model = apiOperation; - NavigationTitle = apiOperation.ApiName; + NavigationTitle = apiOperation.DisplayName; Parent = parent; Url = ApiUrlBuilder.OperationUrl( urlPathPrefix, diff --git a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs index 7524b54a4f..dbf1f1d7e7 100644 --- a/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/DashboardOpenApiNavigationTests.cs @@ -64,6 +64,29 @@ public async Task CreateNavigation_BlankSummary_TitlesOperationByHumanizedOperat titles.Should().ContainSingle().Which.Should().Be("Get Script"); } + [Test] + public async Task CreateNavigation_OperationIdsWithTheSameTitle_StaySeparatePages() + { + var configurationContext = TestHelpers.CreateConfigurationContext(new FileSystem()); + var context = new BuildContext( + new DiagnosticsCollector([]), + DocumentationFileSystem.Resolve(Paths.WorkingDirectoryRoot.FullName), + configurationContext + ); + var fs = new FileSystem(); + var fi = fs.FileInfo.New(fs.Path.Combine(AppContext.BaseDirectory, "TestData", "colliding-operation-ids-openapi.json")); + var doc = await OpenApiReader.Instance.ReadAsync(fi); + + var generator = new OpenApiGenerator(NullLoggerFactory.Instance, context, NoopMarkdownStringRenderer.Instance); + var navigation = generator.CreateNavigation("colliding", doc); + + var operations = Flatten(navigation.NavigationItems).OfType().ToArray(); + operations.Should().HaveCount(2); + operations.Select(o => o.Url).Should().OnlyHaveUniqueItems(); + operations.Should().OnlyContain(o => o.Siblings.Count == 0); + operations.Select(o => o.NavigationTitle).Should().AllBe("Get Script"); + } + private static IEnumerable Flatten(IEnumerable items) { foreach (var item in items) diff --git a/tests/Elastic.ApiExplorer.Tests/TestData/colliding-operation-ids-openapi.json b/tests/Elastic.ApiExplorer.Tests/TestData/colliding-operation-ids-openapi.json new file mode 100644 index 0000000000..be596f3d29 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/TestData/colliding-operation-ids-openapi.json @@ -0,0 +1,22 @@ +{ + "openapi": "3.0.3", + "info": { "title": "Colliding ids", "version": "1.0.0" }, + "paths": { + "/api/first": { + "get": { + "operationId": "get-script", + "tags": ["connectors"], + "description": "Returns the first script.", + "responses": { "200": { "description": "OK" } } + } + }, + "/api/second": { + "get": { + "operationId": "get_script", + "tags": ["connectors"], + "description": "Returns the second script.", + "responses": { "200": { "description": "OK" } } + } + } + } +} From a682640061ae8bc03399870384104b3442eb5a4b Mon Sep 17 00:00:00 2001 From: Andrea De Pirro Date: Mon, 19 Oct 2026 17:12:12 +0200 Subject: [PATCH 4/4] Move the slug title-caser to the shared string extensions The helper is general, so it lives next to the other string extensions in Elastic.Documentation instead of in the API explorer's navigation reader. It now uses invariant culture. Co-Authored-By: Claude Sonnet 5.5 --- .../MarkdownNavigationTitleReader.cs | 14 ++------------ .../Operations/OperationNavigationItem.cs | 3 +-- .../Extensions/StringExtensions.cs | 11 +++++++++++ .../StringExtensionsTests.cs | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 tests/Elastic.Documentation.Tests/StringExtensionsTests.cs diff --git a/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs b/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs index 88e178763d..46def3ea33 100644 --- a/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs +++ b/src/Elastic.ApiExplorer/Navigation/MarkdownNavigationTitleReader.cs @@ -5,6 +5,7 @@ using System.IO.Abstractions; using Elastic.ApiExplorer.Model; using Elastic.ApiExplorer.Operations; +using Elastic.Documentation.Extensions; using YamlDotNet.RepresentationModel; namespace Elastic.ApiExplorer.Navigation; @@ -57,16 +58,5 @@ public static string GetNavigationTitle(IFileSystem readFileSystem, IFileInfo ma } /// Converts kebab-case/snake_case file names to a title-cased navigation title. - internal static string TitleCaseFromFileName(string fileName) => TitleCaseFromSlug(Path.GetFileNameWithoutExtension(fileName)); - - /// Converts a kebab-case/snake_case identifier to a title-cased string. - internal static string TitleCaseFromSlug(string slug) => - string.Join( - ' ', - slug - .Replace('-', ' ') - .Replace('_', ' ') - .Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Select(word => char.ToUpper(word[0]) + word[1..].ToLower()) - ); + internal static string TitleCaseFromFileName(string fileName) => Path.GetFileNameWithoutExtension(fileName).TitleCaseFromSlug(); } diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index ee7aaeea73..20bf93de87 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -6,7 +6,6 @@ using Elastic.ApiExplorer.Infrastructure; using Elastic.ApiExplorer.Landing; using Elastic.ApiExplorer.Model; -using Elastic.ApiExplorer.Navigation; using Elastic.Documentation.Extensions; using Elastic.Documentation.Navigation; using Microsoft.OpenApi; @@ -29,7 +28,7 @@ string ApiName /// The title shown for the operation; an operationId used as the grouping key is shown as readable words. public string DisplayName => !string.IsNullOrWhiteSpace(Operation.OperationId) && string.Equals(ApiName, Operation.OperationId, StringComparison.Ordinal) - ? MarkdownNavigationTitleReader.TitleCaseFromSlug(ApiName) + ? ApiName.TitleCaseFromSlug() : ApiName; /// diff --git a/src/Elastic.Documentation/Extensions/StringExtensions.cs b/src/Elastic.Documentation/Extensions/StringExtensions.cs index 5a5daf85c5..3a1628f37c 100644 --- a/src/Elastic.Documentation/Extensions/StringExtensions.cs +++ b/src/Elastic.Documentation/Extensions/StringExtensions.cs @@ -14,4 +14,15 @@ public static string OptionalWindowsReplace(this string relativePath) relativePath = relativePath.Replace('\\', '/'); return relativePath; } + + /// Converts a kebab-case/snake_case identifier to a title-cased string ("get-script" becomes "Get Script"). + public static string TitleCaseFromSlug(this string slug) => + string.Join( + ' ', + slug + .Replace('-', ' ') + .Replace('_', ' ') + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant()) + ); } diff --git a/tests/Elastic.Documentation.Tests/StringExtensionsTests.cs b/tests/Elastic.Documentation.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000000..619f067877 --- /dev/null +++ b/tests/Elastic.Documentation.Tests/StringExtensionsTests.cs @@ -0,0 +1,19 @@ +// 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.Extensions; + +namespace Elastic.Documentation.Tests; + +public class StringExtensionsTests +{ + [Test] + [Arguments("get-actions-connector-oauth-callback-script", "Get Actions Connector Oauth Callback Script")] + [Arguments("async_search_delete", "Async Search Delete")] + [Arguments("GET-Script", "Get Script")] + [Arguments("-", "")] + public void TitleCaseFromSlug_KebabOrSnakeCase_ReturnsTitleCasedWords(string slug, string expected) => + slug.TitleCaseFromSlug().Should().Be(expected); +}