From caabf1c0408d7b74ae7c8bd43aa769c65e09ac91 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 16 Dec 2025 12:40:40 +0100 Subject: [PATCH 1/7] Add button directive --- docs/_docset.yml | 1 + docs/syntax/buttons.md | 241 +++++++++++++++ .../Assets/markdown/button.css | 68 ++++ .../Assets/styles.css | 291 +++++++++--------- .../Myst/Directives/Button/ButtonBlock.cs | 157 ++++++++++ .../Directives/Button/ButtonGroupView.cshtml | 5 + .../Myst/Directives/Button/ButtonView.cshtml | 32 ++ .../Myst/Directives/Button/ButtonViewModel.cs | 78 +++++ .../Myst/Directives/DirectiveBlockParser.cs | 7 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 32 ++ .../Directives/ButtonTests.cs | 235 ++++++++++++++ 11 files changed, 1002 insertions(+), 145 deletions(-) create mode 100644 docs/syntax/buttons.md create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/button.css create mode 100644 src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/Button/ButtonGroupView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs create mode 100644 tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs 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..7578a5130 --- /dev/null +++ b/docs/syntax/buttons.md @@ -0,0 +1,241 @@ +# Buttons + +Buttons provide styled link elements for calls to action in documentation. Use buttons to highlight important navigation points, downloads, or external resources. + +:::{button} This is an example +:link: docs-content://get-started/introduction.md +::: + +## Basic button + +A button requires the button text as an argument and a `:link:` property: + +:::::::{tab-set} +::::::{tab-item} Output +:::{button} Get Started +:link: # +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{button} Get Started +:link: /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} Primary Button +:link: # +:type: primary +::: +:::{button} Secondary Button +:link: # +:type: secondary +::: +:::: +:::::: + +::::::{tab-item} Markdown +```markdown +::::{button-group} +:::{button} Primary Button +:link: /primary +:type: primary +::: +:::{button} Secondary Button +:link: /secondary +:type: secondary +::: +:::: +``` +:::::: +::::::: + +## Button groups + +Use the `{button-group}` directive to display multiple buttons in a row: + +:::::::{tab-set} +::::::{tab-item} Output +::::{button-group} +:::{button} Elastic Fundamentals +:link: # +:type: primary +::: +:::{button} Upgrade Versions +:link: # +:type: secondary +::: +:::: +:::::: + +::::::{tab-item} Markdown +```markdown +::::{button-group} +:::{button} Elastic Fundamentals +:link: /get-started +:type: primary +::: +:::{button} Upgrade Versions +:link: /deploy-manage/upgrade +:type: secondary +::: +:::: +``` +:::::: +::::::: + +## Alignment + +### Single button alignment + +Control the horizontal alignment of standalone buttons with the `:align:` property: + +:::::::{tab-set} +::::::{tab-item} Output +:::{button} Left (default) +:link: # +:align: left +::: + +:::{button} Center +:link: # +:align: center +::: + +:::{button} Right +:link: # +:align: right +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{button} Left (default) +:link: /example +:align: left +::: + +:::{button} Center +:link: /example +:align: center +::: + +:::{button} Right +:link: /example +:align: right +::: +``` +:::::: +::::::: + +### Button group alignment + +Button groups also support the `:align:` property: + +:::::::{tab-set} +::::::{tab-item} Output +::::{button-group} +:align: center +:::{button} Centered Group +:link: # +::: +:::{button} Second Button +:link: # +:type: secondary +::: +:::: +:::::: + +::::::{tab-item} Markdown +```markdown +::::{button-group} +:align: center +:::{button} Centered Group +:link: /example +::: +:::{button} Second Button +:link: /example +:type: secondary +::: +:::: +``` +:::::: +::::::: + +## External links + +External links (URLs outside elastic.co) automatically open in a new tab. You can also explicitly mark a link as external: + +:::::::{tab-set} +::::::{tab-item} Output +:::{button} Visit GitHub +:link: https://github.com/elastic +:external: +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{button} Visit GitHub +:link: https://github.com/elastic +:external: +::: +``` +:::::: +::::::: + +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 +:link: docs-content://get-started/introduction.md +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{button} Getting Started Guide +:link: 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 | +|----------|----------|---------|-------------| +| (argument) | Yes | - | The button text to display. | +| `:link:` | Yes | - | The URL the button links to. Supports internal paths, external URLs, and cross-repository links (e.g., `kibana://api/index.md`). | +| `:type:` | No | `primary` | Button variant: `primary` (filled) or `secondary` (outlined). | +| `:align:` | No | `left` | Horizontal alignment for standalone buttons: `left`, `center`, or `right`. | +| `:external:` | No | auto | If set, the link opens in a new tab. Auto-detected for non-elastic.co URLs. Cross-links are not treated as external by default. | + +### 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..bec679333 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/button.css @@ -0,0 +1,68 @@ +/* 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; +} + +/* Override markdown link styles for buttons */ +.markdown-content .doc-button-wrapper a, +.markdown-content .doc-button-group a { + text-decoration: none !important; +} + +/* Primary button text color override */ +.markdown-content .doc-button-wrapper a.bg-blue-elastic, +.markdown-content .doc-button-group a.bg-blue-elastic { + color: #ffffff !important; +} + +/* Remove external link icon from buttons in markdown content */ +.markdown-content .doc-button-wrapper a[target="_blank"]::after, +.markdown-content .doc-button-group a[target="_blank"]::after { + content: none !important; +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .doc-button-group { + flex-direction: column; + } + + .doc-button-group > a { + width: 100%; + } +} diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 425c988bc..c5a07b7e7 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -1,232 +1,233 @@ -@import 'tailwindcss'; +@import "tailwindcss"; @config '../tailwind.config.js'; -@import './fonts.css'; -@import './theme.css'; -@import './markdown/applies-to.css'; -@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'; -@import './copybutton.css'; -@import './markdown/admonition.css'; -@import './markdown/dropdown.css'; -@import './markdown/table.css'; -@import './markdown/definition-list.css'; -@import './markdown/footnotes.css'; -@import './markdown/images.css'; -@import './markdown/math.css'; -@import './markdown/image-carousel.css'; -@import './markdown/hr.css'; -@import './modal.css'; -@import './archive.css'; -@import './markdown/stepper.css'; -@import './api-docs.css'; -@import 'tippy.js/dist/tippy.css'; +@import "./fonts.css"; +@import "./theme.css"; +@import "./markdown/applies-to.css"; +@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"; +@import "./copybutton.css"; +@import "./markdown/admonition.css"; +@import "./markdown/dropdown.css"; +@import "./markdown/table.css"; +@import "./markdown/definition-list.css"; +@import "./markdown/footnotes.css"; +@import "./markdown/images.css"; +@import "./markdown/math.css"; +@import "./markdown/image-carousel.css"; +@import "./markdown/hr.css"; +@import "./modal.css"; +@import "./archive.css"; +@import "./markdown/stepper.css"; +@import "./markdown/button.css"; +@import "./api-docs.css"; +@import "tippy.js/dist/tippy.css"; html { - /* We need to use 14px because EUI works best with a 14px base */ - font-size: 14px; - @apply font-body; + /* We need to use 14px because EUI works best with a 14px base */ + font-size: 14px; + @apply font-body; } body { - /* This is still needed because of some usages of ch units and to maintain the previous behavior */ - font-size: 16px; + /* This is still needed because of some usages of ch units and to maintain the previous behavior */ + font-size: 16px; } :root { - --outline-size: max(2px, 0.08em); - --outline-style: auto; - --outline-color: var(--color-blue-elastic); - --outline-offset: 5; - --header-height: calc(var(--spacing) * 21); - --banner-height: calc(var(--spacing) * 9); - /*--offset-top: calc(var(--header-height) + var(--banner-height));*/ - --offset-top: 72px; - --max-text-width: clamp(40ch, 90ch, 100%); - --max-sidebar-width: calc(var(--spacing) * 65); - --content-spacing: calc(var(--spacing) * 8); - --max-layout-width: calc( - var(--max-text-width) + (var(--max-sidebar-width) * 2) + - calc(var(--content-spacing) * 2) - ); - - --max-api-sidebar-width: calc(var(--spacing) * 70); - --max-examples-width: calc(var(--max-api-sidebar-width) * 2); + --outline-size: max(2px, 0.08em); + --outline-style: auto; + --outline-color: var(--color-blue-elastic); + --outline-offset: 5; + --header-height: calc(var(--spacing) * 21); + --banner-height: calc(var(--spacing) * 9); + /*--offset-top: calc(var(--header-height) + var(--banner-height));*/ + --offset-top: 72px; + --max-text-width: clamp(40ch, 90ch, 100%); + --max-sidebar-width: calc(var(--spacing) * 65); + --content-spacing: calc(var(--spacing) * 8); + --max-layout-width: calc( + var(--max-text-width) + (var(--max-sidebar-width) * 2) + + calc(var(--content-spacing) * 2) + ); + + --max-api-sidebar-width: calc(var(--spacing) * 70); + --max-examples-width: calc(var(--max-api-sidebar-width) * 2); } @media screen and (min-width: 767px) { - :root { - --offset-top: 102px; - } + :root { + --offset-top: 102px; + } } @media screen and (min-width: 992px) { - :root { - --offset-top: 110px; - } + :root { + --offset-top: 110px; + } } @media screen and (min-width: 1200px) { - :root { - --offset-top: 72px; - } + :root { + --offset-top: 72px; + } } #pages-nav li.current { - position: relative; - &::before { - content: ''; - position: absolute; - top: 50%; - left: -1px; - width: calc(var(--spacing) * 6); - height: 1px; - background-color: var(--color-grey-10); - } + position: relative; + &::before { + content: ""; + position: absolute; + top: 50%; + left: -1px; + width: calc(var(--spacing) * 6); + height: 1px; + background-color: var(--color-grey-10); + } } #toc-nav a.current { - color: var(--color-blue-elastic); - &:hover { - color: var(--color-blue-elastic); - } + color: var(--color-blue-elastic); + &:hover { + color: var(--color-blue-elastic); + } } @layer components { - .link { - @apply text-blue-elastic hover:text-blue-elastic-100 inline-flex items-center justify-center font-sans font-semibold text-nowrap; - - .link-icon { - @apply mr-2 ml-0 size-4 shrink-0; - } - - .link-arrow { - @apply ml-2 size-7 shrink-0 transition-transform ease-out; - } - - &:hover { - svg:not(.link-icon) { - @apply translate-x-2; - } - } - } - - .sidebar { - .sidebar-nav { - @apply sticky top-(--offset-top) z-30 overflow-y-auto; - max-height: calc(100vh - var(--offset-top)); - scrollbar-gutter: stable; - scroll-behavior: smooth; - } - - .sidebar-link { - @apply text-ink-light inline-block leading-[1.2em] text-pretty hover:text-black md:text-sm; - word-break: break-word; - } - } + .link { + @apply text-blue-elastic hover:text-blue-elastic-100 inline-flex items-center justify-center font-sans font-semibold text-nowrap; + + .link-icon { + @apply mr-2 ml-0 size-4 shrink-0; + } + + .link-arrow { + @apply ml-2 size-7 shrink-0 transition-transform ease-out; + } + + &:hover { + svg:not(.link-icon) { + @apply translate-x-2; + } + } + } + + .sidebar { + .sidebar-nav { + @apply sticky top-(--offset-top) z-30 overflow-y-auto; + max-height: calc(100vh - var(--offset-top)); + scrollbar-gutter: stable; + scroll-behavior: smooth; + } + + .sidebar-link { + @apply text-ink-light inline-block leading-[1.2em] text-pretty hover:text-black md:text-sm; + word-break: break-word; + } + } } * { - scroll-margin-top: calc(var(--offset-top) + var(--spacing) * 6); + scroll-margin-top: calc(var(--offset-top) + var(--spacing) * 6); } :is(a, button, input, textarea, summary):focus { - outline: var(--outline-size) var(--outline-style) var(--outline-color); - outline-offset: var(--outline-offset, var(--outline-size)); + outline: var(--outline-size) var(--outline-style) var(--outline-color); + outline-offset: var(--outline-offset, var(--outline-size)); } :is(a, button, input, textarea, summary):focus-visible { - outline: var(--outline-size) var(--outline-style) var(--outline-color); - outline-offset: var(--outline-offset, var(--outline-size)); + outline: var(--outline-size) var(--outline-style) var(--outline-color); + outline-offset: var(--outline-offset, var(--outline-size)); } :is(a, button, input, textarea, summary):focus:not(:focus-visible) { - outline: none; + outline: none; } .htmx-indicator { - display: none; + display: none; } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { - display: block; - z-index: 9999; + display: block; + z-index: 9999; } .progress { - animation: progress 1s infinite linear; + animation: progress 1s infinite linear; } .left-right { - transform-origin: 0% 50%; + transform-origin: 0% 50%; } @keyframes progress { - 0% { - transform: translateX(0) scaleX(0); - } - 40% { - transform: translateX(0) scaleX(0.4); - } - 100% { - transform: translateX(100%) scaleX(0.5); - } + 0% { + transform: translateX(0) scaleX(0); + } + 40% { + transform: translateX(0) scaleX(0.4); + } + 100% { + transform: translateX(100%) scaleX(0.5); + } } #pages-nav .current { - @apply text-blue-elastic! font-semibold; + @apply text-blue-elastic! font-semibold; } .markdown-content { - @apply font-body; + @apply font-body; } .container { - @apply lg:px-3; - max-width: var(--max-layout-width) !important; + @apply lg:px-3; + max-width: var(--max-layout-width) !important; - * { - @apply font-body; - } + * { + @apply font-body; + } } #elastic-nav-wrapper { - min-height: var(--offset-top); + min-height: var(--offset-top); } #elastic-nav { - @media screen and (min-width: 1200px) { - [data-component='Container'] { - /* subtract the padding */ - max-width: calc(var(--max-layout-width) - (var(--spacing) * 10)); - } - } + @media screen and (min-width: 1200px) { + [data-component="Container"] { + /* subtract the padding */ + max-width: calc(var(--max-layout-width) - (var(--spacing) * 10)); + } + } } body { - min-height: 100vh; - display: grid; - grid-template-rows: auto auto 1fr auto; + min-height: 100vh; + display: grid; + grid-template-rows: auto auto 1fr auto; } #pages-nav details > summary::-webkit-details-marker { - display: none; + display: none; } .tippy-content { - white-space: pre-line; + white-space: pre-line; } .icon, .icon > * { - user-select: none; - pointer-events: none; + user-select: none; + pointer-events: none; } math { - margin-top: 1.5rem; + margin-top: 1.5rem; } 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..70cfc5017 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs @@ -0,0 +1,157 @@ +// 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.Links; +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst.InlineParsers; + +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 renders as a styled link button. +/// +public 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"]; + + /// + /// The button text to display. + /// + public string Text { get; private set; } = string.Empty; + + /// + /// The URL the button links to (internal or external). + /// + public string? Link { get; private set; } + + /// + /// The resolved link URL (handles relative paths). + /// + public string? ResolvedLink { get; private set; } + + /// + /// 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"; + + /// + /// If true, the link opens in a new tab with appropriate security attributes. + /// + public bool External { get; private set; } + + /// + /// Whether this button is inside a button group. + /// + public bool IsInGroup { get; private set; } + + public override void FinalizeAndValidate(ParserContext context) + { + // Get button text from arguments + Text = Arguments ?? string.Empty; + if (string.IsNullOrWhiteSpace(Text)) + this.EmitError("Button directive requires text as an argument."); + + // Get and validate link + Link = Prop("link", "href", "url"); + if (string.IsNullOrWhiteSpace(Link)) + { + this.EmitError("Button directive requires a :link: property."); + } + else + { + ResolveLink(context); + } + + // 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 ResolveLink(ParserContext context) + { + if (string.IsNullOrWhiteSpace(Link)) + return; + + // Get explicit external flag from properties + var explicitExternal = PropBool("external", "new-tab", "newtab"); + + // Check if it's an absolute URL + if (Uri.TryCreate(Link, UriKind.Absolute, out var uri)) + { + // Check if it's a cross-link (e.g., kibana://api/index.md) + if (CrossLinkValidator.IsCrossLink(uri)) + { + context.Build.Collector.EmitCrossLink(Link); + if (context.CrossLinkResolver.TryResolve( + s => this.EmitError(s), + uri, out var resolvedUri)) + { + ResolvedLink = resolvedUri.ToString(); + } + else + { + // Fallback to original link if resolution fails (error already emitted) + ResolvedLink = Link; + } + External = explicitExternal; + return; + } + + // Regular absolute URL (http/https) + if (uri.Scheme.StartsWith("http")) + { + ResolvedLink = Link; + // Auto-detect external links (non-elastic.co URLs), unless explicitly set + External = explicitExternal || !uri.Host.Contains("elastic.co"); + return; + } + } + + // Handle relative URLs - these are internal links + ResolvedLink = DiagnosticLinkInlineParser.UpdateRelativeUrl(context, Link); + External = explicitExternal; + } +} + 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..a778e09b0 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml @@ -0,0 +1,32 @@ +@inherits RazorSlice +@{ + // Use the same Tailwind classes as the landing page buttons + var primaryClasses = "select-none cursor-pointer text-white text-nowrap bg-blue-elastic hover:bg-blue-elastic-110 focus:ring-4 focus:ring-blue-elastic-50 font-semibold font-sans rounded-sm px-6 py-2 focus:outline-none h-10 flex items-center justify-center no-underline"; + var secondaryClasses = "cursor-pointer text-blue-elastic hover:text-blue-elastic-100 text-nowrap border-2 border-blue-elastic hover:border-blue-elastic-100 focus:ring-4 focus:outline-none focus:ring-blue-elastic-50 font-semibold font-sans rounded-sm px-6 py-2 text-center h-10 flex items-center justify-center no-underline"; + var buttonClasses = Model.Type == "secondary" ? secondaryClasses : primaryClasses; +} +@if (!Model.IsInGroup) +{ +
+ @if (Model.External) + { + @Model.Text + } + else + { + @Model.Text + } +
+} +else +{ + @if (Model.External) + { + @Model.Text + } + else + { + @Model.Text + } +} + 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..7bdbbbbce --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs @@ -0,0 +1,78 @@ +// 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. +/// +public class ButtonViewModel : DirectiveViewModel +{ + /// + /// The button text to display. + /// + public required string Text { get; init; } + + /// + /// The resolved URL the button links to. + /// + public required string? Link { get; init; } + + /// + /// 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; } + + /// + /// If true, the link opens in a new tab with appropriate security attributes. + /// + public required bool External { get; init; } + + /// + /// Whether this button is inside a button group. + /// + public required bool IsInGroup { get; init; } + + /// + /// Gets the CSS class for the button type. + /// + public string TypeClass => Type == "secondary" ? "doc-button-secondary" : "doc-button-primary"; + + /// + /// 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..4dbd149cf 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,31 @@ 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, + Text = block.Text, + Link = block.ResolvedLink, + Type = block.Type, + Align = block.Align, + External = block.External, + 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..3de81f0c4 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs @@ -0,0 +1,235 @@ +// 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 +:link: /get-started +::: +""" +) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void ExtractsText() => Block!.Text.Should().Be("Get Started"); + + [Fact] + public void ExtractsLink() => Block!.Link.Should().Be("/get-started"); + + [Fact] + public void DefaultsToPrimaryType() => Block!.Type.Should().Be("primary"); + + [Fact] + public void DefaultsToLeftAlign() => Block!.Align.Should().Be("left"); + + [Fact] + public void DefaultsToNotExternal() => Block!.External.Should().BeFalse(); + + [Fact] + public void RendersButtonElement() => Html.Should().Contain("bg-blue-elastic"); + + [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} Learn More +:link: /learn-more +:type: secondary +::: +""" +) +{ + [Fact] + public void ParsesSecondaryType() => Block!.Type.Should().Be("secondary"); + + [Fact] + public void RendersSecondaryClass() => Html.Should().Contain("border-blue-elastic"); +} + +public class ButtonAlignmentTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} Centered Button +:link: /centered +:align: center +::: +""" +) +{ + [Fact] + public void ParsesCenterAlign() => Block!.Align.Should().Be("center"); + + [Fact] + public void RendersWrapperWithAlignClass() => Html.Should().Contain("doc-button-wrapper doc-button-center"); +} + +public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} GitHub +:link: https://github.com/elastic +:external: +::: +""" +) +{ + [Fact] + public void ParsesExternalFlag() => Block!.External.Should().BeTrue(); + + [Fact] + public void RendersExternalAttributes() => Html.Should().Contain("target=\"_blank\""); + + [Fact] + public void RendersNoopenerNoreferrer() => Html.Should().Contain("rel=\"noopener noreferrer\""); +} + +public class ButtonAutoExternalTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} GitHub +:link: https://github.com/elastic +::: +""" +) +{ + [Fact] + public void AutoDetectsExternalLink() => Block!.External.Should().BeTrue(); +} + +public class ButtonMissingLinkTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} No Link +::: +""" +) +{ + [Fact] + public void EmitsErrorForMissingLink() => + Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires a :link: property")); +} + +public class ButtonMissingTextTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} +:link: /somewhere +::: +""" +) +{ + [Fact] + public void EmitsErrorForMissingText() => + Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires text as an argument")); +} + +public class ButtonInvalidTypeTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} Invalid Type +:link: /test +:type: invalid +::: +""" +) +{ + [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} Primary +:link: /primary +:type: primary +::: +:::{button} Secondary +:link: /secondary +:type: 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("bg-blue-elastic"); + + [Fact] + public void RendersSecondaryButton() => Html.Should().Contain("border-blue-elastic"); +} + +public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest(output, +""" +::::{button-group} +:align: center +:::{button} Centered +:link: /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 +:link: /in-group +::: +:::: +""" +) +{ + [Fact] + public void DetectsButtonIsInGroup() => Block!.IsInGroup.Should().BeTrue(); + + [Fact] + public void DoesNotRenderWrapperInGroup() => Html.Should().NotContain("doc-button-wrapper"); +} + +public class ButtonCrossLinkTests(ITestOutputHelper output) : DirectiveTest(output, +""" +:::{button} Kibana Docs +:link: kibana://api/index.md +::: +""" +) +{ + [Fact] + public void ParsesBlock() => Block.Should().NotBeNull(); + + [Fact] + public void ExtractsLink() => Block!.Link.Should().Be("kibana://api/index.md"); + + [Fact] + public void CrossLinksAreNotExternal() => Block!.External.Should().BeFalse(); +} + From 4d0b93bb50389cdbe24362c9c47e625af8478f8f Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 16 Dec 2025 12:47:18 +0100 Subject: [PATCH 2/7] Prettify CSS --- .../Assets/markdown/button.css | 46 +-- .../Assets/styles.css | 292 +++++++++--------- 2 files changed, 169 insertions(+), 169 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/markdown/button.css b/src/Elastic.Documentation.Site/Assets/markdown/button.css index bec679333..b087e5e5b 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/button.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/button.css @@ -2,67 +2,67 @@ /* Button wrapper for standalone buttons */ .doc-button-wrapper { - display: flex; - margin-block: 1rem; + display: flex; + margin-block: 1rem; } .doc-button-wrapper.doc-button-left { - justify-content: flex-start; + justify-content: flex-start; } .doc-button-wrapper.doc-button-center { - justify-content: center; + justify-content: center; } .doc-button-wrapper.doc-button-right { - justify-content: flex-end; + justify-content: flex-end; } /* Button group container */ .doc-button-group { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-block: 1rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-block: 1rem; } .doc-button-group.doc-button-group-left { - justify-content: flex-start; + justify-content: flex-start; } .doc-button-group.doc-button-group-center { - justify-content: center; + justify-content: center; } .doc-button-group.doc-button-group-right { - justify-content: flex-end; + justify-content: flex-end; } /* Override markdown link styles for buttons */ .markdown-content .doc-button-wrapper a, .markdown-content .doc-button-group a { - text-decoration: none !important; + text-decoration: none !important; } /* Primary button text color override */ .markdown-content .doc-button-wrapper a.bg-blue-elastic, .markdown-content .doc-button-group a.bg-blue-elastic { - color: #ffffff !important; + color: #ffffff !important; } /* Remove external link icon from buttons in markdown content */ -.markdown-content .doc-button-wrapper a[target="_blank"]::after, -.markdown-content .doc-button-group a[target="_blank"]::after { - content: none !important; +.markdown-content .doc-button-wrapper a[target='_blank']::after, +.markdown-content .doc-button-group a[target='_blank']::after { + content: none !important; } /* Responsive adjustments */ @media (max-width: 640px) { - .doc-button-group { - flex-direction: column; - } + .doc-button-group { + flex-direction: column; + } - .doc-button-group > a { - width: 100%; - } + .doc-button-group > a { + width: 100%; + } } diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index c5a07b7e7..d9581c093 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -1,233 +1,233 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @config '../tailwind.config.js'; -@import "./fonts.css"; -@import "./theme.css"; -@import "./markdown/applies-to.css"; -@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"; -@import "./copybutton.css"; -@import "./markdown/admonition.css"; -@import "./markdown/dropdown.css"; -@import "./markdown/table.css"; -@import "./markdown/definition-list.css"; -@import "./markdown/footnotes.css"; -@import "./markdown/images.css"; -@import "./markdown/math.css"; -@import "./markdown/image-carousel.css"; -@import "./markdown/hr.css"; -@import "./modal.css"; -@import "./archive.css"; -@import "./markdown/stepper.css"; -@import "./markdown/button.css"; -@import "./api-docs.css"; -@import "tippy.js/dist/tippy.css"; +@import './fonts.css'; +@import './theme.css'; +@import './markdown/applies-to.css'; +@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'; +@import './copybutton.css'; +@import './markdown/admonition.css'; +@import './markdown/dropdown.css'; +@import './markdown/table.css'; +@import './markdown/definition-list.css'; +@import './markdown/footnotes.css'; +@import './markdown/images.css'; +@import './markdown/math.css'; +@import './markdown/image-carousel.css'; +@import './markdown/hr.css'; +@import './modal.css'; +@import './archive.css'; +@import './markdown/stepper.css'; +@import './markdown/button.css'; +@import './api-docs.css'; +@import 'tippy.js/dist/tippy.css'; html { - /* We need to use 14px because EUI works best with a 14px base */ - font-size: 14px; - @apply font-body; + /* We need to use 14px because EUI works best with a 14px base */ + font-size: 14px; + @apply font-body; } body { - /* This is still needed because of some usages of ch units and to maintain the previous behavior */ - font-size: 16px; + /* This is still needed because of some usages of ch units and to maintain the previous behavior */ + font-size: 16px; } :root { - --outline-size: max(2px, 0.08em); - --outline-style: auto; - --outline-color: var(--color-blue-elastic); - --outline-offset: 5; - --header-height: calc(var(--spacing) * 21); - --banner-height: calc(var(--spacing) * 9); - /*--offset-top: calc(var(--header-height) + var(--banner-height));*/ - --offset-top: 72px; - --max-text-width: clamp(40ch, 90ch, 100%); - --max-sidebar-width: calc(var(--spacing) * 65); - --content-spacing: calc(var(--spacing) * 8); - --max-layout-width: calc( - var(--max-text-width) + (var(--max-sidebar-width) * 2) + - calc(var(--content-spacing) * 2) - ); - - --max-api-sidebar-width: calc(var(--spacing) * 70); - --max-examples-width: calc(var(--max-api-sidebar-width) * 2); + --outline-size: max(2px, 0.08em); + --outline-style: auto; + --outline-color: var(--color-blue-elastic); + --outline-offset: 5; + --header-height: calc(var(--spacing) * 21); + --banner-height: calc(var(--spacing) * 9); + /*--offset-top: calc(var(--header-height) + var(--banner-height));*/ + --offset-top: 72px; + --max-text-width: clamp(40ch, 90ch, 100%); + --max-sidebar-width: calc(var(--spacing) * 65); + --content-spacing: calc(var(--spacing) * 8); + --max-layout-width: calc( + var(--max-text-width) + (var(--max-sidebar-width) * 2) + + calc(var(--content-spacing) * 2) + ); + + --max-api-sidebar-width: calc(var(--spacing) * 70); + --max-examples-width: calc(var(--max-api-sidebar-width) * 2); } @media screen and (min-width: 767px) { - :root { - --offset-top: 102px; - } + :root { + --offset-top: 102px; + } } @media screen and (min-width: 992px) { - :root { - --offset-top: 110px; - } + :root { + --offset-top: 110px; + } } @media screen and (min-width: 1200px) { - :root { - --offset-top: 72px; - } + :root { + --offset-top: 72px; + } } #pages-nav li.current { - position: relative; - &::before { - content: ""; - position: absolute; - top: 50%; - left: -1px; - width: calc(var(--spacing) * 6); - height: 1px; - background-color: var(--color-grey-10); - } + position: relative; + &::before { + content: ''; + position: absolute; + top: 50%; + left: -1px; + width: calc(var(--spacing) * 6); + height: 1px; + background-color: var(--color-grey-10); + } } #toc-nav a.current { - color: var(--color-blue-elastic); - &:hover { - color: var(--color-blue-elastic); - } + color: var(--color-blue-elastic); + &:hover { + color: var(--color-blue-elastic); + } } @layer components { - .link { - @apply text-blue-elastic hover:text-blue-elastic-100 inline-flex items-center justify-center font-sans font-semibold text-nowrap; - - .link-icon { - @apply mr-2 ml-0 size-4 shrink-0; - } - - .link-arrow { - @apply ml-2 size-7 shrink-0 transition-transform ease-out; - } - - &:hover { - svg:not(.link-icon) { - @apply translate-x-2; - } - } - } - - .sidebar { - .sidebar-nav { - @apply sticky top-(--offset-top) z-30 overflow-y-auto; - max-height: calc(100vh - var(--offset-top)); - scrollbar-gutter: stable; - scroll-behavior: smooth; - } - - .sidebar-link { - @apply text-ink-light inline-block leading-[1.2em] text-pretty hover:text-black md:text-sm; - word-break: break-word; - } - } + .link { + @apply text-blue-elastic hover:text-blue-elastic-100 inline-flex items-center justify-center font-sans font-semibold text-nowrap; + + .link-icon { + @apply mr-2 ml-0 size-4 shrink-0; + } + + .link-arrow { + @apply ml-2 size-7 shrink-0 transition-transform ease-out; + } + + &:hover { + svg:not(.link-icon) { + @apply translate-x-2; + } + } + } + + .sidebar { + .sidebar-nav { + @apply sticky top-(--offset-top) z-30 overflow-y-auto; + max-height: calc(100vh - var(--offset-top)); + scrollbar-gutter: stable; + scroll-behavior: smooth; + } + + .sidebar-link { + @apply text-ink-light inline-block leading-[1.2em] text-pretty hover:text-black md:text-sm; + word-break: break-word; + } + } } * { - scroll-margin-top: calc(var(--offset-top) + var(--spacing) * 6); + scroll-margin-top: calc(var(--offset-top) + var(--spacing) * 6); } :is(a, button, input, textarea, summary):focus { - outline: var(--outline-size) var(--outline-style) var(--outline-color); - outline-offset: var(--outline-offset, var(--outline-size)); + outline: var(--outline-size) var(--outline-style) var(--outline-color); + outline-offset: var(--outline-offset, var(--outline-size)); } :is(a, button, input, textarea, summary):focus-visible { - outline: var(--outline-size) var(--outline-style) var(--outline-color); - outline-offset: var(--outline-offset, var(--outline-size)); + outline: var(--outline-size) var(--outline-style) var(--outline-color); + outline-offset: var(--outline-offset, var(--outline-size)); } :is(a, button, input, textarea, summary):focus:not(:focus-visible) { - outline: none; + outline: none; } .htmx-indicator { - display: none; + display: none; } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { - display: block; - z-index: 9999; + display: block; + z-index: 9999; } .progress { - animation: progress 1s infinite linear; + animation: progress 1s infinite linear; } .left-right { - transform-origin: 0% 50%; + transform-origin: 0% 50%; } @keyframes progress { - 0% { - transform: translateX(0) scaleX(0); - } - 40% { - transform: translateX(0) scaleX(0.4); - } - 100% { - transform: translateX(100%) scaleX(0.5); - } + 0% { + transform: translateX(0) scaleX(0); + } + 40% { + transform: translateX(0) scaleX(0.4); + } + 100% { + transform: translateX(100%) scaleX(0.5); + } } #pages-nav .current { - @apply text-blue-elastic! font-semibold; + @apply text-blue-elastic! font-semibold; } .markdown-content { - @apply font-body; + @apply font-body; } .container { - @apply lg:px-3; - max-width: var(--max-layout-width) !important; + @apply lg:px-3; + max-width: var(--max-layout-width) !important; - * { - @apply font-body; - } + * { + @apply font-body; + } } #elastic-nav-wrapper { - min-height: var(--offset-top); + min-height: var(--offset-top); } #elastic-nav { - @media screen and (min-width: 1200px) { - [data-component="Container"] { - /* subtract the padding */ - max-width: calc(var(--max-layout-width) - (var(--spacing) * 10)); - } - } + @media screen and (min-width: 1200px) { + [data-component='Container'] { + /* subtract the padding */ + max-width: calc(var(--max-layout-width) - (var(--spacing) * 10)); + } + } } body { - min-height: 100vh; - display: grid; - grid-template-rows: auto auto 1fr auto; + min-height: 100vh; + display: grid; + grid-template-rows: auto auto 1fr auto; } #pages-nav details > summary::-webkit-details-marker { - display: none; + display: none; } .tippy-content { - white-space: pre-line; + white-space: pre-line; } .icon, .icon > * { - user-select: none; - pointer-events: none; + user-select: none; + pointer-events: none; } math { - margin-top: 1.5rem; + margin-top: 1.5rem; } From f5a246b8237a81e72535ad7f6da0c6a5788acec7 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri-Benedetti Date: Tue, 16 Dec 2025 13:33:12 +0100 Subject: [PATCH 3/7] Potential fix for pull request finding 'Missed ternary opportunity' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Myst/Directives/Button/ButtonBlock.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs index 70cfc5017..15b3e64a0 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs @@ -124,17 +124,11 @@ private void ResolveLink(ParserContext context) if (CrossLinkValidator.IsCrossLink(uri)) { context.Build.Collector.EmitCrossLink(Link); - if (context.CrossLinkResolver.TryResolve( + ResolvedLink = context.CrossLinkResolver.TryResolve( s => this.EmitError(s), - uri, out var resolvedUri)) - { - ResolvedLink = resolvedUri.ToString(); - } - else - { - // Fallback to original link if resolution fails (error already emitted) - ResolvedLink = Link; - } + uri, out var resolvedUri) + ? resolvedUri.ToString() + : Link; External = explicitExternal; return; } From 0c5495a39b7c9a3fcd1d86101a6abaecb23d3d2d Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 16 Dec 2025 14:00:03 +0100 Subject: [PATCH 4/7] Fix crosslinks --- docs/syntax/buttons.md | 40 +++++++++---------- .../Myst/Directives/Button/ButtonBlock.cs | 11 +++++ .../Myst/Directives/Button/ButtonView.cshtml | 8 +++- .../Myst/Directives/Button/ButtonViewModel.cs | 10 +++++ .../Myst/Directives/DirectiveHtmlRenderer.cs | 4 +- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/docs/syntax/buttons.md b/docs/syntax/buttons.md index 7578a5130..aa6bb53fc 100644 --- a/docs/syntax/buttons.md +++ b/docs/syntax/buttons.md @@ -12,8 +12,8 @@ A button requires the button text as an argument and a `:link:` property: :::::::{tab-set} ::::::{tab-item} Output -:::{button} Get Started -:link: # +:::{button} Syntax Guide +:link: /syntax ::: :::::: @@ -36,12 +36,12 @@ Two button variants are available: :::::::{tab-set} ::::::{tab-item} Output ::::{button-group} -:::{button} Primary Button -:link: # +:::{button} Quick Reference +:link: /syntax/quick-ref :type: primary ::: -:::{button} Secondary Button -:link: # +:::{button} View All Syntax +:link: /syntax :type: secondary ::: :::: @@ -70,12 +70,12 @@ Use the `{button-group}` directive to display multiple buttons in a row: :::::::{tab-set} ::::::{tab-item} Output ::::{button-group} -:::{button} Elastic Fundamentals -:link: # +:::{button} How-to Guides +:link: /contribute :type: primary ::: -:::{button} Upgrade Versions -:link: # +:::{button} Configuration +:link: /configure :type: secondary ::: :::: @@ -105,18 +105,18 @@ Control the horizontal alignment of standalone buttons with the `:align:` proper :::::::{tab-set} ::::::{tab-item} Output -:::{button} Left (default) -:link: # +:::{button} Links +:link: /syntax/links :align: left ::: -:::{button} Center -:link: # +:::{button} Images +:link: /syntax/images :align: center ::: -:::{button} Right -:link: # +:::{button} Tables +:link: /syntax/tables :align: right ::: :::::: @@ -149,11 +149,11 @@ Button groups also support the `:align:` property: ::::::{tab-item} Output ::::{button-group} :align: center -:::{button} Centered Group -:link: # +:::{button} Code Blocks +:link: /syntax/code ::: -:::{button} Second Button -:link: # +:::{button} Tabs +:link: /syntax/tabs :type: secondary ::: :::: diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs index 70cfc5017..410d78274 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs @@ -69,6 +69,16 @@ public class ButtonBlock(DirectiveBlockParser parser, ParserContext context) : D /// public bool IsInGroup { get; private set; } + /// + /// Whether the link is a cross-repository link. + /// + public bool IsCrossLink { get; private set; } + + /// + /// Whether the link requires htmx attributes for client-side navigation. + /// + public bool RequiresHtmx => IsCrossLink || (ResolvedLink?.StartsWith('/') == true); + public override void FinalizeAndValidate(ParserContext context) { // Get button text from arguments @@ -123,6 +133,7 @@ private void ResolveLink(ParserContext context) // Check if it's a cross-link (e.g., kibana://api/index.md) if (CrossLinkValidator.IsCrossLink(uri)) { + IsCrossLink = true; context.Build.Collector.EmitCrossLink(Link); if (context.CrossLinkResolver.TryResolve( s => this.EmitError(s), diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml index a778e09b0..f21be2166 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml @@ -1,9 +1,13 @@ @inherits RazorSlice +@using Microsoft.AspNetCore.Html @{ // Use the same Tailwind classes as the landing page buttons var primaryClasses = "select-none cursor-pointer text-white text-nowrap bg-blue-elastic hover:bg-blue-elastic-110 focus:ring-4 focus:ring-blue-elastic-50 font-semibold font-sans rounded-sm px-6 py-2 focus:outline-none h-10 flex items-center justify-center no-underline"; var secondaryClasses = "cursor-pointer text-blue-elastic hover:text-blue-elastic-100 text-nowrap border-2 border-blue-elastic hover:border-blue-elastic-100 focus:ring-4 focus:outline-none focus:ring-blue-elastic-50 font-semibold font-sans rounded-sm px-6 py-2 text-center h-10 flex items-center justify-center no-underline"; var buttonClasses = Model.Type == "secondary" ? secondaryClasses : primaryClasses; + + // htmx attributes for client-side navigation (cross-links always use #main-container) + var htmxAttrs = Model.RequiresHtmx ? new HtmlString("hx-select-oob=\"#main-container\" preload=\"mousedown\"") : HtmlString.Empty; } @if (!Model.IsInGroup) { @@ -14,7 +18,7 @@ } else { - @Model.Text + @Model.Text } } @@ -26,7 +30,7 @@ else } else { - @Model.Text + @Model.Text } } diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs index 7bdbbbbce..e850c9597 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs @@ -39,6 +39,16 @@ public class ButtonViewModel : DirectiveViewModel /// public required bool IsInGroup { get; init; } + /// + /// Whether the link is a cross-repository link. + /// + public required bool IsCrossLink { get; init; } + + /// + /// Whether the link requires htmx attributes for client-side navigation. + /// + public required bool RequiresHtmx { get; init; } + /// /// Gets the CSS class for the button type. /// diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 4dbd149cf..7f5528989 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -205,7 +205,9 @@ private static void WriteButton(HtmlRenderer renderer, ButtonBlock block) Type = block.Type, Align = block.Align, External = block.External, - IsInGroup = block.IsInGroup + IsInGroup = block.IsInGroup, + IsCrossLink = block.IsCrossLink, + RequiresHtmx = block.RequiresHtmx }); RenderRazorSlice(slice, renderer); } From 68e317ebbb731ab28e1296b618297fc9cf7cfae7 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Tue, 16 Dec 2025 14:30:45 +0100 Subject: [PATCH 5/7] Refactor --- docs/syntax/buttons.md | 115 ++++++++---------- .../Assets/markdown/button.css | 71 +++++++++-- .../Myst/Directives/Button/ButtonBlock.cs | 98 ++------------- .../Myst/Directives/Button/ButtonView.cshtml | 32 +---- .../Myst/Directives/Button/ButtonViewModel.cs | 32 +---- .../Myst/Directives/DirectiveHtmlRenderer.cs | 7 +- .../Directives/ButtonTests.cs | 109 +++++------------ 7 files changed, 159 insertions(+), 305 deletions(-) diff --git a/docs/syntax/buttons.md b/docs/syntax/buttons.md index aa6bb53fc..bcf374a76 100644 --- a/docs/syntax/buttons.md +++ b/docs/syntax/buttons.md @@ -2,25 +2,25 @@ Buttons provide styled link elements for calls to action in documentation. Use buttons to highlight important navigation points, downloads, or external resources. -:::{button} This is an example -:link: docs-content://get-started/introduction.md +:::{button} +[Getting Started](docs-content://get-started/introduction.md) ::: ## Basic button -A button requires the button text as an argument and a `:link:` property: +A button wraps a standard Markdown link with button styling: :::::::{tab-set} ::::::{tab-item} Output -:::{button} Syntax Guide -:link: /syntax +:::{button} +[Syntax Guide](index.md) ::: :::::: ::::::{tab-item} Markdown ```markdown -:::{button} Get Started -:link: /get-started +:::{button} +[Get Started](/get-started) ::: ``` :::::: @@ -36,13 +36,12 @@ Two button variants are available: :::::::{tab-set} ::::::{tab-item} Output ::::{button-group} -:::{button} Quick Reference -:link: /syntax/quick-ref -:type: primary +:::{button} +[Quick Reference](quick-ref.md) ::: -:::{button} View All Syntax -:link: /syntax +:::{button} :type: secondary +[Syntax Guide](index.md) ::: :::: :::::: @@ -50,13 +49,12 @@ Two button variants are available: ::::::{tab-item} Markdown ```markdown ::::{button-group} -:::{button} Primary Button -:link: /primary -:type: primary +:::{button} +[Primary Action](/primary) ::: -:::{button} Secondary Button -:link: /secondary +:::{button} :type: secondary +[Secondary Action](/secondary) ::: :::: ``` @@ -70,13 +68,12 @@ Use the `{button-group}` directive to display multiple buttons in a row: :::::::{tab-set} ::::::{tab-item} Output ::::{button-group} -:::{button} How-to Guides -:link: /contribute -:type: primary +:::{button} +[Admonitions](admonitions.md) ::: -:::{button} Configuration -:link: /configure +:::{button} :type: secondary +[Dropdowns](dropdowns.md) ::: :::: :::::: @@ -84,13 +81,12 @@ Use the `{button-group}` directive to display multiple buttons in a row: ::::::{tab-item} Markdown ```markdown ::::{button-group} -:::{button} Elastic Fundamentals -:link: /get-started -:type: primary +:::{button} +[Elastic Fundamentals](/get-started) ::: -:::{button} Upgrade Versions -:link: /deploy-manage/upgrade +:::{button} :type: secondary +[Upgrade Versions](/deploy-manage/upgrade) ::: :::: ``` @@ -105,37 +101,37 @@ Control the horizontal alignment of standalone buttons with the `:align:` proper :::::::{tab-set} ::::::{tab-item} Output -:::{button} Links -:link: /syntax/links +:::{button} :align: left +[Links](links.md) ::: -:::{button} Images -:link: /syntax/images +:::{button} :align: center +[Images](images.md) ::: -:::{button} Tables -:link: /syntax/tables +:::{button} :align: right +[Tables](tables.md) ::: :::::: ::::::{tab-item} Markdown ```markdown -:::{button} Left (default) -:link: /example +:::{button} :align: left +[Left (default)](/example) ::: -:::{button} Center -:link: /example +:::{button} :align: center +[Center](/example) ::: -:::{button} Right -:link: /example +:::{button} :align: right +[Right](/example) ::: ``` :::::: @@ -149,12 +145,12 @@ Button groups also support the `:align:` property: ::::::{tab-item} Output ::::{button-group} :align: center -:::{button} Code Blocks -:link: /syntax/code +:::{button} +[Code Blocks](code.md) ::: -:::{button} Tabs -:link: /syntax/tabs +:::{button} :type: secondary +[Tabs](tabs.md) ::: :::: :::::: @@ -163,12 +159,12 @@ Button groups also support the `:align:` property: ```markdown ::::{button-group} :align: center -:::{button} Centered Group -:link: /example +:::{button} +[Centered Group](/example) ::: -:::{button} Second Button -:link: /example +:::{button} :type: secondary +[Second Button](/example) ::: :::: ``` @@ -177,21 +173,19 @@ Button groups also support the `:align:` property: ## External links -External links (URLs outside elastic.co) automatically open in a new tab. You can also explicitly mark a link as external: +External links (URLs outside elastic.co) automatically open in a new tab, just like regular links: :::::::{tab-set} ::::::{tab-item} Output -:::{button} Visit GitHub -:link: https://github.com/elastic -:external: +:::{button} +[Visit GitHub](https://github.com/elastic) ::: :::::: ::::::{tab-item} Markdown ```markdown -:::{button} Visit GitHub -:link: https://github.com/elastic -:external: +:::{button} +[Visit GitHub](https://github.com/elastic) ::: ``` :::::: @@ -205,15 +199,15 @@ Buttons support [cross-repository links](links.md#cross-repository-links) using :::::::{tab-set} ::::::{tab-item} Output -:::{button} Getting Started Guide -:link: docs-content://get-started/introduction.md +:::{button} +[Getting Started Guide](docs-content://get-started/introduction.md) ::: :::::: ::::::{tab-item} Markdown ```markdown -:::{button} Getting Started Guide -:link: docs-content://get-started/introduction.md +:::{button} +[Getting Started Guide](docs-content://get-started/introduction.md) ::: ``` :::::: @@ -227,15 +221,12 @@ Cross-links are resolved at build time to their target URLs in the documentation | Property | Required | Default | Description | |----------|----------|---------|-------------| -| (argument) | Yes | - | The button text to display. | -| `:link:` | Yes | - | The URL the button links to. Supports internal paths, external URLs, and cross-repository links (e.g., `kibana://api/index.md`). | +| (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`. | -| `:external:` | No | auto | If set, the link opens in a new tab. Auto-detected for non-elastic.co URLs. Cross-links are not treated as external by default. | ### 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 index b087e5e5b..b287e674c 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/button.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/button.css @@ -38,21 +38,68 @@ justify-content: flex-end; } -/* Override markdown link styles for buttons */ -.markdown-content .doc-button-wrapper a, -.markdown-content .doc-button-group a { +/* Button item wrapper for buttons inside groups */ +.doc-button-item { + display: inline-flex; +} + +/* Base button styles - applied to links inside button wrappers */ +/* Matches elastic.co/docs landing page buttons */ +.doc-button-wrapper a, +.doc-button-item a { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + white-space: nowrap; + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; + font-weight: 600; + border-radius: 0.25rem; /* rounded */ + padding: 0.5rem 1.5rem; /* py-2 px-6 */ + height: 2.5rem; /* h-10 */ text-decoration: none !important; + transition: + background-color 0.15s, + border-color 0.15s, + color 0.15s; } -/* Primary button text color override */ -.markdown-content .doc-button-wrapper a.bg-blue-elastic, -.markdown-content .doc-button-group a.bg-blue-elastic { +/* Primary button (filled blue) */ +.doc-button-primary a { + background-color: var(--blue-elastic, #0077cc); color: #ffffff !important; + border: 2px solid transparent; } -/* Remove external link icon from buttons in markdown content */ -.markdown-content .doc-button-wrapper a[target='_blank']::after, -.markdown-content .doc-button-group a[target='_blank']::after { +.doc-button-primary a:hover { + background-color: var(--blue-elastic-110, #0066b3); +} + +.doc-button-primary a:focus { + outline: none; + box-shadow: 0 0 0 4px var(--blue-elastic-50, rgba(0, 119, 204, 0.25)); +} + +/* Secondary button (outlined) */ +.doc-button-secondary a { + background-color: transparent; + color: var(--blue-elastic, #0077cc) !important; + border: 2px solid var(--blue-elastic, #0077cc); +} + +.doc-button-secondary a:hover { + color: var(--blue-elastic-100, #005fa3) !important; + border-color: var(--blue-elastic-100, #005fa3); +} + +.doc-button-secondary a:focus { + outline: none; + box-shadow: 0 0 0 4px var(--blue-elastic-50, rgba(0, 119, 204, 0.25)); +} + +/* Remove external link icon from buttons */ +.doc-button-wrapper a[target='_blank']::after, +.doc-button-item a[target='_blank']::after { content: none !important; } @@ -62,7 +109,11 @@ flex-direction: column; } - .doc-button-group > a { + .doc-button-item { + width: 100%; + } + + .doc-button-item a { width: 100%; } } diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs index 766336995..84c63519d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonBlock.cs @@ -2,9 +2,7 @@ // 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.Links; using Elastic.Markdown.Diagnostics; -using Elastic.Markdown.Myst.InlineParsers; namespace Elastic.Markdown.Myst.Directives.Button; @@ -25,8 +23,15 @@ public override void FinalizeAndValidate(ParserContext context) => } /// -/// A button directive that renders as a styled link button. +/// 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 class ButtonBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { public override string Directive => "button"; @@ -34,21 +39,6 @@ public class ButtonBlock(DirectiveBlockParser parser, ParserContext context) : D private static readonly HashSet ValidTypes = ["primary", "secondary"]; private static readonly HashSet ValidAligns = ["left", "center", "right"]; - /// - /// The button text to display. - /// - public string Text { get; private set; } = string.Empty; - - /// - /// The URL the button links to (internal or external). - /// - public string? Link { get; private set; } - - /// - /// The resolved link URL (handles relative paths). - /// - public string? ResolvedLink { get; private set; } - /// /// Button variant: "primary" (filled) or "secondary" (outlined). /// @@ -59,44 +49,13 @@ public class ButtonBlock(DirectiveBlockParser parser, ParserContext context) : D /// public string Align { get; private set; } = "left"; - /// - /// If true, the link opens in a new tab with appropriate security attributes. - /// - public bool External { get; private set; } - /// /// Whether this button is inside a button group. /// public bool IsInGroup { get; private set; } - /// - /// Whether the link is a cross-repository link. - /// - public bool IsCrossLink { get; private set; } - - /// - /// Whether the link requires htmx attributes for client-side navigation. - /// - public bool RequiresHtmx => IsCrossLink || (ResolvedLink?.StartsWith('/') == true); - public override void FinalizeAndValidate(ParserContext context) { - // Get button text from arguments - Text = Arguments ?? string.Empty; - if (string.IsNullOrWhiteSpace(Text)) - this.EmitError("Button directive requires text as an argument."); - - // Get and validate link - Link = Prop("link", "href", "url"); - if (string.IsNullOrWhiteSpace(Link)) - { - this.EmitError("Button directive requires a :link: property."); - } - else - { - ResolveLink(context); - } - // Get and validate type var type = Prop("type", "variant")?.ToLowerInvariant(); if (type != null && !ValidTypes.Contains(type)) @@ -118,45 +77,4 @@ public override void FinalizeAndValidate(ParserContext context) // Check if inside a button group IsInGroup = Parent is ButtonGroupBlock; } - - private void ResolveLink(ParserContext context) - { - if (string.IsNullOrWhiteSpace(Link)) - return; - - // Get explicit external flag from properties - var explicitExternal = PropBool("external", "new-tab", "newtab"); - - // Check if it's an absolute URL - if (Uri.TryCreate(Link, UriKind.Absolute, out var uri)) - { - // Check if it's a cross-link (e.g., kibana://api/index.md) - if (CrossLinkValidator.IsCrossLink(uri)) - { - IsCrossLink = true; - context.Build.Collector.EmitCrossLink(Link); - ResolvedLink = context.CrossLinkResolver.TryResolve( - s => this.EmitError(s), - uri, out var resolvedUri) - ? resolvedUri.ToString() - : Link; - External = explicitExternal; - return; - } - - // Regular absolute URL (http/https) - if (uri.Scheme.StartsWith("http")) - { - ResolvedLink = Link; - // Auto-detect external links (non-elastic.co URLs), unless explicitly set - External = explicitExternal || !uri.Host.Contains("elastic.co"); - return; - } - } - - // Handle relative URLs - these are internal links - ResolvedLink = DiagnosticLinkInlineParser.UpdateRelativeUrl(context, Link); - External = explicitExternal; - } } - diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml index f21be2166..9add4a7bd 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonView.cshtml @@ -1,36 +1,16 @@ @inherits RazorSlice -@using Microsoft.AspNetCore.Html @{ - // Use the same Tailwind classes as the landing page buttons - var primaryClasses = "select-none cursor-pointer text-white text-nowrap bg-blue-elastic hover:bg-blue-elastic-110 focus:ring-4 focus:ring-blue-elastic-50 font-semibold font-sans rounded-sm px-6 py-2 focus:outline-none h-10 flex items-center justify-center no-underline"; - var secondaryClasses = "cursor-pointer text-blue-elastic hover:text-blue-elastic-100 text-nowrap border-2 border-blue-elastic hover:border-blue-elastic-100 focus:ring-4 focus:outline-none focus:ring-blue-elastic-50 font-semibold font-sans rounded-sm px-6 py-2 text-center h-10 flex items-center justify-center no-underline"; - var buttonClasses = Model.Type == "secondary" ? secondaryClasses : primaryClasses; - - // htmx attributes for client-side navigation (cross-links always use #main-container) - var htmxAttrs = Model.RequiresHtmx ? new HtmlString("hx-select-oob=\"#main-container\" preload=\"mousedown\"") : HtmlString.Empty; + var typeClass = Model.Type == "secondary" ? "doc-button-secondary" : "doc-button-primary"; } @if (!Model.IsInGroup) { -
- @if (Model.External) - { - @Model.Text - } - else - { - @Model.Text - } +
+ @Model.RenderBlock()
} else { - @if (Model.External) - { - @Model.Text - } - else - { - @Model.Text - } + + @Model.RenderBlock() + } - diff --git a/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs index e850c9597..3aba724b8 100644 --- a/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/Button/ButtonViewModel.cs @@ -6,19 +6,10 @@ 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 { - /// - /// The button text to display. - /// - public required string Text { get; init; } - - /// - /// The resolved URL the button links to. - /// - public required string? Link { get; init; } - /// /// Button variant: "primary" (filled) or "secondary" (outlined). /// @@ -29,31 +20,11 @@ public class ButtonViewModel : DirectiveViewModel /// public required string Align { get; init; } - /// - /// If true, the link opens in a new tab with appropriate security attributes. - /// - public required bool External { get; init; } - /// /// Whether this button is inside a button group. /// public required bool IsInGroup { get; init; } - /// - /// Whether the link is a cross-repository link. - /// - public required bool IsCrossLink { get; init; } - - /// - /// Whether the link requires htmx attributes for client-side navigation. - /// - public required bool RequiresHtmx { get; init; } - - /// - /// Gets the CSS class for the button type. - /// - public string TypeClass => Type == "secondary" ? "doc-button-secondary" : "doc-button-primary"; - /// /// Gets the CSS class for the button alignment (only used for standalone buttons). /// @@ -85,4 +56,3 @@ public class ButtonGroupViewModel : DirectiveViewModel _ => "doc-button-group-left" }; } - diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 7f5528989..12a29b96b 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -200,14 +200,9 @@ private static void WriteButton(HtmlRenderer renderer, ButtonBlock block) var slice = ButtonView.Create(new ButtonViewModel { DirectiveBlock = block, - Text = block.Text, - Link = block.ResolvedLink, Type = block.Type, Align = block.Align, - External = block.External, - IsInGroup = block.IsInGroup, - IsCrossLink = block.IsCrossLink, - RequiresHtmx = block.RequiresHtmx + IsInGroup = block.IsInGroup }); RenderRazorSlice(slice, renderer); } diff --git a/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs index 3de81f0c4..a1387b786 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ButtonTests.cs @@ -9,8 +9,8 @@ namespace Elastic.Markdown.Tests.Directives; public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest(output, """ -:::{button} Get Started -:link: /get-started +:::{button} +[Get Started](/get-started) ::: """ ) @@ -18,12 +18,6 @@ public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest Block.Should().NotBeNull(); - [Fact] - public void ExtractsText() => Block!.Text.Should().Be("Get Started"); - - [Fact] - public void ExtractsLink() => Block!.Link.Should().Be("/get-started"); - [Fact] public void DefaultsToPrimaryType() => Block!.Type.Should().Be("primary"); @@ -31,10 +25,7 @@ public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest Block!.Align.Should().Be("left"); [Fact] - public void DefaultsToNotExternal() => Block!.External.Should().BeFalse(); - - [Fact] - public void RendersButtonElement() => Html.Should().Contain("bg-blue-elastic"); + public void RendersPrimaryButtonClass() => Html.Should().Contain("doc-button-primary"); [Fact] public void RendersLinkHref() => Html.Should().Contain("href=\"/get-started\""); @@ -45,9 +36,9 @@ public class ButtonBlockTests(ITestOutputHelper output) : DirectiveTest(output, """ -:::{button} Learn More -:link: /learn-more +:::{button} :type: secondary +[Learn More](/learn-more) ::: """ ) @@ -56,14 +47,14 @@ public class ButtonSecondaryTests(ITestOutputHelper output) : DirectiveTest Block!.Type.Should().Be("secondary"); [Fact] - public void RendersSecondaryClass() => Html.Should().Contain("border-blue-elastic"); + public void RendersSecondaryClass() => Html.Should().Contain("doc-button-secondary"); } public class ButtonAlignmentTests(ITestOutputHelper output) : DirectiveTest(output, """ -:::{button} Centered Button -:link: /centered +:::{button} :align: center +[Centered Button](/centered) ::: """ ) @@ -72,21 +63,17 @@ public class ButtonAlignmentTests(ITestOutputHelper output) : DirectiveTest Block!.Align.Should().Be("center"); [Fact] - public void RendersWrapperWithAlignClass() => Html.Should().Contain("doc-button-wrapper doc-button-center"); + public void RendersWrapperWithAlignClass() => Html.Should().Contain("doc-button-wrapper doc-button-primary doc-button-center"); } public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest(output, """ -:::{button} GitHub -:link: https://github.com/elastic -:external: +:::{button} +[GitHub](https://github.com/elastic) ::: """ ) { - [Fact] - public void ParsesExternalFlag() => Block!.External.Should().BeTrue(); - [Fact] public void RendersExternalAttributes() => Html.Should().Contain("target=\"_blank\""); @@ -94,48 +81,11 @@ public class ButtonExternalTests(ITestOutputHelper output) : DirectiveTest Html.Should().Contain("rel=\"noopener noreferrer\""); } -public class ButtonAutoExternalTests(ITestOutputHelper output) : DirectiveTest(output, -""" -:::{button} GitHub -:link: https://github.com/elastic -::: -""" -) -{ - [Fact] - public void AutoDetectsExternalLink() => Block!.External.Should().BeTrue(); -} - -public class ButtonMissingLinkTests(ITestOutputHelper output) : DirectiveTest(output, -""" -:::{button} No Link -::: -""" -) -{ - [Fact] - public void EmitsErrorForMissingLink() => - Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires a :link: property")); -} - -public class ButtonMissingTextTests(ITestOutputHelper output) : DirectiveTest(output, -""" -:::{button} -:link: /somewhere -::: -""" -) -{ - [Fact] - public void EmitsErrorForMissingText() => - Collector.Diagnostics.Should().ContainSingle(d => d.Message.Contains("requires text as an argument")); -} - public class ButtonInvalidTypeTests(ITestOutputHelper output) : DirectiveTest(output, """ -:::{button} Invalid Type -:link: /test +:::{button} :type: invalid +[Invalid Type](/test) ::: """ ) @@ -151,13 +101,13 @@ public void EmitsWarningForInvalidType() => public class ButtonGroupTests(ITestOutputHelper output) : DirectiveTest(output, """ ::::{button-group} -:::{button} Primary -:link: /primary +:::{button} :type: primary +[Primary](/primary) ::: -:::{button} Secondary -:link: /secondary +:::{button} :type: secondary +[Secondary](/secondary) ::: :::: """ @@ -174,18 +124,18 @@ public void ContainsBothButtons() => Html.Should().Contain("Primary").And.Contain("Secondary"); [Fact] - public void RendersPrimaryButton() => Html.Should().Contain("bg-blue-elastic"); + public void RendersPrimaryButton() => Html.Should().Contain("doc-button-primary"); [Fact] - public void RendersSecondaryButton() => Html.Should().Contain("border-blue-elastic"); + public void RendersSecondaryButton() => Html.Should().Contain("doc-button-secondary"); } public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest(output, """ ::::{button-group} :align: center -:::{button} Centered -:link: /centered +:::{button} +[Centered](/centered) ::: :::: """ @@ -201,8 +151,8 @@ public class ButtonGroupAlignmentTests(ITestOutputHelper output) : DirectiveTest public class ButtonInGroupTests(ITestOutputHelper output) : DirectiveTest(output, """ ::::{button-group} -:::{button} In Group -:link: /in-group +:::{button} +[In Group](/in-group) ::: :::: """ @@ -213,12 +163,15 @@ public class ButtonInGroupTests(ITestOutputHelper output) : DirectiveTest