diff --git a/docs/_docset.yml b/docs/_docset.yml
index b4b9662db..80230bceb 100644
--- a/docs/_docset.yml
+++ b/docs/_docset.yml
@@ -88,6 +88,7 @@ toc:
- file: applies.md
- file: applies-switch.md
- file: automated_settings.md
+ - file: buttons.md
- file: code.md
- file: comments.md
- file: conditionals.md
diff --git a/docs/syntax/buttons.md b/docs/syntax/buttons.md
new file mode 100644
index 000000000..bcf374a76
--- /dev/null
+++ b/docs/syntax/buttons.md
@@ -0,0 +1,232 @@
+# Buttons
+
+Buttons provide styled link elements for calls to action in documentation. Use buttons to highlight important navigation points, downloads, or external resources.
+
+:::{button}
+[Getting Started](docs-content://get-started/introduction.md)
+:::
+
+## Basic button
+
+A button wraps a standard Markdown link with button styling:
+
+:::::::{tab-set}
+::::::{tab-item} Output
+:::{button}
+[Syntax Guide](index.md)
+:::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+:::{button}
+[Get Started](/get-started)
+:::
+```
+::::::
+:::::::
+
+## Button types
+
+Two button variants are available:
+
+- **Primary** (default): Filled blue background with white text, used for main calls to action.
+- **Secondary**: Blue border with transparent background, used for secondary actions.
+
+:::::::{tab-set}
+::::::{tab-item} Output
+::::{button-group}
+:::{button}
+[Quick Reference](quick-ref.md)
+:::
+:::{button}
+:type: secondary
+[Syntax Guide](index.md)
+:::
+::::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+::::{button-group}
+:::{button}
+[Primary Action](/primary)
+:::
+:::{button}
+:type: secondary
+[Secondary Action](/secondary)
+:::
+::::
+```
+::::::
+:::::::
+
+## Button groups
+
+Use the `{button-group}` directive to display multiple buttons in a row:
+
+:::::::{tab-set}
+::::::{tab-item} Output
+::::{button-group}
+:::{button}
+[Admonitions](admonitions.md)
+:::
+:::{button}
+:type: secondary
+[Dropdowns](dropdowns.md)
+:::
+::::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+::::{button-group}
+:::{button}
+[Elastic Fundamentals](/get-started)
+:::
+:::{button}
+:type: secondary
+[Upgrade Versions](/deploy-manage/upgrade)
+:::
+::::
+```
+::::::
+:::::::
+
+## Alignment
+
+### Single button alignment
+
+Control the horizontal alignment of standalone buttons with the `:align:` property:
+
+:::::::{tab-set}
+::::::{tab-item} Output
+:::{button}
+:align: left
+[Links](links.md)
+:::
+
+:::{button}
+:align: center
+[Images](images.md)
+:::
+
+:::{button}
+:align: right
+[Tables](tables.md)
+:::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+:::{button}
+:align: left
+[Left (default)](/example)
+:::
+
+:::{button}
+:align: center
+[Center](/example)
+:::
+
+:::{button}
+:align: right
+[Right](/example)
+:::
+```
+::::::
+:::::::
+
+### Button group alignment
+
+Button groups also support the `:align:` property:
+
+:::::::{tab-set}
+::::::{tab-item} Output
+::::{button-group}
+:align: center
+:::{button}
+[Code Blocks](code.md)
+:::
+:::{button}
+:type: secondary
+[Tabs](tabs.md)
+:::
+::::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+::::{button-group}
+:align: center
+:::{button}
+[Centered Group](/example)
+:::
+:::{button}
+:type: secondary
+[Second Button](/example)
+:::
+::::
+```
+::::::
+:::::::
+
+## External links
+
+External links (URLs outside elastic.co) automatically open in a new tab, just like regular links:
+
+:::::::{tab-set}
+::::::{tab-item} Output
+:::{button}
+[Visit GitHub](https://github.com/elastic)
+:::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+:::{button}
+[Visit GitHub](https://github.com/elastic)
+:::
+```
+::::::
+:::::::
+
+External links include `target="_blank"` and `rel="noopener noreferrer"` attributes for security.
+
+## Cross-repository links
+
+Buttons support [cross-repository links](links.md#cross-repository-links) using the `scheme://path` syntax:
+
+:::::::{tab-set}
+::::::{tab-item} Output
+:::{button}
+[Getting Started Guide](docs-content://get-started/introduction.md)
+:::
+::::::
+
+::::::{tab-item} Markdown
+```markdown
+:::{button}
+[Getting Started Guide](docs-content://get-started/introduction.md)
+:::
+```
+::::::
+:::::::
+
+Cross-links are resolved at build time to their target URLs in the documentation site.
+
+## Properties reference
+
+### Button properties
+
+| Property | Required | Default | Description |
+|----------|----------|---------|-------------|
+| (content) | Yes | - | A Markdown link `[text](url)` that becomes the button. |
+| `:type:` | No | `primary` | Button variant: `primary` (filled) or `secondary` (outlined). |
+| `:align:` | No | `left` | Horizontal alignment for standalone buttons: `left`, `center`, or `right`. |
+
+### Button group properties
+
+| Property | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `:align:` | No | `left` | Horizontal alignment of the button group: `left`, `center`, or `right`. |
diff --git a/src/Elastic.Documentation.Site/Assets/markdown/button.css b/src/Elastic.Documentation.Site/Assets/markdown/button.css
new file mode 100644
index 000000000..1e688faeb
--- /dev/null
+++ b/src/Elastic.Documentation.Site/Assets/markdown/button.css
@@ -0,0 +1,75 @@
+/* Button directive styles */
+
+/* Button wrapper for standalone buttons */
+.doc-button-wrapper {
+ display: flex;
+ margin-block: 1rem;
+}
+
+.doc-button-wrapper.doc-button-left {
+ justify-content: flex-start;
+}
+
+.doc-button-wrapper.doc-button-center {
+ justify-content: center;
+}
+
+.doc-button-wrapper.doc-button-right {
+ justify-content: flex-end;
+}
+
+/* Button group container */
+.doc-button-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-block: 1rem;
+}
+
+.doc-button-group.doc-button-group-left {
+ justify-content: flex-start;
+}
+
+.doc-button-group.doc-button-group-center {
+ justify-content: center;
+}
+
+.doc-button-group.doc-button-group-right {
+ justify-content: flex-end;
+}
+
+/* Button item wrapper for buttons inside groups */
+.doc-button-item {
+ display: inline-flex;
+}
+
+/* Primary button - exact same Tailwind classes as _LandingPage.cshtml */
+.doc-button-primary a {
+ @apply bg-blue-elastic hover:bg-blue-elastic-110 focus:ring-blue-elastic-50 flex h-10 cursor-pointer items-center justify-center rounded-sm px-6 py-2 font-sans text-base font-semibold text-nowrap text-white no-underline select-none hover:text-white focus:ring-4 focus:outline-none;
+}
+
+/* Secondary button - exact same Tailwind classes as _LandingPage.cshtml */
+.doc-button-secondary a {
+ @apply text-blue-elastic hover:text-blue-elastic-100 border-blue-elastic hover:border-blue-elastic-100 focus:ring-blue-elastic-50 flex h-10 cursor-pointer items-center justify-center rounded-sm border-2 px-6 py-2 text-center font-sans text-base font-semibold text-nowrap no-underline focus:ring-4 focus:outline-none;
+}
+
+/* Remove external link icon from buttons */
+.doc-button-wrapper a[target='_blank']::after,
+.doc-button-item a[target='_blank']::after {
+ content: none !important;
+}
+
+/* Responsive adjustments */
+@media (max-width: 640px) {
+ .doc-button-group {
+ flex-direction: column;
+ }
+
+ .doc-button-item {
+ width: 100%;
+ }
+
+ .doc-button-item a {
+ width: 100%;
+ }
+}
diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css
index 425c988bc..d9581c093 100644
--- a/src/Elastic.Documentation.Site/Assets/styles.css
+++ b/src/Elastic.Documentation.Site/Assets/styles.css
@@ -24,6 +24,7 @@
@import './modal.css';
@import './archive.css';
@import './markdown/stepper.css';
+@import './markdown/button.css';
@import './api-docs.css';
@import 'tippy.js/dist/tippy.css';
diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs
new file mode 100644
index 000000000..dffe04570
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs
@@ -0,0 +1,129 @@
+// 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.Text.RegularExpressions;
+using Elastic.Markdown.Diagnostics;
+using Markdig.Syntax;
+
+namespace Elastic.Markdown.Myst.Directives.Button;
+
+///
+/// Container block for grouping multiple buttons in a row.
+///
+public class ButtonGroupBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context)
+{
+ public override string Directive => "button-group";
+
+ ///
+ /// Horizontal alignment of the button group.
+ ///
+ public string Align { get; private set; } = "left";
+
+ public override void FinalizeAndValidate(ParserContext context) =>
+ Align = Prop("align") ?? "left";
+}
+
+///
+/// A button directive that wraps a link with button styling.
+/// The link inside is processed by the standard link parser and renderer,
+/// including cross-link resolution and htmx attributes.
+///
+///
+/// :::{button}
+/// [Get Started](/get-started)
+/// :::
+///
+public partial class ButtonBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context)
+{
+ public override string Directive => "button";
+
+ private static readonly HashSet ValidTypes = ["primary", "secondary"];
+ private static readonly HashSet ValidAligns = ["left", "center", "right"];
+
+ ///
+ /// Button variant: "primary" (filled) or "secondary" (outlined).
+ ///
+ public string Type { get; private set; } = "primary";
+
+ ///
+ /// Horizontal alignment for standalone buttons: "left", "center", or "right".
+ ///
+ public string Align { get; private set; } = "left";
+
+ ///
+ /// Whether this button is inside a button group.
+ ///
+ public bool IsInGroup { get; private set; }
+
+ // Regex to match a Markdown link: [text](url)
+ [GeneratedRegex(@"^\s*\[[^\]]+\]\([^\)]+\)\s*$", RegexOptions.Singleline)]
+ private static partial Regex LinkPattern();
+
+ public override void FinalizeAndValidate(ParserContext context)
+ {
+ // Validate that the content is a single link
+ ValidateContent();
+
+ // Get and validate type
+ var type = Prop("type", "variant")?.ToLowerInvariant();
+ if (type != null && !ValidTypes.Contains(type))
+ {
+ this.EmitWarning($"Invalid button type '{type}'. Valid types are: primary, secondary. Defaulting to 'primary'.");
+ type = "primary";
+ }
+ Type = type ?? "primary";
+
+ // Get and validate alignment
+ var align = Prop("align")?.ToLowerInvariant();
+ if (align != null && !ValidAligns.Contains(align))
+ {
+ this.EmitWarning($"Invalid button alignment '{align}'. Valid alignments are: left, center, right. Defaulting to 'left'.");
+ align = "left";
+ }
+ Align = align ?? "left";
+
+ // Check if inside a button group
+ IsInGroup = Parent is ButtonGroupBlock;
+ }
+
+ private void ValidateContent()
+ {
+ // Extract raw content from child blocks
+ var content = ExtractContent();
+
+ if (string.IsNullOrWhiteSpace(content))
+ {
+ this.EmitError("Button directive requires a link. Use: :::{button}\n[text](url)\n:::");
+ return;
+ }
+
+ // Check if content matches the link pattern
+ if (!LinkPattern().IsMatch(content))
+ {
+ this.EmitError("Button directive must contain only a single Markdown link. Use: :::{button}\n[text](url)\n:::");
+ }
+ }
+
+ private string? ExtractContent()
+ {
+ var lines = new List();
+ foreach (var block in this)
+ {
+ if (block is LeafBlock leafBlock)
+ {
+ var content = leafBlock.Lines.ToString();
+ if (!string.IsNullOrWhiteSpace(content))
+ lines.Add(content);
+ }
+ else if (block is ContainerBlock)
+ {
+ // Nested directive or container - this is invalid
+ this.EmitError("Button directive cannot contain nested directives or blocks. Use only a single Markdown link.");
+ return null;
+ }
+ }
+
+ return lines.Count > 0 ? string.Join("\n", lines) : null;
+ }
+}
diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonGroupView.cshtml b/src/Elastic.Markdown/Myst/Directives/Button/ButtonGroupView.cshtml
new file mode 100644
index 000000000..edad1b15f
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonGroupView.cshtml
@@ -0,0 +1,5 @@
+@inherits RazorSlice
+
+ @Model.RenderBlock()
+
+
diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml
new file mode 100644
index 000000000..9add4a7bd
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml
@@ -0,0 +1,16 @@
+@inherits RazorSlice
+@{
+ var typeClass = Model.Type == "secondary" ? "doc-button-secondary" : "doc-button-primary";
+}
+@if (!Model.IsInGroup)
+{
+
+ @Model.RenderBlock()
+
+}
+else
+{
+
+ @Model.RenderBlock()
+
+}
diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs
new file mode 100644
index 000000000..3aba724b8
--- /dev/null
+++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs
@@ -0,0 +1,58 @@
+// 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.Button;
+
+///
+/// View model for a single button.
+/// The button wraps a link that is rendered by the standard link renderer.
+///
+public class ButtonViewModel : DirectiveViewModel
+{
+ ///
+ /// Button variant: "primary" (filled) or "secondary" (outlined).
+ ///
+ public required string Type { get; init; }
+
+ ///
+ /// Horizontal alignment for standalone buttons: "left", "center", or "right".
+ ///
+ public required string Align { get; init; }
+
+ ///
+ /// Whether this button is inside a button group.
+ ///
+ public required bool IsInGroup { get; init; }
+
+ ///
+ /// Gets the CSS class for the button alignment (only used for standalone buttons).
+ ///
+ public string AlignClass => Align switch
+ {
+ "center" => "doc-button-center",
+ "right" => "doc-button-right",
+ _ => "doc-button-left"
+ };
+}
+
+///
+/// View model for a button group container.
+///
+public class ButtonGroupViewModel : DirectiveViewModel
+{
+ ///
+ /// Horizontal alignment of the button group.
+ ///
+ public required string Align { get; init; }
+
+ ///
+ /// Gets the CSS class for the button group alignment.
+ ///
+ public string AlignClass => Align switch
+ {
+ "center" => "doc-button-group-center",
+ "right" => "doc-button-group-right",
+ _ => "doc-button-group-left"
+ };
+}
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
index 6e96f39e4..df0082d09 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs
@@ -5,6 +5,7 @@
using System.Collections.Frozen;
using Elastic.Markdown.Myst.Directives.Admonition;
using Elastic.Markdown.Myst.Directives.AppliesSwitch;
+using Elastic.Markdown.Myst.Directives.Button;
using Elastic.Markdown.Myst.Directives.CsvInclude;
using Elastic.Markdown.Myst.Directives.Diagram;
using Elastic.Markdown.Myst.Directives.Image;
@@ -155,6 +156,12 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor)
if (info.IndexOf("{step}") > 0)
return new StepBlock(this, context);
+ if (info.IndexOf("{button-group}") > 0)
+ return new ButtonGroupBlock(this, context);
+
+ if (info.IndexOf("{button}") > 0)
+ return new ButtonBlock(this, context);
+
return new UnknownDirectiveBlock(this, info.ToString(), context);
}
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
index eab1fc9ab..12a29b96b 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
@@ -9,6 +9,7 @@
using Elastic.Markdown.Myst.CodeBlocks;
using Elastic.Markdown.Myst.Directives.Admonition;
using Elastic.Markdown.Myst.Directives.AppliesSwitch;
+using Elastic.Markdown.Myst.Directives.Button;
using Elastic.Markdown.Myst.Directives.CsvInclude;
using Elastic.Markdown.Myst.Directives.Diagram;
using Elastic.Markdown.Myst.Directives.Dropdown;
@@ -104,6 +105,12 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo
case StepBlock stepBlock:
WriteStepBlock(renderer, stepBlock);
return;
+ case ButtonGroupBlock buttonGroupBlock:
+ WriteButtonGroup(renderer, buttonGroupBlock);
+ return;
+ case ButtonBlock buttonBlock:
+ WriteButton(renderer, buttonBlock);
+ return;
default:
// if (!string.IsNullOrEmpty(directiveBlock.Info) && !directiveBlock.Info.StartsWith('{'))
// WriteCode(renderer, directiveBlock);
@@ -178,6 +185,28 @@ private static void WriteStepBlock(HtmlRenderer renderer, StepBlock block)
RenderRazorSlice(slice, renderer);
}
+ private static void WriteButtonGroup(HtmlRenderer renderer, ButtonGroupBlock block)
+ {
+ var slice = ButtonGroupView.Create(new ButtonGroupViewModel
+ {
+ DirectiveBlock = block,
+ Align = block.Align
+ });
+ RenderRazorSlice(slice, renderer);
+ }
+
+ private static void WriteButton(HtmlRenderer renderer, ButtonBlock block)
+ {
+ var slice = ButtonView.Create(new ButtonViewModel
+ {
+ DirectiveBlock = block,
+ Type = block.Type,
+ Align = block.Align,
+ IsInGroup = block.IsInGroup
+ });
+ RenderRazorSlice(slice, renderer);
+ }
+
private static void WriteFigure(HtmlRenderer renderer, ImageBlock block)
{
var imageUrl = block.ImageUrl != null &&
diff --git a/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs
new file mode 100644
index 000000000..4eb8e8c93
--- /dev/null
+++ b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs
@@ -0,0 +1,237 @@
+// 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.Button;
+using FluentAssertions;
+
+namespace Elastic.Markdown.Tests.Directives;
+
+public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+[Get Started](/get-started)
+:::
+"""
+)
+{
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void DefaultsToPrimaryType() => Block!.Type.Should().Be("primary");
+
+ [Fact]
+ public void DefaultsToLeftAlign() => Block!.Align.Should().Be("left");
+
+ [Fact]
+ public void RendersPrimaryButtonClass() => Html.Should().Contain("doc-button-primary");
+
+ [Fact]
+ public void RendersLinkHref() => Html.Should().Contain("href=\"/get-started\"");
+
+ [Fact]
+ public void RendersButtonText() => Html.Should().Contain("Get Started");
+}
+
+public class ButtonSecondaryTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+:type: secondary
+[Learn More](/learn-more)
+:::
+"""
+)
+{
+ [Fact]
+ public void ParsesSecondaryType() => Block!.Type.Should().Be("secondary");
+
+ [Fact]
+ public void RendersSecondaryClass() => Html.Should().Contain("doc-button-secondary");
+}
+
+public class ButtonAlignmentTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+:align: center
+[Centered Button](/centered)
+:::
+"""
+)
+{
+ [Fact]
+ public void ParsesCenterAlign() => Block!.Align.Should().Be("center");
+
+ [Fact]
+ public void RendersWrapperWithAlignClass() => Html.Should().Contain("doc-button-wrapper doc-button-primary doc-button-center");
+}
+
+public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+[GitHub](https://github.com/elastic)
+:::
+"""
+)
+{
+ [Fact]
+ public void RendersExternalAttributes() => Html.Should().Contain("target=\"_blank\"");
+
+ [Fact]
+ public void RendersNoopenerNoreferrer() => Html.Should().Contain("rel=\"noopener noreferrer\"");
+}
+
+public class ButtonInvalidTypeTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+:type: invalid
+[Invalid Type](/test)
+:::
+"""
+)
+{
+ [Fact]
+ public void EmitsWarningForInvalidType() =>
+ Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("Invalid button type"));
+
+ [Fact]
+ public void FallsBackToPrimary() => Block!.Type.Should().Be("primary");
+}
+
+public class ButtonGroupTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+::::{button-group}
+:::{button}
+:type: primary
+[Primary](/primary)
+:::
+:::{button}
+:type: secondary
+[Secondary](/secondary)
+:::
+::::
+"""
+)
+{
+ [Fact]
+ public void ParsesButtonGroup() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void RendersButtonGroupContainer() => Html.Should().Contain("class=\"doc-button-group");
+
+ [Fact]
+ public void ContainsBothButtons() =>
+ Html.Should().Contain("Primary").And.Contain("Secondary");
+
+ [Fact]
+ public void RendersPrimaryButton() => Html.Should().Contain("doc-button-primary");
+
+ [Fact]
+ public void RendersSecondaryButton() => Html.Should().Contain("doc-button-secondary");
+}
+
+public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+::::{button-group}
+:align: center
+:::{button}
+[Centered](/centered)
+:::
+::::
+"""
+)
+{
+ [Fact]
+ public void ParsesGroupAlignment() => Block!.Align.Should().Be("center");
+
+ [Fact]
+ public void RendersGroupAlignClass() => Html.Should().Contain("doc-button-group-center");
+}
+
+public class ButtonInGroupTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+::::{button-group}
+:::{button}
+[In Group](/in-group)
+:::
+::::
+"""
+)
+{
+ [Fact]
+ public void DetectsButtonIsInGroup() => Block!.IsInGroup.Should().BeTrue();
+
+ [Fact]
+ public void DoesNotRenderWrapperInGroup() => Html.Should().NotContain("doc-button-wrapper");
+
+ [Fact]
+ public void RendersButtonItem() => Html.Should().Contain("doc-button-item");
+}
+
+public class ButtonCrossLinkTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+[Kibana Docs](kibana://api/index.md)
+:::
+"""
+)
+{
+ [Fact]
+ public void ParsesBlock() => Block.Should().NotBeNull();
+
+ [Fact]
+ public void RendersLinkHref() => Html.Should().Contain("href=\"");
+}
+
+public class ButtonEmptyTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+:::
+"""
+)
+{
+ [Fact]
+ public void EmitsErrorForEmptyContent() =>
+ Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires a link"));
+}
+
+public class ButtonPlainTextTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+Just some text without a link
+:::
+"""
+)
+{
+ [Fact]
+ public void EmitsErrorForPlainText() =>
+ Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("must contain only a single Markdown link"));
+}
+
+public class ButtonMultipleLinksTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+[Link One](/one) and [Link Two](/two)
+:::
+"""
+)
+{
+ [Fact]
+ public void EmitsErrorForMultipleLinks() =>
+ Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("must contain only a single Markdown link"));
+}
+
+public class ButtonNestedDirectiveTests(ITestOutputHelper output) : DirectiveTest(output,
+"""
+:::{button}
+::::{note}
+This is nested
+::::
+:::
+"""
+)
+{
+ [Fact]
+ public void EmitsErrorForNestedDirective() =>
+ Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("cannot contain nested directives"));
+}