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