From 768f503040726a08fd140494749b1ee0dec01af2 Mon Sep 17 00:00:00 2001 From: Liam Thompson Date: Wed, 28 Oct 2026 12:25:02 +0100 Subject: [PATCH] Add labeled commands to get-started options and wrap long commands A get-started option can now offer several labeled commands, each in its own code block with its own copy button. Long commands in the legacy hub layout wrap instead of being clipped, so readers see the whole command. Co-Authored-By: Claude Opus 5.5 --- docs/syntax/get-started.md | 15 +++++ .../Assets/markdown/hub.css | 38 +++++++++++-- .../Components/Pages/Hub/hub.css | 22 +++++++ .../Myst/Directives/DirectiveHtmlRenderer.cs | 7 +++ .../Myst/Directives/Hub/GetStartedBlock.cs | 15 +++++ .../Hub/GetStartedDesignView.cshtml | 25 ++++++-- .../Myst/Directives/Hub/GetStartedView.cshtml | 27 ++++++--- .../Directives/Hub/GetStartedViewModel.cs | 9 +++ .../LlmMarkdown/LlmBlockRenderers.cs | 2 + .../Blocks/Hub/GetStarted.cs | 57 +++++++++++++++++++ .../DesignSystem/DesignSystemDirectives.cs | 28 +++++++++ 11 files changed, 227 insertions(+), 18 deletions(-) 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; } + /// Labeled variants of one command. Each gets its own code block and copy button. + [YamlPropertyName("commands")] + public GetStartedCommand[] Commands { get; set; } = []; + [YamlPropertyName("language")] public string? Language { get; set; } @@ -121,3 +127,12 @@ public record GetStartedStepOption [YamlPropertyName("url-label")] public string? UrlLabel { get; set; } } + +public record GetStartedCommand +{ + [YamlPropertyName("label")] + public string? Label { get; set; } + + [YamlPropertyName("code")] + public string? Code { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedDesignView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedDesignView.cshtml index 5e98da0848..4750b4e4d2 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedDesignView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedDesignView.cshtml @@ -50,17 +50,30 @@ {

@(new HtmlString(option.DescriptionHtml))

} - @if (!string.IsNullOrWhiteSpace(option.Code)) + @if (option.HasCode) { -
- -
@option.Code
- + var language = string.IsNullOrWhiteSpace(option.Language) ? "text" : option.Language; + IReadOnlyList commands = string.IsNullOrWhiteSpace(option.Code) + ? option.Commands + : [new() { Label = null, Code = option.Code }]; +
+ @foreach (var command in commands) + { + @if (!string.IsNullOrWhiteSpace(command.Label)) + { + @command.Label + } +
+ +
@command.Code
+ +
+ }
} @if (!string.IsNullOrWhiteSpace(option.Url)) { - @(string.IsNullOrWhiteSpace(option.UrlLabel) ? (string.IsNullOrWhiteSpace(option.Code) ? "Get started" : "Learn more") : option.UrlLabel) + @(string.IsNullOrWhiteSpace(option.UrlLabel) ? (option.HasCode ? "Learn more" : "Get started") : option.UrlLabel) }
} diff --git a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml index 52425d3a81..81dd009962 100644 --- a/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/Hub/GetStartedView.cshtml @@ -135,17 +135,21 @@ if (!string.IsNullOrWhiteSpace(option.DescriptionHtml)) writer.Write($"

{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("
"); + foreach (var command in option.Commands) + { + if (!string.IsNullOrWhiteSpace(command.Label)) + writer.Write($"{System.Net.WebUtility.HtmlEncode(command.Label)}"); + WriteCommand(writer, command.Code, option.Language); + } + writer.Write("
"); } if (!string.IsNullOrWhiteSpace(option.Url)) { - if (!string.IsNullOrWhiteSpace(option.Code)) + if (option.HasCode) { 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 IReadOnlyList Commands { get; init; } public required string? Language { get; init; } public required string? Url { get; init; } public required string? UrlLabel { get; init; } + + public bool HasCode => !string.IsNullOrWhiteSpace(Code) || Commands.Count > 0; +} + +public sealed record GetStartedCommandViewModel +{ + public required string? Label { get; init; } + public required string Code { get; init; } } diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index 2916a544a0..eefe1283e2 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -668,6 +668,8 @@ private static void WriteGetStartedStep(LlmMarkdownRenderer renderer, GetStarted renderer.WriteLine($" - {option.Label}: {option.Description}"); if (!string.IsNullOrEmpty(option.Code)) renderer.WriteLine($" `{option.Code}`"); + foreach (var command in option.Commands) + renderer.WriteLine($" - {command.Label}: `{command.Code}`"); if (!string.IsNullOrEmpty(option.Url)) renderer.WriteLine($" [{option.UrlLabel ?? "Get started"}]({HubLinkForLlm(renderer, option.Url)})"); } diff --git a/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs b/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs index dec67a93fb..3e23280a85 100644 --- a/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs +++ b/tests/Elastic.Authoring.Tests/Blocks/Hub/GetStarted.cs @@ -260,3 +260,60 @@ public class GetStartedWithAFirstStepThatHasNoOptions : MarkdownTest [Test, DisplayName("has no errors")] public async Task HasNoErrors() => await Docs.HasNoErrors(); } + +public class GetStartedWithLabeledCommands : MarkdownTest +{ + protected override string Markdown => + """ + :::{get-started} + title: Get started + steps: + - title: Run it + options: + - label: Run locally + commands: + - label: Server only + code: install --server-only + - label: Server and UI + code: install + language: sh + - label: Cloud + url: /index.md + ::: + """; + + [Test, DisplayName("labels each command")] + public async Task LabelsEachCommand() => + await Docs.ConvertsToContainingRawHtml("""Server and UI"""); + + [Test, DisplayName("renders each command in its own code block")] + public async Task RendersEachCommandInItsOwnCodeBlock() + { + await Docs.ConvertsToContainingHtml("""install --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(" + """ + :::{get-started} + title: Get started + steps: + - title: Run it + options: + - label: Run locally + commands: + - label: Server only + code: install --server-only + - label: Server and UI + code: install + language: sh + ::: + """; + + [Test, DisplayName("gives each labeled command its own copy button")] + public async Task Renders() + { + await Docs.ConvertsToContainingRawHtml("""Server only"""); + await Docs.ConvertsToContainingRawHtml("data-copy-text=\"install --server-only\""); + await Docs.ConvertsToContainingRawHtml("data-copy-text=\"install\""); + } +}