From a122830399a1ca4a12a9bc69cc83cac1df634ea4 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 17 Jul 2026 19:17:39 +0200 Subject: [PATCH] feat(cli): support applies_to on cli: toc entries for generated pages Generated CLI pages (CliCommandFile, CliNamespaceFile, CliRootFile) have no YAML front matter, so they never rendered applies_to badges. Two mechanisms now fix this: 1. Docset-level fallback: the cli: entry in docset.yml can declare applies_to (stack/serverless/deployment). The value is parsed in TocItemYamlConverter and stored on CliReferenceRef.AppliesTo, then threaded through CliReferenceDocsBuilderExtension into every generated file via a new MarkdownFile.FallbackAppliesTo property that ProcessYamlFrontMatter uses when the page has no front matter. 2. Per-command override: supplemental cmd-*.md / ns-*.md files can include their own applies_to front matter. BuildMarkdown() in each generated file type now prepends the supplemental front matter so ProcessYamlFrontMatter sees it and naturally overrides the fallback. Co-Authored-By: Claude Sonnet 4.6 --- .../Toc/CliReference/CliReferenceRef.cs | 8 +++- .../Toc/TableOfContentsYamlConverters.cs | 44 +++++++++++++++++-- .../Extensions/CliReference/CliCommandFile.cs | 9 +++- .../CliReference/CliNamespaceFile.cs | 9 +++- .../CliReferenceDocsBuilderExtension.cs | 28 +++++++----- .../Extensions/CliReference/CliRootFile.cs | 9 +++- src/Elastic.Markdown/IO/MarkdownFile.cs | 10 ++++- 7 files changed, 95 insertions(+), 22 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs index e41c661ec6..1931985b83 100644 --- a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs @@ -2,6 +2,8 @@ // 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 Elastic.Documentation.AppliesTo; + namespace Elastic.Documentation.Configuration.Toc.CliReference; /// @@ -9,6 +11,9 @@ namespace Elastic.Documentation.Configuration.Toc.CliReference; /// /// - cli: schema/cli.json /// folder: cli-reference/ +/// applies_to: +/// stack: preview +/// serverless: preview /// /// public record CliReferenceRef( @@ -19,5 +24,6 @@ public record CliReferenceRef( string PathRelativeToDocumentationSet, string PathRelativeToContainer, string Context, - IReadOnlyCollection Children + IReadOnlyCollection Children, + ApplicableTo? AppliesTo = null ) : ITableOfContentsItem; diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index baee79ca4d..4a0e533ac1 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -2,8 +2,10 @@ // 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 Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Documentation.Configuration.Toc.DetectionRules; +using Elastic.Documentation.Diagnostics; using YamlDotNet.Core; using YamlDotNet.Core.Events; using YamlDotNet.Serialization; @@ -94,8 +96,10 @@ public class TocItemYamlConverter : IYamlTypeConverter } else if (parser.Accept(out _)) { - // This is a nested mapping - skip it - parser.SkipThisAndNestedEvents(); + if (key.Value == "applies_to") + value = ParseAppliesTo(parser); + else + parser.SkipThisAndNestedEvents(); } dictionary[key.Value] = value; @@ -119,7 +123,8 @@ public class TocItemYamlConverter : IYamlTypeConverter var supplementalFolder = dictionary.TryGetValue("folder", out var f) && f is string fStr ? fStr : null; 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); + var appliesTo = dictionary.TryGetValue("applies_to", out var at) && at is ApplicableTo a ? a : null; + return new CliReferenceRef(cliSchema, supplementalFolder, title, navigationTitle, cliSchema, cliSchema, placeholderContext, children, appliesTo); } // Check for folder+file combination (e.g., folder: getting-started, file: getting-started.md) @@ -183,6 +188,39 @@ public class TocItemYamlConverter : IYamlTypeConverter return null; } + private static ApplicableTo ParseAppliesTo(IParser parser) + { + _ = parser.Consume(); + var diagnostics = new List<(Severity, string)>(); + var appliesTo = new ApplicableTo(); + while (!parser.TryConsume(out _)) + { + var applyKey = parser.Consume(); + if (!parser.Accept(out var applyValue)) + { + parser.SkipThisAndNestedEvents(); + continue; + } + _ = parser.MoveNext(); + switch (applyKey.Value) + { + case "stack": + if (AppliesCollection.TryParse(applyValue.Value, diagnostics, out var stack) && stack is not null) + appliesTo = appliesTo with { Stack = stack }; + break; + case "serverless": + if (AppliesCollection.TryParse(applyValue.Value, diagnostics, out var sv) && sv is not null) + appliesTo = appliesTo with { Serverless = new ServerlessProjectApplicability { Elasticsearch = sv, Observability = sv, Security = sv } }; + break; + case "deployment": + if (AppliesCollection.TryParse(applyValue.Value, diagnostics, out var dep) && dep is not null) + appliesTo = appliesTo with { Deployment = new DeploymentApplicability { Self = dep, Ece = dep, Eck = dep, Ess = dep } }; + break; + } + } + return appliesTo; + } + private static IReadOnlyCollection GetChildren(Dictionary dictionary) { if (!dictionary.TryGetValue("children", out var childrenObj)) diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs index abe87fae75..f77f9ed921 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Markdown.Myst; @@ -33,7 +34,8 @@ public CliCommandFile( string[]? reservedMetaCommands = null, IReadOnlyList<(string Segment, List? Options)>? ancestorNamespaceOptions = null, List? globalOptions = null, - List? shortcuts = null + List? shortcuts = null, + ApplicableTo? appliesTo = null ) : base(sourceFile, rootPath, parser, build) { _command = command; @@ -45,6 +47,7 @@ public CliCommandFile( _globalOptions = globalOptions; _shortcuts = shortcuts; Title = command.Name; + FallbackAppliesTo = appliesTo; } public override string NavigationTitle => $"[cmd]{_command.Name}"; @@ -68,8 +71,10 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - return CliMarkdownGenerator.CommandPage(_command, supplemental, _fullPath, _binaryName, _reservedMetaCommands, + var body = CliMarkdownGenerator.CommandPage(_command, supplemental, _fullPath, _binaryName, _reservedMetaCommands, error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), _ancestorNamespaceOptions, _globalOptions, _shortcuts); + // Prepend supplemental front matter so applies_to (or any other field) in cmd-*.md overrides the fallback + return supplemental?.FrontMatter is { } fm ? $"{fm}\n\n{body}" : body; } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs index 94409b0a25..a76b75db62 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Markdown.Myst; @@ -29,7 +30,8 @@ public CliNamespaceFile( string[]? fullPath = null, string? binaryName = null, string[]? reservedMetaCommands = null, - List? shortcuts = null + List? shortcuts = null, + ApplicableTo? appliesTo = null ) : base(sourceFile, rootPath, parser, build) { _namespace = @namespace; @@ -39,6 +41,7 @@ public CliNamespaceFile( _reservedMetaCommands = reservedMetaCommands; _shortcuts = shortcuts; Title = @namespace.Segment; + FallbackAppliesTo = appliesTo; } public override string NavigationTitle => $"[ns]{_namespace.Segment}"; @@ -62,7 +65,9 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - return CliMarkdownGenerator.NamespacePage(_namespace, supplemental, _fullPath, _binaryName, _reservedMetaCommands, + var body = CliMarkdownGenerator.NamespacePage(_namespace, supplemental, _fullPath, _binaryName, _reservedMetaCommands, error => Collector.EmitError(_supplementalDoc ?? SourceFile, error), _shortcuts); + // Prepend supplemental front matter so applies_to (or any other field) in ns-*.md overrides the fallback + return supplemental?.FrontMatter is { } fm ? $"{fm}\n\n{body}" : body; } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs index 9204df6927..995c88dbd1 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Configuration.Toc.CliReference; @@ -28,7 +29,9 @@ internal sealed record CliEntityInfo( /// Display title for the generated CLI root page. string? Title = null, /// Navigation title for the generated CLI root page. - string? NavigationTitle = null + string? NavigationTitle = null, + /// Default applies_to inherited from the cli: toc entry; overridden by supplemental doc front matter. + ApplicableTo? AppliesTo = null ); public class CliReferenceDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension @@ -117,9 +120,9 @@ 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, 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), + CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc, info.Title, info.NavigationTitle, info.AppliesTo), + 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, info.AppliesTo), + 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, info.AppliesTo), CliShortcutSchema shortcut => new CliAliasFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, shortcut, info.Schema.Name, info.AliasCanonicalRelativePath ?? "../"), _ => null }; @@ -186,11 +189,13 @@ private List BuildSyntheticFiles() var matched = new HashSet(StringComparer.OrdinalIgnoreCase); + var appliesTo = cliRef.AppliesTo; + // Root page 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, Title: cliRef.Title, NavigationTitle: cliRef.NavigationTitle); + var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo, Title: cliRef.Title, NavigationTitle: cliRef.NavigationTitle, AppliesTo: appliesTo); _syntheticFiles![rootSyntheticPath] = rootInfo; if (rootSupplemental != null) _supplementalFiles![rootSupplemental.FullName] = rootInfo; @@ -202,7 +207,7 @@ private List BuildSyntheticFiles() var path = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [cmd.Name], isNamespace: false); var fileInfo = Build.ReadFileSystem.FileInfo.New(path); var supplemental = FindSupplemental(supplementalDirPath, [cmd.Name], isNamespace: false, matched); - var cmdInfo = new CliEntityInfo(schema, cmd, supplemental, fileInfo, FullPath: [cmd.Name]); + var cmdInfo = new CliEntityInfo(schema, cmd, supplemental, fileInfo, FullPath: [cmd.Name], AppliesTo: appliesTo); _syntheticFiles[path] = cmdInfo; if (supplemental != null) _supplementalFiles![supplemental.FullName] = cmdInfo; @@ -210,7 +215,7 @@ private List BuildSyntheticFiles() } // Namespaces (recursive) - CollectNamespaceFiles(Build.DocumentationSourceDirectory.FullName, virtualRoot, supplementalDirPath, schema.Namespaces, [], matched, fileInfos, schema); + CollectNamespaceFiles(Build.DocumentationSourceDirectory.FullName, virtualRoot, supplementalDirPath, schema.Namespaces, [], matched, fileInfos, schema, appliesTo: appliesTo); // Shortcut alias pages foreach (var shortcut in schema.Shortcuts ?? []) @@ -246,7 +251,8 @@ private void CollectNamespaceFiles( HashSet matched, List fileInfos, CliSchema schema, - IReadOnlyList<(string Segment, List? Options)>? ancestorOptions = null) + IReadOnlyList<(string Segment, List? Options)>? ancestorOptions = null, + ApplicableTo? appliesTo = null) { foreach (var ns in namespaces) { @@ -255,7 +261,7 @@ private void CollectNamespaceFiles( var nsFilePath = SyntheticPath(docSourceDir, virtualRoot, fullNsPath, isNamespace: true); var nsFileInfo = Build.ReadFileSystem.FileInfo.New(nsFilePath); var nsSupplemental = FindSupplemental(supplementalDirPath, fullNsPath, isNamespace: true, matched); - var nsInfo = new CliEntityInfo(schema, ns, nsSupplemental, nsFileInfo, FullPath: fullNsPath); + var nsInfo = new CliEntityInfo(schema, ns, nsSupplemental, nsFileInfo, FullPath: fullNsPath, AppliesTo: appliesTo); _syntheticFiles![nsFilePath] = nsInfo; if (nsSupplemental != null) _supplementalFiles![nsSupplemental.FullName] = nsInfo; @@ -272,14 +278,14 @@ private void CollectNamespaceFiles( 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, AncestorNamespaceOptions: cmdAncestors); + var cmdInfo = new CliEntityInfo(schema, cmd, cmdSupplemental, cmdFileInfo, FullPath: cmdSegments, AncestorNamespaceOptions: cmdAncestors, AppliesTo: appliesTo); _syntheticFiles[cmdPath] = cmdInfo; if (cmdSupplemental != null) _supplementalFiles![cmdSupplemental.FullName] = cmdInfo; fileInfos.Add(cmdFileInfo); } - CollectNamespaceFiles(docSourceDir, virtualRoot, supplementalDirPath, ns.Namespaces ?? [], fullNsPath, matched, fileInfos, schema, cmdAncestors); + CollectNamespaceFiles(docSourceDir, virtualRoot, supplementalDirPath, ns.Namespaces ?? [], fullNsPath, matched, fileInfos, schema, cmdAncestors, appliesTo); } } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs index 739409b609..6abed72fdb 100644 --- a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs +++ b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Markdown.Myst; @@ -25,7 +26,8 @@ public CliRootFile( CliSchema schema, IFileInfo? supplementalDoc, string? title = null, - string? navigationTitle = null + string? navigationTitle = null, + ApplicableTo? appliesTo = null ) : base(sourceFile, rootPath, parser, build) { _schema = schema; @@ -33,6 +35,7 @@ public CliRootFile( _title = string.IsNullOrWhiteSpace(title) ? schema.Name : title.Trim(); _navigationTitle = string.IsNullOrWhiteSpace(navigationTitle) ? $"{schema.Name} CLI" : navigationTitle.Trim(); Title = _title; + FallbackAppliesTo = appliesTo; } public override string NavigationTitle => _navigationTitle; @@ -56,6 +59,8 @@ private string BuildMarkdown() ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) : null; var supplemental = CliSupplementalDoc.Parse(rawSupplemental); - return CliMarkdownGenerator.RootPage(_schema, supplemental, _title); + var body = CliMarkdownGenerator.RootPage(_schema, supplemental, _title); + // Prepend supplemental front matter so applies_to (or any other field) in index.md overrides the fallback + return supplemental?.FrontMatter is { } fm ? $"{fm}\n\n{body}" : body; } } diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index f6d035eebb..4c8e11f4be 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Abstractions; +using Elastic.Documentation.AppliesTo; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Products; using Elastic.Documentation.Diagnostics; @@ -61,6 +62,12 @@ BuildContext build public string? UrlPathPrefix { get; } protected MarkdownParser MarkdownParser { get; } public YamlFrontMatter? YamlFrontMatter { get; private set; } + + /// + /// When set, provides a default used for generated pages that have no + /// applies_to in their YAML front matter. Page-level front matter always takes priority. + /// + protected ApplicableTo? FallbackAppliesTo { get; set; } public string? TitleRaw { get; protected set; } public string Title @@ -382,10 +389,11 @@ private static bool IsNestedInOtherDirective(DirectiveBlock block) private YamlFrontMatter ProcessYamlFrontMatter(MarkdownDocument document) { if (document.FirstOrDefault() is not YamlFrontMatterBlock yaml) - return new YamlFrontMatter { Title = Title }; + return new YamlFrontMatter { Title = Title, AppliesTo = FallbackAppliesTo }; var raw = string.Join(Environment.NewLine, yaml.Lines.Lines); var fm = ReadYamlFrontMatter(raw); + fm.AppliesTo ??= FallbackAppliesTo; if (fm.AppliesTo?.Diagnostics is not null) {