From Markdown to HTML: a practical .NET renderer

Less theory, more code: parse Markdown with Markdig, inspect its structure, validate links and sanitize the HTML before displaying it.

Markdown document passing through a parser and validation step into a rendered HTML article.
From Markdown to HTML: parse, validate, render and sanitize.

On a project I worked on, I wanted to write articles in Markdown and display them as HTML. Here is a small .NET renderer, with the useful parts kept visible.

Markdown → parse → validate → render → sanitize

1. Install two packages

dotnet add package Markdig
dotnet add package HtmlSanitizer

Markdig parses Markdown. HtmlSanitizer filters the generated HTML. They do different jobs.

2. Start with one conversion

using Markdig;

var source = "Write **clearly**.";
var html = Markdown.ToHtml(source);

Console.WriteLine(html);
// <p>Write <strong>clearly</strong>.</p>

This demonstrates conversion only. Use the validated, sanitized renderer below before displaying author-provided content.

3. See the difference

The same heading, paragraph and list appear below as source and as a rendered article.

Markdown source beside its rendered result: a heading, a paragraph with bold text and a two-item list.

4. Build a small renderer

This teaching version allows H2–H4 headings and HTTPS links. It rejects raw HTML and images; a CMS can add its own media-ID rules separately.

using Ganss.Xss;
using Markdig;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;

public static class ArticleRenderer
{
    private static readonly MarkdownPipeline Pipeline =
        new MarkdownPipelineBuilder().UsePipeTables().Build();

    private static readonly HashSet<string> Languages =
        new(StringComparer.OrdinalIgnoreCase)
        {
            "csharp", "json", "html", "plaintext"
        };

    public static string Render(string markdown)
    {
        var document = Markdown.Parse(markdown, Pipeline);

        foreach (var node in document.Descendants())
        {
            if (node is HtmlBlock or HtmlInline)
            {
                throw new ArgumentException("Raw HTML is not supported.");
            }

            if (node is HeadingBlock heading &&
                (heading.Level < 2 || heading.Level > 4))
            {
                throw new ArgumentException("Use H2, H3 or H4 headings.");
            }

            if (node is LinkInline link)
            {
                if (link.IsImage || link.GetDynamicUrl is not null ||
                    !Uri.TryCreate(link.Url, UriKind.Absolute, out var uri) ||
                    uri.Scheme != Uri.UriSchemeHttps ||
                    !string.IsNullOrEmpty(uri.UserInfo))
                {
                    throw new ArgumentException("Use a regular HTTPS link.");
                }
            }

            if (node is FencedCodeBlock code)
            {
                var language = Languages.Contains(code.Info ?? "")
                    ? code.Info!.ToLowerInvariant()
                    : "plaintext";

                code.Info = language;
                code.Arguments = "";
                code.GetAttributes().Classes = ["language-" + language];
            }
        }

        var sanitizer = new HtmlSanitizer();
        sanitizer.AllowedTags.Clear();
        sanitizer.AllowedTags.UnionWith([
            "h2", "h3", "h4", "p", "br", "strong", "em",
            "ul", "ol", "li", "blockquote", "a", "pre", "code",
            "table", "thead", "tbody", "tr", "th", "td"
        ]);
        sanitizer.AllowedAttributes.Clear();
        sanitizer.AllowedAttributes.UnionWith(["href", "class"]);
        sanitizer.AllowedSchemes.Clear();
        sanitizer.AllowedSchemes.Add("https");
        sanitizer.AllowDataAttributes = false;

        var html = Markdown.ToHtml(document, Pipeline);
        return sanitizer.Sanitize(html);
    }
}

The article title supplies H1. The body starts at H2. Unknown code languages fall back to plain text; language classes label code but do not add syntax highlighting by themselves.

5. Try it

C# 12+ example, with implicit usings enabled:

var source = """"
## A small improvement

Write **clearly**.

- Parse Markdown
- Render HTML

```csharp
Console.WriteLine("Hello");
```
"""";

var html = ArticleRenderer.Render(source);
Console.WriteLine(html);

HTML structure, with whitespace simplified:

<h2>A small improvement</h2>
<p>Write <strong>clearly</strong>.</p>
<ul>
  <li>Parse Markdown</li>
  <li>Render HTML</li>
</ul>
<pre><code class="language-csharp">Console.WriteLine("Hello");
</code></pre>

6. Inspect the structure too

Once parsed, Markdown is a document tree. For example, extract link destinations without scanning the raw source:

var document = Markdown.Parse(
    "Read the [guide](https://example.com/guide).");

var links = document.Descendants()
    .OfType<LinkInline>()
    .Where(link => !link.IsImage)
    .Select(link => link.Url)
    .ToArray();

// ["https://example.com/guide"]

7. Check the rules

Example xUnit cases for the renderer above:

[Theory]
[InlineData("<script>alert(1)</script>")]
[InlineData("[click](javascript:alert(1))")]
[InlineData("![photo](https://example.com/photo.png)")]
[InlineData("# Another H1")]
public void Rejects_unsupported_content(string source)
{
    Assert.Throws<ArgumentException>(() => ArticleRenderer.Render(source));
}

[Fact]
public void Renders_emphasis()
{
    var html = ArticleRenderer.Render("Write **clearly**.");

    Assert.Contains("<strong>clearly</strong>", html);
}

Parse for structure. Validate your content rules. Sanitize the output. Keep those responsibilities separate, even when they fit in one small class.

Share this article

Facebook LinkedIn WhatsApp X

For Instagram or another app, copy the link or use More options when available.

Bujar Ademi

Written by

Bujar Ademi

Senior .NET Developer & Team Lead

← All articles