diff --git a/docs/_docset.yml b/docs/_docset.yml
index bfbd5018a..bf25daf6f 100644
--- a/docs/_docset.yml
+++ b/docs/_docset.yml
@@ -92,6 +92,7 @@ toc:
- file: headings.md
- file: admonitions.md
- file: applies.md
+ - file: applies-switch.md
- file: automated_settings.md
- file: code.md
- file: comments.md
@@ -156,4 +157,4 @@ toc:
- file: bar.md
- folder: baz
children:
- - file: qux.md
\ No newline at end of file
+ - file: qux.md
diff --git a/docs/syntax/applies-switch.md b/docs/syntax/applies-switch.md
new file mode 100644
index 000000000..52ecf4a29
--- /dev/null
+++ b/docs/syntax/applies-switch.md
@@ -0,0 +1,147 @@
+# Applies switch
+
+The applies-switch directive creates tabbed content where each tab displays an applies_to badge instead of a text title. This is useful for showing content that varies by deployment type, version, or other applicability criteria.
+
+## Basic usage
+
+::::::{tab-set}
+:::::{tab-item} Output
+
+::::{applies-switch}
+
+:::{applies-item} stack:
+Content for Stack
+:::
+
+:::{applies-item} serverless:
+Content for Serverless
+:::
+
+::::
+
+:::::
+:::::{tab-item} Markdown
+
+```markdown
+::::{applies-switch}
+
+:::{applies-item} stack:
+Content for Stack
+:::
+
+:::{applies-item} serverless:
+Content for Serverless
+:::
+
+::::
+```
+:::::
+::::::
+
+## Multiple `applies_to` definitions
+
+You can specify multiple `applies_to` definitions in a single `applies-item` using YAML object notation with curly braces `{}`.
+This is useful when content applies to multiple deployment types or versions simultaneously.
+
+::::::{tab-set}
+:::::{tab-item} Output
+
+::::{applies-switch}
+
+:::{applies-item} { ece:, ess: }
+Content for ECE and ECH
+:::
+
+:::{applies-item} serverless:
+Content for Serverless
+:::
+
+::::
+
+:::::
+:::::{tab-item} Markdown
+
+```markdown
+::::{applies-switch}
+
+:::{applies-item} { ece:, ess: }
+Content for ECE and ECH
+:::
+
+:::{applies-item} serverless:
+Content for Serverless
+:::
+
+::::
+```
+:::::
+::::::
+
+## 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.
+
+The format of the applies_to definition doesn't matter - `stack: preview 9.1`, `{ "stack": "preview 9.1" }`, and `{ stack: "preview 9.1" }` all identify the same content and will sync together.
+
+In the following example, both switch sets are automatically grouped and will stay in sync.
+
+::::::{tab-set}
+:::::{tab-item} Output
+
+::::{applies-switch}
+:::{applies-item} { "stack": "preview 9.0" }
+Content for 9.0 version
+:::
+:::{applies-item} { "stack": "ga 9.1" }
+Content for 9.1 version
+:::
+::::
+
+::::{applies-switch}
+:::{applies-item} stack: preview 9.0
+Other content for 9.0 version
+:::
+:::{applies-item} stack: ga 9.1
+Other content for 9.1 version
+:::
+::::
+
+:::::
+:::::{tab-item} Markdown
+
+```markdown
+::::{applies-switch}
+:::{applies-item} { "stack": "preview 9.0" }
+Content for 9.0 version
+:::
+:::{applies-item} { "stack": "ga 9.1" }
+Content for 9.1 version
+:::
+::::
+
+::::{applies-switch}
+:::{applies-item} stack: preview 9.0
+Other content for 9.0 version
+:::
+:::{applies-item} stack: ga 9.1
+Other content for 9.1 version
+:::
+::::
+```
+:::::
+::::::
+
+## Supported `applies_to` definitions
+
+The `applies-item` directive accepts any valid applies_to definition that would work with the `{applies_to}` role.
+
+See the [](applies.md) page for more details on valid `applies_to` definitions.
+
+## When to use
+
+Use applies switches when:
+
+- Content varies significantly by deployment type, version, or other applicability criteria
+- You want to show applies_to badges as tab titles instead of text
+- You need to group related content that differs by applicability
+- You want to provide a clear visual indication of what each content section applies to
diff --git a/src/Elastic.Documentation.Site/Assets/applies-switch.ts b/src/Elastic.Documentation.Site/Assets/applies-switch.ts
new file mode 100644
index 000000000..5f7fe933b
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/applies-switch.ts
@@ -0,0 +1,88 @@
+// TODO: refactor to typescript. this was copied from the tabs implementation
+
+// @ts-check
+
+// Extra JS capability for selected applies switches to be synced
+// The selection is stored in local storage so that it persists across page loads.
+
+const as_id_to_elements = {}
+const storageKeyPrefix = 'sphinx-design-applies-switch-id-'
+
+function create_key(el: HTMLElement) {
+ const syncId = el.getAttribute('data-sync-id')
+ const syncGroup = el.getAttribute('data-sync-group')
+ if (!syncId || !syncGroup) return null
+ return [syncGroup, syncId, syncGroup + '--' + syncId]
+}
+
+/**
+ * Initialize the applies switch selection.
+ *
+ */
+function ready() {
+ // Find all applies switches with sync data
+
+ const groups = []
+
+ document.querySelectorAll('.applies-switch-label').forEach((label) => {
+ if (label instanceof HTMLElement) {
+ const data = create_key(label)
+ if (data) {
+ const [group, id, key] = data
+
+ // add click event listener
+ label.onclick = onAppliesSwitchLabelClick
+
+ // store map of key to elements
+ if (!as_id_to_elements[key]) {
+ as_id_to_elements[key] = []
+ }
+ as_id_to_elements[key].push(label)
+
+ if (groups.indexOf(group) === -1) {
+ groups.push(group)
+ // Check if a specific switch has been selected via URL parameter
+ const switchParam = new URLSearchParams(
+ window.location.search
+ ).get(group)
+ if (switchParam) {
+ window.sessionStorage.setItem(
+ storageKeyPrefix + group,
+ switchParam
+ )
+ }
+ }
+
+ // Check is a specific switch has been selected previously
+ const previousId = window.sessionStorage.getItem(
+ storageKeyPrefix + group
+ )
+ if (previousId === id) {
+ ;(
+ label.previousElementSibling as HTMLInputElement
+ ).checked = true
+ }
+ }
+ }
+ })
+}
+
+/**
+ * Activate other switches with the same sync id.
+ *
+ * @this {HTMLElement} - The element that was clicked.
+ */
+function onAppliesSwitchLabelClick() {
+ const data = create_key(this)
+ if (!data) return
+ const [group, id, key] = data
+ for (const label of as_id_to_elements[key]) {
+ if (label === this) continue
+ label.previousElementSibling.checked = true
+ }
+ window.sessionStorage.setItem(storageKeyPrefix + group, id)
+}
+
+export function initAppliesSwitch() {
+ ready()
+}
diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts
index 1765ed596..ec0f22c6f 100644
--- a/src/Elastic.Documentation.Site/Assets/main.ts
+++ b/src/Elastic.Documentation.Site/Assets/main.ts
@@ -1,3 +1,4 @@
+import { initAppliesSwitch } from './applies-switch'
import { initCopyButton } from './copybutton'
import { initHighlight } from './hljs'
import { initImageCarousel } from './image-carousel'
@@ -21,6 +22,7 @@ document.addEventListener('htmx:load', function (event) {
initHighlight()
initCopyButton()
initTabs()
+ initAppliesSwitch()
// We do this so that the navigation is not initialized twice
if (isLazyLoadNavigationEnabled) {
diff --git a/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css b/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css
new file mode 100644
index 000000000..aaa4ae90d
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css
@@ -0,0 +1,64 @@
+@layer components {
+ .applies-switch {
+ @apply relative mt-4 flex flex-wrap overflow-hidden;
+
+ .applies-switch-label {
+ @apply text-ink-light border-grey-20 z-20 -mb-[1px] flex cursor-pointer items-center border-1 px-3 py-2;
+ &:not(:nth-of-type(1)) {
+ margin-left: -1px;
+ }
+
+ &:hover {
+ @apply bg-grey-10 border-b-1 border-b-black text-black;
+ }
+ }
+
+ .applies-item {
+ @apply cursor-pointer text-inherit;
+
+ &:hover {
+ @apply text-inherit;
+ }
+ .applicable-info {
+ @apply cursor-pointer border-none bg-transparent p-0;
+
+ &:not(:last-child):after {
+ content: ',';
+ }
+ }
+ .applicable-name,
+ .applicable-meta {
+ @apply text-base;
+ }
+ }
+
+ .applies-switch-input {
+ @apply absolute opacity-0;
+ }
+
+ .applies-switch-content {
+ @apply border-grey-20 z-0 order-99 hidden w-full border-1 px-6 pt-2 pb-6;
+ }
+
+ .applies-switch-input:checked
+ + .applies-switch-label
+ + .applies-switch-content {
+ @apply block;
+ }
+
+ .applies-switch-input:checked + .applies-switch-label,
+ .applies-switch-label:active {
+ @apply border-b-blue-elastic text-blue-elastic border-b-1;
+ }
+
+ .applies-switch-input:focus-visible + .applies-switch-label {
+ outline: var(--outline-size) var(--outline-style)
+ var(--outline-color);
+ outline-offset: var(--outline-offset, var(--outline-size));
+ }
+
+ .applies-switch-fallback {
+ @apply text-sm font-medium;
+ }
+ }
+}
diff --git a/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts b/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts
index bec3af533..dd715904e 100644
--- a/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts
+++ b/src/Elastic.Documentation.Site/Assets/open-details-with-anchor.ts
@@ -1,6 +1,6 @@
import { UAParser } from 'ua-parser-js'
-const { getBrowser } = new UAParser()
+const { browser } = UAParser()
// This is a fix for anchors in details elements in non-Chrome browsers.
export function openDetailsWithAnchor() {
@@ -9,7 +9,6 @@ export function openDetailsWithAnchor() {
if (target) {
const closestDetails = target.closest('details')
if (closestDetails) {
- const browser = getBrowser()
if (browser.name !== 'Chrome') {
closestDetails.open = true
target.scrollIntoView({
diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css
index f68803c76..dce11a744 100644
--- a/src/Elastic.Documentation.Site/Assets/styles.css
+++ b/src/Elastic.Documentation.Site/Assets/styles.css
@@ -7,6 +7,7 @@
@import './markdown/typography.css';
@import './markdown/list.css';
@import './markdown/tabs.css';
+@import './markdown/applies-switch.css';
@import './markdown/code.css';
@import './markdown/icons.css';
@import './markdown/kbd.css';
diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj
index e9fabe6d9..9d5529c8d 100644
--- a/src/Elastic.Markdown/Elastic.Markdown.csproj
+++ b/src/Elastic.Markdown/Elastic.Markdown.csproj
@@ -43,4 +43,9 @@
+
+
+
+
+
diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml
index a4f7aa3d6..a9831405c 100644
--- a/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml
+++ b/src/Elastic.Markdown/Myst/Components/ApplicableToComponent.cshtml
@@ -2,7 +2,7 @@
@foreach (var item in Model.GetApplicabilityItems())
{
-
+ @item.Key
@if (!string.IsNullOrEmpty(item.Key) && (item.RenderData.ShowLifecycleName || item.RenderData.ShowVersion || !string.IsNullOrEmpty(item.RenderData.BadgeLifecycleText)))
diff --git a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs
index 756a020e7..bb7cc6093 100644
--- a/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs
+++ b/src/Elastic.Markdown/Myst/Components/ApplicableToViewModel.cs
@@ -13,6 +13,8 @@ public class ApplicableToViewModel
private readonly ApplicabilityRenderer _applicabilityRenderer = new();
public required bool Inline { get; init; }
+
+ public bool ShowTooltip { get; init; } = true;
public required ApplicableTo AppliesTo { get; init; }
public required VersionsConfiguration VersionsConfig { get; init; }
@@ -162,4 +164,3 @@ private IEnumerable CombineItemsByKey(List
}
-
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml
new file mode 100644
index 000000000..b60efc6df
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml
@@ -0,0 +1,36 @@
+@using Elastic.Markdown.Myst.Components
+@inherits RazorSlice
+
+@{
+ var id = $"applies-switch-item-{Model.AppliesSwitchIndex}-{Model.Index}";
+}
+
+
+
+
+ @Model.RenderBlock()
+
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs
new file mode 100644
index 000000000..2219a247c
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs
@@ -0,0 +1,20 @@
+// 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.AppliesTo;
+using Elastic.Documentation.Configuration;
+
+namespace Elastic.Markdown.Myst.Directives.AppliesSwitch;
+
+public class AppliesItemViewModel : DirectiveViewModel
+{
+ public required int Index { get; init; }
+ public required int AppliesSwitchIndex { get; init; }
+ public required string AppliesToDefinition { get; init; }
+ public required ApplicableTo? AppliesTo { get; init; }
+ public required string? SyncKey { get; init; }
+ public required string? AppliesSwitchGroupKey { get; init; }
+ public required BuildContext BuildContext { get; init; }
+}
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs
new file mode 100644
index 000000000..86f06e36c
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs
@@ -0,0 +1,89 @@
+// 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;
+using Elastic.Documentation.AppliesTo;
+using Elastic.Markdown.Diagnostics;
+using Elastic.Markdown.Helpers;
+
+namespace Elastic.Markdown.Myst.Directives.AppliesSwitch;
+
+public class AppliesSwitchBlock(DirectiveBlockParser parser, ParserContext context)
+ : DirectiveBlock(parser, context)
+{
+ public override string Directive => "applies-switch";
+
+ public int Index { get; set; }
+ public string GetGroupKey() => Prop("group") ?? "applies-switches";
+
+ public override void FinalizeAndValidate(ParserContext context) => Index = FindIndex();
+
+ private int _index = -1;
+
+ // For simplicity, we use the line number as the index.
+ // It's not ideal, but it's unique.
+ // This is more efficient than finding the root block and then finding the index.
+ public int FindIndex()
+ {
+ if (_index > -1)
+ return _index;
+
+ _index = Line;
+ return _index;
+ }
+}
+
+public class AppliesItemBlock(DirectiveBlockParser parser, ParserContext context)
+ : DirectiveBlock(parser, context), IBlockTitle
+{
+ public override string Directive => "applies-item";
+
+ public string AppliesToDefinition { get; private set; } = default!;
+ public string Title => AppliesToDefinition; // IBlockTitle implementation
+ public int Index { get; private set; }
+ public int AppliesSwitchIndex { get; private set; }
+ public string? AppliesSwitchGroupKey { get; private set; }
+ public string? SyncKey { get; private set; }
+ public bool Selected { get; private set; }
+
+ public override void FinalizeAndValidate(ParserContext context)
+ {
+ if (string.IsNullOrWhiteSpace(Arguments))
+ 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;
+
+ AppliesSwitchIndex = appliesSwitch?.FindIndex() ?? -1;
+ AppliesSwitchGroupKey = appliesSwitch?.GetGroupKey();
+
+ // Auto-generate sync key from applies_to definition if not provided
+ SyncKey = Prop("sync") ?? GenerateSyncKey(AppliesToDefinition);
+ Selected = PropBool("selected");
+ }
+
+ public static string GenerateSyncKey(string appliesToDefinition)
+ {
+ // Parse the YAML to get the ApplicableTo object, then use its hash
+ // This ensures both simple syntax and YAML objects produce consistent sync keys
+ try
+ {
+ var applicableTo = YamlSerialization.Deserialize(appliesToDefinition);
+ if (applicableTo != null)
+ {
+ // Use the object's hash for a consistent, unique identifier
+ return $"applies-{Math.Abs(applicableTo.GetHashCode())}";
+ }
+ }
+ catch
+ {
+ // If parsing fails, fall back to the original definition
+ }
+
+ // Fallback to original definition if parsing fails
+ return appliesToDefinition.Slugify().Replace(".", "-");
+ }
+}
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml
new file mode 100644
index 000000000..d6946d1b7
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml
@@ -0,0 +1,5 @@
+@inherits RazorSlice
+
+ @Model.RenderBlock()
+
+
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs
new file mode 100644
index 000000000..1b65b7087
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs
@@ -0,0 +1,8 @@
+// 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.Myst.Directives.AppliesSwitch;
+
+public class AppliesSwitchViewModel : DirectiveViewModel;
+
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
index f53825fc0..01a33ea7f 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
@@ -4,6 +4,7 @@
using System.Collections.Frozen;
using Elastic.Markdown.Myst.Directives.Admonition;
+using Elastic.Markdown.Myst.Directives.AppliesSwitch;
using Elastic.Markdown.Myst.Directives.CsvInclude;
using Elastic.Markdown.Myst.Directives.Diagram;
using Elastic.Markdown.Myst.Directives.Image;
@@ -90,6 +91,12 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor)
if (info.IndexOf("{tab-item}") > 0)
return new TabItemBlock(this, context);
+ if (info.IndexOf("{applies-switch}") > 0)
+ return new AppliesSwitchBlock(this, context);
+
+ if (info.IndexOf("{applies-item}") > 0)
+ return new AppliesItemBlock(this, context);
+
if (info.IndexOf("{dropdown}") > 0)
return new DropdownBlock(this, context);
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
index b17629f3e..faace8aa3 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
@@ -3,9 +3,11 @@
// See the LICENSE file in the project root for more information
using System.Diagnostics.CodeAnalysis;
+using Elastic.Documentation.AppliesTo;
using Elastic.Markdown.Diagnostics;
using Elastic.Markdown.Myst.CodeBlocks;
using Elastic.Markdown.Myst.Directives.Admonition;
+using Elastic.Markdown.Myst.Directives.AppliesSwitch;
using Elastic.Markdown.Myst.Directives.CsvInclude;
using Elastic.Markdown.Myst.Directives.Diagram;
using Elastic.Markdown.Myst.Directives.Dropdown;
@@ -70,6 +72,12 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo
case TabItemBlock tabItem:
WriteTabItem(renderer, tabItem);
return;
+ case AppliesSwitchBlock appliesSwitch:
+ WriteAppliesSwitch(renderer, appliesSwitch);
+ return;
+ case AppliesItemBlock appliesItem:
+ WriteAppliesItem(renderer, appliesItem);
+ return;
case LiteralIncludeBlock literalIncludeBlock:
WriteLiteralIncludeBlock(renderer, literalIncludeBlock);
return;
@@ -251,6 +259,50 @@ private static void WriteTabItem(HtmlRenderer renderer, TabItemBlock block)
RenderRazorSlice(slice, renderer);
}
+ private static void WriteAppliesSwitch(HtmlRenderer renderer, AppliesSwitchBlock block)
+ {
+ var slice = AppliesSwitchView.Create(new AppliesSwitchViewModel { DirectiveBlock = block });
+ RenderRazorSlice(slice, renderer);
+ }
+
+ private static void WriteAppliesItem(HtmlRenderer renderer, AppliesItemBlock block)
+ {
+ // Parse the applies_to definition to get the ApplicableTo object
+ var appliesTo = ParseApplicableTo(block.AppliesToDefinition, block);
+ var slice = AppliesItemView.Create(new AppliesItemViewModel
+ {
+ DirectiveBlock = block,
+ Index = block.Index,
+ AppliesToDefinition = block.AppliesToDefinition,
+ AppliesTo = appliesTo,
+ AppliesSwitchIndex = block.AppliesSwitchIndex,
+ SyncKey = block.SyncKey,
+ AppliesSwitchGroupKey = block.AppliesSwitchGroupKey,
+ BuildContext = block.Build
+ });
+ RenderRazorSlice(slice, renderer);
+ }
+
+ private static ApplicableTo? ParseApplicableTo(string yaml, DirectiveBlock block)
+ {
+ try
+ {
+ var applicableTo = YamlSerialization.Deserialize(yaml);
+ if (applicableTo.Diagnostics is null)
+ return applicableTo;
+ foreach (var (severity, message) in applicableTo.Diagnostics)
+ block.Emit(severity, message);
+ applicableTo.Diagnostics = null;
+ return applicableTo;
+ }
+ catch (Exception e)
+ {
+ block.EmitError($"Unable to parse applies_to definition: {yaml}", e);
+ }
+
+ return null;
+ }
+
private static void WriteDiagram(HtmlRenderer renderer, DiagramBlock block)
{
var slice = DiagramView.Create(new DiagramViewModel
diff --git a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs
new file mode 100644
index 000000000..70e458bfd
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs
@@ -0,0 +1,226 @@
+// 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.Markdown.Myst.Directives.AppliesSwitch;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.Directives;
+
+public class ApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::::{applies-switch}
+
+::::{applies-item} stack: preview 9.1
+:::{tip}
+This feature is in preview for Elastic Stack 9.1.
+:::
+::::
+
+::::{applies-item} ess: preview 9.1
+:::{note}
+This feature is available for Elastic Cloud.
+:::
+::::
+
+::::{applies-item} ece: removed
+:::{warning}
+This feature has been removed from Elastic Cloud Enterprise.
+:::
+::::
+
+:::::
+"""
+)
+{
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void ParsesApplicabilitySwitchItems()
+ {
+ var items = Block!.OfType().ToArray();
+ items.Should().NotBeNull().And.HaveCount(3);
+ for (var i = 0; i < items.Length; i++)
+ items[i].Index.Should().Be(i);
+ }
+
+ [Fact]
+ public void ParsesAppliesToDefinitions()
+ {
+ var items = Block!.OfType().ToArray();
+ items[0].AppliesToDefinition.Should().Be("stack: preview 9.1");
+ items[1].AppliesToDefinition.Should().Be("ess: preview 9.1");
+ items[2].AppliesToDefinition.Should().Be("ece: removed");
+ }
+
+ [Fact]
+ public void SetsCorrectDirectiveType()
+ {
+ Block!.Directive.Should().Be("applies-switch");
+ var items = Block.OfType().ToArray();
+ foreach (var item in items)
+ item.Directive.Should().Be("applies-item");
+ }
+}
+
+public class MultipleApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::::{applies-switch}
+::::{applies-item} stack: ga 8.11
+Content for GA version
+::::
+:::::
+
+Paragraph
+
+:::::{applies-switch}
+::::{applies-item} stack: preview 9.1
+Content for preview version
+::::
+:::::
+"""
+)
+{
+ [Fact]
+ public void ParsesMultipleApplicabilitySwitches()
+ {
+ var switches = Document.OfType().ToArray();
+ switches.Length.Should().Be(2);
+ for (var s = 0; s < switches.Length; s++)
+ {
+ var items = switches[s].OfType().ToArray();
+ items.Should().NotBeNull().And.HaveCount(1);
+ for (var i = 0; i < items.Length; i++)
+ {
+ items[i].Index.Should().Be(i);
+ items[i].AppliesSwitchIndex.Should().Be(switches[s].Line);
+ }
+ }
+ }
+}
+
+public class GroupApplicabilitySwitchTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+::::{applies-switch}
+:::{applies-item} stack: ga 8.11
+Content for GA version
+:::
+
+:::{applies-item} stack: preview 9.1
+Content for preview version
+:::
+
+:::{applies-item} stack: removed
+Content for removed version
+:::
+
+::::
+
+::::{applies-switch}
+:::{applies-item} stack: ga 8.11
+Content for GA version
+:::
+
+:::{applies-item} stack: preview 9.1
+Content for preview version
+:::
+
+:::{applies-item} stack: removed
+Content for removed version
+:::
+
+::::
+"""
+)
+{
+ [Fact]
+ public void ParsesMultipleApplicabilitySwitches()
+ {
+ var switches = Document.OfType().ToArray();
+ switches.Length.Should().Be(2);
+ for (var s = 0; s < switches.Length; s++)
+ {
+ var items = switches[s].OfType().ToArray();
+ items.Should().NotBeNull().And.HaveCount(3);
+ for (var i = 0; i < items.Length; i++)
+ {
+ items[i].Index.Should().Be(i);
+ items[i].AppliesSwitchIndex.Should().Be(switches[s].Line);
+ }
+ }
+ }
+
+ [Fact]
+ public void ParsesGroup()
+ {
+ var switches = Document.OfType().ToArray();
+ switches.Length.Should().Be(2);
+
+ foreach (var s in switches)
+ s.GetGroupKey().Should().Be("applies-switches");
+ }
+
+ [Fact]
+ public void ParsesSyncKey()
+ {
+ var switchBlock = Document.OfType().First();
+ var items = switchBlock.OfType().ToArray();
+ items.Should().HaveCount(3);
+
+ // Verify all sync keys have the expected hash-based format
+ foreach (var item in items)
+ {
+ item.SyncKey.Should().StartWith("applies-", "Sync key should start with 'applies-' prefix");
+ item.SyncKey.Should().MatchRegex(@"^applies-\d+$", "Sync key should be in format 'applies-{hash}'");
+ }
+
+ // Verify that different applies_to definitions produce different sync keys
+ items[0].SyncKey.Should().NotBe(items[1].SyncKey, "Different applies_to definitions should produce different sync keys");
+ items[1].SyncKey.Should().NotBe(items[2].SyncKey, "Different applies_to definitions should produce different sync keys");
+ items[0].SyncKey.Should().NotBe(items[2].SyncKey, "Different applies_to definitions should produce different sync keys");
+ }
+
+ [Fact]
+ public void NormalizesSyncKeyOrder()
+ {
+ // Test that different orderings of the same applies_to definition generate the same sync key
+ var testCases = new[]
+ {
+ ("stack: preview 9.0, ga 9.1", "stack: ga 9.1, preview 9.0"),
+ ("ess: preview 8.11, ga 8.10", "ess: ga 8.10, preview 8.11"),
+ ("stack: removed, preview 9.0", "stack: preview 9.0, removed")
+ };
+
+ foreach (var (definition1, definition2) in testCases)
+ {
+ var key1 = AppliesItemBlock.GenerateSyncKey(definition1);
+ var key2 = AppliesItemBlock.GenerateSyncKey(definition2);
+ key1.Should().Be(key2, $"Sync keys should be the same for '{definition1}' and '{definition2}'");
+ }
+ }
+
+ [Fact]
+ public void GeneratesConsistentSyncKeysForYamlObjects()
+ {
+ // Test that YAML object syntax and simple syntax produce the same sync key
+ var testCases = new[]
+ {
+ ("stack: ga 9.1", "stack: ga 9.1"), // Same format should produce same key
+ ("{ ece: all, ess: all }", "deployment: { ece: all, ess: all }"), // YAML object vs deployment object
+ ("{ stack: ga 9.1 }", "stack: ga 9.1"), // YAML object vs simple syntax
+ ("{ deployment: { ece: ga 9.0, ess: ga 9.1 } }", "deployment: { ece: ga 9.0, ess: ga 9.1 }"), // Nested YAML objects
+ };
+
+ foreach (var (yamlObject, equivalentSyntax) in testCases)
+ {
+ var key1 = AppliesItemBlock.GenerateSyncKey(yamlObject);
+ var key2 = AppliesItemBlock.GenerateSyncKey(equivalentSyntax);
+ key1.Should().Be(key2, $"Sync keys should be the same for YAML object '{yamlObject}' and equivalent syntax '{equivalentSyntax}'");
+
+ // Also verify the key has the expected format
+ key1.Should().StartWith("applies-", "Sync key should start with 'applies-' prefix");
+ key1.Should().MatchRegex(@"^applies-\d+$", "Sync key should be in format 'applies-{hash}'");
+ }
+ }
+}