feat: add support for Obsidian-style callouts

This commit is contained in:
Oliver Booth 2024-05-03 23:31:47 +01:00
parent 16618cc135
commit 01031057e0
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
11 changed files with 519 additions and 1 deletions

View File

@ -0,0 +1,37 @@
using Markdig.Helpers;
using Markdig.Syntax;
namespace OliverBooth.Markdown.Callout;
/// <summary>
/// Represents a callout block.
/// </summary>
internal sealed class CalloutBlock : QuoteBlock
{
/// <summary>
/// Initializes a new instance of the <see cref="CalloutBlock" /> class.
/// </summary>
/// <param name="type">The type of the callout.</param>
public CalloutBlock(StringSlice type) : base(null)
{
Type = type;
}
/// <summary>
/// Gets or sets the title of the callout.
/// </summary>
/// <value>The title of the callout.</value>
public StringSlice Title { get; set; }
/// <summary>
/// Gets or sets the trailing whitespace trivia.
/// </summary>
/// <value>The trailing whitespace trivia.</value>
public StringSlice TrailingWhitespaceTrivia { get; set; }
/// <summary>
/// Gets or sets the type of the callout.
/// </summary>
/// <value>The type of the callout.</value>
public StringSlice Type { get; set; }
}

View File

@ -0,0 +1,32 @@
using Markdig;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Markdown.Callout;
/// <summary>
/// Extension for adding Obsidian-style callouts to a Markdown pipeline.
/// </summary>
internal sealed class CalloutExtension : IMarkdownExtension
{
/// <inheritdoc />
public void Setup(MarkdownPipelineBuilder pipeline)
{
var parser = pipeline.InlineParsers.Find<CalloutInlineParser>();
if (parser is null)
{
pipeline.InlineParsers.InsertBefore<LinkInlineParser>(new CalloutInlineParser());
}
}
/// <inheritdoc />
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
var blockRenderer = renderer.ObjectRenderers.FindExact<CalloutRenderer>();
if (blockRenderer is null)
{
renderer.ObjectRenderers.InsertBefore<QuoteBlockRenderer>(new CalloutRenderer());
}
}
}

View File

