From 7d8e36e9c3bfccdeb75db218ae4a91b76380db2b Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Wed, 10 Jun 2026 09:14:09 +0200 Subject: [PATCH 1/5] feat(cli-reference): support title overrides Allow cli TOC entries to override the generated root page title and navigation label without changing schema command names. Co-authored-by: GPT-5.5 Co-authored-by: Cursor --- docs/cli/cli-reference-how-to.md | 12 +++++++ .../Toc/CliReference/CliReferenceRef.cs | 2 ++ .../Toc/DocumentationSetFile.cs | 2 +- .../Toc/TableOfContentsYamlConverters.cs | 4 ++- .../CliReference/CliMarkdownGenerator.cs | 4 +-- .../CliReferenceDocsBuilderExtension.cs | 10 ++++-- .../Extensions/CliReference/CliRootFile.cs | 16 +++++++--- .../PhysicalDocsetTests.cs | 19 ++++++++++++ .../CliReference/CliMarkdownGeneratorTests.cs | 31 +++++++++++++++++++ 9 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs diff --git a/docs/cli/cli-reference-how-to.md b/docs/cli/cli-reference-how-to.md index adfa46bbb9..df0cb0aa5e 100644 --- a/docs/cli/cli-reference-how-to.md +++ b/docs/cli/cli-reference-how-to.md @@ -62,6 +62,16 @@ toc: folder: cli-reference ``` +Use `title:` to customize the generated CLI root page title, and `navigation_title:` to customize the sidebar and breadcrumb label without changing generated command examples: + +```yaml +toc: + - cli: cli-schema.json + folder: cli-reference + title: Elastic CLI reference + navigation_title: CLI reference +``` + Use `children:` to prepend hand-written pages — installation guides, conceptual overviews, or quick-start tutorials — before the auto-generated reference. All schema-generated pages follow the listed children: ```yaml @@ -101,4 +111,6 @@ Your CLI reference section is live. As your CLI evolves, regenerate the schema a |---|---| | `cli: ` | Path to the schema JSON, relative to `docset.yml` | | `folder: ` | Supplemental docs folder; also sets the URL prefix | +| `title: ` | Optional generated CLI root page title | +| `navigation_title: <title>` | Optional generated CLI root navigation label | | `children:` | Regular toc items prepended before generated pages | diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs index f67f023a2c..e41c661ec6 100644 --- a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs @@ -14,6 +14,8 @@ namespace Elastic.Documentation.Configuration.Toc.CliReference; public record CliReferenceRef( string SchemaPath, string? SupplementalFolder, + string? Title, + string? NavigationTitle, string PathRelativeToDocumentationSet, string PathRelativeToContainer, string Context, diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 5b94ab7f71..f82f871504 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -548,7 +548,7 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol ? ResolveTableOfContents(collector, cliRef.Children, baseDirectory, fileSystem, fullVirtualRoot, containerPath, context) : []; - return new CliReferenceRef(schemaFullPath, cliRef.SupplementalFolder, fullVirtualRoot, pathRelativeToContainer, context, resolvedChildren); + return new CliReferenceRef(schemaFullPath, cliRef.SupplementalFolder, cliRef.Title, cliRef.NavigationTitle, fullVirtualRoot, pathRelativeToContainer, context, resolvedChildren); } /// <summary> diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index 664c18085b..baee79ca4d 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -117,7 +117,9 @@ public class TocItemYamlConverter : IYamlTypeConverter if (dictionary.TryGetValue("cli", out var cliSchemaPath) && cliSchemaPath is string cliSchema) { var supplementalFolder = dictionary.TryGetValue("folder", out var f) && f is string fStr ? fStr : null; - return new CliReferenceRef(cliSchema, supplementalFolder, cliSchema, cliSchema, placeholderContext, children); + var title = dictionary.TryGetValue("title", out var t) && t is string titleStr ? titleStr : null; + var navigationTitle = dictionary.TryGetValue("navigation_title", out var nt) && nt is string navigationTitleStr ? navigationTitleStr : null; + return new CliReferenceRef(cliSchema, supplementalFolder, title, navigationTitle, cliSchema, cliSchema, placeholderContext, children); } // Check for folder+file combination (e.g., folder: getting-started, file: getting-started.md) diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs index d4a6168141..761e4e8aaa 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -10,10 +10,10 @@ namespace Elastic.Markdown.Extensions.CliReference; internal static partial class CliMarkdownGenerator { - public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental) + public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental, string? title = null) { var sb = new StringBuilder(); - _ = sb.AppendLine($"# {schema.Name}"); + _ = sb.AppendLine($"# {title ?? schema.Name}"); _ = sb.AppendLine(); var description = supplemental?.Description ?? schema.Description?.Trim(); diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs index 3bc9e2e010..9204df6927 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs @@ -24,7 +24,11 @@ internal sealed record CliEntityInfo( /// <summary>Ancestor namespace options ordered from closest to furthest (direct parent first).</summary> IReadOnlyList<(string Segment, List<CliParamSchema>? Options)>? AncestorNamespaceOptions = null, /// <summary>Relative path from this file to the alias target — set for CliShortcutSchema entities only.</summary> - string? AliasCanonicalRelativePath = null + string? AliasCanonicalRelativePath = null, + /// <summary>Display title for the generated CLI root page.</summary> + string? Title = null, + /// <summary>Navigation title for the generated CLI root page.</summary> + string? NavigationTitle = null ); public class CliReferenceDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension @@ -113,7 +117,7 @@ private void EnsureSyntheticFilesBuilt() private MarkdownFile? CreateCliFileFromInfo(IFileInfo sourceFile, MarkdownParser markdownParser, CliEntityInfo info) => info.Entity switch { - CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc), + CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc, info.Title, info.NavigationTitle), 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 ?? "../"), @@ -186,7 +190,7 @@ private List<IFileInfo> BuildSyntheticFiles() var rootSupplemental = FindSupplemental(supplementalDirPath, [], isNamespace: true, matched); var rootSyntheticPath = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [], isNamespace: true); var rootFileInfo = Build.ReadFileSystem.FileInfo.New(rootSyntheticPath); - var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo); + var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo, Title: cliRef.Title, NavigationTitle: cliRef.NavigationTitle); _syntheticFiles![rootSyntheticPath] = rootInfo; if (rootSupplemental != null) _supplementalFiles![rootSupplemental.FullName] = rootInfo; diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs index 991622158d..1eefadd2b7 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs @@ -14,6 +14,8 @@ public record CliRootFile : IO.MarkdownFile { private readonly CliSchema _schema; private readonly IFileInfo? _supplementalDoc; + private readonly string _title; + private readonly string _navigationTitle; public CliRootFile( IFileInfo sourceFile, @@ -21,19 +23,23 @@ public CliRootFile( MarkdownParser parser, BuildContext build, CliSchema schema, - IFileInfo? supplementalDoc + IFileInfo? supplementalDoc, + string? title = null, + string? navigationTitle = null ) : base(sourceFile, rootPath, parser, build) { _schema = schema; _supplementalDoc = supplementalDoc; - Title = schema.Name; + _title = string.IsNullOrWhiteSpace(title) ? schema.Name : title; + _navigationTitle = string.IsNullOrWhiteSpace(navigationTitle) ? $"{schema.Name} CLI" : navigationTitle; + Title = _title; } - public override string NavigationTitle => $"{_schema.Name} CLI"; + public override string NavigationTitle => _navigationTitle; protected override Task<MarkdownDocument> GetMinimalParseDocumentAsync(Cancel ctx) { - Title = _schema.Name; + Title = _title; var markdown = BuildMarkdown(); return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null)); } @@ -50,6 +56,6 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - return CliMarkdownGenerator.RootPage(_schema, supplemental); + return CliMarkdownGenerator.RootPage(_schema, supplemental, _title); } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs index ffb96d96ef..9a7d41b801 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/PhysicalDocsetTests.cs @@ -10,6 +10,25 @@ namespace Elastic.Documentation.Configuration.Tests; public class PhysicalDocsetTests { + [Fact] + public void CliReferenceRefReadsTitleOverrides() + { + const string yaml = """ + project: test + toc: + - cli: cli/schema.json + folder: cli + title: Elastic CLI reference + navigation_title: CLI reference + """; + + var docSet = ConfigurationFileProvider.Deserializer.Deserialize<DocumentationSetFile>(yaml); + var cliRef = docSet.TableOfContents.OfType<CliReferenceRef>().Single(); + + cliRef.Title.Should().Be("Elastic CLI reference"); + cliRef.NavigationTitle.Should().Be("CLI reference"); + } + [Fact] public void PhysicalDocsetFileCanBeDeserialized() { diff --git a/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs b/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs new file mode 100644 index 0000000000..70eeecf8d5 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs @@ -0,0 +1,31 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Extensions.CliReference; + +namespace Elastic.Markdown.Tests.CliReference; + +public class CliMarkdownGeneratorTests +{ + [Fact] + public void RootPage_UsesTitleOverrideForHeading() + { + var schema = new CliSchema( + SchemaVersion: 1, + Name: "elastic", + Description: "Interact with Elastic from the command line.", + GlobalOptions: [], + RootDefault: null, + Commands: [], + Namespaces: [] + ); + + var markdown = CliMarkdownGenerator.RootPage(schema, null, "Elastic CLI reference"); + + markdown.Should().StartWith("# Elastic CLI reference"); + markdown.Should().Contain("Interact with Elastic from the command line."); + } +} From b598a20389edecd0d4688b5f6035d42444bee184 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti <fabri.ferribenedetti@elastic.co> Date: Wed, 10 Jun 2026 15:43:33 +0200 Subject: [PATCH 2/5] fix(cli-reference): ignore blank title overrides Co-authored-by: GPT-5.5 <gpt-5.5@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> --- .../CliReference/CliMarkdownGenerator.cs | 3 ++- .../CliReference/CliMarkdownGeneratorTests.cs | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs index 0de971d593..0b718330e8 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -14,7 +14,8 @@ public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental { var sb = new StringBuilder(); AppendFrontMatter(sb, supplemental); - _ = sb.AppendLine($"# {title ?? schema.Name}"); + var pageTitle = string.IsNullOrWhiteSpace(title) ? schema.Name : title.Trim(); + _ = sb.AppendLine($"# {pageTitle}"); _ = sb.AppendLine(); var description = supplemental?.Description ?? schema.Description?.Trim(); diff --git a/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs b/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs index 70eeecf8d5..796a3c377e 100644 --- a/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs +++ b/tests/Elastic.Markdown.Tests/CliReference/CliMarkdownGeneratorTests.cs @@ -28,4 +28,24 @@ public void RootPage_UsesTitleOverrideForHeading() markdown.Should().StartWith("# Elastic CLI reference"); markdown.Should().Contain("Interact with Elastic from the command line."); } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void RootPage_FallsBackToSchemaNameForBlankTitleOverride(string title) + { + var schema = new CliSchema( + SchemaVersion: 1, + Name: "elastic", + Description: "Interact with Elastic from the command line.", + GlobalOptions: [], + RootDefault: null, + Commands: [], + Namespaces: [] + ); + + var markdown = CliMarkdownGenerator.RootPage(schema, null, title); + + markdown.Should().StartWith("# elastic"); + } } From 7566ded4e910696628fa062e0a1475e5735338f5 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti <fabri.ferribenedetti@elastic.co> Date: Thu, 11 Jun 2026 13:46:56 +0200 Subject: [PATCH 3/5] fix(cli-reference): use segment path instead of segment/index.md for namespace page cards Namespace and sub-namespace page card links were generated as ./segment/index.md, which the docs-builder resolved to /cli/.../segment/index (a 404) instead of /cli/.../segment. Drop the /index.md suffix so links resolve to the correct namespace URL. Affects RootPage (top-level namespaces), NamespacePage (sub-namespaces and alias blurb links). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../Extensions/CliReference/CliMarkdownGenerator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs index 0b718330e8..d260b30f8b 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -46,7 +46,7 @@ public static string RootPage(CliSchema schema, CliSupplementalDoc? supplemental _ = sb.AppendLine("## Namespaces"); _ = sb.AppendLine(); foreach (var ns in schema.Namespaces) - AppendPageCard(sb, ns.Segment, $"./{ns.Segment}/index.md", ns.Summary); + AppendPageCard(sb, ns.Segment, $"./{ns.Segment}", ns.Summary); } if (schema.Environment?.Variables is { Count: > 0 } envVars) @@ -129,7 +129,7 @@ public static string NamespacePage( { var depth = fullPath?.Length ?? 1; var upPrefix = string.Concat(Enumerable.Repeat("../", depth)); - var links = nsAliases.Select(a => $"[`{binaryName ?? a} {a}`]({upPrefix}{a}/index.md)"); + var links = nsAliases.Select(a => $"[`{binaryName ?? a} {a}`]({upPrefix}{a})"); _ = sb.AppendLine($"Also accessible as {string.Join(", ", links)}."); _ = sb.AppendLine(); } @@ -159,7 +159,7 @@ public static string NamespacePage( _ = sb.AppendLine("## Sub-namespaces"); _ = sb.AppendLine(); foreach (var sub in subNamespaces) - AppendPageCard(sb, sub.Segment, $"./{sub.Segment}/index.md", sub.Summary); + AppendPageCard(sb, sub.Segment, $"./{sub.Segment}", sub.Summary); } var options = ns.Options ?? []; From 66ba93ee69457b9f1d8f54252b9ae84fe90c722a Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti <fabri.ferribenedetti@elastic.co> Date: Thu, 11 Jun 2026 13:59:24 +0200 Subject: [PATCH 4/5] fix(cli-reference): trim title and navigation_title override values Prevents padded whitespace in non-empty overrides from leaking into nav/file metadata. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs index 1eefadd2b7..739409b609 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs @@ -30,8 +30,8 @@ public CliRootFile( { _schema = schema; _supplementalDoc = supplementalDoc; - _title = string.IsNullOrWhiteSpace(title) ? schema.Name : title; - _navigationTitle = string.IsNullOrWhiteSpace(navigationTitle) ? $"{schema.Name} CLI" : navigationTitle; + _title = string.IsNullOrWhiteSpace(title) ? schema.Name : title.Trim(); + _navigationTitle = string.IsNullOrWhiteSpace(navigationTitle) ? $"{schema.Name} CLI" : navigationTitle.Trim(); Title = _title; } From 1945fcf3e23d2147e626f1620070f73603fab088 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti <fabri.ferribenedetti@elastic.co> Date: Thu, 11 Jun 2026 15:15:13 +0200 Subject: [PATCH 5/5] fix(page-card): apply UrlPathPrefix to resolved URLs Page-card links were generating site-root-relative hrefs (e.g. /cli/stack/es) without the deployment path prefix, causing 404s in preview environments where the site is hosted under a subpath (e.g. /elastic/cli/pull/406/cli/stack/es). Apply the same UrlPathPrefix logic used by DiagnosticLinkInlineParser. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../Myst/Directives/PageCard/PageCardBlock.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs index 4cdc9db58f..810341d6c2 100644 --- a/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs @@ -55,6 +55,11 @@ public override void FinalizeAndValidate(ParserContext context) : relativeToSource; ResolvedUrl = "/" + withoutExtension.Replace('\\', '/'); + + // Apply URL path prefix so links work in preview/sub-path deployments (same logic as DiagnosticLinkInlineParser) + var urlPathPrefix = context.Build.UrlPathPrefix ?? string.Empty; + if (!string.IsNullOrWhiteSpace(urlPathPrefix) && !ResolvedUrl.StartsWith(urlPathPrefix, StringComparison.OrdinalIgnoreCase)) + ResolvedUrl = $"{urlPathPrefix.TrimEnd('/')}{ResolvedUrl}"; } [GeneratedRegex(@"^\[([^\]]+)\]\(([^)]+)\)$")]