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