From 9d85de526a2b6cfe559b5f1805c4be564c34006b Mon Sep 17 00:00:00 2001 From: Florent LB Date: Tue, 28 Jul 2026 18:21:05 +0200 Subject: [PATCH] Add dropdown appearance to applies-switch for versioned code examples Documentation increasingly shows API payloads that differ across versions and deployment types. The tabs appearance spends a full-width tab bar per snippet and scales poorly on pages with many versioned examples. :appearance: dropdown renders an applies-switch as a compact selector chip attached to the top-right corner of each item's code block, with a floating panel listing the alternatives. Items accept the full applies_to syntax and each carries its own code block and callout list, so callouts always match the selected version. Selections render as compact text labels (9.1+, ECH 8.0+, 9.0 (preview)) derived from the same applicability pipeline as badges, sync across all switches on the page regardless of appearance, and persist per session. Guard rails: every item must start with a code block, otherwise the switch warns and falls back to tabs; unknown :appearance: values do the same. :selected: now marks the default item (previously parsed but ignored); multiple :selected: items warn. Hidden radios no longer intercept pointer events in the selector, and selection sync now also covers keyboard and panel-row selections via change events. Also hardens llms.txt generation against code blocks with no content lines, which crashed the LLM exporter with a NullReferenceException. Co-Authored-By: Claude Fable 5 --- docs/syntax/applies-switch.md | 158 ++++++++++++++ .../Assets/applies-switch.ts | 109 +++++++++- .../Assets/markdown/applies-switch.css | 139 +++++++++++- .../AppliesSwitch/AppliesItemLabelView.cshtml | 41 ++++ .../AppliesSwitch/AppliesItemView.cshtml | 48 ++--- .../AppliesSwitch/AppliesItemViewModel.cs | 63 ++++++ .../AppliesSwitch/AppliesSwitchBlock.cs | 48 ++++- .../AppliesSwitch/AppliesSwitchView.cshtml | 19 +- .../AppliesSwitch/AppliesSwitchViewModel.cs | 9 +- .../Myst/Directives/DirectiveHtmlRenderer.cs | 29 ++- .../LlmMarkdown/LlmBlockRenderers.cs | 4 + .../Directives/ApplicabilitySwitchTests.cs | 203 ++++++++++++++++++ 12 files changed, 823 insertions(+), 47 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemLabelView.cshtml diff --git a/docs/syntax/applies-switch.md b/docs/syntax/applies-switch.md index e56f6f07cd..13b87856ae 100644 --- a/docs/syntax/applies-switch.md +++ b/docs/syntax/applies-switch.md @@ -139,6 +139,163 @@ Other content for version 9.0 ::::: :::::: +## Dropdown appearance + +Add `:appearance: dropdown` to render the switch as a compact dropdown instead of tabs. This works well for version-specific code examples: readers select the version they run, and the code block, including its callouts, updates to match. + +Because each `applies-item` contains both the code block and its callout list, the callouts always match the selected version. + +The dropdown appearance requires every `applies-item` to start with a code block: the selector chip attaches to the code block's top edge. A switch with other leading content falls back to the tabs appearance and docs-builder emits a warning. + +Instead of badges, the selector shows a compact text form of each `applies_to` definition: Elastic Stack entries show only the version (`9.1+`, `=9.0`, `9.1-9.3`), other entries keep their product or deployment name (`Serverless`, `ECH 8.0+`), and the lifecycle appears in parentheses when it is not GA, for example `9.0 (preview)`. Hover the selector to see the full definition. + +The appearance only changes the presentation. Switches with the same `applies_to` definitions stay in sync through the same [automatic grouping](#automatic-grouping) regardless of their appearance: selecting a version in a dropdown switch also updates tab switches on the page, and the other way around. + +::::::{tab-set} +:::::{tab-item} Output + +::::{applies-switch} +:appearance: dropdown + +:::{applies-item} { serverless: ga, stack: ga 9.1+ } +:selected: +```console +PUT api/dashboards/dashboard/my-dashboard +{ + "attributes": { + "title": "My dashboard", <1> + "panels": [ + { + "type": "metric", + "config": { + "metrics": [ { "field": "system.cpu.usage" } ] <2> + } + } + ] + } +} +``` + +1. The dashboard title, displayed in the dashboard listing. +2. In this version, the metric chart accepts multiple metrics. + +::: + +:::{applies-item} stack: preview =9.0 +```console +PUT api/dashboards/dashboard/my-dashboard +{ + "attributes": { + "title": "My dashboard", <1> + "panels": [ + { + "type": "metric", + "config": { + "metric": { "field": "system.cpu.usage" } <2> + } + } + ] + } +} +``` + +1. The dashboard title, displayed in the dashboard listing. +2. In this version, the metric chart accepts a single metric. + +::: + +:::: + +::::: +:::::{tab-item} Markdown + +`````markdown +::::{applies-switch} +:appearance: dropdown + +:::{applies-item} { serverless: ga, stack: ga 9.1+ } +:selected: +```console +PUT api/dashboards/dashboard/my-dashboard +{ + "attributes": { + "title": "My dashboard", <1> + ... + } +} +``` + +1. The dashboard title, displayed in the dashboard listing. + +::: + +:::{applies-item} stack: preview =9.0 +```console +PUT api/dashboards/dashboard/my-dashboard +{ + "attributes": { ... } +} +``` + +1. The dashboard title, displayed in the dashboard listing. + +::: + +:::: +````` +::::: +:::::: + +## Default selection + +By default, the first `applies-item` is selected. Add the `:selected:` option to an item to select a different one, for example to default to the newest version when older versions come first in the source. If multiple items have `:selected:`, only the first one is honored and docs-builder emits a warning. + +::::::{tab-set} +:::::{tab-item} Output + +::::{applies-switch} +:appearance: dropdown + +:::{applies-item} stack: preview =9.0 +```console +GET api/dashboards/dashboard +``` +::: + +:::{applies-item} stack: ga 9.1+ +:selected: +```console +GET api/dashboards/dashboard?page=1 +``` +::: + +:::: + +::::: +:::::{tab-item} Markdown + +````markdown +::::{applies-switch} +:appearance: dropdown + +:::{applies-item} stack: preview =9.0 +```console +GET api/dashboards/dashboard +``` +::: + +:::{applies-item} stack: ga 9.1+ +:selected: +```console +GET api/dashboards/dashboard?page=1 +``` +::: + +:::: +```` +::::: +:::::: + ## Supported `applies_to` definitions The `applies-item` directive accepts any valid applies_to definition that would work with the `{applies_to}` role. @@ -153,3 +310,4 @@ Use applies switches when: - 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 +- You want to offer version-specific code examples without duplicating the surrounding prose: use the dropdown appearance diff --git a/src/Elastic.Documentation.Site/Assets/applies-switch.ts b/src/Elastic.Documentation.Site/Assets/applies-switch.ts index d9b7245456..c88437a8a7 100644 --- a/src/Elastic.Documentation.Site/Assets/applies-switch.ts +++ b/src/Elastic.Documentation.Site/Assets/applies-switch.ts @@ -64,6 +64,9 @@ function ready() { } } }) + + // Reflect the (possibly restored) selection in dropdown content panes + $$optional('.applies-switch--dropdown').forEach(updateDropdownContents) } /** @@ -72,20 +75,116 @@ function ready() { * @this {HTMLElement} - The element that was clicked. */ function onAppliesSwitchLabelClick(this: HTMLLabelElement) { - const data = create_key(this) + syncSelection(this) +} + +/** + * Activate the same applies_to selection in all other switches on the page + * and persist it. `label` is the trigger label of the newly selected option. + */ +function syncSelection(label: HTMLElement) { + const data = create_key(label) if (!data) return const [group, id, key] = data - for (const label of as_id_to_elements[key]) { - if (label === this) { + for (const other of as_id_to_elements[key] ?? []) { + if (other === label) { continue } - if (label.previousElementSibling instanceof HTMLInputElement) { - label.previousElementSibling.checked = true + if (other.previousElementSibling instanceof HTMLInputElement) { + other.previousElementSibling.checked = true + // Setting .checked programmatically fires no change event, so + // synced dropdowns need their content panes updated explicitly + const dropdown = other.closest('.applies-switch--dropdown') + if (dropdown) updateDropdownContents(dropdown) } } window.sessionStorage.setItem(storageKeyPrefix + group, id) } +/** + * Reflect the checked input of a dropdown switch: show the matching content + * pane and hide the matching panel row (the current selection is already + * visible in the chip, the panel only lists the alternatives). + * + * Dropdown switches group their inputs and labels in a selector overlay, so + * the pure-CSS sibling selector used by the tabs appearance cannot reach the + * content panes. + */ +function updateDropdownContents(dropdown: Element) { + const checked = dropdown.querySelector('.applies-switch-input:checked') + if (!checked) return + const index = checked.getAttribute('data-index') + dropdown.querySelectorAll('.applies-switch-content').forEach((content) => { + content.classList.toggle( + 'applies-switch-content--active', + content.getAttribute('data-index') === index + ) + }) + dropdown.querySelectorAll('.applies-switch-panel-row').forEach((row) => { + row.classList.toggle( + 'applies-switch-panel-row--current', + row.getAttribute('data-index') === index + ) + }) +} + +/** + * Open/close behavior for switches with the dropdown appearance. + * + * Delegated document-level listeners registered once at module scope so they + * survive htmx swaps (initAppliesSwitch re-runs on every htmx:load). + */ +function closeDropdowns(except?: Element | null) { + document + .querySelectorAll('.applies-switch--dropdown.open') + .forEach((dropdown) => { + if (dropdown !== except) dropdown.classList.remove('open') + }) +} + +document.addEventListener('click', (event) => { + const target = event.target as HTMLElement + // A click on a label also fires a synthetic click on its radio input; + // ignore it so it doesn't immediately close the dropdown we just opened. + if (target.closest('.applies-switch--dropdown .applies-switch-input')) { + return + } + // Selecting a panel row checks its radio natively (label for=); the + // change listener below does the rest, so only close the menu here. + if (target.closest('.applies-switch-panel-row')) { + closeDropdowns() + return + } + const label = target.closest( + '.applies-switch--dropdown .applies-switch-label' + ) + if (!label) { + closeDropdowns() + return + } + const dropdown = label.closest('.applies-switch--dropdown') + if (!dropdown) return + dropdown.classList.toggle('open') + closeDropdowns(dropdown) +}) + +document.addEventListener('change', (event) => { + const input = (event.target as HTMLElement).closest( + '.applies-switch--dropdown .applies-switch-input' + ) + const dropdown = input?.closest('.applies-switch--dropdown') + if (!input || !dropdown) return + updateDropdownContents(dropdown) + // Panel-row and keyboard selections bypass the trigger-label click + // handler, so propagate the sync from here. + const label = input.nextElementSibling + if (label instanceof HTMLElement) syncSelection(label) +}) + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeDropdowns() +}) + export function initAppliesSwitch() { ready() } diff --git a/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css b/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css index 4d183e939a..a901fa5352 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/applies-switch.css @@ -13,7 +13,7 @@ } } - & > .applies-switch-label > .applies-item { + .applies-switch-label > .applies-item { @apply cursor-pointer text-inherit; &:hover { @@ -56,5 +56,142 @@ .applies-switch-fallback { @apply text-sm font-medium; } + + /* Dropdown appearance: a tab-shaped trigger chip sits attached to the + content's first block at its upper-right corner; opening it shows a + detached floating panel with the other options. The trigger groups + all inputs and labels in .applies-switch-select (only the checked + one is visible); the panel rows are extra labels pointing at the + same radios. Content visibility and the panel's current-row hiding + are handled by applies-switch.ts. */ + &.applies-switch--dropdown { + @apply relative block; + + .applies-switch-select { + @apply border-grey-30 absolute top-0 right-0 z-10 flex w-max cursor-pointer flex-col items-stretch overflow-hidden border-1 border-b-0 bg-white; + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; + box-shadow: 0 -2px 6px rgb(0 0 0 / 0.05); + } + + /* The invisible absolutely-positioned radios all stack at the + chip's top-left corner and would intercept clicks there (dead + clicks or silent switches). Labels are the only pointer + targets; the radios stay keyboard-focusable. */ + .applies-switch-input { + pointer-events: none; + } + + .applies-switch-label { + @apply flex items-center border-0 px-3; + column-gap: 0.5rem; + /* Collapsed options stay in the layout (invisible, zero + height) so the chip is always as wide as the widest + option and never resizes on selection. */ + visibility: hidden; + height: 0; + padding-top: 0; + padding-bottom: 0; + overflow: hidden; + margin-left: 0; + margin-bottom: 0; + + &:hover { + @apply bg-grey-10; + border-bottom: 0; + transition: background-color 0.12s ease; + } + } + + /* Text color comes from the component base rule: checked labels + are text-blue-elastic, the docs' interactive accent. The + chevron follows via currentColor. */ + .applies-switch-input:checked + .applies-switch-label { + visibility: visible; + height: 2.25rem; + border-bottom: 0; + } + + .applies-switch-chevron { + @apply shrink-0; + margin-left: auto; + transition: transform 0.18s ease; + } + + .applies-switch-short { + @apply text-sm font-medium whitespace-nowrap; + } + + /* Floating options panel, right-aligned 4px below the chip. */ + .applies-switch-panel { + @apply absolute right-0 z-20 w-max rounded-lg bg-white p-1; + top: calc(2.25rem + 1px + 4px); + border: 1px solid var(--color-grey-20); + box-shadow: + 0 8px 24px rgb(0 0 0 / 0.12), + 0 2px 6px rgb(0 0 0 / 0.08); + opacity: 0; + transform: translateY(-4px); + pointer-events: none; + transition: + opacity 0.16s ease, + transform 0.16s ease; + } + + .applies-switch-panel-row { + @apply text-ink-light flex cursor-pointer items-center rounded-md px-3 text-sm whitespace-nowrap; + height: 1.875rem; + transition: background-color 0.12s ease; + + &:hover { + @apply bg-grey-10 text-black; + } + + /* The current selection is visible in the chip right above; + the panel only lists the alternatives. */ + &.applies-switch-panel-row--current { + display: none; + } + } + + /* The chip sits flush on the content's first block: no frame, and + the top padding matches the chip's outer height exactly + (2.25rem label + 1px top border) so the block's top edge runs + at the same line beside and under the chip. */ + .applies-switch-content { + @apply hidden w-full border-0 px-0 pb-0; + padding-top: calc(2.25rem + 1px); + + & > :first-child { + @apply mt-0; + + .highlight { + @apply mt-0; + } + + /* The chip covers the block's top-right corner; a rounded + corner would leave a light notch under it. */ + pre code:first-child { + @apply rounded-tr-none; + } + } + } + + .applies-switch-content--active { + @apply block; + } + + &.open { + .applies-switch-panel { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + .applies-switch-chevron { + transform: rotate(180deg); + } + } + } } } diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemLabelView.cshtml b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemLabelView.cshtml new file mode 100644 index 0000000000..bef858202e --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemLabelView.cshtml @@ -0,0 +1,41 @@ +@using Elastic.Markdown.Myst.Components +@inherits RazorSlice + +@{ + var id = $"applies-switch-item-{Model.AppliesSwitchIndex}-{Model.Index}"; +} + + + diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml index b60efc6df8..ee209f1421 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemView.cshtml @@ -1,36 +1,18 @@ -@using Elastic.Markdown.Myst.Components +@using Elastic.Markdown.Myst.Directives.AppliesSwitch @inherits RazorSlice -@{ - var id = $"applies-switch-item-{Model.AppliesSwitchIndex}-{Model.Index}"; +@if (Model.IsDropdown) +{ + @* The parent switch view renders the inputs and labels grouped in the + selector overlay; the item only contributes its content pane. *@ +
+ @Model.RenderBlock() +
+} +else +{ + @await RenderPartialAsync(AppliesItemLabelView.Create(Model)) +
+ @Model.RenderBlock() +
} - - - -
- @Model.RenderBlock() -
diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs index 9d0ea3bc34..11698ebea1 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesItemViewModel.cs @@ -5,16 +5,79 @@ using Elastic.Documentation; using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; +using Elastic.Markdown.Myst.Components; namespace Elastic.Markdown.Myst.Directives.AppliesSwitch; public class AppliesItemViewModel : DirectiveViewModel { public required int Index { get; init; } + public required bool Checked { get; init; } + public required bool IsDropdown { 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; } + + /// + /// Compact text form of the applies_to definition used by the dropdown + /// appearance, e.g. "Serverless, 9.1+" or "9.0 (preview)". Stack segments + /// drop the product name, and the lifecycle only shows when it is not GA. + /// + public string ShortLabel() + { + if (AppliesTo is null) + return AppliesToDefinition ?? string.Empty; + + try + { + var viewModel = new ApplicableToViewModel + { + AppliesTo = AppliesTo, + Inline = true, + ShowTooltip = false, + VersionsConfig = BuildContext.VersionsConfiguration + }; + var segments = viewModel.GetApplicabilityItems() + .Select(FormatShortSegment) + .Where(s => s.Length > 0) + .ToList(); + return segments.Count > 0 ? string.Join(", ", segments) : AppliesToDefinition ?? string.Empty; + } + catch + { + // Mirrors the badge view's fallback: an applies_to definition that + // cannot be resolved against the versions configuration renders as + // its raw definition text. + return AppliesToDefinition ?? string.Empty; + } + } + + private static string FormatShortSegment(ApplicabilityItem item) + { + var version = item.RenderData.ShowVersion ? item.RenderData.Version : string.Empty; + var name = item.Key == ApplicabilityMappings.Stack.Key && version.Length > 0 ? string.Empty : item.Key; + var text = name.Length > 0 && version.Length > 0 ? $"{name} {version}" : name + version; + var lifecycle = ShortLifecycleName(item.Applicability.Lifecycle); + if (lifecycle is null) + return text; + return text.Length > 0 ? $"{text} ({lifecycle})" : $"({lifecycle})"; + } + + private static string? ShortLifecycleName(ProductLifecycle lifecycle) => lifecycle switch + { + ProductLifecycle.GenerallyAvailable => null, + ProductLifecycle.TechnicalPreview => "preview", + ProductLifecycle.Beta => "beta", + ProductLifecycle.Experimental => "experimental", + ProductLifecycle.Deprecated => "deprecated", + ProductLifecycle.Removed => "removed", + ProductLifecycle.Unavailable => "unavailable", + ProductLifecycle.Development => "development", + ProductLifecycle.Planned => "planned", + ProductLifecycle.Discontinued => "discontinued", + _ => null + }; } diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs index 7c0c959393..fd8719a092 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchBlock.cs @@ -7,6 +7,10 @@ using Elastic.Documentation.Extensions; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; +using Elastic.Markdown.Myst.CodeBlocks; +using Elastic.Markdown.Myst.Comments; +using Elastic.Markdown.Myst.Directives.AppliesTo; +using Elastic.Markdown.Myst.Directives.Contributors; namespace Elastic.Markdown.Myst.Directives.AppliesSwitch; @@ -16,9 +20,51 @@ public class AppliesSwitchBlock(DirectiveBlockParser parser, ParserContext conte public override string Directive => "applies-switch"; public int Index { get; set; } + public bool IsDropdown { get; private set; } public string GetGroupKey() => Prop("group") ?? "applies-switches"; - public override void FinalizeAndValidate(ParserContext context) => Index = FindIndex(); + public override void FinalizeAndValidate(ParserContext context) + { + Index = FindIndex(); + IsDropdown = ParseAppearance() && ValidateDropdownItems(); + if (this.OfType().Count(i => i.Selected) > 1) + this.EmitWarning("{applies-switch} has multiple items marked :selected:, only the first one is selected."); + } + + private bool ParseAppearance() + { + var appearance = Prop("appearance"); + switch (appearance) + { + case null or "" or "tabs": + return false; + case "dropdown": + return true; + default: + this.EmitWarning($"{{applies-switch}} appearance '{appearance}' is not supported. Valid appearances are: tabs, dropdown. Defaulting to 'tabs'."); + return false; + } + } + + /// The dropdown appearance attaches the selector chip to the top edge of a + /// code block; other leading content has no edge to attach to and renders + /// poorly, so it falls back to tabs with a warning. + private bool ValidateDropdownItems() + { + foreach (var item in this.OfType()) + { + var first = item.FirstOrDefault(c => c is not CommentBlock); + if (first is EnhancedCodeBlock { Language: not "mermaid" } and not AppliesToDirective and not ContributorsBlock) + continue; + + this.EmitWarning( + $"{{applies-switch}} dropdown appearance requires every {{applies-item}} to start with a code block. " + + $"Item '{item.AppliesToDefinition}' does not, falling back to tabs."); + return false; + } + + return true; + } private int _index = -1; diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml index d6946d1b7e..973bc4b5f4 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchView.cshtml @@ -1,5 +1,20 @@ +@using Elastic.Markdown.Myst.Directives.AppliesSwitch @inherits RazorSlice -
+
+ @if (Model.IsDropdown) + { +
+ @foreach (var item in Model.Items) + { + @await RenderPartialAsync(AppliesItemLabelView.Create(item)) + } +
+
+ @foreach (var item in Model.Items) + { + + } +
+ } @Model.RenderBlock()
- diff --git a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs index 1b65b70874..4a6821ee38 100644 --- a/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/AppliesSwitch/AppliesSwitchViewModel.cs @@ -4,5 +4,12 @@ namespace Elastic.Markdown.Myst.Directives.AppliesSwitch; -public class AppliesSwitchViewModel : DirectiveViewModel; +public class AppliesSwitchViewModel : DirectiveViewModel +{ + public required bool IsDropdown { get; init; } + /// Item view models for the selector labels; only populated for the dropdown + /// appearance, where the switch view renders all inputs and labels itself so + /// they can be grouped into a single overlay menu. + public required IReadOnlyList Items { get; init; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 65428660d4..9fe7e646cf 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -420,27 +420,48 @@ private static void WriteTabItem(HtmlRenderer renderer, TabItemBlock block) private static void WriteAppliesSwitch(HtmlRenderer renderer, AppliesSwitchBlock block) { - var slice = AppliesSwitchView.Create(new AppliesSwitchViewModel { DirectiveBlock = block }); + var slice = AppliesSwitchView.Create(new AppliesSwitchViewModel + { + DirectiveBlock = block, + IsDropdown = block.IsDropdown, + Items = block.IsDropdown + ? [.. block.OfType().Select(BuildAppliesItemViewModel)] + : [] + }); RenderRazorSlice(slice, renderer); } private static void WriteAppliesItem(HtmlRenderer renderer, AppliesItemBlock block) + { + var slice = AppliesItemView.Create(BuildAppliesItemViewModel(block)); + RenderRazorSlice(slice, renderer); + } + + private static AppliesItemViewModel BuildAppliesItemViewModel(AppliesItemBlock block) { // Parse the applies_to definition to get the ApplicableTo object // Use the pre-parsed AppliesTo from the block (implementing IBlockAppliesTo) var appliesTo = block.AppliesTo ?? (block.AppliesToDefinition is not null ? ParseApplicableTo(block.AppliesToDefinition, block) : null); - var slice = AppliesItemView.Create(new AppliesItemViewModel + return new AppliesItemViewModel { DirectiveBlock = block, Index = block.Index, + Checked = IsDefaultChecked(block), + IsDropdown = (block.Parent as AppliesSwitchBlock)?.IsDropdown ?? false, AppliesToDefinition = block.AppliesToDefinition, AppliesTo = appliesTo, AppliesSwitchIndex = block.AppliesSwitchIndex, SyncKey = block.SyncKey, AppliesSwitchGroupKey = block.AppliesSwitchGroupKey, BuildContext = block.Build - }); - RenderRazorSlice(slice, renderer); + }; + } + + private static bool IsDefaultChecked(AppliesItemBlock block) + { + var siblings = block.Parent?.OfType().ToList() ?? []; + var selectedIndex = siblings.FindIndex(i => i.Selected); + return selectedIndex >= 0 ? block.Index == selectedIndex : block.Index == 0; } private static ApplicableTo? ParseApplicableTo(string yaml, DirectiveBlock block) diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index a7351ecb9d..fc2f53c463 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -297,6 +297,10 @@ private static void WriteContributorsBlock(LlmMarkdownRenderer renderer, Contrib private static int GetLastNonEmptyLineIndex(EnhancedCodeBlock obj) { + // A fence with no content lines never allocates the Lines array + if (obj.Lines.Lines is null) + return -1; + var lastNonEmptyIndex = obj.Lines.Lines.Length - 1; while (lastNonEmptyIndex >= 0 && string.IsNullOrWhiteSpace(obj.Lines.Lines[lastNonEmptyIndex].ToString())) lastNonEmptyIndex--; diff --git a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs index 2aff82e3c4..64ddd9424f 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ApplicabilitySwitchTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using AwesomeAssertions; +using Elastic.Markdown.Exporters; using Elastic.Markdown.Myst.Directives.AppliesSwitch; namespace Elastic.Markdown.Tests.Directives; @@ -296,3 +297,205 @@ public void GeneratesDeterministicSyncKeysAcrossMultipleRuns() "Different applies_to definitions must produce different sync keys"); } } + +public class ApplicabilitySwitchDropdownTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +:appearance: dropdown + +::::{applies-item} stack: preview 9.4 +```console +GET /_search +``` +:::: + +::::{applies-item} { serverless: ga, stack: ga 9.5 } +:selected: +```console +GET /_search?size=10 +``` +:::: + +::::: +""" +) +{ + [Fact] + public void ParsesDropdownAppearance() => Block!.IsDropdown.Should().BeTrue(); + + [Fact] + public void RendersDropdownModifierClass() => + Html.Should().Contain("applies-switch applies-switch--dropdown"); + + [Fact] + public void SelectedItemRendersChecked() => + Html.Should().MatchRegex("""checked="checked" id="applies-switch-item-\d+-1"""); + + [Fact] + public void FirstItemDoesNotRenderCheckedWhenAnotherIsSelected() => + Html.Should().NotMatchRegex("""checked="checked" id="applies-switch-item-\d+-0"""); + + [Fact] + public void RendersSelectorOverlay() => + Html.Should().Contain("applies-switch-select"); + + [Fact] + public void RendersPanelRowsPointingAtTheRadios() + { + Html.Should().Contain("applies-switch-panel"); + Html.Should().MatchRegex("""applies-switch-panel-row" for="applies-switch-item-\d+-0" data-index="0"""); + Html.Should().MatchRegex("""applies-switch-panel-row" for="applies-switch-item-\d+-1" data-index="1"""); + } + + [Fact] + public void ActiveContentPaneMatchesSelectedItem() + { + Html.Should().Contain("applies-switch-content applies-switch-content--active\" data-index=\"1\""); + Html.Should().NotContain("applies-switch-content applies-switch-content--active\" data-index=\"0\""); + } + + [Fact] + public void EmitsNoDiagnostics() => Collector.Diagnostics.Should().BeEmpty(); +} + +public class ApplicabilitySwitchInvalidAppearanceTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +:appearance: accordion + +::::{applies-item} stack: ga 9.1 +Content +:::: + +::::: +""" +) +{ + [Fact] + public void FallsBackToTabs() => Block!.IsDropdown.Should().BeFalse(); + + [Fact] + public void EmitsWarning() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("appearance 'accordion' is not supported")); +} + +public class ApplicabilitySwitchMultipleSelectedTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} + +::::{applies-item} stack: ga 9.1 +:selected: +Content A +:::: + +::::{applies-item} serverless: ga +:selected: +Content B +:::: + +::::: +""" +) +{ + [Fact] + public void FirstSelectedItemWins() => + Html.Should().MatchRegex("""checked="checked" id="applies-switch-item-\d+-0"""); + + [Fact] + public void EmitsWarning() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("multiple items marked :selected:")); +} + +public class ApplicabilitySwitchDropdownShortLabelTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +:appearance: dropdown + +::::{applies-item} { stack: ga 8.0+, ess: ga 8.0+ } +```console +GET /_search +``` +:::: + +::::{applies-item} stack: preview =7.17 +```console +GET /_search?size=10 +``` +:::: + +::::: +""" +) +{ + [Fact] + public void RendersCompactSegmentsForCompoundDefinition() => + // Razor html-encodes the + sign + Html.Should().Contain("8.0+, ECH 8.0+"); + + [Fact] + public void RendersExactVersionWithLifecycleInParentheses() => + Html.Should().Contain("7.17 (preview)"); + + [Fact] + public void DropdownDoesNotRenderBadges() => + Html.Should().NotContain("applies-to-popover"); +} + +public class ApplicabilitySwitchDropdownGuardTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +:appearance: dropdown + +::::{applies-item} stack: ga 9.1 +Prose content without a code block. +:::: + +::::{applies-item} serverless: ga +```console +GET /_search +``` +:::: + +::::: +""" +) +{ + [Fact] + public void FallsBackToTabs() => Block!.IsDropdown.Should().BeFalse(); + + [Fact] + public void EmitsWarning() => + Collector.Diagnostics.Should().Contain(d => d.Message.Contains("requires every {applies-item} to start with a code block")); + + [Fact] + public void RendersAsTabs() => Html.Should().NotContain("applies-switch--dropdown"); +} + +// Regression: a code block with no content lines has a null Lines array and +// crashed llms.txt generation; dropdown switches also exercise code blocks +// nested in directives through the LLM renderer. +public class ApplicabilitySwitchDropdownLlmExportTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::::{applies-switch} +:appearance: dropdown + +::::{applies-item} stack: ga 9.1 +```json +{ "size": 10 } +``` +:::: + +::::: + +```console +``` +""" +) +{ + [Fact] + public void ConvertsToLlmMarkdownWithoutThrowing() + { + var llm = LlmMarkdownExporter.ConvertToLlmMarkdown(Document, Block!.Build); + llm.Should().Contain("\"size\": 10"); + } +}