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.
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("")]
[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.