Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions docs/syntax/titles.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,48 @@ 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 the page resolves to exactly one product and the heading does not already contain
that product's display name, the builder adds the product name automatically. Product
resolution merges page frontmatter with docset, repository, `applies_to`, and `mapped_pages`
metadata:

```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
---
Expand Down
4 changes: 3 additions & 1 deletion src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
11 changes: 11 additions & 0 deletions src/Elastic.ApiExplorer/Operations/OperationViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context
public IReadOnlyList<string> 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<ApiTocItem> GetTocItems()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Elastic.Markdown.Helpers;
using Elastic.Markdown.IO;
using Elastic.Markdown.Myst.InlineParsers;
using Elastic.Markdown.Page;
using Markdig.Syntax;
using Microsoft.Extensions.Logging;
using Pagefind.Net;
Expand Down Expand Up @@ -55,7 +56,23 @@ public ValueTask<bool> ExportAsync(MarkdownExportFileContext fileContext, Cancel
var parents = navigation.GetParentsOfMarkdownFile(file).Reverse().ToArray();
var breadcrumbsMeta = BuildBreadcrumbsMeta(parents, fileContext.BuildContext.CanonicalBaseUrl);

var meta = new Dictionary<string, string> { ["title"] = file.Title ?? url };
var inference = fileContext.InferenceService.InferForMarkdown(
fileContext.BuildContext.Git.RepositoryName,
file.YamlFrontMatter?.MappedPages,
fileContext.DocumentationSet.Configuration.Products,
file.YamlFrontMatter?.Products,
file.YamlFrontMatter?.AppliesTo
);
var title = PageTitleResolver.Resolve(
file.Title ?? url,
inference.RelatedProducts,
new(
fileContext.BuildContext.BuildType,
fileContext.DocumentationSet.Configuration.Branding,
fileContext.DocumentationSet.Navigation.NavigationTitle
)
);
var meta = new Dictionary<string, string> { ["title"] = title };
if (!string.IsNullOrEmpty(breadcrumbsMeta))
meta["breadcrumbs"] = breadcrumbsMeta;

Expand Down
4 changes: 2 additions & 2 deletions src/Elastic.Markdown/Page/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
theletterf marked this conversation as resolved.
Description = Model.Description,
PageTocItems = Model.PageTocItems.Where(i => i is
{
Expand Down Expand Up @@ -72,7 +72,7 @@
<meta name="DC.identifier" content="@(new HtmlString(Model.CurrentVersion))"/>
}
<link rel="alternate" type="text/markdown" href="@(Model.MarkdownUrl)" title="Markdown export"/>
<meta data-pagefind-meta="title[content]" content="@Model.Title"/>
<meta data-pagefind-meta="title[content]" content="@Model.PageTitle"/>
<meta data-pagefind-meta="breadcrumbs[content]" content="@Model.StructuredBreadcrumbsJson"/>
<script type="application/ld+json">
@(new HtmlString(Model.StructuredBreadcrumbsJson))
Expand Down
1 change: 1 addition & 0 deletions src/Elastic.Markdown/Page/IndexViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class IndexViewModel
public required string SiteName { get; init; }
public required string DocSetName { get; init; }
public required string Title { get; init; }
public string PageTitle => PageTitleResolver.Resolve(Title, Products, new(BuildType, Branding, SiteName));
public required string Description { get; init; }
public required string TitleRaw { get; init; }
public required string MarkdownHtml { get; init; }
Expand Down
27 changes: 27 additions & 0 deletions src/Elastic.Markdown/Page/PageTitleResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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 Elastic.Documentation;
using Elastic.Documentation.Configuration.Products;
using Elastic.Documentation.Configuration.Toc;

namespace Elastic.Markdown.Page;

internal readonly record struct PageTitleOptions(BuildType BuildType, BrandingConfiguration? Branding, string SiteName);

internal static class PageTitleResolver
{
public static string Resolve(string title, IReadOnlyCollection<Product> products, PageTitleOptions options)
{
if (products is { Count: 1 })
{
var productName = products.First().DisplayName;
if (!title.Contains(productName, StringComparison.OrdinalIgnoreCase))
title = $"{title} - {productName}";
}

var suffix = options.BuildType == BuildType.Assembler && options.Branding is null ? "Elastic Docs" : options.SiteName;
return $"{title} | {suffix}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("<title>Run a search - Elasticsearch API | Elastic Docs</title>");
operationHtml.Should().Contain("<meta property=\"og:title\" content=\"Run a search - Elasticsearch API | Elastic Docs\"");
}

[Fact]
public async Task Generate_IsolatedOperationPage_KeepsProductSuffix()
{
var outputRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, $"api-title-{Guid.NewGuid():N}");
var context = CreateGenerateContext(outputRoot, BuildType.Isolated);
using var versionIndexClient = new VersionIndexClient(BaseUri, MainOnlyHandler(), sleep: (_, _) => 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("<title>Run a search - Elasticsearch API | Fixture API</title>");
}

[Fact]
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -190,11 +223,15 @@ public async Task SimpleMarkdownPage_WritesAuthoredSource()
wrapped.Should().NotContain("<!DOCTYPE");
}

private static BuildContext CreateGenerateContext(string outputRoot)
private static BuildContext CreateGenerateContext(string outputRoot, BuildType buildType = BuildType.Assembler)
{
var collector = new DiagnosticsCollector([]);
var stack = TestHelpers.CreateStackVersionsConfiguration(currentMajor: 9);
var product = TestHelpers.CreateProduct("elasticsearch", stack.GetVersioningSystem(VersioningSystemId.Stack));
var product = TestHelpers.CreateProduct(
"elasticsearch",
stack.GetVersioningSystem(VersioningSystemId.Stack),
displayName: "Elasticsearch"
);
var repoRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, $"api-md-repo-{Guid.NewGuid():N}");
var configPath = Path.Join(repoRoot, "docs", "docset.yml");
var docsetYaml =
Expand Down Expand Up @@ -235,7 +272,8 @@ private static BuildContext CreateGenerateContext(string outputRoot)
}
),
configurationContext
);
)
{ BuildType = buildType };
}

private static IOpenApiSpecificationReader CreateSequentialReader(params OpenApiDocument[] documents)
Expand Down
146 changes: 146 additions & 0 deletions tests/Elastic.Markdown.Tests/PageTitleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.IO.Abstractions.TestingHelpers;
using AwesomeAssertions;
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;
using Elastic.Markdown.IO;

namespace Elastic.Markdown.Tests;

public class PageTitleTests(ITestOutputHelper output)
{
[Fact]
public async Task GenerateAll_DocsetProductMissingFromH1_AppendsInferredProductName()
{
var html = await Generate(BuildType.Assembler, "# Query DSL", docsetProduct: "elasticsearch");

html.Should().Contain("<title>Query DSL - Elasticsearch | Elastic Docs</title>");
html.Should().Contain("<meta property=\"og:title\" content=\"Query DSL - Elasticsearch | Elastic Docs\"");
html.Should().Contain("<meta data-pagefind-meta=\"title[content]\" content=\"Query DSL - Elasticsearch | Elastic Docs\"");
html.Should().Contain("<h1>Query DSL</h1>");
}

[Fact]
public async Task GenerateAll_ProductAlreadyInH1_DoesNotAppendProductName()
{
var html = await Generate(
BuildType.Assembler,
"""
---
products:
- id: elasticsearch
---

# Elasticsearch query DSL
"""
);

html.Should().Contain("<title>Elasticsearch query DSL | Elastic Docs</title>");
}

[Fact]
public async Task GenerateAll_MultipleProducts_DoesNotChooseAProductName()
{
var html = await Generate(
BuildType.Assembler,
"""
---
products:
- id: elasticsearch
- id: kibana
---

# Query languages
"""
);

html.Should().Contain("<title>Query languages | Elastic Docs</title>");
}

[Fact]
public async Task GenerateAll_BrandedAssemblerBuild_KeepsExistingSuffix()
{
var html = await Generate(
BuildType.Assembler,
"""
---
products:
- id: elasticsearch
---

# Query DSL
""",
branded: true
);

html.Should().Contain("<title>Query DSL - Elasticsearch | Query DSL</title>");
html.Should().NotContain("| Elastic Docs</title>");
}

[Fact]
public async Task GenerateAll_IsolatedBuild_KeepsExistingSuffix()
{
var html = await Generate(
BuildType.Isolated,
"""
---
products:
- id: elasticsearch
---

# Query DSL
"""
);

html.Should().Contain("<title>Query DSL - Elasticsearch | Query DSL</title>");
html.Should().NotContain("| Elastic Docs</title>");
}

private async Task<string> Generate(BuildType buildType, string markdown, bool branded = false, string? docsetProduct = null)
{
var branding = branded ? """
branding:
icon: assets/logo.svg
""" : string.Empty;
var products = docsetProduct is null
? string.Empty
: $"""
products:
- id: {docsetProduct}
""";
var fileSystem = new MockFileSystem(
new Dictionary<string, MockFileData>
{
["docs/docset.yml"] = new(
$"""
project: test
{products}
toc:
- file: index.md
{branding}
"""
),
["docs/index.md"] = new(markdown),
["docs/assets/logo.svg"] = new("<svg/>")
},
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"));
}
}
Loading