diff --git a/src/NuGetGallery.Services/Configuration/FeatureFlagService.cs b/src/NuGetGallery.Services/Configuration/FeatureFlagService.cs index b74474a570..d5598af029 100644 --- a/src/NuGetGallery.Services/Configuration/FeatureFlagService.cs +++ b/src/NuGetGallery.Services/Configuration/FeatureFlagService.cs @@ -40,6 +40,7 @@ public class FeatureFlagService : IFeatureFlagService private const string PackageRenamesFeatureName = GalleryPrefix + "PackageRenames"; private const string EmbeddedReadmeFlightName = GalleryPrefix + "EmbeddedReadmes"; private const string LicenseMdRenderingFlightName = GalleryPrefix + "LicenseMdRendering"; + private const string MarkdigMdRenderingFlightName = GalleryPrefix + "MarkdigMdRendering"; private const string DeletePackageApiFlightName = GalleryPrefix + "DeletePackageApi"; private const string ODataV1GetAllNonHijackedFeatureName = GalleryPrefix + "ODataV1GetAllNonHijacked"; @@ -286,6 +287,11 @@ public bool IsODataV2SearchCountNonHijackedEnabled() return _client.IsEnabled(ODataV2SearchCountNonHijackedFeatureName, defaultValue: true); } + public bool IsMarkdigMdRenderingEnabled() + { + return _client.IsEnabled(MarkdigMdRenderingFlightName, defaultValue: false); + } + public bool IsDeletePackageApiEnabled(User user) { return _client.IsEnabled(DeletePackageApiFlightName, user, defaultValue: false); diff --git a/src/NuGetGallery.Services/Configuration/IFeatureFlagService.cs b/src/NuGetGallery.Services/Configuration/IFeatureFlagService.cs index 7557eaaefe..635a3d1c57 100644 --- a/src/NuGetGallery.Services/Configuration/IFeatureFlagService.cs +++ b/src/NuGetGallery.Services/Configuration/IFeatureFlagService.cs @@ -227,6 +227,10 @@ public interface IFeatureFlagService bool IsODataV2SearchCountNonHijackedEnabled(); /// + /// Whether rendering Markdown content to HTML using Markdig is enabled + /// + bool IsMarkdigMdRenderingEnabled(); + /// Whether or not the user can delete a package through the API. /// bool IsDeletePackageApiEnabled(User user); diff --git a/src/NuGetGallery/Services/MarkdownService.cs b/src/NuGetGallery/Services/MarkdownService.cs index e8374fa936..7cc54f5376 100644 --- a/src/NuGetGallery/Services/MarkdownService.cs +++ b/src/NuGetGallery/Services/MarkdownService.cs @@ -3,10 +3,18 @@ using System; using System.IO; +using System.Linq; +using System.Management; using System.Text.RegularExpressions; +using System.Timers; using System.Web; using CommonMark; using CommonMark.Syntax; +using Markdig; +using Markdig.Parsers; +using Markdig.Renderers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; namespace NuGetGallery { @@ -14,14 +22,55 @@ public class MarkdownService : IMarkdownService { private static readonly TimeSpan RegexTimeout = TimeSpan.FromMinutes(1); private static readonly Regex EncodedBlockQuotePattern = new Regex("^ {0,3}>", RegexOptions.Multiline, RegexTimeout); - private static readonly Regex CommonMarkLinkPattern = new Regex(" _featureFlagService; public GetReadMeHtmlMethod() { - _markdownService = new MarkdownService(); + _featureFlagService = new Mock(); + _markdownService = new MarkdownService(_featureFlagService.Object); + } + + [Theory] + [InlineData(-1)] + public void ThrowsArgumentOutOfRangeExceptionForNegativeIncrementHeadersBy(int incrementHeadersBy) + { + var exception = Assert.Throws(() => _markdownService.GetHtmlFromMarkdown("markdown file test", incrementHeadersBy)); + Assert.Equal(nameof(incrementHeadersBy), exception.ParamName); + Assert.Contains("must be greater than or equal to 0", exception.Message); + } + + [Fact] + public void ThrowsArgumentNullExceptionForNullMarkdownString() + { + Assert.Throws(() => _markdownService.GetHtmlFromMarkdown(null, 0)); + Assert.Throws(() => _markdownService.GetHtmlFromMarkdown(null)); + } + + [Theory] + [InlineData("", "

<script>alert('test')</script>

", true)] + [InlineData("", "

<script>alert('test')</script>

", false)] + [InlineData("", "

<img src="javascript:alert('test');">

", true)] + [InlineData("", "

<img src="javascript:alert('test');">

", false)] + [InlineData("
", "

<a href="javascript:alert('test');">

", true)] + [InlineData("
", "

<a href="javascript:alert('test');">

", false)] + public void EncodesHtmlInMarkdown(string originalMd, string expectedHtml, bool isMarkdigMdRenderingEnabled) + { + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(isMarkdigMdRenderingEnabled); + Assert.Equal(expectedHtml, _markdownService.GetHtmlFromMarkdown(originalMd).Content); } [Theory] - [InlineData("", "

<script>alert('test')</script>

")] - [InlineData("", "

<img src="javascript:alert('test');">

")] - [InlineData("
", "

<a href="javascript:alert('test');">

")] - public void EncodesHtmlInMarkdown(string originalMd, string expectedHtml) + [InlineData("# Heading", "

Heading

", true, 0)] + [InlineData("# Heading", "

Heading

", false, 0)] + [InlineData("# Heading", "

Heading

", true, 1)] + [InlineData("# Heading", "

Heading

", false, 1)] + [InlineData("# Heading", "
Heading
", true, 6)] + [InlineData("# Heading", "
Heading
", false, 6)] + [InlineData("# Heading", "
Heading
", true, 7)] + [InlineData("# Heading", "
Heading
", false, 7)] + [InlineData("# Heading", "
Heading
", true, 5)] + [InlineData("# Heading", "
Heading
", false, 5)] + public void EncodesHtmlInMarkdownWithAdaptiveHeader(string originalMd, string expectedHtml, bool isMarkdigMdRenderingEnabled, int incrementHeadersBy) { - Assert.Equal(expectedHtml, StripNewLines( - _markdownService.GetHtmlFromMarkdown(originalMd).Content)); + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(isMarkdigMdRenderingEnabled); + Assert.Equal(expectedHtml, _markdownService.GetHtmlFromMarkdown(originalMd, incrementHeadersBy).Content); } [Theory] - [InlineData("# Heading", "

Heading

", false)] - [InlineData("\ufeff# Heading with BOM", "

Heading with BOM

", false)] - [InlineData("- List", "", false)] - [InlineData("[text](http://www.test.com)", "

text

", false)] - [InlineData("[text](javascript:alert('hi'))", "

text

", false)] - [InlineData("> Blockquote", "

<text>Blockquote</text>

", false)] - [InlineData("[text](http://www.asp.net)", "

text

", false)] - [InlineData("[text](badurl://www.asp.net)", "

text

", false)] - [InlineData("![image](http://www.asp.net/fake.jpg)", "

\"image\"

", true)] - [InlineData("![image](https://www.asp.net/fake.jpg)", "

\"image\"

", false)] - [InlineData("![image](http://www.otherurl.net/fake.jpg)", "

\"image\"

", true)] - public void ConvertsMarkdownToHtml(string originalMd, string expectedHtml, bool imageRewriteExpected) - { - var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); - Assert.Equal(expectedHtml, StripNewLines(readMeResult.Content)); + [InlineData("# Heading", "

Heading

", false, true)] + [InlineData("# Heading", "

Heading

", false, false)] + [InlineData("\ufeff# Heading with BOM", "

Heading with BOM

", false, true)] + [InlineData("\ufeff# Heading with BOM", "

Heading with BOM

", false, false)] + [InlineData("- List", "", false, true)] + [InlineData("- List", "", false, false)] + [InlineData("[text](http://www.test.com)", "

text

", false, true)] + [InlineData("[text](http://www.test.com)", "

text

", false, false)] + [InlineData("[text](javascript:alert('hi'))", "

text

", false, true)] + [InlineData("[text](javascript:alert('hi'))", "

text

", false, false)] + [InlineData("> Blockquote", "
\n

<text>Blockquote</text>

\n
", false, true)] + [InlineData("> Blockquote", "
\r\n

<text>Blockquote</text>

\r\n
", false, false)] + [InlineData("> > Blockquote", "
\n
\n

<text>Blockquote</text>

\n
\n
", false, true)] + [InlineData("> > Blockquote", "
\r\n

> <text>Blockquote</text>

\r\n
", false, false)] + [InlineData("[text](http://www.asp.net)", "

text

", false, true)] + [InlineData("[text](http://www.asp.net)", "

text

", false, false)] + [InlineData("[text](badurl://www.asp.net)", "

text

", false, true)] + [InlineData("[text](badurl://www.asp.net)", "

text

", false, false)] + [InlineData("![image](http://www.asp.net/fake.jpg)", "

\"image\"

", true, true)] + [InlineData("![image](http://www.asp.net/fake.jpg)", "

\"image\"

", true, false)] + [InlineData("![image](https://www.asp.net/fake.jpg)", "

\"image\"

", false, true)] + [InlineData("![image](https://www.asp.net/fake.jpg)", "

\"image\"

", false, false)] + [InlineData("![image](http://www.otherurl.net/fake.jpg)", "

\"image\"

", true, true)] + [InlineData("![image](http://www.otherurl.net/fake.jpg)", "

\"image\"

", true, false)] + [InlineData("## License\n\tLicensed under the Apache License, Version 2.0 (the \"License\");", "

License

\n
Licensed under the Apache License, Version 2.0 (the "License");\n
", false, true)] + [InlineData("## License\n\tLicensed under the Apache License, Version 2.0 (the \"License\");", "

License

\n
Licensed under the Apache License, Version 2.0 (the "License");\n
", false, true)] + public void ConvertsMarkdownToHtml(string originalMd, string expectedHtml, bool imageRewriteExpected, bool isMarkdigMdRenderingEnabled) + { + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(isMarkdigMdRenderingEnabled); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); Assert.Equal(imageRewriteExpected, readMeResult.ImagesRewritten); } - private static string StripNewLines(string text) + [Fact] + public void TestToHtmlWithExtension() + { + var originalMd = "This is a paragraph\r\n with a break inside"; + var expectedHtml = "

This is a paragraph
\nwith a break inside

"; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); + } + + [Fact] + public void TestToHtmlWithPipeTable() { - return text.Replace("\r\n", "").Replace("\n", ""); + var originalMd = @"a | b +-- | - +0 | 1"; + + var expectedHtml = "\n\n\n\n\n\n\n\n\n\n\n\n\n
ab
01
"; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); + } + + [Fact] + public void TestToHtmlWithGridTable() + { + var originalMd = @"+---+---+ +| a | b | ++===+===+ +| 1 | 2 | ++---+---+ +"; + + var expectedHtml = "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ab
12
"; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); + } + + [Fact] + public void TestToHtmlWithEmojiAndSmiley() + { + var originalMd = "This is a test with a :) and a :angry: smiley"; + + var expectedHtml = "

This is a test with a 😃 and a 😠 smiley

"; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); + } + + [Fact] + public void TestToHtmlWithTaskLists() + { + var originalMd = @"- [ ] Item1 +- [x] Item2 +- [ ] Item3 +- Item4"; + + var expectedHtml = ""; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); + } + + [Fact] + public void TestToHtmlWithAddtionalList() + { + var originalMd = @"1. First item + +Some text + +2. Second item"; + + var expectedHtml = "
    \n
  1. First item
  2. \n
\n

Some text

\n
    \n
  1. Second item
  2. \n
"; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); + } + + [Fact] + public void TestToHtmlWithAutoLinks() + { + var originalMd = "This is a http://www.google.com URL and https://www.google.com"; + + var expectedHtml = "

This is a http://www.google.com URL and https://www.google.com

"; + _featureFlagService.Setup(x => x.IsMarkdigMdRenderingEnabled()).Returns(true); + var readMeResult = _markdownService.GetHtmlFromMarkdown(originalMd); + Assert.Equal(expectedHtml, readMeResult.Content); + Assert.Equal(false, readMeResult.ImagesRewritten); } } }