diff --git a/Directory.Packages.props b/Directory.Packages.props
index d68e0a42a..fa35cb23a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,7 +15,7 @@
-
+
@@ -32,7 +32,7 @@
-
+
@@ -48,6 +48,7 @@
+
@@ -63,4 +64,4 @@
-
+
\ No newline at end of file
diff --git a/docs/syntax/frontmatter.md b/docs/syntax/frontmatter.md
index 7c7b5d52e..4e30c4d7e 100644
--- a/docs/syntax/frontmatter.md
+++ b/docs/syntax/frontmatter.md
@@ -12,23 +12,111 @@ navigation_title: This is the navigation title <1>
description: This is a description of the page <2>
applies_to: <3>
serverless: all
+products: <4>
+ - apm-java-agent
+ - edot-java
---
```
+
1. [`navigation_title`](#navigation-title)
2. [`description`](#description)
3. [`applies_to`](#applies-to)
+4. [`products`](#products)
## Navigation Title
+
See [](./titles.md)
## Description
-Use the `description` frontmatter to set the description meta tag for a page.
+Use the `description` frontmatter to set the description meta tag for a page.
This helps search engines and social media.
It also sets the `og:description` and `twitter:description` meta tags.
-The `description` frontmatter is a string, recommended to be around 150 characters. If you don't set a `description`,
+The `description` frontmatter is a string, recommended to be around 150 characters. If you don't set a `description`,
it will be generated from the first few paragraphs of the page until it reaches 150 characters.
## Applies to
+
See [](./applies.md)
+
+## Products
+
+The products frontmatter is a list of products that the page relates to.
+This is used for the "Products" filter in the Search UI.
+
+The products frontmatter is a list of strings, each string is the id of a product.
+
+| Product ID | Product Name |
+|---------------------------------------------|-----------------------------------------------|
+| `apm` | APM |
+| `apm-android-agent` | APM Android Agent |
+| `apm-attacher` | APM Attacher |
+| `apm-aws-lambda-extension` | APM AWS Lambda extension |
+| `apm-dotnet-agent` | APM .NET Agent |
+| `apm-go-agent` | APM Go Agent |
+| `apm-ios-agent` | APM iOS Agent |
+| `apm-java-agent` | APM Java Agent |
+| `apm-node-agent` | APM Node.js Agent |
+| `apm-php-agent` | APM PHP Agent |
+| `apm-python-agent` | APM Python Agent |
+| `apm-ruby-agent` | APM Ruby Agent |
+| `apm-rum-agent` | APM RUM Agent |
+| `beats-logging-plugin` | Beats Logging plugin |
+| `cloud-control-ecctl` | Cloud Control ECCTL |
+| `cloud-enterprise` | Cloud Enterprise |
+| `cloud-hosted` | Cloud Hosted |
+| `cloud-kubernetes` | Cloud Kubernetes |
+| `cloud-native-ingest` | Cloud Native Ingest |
+| `cloud-serverless` | Cloud Serverless |
+| `cloud-terraform` | Cloud Terraform |
+| `ecs` | Elastic Common Schema (ECS) |
+| `ecs-logging-dotnet` | ECS Logging .NET |
+| `ecs-logging-go-logrus` | ECS Logging Go Logrus |
+| `ecs-logging-go-zap` | ECS Logging Go Zap |
+| `ecs-logging-go-zerolog` | ECS Logging Go Zerolog |
+| `ecs-logging-java` | ECS Logging Java |
+| `ecs-logging-node` | ECS Logging Node.js |
+| `ecs-logging-php` | ECS Logging PHP |
+| `ecs-logging-python` | ECS Logging Python |
+| `ecs-logging-ruby` | ECS Logging Ruby |
+| `edot-android` | Elastic Distribution of OpenTelemetry Android |
+| `edot-collector` | Elastic Distribution of OpenTelemetry Collector |
+| `edot-dotnet` | Elastic Distribution of OpenTelemetry .NET |
+| `edot-ios` | Elastic Distribution of OpenTelemetry iOS |
+| `edot-java` | Elastic Distribution of OpenTelemetry Java |
+| `edot-nodejs` | Elastic Distribution of OpenTelemetry Node.js |
+| `edot-php` | Elastic Distribution of OpenTelemetry PHP |
+| `edot-python` | Elastic Distribution of OpenTelemetry Python |
+| `elastic-agent` | Elastic Agent |
+| `elastic-products-platform` | Elastic Products platform |
+| `elastic-stack` | Elastic Stack |
+| `elasticsearch` | Elasticsearch |
+| `elasticsearch-apache-hadoop` | Elasticsearch Apache Hadoop |
+| `elasticsearch-cloud-hosted-heroku` | Elasticsearch Cloud Hosted Heroku |
+| `elasticsearch-community-clients` | Elasticsearch community clients |
+| `elasticsearch-curator` | Elasticsearch Curator |
+| `elasticsearch-dotnet-client` | Elasticsearch .NET Client |
+| `elasticsearch-eland-python-client` | Elasticsearch Eland Python Client |
+| `elasticsearch-go-client` | Elasticsearch Go Client |
+| `elasticsearch-groovy-client` | Elasticsearch Groovy Client |
+| `elasticsearch-java-client` | Elasticsearch Java Client |
+| `elasticsearch-java-script-client` | Elasticsearch JavaScript Client |
+| `elasticsearch-painless-scripting-language` | Elasticsearch Painless scripting language |
+| `elasticsearch-perl-client` | Elasticsearch Perl Client |
+| `elasticsearch-php-client` | Elasticsearch PHP Client |
+| `elasticsearch-plugins` | Elasticsearch plugins |
+| `elasticsearch-python-client` | Elasticsearch Python Client |
+| `elasticsearch-resiliency-status` | Elasticsearch Resiliency Status |
+| `elasticsearch-ruby-client` | Elasticsearch Ruby Client |
+| `elasticsearch-rust-client` | Elasticsearch Rust Client |
+| `fleet` | Fleet |
+| `ingest` | Ingest |
+| `integrations` | Integrations |
+| `kibana` | Kibana |
+| `logstash` | Logstash |
+| `machine-learning` | Machine Learning |
+| `observability` | Observability |
+| `reference-architectures` | Reference Architectures |
+| `search-ui` | Search UI |
+| `security` | Security |
diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj
index f4e330173..2ba55d0ef 100644
--- a/src/Elastic.Markdown/Elastic.Markdown.csproj
+++ b/src/Elastic.Markdown/Elastic.Markdown.csproj
@@ -56,6 +56,7 @@
+
diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs
index f11e1d6f4..d6823cf06 100644
--- a/src/Elastic.Markdown/IO/MarkdownFile.cs
+++ b/src/Elastic.Markdown/IO/MarkdownFile.cs
@@ -330,6 +330,11 @@ private YamlFrontMatter ReadYamlFrontMatter(string raw)
{
return YamlSerialization.Deserialize(raw);
}
+ catch (InvalidProductException e)
+ {
+ Collector.EmitError(FilePath, "Invalid product in yaml front matter.", e);
+ return new YamlFrontMatter();
+ }
catch (Exception e)
{
Collector.EmitError(FilePath, "Failed to parse yaml front matter block.", e);
diff --git a/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs b/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs
index fad211c79..70f65f1f0 100644
--- a/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs
+++ b/src/Elastic.Markdown/Myst/FrontMatter/FrontMatterParser.cs
@@ -2,18 +2,10 @@
// 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.Runtime.Serialization;
using YamlDotNet.Serialization;
namespace Elastic.Markdown.Myst.FrontMatter;
-public enum LayoutName
-{
- [EnumMember(Value = "landing-page")] LandingPage,
- [EnumMember(Value = "not-found")] NotFound,
- [EnumMember(Value = "archive")] Archive
-}
-
[YamlSerializable]
public class YamlFrontMatter
{
@@ -38,4 +30,7 @@ public class YamlFrontMatter
[YamlMember(Alias = "mapped_pages")]
public IReadOnlyCollection? MappedPages { get; set; }
+
+ [YamlMember(Alias = "products")]
+ public IReadOnlyCollection? Products { get; set; }
}
diff --git a/src/Elastic.Markdown/Myst/FrontMatter/Layout.cs b/src/Elastic.Markdown/Myst/FrontMatter/Layout.cs
new file mode 100644
index 000000000..c62efbade
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/FrontMatter/Layout.cs
@@ -0,0 +1,14 @@
+// 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.Runtime.Serialization;
+
+namespace Elastic.Markdown.Myst.FrontMatter;
+
+public enum LayoutName
+{
+ [EnumMember(Value = "landing-page")] LandingPage,
+ [EnumMember(Value = "not-found")] NotFound,
+ [EnumMember(Value = "archive")] Archive
+}
diff --git a/src/Elastic.Markdown/Myst/FrontMatter/Products.cs b/src/Elastic.Markdown/Myst/FrontMatter/Products.cs
new file mode 100644
index 000000000..d7cc8ea66
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/FrontMatter/Products.cs
@@ -0,0 +1,268 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+using System.Collections.Frozen;
+using System.ComponentModel.DataAnnotations;
+using Elastic.Markdown.Suggestions;
+using EnumFastToStringGenerated;
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+
+namespace Elastic.Markdown.Myst.FrontMatter;
+
+[EnumGenerator]
+public enum Product
+{
+ [Display(Name = "apm", Description = "APM")]
+ Apm,
+
+ [Display(Name = "apm-dotnet-agent", Description = "APM .NET Agent")]
+ ApmDotnetAgent,
+
+ [Display(Name = "apm-android-agent", Description = "APM Android Agent")]
+ ApmAndroidAgent,
+
+ [Display(Name = "apm-attacher", Description = "APM Attacher")]
+ ApmAttacher,
+
+ [Display(Name = "apm-aws-lambda-extension", Description = "APM AWS Lambda extension")]
+ ApmAwsLambdaExtension,
+
+ [Display(Name = "apm-go-agent", Description = "APM Go Agent")]
+ ApmGoAgent,
+
+ [Display(Name = "apm-ios-agent", Description = "APM iOS Agent")]
+ ApmIosAgent,
+
+ [Display(Name = "apm-java-agent", Description = "APM Java Agent")]
+ ApmJavaAgent,
+
+ [Display(Name = "apm-node-agent", Description = "APM Node.js Agent")]
+ ApmNodeAgent,
+
+ [Display(Name = "apm-php-agent", Description = "APM PHP Agent")]
+ ApmPhpAgent,
+
+ [Display(Name = "apm-python-agent", Description = "APM Python Agent")]
+ ApmPythonAgent,
+
+ [Display(Name = "apm-ruby-agent", Description = "APM Ruby Agent")]
+ ApmRubyAgent,
+
+ [Display(Name = "apm-rum-agent", Description = "APM RUM Agent")]
+ ApmRumAgent,
+
+ [Display(Name = "beats-logging-plugin", Description = "Beats Logging plugin")]
+ BeatsLoggingPlugin,
+
+ [Display(Name = "cloud-control-ecctl", Description = "Cloud Control ECCTL")]
+ CloudControlEcctl,
+
+ [Display(Name = "cloud-enterprise", Description = "Cloud Enterprise")]
+ CloudEnterprise,
+
+ [Display(Name = "cloud-hosted", Description = "Cloud Hosted")]
+ CloudHosted,
+
+ [Display(Name = "cloud-kubernetes", Description = "Cloud Kubernetes")]
+ CloudKubernetes,
+
+ [Display(Name = "cloud-native-ingest", Description = "Cloud Native Ingest")]
+ CloudNativeIngest,
+
+ [Display(Name = "cloud-serverless", Description = "Cloud Serverless")]
+ CloudServerless,
+
+ [Display(Name = "cloud-terraform", Description = "Cloud Terraform")]
+ CloudTerraform,
+
+ [Display(Name = "ecs-logging", Description = "ECS Logging")]
+ EcsLogging,
+
+ [Display(Name = "ecs-logging-dotnet", Description = "ECS Logging .NET")]
+ EcsLoggingDotnet,
+
+ [Display(Name = "ecs-logging-go-logrus", Description = "ECS Logging Go Logrus")]
+ EcsLoggingGoLogrus,
+
+ [Display(Name = "ecs-logging-go-zap", Description = "ECS Logging Go Zap")]
+ EcsLoggingGoZap,
+
+ [Display(Name = "ecs-logging-go-zerolog", Description = "ECS Logging Go Zerolog")]
+ EcsLoggingGoZerolog,
+
+ [Display(Name = "ecs-logging-java", Description = "ECS Logging Java")]
+ EcsLoggingJava,
+
+ [Display(Name = "ecs-logging-node", Description = "ECS Logging Node.js")]
+ EcsLoggingNode,
+
+ [Display(Name = "ecs-logging-php", Description = "ECS Logging PHP")]
+ EcsLoggingPhp,
+
+ [Display(Name = "ecs-logging-python", Description = "ECS Logging Python")]
+ EcsLoggingPython,
+
+ [Display(Name = "ecs-logging-ruby", Description = "ECS Logging Ruby")]
+ EcsLoggingRuby,
+
+ [Display(Name = "elastic-agent", Description = "Elastic Agent")]
+ ElasticAgent,
+
+ [Display(Name = "ecs", Description = "Elastic Common Schema (ECS)")]
+ Ecs,
+
+ [Display(Name = "elastic-products-platform", Description = "Elastic Products platform")]
+ ElasticProductsPlatform,
+
+ [Display(Name = "elastic-stack", Description = "Elastic Stack")]
+ ElasticStack,
+
+ [Display(Name = "elasticsearch", Description = "Elasticsearch")]
+ Elasticsearch,
+
+ [Display(Name = "elasticsearch-dotnet-client", Description = "Elasticsearch .NET Client")]
+ ElasticsearchDotnetClient,
+
+ [Display(Name = "elasticsearch-apache-hadoop", Description = "Elasticsearch Apache Hadoop")]
+ ElasticsearchApacheHadoop,
+
+ [Display(Name = "elasticsearch-cloud-hosted-heroku", Description = "Elasticsearch Cloud Hosted Heroku")]
+ ElasticsearchCloudHostedHeroku,
+
+ [Display(Name = "elasticsearch-community-clients", Description = "Elasticsearch community clients")]
+ ElasticsearchCommunityClients,
+
+ [Display(Name = "elasticsearch-curator", Description = "Elasticsearch Curator")]
+ ElasticsearchCurator,
+
+ [Display(Name = "elasticsearch-eland-python-client", Description = "Elasticsearch Eland Python Client")]
+ ElasticsearchElandPythonClient,
+
+ [Display(Name = "elasticsearch-go-client", Description = "Elasticsearch Go Client")]
+ ElasticsearchGoClient,
+
+ [Display(Name = "elasticsearch-groovy-client", Description = "Elasticsearch Groovy Client")]
+ ElasticsearchGroovyClient,
+
+ [Display(Name = "elasticsearch-java-client", Description = "Elasticsearch Java Client")]
+ ElasticsearchJavaClient,
+
+ [Display(Name = "elasticsearch-java-script-client", Description = "Elasticsearch JavaScript Client")]
+ ElasticsearchJavaScriptClient,
+
+ [Display(Name = "elasticsearch-painless-scripting-language", Description = "Elasticsearch Painless scripting language")]
+ ElasticsearchPainlessScriptingLanguage,
+
+ [Display(Name = "elasticsearch-perl-client", Description = "Elasticsearch Perl Client")]
+ ElasticsearchPerlClient,
+
+ [Display(Name = "elasticsearch-php-client", Description = "Elasticsearch PHP Client")]
+ ElasticsearchPhpClient,
+
+ [Display(Name = "elasticsearch-plugins", Description = "Elasticsearch plugins")]
+ ElasticsearchPlugins,
+
+ [Display(Name = "elasticsearch-python-client", Description = "Elasticsearch Python Client")]
+ ElasticsearchPythonClient,
+
+ [Display(Name = "elasticsearch-resiliency-status", Description = "Elasticsearch Resiliency Status")]
+ ElasticsearchResiliencyStatus,
+
+ [Display(Name = "elasticsearch-ruby-client", Description = "Elasticsearch Ruby Client")]
+ ElasticsearchRubyClient,
+
+ [Display(Name = "elasticsearch-rust-client", Description = "Elasticsearch Rust Client")]
+ ElasticsearchRustClient,
+
+ [Display(Name = "fleet", Description = "Fleet")]
+ Fleet,
+
+ [Display(Name = "ingest", Description = "Ingest")]
+ Ingest,
+
+ [Display(Name = "integrations", Description = "Integrations")]
+ Integrations,
+
+ [Display(Name = "kibana", Description = "Kibana")]
+ Kibana,
+
+ [Display(Name = "logstash", Description = "Logstash")]
+ Logstash,
+
+ [Display(Name = "machine-learning", Description = "Machine Learning")]
+ MachineLearning,
+
+ [Display(Name = "observability", Description = "Observability")]
+ Observability,
+
+ [Display(Name = "reference-architectures", Description = "Reference Architectures")]
+ ReferenceArchitectures,
+
+ [Display(Name = "search-ui", Description = "Search UI")]
+ SearchUi,
+
+ [Display(Name = "security", Description = "Security")]
+ Security,
+
+ [Display(Name = "edot-collector", Description = "Elastic Distribution of OpenTelemetry Collector")]
+ EdotCollector,
+
+ [Display(Name = "edot-java", Description = "Elastic Distribution of OpenTelemetry Java")]
+ EdotJava,
+
+ [Display(Name = "edot-dotnet", Description = "Elastic Distribution of OpenTelemetry .NET")]
+ EdotDotnet,
+
+ [Display(Name = "edot-nodejs", Description = "Elastic Distribution of OpenTelemetry Node.js")]
+ EdotNodeJs,
+
+ [Display(Name = "edot-php", Description = "Elastic Distribution of OpenTelemetry PHP")]
+ EdotPhp,
+
+ [Display(Name = "edot-python", Description = "Elastic Distribution of OpenTelemetry Python")]
+ EdotPython,
+
+ [Display(Name = "edot-android", Description = "Elastic Distribution of OpenTelemetry Android")]
+ EdotAndroid,
+
+ [Display(Name = "edot-ios", Description = "Elastic Distribution of OpenTelemetry iOS")]
+ EdotIos,
+}
+
+public class ProductConverter : IYamlTypeConverter
+{
+ public bool Accepts(Type type) => type == typeof(Product);
+
+ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
+ {
+ var value = parser.Consume();
+ if (string.IsNullOrWhiteSpace(value.Value))
+ throw new InvalidProductException("");
+
+ var product = Enum.GetValues()
+ .FirstOrDefault(p => p.ToDisplayFast()?.Equals(value.Value, StringComparison.Ordinal) ?? false);
+
+ if (ProductEnumExtensions.IsDefinedFast(product) && product.ToDisplayFast()?.Equals(value.Value) == true)
+ return product;
+
+ throw new InvalidProductException(value.Value);
+ }
+
+ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => serializer.Invoke(value, type);
+}
+
+public class InvalidProductException(string invalidValue)
+ : Exception(
+ $"Invalid products frontmatter value: \"{invalidValue}\"." +
+ (!string.IsNullOrWhiteSpace(invalidValue) ? " " + new Suggestion(ProductExtensions.GetProductIds(), invalidValue).GetSuggestionQuestion() : "") +
+ "\nYou can find the full list at https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/syntax/frontmatter#products.");
+
+public static class ProductExtensions
+{
+ public static IReadOnlySet GetProductIds() =>
+ ProductEnumExtensions.GetValuesFast()
+ .Select(p => p.ToDisplayFast()).ToFrozenSet();
+}
diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs
index 9cdf134db..179903435 100644
--- a/src/Elastic.Markdown/Myst/YamlSerialization.cs
+++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs
@@ -19,6 +19,7 @@ public static T Deserialize(string yaml)
.IgnoreUnmatchedProperties()
.WithEnumNamingConvention(HyphenatedNamingConvention.Instance)
.WithTypeConverter(new SemVersionConverter())
+ .WithTypeConverter(new ProductConverter())
#pragma warning disable CS0618 // Type or member is obsolete
.WithTypeConverter(new DeploymentConverter())
.WithTypeConverter(new ApplicableToConverter())
diff --git a/src/Elastic.Markdown/Slices/Index.cshtml b/src/Elastic.Markdown/Slices/Index.cshtml
index ec7f4fb07..0507fcf20 100644
--- a/src/Elastic.Markdown/Slices/Index.cshtml
+++ b/src/Elastic.Markdown/Slices/Index.cshtml
@@ -1,4 +1,6 @@
+@using Elastic.Markdown.Myst.FrontMatter
@using Elastic.Markdown.Slices.Components
+@using EnumFastToStringGenerated
@using Markdig
@inherits RazorSliceHttpResult
@implements IUsesLayout
@@ -22,7 +24,8 @@
Features = Model.Features,
StaticFileContentHashProvider = Model.StaticFileContentHashProvider,
ReportIssueUrl = Model.ReportIssueUrl,
- LegacyPage = Model.LegacyPage
+ LegacyPage = Model.LegacyPage,
+ Products = Model.CurrentDocument.YamlFrontMatter?.Products is { Count: > 0} products ? string.Join(",", products.Select(p => p.ToDescriptionFast()).ToList()) : null,
};
}
diff --git a/src/Elastic.Markdown/Slices/Layout/_Head.cshtml b/src/Elastic.Markdown/Slices/Layout/_Head.cshtml
index 2f897dcbd..432699c38 100644
--- a/src/Elastic.Markdown/Slices/Layout/_Head.cshtml
+++ b/src/Elastic.Markdown/Slices/Layout/_Head.cshtml
@@ -35,4 +35,9 @@
{
}
+ @if (!string.IsNullOrEmpty(Model.Products))
+ {
+
+
+ }
diff --git a/src/Elastic.Markdown/Slices/_ViewModels.cs b/src/Elastic.Markdown/Slices/_ViewModels.cs
index 740da3d4b..36a9a745a 100644
--- a/src/Elastic.Markdown/Slices/_ViewModels.cs
+++ b/src/Elastic.Markdown/Slices/_ViewModels.cs
@@ -68,6 +68,8 @@ public class LayoutViewModel
public required MarkdownFile[] Parents { get; init; }
+ public required string? Products { get; init; }
+
public string Static(string path)
{
var staticPath = $"_static/{path.TrimStart('/')}";
diff --git a/src/Elastic.Markdown/Suggestions/Suggestions.cs b/src/Elastic.Markdown/Suggestions/Suggestions.cs
new file mode 100644
index 000000000..9674407a9
--- /dev/null
+++ b/src/Elastic.Markdown/Suggestions/Suggestions.cs
@@ -0,0 +1,65 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+namespace Elastic.Markdown.Suggestions;
+
+public class Suggestion(IReadOnlySet candidates, string input)
+{
+ private IReadOnlyCollection GetSuggestions() =>
+ candidates
+ .Select(source => (source, Distance: LevenshteinDistance(input, source)))
+ .OrderBy(suggestion => suggestion.Distance)
+ .Where(suggestion => suggestion.Distance <= 2)
+ .Select(suggestion => suggestion.source)
+ .Take(3)
+ .ToList();
+
+ public string GetSuggestionQuestion()
+ {
+ var suggestions = GetSuggestions();
+ if (suggestions.Count == 0)
+ return string.Empty;
+
+ return "Did you mean " + string.Join(", ", suggestions.SkipLast(1).Select(s => $"\"{s}\"")) + (suggestions.Count > 1 ? " or " : "") + (suggestions.LastOrDefault() != null ? $"\"{suggestions.LastOrDefault()}\"" : "") + "?";
+ }
+
+ private static int LevenshteinDistance(string source, string target)
+ {
+ if (string.IsNullOrEmpty(target))
+ return int.MaxValue;
+
+ var sourceLength = source.Length;
+ var targetLength = target.Length;
+
+ if (sourceLength == 0)
+ return targetLength;
+
+ if (targetLength == 0)
+ return sourceLength;
+
+ var distance = new int[sourceLength + 1, targetLength + 1];
+
+ for (var i = 0; i <= sourceLength; i++)
+ distance[i, 0] = i;
+
+ for (var j = 0; j <= targetLength; j++)
+ distance[0, j] = j;
+
+ for (var i = 1; i <= sourceLength; i++)
+ {
+ for (var j = 1; j <= targetLength; j++)
+ {
+ var cost = (source[i - 1] == target[j - 1]) ? 0 : 1;
+
+ distance[i, j] = Math.Min(
+ Math.Min(
+ distance[i - 1, j] + 1,
+ distance[i, j - 1] + 1),
+ distance[i - 1, j - 1] + cost);
+ }
+ }
+
+ return distance[sourceLength, targetLength];
+ }
+}
diff --git a/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs b/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs
index 86f653dd4..f24aabccd 100644
--- a/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs
+++ b/tests/Elastic.Markdown.Tests/FrontMatter/YamlFrontMatterTests.cs
@@ -2,6 +2,7 @@
// 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.Markdown.Myst.FrontMatter;
using Elastic.Markdown.Tests.Directives;
using FluentAssertions;
@@ -63,3 +64,123 @@ public class NavigationTitleSupportReplacements(ITestOutputHelper output) : Dire
[Fact]
public void ReadsNavigationTitle() => File.NavigationTitle.Should().Be("Documentation Guide: value");
}
+
+public class ProductsSingle(ITestOutputHelper output) : DirectiveTest(output,
+ """
+ ---
+ products:
+ - "apm"
+ ---
+
+ # APM
+ """
+)
+{
+ [Fact]
+ public void ReadsProducts()
+ {
+ File.YamlFrontMatter.Should().NotBeNull();
+ File.YamlFrontMatter!.Products.Should().NotBeNull()
+ .And.HaveCount(1)
+ .And.Contain(Product.Apm);
+ }
+}
+
+public class ProductsMultiple(ITestOutputHelper output) : DirectiveTest(output,
+ """
+ ---
+ products:
+ - "apm"
+ - "elasticsearch"
+ ---
+
+ # APM
+ """
+)
+{
+ [Fact]
+ public void ReadsProducts()
+ {
+ File.YamlFrontMatter.Should().NotBeNull();
+ File.YamlFrontMatter!.Products.Should().NotBeNull()
+ .And.HaveCount(2)
+ .And.Contain(Product.Apm)
+ .And.Contain(Product.Elasticsearch);
+ }
+}
+
+public class ProductsSuggestionWhenMispelled(ITestOutputHelper output) : DirectiveTest(output,
+ """
+ ---
+ products:
+ - aapm
+ ---
+
+ # APM
+ """
+)
+{
+ [Fact]
+ public void HasErrors()
+ {
+ Collector.Diagnostics.Should().HaveCount(1);
+ Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"aapm\". Did you mean \"apm\"?"));
+ }
+}
+
+public class ProductsSuggestionWhenMispelled2(ITestOutputHelper output) : DirectiveTest(output,
+ """
+ ---
+ products:
+ - apm-javaagent
+ ---
+
+ # APM
+ """
+)
+{
+ [Fact]
+ public void HasErrors()
+ {
+ Collector.Diagnostics.Should().HaveCount(1);
+ Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"apm-javaagent\". Did you mean \"apm-java-agent\"?"));
+ }
+}
+
+public class ProductsSuggestionWhenCasingError(ITestOutputHelper output) : DirectiveTest(output,
+ """
+ ---
+ products:
+ - Apm
+ ---
+
+ # APM
+ """
+)
+{
+ [Fact]
+ public void HasErrors()
+ {
+ Collector.Diagnostics.Should().HaveCount(1);
+ Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"Apm\". Did you mean \"apm\"?"));
+ }
+}
+
+public class ProductsSuggestionWhenEmpty(ITestOutputHelper output) : DirectiveTest(output,
+ """
+ ---
+ products:
+ - ""
+ ---
+
+ # APM
+ """
+)
+{
+ [Fact]
+ public void HasErrors()
+ {
+ Collector.Diagnostics.Should().HaveCount(1);
+ Collector.Diagnostics.Should().Contain(d => d.Message.Contains("Invalid products frontmatter value: \"\".\nYou can find the full list at https://docs-v3-preview.elastic.dev/elastic/docs-builder/tree/main/syntax/frontmatter#products."));
+ }
+}