diff --git a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs index 09e78ddad..109b5f9e4 100644 --- a/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs +++ b/src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Products; using Elastic.Markdown.Helpers; +using Elastic.Markdown.Myst.Renderers.LlmMarkdown; using Markdig.Syntax; namespace Elastic.Markdown.Exporters; @@ -76,7 +77,7 @@ public async ValueTask ExportAsync(MarkdownExportFileContext fileContext, if (outputFile.Directory is { Exists: false }) outputFile.Directory.Create(); - var content = IsRootIndexFile(fileContext) ? LlmsTxtTemplate : CreateLlmContentWithMetadata(fileContext, llmMarkdown); + var content = IsRootIndexFile(fileContext) ? LlmsTxtTemplate : CreateLlmContentWithMetadataInternal(fileContext, llmMarkdown); await fileContext.SourceFile.SourceFile.FileSystem.File.WriteAllTextAsync( outputFile.FullName, @@ -93,6 +94,13 @@ public static string ConvertToLlmMarkdown(MarkdownDocument document, IDocumentat _ = renderer.Render(obj); }); + /// + /// Creates the full LLM content with metadata section (frontmatter). + /// This is exposed for testing purposes. + /// + public static string CreateLlmContentWithMetadata(MarkdownExportFileContext context, string llmMarkdown) => + new LlmMarkdownExporter().CreateLlmContentWithMetadataInternal(context, llmMarkdown); + private static bool IsRootIndexFile(MarkdownExportFileContext fileContext) { var fs = fileContext.BuildContext.ReadFileSystem; @@ -128,7 +136,7 @@ private static IFileInfo GetLlmOutputFile(MarkdownExportFileContext fileContext) } - private string CreateLlmContentWithMetadata(MarkdownExportFileContext context, string llmMarkdown) + private string CreateLlmContentWithMetadataInternal(MarkdownExportFileContext context, string llmMarkdown) { var sourceFile = context.SourceFile; var metadata = DocumentationObjectPoolProvider.StringBuilderPool.Get(); @@ -155,6 +163,14 @@ private string CreateLlmContentWithMetadata(MarkdownExportFileContext context, s _ = metadata.AppendLine($" - {item}"); } + // Add applies_to information from frontmatter + if (sourceFile.YamlFrontMatter?.AppliesTo is not null) + { + var appliesToText = LlmAppliesToHelper.RenderAppliesToBlock(sourceFile.YamlFrontMatter.AppliesTo, context.BuildContext); + if (!string.IsNullOrEmpty(appliesToText)) + _ = metadata.Append(appliesToText); + } + _ = metadata.AppendLine("---"); _ = metadata.AppendLine(); _ = metadata.AppendLine($"# {sourceFile.Title}"); diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmAppliesToHelper.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmAppliesToHelper.cs new file mode 100644 index 000000000..58568bd19 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmAppliesToHelper.cs @@ -0,0 +1,110 @@ +// 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.Text; +using Elastic.Documentation; +using Elastic.Documentation.AppliesTo; +using Elastic.Documentation.Configuration; +using Elastic.Markdown.Myst.Components; + +namespace Elastic.Markdown.Myst.Renderers.LlmMarkdown; + +/// +/// Helper class to render ApplicableTo information in LLM-friendly text format +/// +public static class LlmAppliesToHelper +{ + /// + /// Converts ApplicableTo to a readable text format for LLM consumption (block level - for page or section) + /// + public static string RenderAppliesToBlock(ApplicableTo? appliesTo, IDocumentationConfigurationContext buildContext) + { + if (appliesTo is null || appliesTo == ApplicableTo.All) + return string.Empty; + + var items = GetApplicabilityItems(appliesTo, buildContext); + if (items.Count == 0) + return string.Empty; + + var sb = new StringBuilder(); + _ = sb.AppendLine(); + _ = sb.AppendLine("This applies to:"); + + foreach (var (productName, availabilityText) in items) + _ = sb.AppendLine($"- {availabilityText} for {productName}"); + + return sb.ToString(); + } + + /// + /// Converts ApplicableTo to a readable inline text format for LLM consumption + /// + public static string RenderApplicableTo(ApplicableTo? appliesTo, IDocumentationConfigurationContext buildContext) + { + if (appliesTo is null || appliesTo == ApplicableTo.All) + return string.Empty; + + var items = GetApplicabilityItems(appliesTo, buildContext); + if (items.Count == 0) + return string.Empty; + + var itemList = items.Select(item => $"{item.availabilityText} for {item.productName}").ToList(); + return string.Join(", ", itemList); + } + + private static List<(string productName, string availabilityText)> GetApplicabilityItems( + ApplicableTo appliesTo, + IDocumentationConfigurationContext buildContext) + { + var viewModel = new ApplicableToViewModel + { + AppliesTo = appliesTo, + Inline = false, + ShowTooltip = false, + VersionsConfig = buildContext.VersionsConfiguration + }; + + var applicabilityItems = viewModel.GetApplicabilityItems(); + var results = new List<(string productName, string availabilityText)>(); + + foreach (var item in applicabilityItems) + { + var renderData = item.RenderData; + var productName = item.Key; + + // Get the availability text from the popover data + var availabilityText = GetAvailabilityText(renderData); + if (!string.IsNullOrEmpty(availabilityText)) + results.Add((productName, availabilityText)); + } + + return results; + } + + private static string GetAvailabilityText(ApplicabilityRenderer.ApplicabilityRenderData renderData) + { + // Use the first availability item's text if available (this is what the popover shows) + if (renderData.PopoverData?.AvailabilityItems is { Length: > 0 } items) + { + // The popover text already includes lifecycle and version info + // e.g., "Generally available since 9.1", "Preview in 8.0", etc. + // We use the first item because it represents the most current/relevant status + // (items are sorted by version descending in ApplicabilityRenderer) + return items[0].Text; + } + + // Fallback to constructing from badge data + var parts = new List(); + + if (!string.IsNullOrEmpty(renderData.LifecycleName) && renderData.LifecycleName != "Generally available") + parts.Add(renderData.LifecycleName); + + if (!string.IsNullOrEmpty(renderData.Version)) + parts.Add(renderData.Version); + else if (!string.IsNullOrEmpty(renderData.BadgeLifecycleText)) + parts.Add(renderData.BadgeLifecycleText); + + return parts.Count > 0 ? string.Join(" ", parts) : "Available"; + } +} diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs index d5eeec1c9..5b1e44d3f 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmBlockRenderers.cs @@ -436,7 +436,20 @@ protected override void Write(LlmMarkdownRenderer renderer, DirectiveBlock obj) switch (obj) { case IBlockAppliesTo appliesBlock when !string.IsNullOrEmpty(appliesBlock.AppliesToDefinition): - renderer.Writer.Write($" applies-to=\"{appliesBlock.AppliesToDefinition}\""); + // Check if the block has a parsed AppliesTo object (e.g., AdmonitionBlock) + // Only AdmonitionBlock currently parses the YAML into an ApplicableTo object + // Other directive types may implement IBlockAppliesTo but not parse it + var appliesToText = obj switch + { + AdmonitionBlock admonition when admonition.AppliesTo is not null => + LlmAppliesToHelper.RenderApplicableTo(admonition.AppliesTo, renderer.BuildContext), + _ => null + }; + // Fallback to raw definition if parsing didn't work or returned empty + appliesToText ??= appliesBlock.AppliesToDefinition; + + if (!string.IsNullOrEmpty(appliesToText)) + renderer.Writer.Write($" applies-to=\"{appliesToText}\""); break; } diff --git a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs index a78bcd534..5be49bf2b 100644 --- a/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs +++ b/src/Elastic.Markdown/Myst/Renderers/LlmMarkdown/LlmInlineRenderers.cs @@ -4,6 +4,7 @@ using Elastic.Markdown.Myst.InlineParsers.Substitution; using Elastic.Markdown.Myst.Roles; +using Elastic.Markdown.Myst.Roles.AppliesTo; using Elastic.Markdown.Myst.Roles.Kbd; using Markdig.Renderers; using Markdig.Syntax.Inlines; @@ -101,7 +102,13 @@ protected override void Write(LlmMarkdownRenderer renderer, RoleLeaf obj) renderer.Writer.Write(output); break; } - // TODO: Add support for applies_to role + case AppliesToRole appliesTo: + { + var text = LlmAppliesToHelper.RenderApplicableTo(appliesTo.AppliesTo, renderer.BuildContext); + if (!string.IsNullOrEmpty(text)) + renderer.Writer.Write($"[{text}]"); + break; + } default: { new LlmCodeInlineRenderer().Write(renderer, obj); diff --git a/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs b/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs index 123455e19..b99b3d2ba 100644 --- a/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs +++ b/tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs @@ -133,9 +133,9 @@ This is an inline {applies_to}`stack: preview 9.1` element. """ [] - let ``converts to plain text with optional comment`` () = + let ``converts to readable text`` () = markdown |> convertsToNewLLM """ - This is an inline `stack: preview 9.1` element. + This is an inline [Planned for Stack] element. """ type ``admonition directive`` () = @@ -214,15 +214,15 @@ This is a custom admonition with applies_to information. [] let ``renders correctly with applies_to information`` () = markdown |> convertsToNewLLM """ - + This is a note admonition with applies_to information. - + This is a warning admonition with applies_to information. - + This is a tip admonition with applies_to information. @@ -751,3 +751,41 @@ type ``images in tables`` () = |---------------------------------------|------| | ![logo](https://example.com/logo.png) | Logo | """ + +type ``page level applies_to in frontmatter`` () = + static let markdown = Setup.Document """--- +applies_to: + stack: ga 8.5 + serverless: preview +--- + +# Test Page + +This is a test page with applies_to frontmatter. +""" + + [] + let ``exports with applies_to in metadata`` () = + // Test that the applies_to helper renders the expected output + let results = markdown.Value + let defaultFile = results.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md") + + // Get the AppliesTo object from frontmatter + test <@ defaultFile.File.YamlFrontMatter <> null @> + match defaultFile.File.YamlFrontMatter with + | NonNull yamlFrontMatter -> + test <@ yamlFrontMatter.AppliesTo <> null @> + match yamlFrontMatter.AppliesTo with + | NonNull appliesTo -> + // Test that the LlmAppliesToHelper renders the correct output + let appliesToText = Elastic.Markdown.Myst.Renderers.LlmMarkdown.LlmAppliesToHelper.RenderAppliesToBlock( + appliesTo, + defaultFile.Context.Generator.Context + ) + + // Verify it contains the expected sections + test <@ appliesToText.Contains("This applies to:") @> + test <@ appliesToText.Contains("for Stack") @> + test <@ appliesToText.Contains("for Serverless") @> + | _ -> failwith "AppliesTo should not be null" + | _ -> failwith "YamlFrontMatter should not be null"