diff --git a/docs/syntax/applies-switch.md b/docs/syntax/applies-switch.md index 52ecf4a29..4692a4eb7 100644 --- a/docs/syntax/applies-switch.md +++ b/docs/syntax/applies-switch.md @@ -77,6 +77,45 @@ Content for Serverless ::::: :::::: +## Automatic ordering + +Applies-switch tabs are **automatically ordered** according to documentation standards, regardless of how they appear in the source file. This ensures consistency across documentation. + +The ordering rules are: + +1. **Serverless** appears first +2. **Stack** versions appear second, ordered from **latest to oldest** +3. **Deployment types** appear third, in this order: + - ECH/ESS (Elastic Cloud Hosted) + - ECE (Elastic Cloud Enterprise) + - ECK (Elastic Cloud on Kubernetes) + - Self-managed +4. Items with **unavailable** lifecycle always appear **last** + +**Example:** + +Even if you write the tabs in a different order: + +```markdown +::::{applies-switch} +:::{applies-item} stack: ga 8.11 +Old stack version +::: +:::{applies-item} serverless: ga +Serverless content +::: +:::{applies-item} stack: preview 9.1 +New stack version +::: +:::: +``` + +They will automatically render in the correct order: Serverless → Stack 9.1 → Stack 8.11 + +:::{tip} +You don't need to manually order tabs anymore. Focus on content, and the system will handle the ordering for consistency. +::: + ## Automatic grouping All applies switches on a page automatically sync together. When you select an applies_to definition in one switch, all other switches will switch to the same applies_to definition. diff --git a/docs/syntax/applies.md b/docs/syntax/applies.md index 90e5397ab..e18e1bd7c 100644 --- a/docs/syntax/applies.md +++ b/docs/syntax/applies.md @@ -369,18 +369,24 @@ This section provides detailed rules for how badges are rendered based on lifecy ### Rendering order -`applies_to` badges are displayed in a consistent order regardless of how they appear in your source files: - -1. **Stack** - Elastic Stack -2. **Serverless** - Elastic Cloud Serverless offerings -3. **Deployment** - Deployment options (ECH, ECK, ECE, Self-managed) +`applies_to` badges are **automatically displayed in a consistent order** regardless of how they appear in your source files: + +1. **Serverless** - Elastic Cloud Serverless offerings (appears first) +2. **Stack** - Elastic Stack (ordered from latest to oldest version) +3. **Deployment** - Deployment options in this order: + - ECH/ESS (Elastic Cloud Hosted) + - ECE (Elastic Cloud Enterprise) + - ECK (Elastic Cloud on Kubernetes) + - Self-managed 4. **ProductApplicability** - Specialized tools and agents (ECCTL, Curator, EDOT, APM Agents) 5. **Product (generic)** - Generic product applicability +Items with **unavailable** lifecycle always appear **last**, regardless of their category. + Within the ProductApplicability category, EDOT and APM Agent items are sorted alphabetically for better scanning. :::{note} -Inline applies annotations are rendered in the order they appear in the source file. +This automatic ordering applies to badges displayed at page and section levels, as well as in [applies-switch](applies-switch.md) tabs. Inline applies annotations are rendered in the order they appear in the source file. ::: ### Badge rendering rules diff --git a/src/Elastic.Documentation/AppliesTo/ApplicableToOrderComparer.cs b/src/Elastic.Documentation/AppliesTo/ApplicableToOrderComparer.cs new file mode 100644 index 000000000..ff91933e6 --- /dev/null +++ b/src/Elastic.Documentation/AppliesTo/ApplicableToOrderComparer.cs @@ -0,0 +1,174 @@ +// 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.Linq; + +namespace Elastic.Documentation.AppliesTo; + +/// +/// Comparer for ordering ApplicableTo objects according to documentation standards. +/// Orders by: +/// 1. Serverless first, then Stack (latest to oldest) +/// 2. For deployments: ech/ess, ece, eck, self-managed +/// 3. Unavailable lifecycle comes last +/// +public class ApplicableToOrderComparer : IComparer +{ + public int Compare(ApplicableTo? x, ApplicableTo? y) + { + if (ReferenceEquals(x, y)) + return 0; + if (x is null) + return 1; + if (y is null) + return -1; + + // Check if either has unavailable lifecycle - unavailable goes last + var xIsUnavailable = IsUnavailable(x); + var yIsUnavailable = IsUnavailable(y); + + if (xIsUnavailable && !yIsUnavailable) + return 1; + if (!xIsUnavailable && yIsUnavailable) + return -1; + + // Both unavailable or both available, continue with normal ordering + + // Determine the primary category for each + var xCategory = GetPrimaryCategory(x); + var yCategory = GetPrimaryCategory(y); + + // Compare by category first + var categoryComparison = xCategory.CompareTo(yCategory); + if (categoryComparison != 0) + return categoryComparison; + + // Within the same category, apply specific ordering rules + return xCategory switch + { + ApplicabilityCategory.Serverless => 0, + ApplicabilityCategory.Stack => CompareStack(x, y), + ApplicabilityCategory.Deployment => CompareDeployment(x, y), + _ => 0 + }; + } + + private static bool IsUnavailable(ApplicableTo applicableTo) + { + // Check if any applicability has unavailable lifecycle + if (applicableTo.Stack is not null && + applicableTo.Stack.Any(a => a.Lifecycle == ProductLifecycle.Unavailable)) + return true; + + if (applicableTo.Serverless is not null) + { + var serverless = applicableTo.Serverless; + if ((serverless.Elasticsearch?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false) || + (serverless.Observability?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false) || + (serverless.Security?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false)) + return true; + } + + if (applicableTo.Deployment is not null) + { + var deployment = applicableTo.Deployment; + if ((deployment.Ess?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false) || + (deployment.Ece?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false) || + (deployment.Eck?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false) || + (deployment.Self?.Any(a => a.Lifecycle == ProductLifecycle.Unavailable) ?? false)) + return true; + } + + return false; + } + + private static ApplicabilityCategory GetPrimaryCategory(ApplicableTo applicableTo) + { + // Serverless takes priority + if (applicableTo.Serverless is not null) + return ApplicabilityCategory.Serverless; + + // Then Stack + if (applicableTo.Stack is not null) + return ApplicabilityCategory.Stack; + + // Then Deployment + if (applicableTo.Deployment is not null) + return ApplicabilityCategory.Deployment; + + // Default + return ApplicabilityCategory.Other; + } + + private static int CompareStack(ApplicableTo x, ApplicableTo y) + { + // Stack: order from latest to oldest version + var xVersion = GetLatestVersion(x.Stack); + var yVersion = GetLatestVersion(y.Stack); + + if (xVersion is null && yVersion is null) + return 0; + if (xVersion is null) + return 1; + if (yVersion is null) + return -1; + + // Higher version comes first (descending order) + return yVersion.CompareTo(xVersion); + } + + private static int CompareDeployment(ApplicableTo x, ApplicableTo y) + { + // Deployment order: ech/ess, ece, eck, self-managed + var xDeploymentType = GetPrimaryDeploymentType(x.Deployment!); + var yDeploymentType = GetPrimaryDeploymentType(y.Deployment!); + + return xDeploymentType.CompareTo(yDeploymentType); + } + + private static DeploymentType GetPrimaryDeploymentType(DeploymentApplicability deployment) + { + // Return the first deployment type found in priority order + if (deployment.Ess is not null) + return DeploymentType.Ech; // ESS = ECH (Elastic Cloud Hosted) + if (deployment.Ece is not null) + return DeploymentType.Ece; + if (deployment.Eck is not null) + return DeploymentType.Eck; + if (deployment.Self is not null) + return DeploymentType.SelfManaged; + + return DeploymentType.Unknown; + } + + private static SemVersion? GetLatestVersion(AppliesCollection? collection) + { + if (collection is null || collection.Count == 0) + return null; + + // Find the highest version in the collection using LINQ + return collection + .Select(applicability => applicability.Version?.Min) + .Where(version => version is not null) + .DefaultIfEmpty(null) + .Max(); + } + + private enum ApplicabilityCategory + { + Serverless = 0, // Serverless first + Stack = 1, // Stack second + Deployment = 2, // Deployment third + Other = 3 // Everything else last + } + + private enum DeploymentType + { + Ech = 0, // ECH/ESS first + Ece = 1, // ECE second + Eck = 2, // ECK third + SelfManaged = 3, // Self-managed last + Unknown = 4 + } +} diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs index ffe651543..a8600d704 100644 --- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs +++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs @@ -17,10 +17,10 @@ public class ApplicableToViewModel private static readonly Dictionary, ApplicabilityMappings.ApplicabilityDefinition> DeploymentMappings = new() { - [d => d.Ess] = ApplicabilityMappings.Ech, - [d => d.Eck] = ApplicabilityMappings.Eck, - [d => d.Ece] = ApplicabilityMappings.Ece, - [d => d.Self] = ApplicabilityMappings.Self + [d => d.Ess] = ApplicabilityMappings.Ech, // ESS/ECH first + [d => d.Ece] = ApplicabilityMappings.Ece, // ECE second + [d => d.Eck] = ApplicabilityMappings.Eck, // ECK third + [d => d.Self] = ApplicabilityMappings.Self // Self-managed last }; private static readonly Dictionary, ApplicabilityMappings.ApplicabilityDefinition> ServerlessMappings = new() @@ -81,7 +81,12 @@ public IReadOnlyCollection GetApplicabilityItems() if (AppliesTo.Product is not null) rawItems.AddRange(CollectFromCollection(AppliesTo.Product, ApplicabilityMappings.Product)); - return RenderGroupedItems(rawItems).ToArray(); + var items = RenderGroupedItems(rawItems).ToList(); + + // Sort badges: unavailable lifecycle comes last + return items + .OrderBy(item => item.PrimaryApplicability.Lifecycle == ProductLifecycle.Unavailable ? 1 : 0) + .ToArray(); } /// diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs index 82599b415..3a630fe23 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs @@ -18,7 +18,11 @@ public class AppliesSwitchBlock(DirectiveBlockParser parser, ParserContext conte public int Index { get; set; } public string GetGroupKey() => Prop("group") ?? "applies-switches"; - public override void FinalizeAndValidate(ParserContext context) => Index = FindIndex(); + public override void FinalizeAndValidate(ParserContext context) + { + Index = FindIndex(); + SortAppliesItems(); + } private int _index = -1; @@ -30,6 +34,52 @@ public int FindIndex() _index = GetUniqueLineIndex(); return _index; } + + private void SortAppliesItems() + { + // Get all applies-item children + var items = this.OfType().ToList(); + if (items.Count <= 1) + return; // No need to sort if 0 or 1 items + + // Parse ApplicableTo for each item for sorting + var itemsWithAppliesTo = items.Select(item => + { + try + { + var applicableTo = YamlSerialization.Deserialize( + item.AppliesToDefinition, + Build.ProductsConfiguration); + return (Item: item, AppliesTo: (ApplicableTo?)applicableTo); + } + catch + { + // If parsing fails, keep original order for this item + return (Item: item, AppliesTo: null); + } + }).ToList(); + + // Create comparer + var comparer = new ApplicableToOrderComparer(); + + // Sort items based on their ApplicableTo, putting unparseable items at the end + var sorted = itemsWithAppliesTo + .OrderBy(x => x.AppliesTo is null ? 1 : 0) // Unparseable items last + .ThenBy(x => x.AppliesTo, comparer) + .ToList(); + + // Remove all items from the block + foreach (var item in items) + _ = Remove(item); + + // Re-add items in sorted order + foreach (var (item, _) in sorted) + Add(item); + + // Update indices after sorting + foreach (var item in items) + item.UpdateIndex(); + } } public class AppliesItemBlock(DirectiveBlockParser parser, ParserContext context) @@ -51,7 +101,6 @@ public override void FinalizeAndValidate(ParserContext context) this.EmitError("{applies-item} requires an argument with applies_to definition."); AppliesToDefinition = (Arguments ?? "{undefined}").ReplaceSubstitutions(context); - Index = Parent!.IndexOf(this); var appliesSwitch = Parent as AppliesSwitchBlock; @@ -63,6 +112,9 @@ public override void FinalizeAndValidate(ParserContext context) Selected = PropBool("selected"); } + // Called after sorting to update the index + internal void UpdateIndex() => Index = Parent!.IndexOf(this); + public static string GenerateSyncKey(string appliesToDefinition, ProductsConfiguration productsConfiguration) { var applicableTo = YamlSerialization.Deserialize(appliesToDefinition, productsConfiguration); diff --git a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs index b60ea8d17..6f77f18b4 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs @@ -258,3 +258,129 @@ public void GeneratesDeterministicSyncKeysAcrossMultipleRuns() "Different applies_to definitions must produce different sync keys"); } } + +public class ApplicabilitySwitchOrderingTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +::::{applies-item} stack: ga 8.11 +Old stack version content +:::: + +::::{applies-item} serverless: ga +Serverless content +:::: + +::::{applies-item} stack: preview 9.1 +New stack version content +:::: + +::::{applies-item} deployment: { ece: ga, ess: ga } +Deployment content +:::: +::::: +""" +) +{ + [Fact] + public void OrdersAppliesItemsCorrectly() + { + var items = Block!.OfType().ToArray(); + items.Should().HaveCount(4); + + // After automatic sorting: + // 1. Serverless first (index 0) + // 2. Stack 9.1 - latest stack version (index 1) + // 3. Stack 8.11 - older stack version (index 2) + // 4. Deployment (index 3) + + items[0].AppliesToDefinition.Should().Contain("serverless"); + items[1].AppliesToDefinition.Should().Contain("stack: preview 9.1"); + items[2].AppliesToDefinition.Should().Contain("stack: ga 8.11"); + items[3].AppliesToDefinition.Should().Contain("deployment"); + } + + [Fact] + public void UpdatesIndicesAfterSorting() + { + var items = Block!.OfType().ToArray(); + for (var i = 0; i < items.Length; i++) + items[i].Index.Should().Be(i, $"Item at position {i} should have Index {i}"); + } +} + +public class UnavailableLastOrderingTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +::::{applies-item} stack: ga 9.1 +Available content +:::: + +::::{applies-item} stack: unavailable 9.0 +Unavailable content +:::: + +::::{applies-item} serverless: ga +Serverless content +:::: +::::: +""" +) +{ + [Fact] + public void OrdersUnavailableLast() + { + var items = Block!.OfType().ToArray(); + items.Should().HaveCount(3); + + // After automatic sorting: + // 1. Serverless (index 0) + // 2. Stack 9.1 available (index 1) + // 3. Stack 9.0 unavailable (index 2) - unavailable should be last + + items[0].AppliesToDefinition.Should().Contain("serverless"); + items[1].AppliesToDefinition.Should().Contain("stack: ga 9.1"); + items[2].AppliesToDefinition.Should().Contain("unavailable"); + } +} + +public class DeploymentOrderingTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +::::{applies-item} deployment: { self: ga } +Self-managed content +:::: + +::::{applies-item} deployment: { eck: ga } +ECK content +:::: + +::::{applies-item} deployment: { ess: ga } +ESS/ECH content +:::: + +::::{applies-item} deployment: { ece: ga } +ECE content +:::: +::::: +""" +) +{ + [Fact] + public void OrdersDeploymentsCorrectly() + { + var items = Block!.OfType().ToArray(); + items.Should().HaveCount(4); + + // After automatic sorting: + // 1. ESS/ECH first (index 0) + // 2. ECE second (index 1) + // 3. ECK third (index 2) + // 4. Self-managed last (index 3) + + items[0].AppliesToDefinition.Should().Contain("ess"); + items[1].AppliesToDefinition.Should().Contain("ece"); + items[2].AppliesToDefinition.Should().Contain("eck"); + items[3].AppliesToDefinition.Should().Contain("self"); + } +} + diff --git a/tests/authoring/Applicability/ApplicableToComponent.fs b/tests/authoring/Applicability/ApplicableToComponent.fs index 7b6b61bcf..b37bc2053 100644 --- a/tests/authoring/Applicability/ApplicableToComponent.fs +++ b/tests/authoring/Applicability/ApplicableToComponent.fs @@ -405,10 +405,10 @@ apm_agent_java: beta 9.1.0 - - + + @@ -475,8 +475,8 @@ product: ga 9.0.0 - +