From 6c47c5a55774e0f257361af6d714bc4ea7b11ccd Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Mon, 4 Mar 2024 09:22:48 +0100 Subject: [PATCH] Add support for GitHub alert blocks --- src/Markdig.Tests/Markdig.Tests.csproj | 2 +- .../Specs/AlertBlockSpecs.generated.cs | 88 ++++++++++++ src/Markdig.Tests/Specs/AlertBlockSpecs.md | 57 ++++++++ .../Specs/MathSpecs.generated.cs | 2 +- src/Markdig/Extensions/Alerts/AlertBlock.cs | 33 +++++ .../Extensions/Alerts/AlertBlockRenderer.cs | 105 +++++++++++++++ .../Extensions/Alerts/AlertExtension.cs | 44 ++++++ .../Extensions/Alerts/AlertInlineParser.cs | 126 ++++++++++++++++++ src/Markdig/MarkdownExtensions.cs | 18 +++ src/SpecFileGen/Program.cs | 1 + 10 files changed, 474 insertions(+), 2 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..bf7f11288 --- /dev/null +++ b/src/Markdig.Tests/Specs/AlertBlockSpecs.generated.cs @@ -0,0 +1,88 @@ + +// -------------------------------- +// 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"); + } + + // 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. + [Test] + public void ExtensionsAlertBlocks_Example002() + { + // Example 2 + // 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 2\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..6dc27d989 --- /dev/null +++ b/src/Markdig.Tests/Specs/AlertBlockSpecs.md @@ -0,0 +1,57 @@ +# 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.

+
+```````````````````````````````` + +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. + +```````````````````````````````` 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.

+
+```````````````````````````````` 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..f8bb74316 --- /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..8f5b607c9 --- /dev/null +++ b/src/Markdig/Extensions/Alerts/AlertBlockRenderer.cs @@ -0,0 +1,105 @@ +// 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) + { + if (kind.Match("NOTE")) + { + renderer.WriteLine( + """ +

Note

+ """ + ); + } + else if (kind.Match("TIP")) + { + renderer.WriteLine( + """ +

Tip

+ """ + ); + } + else if (kind.Match("IMPORTANT")) + { + renderer.WriteLine( + """ +

Important

+ """ + ); + } + else if (kind.Match("WARNING")) + { + renderer.WriteLine( + """ +

Warning

+ """ + ); + } + else if (kind.Match("CAUTION")) + { + renderer.WriteLine( + """ +

Caution

+ """ + ); + } + } +} \ 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..2cb9b7fa4 --- /dev/null +++ b/src/Markdig/Extensions/Alerts/AlertInlineParser.cs @@ -0,0 +1,126 @@ +// 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 ParagraphBlock paragraphBlock) || !(paragraphBlock.Parent is QuoteBlock quoteBlock) || quoteBlock.Count != 1) + { + return false; + } + + var startPosition = processor.GetSourcePosition(slice.Start, out int line, out int column); + + 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 = new SourceSpan(startPosition, processor.GetSourcePosition(slice.Start - 1)), + TriviaSpaceAfterKind = new StringSlice(slice.Text, start, end), + Line = line, + Column = 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.RemoveAt(indexOfQuoteBlock); + parentQuoteBlock.Insert(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..d40250972 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/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"),