@ -0,0 +1,171 @@
using System.Reflection;
using Cysharp.Text;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
namespace OliverBooth.Markdown.Callout;
/// <summary>
/// An inline parser for Obsidian-style callouts (<c>[!NOTE]</c> etc.)
/// </summary>
internal sealed class CalloutInlineParser : InlineParser
{
// ugly hack to access internal method
private static readonly MethodInfo ReplaceParentContainerMethod =
typeof(InlineProcessor).GetMethod("ReplaceParentContainer", BindingFlags.Instance | BindingFlags.NonPublic)!;
// but we can at least make it a bit nicer to access
private static readonly Action<InlineProcessor, ContainerBlock, ContainerBlock> ReplaceParentContainer =
(processor, before, after) => ReplaceParentContainerMethod.Invoke(processor, [before, after]);
/// <summary>
/// Initializes a new instance of the <see cref="CalloutInlineParser" /> class.
/// </summary>
public CalloutInlineParser()
{
OpeningCharacters = ['['];
}
/// <inheritdoc />
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// We expect the alert to be the first child of a quote block. Example:
// > [!NOTE]
// > This is a note
if (processor.Block is not ParagraphBlock { Parent: QuoteBlock quoteBlock } paragraphBlock ||
paragraphBlock.Inline?.FirstChild != null)
{
return false;
}
StringSlice cache = slice;
char current = slice.NextChar();
if (current != '!')
{
slice = cache;
return false;
}
current = slice.NextChar(); // skip !
int start = slice.Start;
int end = start;
while (current.IsAlphaUpper())
{
end = slice.Start;
current = slice.NextChar();
}
if (current != ']' || start == end)
{
slice = cache;
return false;
}
var type = new StringSlice(slice.Text, start, end);
current = slice.NextChar(); // skip ]
start = slice.Start;
ReadTitle(current, ref slice, out StringSlice title, start, out end);
var callout = new CalloutBlock(type)
{
Span = quoteBlock.Span,
TrailingWhitespaceTrivia = new StringSlice(slice.Text, start, end),
Line = quoteBlock.Line,
Column = quoteBlock.Column,
Title = title
};
AddAttributes(callout, type);
ReplaceQuoteBlock(processor, quoteBlock, callout);
return true;
}
private static void ReadTitle(char startChar, ref StringSlice slice, out StringSlice title, int start, out int end)
{
using Utf16ValueStringBuilder builder = ZString.CreateStringBuilder();
char current = startChar;
while (true)
{
if (current is not ('\0' or '\r' or '\n'))
{
builder.Append(current);
current = slice.NextChar();
continue;
}
end = slice.Start;
if (HandleCharacter(ref slice, ref end, ref current))
{
continue;
}
break;
}
title = new StringSlice(builder.ToString(), 0, builder.Length);
}
private static bool HandleCharacter(ref StringSlice slice, ref int end, ref char current)
{
switch (current)
{
case '\r':
current = slice.NextChar(); // skip \r
if (current is not ('\0' or '\n'))
{
return true;
}
end = slice.Start;
if (current == '\n')
{
slice.NextChar(); // skip \n
}
break;
case '\n':
slice.NextChar(); // skip \n
break;
}
return false;
}
private static void AddAttributes(IMarkdownObject callout, StringSlice type)
{
HtmlAttributes attributes = callout.GetAttributes();
attributes.AddClass("callout");
attributes.AddProperty("data-callout", type.AsSpan().ToString().ToLowerInvariant());
}
private static void ReplaceQuoteBlock(InlineProcessor processor, QuoteBlock quoteBlock, CalloutBlock callout)
{
ContainerBlock? parentQuoteBlock = quoteBlock.Parent;
if (parentQuoteBlock is null)
{
return;
}
int indexOfQuoteBlock = parentQuoteBlock.IndexOf(quoteBlock);
parentQuoteBlock[indexOfQuoteBlock] = callout;
while (quoteBlock.Count > 0)
{
var block = quoteBlock[0];
quoteBlock.RemoveAt(0);
callout.Add(block);
}
ReplaceParentContainerMethod.Invoke(processor, [quoteBlock, callout]);
// ReplaceParentContainer(processor, quoteBlock, callout);
}
}

View File

@ -0,0 +1,82 @@
using Humanizer;
using Markdig.Renderers;
using Markdig.Renderers.Html;
namespace OliverBooth.Markdown.Callout;
/// <summary>
/// Represents an HTML renderer which renders a <see cref="CalloutBlock" />.
/// </summary>
internal sealed class CalloutRenderer : HtmlObjectRenderer<CalloutBlock>
{
private static readonly Dictionary<string, string> CalloutTypes = new()
{
["NOTE"] = "pencil",
["ABSTRACT"] = "clipboard-list",
["INFO"] = "info",
["TODO"] = "circle-check",
["TIP"] = "flame",
["SUCCESS"] = "check",
["QUESTION"] = "circle-help",
["WARNING"] = "triangle-alert",
["FAILURE"] = "x",
["DANGER"] = "zap",
["BUG"] = "bug",
["EXAMPLE"] = "list",
["CITE"] = "quote",
["UPDATE"] = "calendar-check",
};
/// <inheritdoc />
protected override void Write(HtmlRenderer renderer, CalloutBlock block)
{
renderer.EnsureLine();
if (renderer.EnableHtmlForBlock)
{
RenderAsHtml(renderer, block);
}
else
{
RenderAsText(renderer, block);
}
renderer.EnsureLine();
}
private static void RenderAsHtml(HtmlRenderer renderer, CalloutBlock block)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
Span<char> upperType = stackalloc char[type.Length];
type.ToUpperInvariant(upperType);
if (!CalloutTypes.TryGetValue(upperType.ToString(), out string? lucideClass))
{
lucideClass = "pencil";
}
var typeString = type.ToString().ToLowerInvariant();
renderer.Write($"<div class=\"callout\" data-callout=\"{typeString}\">");
renderer.Write("<div class=\"callout-title\"><i data-lucide=\"");
renderer.Write(lucideClass);
renderer.Write("\"></i> ");
renderer.Write(title.Length == 0 ? typeString.Humanize(LetterCasing.Sentence) : title);
renderer.WriteLine("</div>");
renderer.WriteChildren(block);
renderer.WriteLine("</div>");
renderer.EnsureLine();
}
private static void RenderAsText(HtmlRenderer renderer, CalloutBlock block)
{
string title = block.Title.Text;
ReadOnlySpan<char> type = block.Type.AsSpan();
renderer.WriteLine(title.Length == 0 ? type.ToString().ToUpperInvariant() : title.ToUpperInvariant());
renderer.WriteChildren(block);
renderer.EnsureLine();
}
}

