diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs index d4324974ce..7ceda4bfba 100644 --- a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs @@ -22,7 +22,8 @@ public record CliSchema( string[]? Tags = null, bool? RequiresAuth = null, string[]? AuthCommands = null, - CliEnvironmentSchema? Environment = null + CliEnvironmentSchema? Environment = null, + List? Shortcuts = null ) { public static CliSchema Load(IFileInfo schemaFile) @@ -33,6 +34,8 @@ public static CliSchema Load(IFileInfo schemaFile) } } +public record CliShortcutSchema(string From, string[] To); + public record CliCommandSchema( string[] Path, string Name, @@ -55,10 +58,10 @@ public record CliNamespaceSchema( string Segment, string? Summary, string? Notes, - List Options, + List? Options, CliDefaultSchema? DefaultCommand, - List Commands, - List Namespaces + List? Commands, + List? Namespaces ); public record CliParamSchema( diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs index b1746079a0..16f9af2252 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs @@ -484,7 +484,14 @@ INavigationHomeAccessor homeAccessor children.Add(childNav); } - // All root commands + namespaces from the schema always follow + // Shortcut alias pages first, then commands and namespaces + foreach (var shortcut in schema.Shortcuts ?? []) + { + var aliasNav = MakeFileLeaf(docSourceDir, virtualRoot, [shortcut.From], isNamespace: true, childIndex++, folderNavigation, homeAccessor, context); + if (aliasNav is not null) + children.Add(aliasNav); + } + foreach (var cmd in schema.Commands) { var cmdNav = MakeFileLeaf(docSourceDir, virtualRoot, [cmd.Name], isNamespace: false, childIndex++, folderNavigation, homeAccessor, context); @@ -532,7 +539,7 @@ IDocumentationSetContext context children.Add(nsIndexNav); // Namespace commands - foreach (var cmd in ns.Commands) + foreach (var cmd in ns.Commands ?? []) { var cmdSegments = segments.Append(cmd.Name).ToArray(); var cmdNav = MakeFileLeaf(docSourceDir, virtualRoot, cmdSegments, isNamespace: false, childIndex++, nsFolderNav, homeAccessor, context); @@ -541,7 +548,7 @@ IDocumentationSetContext context } // Sub-namespaces - foreach (var subNs in ns.Namespaces) + foreach (var subNs in ns.Namespaces ?? []) { var subSegments = segments.Append(subNs.Segment).ToArray(); var subNav = BuildNamespaceNavigation(docSourceDir, virtualRoot, subNs, subSegments, childIndex++, nsFolderNav, homeAccessor, context); diff --git a/src/Elastic.Documentation.Site/Assets/markdown/cli-modifiers.css b/src/Elastic.Documentation.Site/Assets/markdown/cli-modifiers.css index 9d72f58202..e4c9edb0b0 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/cli-modifiers.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/cli-modifiers.css @@ -1,13 +1,24 @@ @layer components { .cli-modifiers { - @apply flex flex-wrap gap-2 pb-4; + @apply mt-4 mb-4 flex flex-wrap gap-2; .cli-modifier { - @apply inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium; + @apply relative inline-flex cursor-help items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium; svg { @apply size-3.5 shrink-0; } + + /* CSS tooltip via data-tooltip attribute */ + &[data-tooltip]::after { + content: attr(data-tooltip); + @apply pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-max max-w-64 -translate-x-1/2 rounded px-2 py-1 text-xs text-white opacity-0 transition-opacity; + background: #1e293b; + } + &[data-tooltip]:hover::after, + &[data-tooltip]:focus-visible::after { + @apply opacity-100; + } } .cli-modifier--destructive { diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index cbd1dbfaa5..40d981dbb6 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -11,14 +11,16 @@ { if (raw.StartsWith("[ns]", StringComparison.Ordinal)) return ("ns", raw[4..]); if (raw.StartsWith("[cmd]", StringComparison.Ordinal)) return ("cmd", raw[5..]); + if (raw.StartsWith("[alias]", StringComparison.Ordinal)) return ("alias", raw[7..]); return (null, raw); } // Inline styles to avoid Tailwind purge — very muted tints, subtle border for definition - const string NsStyle = "background:#f5f3ff;color:#7c3aed;border:1px solid #ddd6fe;"; - const string CmdStyle = "background:#fffbeb;color:#b45309;border:1px solid #fde68a;"; + const string NsStyle = "background:#f5f3ff;color:#7c3aed;border:1px solid #ddd6fe;"; + const string CmdStyle = "background:#fffbeb;color:#b45309;border:1px solid #fde68a;"; + const string AliasStyle = "background:#f0f9ff;color:#0369a1;border:1px solid #bae6fd;"; - static string BadgeStyle(string? badge) => badge == "ns" ? NsStyle : badge == "cmd" ? CmdStyle : ""; + static string BadgeStyle(string? badge) => badge switch { "ns" => NsStyle, "cmd" => CmdStyle, "alias" => AliasStyle, _ => "" }; } @if (isTopLevel && !Model.IsGlobalAssemblyBuild && !Model.IsPrimaryNavEnabled && !Model.SubTree.Index.Hidden) { @@ -56,6 +58,7 @@ @groupLabel @if (groupBadge == "ns") { ns } else if (groupBadge == "cmd") { cmd } + else if (groupBadge == "alias") { alias } } @@ -73,6 +76,7 @@ @folderLabel @if (folderBadge == "ns") { ns } else if (folderBadge == "cmd") { cmd } + else if (folderBadge == "alias") { alias } @if (!allHidden) { @@ -125,6 +129,7 @@ @leafLabel @if (leafBadge == "ns") { ns } else if (leafBadge == "cmd") { cmd } + else if (leafBadge == "alias") { alias } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs new file mode 100644 index 0000000000..d5d3de981b --- /dev/null +++ b/src/Elastic.Markdown/Extensions/CliReference/CliAliasFile.cs @@ -0,0 +1,54 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Myst; +using Markdig.Syntax; + +namespace Elastic.Markdown.Extensions.CliReference; + +public record CliAliasFile : IO.MarkdownFile +{ + private readonly CliShortcutSchema _shortcut; + private readonly string _binaryName; + private readonly string _canonicalRelativePath; + + public CliAliasFile( + IFileInfo sourceFile, + IDirectoryInfo rootPath, + MarkdownParser parser, + BuildContext build, + CliShortcutSchema shortcut, + string binaryName, + string canonicalRelativePath + ) : base(sourceFile, rootPath, parser, build) + { + _shortcut = shortcut; + _binaryName = binaryName; + _canonicalRelativePath = canonicalRelativePath; + Title = shortcut.From; + } + + public override string NavigationTitle => $"[alias]{_shortcut.From}"; + + public override string? RedirectUrl => _canonicalRelativePath; + + protected override Task GetMinimalParseDocumentAsync(Cancel ctx) + { + Title = _shortcut.From; + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null)); + } + + protected override Task GetParseDocumentAsync(Cancel ctx) + { + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null)); + } + + private string BuildMarkdown() => + CliMarkdownGenerator.AliasPage(_shortcut, _binaryName, _canonicalRelativePath); +} diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs index 2289a0a5b3..abe87fae75 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs @@ -17,6 +17,9 @@ public record CliCommandFile : IO.MarkdownFile private readonly string? _binaryName; private readonly string[] _fullPath; private readonly string[]? _reservedMetaCommands; + private readonly IReadOnlyList<(string Segment, List? Options)>? _ancestorNamespaceOptions; + private readonly List? _globalOptions; + private readonly List? _shortcuts; public CliCommandFile( IFileInfo sourceFile, @@ -27,7 +30,10 @@ public CliCommandFile( IFileInfo? supplementalDoc, string[]? fullPath = null, string? binaryName = null, - string[]? reservedMetaCommands = null + string[]? reservedMetaCommands = null, + IReadOnlyList<(string Segment, List? Options)>? ancestorNamespaceOptions = null, + List? globalOptions = null, + List? shortcuts = null ) : base(sourceFile, rootPath, parser, build) { _command = command; @@ -35,6 +41,9 @@ public CliCommandFile( _fullPath = fullPath ?? [command.Name]; _binaryName = binaryName; _reservedMetaCommands = reservedMetaCommands; + _ancestorNamespaceOptions = ancestorNamespaceOptions; + _globalOptions = globalOptions; + _shortcuts = shortcuts; Title = command.Name; } @@ -60,6 +69,7 @@ private string BuildMarkdown() : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); return CliMarkdownGenerator.CommandPage(_command, supplemental, _fullPath, _binaryName, _reservedMetaCommands, - error => Collector.EmitError(_supplementalDoc ?? SourceFile, error)); + error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), + _ancestorNamespaceOptions, _globalOptions, _shortcuts); } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs index f651586256..d4a6168141 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -87,24 +87,50 @@ public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental return sb.ToString(); } + public static string AliasPage(CliShortcutSchema shortcut, string binaryName, string canonicalUrl) + { + var sb = new StringBuilder(); + var canonicalFull = string.Join(" ", shortcut.To); + _ = sb.AppendLine($"# {shortcut.From} alias"); + _ = sb.AppendLine(); + _ = sb.AppendLine($"`{binaryName} {shortcut.From}` is a shortcut for [`{binaryName} {canonicalFull}`]({canonicalUrl})."); + _ = sb.AppendLine(); + return sb.ToString(); + } + public static string NamespacePage( CliNamespaceSchema ns, CliSupplementalDoc? supplemental, string[]? fullPath = null, string? binaryName = null, string[]? reservedMetaCommands = null, - Action? emitError = null) + Action? emitError = null, + List? shortcuts = null) { var sb = new StringBuilder(); var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : ns.Segment; _ = sb.AppendLine($"# {heading} cli namespace"); _ = sb.AppendLine(); + // Usage block: canonical form first, then each alias form + var nsAliases = NamespaceAliases(fullPath, shortcuts); _ = sb.AppendLine("```bash"); _ = sb.AppendLine($"{binaryName ?? heading} {heading} --help"); + foreach (var alias in nsAliases) + _ = sb.AppendLine($"{binaryName ?? alias} {alias} --help"); _ = sb.AppendLine("```"); _ = sb.AppendLine(); + // Alias blurb + if (nsAliases.Count > 0) + { + var depth = fullPath?.Length ?? 1; + var upPrefix = string.Concat(Enumerable.Repeat("../", depth)); + var links = nsAliases.Select(a => $"[`{binaryName ?? a} {a}`]({upPrefix}{a}/index.md)"); + _ = sb.AppendLine($"Also accessible as {string.Join(", ", links)}."); + _ = sb.AppendLine(); + } + var description = supplemental?.Description ?? ns.Summary?.Trim(); if (!string.IsNullOrWhiteSpace(description)) { @@ -115,7 +141,7 @@ public static string NamespacePage( if (ns.DefaultCommand is { Hidden: false } defaultCmd) AppendDefaultCommand(sb, defaultCmd, ns, fullPath, binaryName, reservedMetaCommands); - var visibleCmds = ns.Commands.Where(c => !c.Hidden).ToList(); + var visibleCmds = (ns.Commands ?? []).Where(c => !c.Hidden).ToList(); if (visibleCmds.Count > 0) { _ = sb.AppendLine("## Commands"); @@ -124,24 +150,26 @@ public static string NamespacePage( AppendPageCard(sb, cmd.Name, $"./{CommandPath(cmd.Name)}.md", cmd.Summary); } - if (ns.Namespaces.Count > 0) + var subNamespaces = ns.Namespaces ?? []; + if (subNamespaces.Count > 0) { _ = sb.AppendLine("## Sub-namespaces"); _ = sb.AppendLine(); - foreach (var sub in ns.Namespaces) + foreach (var sub in subNamespaces) AppendPageCard(sb, sub.Segment, $"./{sub.Segment}/index.md", sub.Summary); } - if (ns.Options.Count > 0) + var options = ns.Options ?? []; + if (options.Count > 0) { _ = sb.AppendLine("## Namespace Flags"); _ = sb.AppendLine(); - AppendParameters(sb, ns.Options, supplemental?.OptionOverrides); + AppendParameters(sb, options, supplemental?.OptionOverrides); } if (supplemental is not null && emitError is not null) { - var nsOptionNames = new HashSet(ns.Options.Select(o => o.Name), StringComparer.OrdinalIgnoreCase); + var nsOptionNames = new HashSet(options.Select(o => o.Name), StringComparer.OrdinalIgnoreCase); foreach (var key in supplemental.OptionOverrides.Keys) { if (!nsOptionNames.Contains(key)) @@ -164,23 +192,39 @@ public static string CommandPage( string[]? fullPath = null, string? binaryName = null, string[]? reservedMetaCommands = null, - Action? emitError = null) + Action? emitError = null, + IReadOnlyList<(string Segment, List? Options)>? ancestorNamespaceOptions = null, + List? globalOptions = null, + List? shortcuts = null) { var sb = new StringBuilder(); var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : cmd.Name; _ = sb.AppendLine($"# {heading} cli command"); _ = sb.AppendLine(); - var usage = !string.IsNullOrWhiteSpace(cmd.Usage) + AppendCommandModifiers(sb, cmd); + + var canonicalUsage = !string.IsNullOrWhiteSpace(cmd.Usage) ? CleanUsage(cmd.Usage, reservedMetaCommands) : GenerateUsage(cmd, fullPath, binaryName); + var allUsages = new[] { canonicalUsage } + .Concat(AliasUsages(fullPath, binaryName, canonicalUsage, shortcuts)) + .ToList(); + var shortestUsage = allUsages.MinBy(u => u.Length) ?? canonicalUsage; + var otherUsages = allUsages.Where(u => u != shortestUsage).ToList(); + _ = sb.AppendLine("```bash"); - _ = sb.AppendLine(FormatUsage(usage)); + _ = sb.AppendLine(FormatUsage(shortestUsage)); _ = sb.AppendLine("```"); _ = sb.AppendLine(); - AppendCommandModifiers(sb, cmd); + if (otherUsages.Count > 0) + { + var others = string.Join(", ", otherUsages.Select(u => $"`{CommandName(u)}`")); + _ = sb.AppendLine($"Also available as: {others}."); + _ = sb.AppendLine(); + } var description = supplemental?.Description ?? (cmd.Summary is not null ? CleanSummary(cmd.Summary).description.Trim() : null); if (!string.IsNullOrWhiteSpace(description)) @@ -236,6 +280,24 @@ public static string CommandPage( } } + foreach (var (segment, nsOptions) in ancestorNamespaceOptions ?? []) + { + var visibleNsOptions = (nsOptions ?? []).Where(p => !p.Hidden && p.Name != "_").ToList(); + if (visibleNsOptions.Count == 0) + continue; + _ = sb.AppendLine($"## {segment} Options"); + _ = sb.AppendLine(); + AppendParameters(sb, visibleNsOptions, null); + } + + var visibleGlobalOptions = (globalOptions ?? []).Where(p => !p.Hidden && p.Name != "_").ToList(); + if (visibleGlobalOptions.Count > 0) + { + _ = sb.AppendLine("## Global Options"); + _ = sb.AppendLine(); + AppendParameters(sb, visibleGlobalOptions, null); + } + if (cmd.Examples is { Length: > 0 }) { _ = sb.AppendLine("## Examples"); @@ -339,7 +401,7 @@ private static void AppendDefaultCommand(StringBuilder sb, CliDefaultSchema defa // If Kind matches a named command, emit an alias note instead of duplicating parameters if (!string.IsNullOrWhiteSpace(defaultCmd.Kind) && - ns.Commands.Any(c => c.Name.Equals(defaultCmd.Kind, StringComparison.OrdinalIgnoreCase))) + (ns.Commands ?? []).Any(c => c.Name.Equals(defaultCmd.Kind, StringComparison.OrdinalIgnoreCase))) { _ = sb.AppendLine($"> Running without a subcommand is an alias for [{defaultCmd.Kind}](./{CommandPath(defaultCmd.Kind)}.md)."); _ = sb.AppendLine(); @@ -432,7 +494,10 @@ private static void AppendParameters( IEnumerable parameters, Dictionary? overrides) { - foreach (var p in parameters.Where(p => p.Name != "_" && !p.Hidden)) + var filtered = parameters.Where(p => p.Name != "_" && !p.Hidden).ToList(); + var positionals = filtered.Where(p => p.Role == "positional"); + var options = filtered.Where(p => p.Role != "positional").OrderByDescending(p => p.Required); + foreach (var p in positionals.Concat(options)) { var isBool = IsBoolFlag(p.Type); var flagName = FormatFlagName(p); @@ -609,10 +674,10 @@ private static (string description, string values, string defaultValue) CleanSum { var defIdx = normalized.IndexOf(defaultSep, StringComparison.OrdinalIgnoreCase); if (defIdx < 0) - return (normalized, string.Empty, string.Empty); + return (EscapeSubstitutions(normalized), string.Empty, string.Empty); return ( - normalized[..defIdx].Trim(), + EscapeSubstitutions(normalized[..defIdx].Trim()), string.Empty, normalized[(defIdx + defaultSep.Length)..].Trim().TrimEnd('.') ); @@ -623,11 +688,21 @@ private static (string description, string values, string defaultValue) CleanSum var defInRemainder = remainder.IndexOf(defaultSep, StringComparison.OrdinalIgnoreCase); if (defInRemainder < 0) - return (description, remainder.Trim().TrimEnd('.'), string.Empty); + return (EscapeSubstitutions(description), remainder.Trim().TrimEnd('.'), string.Empty); var values = remainder[..defInRemainder].Trim().TrimEnd('.'); var defaultValue = remainder[(defInRemainder + defaultSep.Length)..].Trim().TrimEnd('.'); - return (description, values, defaultValue); + return (EscapeSubstitutions(description), values, defaultValue); + } + + // Escapes {{key}} patterns so they are not interpreted as docs-builder substitution keys. + // \{ is a CommonMark escape that renders as {, so \{{key}} renders as {{key}} in output + // but the substitution parser (which requires two opening braces) won't fire. + private static string EscapeSubstitutions(string? text) + { + if (string.IsNullOrEmpty(text) || !text.Contains("{{")) + return text ?? string.Empty; + return text.Replace("{{", "\\{{", StringComparison.Ordinal); } private static bool IsBoolFlag(string type) => @@ -727,6 +802,71 @@ private static string FormatUsage(string usage) return result.ToString(); } + // Returns the `from` values of shortcuts whose `to` path exactly matches the namespace's full path. + private static List NamespaceAliases(string[]? fullPath, List? shortcuts) + { + if (shortcuts is not { Count: > 0 } || fullPath is not { Length: > 0 }) + return []; + + var heading = string.Join(" ", fullPath); + return shortcuts + .Where(s => string.Join(" ", s.To).Equals(heading, StringComparison.OrdinalIgnoreCase)) + .Select(s => s.From) + .ToList(); + } + + // Returns one alternate usage string per shortcut whose `to` path is a prefix of the command's full path. + // E.g. shortcut {from:"es", to:["stack","es"]} + command path ["stack","es","bulk"] → "elastic es bulk [options]" + private static string CommandName(string usage) + { + var flagIdx = usage.IndexOf(" --", StringComparison.Ordinal); + var argIdx = usage.IndexOf(" <", StringComparison.Ordinal); + var optIdx = usage.IndexOf(" [", StringComparison.Ordinal); + var end = new[] { flagIdx, argIdx, optIdx }.Where(i => i >= 0).DefaultIfEmpty(usage.Length).Min(); + return usage[..end]; + } + + private static IEnumerable AliasUsages( + string[]? fullPath, + string? binaryName, + string canonicalUsage, + List? shortcuts) + { + if (shortcuts is not { Count: > 0 } || fullPath is not { Length: > 1 }) + yield break; + + foreach (var shortcut in shortcuts) + { + var to = shortcut.To; + if (to.Length == 0 || to.Length >= fullPath.Length) + continue; + // Check that to[] matches the first to.Length segments of fullPath + var matches = true; + for (var i = 0; i < to.Length; i++) + { + if (!string.Equals(fullPath[i], to[i], StringComparison.OrdinalIgnoreCase)) + { + matches = false; + break; + } + } + if (!matches) + continue; + + // Build alias usage by replacing the canonical prefix in the original usage line + var canonicalPrefix = string.Join(" ", binaryName is not null + ? [binaryName, .. to] + : to); + if (!canonicalUsage.StartsWith(canonicalPrefix, StringComparison.OrdinalIgnoreCase)) + continue; // can't safely rewrite; skip this alias variant + var suffix = canonicalUsage[canonicalPrefix.Length..]; + var aliasBase = binaryName is not null + ? $"{binaryName} {shortcut.From}" + : shortcut.From; + yield return aliasBase + suffix; + } + } + [GeneratedRegex(@"\s{2,}")] private static partial Regex WhitespaceRegex(); } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs index a8b2ff0197..94409b0a25 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs @@ -17,6 +17,7 @@ public record CliNamespaceFile : IO.MarkdownFile private readonly string? _binaryName; private readonly string[] _fullPath; private readonly string[]? _reservedMetaCommands; + private readonly List? _shortcuts; public CliNamespaceFile( IFileInfo sourceFile, @@ -27,7 +28,8 @@ public CliNamespaceFile( IFileInfo? supplementalDoc, string[]? fullPath = null, string? binaryName = null, - string[]? reservedMetaCommands = null + string[]? reservedMetaCommands = null, + List? shortcuts = null ) : base(sourceFile, rootPath, parser, build) { _namespace = @namespace; @@ -35,6 +37,7 @@ public CliNamespaceFile( _fullPath = fullPath ?? [@namespace.Segment]; _binaryName = binaryName; _reservedMetaCommands = reservedMetaCommands; + _shortcuts = shortcuts; Title = @namespace.Segment; } @@ -60,6 +63,6 @@ private string BuildMarkdown() : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); return CliMarkdownGenerator.NamespacePage(_namespace, supplemental, _fullPath, _binaryName, _reservedMetaCommands, - error => Collector.EmitError(_supplementalDoc ?? SourceFile, error)); + error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), _shortcuts); } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs index 70fc6325b2..3bc9e2e010 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs @@ -15,12 +15,16 @@ namespace Elastic.Markdown.Extensions.CliReference; internal sealed record CliEntityInfo( CliSchema Schema, - object Entity, // CliSchema | CliNamespaceSchema | CliCommandSchema + object Entity, // CliSchema | CliNamespaceSchema | CliCommandSchema | CliShortcutSchema IFileInfo? SupplementalDoc, /// The clean synthetic file (no cmd- prefix) — used as the MarkdownFile source for correct URL generation. IFileInfo? CleanSyntheticFile = null, /// Full path segments used to build the page heading (e.g. ["assembler", "bloom-filter"]). - string[]? FullPath = null + string[]? FullPath = null, + /// Ancestor namespace options ordered from closest to furthest (direct parent first). + IReadOnlyList<(string Segment, List? Options)>? AncestorNamespaceOptions = null, + /// Relative path from this file to the alias target — set for CliShortcutSchema entities only. + string? AliasCanonicalRelativePath = null ); public class CliReferenceDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension @@ -110,8 +114,9 @@ private void EnsureSyntheticFilesBuilt() info.Entity switch { CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc), - CliNamespaceSchema ns => new CliNamespaceFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, ns, info.SupplementalDoc, info.FullPath ?? [ns.Segment], info.Schema.Name, info.Schema.ReservedMetaCommands), - CliCommandSchema cmd => new CliCommandFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, cmd, info.SupplementalDoc, info.FullPath ?? [cmd.Name], info.Schema.Name, info.Schema.ReservedMetaCommands), + CliNamespaceSchema ns => new CliNamespaceFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, ns, info.SupplementalDoc, info.FullPath ?? [ns.Segment], info.Schema.Name, info.Schema.ReservedMetaCommands, info.Schema.Shortcuts), + CliCommandSchema cmd => new CliCommandFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, cmd, info.SupplementalDoc, info.FullPath ?? [cmd.Name], info.Schema.Name, info.Schema.ReservedMetaCommands, info.AncestorNamespaceOptions, info.Schema.GlobalOptions, info.Schema.Shortcuts), + CliShortcutSchema shortcut => new CliAliasFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, shortcut, info.Schema.Name, info.AliasCanonicalRelativePath ?? "../"), _ => null }; @@ -203,6 +208,23 @@ private List BuildSyntheticFiles() // Namespaces (recursive) CollectNamespaceFiles(Build.DocumentationSourceDirectory.FullName, virtualRoot, supplementalDirPath, schema.Namespaces, [], matched, fileInfos, schema); + // Shortcut alias pages + foreach (var shortcut in schema.Shortcuts ?? []) + { + var aliasPath = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [shortcut.From], isNamespace: true); + if (_syntheticFiles!.ContainsKey(aliasPath)) + { + Build.Collector.EmitError(schemaFileInfo, + $"CLI shortcut '{shortcut.From}' conflicts with an existing path; skipping alias."); + continue; + } + var aliasFileInfo = Build.ReadFileSystem.FileInfo.New(aliasPath); + var canonicalUrl = "/" + virtualRoot + "/" + string.Join("/", shortcut.To) + "/"; + var aliasInfo = new CliEntityInfo(schema, shortcut, null, aliasFileInfo, AliasCanonicalRelativePath: canonicalUrl); + _syntheticFiles[aliasPath] = aliasInfo; + fileInfos.Add(aliasFileInfo); + } + // Validate supplemental files if (supplementalDirPath is not null && Build.ReadFileSystem.Directory.Exists(supplementalDirPath)) ValidateSupplementalFiles(supplementalDirPath, matched, cliRef.Context); @@ -219,7 +241,8 @@ private void CollectNamespaceFiles( string[] nsPath, HashSet matched, List fileInfos, - CliSchema schema) + CliSchema schema, + IReadOnlyList<(string Segment, List? Options)>? ancestorOptions = null) { foreach (var ns in namespaces) { @@ -234,20 +257,25 @@ private void CollectNamespaceFiles( _supplementalFiles![nsSupplemental.FullName] = nsInfo; fileInfos.Add(nsFileInfo); - foreach (var cmd in ns.Commands) + // Build ordered ancestor list for commands: direct parent first, then grandparents + var cmdAncestors = new List<(string, List?)> { (ns.Segment, ns.Options) }; + if (ancestorOptions is { Count: > 0 }) + cmdAncestors.AddRange(ancestorOptions); + + foreach (var cmd in ns.Commands ?? []) { var cmdSegments = fullNsPath.Append(cmd.Name).ToArray(); var cmdPath = SyntheticPath(docSourceDir, virtualRoot, cmdSegments, isNamespace: false); var cmdFileInfo = Build.ReadFileSystem.FileInfo.New(cmdPath); var cmdSupplemental = FindSupplemental(supplementalDirPath, cmdSegments, isNamespace: false, matched); - var cmdInfo = new CliEntityInfo(schema, cmd, cmdSupplemental, cmdFileInfo, FullPath: cmdSegments); + var cmdInfo = new CliEntityInfo(schema, cmd, cmdSupplemental, cmdFileInfo, FullPath: cmdSegments, AncestorNamespaceOptions: cmdAncestors); _syntheticFiles[cmdPath] = cmdInfo; if (cmdSupplemental != null) _supplementalFiles![cmdSupplemental.FullName] = cmdInfo; fileInfos.Add(cmdFileInfo); } - CollectNamespaceFiles(docSourceDir, virtualRoot, supplementalDirPath, ns.Namespaces, fullNsPath, matched, fileInfos, schema); + CollectNamespaceFiles(docSourceDir, virtualRoot, supplementalDirPath, ns.Namespaces ?? [], fullNsPath, matched, fileInfos, schema, cmdAncestors); } } diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 442f149616..575eb92df3 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -192,7 +192,8 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc GitRepository = gitHubRepo, GitHubDocsUrl = gitHubDocsUrl, GitHubRef = DocumentationSet.Context.Git.GitHubRef, - Branding = DocumentationSet.Configuration.Branding + Branding = DocumentationSet.Configuration.Branding, + RedirectUrl = markdown.RedirectUrl }); return new RenderResult diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index 3d2b6bad20..f6d035eebb 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -82,6 +82,8 @@ public virtual string NavigationTitle private set => field = value.StripMarkdown(); } + public virtual string? RedirectUrl => null; + //indexed by slug private readonly Dictionary _pageTableOfContent = [with(StringComparer.OrdinalIgnoreCase)]; diff --git a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs index 5793cb124f..51edce1356 100644 --- a/src/Elastic.Markdown/MarkdownLayoutViewModel.cs +++ b/src/Elastic.Markdown/MarkdownLayoutViewModel.cs @@ -30,6 +30,8 @@ public record MarkdownLayoutViewModel : GlobalLayoutViewModel public required string? CurrentVersion { get; init; } public required string? AllVersionsUrl { get; init; } + + public string? RedirectUrl { get; init; } } public record PageTocItem diff --git a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs index 8ab3409062..928ac14583 100644 --- a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersBlock.cs @@ -10,21 +10,35 @@ public class CliModifiersBlock(DirectiveBlockParser parser, ParserContext contex public override string Directive => "cli-modifiers"; public bool Destructive { get; private set; } + public string? DestructiveDescription { get; private set; } public bool RequiresConfirmation { get; private set; } + public string? RequiresConfirmationDescription { get; private set; } public bool RequiresAuth { get; private set; } + public string? RequiresAuthDescription { get; private set; } public bool Idempotent { get; private set; } + public string? IdempotentDescription { get; private set; } public string? Scope { get; private set; } + public string? ScopeDescription { get; private set; } public bool Streaming { get; private set; } + public string? StreamingDescription { get; private set; } public bool LongRunning { get; private set; } + public string? LongRunningDescription { get; private set; } public override void FinalizeAndValidate(ParserContext context) { Destructive = PropBool("destructive"); + DestructiveDescription = Prop("destructive-description"); RequiresConfirmation = PropBool("requires-confirmation"); + RequiresConfirmationDescription = Prop("requires-confirmation-description"); RequiresAuth = PropBool("requires-auth"); + RequiresAuthDescription = Prop("requires-auth-description"); Idempotent = PropBool("idempotent"); + IdempotentDescription = Prop("idempotent-description"); Scope = Prop("scope"); + ScopeDescription = Prop("scope-description"); Streaming = PropBool("streaming"); + StreamingDescription = Prop("streaming-description"); LongRunning = PropBool("long-running"); + LongRunningDescription = Prop("long-running-description"); } } diff --git a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersView.cshtml b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersView.cshtml index dc7183e838..9b6f9f795c 100644 --- a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersView.cshtml @@ -1,8 +1,13 @@ @inherits RazorSlice +@{ + string Tip(string? schemaDescription, string fallback) => + !string.IsNullOrWhiteSpace(schemaDescription) ? schemaDescription : fallback; +}
@if (Model.Destructive) { - + @@ -11,7 +16,8 @@ } @if (Model.RequiresConfirmation) { - + @@ -20,7 +26,8 @@ } @if (Model.RequiresAuth) { - + @@ -29,7 +36,8 @@ } @if (Model.Idempotent) { - + @@ -38,7 +46,8 @@ } @if (!string.IsNullOrWhiteSpace(Model.Scope)) { - + + @@ -57,7 +67,8 @@ } @if (Model.LongRunning) { - + diff --git a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersViewModel.cs b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersViewModel.cs index 6987477b71..67618df6d5 100644 --- a/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/CliModifiers/CliModifiersViewModel.cs @@ -7,10 +7,17 @@ namespace Elastic.Markdown.Myst.Directives.CliModifiers; public class CliModifiersViewModel : DirectiveViewModel { public bool Destructive { get; init; } + public string? DestructiveDescription { get; init; } public bool RequiresConfirmation { get; init; } + public string? RequiresConfirmationDescription { get; init; } public bool RequiresAuth { get; init; } + public string? RequiresAuthDescription { get; init; } public bool Idempotent { get; init; } + public string? IdempotentDescription { get; init; } public string? Scope { get; init; } + public string? ScopeDescription { get; init; } public bool Streaming { get; init; } + public string? StreamingDescription { get; init; } public bool LongRunning { get; init; } + public string? LongRunningDescription { get; init; } } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index b0c7891252..c4a5342ea9 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -268,12 +268,19 @@ private static void WriteCliModifiers(HtmlRenderer renderer, CliModifiersBlock b { DirectiveBlock = block, Destructive = block.Destructive, + DestructiveDescription = block.DestructiveDescription, RequiresConfirmation = block.RequiresConfirmation, + RequiresConfirmationDescription = block.RequiresConfirmationDescription, RequiresAuth = block.RequiresAuth, + RequiresAuthDescription = block.RequiresAuthDescription, Idempotent = block.Idempotent, + IdempotentDescription = block.IdempotentDescription, Scope = block.Scope, + ScopeDescription = block.ScopeDescription, Streaming = block.Streaming, + StreamingDescription = block.StreamingDescription, LongRunning = block.LongRunning, + LongRunningDescription = block.LongRunningDescription, }); RenderRazorSlice(slice, renderer); } diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index e8325b5e91..3793b4a6ee 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -63,6 +63,7 @@ GitHubDocsUrl = Model.GitHubDocsUrl, GitHubRef = Model.GitHubRef, Branding = Model.Branding, + RedirectUrl = Model.RedirectUrl, }; protected override Task ExecuteSectionAsync(string name) { diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index 44ecf22014..bf5e729871 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -82,6 +82,9 @@ public class IndexViewModel /// Pre-computed site root path for HTMX. When set (codex builds), used as data-root-path. public string? SiteRootPath { get; set; } + + /// When set, the page performs a client-side redirect to this URL (used for alias pages). + public string? RedirectUrl { get; init; } } public class VersionDropDownItemViewModel diff --git a/src/Elastic.Markdown/_Layout.cshtml b/src/Elastic.Markdown/_Layout.cshtml index 8c9b585a2d..098ddc337a 100644 --- a/src/Elastic.Markdown/_Layout.cshtml +++ b/src/Elastic.Markdown/_Layout.cshtml @@ -13,6 +13,11 @@ if (name == GlobalSections.Head) { + if (Model.RedirectUrl is { } redirectUrl && Model.Layout is not MarkdownPageLayout.Archive) + { + var encodedUrl = System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(redirectUrl); + + } //this ensures we forward head sections declared in this project into to GlobalLayout view's section await RenderSectionAsync(GlobalSections.Head); }