using Markdig.Extensions.Mathematics; using Markdig.Extensions.Tables; using Markdig.Syntax; using Markdig.Syntax.Inlines; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; // ReSharper disable MemberCanBePrivate.Global namespace MudBlazor; public class MudMarkdown : ComponentBase, IDisposable { protected IMudMarkdownThemeService? ThemeService; protected MarkdownPipeline? Pipeline; protected bool EnableLinkNavigation; protected int ElementIndex; /// /// Markdown text to be rendered in the component. /// [Parameter] public string Value { get; set; } = string.Empty; /// /// Minimum width (in pixels) for a table cell.
/// If or negative the minimum width is not applied. ///
[Parameter] public int? TableCellMinWidth { get; set; } /// /// Command which is invoked when a link is clicked.
/// If a link is opened in the browser. ///
[Parameter] public ICommand? LinkCommand { get; set; } /// /// Theme of the code block.
/// Browse available themes here: https://highlightjs.org/static/demo/ ///
[Parameter] public CodeBlockTheme CodeBlockTheme { get; set; } /// /// Override the original URL address of the .
/// If a function is not provided is used ///
[Parameter] public Func? OverrideLinkUrl { get; set; } /// /// Typography variant to use for Heading Level 1-6.
/// If a function is not provided a default typo for each level is set (e.g. for <h1> it will be , etc.) ///
[Parameter] public Func? OverrideHeaderTypo { get; set; } /// /// Override default styling of the markdown component /// [Parameter] public MudMarkdownStyling Styling { get; set; } = new(); [Parameter] public MarkdownPipeline? MarkdownPipeline { get; set; } [Inject] protected NavigationManager? NavigationManager { get; init; } [Inject] protected IJSRuntime JsRuntime { get; init; } = default!; [Inject] protected IServiceProvider? ServiceProvider { get; init; } public virtual void Dispose() { if (NavigationManager != null) NavigationManager.LocationChanged -= NavigationManagerOnLocationChanged; if (ThemeService != null) ThemeService.CodeBlockThemeChanged -= OnCodeBlockThemeChanged; GC.SuppressFinalize(this); } protected override void BuildRenderTree(RenderTreeBuilder builder) { if (string.IsNullOrEmpty(Value)) return; ElementIndex = 0; var pipeline = GetMarkdownPipeLine(); var parsedText = Markdown.Parse(Value, pipeline); if (parsedText.Count == 0) return; builder.OpenElement(ElementIndex++, "article"); builder.AddAttribute(ElementIndex++, "class", "mud-markdown-body"); RenderMarkdown(parsedText, builder); builder.CloseElement(); } protected override void OnInitialized() { base.OnInitialized(); ThemeService = ServiceProvider?.GetService(); if (ThemeService != null) ThemeService.CodeBlockThemeChanged += OnCodeBlockThemeChanged; } protected override void OnAfterRender(bool firstRender) { if (!firstRender || !EnableLinkNavigation || NavigationManager == null) return; var args = new LocationChangedEventArgs(NavigationManager.Uri, true); NavigationManagerOnLocationChanged(NavigationManager, args); NavigationManager.LocationChanged += NavigationManagerOnLocationChanged; } protected virtual void RenderMarkdown(ContainerBlock container, RenderTreeBuilder builder) { for (var i = 0; i < container.Count; i++) { switch (container[i]) { case ParagraphBlock paragraph: { RenderParagraphBlock(paragraph, builder); break; } case HeadingBlock heading: { var typo = (Typo)heading.Level; typo = OverrideHeaderTypo?.Invoke(typo) ?? typo; EnableLinkNavigation = true; var id = heading.BuildIdString(); RenderParagraphBlock(heading, builder, typo, id); break; } case QuoteBlock quote: { builder.OpenElement(ElementIndex++, "blockquote"); RenderMarkdown(quote, builder); builder.CloseElement(); break; } case Table table: { RenderTable(table, builder); break; } case ListBlock list: { RenderList(list, builder); break; } case ThematicBreakBlock: { builder.OpenComponent(ElementIndex++); builder.CloseComponent(); break; } case FencedCodeBlock code: { var text = code.CreateCodeBlockText(); // Necessary to prevent Blazor from crashing when the code block is empty. // It seems that Blazor does not like empty attributes. if(string.IsNullOrWhiteSpace(text)) break; builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudCodeHighlight.Text), text); builder.AddAttribute(ElementIndex++, nameof(MudCodeHighlight.Language), code.Info ?? string.Empty); builder.AddAttribute(ElementIndex++, nameof(MudCodeHighlight.Theme), CodeBlockTheme); builder.CloseComponent(); break; } case HtmlBlock html: { if (html.TryGetDetails(out var detailsData)) RenderDetailsHtml(builder, detailsData.Header, detailsData.Content); else RenderHtml(builder, html.Lines); break; } default: { OnRenderMarkdownBlockDefault(container[i]); break; } } } } /// /// Renders a markdown block which is not covered by the switch-case block in /// protected virtual void OnRenderMarkdownBlockDefault(Markdig.Syntax.Block block) { } protected virtual void RenderParagraphBlock(LeafBlock paragraph, RenderTreeBuilder builder, Typo typo = Typo.body1, string? id = null) { if (paragraph.Inline == null) return; builder.OpenComponent(ElementIndex++); if (!string.IsNullOrEmpty(id)) builder.AddAttribute(ElementIndex++, "id", id); builder.AddAttribute(ElementIndex++, nameof(MudText.Typo), typo); builder.AddAttribute(ElementIndex++, nameof(MudText.ChildContent), (RenderFragment)(contentBuilder => RenderInlines(paragraph.Inline, contentBuilder))); builder.CloseComponent(); } protected virtual void RenderInlines(ContainerInline inlines, RenderTreeBuilder builder) { foreach (var inline in inlines) { switch (inline) { case LiteralInline x: { builder.AddContent(ElementIndex++, x.Content); break; } case HtmlInline x: { builder.AddMarkupContent(ElementIndex++, x.Tag); break; } case LineBreakInline: { builder.OpenElement(ElementIndex++, "br"); builder.CloseElement(); break; } case CodeInline x: { builder.OpenElement(ElementIndex++, "code"); builder.AddContent(ElementIndex++, x.Content); builder.CloseElement(); break; } case EmphasisInline x: { if (!x.TryGetEmphasisElement(out var elementName)) continue; builder.OpenElement(ElementIndex++, elementName); RenderInlines(x, builder); builder.CloseElement(); break; } case LinkInline x: { var url = OverrideLinkUrl?.Invoke(x) ?? x.Url; if (x.IsImage) { var alt = x .OfType() .Select(static x => x.Content); builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudImage.Class), "rounded-lg"); builder.AddAttribute(ElementIndex++, nameof(MudImage.Src), url); builder.AddAttribute(ElementIndex++, nameof(MudImage.Alt), string.Join(null, alt)); builder.AddAttribute(ElementIndex++, nameof(MudImage.Elevation), 25); builder.CloseComponent(); } else if (LinkCommand == null) { builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudLink.Href), url); builder.AddAttribute(ElementIndex++, nameof(MudLink.ChildContent), (RenderFragment)(linkBuilder => RenderInlines(x, linkBuilder))); if (url.IsExternalUri(NavigationManager?.BaseUri)) { builder.AddAttribute(ElementIndex++, nameof(MudLink.Target), "_blank"); builder.AddAttribute(ElementIndex++, "rel", "noopener noreferrer"); } // (prevent scrolling to the top of the page) // custom implementation only for links on the same page else if (url?.StartsWith('#') ?? false) { builder.AddEventPreventDefaultAttribute(ElementIndex++, "onclick", true); builder.AddAttribute(ElementIndex++, "onclick", EventCallback.Factory.Create(this, () => { if (NavigationManager == null) return; var uriBuilder = new UriBuilder(NavigationManager.Uri) { Fragment = url, }; var args = new LocationChangedEventArgs(uriBuilder.Uri.AbsoluteUri, true); NavigationManagerOnLocationChanged(NavigationManager, args); })); } builder.CloseComponent(); } else { builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudLinkButton.Command), LinkCommand); builder.AddAttribute(ElementIndex++, nameof(MudLinkButton.CommandParameter), url); builder.AddAttribute(ElementIndex++, nameof(MudLinkButton.ChildContent), (RenderFragment)(linkBuilder => RenderInlines(x, linkBuilder))); builder.CloseComponent(); } break; } case MathInline x: { builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudMathJax.Delimiter), x.GetDelimiter()); builder.AddAttribute(ElementIndex++, nameof(MudMathJax.Value), x.Content); builder.CloseComponent(); break; } default: { OnRenderInlinesDefault(inline, builder); break; } } } } /// /// Renders inline block which is not covered by the switch-case block in /// protected virtual void OnRenderInlinesDefault(Inline inline, RenderTreeBuilder builder) { } protected virtual void RenderTable(Table table, RenderTreeBuilder builder) { // First child is columns if (table.Count < 2) return; builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudSimpleTable.Style), "overflow-x: auto;"); builder.AddAttribute(ElementIndex++, nameof(MudSimpleTable.Striped), Styling.Table.IsStriped); builder.AddAttribute(ElementIndex++, nameof(MudSimpleTable.Bordered), Styling.Table.IsBordered); builder.AddAttribute(ElementIndex++, nameof(MudSimpleTable.Elevation), Styling.Table.Elevation); builder.AddAttribute(ElementIndex++, nameof(MudSimpleTable.ChildContent), (RenderFragment)(contentBuilder => { // thread contentBuilder.OpenElement(ElementIndex++, "thead"); RenderTableRow((TableRow)table[0], "th", contentBuilder, TableCellMinWidth); contentBuilder.CloseElement(); // tbody contentBuilder.OpenElement(ElementIndex++, "tbody"); for (var j = 1; j < table.Count; j++) { RenderTableRow((TableRow)table[j], "td", contentBuilder); } contentBuilder.CloseElement(); })); builder.CloseComponent(); } protected virtual void RenderTableRow(TableRow row, string cellElementName, RenderTreeBuilder builder, int? minWidth = null) { builder.OpenElement(ElementIndex++, "tr"); for (var j = 0; j < row.Count; j++) { var cell = (TableCell)row[j]; builder.OpenElement(ElementIndex++, cellElementName); if (minWidth is > 0) builder.AddAttribute(ElementIndex++, "style", $"min-width:{minWidth}px"); if (cell.Count != 0 && cell[0] is ParagraphBlock paragraphBlock) RenderParagraphBlock(paragraphBlock, builder); builder.CloseElement(); } builder.CloseElement(); } protected virtual void RenderList(ListBlock list, RenderTreeBuilder builder) { if (list.Count == 0) return; var elementName = list.IsOrdered ? "ol" : "ul"; var orderStart = list.OrderedStart.ParseOrDefault(); builder.OpenElement(ElementIndex++, elementName); if (orderStart > 1) { builder.AddAttribute(ElementIndex++, "start", orderStart); } for (var i = 0; i < list.Count; i++) { var block = (ListItemBlock)list[i]; for (var j = 0; j < block.Count; j++) { switch (block[j]) { case ListBlock x: { RenderList(x, builder); break; } case ParagraphBlock x: { builder.OpenElement(ElementIndex++, "li"); RenderParagraphBlock(x, builder); builder.CloseElement(); break; } default: { OnRenderListDefault(block[j], builder); break; } } } } builder.CloseElement(); } /// /// Renders a markdown block which is not covered by the switch-case block in /// protected virtual void OnRenderListDefault(Markdig.Syntax.Block block, RenderTreeBuilder builder) { } protected virtual void RenderDetailsHtml(in RenderTreeBuilder builder, in string header, in string content) { var headerHtml = Markdown.Parse(header, Pipeline); var contentHtml = Markdown.Parse(content); builder.OpenComponent(ElementIndex++); builder.AddAttribute(ElementIndex++, nameof(MudMarkdownDetails.TitleContent), (RenderFragment)(titleBuilder => RenderMarkdown(headerHtml, titleBuilder))); builder.AddAttribute(ElementIndex++, nameof(MudMarkdownDetails.ChildContent), (RenderFragment)(contentBuilder => RenderMarkdown(contentHtml, contentBuilder))); builder.CloseComponent(); } protected virtual void RenderHtml(in RenderTreeBuilder builder, in StringLineGroup lines) { var markupString = new MarkupString(lines.ToString()); builder.AddContent(ElementIndex, markupString); } private async void NavigationManagerOnLocationChanged(object? sender, LocationChangedEventArgs e) { var idFragment = new Uri(e.Location, UriKind.Absolute).Fragment; if (!idFragment.StartsWith('#') || idFragment.Length < 2) return; idFragment = idFragment[1..]; await JsRuntime.InvokeVoidAsync("scrollToElementId", idFragment) .ConfigureAwait(false); } private void OnCodeBlockThemeChanged(object? sender, CodeBlockTheme e) => CodeBlockTheme = e; private MarkdownPipeline GetMarkdownPipeLine() { if (MarkdownPipeline != null) return MarkdownPipeline; return Pipeline ??= new MarkdownPipelineBuilder() .UseAdvancedExtensions() .Build(); } }