View File

@ -0,0 +1,21 @@
using Markdig;
using OliverBooth.Markdown.Callout;
namespace OliverBooth.Markdown;
/// <summary>
/// Extension methods for <see cref="MarkdownPipelineBuilder" />.
/// </summary>
internal static class MarkdownExtensions
{
/// <summary>
/// Uses this extension to enable Obsidian-style callouts.
/// </summary>
/// <param name="pipeline">The pipeline.</param>
/// <returns>The modified pipeline.</returns>
public static MarkdownPipelineBuilder UseCallouts(this MarkdownPipelineBuilder pipeline)
{
pipeline.Extensions.AddIfNotAlready<CalloutExtension>();
return pipeline;
}
}

View File

@ -122,6 +122,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.js" integrity="sha512-aoZChv+8imY/U1O7KIHXvO87EOzCuKO0GhFtpD6G2Cyjo/xPeTgdf3/bchB10iB+AojMTDkMHDPLKNxPJVqDcw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.js" integrity="sha512-aoZChv+8imY/U1O7KIHXvO87EOzCuKO0GhFtpD6G2Cyjo/xPeTgdf3/bchB10iB+AojMTDkMHDPLKNxPJVqDcw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js" integrity="sha512-uKQ39gEGiyUJl4AI6L+ekBdGKpGw4xJ55+xyJG7YFlJokPNYegn9KwQ3P8A7aFQAUtUsAQHep+d/lrGqrbPIDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js" integrity="sha512-uKQ39gEGiyUJl4AI6L+ekBdGKpGw4xJ55+xyJG7YFlJokPNYegn9KwQ3P8A7aFQAUtUsAQHep+d/lrGqrbPIDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="~/js/prism.min.js" asp-append-version="true" data-manual></script> <script src="~/js/prism.min.js" asp-append-version="true" data-manual></script>
<script src="~/js/app.min.js" asp-append-version="true"></script> <script src="~/js/app.min.js" asp-append-version="true"></script>

View File

@ -3,6 +3,8 @@ using Markdig;
using OliverBooth.Data.Blog; using OliverBooth.Data.Blog;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
using OliverBooth.Extensions; using OliverBooth.Extensions;
using OliverBooth.Markdown;
using OliverBooth.Markdown.Callout;
using OliverBooth.Markdown.Template; using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp; using OliverBooth.Markdown.Timestamp;
using OliverBooth.Services; using OliverBooth.Services;
@ -24,7 +26,31 @@ builder.Logging.AddSerilog();
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder() builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
.Use<TimestampExtension>() .Use<TimestampExtension>()
.Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>())) .Use(new TemplateExtension(provider.GetRequiredService<ITemplateService>()))
.UseAdvancedExtensions()
// we have our own "alert blocks"
.UseCallouts()
// advanced extensions. add explicitly to avoid UseAlertBlocks
.UseAbbreviations()
.UseAutoIdentifiers()
.UseCitations()
.UseCustomContainers()
.UseDefinitionLists()
.UseEmphasisExtras()
.UseFigures()
.UseFooters()
.UseFootnotes()
.UseGridTables()
.UseMathematics()
.UseMediaLinks()
.UsePipeTables()
.UseListExtras()
.UseTaskLists()
.UseDiagrams()
.UseAutoLinks()
.UseGenericAttributes() // must be last as it is one parser that is modifying other parsers
// no more advanced extensions
.UseBootstrap() .UseBootstrap()
.UseEmojiAndSmiley() .UseEmojiAndSmiley()
.UseSmartyPants() .UseSmartyPants()

