From fe684cb5240480a7a248f76589f7452be1eb67a5 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Thu, 22 Jan 2026 11:54:03 +0100 Subject: [PATCH 1/2] Add Markdown support to the CSV directive --- docs/_snippets/sample-data-markdown.csv | 6 ++ docs/syntax/csv-include.md | 28 ++++++++++ .../Directives/CsvInclude/CsvIncludeBlock.cs | 5 ++ .../CsvInclude/CsvIncludeView.cshtml | 4 +- .../CsvInclude/CsvIncludeViewModel.cs | 16 +++++- .../Myst/Directives/DirectiveHtmlRenderer.cs | 56 ++++++++++++++++++- .../Directives/CsvIncludeTests.cs | 19 +++++++ 7 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 docs/_snippets/sample-data-markdown.csv diff --git a/docs/_snippets/sample-data-markdown.csv b/docs/_snippets/sample-data-markdown.csv new file mode 100644 index 000000000..72b86cf9a --- /dev/null +++ b/docs/_snippets/sample-data-markdown.csv @@ -0,0 +1,6 @@ +Name,Notes,Links +Alpha,"**Bold** text, _italic_ text, and `code`.","https://www.google.com" +Bravo,"Inline role: {kbd}`Ctrl+C`.","[Elastic docs](https://www.elastic.co/docs)" +Charlie,"Mixed **bold** and _italic_, plus `inline code`.","[Search](https://www.elastic.co/docs/solutions/search)" +Delta,"Feature {applies_to}`stack: ga 9.1` available now.","https://www.elastic.co" +Echo,"This is {preview} functionality.","[Preview docs](https://www.elastic.co/docs)" diff --git a/docs/syntax/csv-include.md b/docs/syntax/csv-include.md index 6353a51b3..d2de57c46 100644 --- a/docs/syntax/csv-include.md +++ b/docs/syntax/csv-include.md @@ -57,6 +57,34 @@ The directive includes built-in performance limits to handle large files efficie - **Column limit**: Maximum of 10 columns will be displayed - **File size limit**: Maximum file size of 10MB +### Markdown rendering in cells + +Cells are parsed as Markdown, so they can render inline formatting as well as links. For example, a cell containing `**Bold**` becomes bold text, `https://www.google.com` becomes a link, and `[Text](https://www.google.com)` becomes a link. + +Here is a complete example that uses multiple Markdown formats and both link styles: + +:::::{tab-set} + +::::{tab-item} Output + +:::{csv-include} ../_snippets/sample-data-markdown.csv +:caption: Sample data with Markdown formatting +::: + +:::: + +::::{tab-item} Markdown + +```markdown +:::{csv-include} _snippets/sample-data-markdown.csv +:caption: Sample data with Markdown formatting +::: +``` + +:::: + +::::: + ## Performance considerations The CSV directive is optimized for large files: diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs index 1fffcb5ba..0413c2d85 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeBlock.cs @@ -2,6 +2,7 @@ // 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.IO.Abstractions; using Elastic.Markdown.Diagnostics; namespace Elastic.Markdown.Myst.Directives.CsvInclude; @@ -10,6 +11,10 @@ public class CsvIncludeBlock(DirectiveBlockParser parser, ParserContext context) { public override string Directive => "csv-include"; + public ParserContext Context { get; } = context; + + public IFileInfo IncludeFrom { get; } = context.MarkdownSourcePath; + public string? CsvFilePath { get; private set; } public string? CsvFilePathRelativeToSource { get; private set; } public bool Found { get; private set; } diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeView.cshtml b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeView.cshtml index 9f3b84026..9a2be7f38 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeView.cshtml +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeView.cshtml @@ -26,7 +26,7 @@ @for (var i = 0; i < csvRows[0].Length; i++) { - @csvRows[0][i] + @Model.RenderCell(csvRows[0][i]) } @@ -37,7 +37,7 @@ @for (var i = 0; i < csvRows[rowIndex].Length; i++) { - @csvRows[rowIndex][i] + @Model.RenderCell(csvRows[rowIndex][i]) } } diff --git a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs index 072cbb187..0bfb1a57e 100644 --- a/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs +++ b/src/Elastic.Markdown/Myst/Directives/CsvInclude/CsvIncludeViewModel.cs @@ -3,11 +3,14 @@ // See the LICENSE file in the project root for more information using Elastic.Markdown.Diagnostics; +using Microsoft.AspNetCore.Html; namespace Elastic.Markdown.Myst.Directives.CsvInclude; public class CsvIncludeViewModel : DirectiveViewModel { + public required Func RenderMarkdown { get; init; } + public IEnumerable GetCsvRows() { if (DirectiveBlock is not CsvIncludeBlock csvBlock || !csvBlock.Found || string.IsNullOrEmpty(csvBlock.CsvFilePath)) @@ -48,9 +51,18 @@ public IEnumerable GetCsvRows() }); } - public static CsvIncludeViewModel Create(CsvIncludeBlock csvBlock) => + public HtmlString RenderCell(string? value) + { + if (string.IsNullOrEmpty(value)) + return HtmlString.Empty; + + return RenderMarkdown(value); + } + + public static CsvIncludeViewModel Create(CsvIncludeBlock csvBlock, Func renderMarkdown) => new() { - DirectiveBlock = csvBlock + DirectiveBlock = csvBlock, + RenderMarkdown = renderMarkdown }; } diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index e342ae0d2..06a37502c 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -2,6 +2,7 @@ // 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; using System.Diagnostics.CodeAnalysis; using Elastic.Documentation.AppliesTo; using Elastic.Markdown.Diagnostics; @@ -29,6 +30,7 @@ using Markdig.Renderers.Html; using Markdig.Syntax; using Markdig.Syntax.Inlines; +using Microsoft.AspNetCore.Html; using RazorSlices; using YamlDotNet.Core; @@ -517,11 +519,63 @@ void Render(Block o) private static void WriteCsvIncludeBlock(HtmlRenderer renderer, CsvIncludeBlock block) { - var viewModel = CsvIncludeViewModel.Create(block); + var viewModel = CsvIncludeViewModel.Create(block, value => RenderCsvCellMarkdown(block, value)); var slice = CsvIncludeView.Create(viewModel); RenderRazorSlice(slice, renderer); } + private static HtmlString RenderCsvCellMarkdown(CsvIncludeBlock block, string value) + { + if (string.IsNullOrWhiteSpace(value)) + return HtmlString.Empty; + + var markdown = NormalizeCsvCellMarkdown(value); + var document = MarkdownParser.ParseMarkdownStringAsync( + block.Build, + block.Context, + markdown, + block.IncludeFrom, + block.Context.YamlFrontMatter, + MarkdownParser.Pipeline); + + if (document.Count == 1 && document.FirstOrDefault() is ParagraphBlock paragraph && paragraph.Inline != null) + return RenderInlineMarkdown(paragraph); + + var html = document.ToHtml(MarkdownParser.Pipeline); + return new HtmlString(html.EnsureTrimmed()); + } + + private static HtmlString RenderInlineMarkdown(ParagraphBlock paragraph) + { + if (paragraph.Inline is null) + return HtmlString.Empty; + + var subscription = DocumentationObjectPoolProvider.HtmlRendererPool.Get(); + subscription.HtmlRenderer.WriteChildren(paragraph.Inline); + + var result = subscription.RentedStringBuilder?.ToString(); + DocumentationObjectPoolProvider.HtmlRendererPool.Return(subscription); + + return result == null ? HtmlString.Empty : new HtmlString(result.EnsureTrimmed()); + } + + private static string NormalizeCsvCellMarkdown(string value) + { + var trimmed = value.Trim(); + return IsPlainUrl(trimmed) ? $"<{trimmed}>" : value; + } + + private static bool IsPlainUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + if (value.IndexOfAny([' ', '\t', '\r', '\n']) >= 0) + return false; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + return false; + return uri.Scheme is "http" or "https"; + } + private static void WriteChangelogBlock(HtmlRenderer renderer, ChangelogBlock block) { if (!block.Found || block.BundlesFolderPath is null) diff --git a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs index adc952347..b8adb507f 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -123,6 +123,25 @@ public void HandlesEscapedQuotes() } } +public class CsvIncludeRenderLinksTests(ITestOutputHelper output) : DirectiveTest(output, +""" +::::{csv-include} test-data.csv +:::: +""") +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/test-data.csv", new MockFileData( +@"Name,Link +Google,https://www.google.com +Search,[Text](https://www.google.com)")); + + [Fact] + public void RendersBareUrlAsLink() => Html.Should().Contain(">https://www.google.com"); + + [Fact] + public void RendersMarkdownLinkAsLink() => Html.Should().Contain(">Text"); +} + public class CsvIncludeNotFoundTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{csv-include} missing-file.csv From ac4564aec85b4946980a185863d02b8a47139398 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri Benedetti Date: Fri, 23 Jan 2026 10:21:35 +0100 Subject: [PATCH 2/2] Remove autolinks from this PR --- docs/_snippets/sample-data-markdown.csv | 6 +++--- docs/syntax/csv-include.md | 4 ++-- .../Myst/Directives/DirectiveHtmlRenderer.cs | 20 +------------------ .../Directives/CsvIncludeTests.cs | 4 ---- 4 files changed, 6 insertions(+), 28 deletions(-) diff --git a/docs/_snippets/sample-data-markdown.csv b/docs/_snippets/sample-data-markdown.csv index 72b86cf9a..8a868bca6 100644 --- a/docs/_snippets/sample-data-markdown.csv +++ b/docs/_snippets/sample-data-markdown.csv @@ -1,6 +1,6 @@ Name,Notes,Links -Alpha,"**Bold** text, _italic_ text, and `code`.","https://www.google.com" +Alpha,"**Bold** text, _italic_ text, and `code`.","[Google](https://www.google.com)" Bravo,"Inline role: {kbd}`Ctrl+C`.","[Elastic docs](https://www.elastic.co/docs)" Charlie,"Mixed **bold** and _italic_, plus `inline code`.","[Search](https://www.elastic.co/docs/solutions/search)" -Delta,"Feature {applies_to}`stack: ga 9.1` available now.","https://www.elastic.co" -Echo,"This is {preview} functionality.","[Preview docs](https://www.elastic.co/docs)" +Delta,"Feature {applies_to}`stack: ga 9.1` available now.","[Elastic](https://www.elastic.co)" +Echo,"This is {preview}`9.1` functionality.","[Preview docs](https://www.elastic.co/docs)" diff --git a/docs/syntax/csv-include.md b/docs/syntax/csv-include.md index d2de57c46..c55fd0785 100644 --- a/docs/syntax/csv-include.md +++ b/docs/syntax/csv-include.md @@ -59,9 +59,9 @@ The directive includes built-in performance limits to handle large files efficie ### Markdown rendering in cells -Cells are parsed as Markdown, so they can render inline formatting as well as links. For example, a cell containing `**Bold**` becomes bold text, `https://www.google.com` becomes a link, and `[Text](https://www.google.com)` becomes a link. +Cells are parsed as Markdown, so they can render inline formatting and links. For example, a cell containing `**Bold**` becomes bold text, and `[Text](https://www.google.com)` becomes a link. -Here is a complete example that uses multiple Markdown formats and both link styles: +Here is a complete example that uses multiple Markdown formats: :::::{tab-set} diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 06a37502c..6a3808373 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -529,11 +529,10 @@ private static HtmlString RenderCsvCellMarkdown(CsvIncludeBlock block, string va if (string.IsNullOrWhiteSpace(value)) return HtmlString.Empty; - var markdown = NormalizeCsvCellMarkdown(value); var document = MarkdownParser.ParseMarkdownStringAsync( block.Build, block.Context, - markdown, + value, block.IncludeFrom, block.Context.YamlFrontMatter, MarkdownParser.Pipeline); @@ -559,23 +558,6 @@ private static HtmlString RenderInlineMarkdown(ParagraphBlock paragraph) return result == null ? HtmlString.Empty : new HtmlString(result.EnsureTrimmed()); } - private static string NormalizeCsvCellMarkdown(string value) - { - var trimmed = value.Trim(); - return IsPlainUrl(trimmed) ? $"<{trimmed}>" : value; - } - - private static bool IsPlainUrl(string value) - { - if (string.IsNullOrWhiteSpace(value)) - return false; - if (value.IndexOfAny([' ', '\t', '\r', '\n']) >= 0) - return false; - if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) - return false; - return uri.Scheme is "http" or "https"; - } - private static void WriteChangelogBlock(HtmlRenderer renderer, ChangelogBlock block) { if (!block.Found || block.BundlesFolderPath is null) diff --git a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs index b8adb507f..1b4de4851 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -132,12 +132,8 @@ public class CsvIncludeRenderLinksTests(ITestOutputHelper output) : DirectiveTes protected override void AddToFileSystem(MockFileSystem fileSystem) => fileSystem.AddFile("docs/test-data.csv", new MockFileData( @"Name,Link -Google,https://www.google.com Search,[Text](https://www.google.com)")); - [Fact] - public void RendersBareUrlAsLink() => Html.Should().Contain(">https://www.google.com"); - [Fact] public void RendersMarkdownLinkAsLink() => Html.Should().Contain(">Text"); }