diff --git a/docs/syntax/get-started.md b/docs/syntax/get-started.md
index aa2037017b..9ed7d92c27 100644
--- a/docs/syntax/get-started.md
+++ b/docs/syntax/get-started.md
@@ -98,6 +98,21 @@ A step takes one of three shapes.
url-label: How to publish
```
+When an option offers variants of one command, use `commands` instead of `code`. Each command takes a short `label` and gets its own code block, so each copy button copies exactly one command. All commands use the option's `language`.
+
+```yaml
+- label: Run locally
+ description: Spin up Elasticsearch on your machine for development with `start-local`.
+ commands:
+ - label: Elasticsearch only
+ code: curl -fsSL https://elastic.co/start-local | sh -s -- --esonly
+ - label: Elasticsearch and Kibana
+ code: curl -fsSL https://elastic.co/start-local | sh
+ language: sh
+```
+
+An option takes either `code` or `commands`, not both.
+
Steps are numbered automatically, in source order. Each number sits to the left of its title.
## Links
diff --git a/src/Elastic.Documentation.Site/Assets/markdown/hub.css b/src/Elastic.Documentation.Site/Assets/markdown/hub.css
index 0987a10c6d..2440bba8e1 100644
--- a/src/Elastic.Documentation.Site/Assets/markdown/hub.css
+++ b/src/Elastic.Documentation.Site/Assets/markdown/hub.css
@@ -228,20 +228,21 @@
--code-size: var(--text-xs);
--code-leading: 1.2;
}
- /* A long command scrolls sideways inside its box instead of being clipped. */
+ /* A long command wraps inside its box, so the whole command stays visible.
+ The box keeps the option button's height for a one-line command. */
.hub-get-started .hub-get-started-install pre {
display: flex;
align-items: center;
box-sizing: border-box;
- height: 37px;
+ min-height: 37px;
margin: 0;
- padding: 0 36px 0 12px;
- overflow-x: auto;
+ padding: 8px 36px 8px 12px;
background: var(--color-ink-navy);
border-radius: 8px;
}
.hub-get-started .hub-get-started-install code {
- white-space: nowrap;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
}
.hub-get-started .hub-get-started-track {
@@ -383,9 +384,36 @@
line-height: 1.45;
}
.hub-get-started .hub-get-started-option .hub-get-started-install,
+ .hub-get-started .hub-get-started-option .hub-get-started-commands,
.hub-get-started .hub-get-started-option-btn {
margin-top: auto;
}
+ /* Labeled variants of one command, stacked, each in its own copyable chip. */
+ .hub-get-started .hub-get-started-commands {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+ /* Stacked chips share one width, so their edges line up. */
+ .hub-get-started .hub-get-started-commands .hub-get-started-install {
+ width: 100%;
+ margin-top: 0;
+ }
+ .hub-get-started .hub-get-started-command-label {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--color-ink-light, #4c4c4c);
+ }
+ .hub-get-started .hub-get-started-command-label:not(:first-child) {
+ margin-top: 8px;
+ }
+ /* A command stack is taller than a button, so a button beside one stays under its own
+ text instead of dropping to the bottom of the row. */
+ .hub-get-started
+ .hub-get-started-fork:has(.hub-get-started-commands)
+ .hub-get-started-option-btn {
+ margin-top: 0;
+ }
/* Same height as the command chip, and smaller than the body type. */
.hub-get-started .hub-get-started-option-btn {
@apply inline-flex items-center justify-center;
diff --git a/src/Elastic.Documentation.Site/Components/Pages/Hub/hub.css b/src/Elastic.Documentation.Site/Components/Pages/Hub/hub.css
index 314da846dd..d9445ba2d7 100644
--- a/src/Elastic.Documentation.Site/Components/Pages/Hub/hub.css
+++ b/src/Elastic.Documentation.Site/Components/Pages/Hub/hub.css
@@ -607,6 +607,28 @@ a:focus-visible .hub-arrow {
color: var(--color-code-text);
}
+/* Labeled variants of one command, stacked, each with its own copy button. */
+.get-started__commands {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-top: auto;
+}
+
+.get-started__commands .get-started__code {
+ margin-top: 0;
+}
+
+.get-started__command-label {
+ color: var(--color-text-muted);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.get-started__command-label:not(:first-child) {
+ margin-top: var(--space-2);
+}
+
.get-started__prompt {
color: var(--color-code-keyword);
font-family: var(--font-mono);
diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
index 8730cd910f..31db6a0eff 100644
--- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
+++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs
@@ -336,6 +336,13 @@ .. step.Options.Select(
Label = option.Label,
DescriptionHtml = RenderInlineMarkdown(option.Description),
Code = option.Code,
+ Commands =
+ [
+ .. option
+ .Commands
+ .Where(command => !string.IsNullOrWhiteSpace(command.Code))
+ .Select(command => new GetStartedCommandViewModel { Label = command.Label, Code = command.Code! })
+ ],
Language = option.Language,
Url = option.Url,
UrlLabel = option.UrlLabel
diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs
index ae1343a828..302bd5255b 100644
--- a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs
+++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedBlock.cs
@@ -59,6 +59,8 @@ public override void FinalizeAndValidate(ParserContext context)
step.Description = DirectiveLinkValidator.ResolveMarkdownLinks(step.Description, this, context);
foreach (var option in step.Options)
{
+ if (!string.IsNullOrWhiteSpace(option.Code) && option.Commands.Length > 0)
+ this.EmitError("{get-started} option takes either `code` or `commands`, not both.");
option.Url = DirectiveLinkValidator.ValidateAndResolve(option.Url, this, context);
option.Description = DirectiveLinkValidator.ResolveMarkdownLinks(option.Description, this, context);
}
@@ -112,6 +114,10 @@ public record GetStartedStepOption
[YamlPropertyName("code")]
public string? Code { get; set; }
+ ///
@(new HtmlString(option.DescriptionHtml))
} - @if (!string.IsNullOrWhiteSpace(option.Code)) + @if (option.HasCode) { -@option.Code
-
+ var language = string.IsNullOrWhiteSpace(option.Language) ? "text" : option.Language;
+ IReadOnlyList@command.Code
+
+ {option.DescriptionHtml}
"); if (!string.IsNullOrWhiteSpace(option.Code)) + WriteCommand(writer, option.Code, option.Language); + else if (option.Commands.Count > 0) { - var language = string.IsNullOrWhiteSpace(option.Language) ? "text" : option.Language; - writer.Write("");
- writer.Write(System.Net.WebUtility.HtmlEncode(option.Code));
- writer.Write("");
+ writer.Write(System.Net.WebUtility.HtmlEncode(code));
+ writer.Write("");
+ }
}
diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs
index 057d2e44e6..59e14b0901 100644
--- a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs
+++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedViewModel.cs
@@ -53,7 +53,16 @@ public sealed record GetStartedOptionViewModel
public required string? Label { get; init; }
public required string? DescriptionHtml { get; init; }
public required string? Code { get; init; }
+ public required IReadOnlyListinstall --server-only""");
+ await Docs.ConvertsToContainingHtml("""install""");
+ }
+
+ [Test, DisplayName("has no errors")]
+ public async Task HasNoErrors() => await Docs.HasNoErrors();
+}
+
+public class GetStartedWithCodeAndCommands : MarkdownTest
+{
+ protected override string Markdown =>
+ """
+ :::{get-started}
+ title: Get started
+ steps:
+ - title: Run it
+ options:
+ - label: Run locally
+ code: install
+ commands:
+ - label: Server only
+ code: install --server-only
+ :::
+ """;
+
+ [Test, DisplayName("rejects code and commands together")]
+ public async Task RejectsCodeAndCommandsTogether() => await Docs.HasError("either `code` or `commands`");
+}
diff --git a/tests/Elastic.Authoring.Tests/DesignSystem/DesignSystemDirectives.cs b/tests/Elastic.Authoring.Tests/DesignSystem/DesignSystemDirectives.cs
index 3bfc00e1cb..a441e94509 100644
--- a/tests/Elastic.Authoring.Tests/DesignSystem/DesignSystemDirectives.cs
+++ b/tests/Elastic.Authoring.Tests/DesignSystem/DesignSystemDirectives.cs
@@ -488,3 +488,31 @@ public async Task Renders()
await Docs.DoesNotConvertToContainingHtml("