https://github.com/marvindrude/beskar.markdown
Simple, low-memory, extensible markdown parser and html renderer.
https://github.com/marvindrude/beskar.markdown
allocation csharp markdown markdown-to-html performance stackalloc
Last synced: 27 days ago
JSON representation
Simple, low-memory, extensible markdown parser and html renderer.
- Host: GitHub
- URL: https://github.com/marvindrude/beskar.markdown
- Owner: MarvinDrude
- License: mit
- Created: 2026-05-02T19:04:38.000Z (about 2 months ago)
- Default Branch: master
- Last Pushed: 2026-05-26T21:59:22.000Z (about 1 month ago)
- Last Synced: 2026-05-26T23:24:49.730Z (about 1 month ago)
- Topics: allocation, csharp, markdown, markdown-to-html, performance, stackalloc
- Language: C#
- Homepage: https://marvindrude.com/
- Size: 355 KB
- Stars: 5
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# Beskar.Markdown
[](https://www.nuget.org/packages/Beskar.Markdown)
Beskar.Markdown is a high-performance, low-allocation Markdown parser and HTML renderer
for .NET. It is built from the ground up to leverage modern C# features like `Span`,
`ReadOnlySequence`, and efficient memory management to provide a blazing-fast and lean experience.
## Table of Contents
- [Why use this library?](#why-use-this-library)
- [Motivation](#motivation)
- [Getting Started](#getting-started)
- [Features](#features)
- [Main Features](#main-features)
- [Currently Supported Blocks & Inlines](#currently-supported-blocks--inlines)
- [Future Plans](#future-plans)
- [Frontmatter Parsing](#frontmatter-parsing)
- [Sluggable Headers](#sluggable-headers)
- [Code Block Intercept](#code-block-intercept)
- [⚠️ Security Warning](#%EF%B8%8F-security-warning)
- [Simple custom markdown extensions](#simple-custom-markdown-extensions)
- [Simple inline extension](#simple-inline-extension)
- [Simple block extension](#simple-block-extension)
- [Benchmark Results](#benchmark-results)
---
> Disclaimer: This is just my fun project i do on the side. I do not want to replace any of the major
> markdown solutions for csharp, neither could I do that even if i wanted. It's just for me internally
> to use.
## Why use this library?
- **Performance First**: Designed for scenarios where every microsecond and every byte counts.
- **Low Allocation**: Minimizes pressure on the Garbage Collector by using stack-allocated buffers and pooling where possible.
- **Modern C#**: Built for modern .NET, taking advantage of the latest language and runtime optimizations.
- **Simplicity**: A clean, easy-to-use API that gets the job done without unnecessary complexity.
- **Tests**: 1,009 passing tests (652 **CommonMark** Spec Tests)
## Motivation
I created this library because I love to learn about deep-level code topics.
Building a Markdown parser is a fantastic way to explore memory layout optimization,
and the intricacies of text processing at scale. It's a passion project aimed at
achieving technical excellence and pushing the boundaries of what's possible in .NET.
## Getting Started
Getting started with Beskar.Markdown is easy. Call the static `ToHtml` method:
```csharp
using Beskar.Markdown;
string markdown = "# Hello World\nThis is a **bold** statement.";
string html = BeMarkdown.ToHtml(markdown);
Console.WriteLine(html);
// Output:
Hello World
This is a bold statement.
```
## Features
### Main Features
- **Fast**: Beskar.Markdown is designed to be fast and leaner than existing solutions.
- **Modern**: Built for modern .NET, taking advantage of the latest language and runtime optimizations.
- **Easy to Use**: A clean, intuitive API that makes Markdown processing straightforward.
- **Extensible**: Easily add support for new Markdown features or extensions.
- **Frontmatter**: Built-in support for parsing document frontmatter.
- **Sluggable Headers**: Automatically generate `id` attributes for headers.
- **Advanced**: Supports contextual rendering
- **Tests**: 1,009 passing tests (652 **CommonMark** Spec Tests)
### Currently Supported Blocks & Inlines
- **Blocks**:
- Headers (ATX & Setext)
- Paragraphs
- Blockquotes
- Lists (Ordered & Unordered)
- Task list items (like GitHub)
- Fenced Code Blocks
- Indented Code Blocks
- Thematic Breaks (Horizontal Rules)
- HTML Blocks
- GitHub like Tables
- Full reference links
- **Inlines**:
- Emphasis (Bold, Italic)
- Links
- Autolinks
- Inline Code
- Inline HTML
- Line Breaks
- Strikethrough
- Images
### Future Plans
- [ ] In memory assembly baking
## ⚠️ Security Warning
**Important**: Beskar.Markdown does not perform HTML sanitization by default.
If you are processing Markdown input from untrusted users, you **MUST** sanitize
the resulting HTML to prevent Cross-Site Scripting (XSS) attacks.
Example:
```csharp
var rawHtml = BeMarkdown.ToHtml(userContent);
var sanitizer = new HtmlSanitizer();
var safeHtml = sanitizer.Sanitize(rawHtml);
```
If your sanitizer supports spans, you can use the following to prevent double allocation unlike above:
```csharp
var options = RenderOptions.HtmlDefault;
options.SanitizerFunc = (span) => HtmlSanitizer.Sanitize(span);
var safeHtml = BeMarkdown.ToHtml(userContent, renderOptions: options);
```
## Frontmatter Parsing
Beskar.Markdown can automatically parse YAML-like frontmatter into key-value pairs.
To enable this, use the `WithFrontMatter()` option and the `Parse` method:
```csharp
var options = MarkdownOptionBuilder.Create()
.WithFrontMatter()
.Build();
var markdown = """
---
title: My Awesome Page
author: Marvin
---
# Content
""";
var result = BeMarkdown.Parse(markdown, options);
Console.WriteLine(result.Context.FrontMatter["title"]); // My Awesome Page
Console.WriteLine(result.Html); //
Content
```
## Sluggable Headers
Beskar.Markdown can automatically generate `id` attributes for headers based on their text content.
To enable this, use the `WithSluggableHeaders()` option:
```csharp
var options = MarkdownOptionBuilder.Create()
.WithSluggableHeaders()
.Build();
var markdown = "# My Header Text";
var html = BeMarkdown.ToHtml(markdown, options);
Console.WriteLine(html);
// Output:
My Header Text
```
If you need a table of contents or anchor list, use `Parse` instead of `ToHtml`.
The returned context exposes headers in document order through `Context.Headers`.
Each item contains the generated slug, the plain text, and the heading level:
```csharp
var markdown = """
# My Header Text
## Details
""";
var result = BeMarkdown.Parse(markdown, options);
foreach (var header in result.Context.Headers)
{
Console.WriteLine($"{header.Level}: {header.PlainText} -> #{header.Slug}");
}
// Output:
// 1: My Header Text -> #my-header-text
// 2: Details -> #details
```
## Code Block Intercept
You can intercept the rendering of code blocks (both fenced and indented) to provide your own
HTML output. This is particularly useful for server-side syntax highlighting (e.g., using
libraries like `ColorCode` or calling a highlighting service).
To use this, implement the `ICodeBlockRenderer` interface and register it via `WithCodeBlockRenderer`:
```csharp
public sealed class MySyntaxHighlighter : ICodeBlockRenderer
{
public bool TryRender(
MarkdownContext context,
ref TextWriterIndentSlim writer,
ReadOnlySpan code,
ReadOnlySpan language)
{
// Intercept only C# blocks
if (language.Equals("csharp", StringComparison.OrdinalIgnoreCase))
{
writer.Write("
");
writer.WriteHtmlDecodedAndEncoded(language);
writer.Write(":");
// Your custom highlighting logic here
writer.Write(code);
writer.Write("");
return true; // Successfully intercepted
}
return false; // Fallback to default renderer
}
}
// Usage:
var options = MarkdownOptionBuilder.Create()
.WithCodeBlockRenderer(new MySyntaxHighlighter())
.Build();
var html = BeMarkdown.ToHtml("```csharp\nvar x = 1;\n```", options);
```
> **Note**: When writing the `language` span to the output, always use `writer.WriteHtmlDecodedAndEncoded(language)`
> to ensure proper HTML encoding.
## Simple custom markdown extensions
### Simple inline extension
This example inline extension adds a random emoji if you use `.RandomEmoji.`:
```csharp
var options = MarkdownOptionBuilder.Create()
.WithExtension(new EmojiInlineExtension())
.Build();
const string markdown =
"""
Hello, World! .RandomEmoji.
""";
var result = BeMarkdown.ToHtml(markdown, options);
Console.WriteLine(result); //
Hello, World! 💻
```
Implementation:
```csharp
public sealed class EmojiInlineExtension : BaseInlineExtension
{
private const int _targetTypeValue = BeMarkdown.BuiltInNodeTypeValueOffset + 4;
private static readonly ImmutableArray _emojis = ImmutableArray.CreateRange([
"😀", "🎉", "🚀", "🌟", "🔥", "🐱", "🍕", "💻", "☕"]);
public EmojiInlineExtension()
{
Parsers = [new EmojiInlineParser()];
Renderers = [new HtmlEmojiInlineRenderer()];
}
private sealed class HtmlEmojiInlineRenderer : INodeRenderer
{
public int TargetTypeValue => _targetTypeValue;
public void Render(
MarkdownContext context,
ReadOnlySpan rawText,
ref TextWriterIndentSlim writer,
in MarkdownNode current,
ReadOnlySpan nodes,
RenderOptions options)
{
writer.Write("");
writer.Write(_emojis[Random.Shared.Next(0, _emojis.Length)]);
writer.Write("");
}
}
private sealed class EmojiInlineParser : IInlineParser
{
private const string _identifier = ".RandomEmoji.";
public int Priority => 8_000;
public int SupportedTypeValue => _targetTypeValue;
public char TriggerChar => '.';
public char TriggerAltChar => '.';
public bool TryMatch(
ref InlineState state,
int parentIndex,
ref BufferWriter writer,
scoped ref InlineParser parser,
ParserOptions options)
{
if (state.RemainingText.Length < _identifier.Length)
return false;
if (!state.RemainingText.StartsWith(_identifier))
return false;
var nodeIndex = writer.WrittenSpan.Length;
writer.Add(new MarkdownNode()
{
Type = (NodeType)SupportedTypeValue,
TextSpan = new TextSpan(state.GlobalOffset, _identifier.Length),
NextSiblingIndex = -1,
FirstChildIndex = -1,
LastChildIndex = -1
});
parser.LinkInlineNode(ref writer, parentIndex, nodeIndex);
state.Advance(_identifier.Length);
return true;
}
}
}
```
### Simple block extension
This example block extension adds a basic parsing for a div that is red:
```csharp
var options = MarkdownOptionBuilder.Create()
.WithExtension(new RedBlockExtension())
.WithMaxBlockDepth(16)
.Build();
const string markdown =
"""
+red block+
inside of `code` inline
red
> blockquote
""";
var result = BeMarkdown.ToHtml(markdown, options);
Console.WriteLine(result); //
inside of code inline\nred
blockquote
```
Implementation:
```csharp
public sealed class RedBlockExtension : BaseBlockExtension
{
private const int _targetTypeValue = BeMarkdown.BuiltInNodeTypeValueOffset + 5;
public RedBlockExtension()
{
Parsers = [new RedBlockParser()];
Renderers = [new HtmlRedBlockRenderer()];
}
private sealed class HtmlRedBlockRenderer : INodeRenderer
{
public int TargetTypeValue => _targetTypeValue;
public void Render(
MarkdownContext context,
ReadOnlySpan rawText,
ref TextWriterIndentSlim writer,
in MarkdownNode current,
ReadOnlySpan nodes,
RenderOptions options)
{
writer.Write("
");
current.RenderChildren(context, rawText, nodes, ref writer, options);
writer.Write("");
}
}
private sealed class RedBlockParser : IBlockParser
{
private const string _identifier = "+red block+";
public int Priority => 10; // low priority
public int SupportedTypeValue => _targetTypeValue;
public int TryMatch(ref LineState state, int parentIndex, ref BufferWriter writer)
{
if (state.IsBlank || state.LeadingSpaces > 0)
{
return -1;
}
if (state.FirstChar != '+' && state.RawLine.Length < _identifier.Length)
{
return -1;
}
if (!state.RawLine.StartsWith(_identifier))
{
return -1;
}
var nodeIndex = writer.WrittenSpan.Length;
writer.Add(new MarkdownNode()
{
Type = (NodeType)SupportedTypeValue,
TextSpan = new TextSpan(-1, 0),
FirstChildIndex = -1,
NextSiblingIndex = -1,
LastChildIndex = -1
});
state.ConsumeRest();
return nodeIndex;
}
public bool CanContinue(ref MarkdownNode node, ref LineState state, ref BufferWriter writer)
{
// simple example only an empty line can stop the block
if (state.IsBlank)
{
return false;
}
if (node.TextSpan.Start == -1)
{
node.TextSpan = new TextSpan(state.GlobalOffset, state.RawLine.Length);
}
else
{
var newLength = (state.GlobalOffset - node.TextSpan.Start) + state.RawLine.Length;
node.TextSpan = node.TextSpan with { Length = newLength };
}
return true;
}
}
}
```
## Benchmark Results
Beskar.Markdown is designed to be fast and leaner than existing solutions.
Here is a comparison with some other libraries (especially when it comes to memory usage):
| Method | Categories | Mean | Rank | Gen0 | Gen1 | Gen2 | Allocated |
|---------------- |----------- |---------------:|-----:|----------:|----------:|----------:|-----------:|
| Markdig | Bigger | 222.117 us | 2 | - | - | - | 37256 B |
| Beskar.Markdown | Bigger | 95.211 us | 1 | - | - | - | 5288 B |
| CommonMark.Net | Bigger | 86.785 us | 1 | - | - | - | 66024 B |
| MarkdownSharp | Bigger | 429.370 us | 3 | - | - | - | 273996 B |
| | | | | | | | |
| Markdig | Full Spec | 1,919.994 us | 3 | 125.0000 | 125.0000 | 125.0000 | 2077013 B |
| Beskar.Markdown | Full Spec | 1,302.109 us | 1 | 31.2500 | 31.2500 | 31.2500 | 437967 B |
| CommonMark.Net | Full Spec | 1,577.537 us | 2 | 125.0000 | 125.0000 | 125.0000 | 3153930 B |
| MarkdownSharp | Full Spec | 346,914.621 us | 4 | 1656.2500 | 1625.0000 | 1468.7500 | 15909676 B |
| | | | | | | | |
| Markdig | Small | 5.425 us | 3 | - | - | - | 1144 B |
| Beskar.Markdown | Small | 2.736 us | 2 | - | - | - | 504 B |
| CommonMark.Net | Small | 1.768 us | 1 | - | - | - | 11488 B |
| MarkdownSharp | Small | 5.056 us | 3 | - | - | - | 2752 B |