diff --git a/Directory.Packages.props b/Directory.Packages.props index ae9d86bfb75d..2ccfc7ed49e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,6 +50,7 @@ + diff --git a/NOTICES.txt b/NOTICES.txt index a3cb471492e1..b1a1b7ca5e3d 100644 --- a/NOTICES.txt +++ b/NOTICES.txt @@ -196,6 +196,14 @@ Copyright: 2013-2024 .NET Foundation and Contributors --- +Markdig: A fast, powerful, CommonMark compliant, extensible Markdown processor for .NET + +URL: https://github.com/xoofx/markdig +License: BSD-2-Clause license +Copyright: 2018+, Alexandre Mutel. All rights reserved. + +--- + Markdown: A library for parsing and compiling Markdown URL: https://github.com/hey-red/Markdown diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs index e75984c9e27c..6a610180b8b5 100644 --- a/src/Umbraco.Core/Composing/TypeFinder.cs +++ b/src/Umbraco.Core/Composing/TypeFinder.cs @@ -29,7 +29,7 @@ public class TypeFinder : ITypeFinder "DataAnnotationsExtensions,", "DataAnnotationsExtensions.", "Dynamic,", "Examine,", "Examine.", "HtmlAgilityPack,", "HtmlAgilityPack.", "HtmlDiff,", "ICSharpCode.", "Iesi.Collections,", // used by NHibernate "JetBrains.Annotations,", "LightInject.", // DI - "LightInject,", "Lucene.", "Markdown,", "Microsoft.", "MiniProfiler,", "Moq,", "MySql.", "NHibernate,", + "LightInject,", "Lucene.", "Markdig,", "Markdown,", "Microsoft.", "MiniProfiler,", "Moq,", "MySql.", "NHibernate,", "NHibernate.", "Newtonsoft.", "NPoco,", "NuGet.", "RouteDebugger,", "Semver.", "Serilog.", "Serilog,", "ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog "System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.", diff --git a/src/Umbraco.Core/Strings/IMarkdownToHtmlConverter.cs b/src/Umbraco.Core/Strings/IMarkdownToHtmlConverter.cs new file mode 100644 index 000000000000..bea67572a9ad --- /dev/null +++ b/src/Umbraco.Core/Strings/IMarkdownToHtmlConverter.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Strings; + +/// +/// Defines a service that converts Markdown-formatted text to HTML. +/// +public interface IMarkdownToHtmlConverter +{ + /// + /// Converts the specified Markdown-formatted text to an HTML-encoded string. + /// + /// The input string containing Markdown syntax to be converted. + /// A string containing the HTML representation of the input Markdown. + public string ToHtml(string markdown); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 36f28daf1617..7ec1e8dfa3d4 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -174,7 +174,7 @@ public static IUmbracoBuilder AddCoreInitialServices(this IUmbracoBuilder builde .Remove(); // register *all* checks, except those marked [HideFromTypeFinder] of course - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index f8798816ad70..2b569ddd5ab6 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -23,6 +23,7 @@ using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Cms.Infrastructure.Services; using Umbraco.Cms.Infrastructure.Services.Implement; +using Umbraco.Cms.Infrastructure.Strings; using Umbraco.Cms.Infrastructure.Telemetry.Providers; using Umbraco.Cms.Infrastructure.Templates.PartialViews; using Umbraco.Extensions; @@ -87,6 +88,11 @@ internal static IUmbracoBuilder AddServices(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.Services.AddUnique(); +#pragma warning disable CS0618 // Type or member is obsolete + // TODO (V18): Replace this with MarkdigMarkdownToHtmlConverter as the default implementation. + builder.Services.AddUnique(); +#pragma warning restore CS0618 // Type or member is obsolete + return builder; } diff --git a/src/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverter.cs b/src/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverter.cs index 4020dc313661..6f64fdef2f2a 100644 --- a/src/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverter.cs +++ b/src/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverter.cs @@ -1,4 +1,5 @@ -using HeyRed.MarkdownSharp; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.HealthChecks; using Umbraco.Cms.Core.HealthChecks.NotificationMethods; @@ -6,10 +7,19 @@ namespace Umbraco.Cms.Infrastructure.HealthChecks; public class MarkdownToHtmlConverter : IMarkdownToHtmlConverter { + private readonly Core.Strings.IMarkdownToHtmlConverter _markdownToHtmlConverter; + + public MarkdownToHtmlConverter(Core.Strings.IMarkdownToHtmlConverter markdownToHtmlConverter) => _markdownToHtmlConverter = markdownToHtmlConverter; + + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")] + public MarkdownToHtmlConverter() + : this(StaticServiceProvider.Instance.GetRequiredService()) + { + } + public string ToHtml(HealthCheckResults results, HealthCheckNotificationVerbosity verbosity) { - var mark = new Markdown(); - var html = mark.Transform(results.ResultsAsMarkDown(verbosity)); + var html = _markdownToHtmlConverter.ToHtml(results.ResultsAsMarkDown(verbosity)); html = ApplyHtmlHighlighting(html); return html; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs index 443e497b0931..df5b4caec7f6 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs @@ -1,7 +1,8 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using HeyRed.MarkdownSharp; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; using Umbraco.Cms.Core.Strings; @@ -15,11 +16,22 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliver { private readonly HtmlLocalLinkParser _localLinkParser; private readonly HtmlUrlParser _urlParser; + private readonly IMarkdownToHtmlConverter _markdownToHtmlConverter; - public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser) + public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser, IMarkdownToHtmlConverter markdownToHtmlConverter) { _localLinkParser = localLinkParser; _urlParser = urlParser; + _markdownToHtmlConverter = markdownToHtmlConverter; + } + + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")] + public MarkdownEditorValueConverter(HtmlLocalLinkParser localLinkParser, HtmlUrlParser urlParser) + : this( + localLinkParser, + urlParser, + StaticServiceProvider.Instance.GetRequiredService()) + { } public override bool IsConverter(IPublishedPropertyType propertyType) @@ -49,10 +61,15 @@ public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType public override object ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) { - // convert markup to HTML for frontend rendering. - // source should come from ConvertSource and be a string (or null) already - var mark = new Markdown(); - return new HtmlEncodedString(inter == null ? string.Empty : mark.Transform((string)inter)); + // Convert markup to HTML for frontend rendering. + // Source should come from ConvertSource and be a string (or null) already. + if (inter is null) + { + return new HtmlEncodedString(string.Empty); + } + + var htmlString = _markdownToHtmlConverter.ToHtml((string)inter); + return new HtmlEncodedString(htmlString); } public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Element; diff --git a/src/Umbraco.Infrastructure/Strings/HeyRedMarkdownToHtmlConverter.cs b/src/Umbraco.Infrastructure/Strings/HeyRedMarkdownToHtmlConverter.cs new file mode 100644 index 000000000000..d6f9cc6a709d --- /dev/null +++ b/src/Umbraco.Infrastructure/Strings/HeyRedMarkdownToHtmlConverter.cs @@ -0,0 +1,18 @@ +using HeyRed.MarkdownSharp; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Infrastructure.Strings; + +// TODO (V19): Remove this class along with the HeyRed.MarkdownSharp library entirely (remove reference from Directory.props and .csproj, and remove from NOTICES.txt). + +/// +/// Implements a service that converts Markdown-formatted text to HTML using the HeyRed.MarkdownSharp library. +/// +[Obsolete("Uses the deprecated HeyRed.MarkdownSharp library which will continue to be provided for the lifetime of Umbraco 17 as the default implementation of IMarkdownToHtmlConverter. The default will be changed to MarkdigMarkdownToHtmlConverter for Umbraco 18. Scheduled for removal along with the HeyRed.MarkdownSharp library in Umbraco 19.")] +public class HeyRedMarkdownToHtmlConverter : IMarkdownToHtmlConverter +{ + private static readonly Markdown _markdownConverter = new(); + + /// + public string ToHtml(string markdown) => _markdownConverter.Transform(markdown); +} diff --git a/src/Umbraco.Infrastructure/Strings/MarkdigMarkdownToHtmlConverter.cs b/src/Umbraco.Infrastructure/Strings/MarkdigMarkdownToHtmlConverter.cs new file mode 100644 index 000000000000..c1f0162547ed --- /dev/null +++ b/src/Umbraco.Infrastructure/Strings/MarkdigMarkdownToHtmlConverter.cs @@ -0,0 +1,15 @@ +using Markdig; +using Umbraco.Cms.Core.Strings; + +namespace Umbraco.Cms.Infrastructure.Strings; + +/// +/// Implements a service that converts Markdown-formatted text to HTML using the Markdig library. +/// +public class MarkdigMarkdownToHtmlConverter : IMarkdownToHtmlConverter +{ + private static readonly MarkdownPipeline _markdownPipeline = new MarkdownPipelineBuilder().Build(); + + /// + public string ToHtml(string markdown) => Markdown.ToHtml(markdown, _markdownPipeline); +} diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index 06e73265fcaa..0c58eab632d1 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -1,4 +1,4 @@ - + Umbraco.Cms.Infrastructure Umbraco CMS - Infrastructure @@ -39,6 +39,7 @@ + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs index 081afae7ec5d..6cd3a3af0c7b 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/MarkdownEditorValueConverterTests.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Templates; +using Umbraco.Cms.Infrastructure.Strings; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -25,7 +26,9 @@ public void MarkdownEditorValueConverter_ConvertsValueToMarkdownString(object in { var linkParser = new HtmlLocalLinkParser(Mock.Of()); var urlParser = new HtmlUrlParser(Mock.Of>(), Mock.Of>(), Mock.Of(), Mock.Of()); - var valueConverter = new MarkdownEditorValueConverter(linkParser, urlParser); +#pragma warning disable CS0618 // Type or member is obsolete + var valueConverter = new MarkdownEditorValueConverter(linkParser, urlParser, new HeyRedMarkdownToHtmlConverter()); +#pragma warning restore CS0618 // Type or member is obsolete Assert.AreEqual(typeof(string), valueConverter.GetDeliveryApiPropertyValueType(Mock.Of())); var result = valueConverter.ConvertIntermediateToDeliveryApiObject(Mock.Of(), Mock.Of(), PropertyCacheLevel.Element, inter, false, false); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverterTests.cs new file mode 100644 index 000000000000..0c647697b4b5 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HealthChecks/MarkdownToHtmlConverterTests.cs @@ -0,0 +1,146 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core.HealthChecks; +using Umbraco.Cms.Infrastructure.HealthChecks; +using Umbraco.Cms.Infrastructure.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HealthChecks; + +[TestFixture] +public class MarkdownToHtmlConverterTests +{ + [Test] + public async Task ToHtml_WithSuccessResult_AppliesGreenHighlighting() + { + var checks = new List { new SuccessHealthCheck() }; + var results = await HealthCheckResults.Create(checks); + var converter = CreateConverter(); + + var html = converter.ToHtml(results, HealthCheckNotificationVerbosity.Summary); + + Assert.That(html, Does.Contain("Success")); + } + + [Test] + public async Task ToHtml_WithWarningResult_AppliesOrangeHighlighting() + { + var checks = new List { new WarningHealthCheck() }; + var results = await HealthCheckResults.Create(checks); + var converter = CreateConverter(); + + var html = converter.ToHtml(results, HealthCheckNotificationVerbosity.Summary); + + Assert.That(html, Does.Contain("Warning")); + } + + [Test] + public async Task ToHtml_WithErrorResult_AppliesRedHighlighting() + { + var checks = new List { new ErrorHealthCheck() }; + var results = await HealthCheckResults.Create(checks); + var converter = CreateConverter(); + + var html = converter.ToHtml(results, HealthCheckNotificationVerbosity.Summary); + + Assert.That(html, Does.Contain("Error")); + } + + [Test] + public async Task ToHtml_WithMixedResults_AppliesCorrectHighlightingForEachStatus() + { + var checks = new List + { + new SuccessHealthCheck(), + new WarningHealthCheck(), + new ErrorHealthCheck(), + }; + var results = await HealthCheckResults.Create(checks); + var converter = CreateConverter(); + + var html = converter.ToHtml(results, HealthCheckNotificationVerbosity.Summary); + + Assert.Multiple(() => + { + Assert.That(html, Does.Contain("Success"), "Success should have green highlighting"); + Assert.That(html, Does.Contain("Warning"), "Warning should have orange highlighting"); + Assert.That(html, Does.Contain("Error"), "Error should have red highlighting"); + }); + } + + [Test] + public async Task ToHtml_WithDetailedVerbosity_IncludesMessageInOutput() + { + var checks = new List { new SuccessHealthCheck() }; + var results = await HealthCheckResults.Create(checks); + var converter = CreateConverter(); + + var html = converter.ToHtml(results, HealthCheckNotificationVerbosity.Detailed); + + Assert.That(html, Does.Contain("Check passed")); + } + + [Test] + public async Task ToHtml_WithSummaryVerbosity_DoesNotIncludeSuccessMessageInOutput() + { + var checks = new List { new SuccessHealthCheck() }; + var results = await HealthCheckResults.Create(checks); + var converter = CreateConverter(); + + var html = converter.ToHtml(results, HealthCheckNotificationVerbosity.Summary); + + Assert.That(html, Does.Not.Contain("Check passed")); + } + + [HealthCheck("CFD6FC34-59C9-4402-B55F-C8BC96B628A1", "Stub check")] + public abstract class StubHealthCheck : HealthCheck + { + private readonly string _message; + private readonly StatusResultType _resultType; + + protected StubHealthCheck(StatusResultType resultType, string message) + { + _resultType = resultType; + _message = message; + } + + public override HealthCheckStatus ExecuteAction(HealthCheckAction action) => + throw new NotImplementedException(); + + public override Task> GetStatusAsync() => + Task.FromResult>([new(_message) { ResultType = _resultType }]); + } + + [HealthCheck("CFD6FC34-59C9-4402-B55F-C8BC96B628A1", "Success Check")] + public class SuccessHealthCheck : StubHealthCheck + { + public SuccessHealthCheck() + : base(StatusResultType.Success, "Check passed") + { + } + } + + [HealthCheck("CFD6FC34-59C9-4402-B55F-C8BC96B628A2", "Warning Check")] + public class WarningHealthCheck : StubHealthCheck + { + public WarningHealthCheck() + : base(StatusResultType.Warning, "Check has warnings") + { + } + } + + [HealthCheck("CFD6FC34-59C9-4402-B55F-C8BC96B628A3", "Error Check")] + public class ErrorHealthCheck : StubHealthCheck + { + public ErrorHealthCheck() + : base(StatusResultType.Error, "Check failed") + { + } + } + +#pragma warning disable CS0618 // Type or member is obsolete + private static MarkdownToHtmlConverter CreateConverter() => + new(new HeyRedMarkdownToHtmlConverter()); +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Strings/MarkdownToHtmlConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Strings/MarkdownToHtmlConverterTests.cs new file mode 100644 index 000000000000..cc61073c5d43 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Strings/MarkdownToHtmlConverterTests.cs @@ -0,0 +1,75 @@ +using System.Text.RegularExpressions; +using NUnit.Framework; +using Umbraco.Cms.Infrastructure.Strings; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Strings; + +[TestFixture] +public partial class MarkdownToHtmlConverterTests +{ + private const string MarkdownSample = """ + # Heading + + This is **bold** and *italic* text. + + - Item one + - Item two + + ``` + var x = 1; + ``` + """; + + [Test] + public void MarkdigMarkdownToHtmlConverter_ToHtml_ConvertsMarkdownToHtml() + { + var converter = new MarkdigMarkdownToHtmlConverter(); + + var result = converter.ToHtml(MarkdownSample); + + const string ExpectedHtml = """ +

