diff --git a/OliverBooth/Markdown/Callout/CalloutBlock.cs b/OliverBooth/Markdown/Callout/CalloutBlock.cs new file mode 100644 index 0000000..b640fb0 --- /dev/null +++ b/OliverBooth/Markdown/Callout/CalloutBlock.cs @@ -0,0 +1,37 @@ +using Markdig.Helpers; +using Markdig.Syntax; + +namespace OliverBooth.Markdown.Callout; + +/// +/// Represents a callout block. +/// +internal sealed class CalloutBlock : QuoteBlock +{ + /// + /// Initializes a new instance of the class. + /// + /// The type of the callout. + public CalloutBlock(StringSlice type) : base(null) + { + Type = type; + } + + /// + /// Gets or sets the title of the callout. + /// + /// The title of the callout. + public StringSlice Title { get; set; } + + /// + /// Gets or sets the trailing whitespace trivia. + /// + /// The trailing whitespace trivia. + public StringSlice TrailingWhitespaceTrivia { get; set; } + + /// + /// Gets or sets the type of the callout. + /// + /// The type of the callout. + public StringSlice Type { get; set; } +} diff --git a/OliverBooth/Markdown/Callout/CalloutExtension.cs b/OliverBooth/Markdown/Callout/CalloutExtension.cs new file mode 100644 index 0000000..59f85a0 --- /dev/null +++ b/OliverBooth/Markdown/Callout/CalloutExtension.cs @@ -0,0 +1,32 @@ +using Markdig; +using Markdig.Parsers.Inlines; +using Markdig.Renderers; +using Markdig.Renderers.Html; + +namespace OliverBooth.Markdown.Callout; + +/// +/// Extension for adding Obsidian-style callouts to a Markdown pipeline. +/// +internal sealed class CalloutExtension : IMarkdownExtension +{ + /// + public void Setup(MarkdownPipelineBuilder pipeline) + { + var parser = pipeline.InlineParsers.Find(); + if (parser is null) + { + pipeline.InlineParsers.InsertBefore(new CalloutInlineParser()); + } + } + + /// + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) + { + var blockRenderer = renderer.ObjectRenderers.FindExact(); + if (blockRenderer is null) + { + renderer.ObjectRenderers.InsertBefore(new CalloutRenderer()); + } + } +} diff --git a/OliverBooth/Markdown/Callout/CalloutInlineParser.cs b/OliverBooth/Markdown/Callout/CalloutInlineParser.cs new file mode 100644 index 0000000..5ded38e --- /dev/null +++ b/OliverBooth/Markdown/Callout/CalloutInlineParser.cs @@ -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; + +/// +/// An inline parser for Obsidian-style callouts ([!NOTE] etc.) +/// +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 ReplaceParentContainer = + (processor, before, after) => ReplaceParentContainerMethod.Invoke(processor, [before, after]); + + /// + /// Initializes a new instance of the class. + /// + public CalloutInlineParser() + { + OpeningCharacters = ['[']; + } + + /// + 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); + } +} diff --git a/OliverBooth/Markdown/Callout/CalloutRenderer.cs b/OliverBooth/Markdown/Callout/CalloutRenderer.cs new file mode 100644 index 0000000..ee10336 --- /dev/null +++ b/OliverBooth/Markdown/Callout/CalloutRenderer.cs @@ -0,0 +1,82 @@ +using Humanizer; +using Markdig.Renderers; +using Markdig.Renderers.Html; + +namespace OliverBooth.Markdown.Callout; + +/// +/// Represents an HTML renderer which renders a . +/// +internal sealed class CalloutRenderer : HtmlObjectRenderer +{ + private static readonly Dictionary 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", + }; + + /// + 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 type = block.Type.AsSpan(); + Span 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($"
"); + renderer.Write("
"); + + renderer.Write(title.Length == 0 ? typeString.Humanize(LetterCasing.Sentence) : title); + renderer.WriteLine("
"); + + renderer.WriteChildren(block); + + renderer.WriteLine("
"); + renderer.EnsureLine(); + } + + private static void RenderAsText(HtmlRenderer renderer, CalloutBlock block) + { + string title = block.Title.Text; + ReadOnlySpan type = block.Type.AsSpan(); + renderer.WriteLine(title.Length == 0 ? type.ToString().ToUpperInvariant() : title.ToUpperInvariant()); + renderer.WriteChildren(block); + renderer.EnsureLine(); + } +} diff --git a/OliverBooth/Markdown/MarkdownExtensions.cs b/OliverBooth/Markdown/MarkdownExtensions.cs new file mode 100644 index 0000000..d63d869 --- /dev/null +++ b/OliverBooth/Markdown/MarkdownExtensions.cs @@ -0,0 +1,21 @@ +using Markdig; +using OliverBooth.Markdown.Callout; + +namespace OliverBooth.Markdown; + +/// +/// Extension methods for . +/// +internal static class MarkdownExtensions +{ + /// + /// Uses this extension to enable Obsidian-style callouts. + /// + /// The pipeline. + /// The modified pipeline. + public static MarkdownPipelineBuilder UseCallouts(this MarkdownPipelineBuilder pipeline) + { + pipeline.Extensions.AddIfNotAlready(); + return pipeline; + } +} diff --git a/OliverBooth/Pages/Shared/_Layout.cshtml b/OliverBooth/Pages/Shared/_Layout.cshtml index e880c92..db84eee 100644 --- a/OliverBooth/Pages/Shared/_Layout.cshtml +++ b/OliverBooth/Pages/Shared/_Layout.cshtml @@ -122,6 +122,7 @@ + diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index b09d159..6360e7a 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -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() .Use(new TemplateExtension(provider.GetRequiredService())) - .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() diff --git a/src/scss/app.scss b/src/scss/app.scss index 3833241..df43c57 100644 --- a/src/scss/app.scss +++ b/src/scss/app.scss @@ -1,3 +1,5 @@ +@import "markdown"; + html, body { background: #121212; color: #f5f5f5; diff --git a/src/scss/markdown-callouts.scss b/src/scss/markdown-callouts.scss new file mode 100644 index 0000000..7a77be0 --- /dev/null +++ b/src/scss/markdown-callouts.scss @@ -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; +} diff --git a/src/scss/markdown.scss b/src/scss/markdown.scss new file mode 100644 index 0000000..cb5b1e1 --- /dev/null +++ b/src/scss/markdown.scss @@ -0,0 +1 @@ +@import "markdown-callouts"; diff --git a/src/ts/app.ts b/src/ts/app.ts index bcf9d34..472fc86 100644 --- a/src/ts/app.ts +++ b/src/ts/app.ts @@ -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': {