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}'"); + } + } +}