From b62a12d32db9f86070352b8a2871b80fc217d2d8 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Thu, 14 Mar 2024 08:09:42 +0100 Subject: [PATCH] Add support for GitHub alert blocks (#776) * Add support for GitHub alert blocks * Fix alert for "must come first in a quote block" * Fix comment * Update src/Markdig/Extensions/Alerts/AlertBlockRenderer.cs Co-authored-by: Miha Zupan * Update src/Markdig/MarkdownExtensions.cs Co-authored-by: Miha Zupan * Fix parsing of alert block with multiple children blocks * Allow null for BlockParser ctor argument of QuoteBlock --------- Co-authored-by: Miha Zupan --- src/Markdig.Tests/Markdig.Tests.csproj | 2 +- .../Specs/AlertBlockSpecs.generated.cs | 139 ++++++++++++++++++ src/Markdig.Tests/Specs/AlertBlockSpecs.md | 95 ++++++++++++ .../Specs/MathSpecs.generated.cs | 2 +- src/Markdig/Extensions/Alerts/AlertBlock.cs | 33 +++++ .../Extensions/Alerts/AlertBlockRenderer.cs | 79 ++++++++++ .../Extensions/Alerts/AlertExtension.cs | 44 ++++++ .../Extensions/Alerts/AlertInlineParser.cs | 123 ++++++++++++++++ src/Markdig/MarkdownExtensions.cs | 18 +++ src/Markdig/Syntax/QuoteBlock.cs | 2 +- src/SpecFileGen/Program.cs | 1 + 11 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 src/Markdig.Tests/Specs/AlertBlockSpecs.generated.cs create mode 100644 src/Markdig.Tests/Specs/AlertBlockSpecs.md create mode 100644 src/Markdig/Extensions/Alerts/AlertBlock.cs create mode 100644 src/Markdig/Extensions/Alerts/AlertBlockRenderer.cs create mode 100644 src/Markdig/Extensions/Alerts/AlertExtension.cs create mode 100644 src/Markdig/Extensions/Alerts/AlertInlineParser.cs diff --git a/src/Markdig.Tests/Markdig.Tests.csproj b/src/Markdig.Tests/Markdig.Tests.csproj index 2068bf5f2..49c875fc6 100644 --- a/src/Markdig.Tests/Markdig.Tests.csproj +++ b/src/Markdig.Tests/Markdig.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Markdig.Tests/Specs/AlertBlockSpecs.generated.cs b/src/Markdig.Tests/Specs/AlertBlockSpecs.generated.cs new file mode 100644 index 000000000..78cf29c13 --- /dev/null +++ b/src/Markdig.Tests/Specs/AlertBlockSpecs.generated.cs @@ -0,0 +1,139 @@ + +// -------------------------------- +// Alert Blocks +// -------------------------------- + +using System; +using NUnit.Framework; + +namespace Markdig.Tests.Specs.AlertBlocks +{ + [TestFixture] + public class TestExtensionsAlertBlocks + { + // # Extensions + // + // This section describes the different extensions supported: + // + // ## Alert Blocks + // + // This is supporting the [GitHub Alert blocks](https://github.com/orgs/community/discussions/16925) + [Test] + public void ExtensionsAlertBlocks_Example001() + { + // Example 1 + // Section: Extensions / Alert Blocks + // + // The following Markdown: + // > [!NOTE] + // > Highlights information that users should take into account, even when skimming. + // + // > [!TIP] + // > Optional information to help a user be more successful. + // + // > [!IMPORTANT] + // > Crucial information necessary for users to succeed. + // + // > [!WARNING] + // > Critical content demanding immediate user attention due to potential risks. + // + // > [!CAUTION] + // > Negative potential consequences of an action. + // + // Should be rendered as: + //
+ //

Note

+ //

Highlights information that users should take into account, even when skimming.

+ //
+ //
+ //

Tip

+ //

Optional information to help a user be more successful.

+ //
+ //
+ //

Important

+ //

Crucial information necessary for users to succeed.

+ //
+ //
+ //

Warning

+ //

Critical content demanding immediate user attention due to potential risks.

+ //
+ //
+ //

Caution

+ //

Negative potential consequences of an action.

+ //
+ + TestParser.TestSpec("> [!NOTE] \n> Highlights information that users should take into account, even when skimming.\n\n> [!TIP]\n> Optional information to help a user be more successful.\n\n> [!IMPORTANT] \n> Crucial information necessary for users to succeed.\n\n> [!WARNING] \n> Critical content demanding immediate user attention due to potential risks.\n\n> [!CAUTION]\n> Negative potential consequences of an action.", "
\n

Note

\n

Highlights information that users should take into account, even when skimming.

\n
\n
\n

Tip

\n

Optional information to help a user be more successful.

\n
\n
\n

Important

\n

Crucial information necessary for users to succeed.

\n
\n
\n

Warning

\n

Critical content demanding immediate user attention due to potential risks.

\n
\n
\n

Caution

\n

Negative potential consequences of an action.

\n
", "advanced", context: "Example 1\nSection Extensions / Alert Blocks\n"); + } + + // Example with code blocks and mix formatting: + [Test] + public void ExtensionsAlertBlocks_Example002() + { + // Example 2 + // Section: Extensions / Alert Blocks + // + // The following Markdown: + // > [!NOTE] + // > Highlights information that users should take into account, even when skimming. + // > Testing rendering for multiple lines + // > ```csharp + // > var test = "I can also add code to panels + // > ``` + // > `Inline code testing` + // + // Should be rendered as: + //
+ //

Note

+ //

Highlights information that users should take into account, even when skimming. + // Testing rendering for multiple lines

+ //
var test = "I can also add code to panels
+            //     
+ //

+ //
+ + TestParser.TestSpec("> [!NOTE]\n> Highlights information that users should take into account, even when skimming.\n> Testing rendering for multiple lines\n> ```csharp\n> var test = \"I can also add code to panels\n> ```\n> `Inline code testing`", "
\n

Note

\n

Highlights information that users should take into account, even when skimming.\nTesting rendering for multiple lines

\n
var test = "I can also add code to panels\n
\n

\n
", "advanced", context: "Example 2\nSection Extensions / Alert Blocks\n"); + } + + // An alert inline (e.g `[!NOTE]`) must come first in a quote block, and must be followed by optional spaces with a new line. If no new lines are found, it will not be considered as an alert block. + // + // Followed by space and new line: + [Test] + public void ExtensionsAlertBlocks_Example003() + { + // Example 3 + // Section: Extensions / Alert Blocks + // + // The following Markdown: + // > [!NOTE] This is invalid because no new line + // > Highlights information that users should take into account, even when skimming. + // + // Should be rendered as: + //
+ //

[!NOTE] This is invalid because no new line + // Highlights information that users should take into account, even when skimming.

+ //
+ + TestParser.TestSpec("> [!NOTE] This is invalid because no new line\n> Highlights information that users should take into account, even when skimming.", "
\n

[!NOTE] This is invalid because no new line\nHighlights information that users should take into account, even when skimming.

\n
", "advanced", context: "Example 3\nSection Extensions / Alert Blocks\n"); + } + + // Must come first in a quote block: + [Test] + public void ExtensionsAlertBlocks_Example004() + { + // Example 4 + // Section: Extensions / Alert Blocks + // + // The following Markdown: + // > This is not a [!NOTE] + // > Highlights information that users should take into account, even when skimming. + // + // Should be rendered as: + //
+ //

This is not a [!NOTE] + // Highlights information that users should take into account, even when skimming.

+ //
+ + TestParser.TestSpec("> This is not a [!NOTE]\n> Highlights information that users should take into account, even when skimming.", "
\n

This is not a [!NOTE]\nHighlights information that users should take into account, even when skimming.

\n
", "advanced", context: "Example 4\nSection Extensions / Alert Blocks\n"); + } + } +} diff --git a/src/Markdig.Tests/Specs/AlertBlockSpecs.md b/src/Markdig.Tests/Specs/AlertBlockSpecs.md new file mode 100644 index 000000000..015c02902 --- /dev/null +++ b/src/Markdig.Tests/Specs/AlertBlockSpecs.md @@ -0,0 +1,95 @@ +# Extensions + +This section describes the different extensions supported: + +## Alert Blocks + +This is supporting the [GitHub Alert blocks](https://github.com/orgs/community/discussions/16925) + +```````````````````````````````` example +> [!NOTE] +> Highlights information that users should take into account, even when skimming. + +> [!TIP] +> Optional information to help a user be more successful. + +> [!IMPORTANT] +> Crucial information necessary for users to succeed. + +> [!WARNING] +> Critical content demanding immediate user attention due to potential risks. + +> [!CAUTION] +> Negative potential consequences of an action. +. +
+

Note

+

Highlights information that users should take into account, even when skimming.

+
+
+

Tip

+

Optional information to help a user be more successful.

+
+
+

Important

+

Crucial information necessary for users to succeed.

+
+
+

Warning

+

Critical content demanding immediate user attention due to potential risks.

+
+
+

Caution

+

Negative potential consequences of an action.

+
+```````````````````````````````` + +Example with code blocks and mix formatting: + + +```````````````````````````````` example +> [!NOTE] +> Highlights information that users should take into account, even when skimming. +> Testing rendering for multiple lines +> ```csharp +> var test = "I can also add code to panels +> ``` +> `Inline code testing` +. +
+

Note

+

Highlights information that users should take into account, even when skimming. +Testing rendering for multiple lines

+
var test = "I can also add code to panels
+
+

+
+```````````````````````````````` + +An alert inline (e.g `[!NOTE]`) must come first in a quote block, and must be followed by optional spaces with a new line. If no new lines are found, it will not be considered as an alert block. + +Followed by space and new line: + +```````````````````````````````` example +> [!NOTE] This is invalid because no new line +> Highlights information that users should take into account, even when skimming. +. +
+

[!NOTE] This is invalid because no new line +Highlights information that users should take into account, even when skimming.

+
+```````````````````````````````` + +Must come first in a quote block: + +```````````````````````````````` example +> This is not a [!NOTE] +> Highlights information that users should take into account, even when skimming. +. +
+

This is not a [!NOTE] +Highlights information that users should take into account, even when skimming.

+
+```````````````````````````````` + + diff --git a/src/Markdig.Tests/Specs/MathSpecs.generated.cs b/src/Markdig.Tests/Specs/MathSpecs.generated.cs index 35eca3baa..ee0a291c6 100644 --- a/src/Markdig.Tests/Specs/MathSpecs.generated.cs +++ b/src/Markdig.Tests/Specs/MathSpecs.generated.cs @@ -279,7 +279,7 @@ public class TestExtensionsMathBlock { // ## Math Block // - // The match block can spawn on multiple lines by having a $$ starting on a line. + // The math block can spawn on multiple lines by having a $$ starting on a line. // It is working as a fenced code block. [Test] public void ExtensionsMathBlock_Example017() diff --git a/src/Markdig/Extensions/Alerts/AlertBlock.cs b/src/Markdig/Extensions/Alerts/AlertBlock.cs new file mode 100644 index 000000000..4e5ff5293 --- /dev/null +++ b/src/Markdig/Extensions/Alerts/AlertBlock.cs @@ -0,0 +1,33 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Helpers; +using Markdig.Syntax; + +namespace Markdig.Extensions.Alerts; + +/// +/// A block representing an alert quote block. +/// +public class AlertBlock : QuoteBlock +{ + /// + /// Creates a new instance of this block. + /// + /// + public AlertBlock(StringSlice kind) : base(null) + { + Kind = kind; + } + + /// + /// Gets or sets the kind of the alert block (e.g `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`) + /// + public StringSlice Kind { get; set; } + + /// + /// Gets or sets the trivia space after the kind. + /// + public StringSlice TriviaSpaceAfterKind { get; set; } +} diff --git a/src/Markdig/Extensions/Alerts/AlertBlockRenderer.cs b/src/Markdig/Extensions/Alerts/AlertBlockRenderer.cs new file mode 100644 index 000000000..f9b95d612 --- /dev/null +++ b/src/Markdig/Extensions/Alerts/AlertBlockRenderer.cs @@ -0,0 +1,79 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Helpers; +using Markdig.Renderers; +using Markdig.Renderers.Html; +using Markdig.Syntax; + +namespace Markdig.Extensions.Alerts; + +/// +/// A HTML renderer for a . +/// +/// +public class AlertBlockRenderer : HtmlObjectRenderer +{ + /// + /// Creates a new instance of this renderer. + /// + public AlertBlockRenderer() + { + RenderKind = DefaultRenderKind; + } + + /// + /// Gets of sets a delegate to render the kind of the alert. + /// + public Action RenderKind { get; set; } + + + /// + protected override void Write(HtmlRenderer renderer, AlertBlock obj) + { + renderer.EnsureLine(); + if (renderer.EnableHtmlForBlock) + { + renderer.Write("'); + } + + RenderKind(renderer, obj.Kind); + + var savedImplicitParagraph = renderer.ImplicitParagraph; + renderer.ImplicitParagraph = false; + renderer.WriteChildren(obj); + renderer.ImplicitParagraph = savedImplicitParagraph; + if (renderer.EnableHtmlForBlock) + { + renderer.WriteLine(""); + } + renderer.EnsureLine(); + } + + + /// + /// Renders the kind of the alert. + /// + /// The HTML renderer. + /// The kind of the alert to render + public static void DefaultRenderKind(HtmlRenderer renderer, StringSlice kind) + { + string? html = kind.AsSpan() switch + { + "NOTE" => "

Note

", + "TIP" => "

Tip

", + "IMPORTANT" => "

Important

", + "WARNING" => "

Warning

", + "CAUTION" => "

Caution

", + _ => null + }; + + if (html is not null) + { + renderer.WriteLine(html); + } + } +} \ No newline at end of file diff --git a/src/Markdig/Extensions/Alerts/AlertExtension.cs b/src/Markdig/Extensions/Alerts/AlertExtension.cs new file mode 100644 index 000000000..38fb56984 --- /dev/null +++ b/src/Markdig/Extensions/Alerts/AlertExtension.cs @@ -0,0 +1,44 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Helpers; +using Markdig.Parsers.Inlines; +using Markdig.Renderers; +using Markdig.Renderers.Html; + +namespace Markdig.Extensions.Alerts; + +/// +/// Extension for adding alerts to a Markdown pipeline. +/// +public class AlertExtension : IMarkdownExtension +{ + /// + /// Gets or sets the delegate to render the kind of the alert. + /// + public Action? RenderKind { get; set; } + + /// + public void Setup(MarkdownPipelineBuilder pipeline) + { + var inlineParser = pipeline.InlineParsers.Find(); + if (inlineParser == null) + { + pipeline.InlineParsers.InsertBefore(new AlertInlineParser()); + } + } + + /// + public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) + { + var blockRenderer = renderer.ObjectRenderers.FindExact(); + if (blockRenderer == null) + { + renderer.ObjectRenderers.InsertBefore(new AlertBlockRenderer() + { + RenderKind = RenderKind ?? AlertBlockRenderer.DefaultRenderKind + }); + } + } +} \ No newline at end of file diff --git a/src/Markdig/Extensions/Alerts/AlertInlineParser.cs b/src/Markdig/Extensions/Alerts/AlertInlineParser.cs new file mode 100644 index 000000000..347b4d529 --- /dev/null +++ b/src/Markdig/Extensions/Alerts/AlertInlineParser.cs @@ -0,0 +1,123 @@ +// Copyright (c) Alexandre Mutel. All rights reserved. +// This file is licensed under the BSD-Clause 2 license. +// See the license.txt file in the project root for more information. + +using Markdig.Helpers; +using Markdig.Parsers; +using Markdig.Renderers.Html; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Markdig.Extensions.Alerts; + +/// +/// An inline parser for an alert inline (e.g. `[!NOTE]`). +/// +/// +public class AlertInlineParser : InlineParser +{ + /// + /// Initializes a new instance of the class. + /// + public AlertInlineParser() + { + 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 paragraphBlock || paragraphBlock.Parent is not QuoteBlock quoteBlock || paragraphBlock.Inline?.FirstChild != null) + { + return false; + } + + var saved = slice; + var c = slice.NextChar(); + if (c != '!') + { + slice = saved; + return false; + } + + c = slice.NextChar(); // Skip ! + + var start = slice.Start; + var end = start; + while (c.IsAlphaUpper()) + { + end = slice.Start; + c = slice.NextChar(); + } + + // We need at least one character + if (c != ']' || start == end) + { + slice = saved; + return false; + } + + var alertType = new StringSlice(slice.Text, start, end); + c = slice.NextChar(); // Skip ] + + start = slice.Start; + while (true) + { + if (c == '\0' || c == '\n' || c == '\r') + { + end = slice.Start; + if (c == '\r') + { + c = slice.NextChar(); // Skip \r + if (c == '\0' || c == '\n') + { + end = slice.Start; + if (c == '\n') + { + slice.NextChar(); // Skip \n + } + } + } + else if (c == '\n') + { + slice.NextChar(); // Skip \n + } + break; + } + else if (!c.IsSpaceOrTab()) + { + slice = saved; + return false; + } + + c = slice.NextChar(); + } + + var alertBlock = new AlertBlock(alertType) + { + Span = quoteBlock.Span, + TriviaSpaceAfterKind = new StringSlice(slice.Text, start, end), + Line = quoteBlock.Line, + Column = quoteBlock.Column, + }; + + alertBlock.GetAttributes().AddClass("markdown-alert"); + alertBlock.GetAttributes().AddClass($"markdown-alert-{alertType.ToString().ToLowerInvariant()}"); + + // Replace the quote block with the alert block + var parentQuoteBlock = quoteBlock.Parent!; + var indexOfQuoteBlock = parentQuoteBlock.IndexOf(quoteBlock); + parentQuoteBlock[indexOfQuoteBlock] = alertBlock; + + while (quoteBlock.Count > 0) + { + var block = quoteBlock[0]; + quoteBlock.RemoveAt(0); + alertBlock.Add(block); + } + + return true; + } +} diff --git a/src/Markdig/MarkdownExtensions.cs b/src/Markdig/MarkdownExtensions.cs index 261bbe313..185cb3b5f 100644 --- a/src/Markdig/MarkdownExtensions.cs +++ b/src/Markdig/MarkdownExtensions.cs @@ -3,6 +3,7 @@ // See the license.txt file in the project root for more information. using Markdig.Extensions.Abbreviations; +using Markdig.Extensions.Alerts; using Markdig.Extensions.AutoIdentifiers; using Markdig.Extensions.AutoLinks; using Markdig.Extensions.Bootstrap; @@ -34,6 +35,7 @@ using Markdig.Helpers; using Markdig.Parsers; using Markdig.Parsers.Inlines; +using Markdig.Renderers; namespace Markdig; @@ -74,6 +76,7 @@ public static MarkdownPipelineBuilder Use(this MarkdownPipelineBuild public static MarkdownPipelineBuilder UseAdvancedExtensions(this MarkdownPipelineBuilder pipeline) { return pipeline + .UseAlertBlocks() .UseAbbreviations() .UseAutoIdentifiers() .UseCitations() @@ -94,6 +97,18 @@ public static MarkdownPipelineBuilder UseAdvancedExtensions(this MarkdownPipelin .UseGenericAttributes(); // Must be last as it is one parser that is modifying other parsers } + /// + /// Uses this extension to enable alert blocks. + /// + /// The pipeline. + /// Replace the default renderer for the kind with a custom renderer + /// The modified pipeline + public static MarkdownPipelineBuilder UseAlertBlocks(this MarkdownPipelineBuilder pipeline, Action? renderKind = null) + { + pipeline.Extensions.ReplaceOrAdd(new AlertExtension() { RenderKind = renderKind }); + return pipeline; + } + /// /// Uses this extension to enable autolinks from text `http://`, `https://`, `ftp://`, `mailto:`, `www.xxx.yyy` /// @@ -552,6 +567,9 @@ public static MarkdownPipelineBuilder Configure(this MarkdownPipelineBuilder pip case "advanced": pipeline.UseAdvancedExtensions(); break; + case "alerts": + pipeline.UseAlertBlocks(); + break; case "pipetables": pipeline.UsePipeTables(); break; diff --git a/src/Markdig/Syntax/QuoteBlock.cs b/src/Markdig/Syntax/QuoteBlock.cs index 30379aae2..251e4273c 100644 --- a/src/Markdig/Syntax/QuoteBlock.cs +++ b/src/Markdig/Syntax/QuoteBlock.cs @@ -19,7 +19,7 @@ public class QuoteBlock : ContainerBlock /// Initializes a new instance of the class. /// /// The parser used to create this block. - public QuoteBlock(BlockParser parser) : base(parser) + public QuoteBlock(BlockParser? parser) : base(parser) { } diff --git a/src/SpecFileGen/Program.cs b/src/SpecFileGen/Program.cs index bc487249f..3c04fb64a 100644 --- a/src/SpecFileGen/Program.cs +++ b/src/SpecFileGen/Program.cs @@ -61,6 +61,7 @@ public RoundtripSpec(string name, string fileName, string extensions) static readonly Spec[] Specs = new[] { new Spec("CommonMarkSpecs", "CommonMark.md", ""), + new Spec("Alert Blocks", "AlertBlockSpecs.md", "advanced"), new Spec("Pipe Tables", "PipeTableSpecs.md", "pipetables|advanced"), new Spec("GFM Pipe Tables", "PipeTableGfmSpecs.md", "gfm-pipetables"), new Spec("Footnotes", "FootnotesSpecs.md", "footnotes|advanced"),