From 1d628d17141c7ae08520c547f1d575eba9b151e0 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 14 Sep 2026 12:20:48 +0200 Subject: [PATCH 1/3] Derive page titles from product metadata Use a single declared product as the SEO qualifier while keeping the H1 as the visible title and the manual override available as a final fallback. Co-Authored-By: GPT-5.6 Sol --- docs/syntax/titles.md | 36 +++-- .../Infrastructure/ApiViewModel.cs | 4 +- .../Operations/OperationViewModel.cs | 11 ++ src/Elastic.Markdown/Page/Index.cshtml | 2 +- src/Elastic.Markdown/Page/IndexViewModel.cs | 16 ++ .../OpenApiGeneratorMarkdownEmissionTests.cs | 46 +++++- .../Elastic.Markdown.Tests/PageTitleTests.cs | 148 ++++++++++++++++++ 7 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 tests/Elastic.Markdown.Tests/PageTitleTests.cs diff --git a/docs/syntax/titles.md b/docs/syntax/titles.md index ab8ffb0a0b..40769d376d 100644 --- a/docs/syntax/titles.md +++ b/docs/syntax/titles.md @@ -4,28 +4,46 @@ navigation_title: Title # Page title -### Syntax +## Syntax -Each page is required to at least define a level one heading. +Each page must define a level-one heading. ```markdown # This is my title ``` -This title is used both by the documentation navigation +The heading supplies the visible page title, link text, and the default HTML page title. +For public Elastic Docs builds, the builder appends `| Elastic Docs` to the HTML title. -* Left hand site -* Navigational elements such as breadcrumbs and previous/next links. - -As well as when using the [auto text links](./links.md#same-page-links-anchors), e.g: +When frontmatter identifies exactly one product and the heading does not already contain +that product's display name, the builder adds the product name automatically: ```markdown -[](titles.md) +--- +products: + - id: elasticsearch +--- + +# Query DSL ``` -Generated link text: [](titles.md) +The resulting HTML title is `Query DSL - Elasticsearch | Elastic Docs`, while the +visible heading remains `Query DSL`. Pages associated with multiple products keep the +heading as their default HTML title because the builder cannot choose one product keyword. + +API operation pages use the related form `{H1} - {Product} API | Elastic Docs`. +The heading is also used by: + +* The left navigation. +* Navigational elements, such as breadcrumbs and previous and next links. +* [Automatic link text](./links.md#same-page-links-anchors). + +```markdown +[](titles.md) +``` +Generated link text: [](titles.md). ```markdown --- diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index 6ce5455f1a..14652e56d2 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -77,7 +77,9 @@ public ApiLayoutViewModel CreateGlobalLayoutModel() { var docTitle = Document.Info?.Title ?? "API Documentation"; var pageTitle = LayoutPageTitle; - var documentTitle = pageTitle is not null ? $"{pageTitle} | {docTitle}" : docTitle; + var documentTitle = BuildContext.BuildType == BuildType.Assembler && BuildContext.Configuration.Branding is null + ? $"{pageTitle ?? docTitle} | Elastic Docs" + : pageTitle is not null ? $"{pageTitle} | {docTitle}" : docTitle; return new() { diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs index 22880fb562..4b0bb8887d 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs @@ -26,6 +26,17 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context public IReadOnlyList PrerequisiteNames => [.. (Prerequisites ?? []).Select(static r => r.Label).Where(static l => l.Length > 0)]; + protected override string? LayoutPageTitle + { + get + { + var title = string.IsNullOrWhiteSpace(Operation.Operation.Summary) + ? CurrentNavigationItem.NavigationTitle + : Operation.Operation.Summary; + return RenderContext.Product?.DisplayName is { Length: > 0 } product ? $"{title} - {product} API" : title; + } + } + protected override string BreadcrumbCurrentTitle => Operation.Operation.Summary ?? CurrentNavigationItem.NavigationTitle; protected override IReadOnlyList GetTocItems() diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index 39c9776b28..cdbcb80818 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -16,7 +16,7 @@ Layout = Model.CurrentDocument.YamlFrontMatter?.Layout, RenderHamburgerIcon = Model.CurrentDocument.YamlFrontMatter?.Layout != MarkdownPageLayout.LandingPage, DocSetName = Model.DocSetName, - Title = $"{Model.Title} | {Model.SiteName}", + Title = Model.PageTitle, Description = Model.Description, PageTocItems = Model.PageTocItems.Where(i => i is { diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index 729e9d3a0a..d89496702c 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -25,6 +25,22 @@ public class IndexViewModel public required string SiteName { get; init; } public required string DocSetName { get; init; } public required string Title { get; init; } + public string PageTitle + { + get + { + var title = Title; + if (CurrentDocument.YamlFrontMatter?.Products is { Count: 1 } products) + { + var productName = products.First().DisplayName; + if (!title.Contains(productName, StringComparison.OrdinalIgnoreCase)) + title = $"{title} - {productName}"; + } + + var suffix = BuildType == BuildType.Assembler && Branding is null ? "Elastic Docs" : SiteName; + return $"{title} | {suffix}"; + } + } public required string Description { get; init; } public required string TitleRaw { get; init; } public required string MarkdownHtml { get; init; } diff --git a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs index 8c8093d226..b00ac687aa 100644 --- a/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OpenApiGeneratorMarkdownEmissionTests.cs @@ -51,6 +51,39 @@ public async Task Generate_WritesSiblingMarkdownForEveryRenderedPage() write.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "group", "endpoint-search.md")).Should().BeTrue(); write.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "operation", "operation-search.md")).Should().BeTrue(); write.Exists(Path.Join(outputRoot, "api", "doc", "elasticsearch", "types", "_types-query_dsl-querycontainer.md")).Should().BeTrue(); + + var operationHtml = await write.ReadAllTextAsync( + Path.Join(outputRoot, "api", "doc", "elasticsearch", "operation", "operation-search", "index.html"), + TestContext.Current.CancellationToken + ); + operationHtml.Should().Contain("Run a search - Elasticsearch API | Elastic Docs"); + operationHtml.Should().Contain(" Task.CompletedTask); + var generator = new OpenApiGenerator( + NullLoggerFactory.Instance, + context, + PassthroughMarkdownRenderer.Instance, + versionIndexClient, + CreateSequentialReader(fixture.Document) + ); + + await generator.Generate(TestContext.Current.CancellationToken); + + var operationHtml = await context + .WriteFileSystem + .File + .ReadAllTextAsync( + Path.Join(outputRoot, "api", "doc", "elasticsearch", "operation", "operation-search", "index.html"), + TestContext.Current.CancellationToken + ); + operationHtml.Should().Contain("Run a search - Elasticsearch API | Fixture API"); } [Fact] @@ -91,7 +124,7 @@ public async Task Generate_WritesReadableCommonMarkNotHtmlDocument() landing.Should().Contain("title: Fixture API"); landing.Should().Contain("url: /api/doc/elasticsearch"); landing.Should().Contain("resource: /api/doc/elasticsearch"); - landing.Should().Contain(" - elasticsearch"); + landing.Should().Contain(" - Elasticsearch"); landing.Should().NotContain("applies_to:"); landing.Should().Contain("# Fixture API"); landing.Should().Contain("Search APIs"); @@ -190,11 +223,15 @@ public async Task SimpleMarkdownPage_WritesAuthoredSource() wrapped.Should().NotContain("Query DSL - Elasticsearch | Elastic Docs"); + html.Should().Contain("Query DSL"); + } + + [Fact] + public async Task GenerateAll_ProductAlreadyInH1_DoesNotAppendProductName() + { + var html = await Generate( + BuildType.Assembler, + """ + --- + products: + - id: elasticsearch + --- + + # Elasticsearch query DSL + """ + ); + + html.Should().Contain("Elasticsearch query DSL | Elastic Docs"); + } + + [Fact] + public async Task GenerateAll_MultipleProducts_DoesNotChooseAProductName() + { + var html = await Generate( + BuildType.Assembler, + """ + --- + products: + - id: elasticsearch + - id: kibana + --- + + # Query languages + """ + ); + + html.Should().Contain("Query languages | Elastic Docs"); + } + + [Fact] + public async Task GenerateAll_BrandedAssemblerBuild_KeepsExistingSuffix() + { + var html = await Generate( + BuildType.Assembler, + """ + --- + products: + - id: elasticsearch + --- + + # Query DSL + """, + branded: true + ); + + html.Should().Contain("Query DSL - Elasticsearch | Query DSL"); + html.Should().NotContain("| Elastic Docs"); + } + + [Fact] + public async Task GenerateAll_IsolatedBuild_KeepsExistingSuffix() + { + var html = await Generate( + BuildType.Isolated, + """ + --- + products: + - id: elasticsearch + --- + + # Query DSL + """ + ); + + html.Should().Contain("Query DSL - Elasticsearch | Query DSL"); + html.Should().NotContain("| Elastic Docs"); + } + + private async Task Generate(BuildType buildType, string markdown, bool branded = false) + { + var branding = branded ? """ + branding: + icon: assets/logo.svg + """ : string.Empty; + var fileSystem = new MockFileSystem( + new Dictionary + { + ["docs/docset.yml"] = new( + $""" + project: test + toc: + - file: index.md + {branding} + """ + ), + ["docs/index.md"] = new(markdown), + ["docs/assets/logo.svg"] = new("") + }, + new MockFileSystemOptions { CurrentDirectory = Paths.WorkingDirectoryRoot.FullName } + ); + await using var collector = new DiagnosticsCollector([]).StartAsync(TestContext.Current.CancellationToken); + var configurationContext = TestHelpers.CreateConfigurationContext(fileSystem); + var context = new BuildContext(collector, TestHelpers.CreateDocumentationFileSystem(fileSystem), configurationContext) + { + BuildType = buildType + }; + var set = new DocumentationSet(context, new TestLoggerFactory(output), new TestCrossLinkResolver()); + var generator = new DocumentationGenerator(set, new TestLoggerFactory(output)); + + await generator.GenerateAll(TestContext.Current.CancellationToken); + await collector.StopAsync(TestContext.Current.CancellationToken); + + return fileSystem.File.ReadAllText(Path.Join(set.OutputDirectory.FullName, "index.html")); + } +} From 099fc9999360ca9d2c4ac17639c56636a2fd4558 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Mon, 14 Sep 2026 12:43:44 +0200 Subject: [PATCH 2/3] Align Pagefind with derived page titles Keep search result metadata consistent with the browser and Open Graph titles when product context is added automatically. Co-Authored-By: GPT-5.6 Sol --- src/Elastic.Markdown/Page/Index.cshtml | 2 +- tests/Elastic.Markdown.Tests/PageTitleTests.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index cdbcb80818..77370770a4 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -72,7 +72,7 @@ } - +