feat: add support for Obsidian-style callouts
This commit is contained in:
parent
16618cc135
commit
01031057e0
37
OliverBooth/Markdown/Callout/CalloutBlock.cs
Normal file
37
OliverBooth/Markdown/Callout/CalloutBlock.cs
Normal 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; }
|
||||
}
|
32
OliverBooth/Markdown/Callout/CalloutExtension.cs
Normal file
32
OliverBooth/Markdown/Callout/CalloutExtension.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
171
OliverBooth/Markdown/Callout/CalloutInlineParser.cs
Normal file
171
OliverBooth/Markdown/Callout/CalloutInlineParser.cs
Normal 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);
|
||||
}
|
||||
}
|
82
OliverBooth/Markdown/Callout/CalloutRenderer.cs
Normal file
82
OliverBooth/Markdown/Callout/CalloutRenderer.cs
Normal 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();
|
||||
}
|
||||
}
|
21
OliverBooth/Markdown/MarkdownExtensions.cs
Normal file
21
OliverBooth/Markdown/MarkdownExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
@ -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/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://unpkg.com/lucide@latest"></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>
|
||||
|
||||
|
@ -3,6 +3,8 @@ using Markdig;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Data.Web;
|
||||
using OliverBooth.Extensions;
|
||||
using OliverBooth.Markdown;
|
||||
using OliverBooth.Markdown.Callout;
|
||||
using OliverBooth.Markdown.Template;
|
||||
using OliverBooth.Markdown.Timestamp;
|
||||
using OliverBooth.Services;
|
||||
@ -24,7 +26,31 @@ builder.Logging.AddSerilog();
|
||||
builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
|
||||
.Use<TimestampExtension>()
|
||||
.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()
|
||||
.UseEmojiAndSmiley()
|
||||
.UseSmartyPants()
|
||||
|
@ -1,3 +1,5 @@
|
||||
@import "markdown";
|
||||
|
||||
html, body {
|
||||
background: #121212;
|
||||
color: #f5f5f5;
|
||||
|
142
src/scss/markdown-callouts.scss
Normal file
142
src/scss/markdown-callouts.scss
Normal 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
1
src/scss/markdown.scss
Normal file
@ -0,0 +1 @@
|
||||
@import "markdown-callouts";
|
@ -6,8 +6,11 @@ import BlogPost from "./BlogPost";
|
||||
|
||||
declare const Handlebars: any;
|
||||
declare const Prism: any;
|
||||
declare const lucide: any;
|
||||
|
||||
(() => {
|
||||
lucide.createIcons();
|
||||
|
||||
Prism.languages.extend('markup', {});
|
||||
Prism.languages.hex = {
|
||||
'number': {
|
||||
|
Loading…
Reference in New Issue
Block a user