diff --git a/docs/_snippets/sample-data-markdown.csv b/docs/_snippets/sample-data-markdown.csv new file mode 100644 index 000000000..8a868bca6 --- /dev/null +++ b/docs/_snippets/sample-data-markdown.csv @@ -0,0 +1,6 @@ +Name,Notes,Links +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.","[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 6353a51b3..c55fd0785 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 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: + +:::::{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..6a3808373 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,45 @@ 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 document = MarkdownParser.ParseMarkdownStringAsync( + block.Build, + block.Context, + value, + 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 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..1b4de4851 100644 --- a/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/CsvIncludeTests.cs @@ -123,6 +123,21 @@ 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 +Search,[Text](https://www.google.com)")); + + [Fact] + public void RendersMarkdownLinkAsLink() => Html.Should().Contain(">Text"); +} + public class CsvIncludeNotFoundTests(ITestOutputHelper output) : DirectiveTest(output, """ :::{csv-include} missing-file.csv