Heading

+

This is bold and italic text.

+
    +
  • Item one
  • +
  • Item two
  • +
+
var x = 1;
+ """; + + Assert.That(NormalizeHtml(result), Is.EqualTo(NormalizeHtml(ExpectedHtml))); + } + +#pragma warning disable CS0618 // Type or member is obsolete + [Test] + public void HeyRedMarkdownToHtmlConverter_ToHtml_ConvertsMarkdownToHtml() + { + var converter = new HeyRedMarkdownToHtmlConverter(); + + var result = converter.ToHtml(MarkdownSample); + + const string ExpectedHtml = """ +

Heading

+

This is bold and italic text.

+
    +
  • Item one
  • +
  • Item two
  • +
+

var x = 1;

+ """; + + Assert.That(NormalizeHtml(result), Is.EqualTo(NormalizeHtml(ExpectedHtml))); + } +#pragma warning restore CS0618 // Type or member is obsolete + + private static string NormalizeHtml(string html) => + WhitespaceBeforeTagRegex().Replace( + WhitespaceAfterTagRegex().Replace(html, ">"), + "<").Trim(); + + [GeneratedRegex(@">\s+")] + private static partial Regex WhitespaceAfterTagRegex(); + + [GeneratedRegex(@"\s+<")] + private static partial Regex WhitespaceBeforeTagRegex(); +}