View File

@ -1,3 +1,5 @@
@import "markdown";
html, body { html, body {
background: #121212; background: #121212;
color: #f5f5f5; color: #f5f5f5;

View File

@ -0,0 +1,142 @@
$callout-bg-blue: #1b2735;
$callout-bg-cyan: #233232;
$callout-bg-green: #223026;
$callout-bg-orange: #332a21;
$callout-bg-red: #352223;
$callout-bg-purple: #2c2835;
$callout-bg-grey: #2b2b2b;
$callout-fg-blue: #157aff;
$callout-fg-cyan: #53dfdd;
$callout-fg-green: #44cf6e;
$callout-fg-orange: #e9973f;
$callout-fg-red: #fb464c;
$callout-fg-purple: #a882ff;
$callout-fg-grey: #9e9e9e;
.callout {
font-size: 16px;
border-radius: 5px;
padding: 20px;
margin-bottom: 1rem;
.callout-title {
font-weight: bold;
}
&[data-callout="note"] {
background-color: $callout-bg-blue;
.callout-title {
color: $callout-fg-blue;
}
}
&[data-callout="abstract"] {
background-color: $callout-bg-cyan;
.callout-title {
color: $callout-fg-cyan;
}
}
&[data-callout="info"] {
background-color: $callout-bg-blue;
.callout-title {
color: $callout-fg-blue;
}
}
&[data-callout="todo"] {
background-color: $callout-bg-blue;
.callout-title {
color: $callout-fg-blue;
}
}
&[data-callout="tip"] {
background-color: $callout-bg-cyan;
.callout-title {
color: $callout-fg-cyan;
}
}
&[data-callout="success"] {
background-color: $callout-bg-green;
.callout-title {
color: $callout-fg-green;
}
}
&[data-callout="question"] {
background-color: $callout-bg-orange;
.callout-title {
color: $callout-fg-orange;
}
}
&[data-callout="warning"] {
background-color: $callout-bg-orange;
.callout-title {
color: $callout-fg-orange;
}
}
&[data-callout="failure"] {
background-color: $callout-bg-red;
.callout-title {
color: $callout-fg-red;
}
}
&[data-callout="danger"] {
background-color: $callout-bg-red;
.callout-title {
color: $callout-fg-red;
}
}
&[data-callout="bug"] {
background-color: $callout-bg-red;
.callout-title {
color: $callout-fg-red;
}
}
&[data-callout="example"] {
background-color: $callout-bg-purple;
.callout-title {
color: $callout-fg-purple;
}
}
&[data-callout="cite"] {
background-color: $callout-bg-grey;
.callout-title {
color: $callout-fg-grey;
}
}
&[data-callout="update"] {
background-color: $callout-bg-blue;
.callout-title {
color: $callout-fg-blue;
}
}
}
svg.lucide {
width: 16px;
}

1
src/scss/markdown.scss Normal file
View File

@ -0,0 +1 @@
@import "markdown-callouts";

View File

@ -6,8 +6,11 @@ import BlogPost from "./BlogPost";
declare const Handlebars: any; declare const Handlebars: any;
declare const Prism: any; declare const Prism: any;
declare const lucide: any;
(() => { (() => {
lucide.createIcons();
Prism.languages.extend('markup', {}); Prism.languages.extend('markup', {});
Prism.languages.hex = { Prism.languages.hex = {
'number': { 'number': {