Skip to content
20 changes: 18 additions & 2 deletions src/Elastic.Markdown/Exporters/LlmMarkdownExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,7 +77,7 @@ public async ValueTask<bool> 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,
Expand All @@ -93,6 +94,13 @@ public static string ConvertToLlmMarkdown(MarkdownDocument document, IDocumentat
_ = renderer.Render(obj);
});

/// <summary>
/// Creates the full LLM content with metadata section (frontmatter).
/// This is exposed for testing purposes.
/// </summary>
public static string CreateLlmContentWithMetadata(MarkdownExportFileContext context, string llmMarkdown) =>
new LlmMarkdownExporter().CreateLlmContentWithMetadataInternal(context, llmMarkdown);

private static bool IsRootIndexFile(MarkdownExportFileContext fileContext)
{
var fs = fileContext.BuildContext.ReadFileSystem;
Expand Down Expand Up @@ -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();
Expand All @@ -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}");
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Helper class to render ApplicableTo information in LLM-friendly text format
/// </summary>
public static class LlmAppliesToHelper
{
/// <summary>
/// Converts ApplicableTo to a readable text format for LLM consumption (block level - for page or section)
/// </summary>
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();
}

/// <summary>
/// Converts ApplicableTo to a readable inline text format for LLM consumption
/// </summary>
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<string>();

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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 43 additions & 5 deletions tests/authoring/LlmMarkdown/LlmMarkdownOutput.fs
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ This is an inline {applies_to}`stack: preview 9.1` element.
"""

[<Fact>]
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`` () =
Expand Down Expand Up @@ -214,15 +214,15 @@ This is a custom admonition with applies_to information.
[<Fact>]
let ``renders correctly with applies_to information`` () =
markdown |> convertsToNewLLM """
<note applies-to="stack: ga">
<note applies-to="Generally available in 8.0+ for Stack">
This is a note admonition with applies_to information.
</note>

<warning applies-to="serverless: ga">
<warning applies-to="Generally available in 8.0+ for Serverless">
This is a warning admonition with applies_to information.
</warning>

<tip applies-to="elasticsearch: preview">
<tip applies-to="Preview in 8.0+ for Serverless Elasticsearch">
This is a tip admonition with applies_to information.
</tip>

Expand Down Expand Up @@ -751,3 +751,41 @@ type ``images in tables`` () =
|---------------------------------------|------|
| ![logo](https://example.com/logo.png) | Logo |
"""

type ``page level applies_to in frontmatter`` () =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot This test is useless if we cannot test the output of the frontmatter applies_to

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 5a4c560. The test now directly calls LlmAppliesToHelper.RenderAppliesToBlock to test the actual output that appears in the metadata section. The test verifies:

  1. Frontmatter is parsed into ApplicableTo object
  2. RenderAppliesToBlock produces output containing "This applies to:"
  3. Output includes "for Stack" and "for Serverless"

This tests the actual rendering logic that generates the applies_to metadata text in exported files.

static let markdown = Setup.Document """---
applies_to:
stack: ga 8.5
serverless: preview
---

# Test Page

This is a test page with applies_to frontmatter.
"""

[<Fact>]
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"
Loading