Skip to content
9 changes: 9 additions & 0 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 @@ -155,6 +156,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
18 changes: 18 additions & 0 deletions tests/authoring/Framework/LlmMarkdownAssertions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,23 @@ module LlmMarkdownAssertions =

-- ACTUAL --
{actualLLM}
"""
raise (XunitException(msg))

[<DebuggerStepThrough>]
let llmOutputContains (expectedText: string) (actual: Lazy<GeneratorResults>) =
// Check if the document content contains the expected text
let results = actual.Value
let defaultFile = results.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md")
let actualLLM = toLlmMarkdown defaultFile

if not (actualLLM.Contains(expectedText)) then
let msg = $"""LLM output does not contain expected text

-- EXPECTED TO CONTAIN --
{expectedText}

-- ACTUAL OUTPUT --
{actualLLM}
"""
raise (XunitException(msg))
62 changes: 57 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,55 @@ 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 ``renders content correctly`` () =
// The page content should be rendered in LLM markdown
markdown |> convertsToNewLLM """
This is a test page with applies_to frontmatter.
"""

[<Fact>]
let ``frontmatter applies_to is exported in metadata section`` () =
// The applies_to from frontmatter is processed and would appear in the exported LLM file
// In the format:
// ---
// title: Test Page
// description: ...
//
// This applies to:
// - Generally available since 8.5 for Stack
// - Preview for Serverless
// ---
//
// This happens in LlmMarkdownExporter.CreateLlmContentWithMetadata during export.
// We verify the frontmatter is parsed correctly so it can be used during export.
let results = markdown.Value
let defaultFile = results.MarkdownResults |> Seq.find (fun r -> r.File.RelativePath = "index.md")

// Verify frontmatter was parsed
test <@ defaultFile.File.YamlFrontMatter <> null @>
match defaultFile.File.YamlFrontMatter with
| NonNull yamlFrontMatter ->
// Verify applies_to was parsed and contains expected data
test <@ yamlFrontMatter.AppliesTo <> null @>
match yamlFrontMatter.AppliesTo with
| NonNull appliesTo ->
// Verify Stack configuration exists
test <@ appliesTo.Stack <> null @>
// Verify Serverless configuration exists
test <@ appliesTo.Serverless <> null @>
| _ -> ()
| _ -> ()
Loading