From 8ff11e7c6457b7a462bba8b373122d0a8bb409ac Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 22 Sep 2025 11:34:08 +0200 Subject: [PATCH] Link rendering: Add support for `UrlMode` parameter in `HtmlLocalLinkParser` (port to 16) (#20207) * Add support for UrlMode parameter in HtmlLocalLinkParser (port of #20200 from 13 to 16). * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Kenn Jacobsen --- .../TextStringValueConverter.cs | 2 +- .../Templates/HtmlLocalLinkParser.cs | 20 +- .../MarkdownEditorValueConverter.cs | 2 +- .../RteBlockRenderingValueConverter.cs | 2 +- .../Templates/HtmlLocalLinkParserTests.cs | 241 ++++++++++++++++-- 5 files changed, 233 insertions(+), 34 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs index 8fe15645e1..0a290e7492 100644 --- a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs @@ -40,7 +40,7 @@ public class TextStringValueConverter : PropertyValueConverterBase, IDeliveryApi var sourceString = source.ToString(); // ensures string is parsed for {localLink} and URLs are resolved correctly - sourceString = _linkParser.EnsureInternalLinks(sourceString!, preview); + sourceString = _linkParser.EnsureInternalLinks(sourceString!); sourceString = _urlParser.EnsureUrls(sourceString); return sourceString; diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs index 4714ebcd2e..73aec2e74d 100644 --- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs +++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.RegularExpressions; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; namespace Umbraco.Cms.Core.Templates; @@ -45,17 +46,18 @@ public sealed class HtmlLocalLinkParser /// /// Parses the string looking for the {localLink} syntax and updates them to their correct links. /// - /// - /// - /// + [Obsolete("This method overload is no longer used in Umbraco and delegates to the overload without the preview parameter. Scheduled for removal in Umbraco 18.")] public string EnsureInternalLinks(string text, bool preview) => EnsureInternalLinks(text); /// /// Parses the string looking for the {localLink} syntax and updates them to their correct links. /// - /// - /// - public string EnsureInternalLinks(string text) + public string EnsureInternalLinks(string text) => EnsureInternalLinks(text, UrlMode.Default); + + /// + /// Parses the string looking for the {localLink} syntax and updates them to their correct links. + /// + public string EnsureInternalLinks(string text, UrlMode urlMode) { foreach (LocalLinkTag tagData in FindLocalLinkIds(text)) { @@ -63,8 +65,8 @@ public sealed class HtmlLocalLinkParser { var newLink = tagData.Udi?.EntityType switch { - Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid), - Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid), + Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid, urlMode), + Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid, urlMode), _ => string.Empty, }; @@ -73,7 +75,7 @@ public sealed class HtmlLocalLinkParser } else if (tagData.IntId.HasValue) { - var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value); + var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value, urlMode); text = text.Replace(tagData.TagHref, newLink); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs index 05c6a8a4f1..ff0962a827 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs @@ -41,7 +41,7 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliver var sourceString = source.ToString()!; // ensures string is parsed for {localLink} and URLs are resolved correctly - sourceString = _localLinkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _localLinkParser.EnsureInternalLinks(sourceString); sourceString = _urlParser.EnsureUrls(sourceString); return sourceString; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs index b2c47fc3cb..d39d13e243 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs @@ -135,7 +135,7 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe var sourceString = intermediateValue.Markup; // ensures string is parsed for {localLink} and URLs and media are resolved correctly - sourceString = _linkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _linkParser.EnsureInternalLinks(sourceString); sourceString = _urlParser.EnsureUrls(sourceString); sourceString = _imageSourceParser.EnsureImageSources(sourceString); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs index d1e5e0f494..0aa00b48d6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Templates; +using Umbraco.Cms.Core.Web; using Umbraco.Cms.Tests.Common; using Umbraco.Cms.Tests.UnitTests.TestHelpers.Objects; @@ -216,18 +217,204 @@ public class HtmlLocalLinkParserTests var umbracoContextAccessor = new TestUmbracoContextAccessor(); + var umbracoContextFactory = TestUmbracoContextFactory.Create( + umbracoContextAccessor: umbracoContextAccessor); + + using (var reference = umbracoContextFactory.EnsureUmbracoContext()) + { + var contentCache = Mock.Get(reference.UmbracoContext.Content); + contentCache.Setup(x => x.GetById(It.IsAny())).Returns(publishedContent.Object); + contentCache.Setup(x => x.GetById(It.IsAny())).Returns(publishedContent.Object); + + var mediaCache = Mock.Get(reference.UmbracoContext.Media); + mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); + mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); + + var publishedUrlProvider = CreatePublishedUrlProvider( + contentUrlProvider, + mediaUrlProvider, + umbracoContextAccessor); + + var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); + + var output = linkParser.EnsureInternalLinks(input); + + Assert.AreEqual(result, output); + } + } + + [Test] + public void ParseLocalLinks_WithUrlMode_RespectsUrlMode() + { + // Arrange + var input = "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world"; + + // Setup content URL provider that returns different URLs based on UrlMode + var contentUrlProvider = new Mock(); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Relative, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Absolute, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("http://example.com/absolute-url")); + + var contentType = new PublishedContentType( + Guid.NewGuid(), + 666, + "alias", + PublishedItemType.Content, + Enumerable.Empty(), + Enumerable.Empty(), + ContentVariation.Nothing); + var publishedContent = new Mock(); + publishedContent.Setup(x => x.Id).Returns(1234); + publishedContent.Setup(x => x.ContentType).Returns(contentType); + + var umbracoContextAccessor = new TestUmbracoContextAccessor(); var umbracoContextFactory = TestUmbracoContextFactory.Create( umbracoContextAccessor: umbracoContextAccessor); var webRoutingSettings = new WebRoutingSettings(); - var navigationQueryService = new Mock(); - // Guid? parentKey = null; - // navigationQueryService.Setup(x => x.TryGetParentKey(It.IsAny(), out parentKey)).Returns(true); - IEnumerable ancestorKeys = []; - navigationQueryService.Setup(x => x.TryGetAncestorsKeys(It.IsAny(), out ancestorKeys)).Returns(true); + var publishedUrlProvider = CreatePublishedUrlProvider( + contentUrlProvider, + new Mock(), + umbracoContextAccessor); - var publishedContentStatusFilteringService = new Mock(); + using (var reference = umbracoContextFactory.EnsureUmbracoContext()) + { + var contentCache = Mock.Get(reference.UmbracoContext.Content); + contentCache.Setup(x => x.GetById(It.IsAny())).Returns(publishedContent.Object); + + var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); + + // Act + var relativeOutput = linkParser.EnsureInternalLinks(input, UrlMode.Relative); + var absoluteOutput = linkParser.EnsureInternalLinks(input, UrlMode.Absolute); + + // Assert + Assert.AreEqual("hello href=\"/relative-url\" world", relativeOutput); + Assert.AreEqual("hello href=\"http://example.com/absolute-url\" world", absoluteOutput); + } + } + + [TestCase(UrlMode.Default, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Relative, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Absolute, "hello href=\"{localLink:1234}\" world ", "hello href=\"https://example.com/absolute-url\" world ")] + [TestCase(UrlMode.Auto, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Default, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/absolute-url\" world ")] + [TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Default, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")] + [TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")] + [TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/media/absolute/image.jpg\" world ")] + [TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")] + public void ParseLocalLinks_WithVariousUrlModes_ReturnsCorrectUrls(UrlMode urlMode, string input, string expectedResult) + { + // Setup content URL provider that returns different URLs based on UrlMode + var contentUrlProvider = new Mock(); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Default, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Relative, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Absolute, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("https://example.com/absolute-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Auto, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + + var contentType = new PublishedContentType( + Guid.NewGuid(), + 666, + "alias", + PublishedItemType.Content, + Enumerable.Empty(), + Enumerable.Empty(), + ContentVariation.Nothing); + var publishedContent = new Mock(); + publishedContent.Setup(x => x.Id).Returns(1234); + publishedContent.Setup(x => x.ContentType).Returns(contentType); + + // Setup media URL provider that returns different URLs based on UrlMode + var mediaUrlProvider = new Mock(); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Default, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/media/relative/image.jpg")); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Relative, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/media/relative/image.jpg")); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Absolute, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("https://example.com/media/absolute/image.jpg")); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Auto, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/media/relative/image.jpg")); + + var mediaType = new PublishedContentType( + Guid.NewGuid(), + 777, + "image", + PublishedItemType.Media, + Enumerable.Empty(), + Enumerable.Empty(), + ContentVariation.Nothing); + var media = new Mock(); + media.Setup(x => x.ContentType).Returns(mediaType); + + var umbracoContextAccessor = new TestUmbracoContextAccessor(); + var umbracoContextFactory = TestUmbracoContextFactory.Create( + umbracoContextAccessor: umbracoContextAccessor); + + var webRoutingSettings = new WebRoutingSettings(); + + var publishedUrlProvider = CreatePublishedUrlProvider( + contentUrlProvider, + mediaUrlProvider, + umbracoContextAccessor); using (var reference = umbracoContextFactory.EnsureUmbracoContext()) { @@ -239,25 +426,35 @@ public class HtmlLocalLinkParserTests mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); - var publishStatusQueryService = new Mock(); - publishStatusQueryService - .Setup(x => x.IsDocumentPublished(It.IsAny(), It.IsAny())) - .Returns(true); - - var publishedUrlProvider = new UrlProvider( - umbracoContextAccessor, - Options.Create(webRoutingSettings), - new UrlProviderCollection(() => new[] { contentUrlProvider.Object }), - new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }), - Mock.Of(), - navigationQueryService.Object, - publishedContentStatusFilteringService.Object); - var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); - var output = linkParser.EnsureInternalLinks(input); + var output = linkParser.EnsureInternalLinks(input, urlMode); - Assert.AreEqual(result, output); + Assert.AreEqual(expectedResult, output); } } + + private static UrlProvider CreatePublishedUrlProvider( + Mock contentUrlProvider, + Mock mediaUrlProvider, + TestUmbracoContextAccessor umbracoContextAccessor) + { + var navigationQueryService = new Mock(); + IEnumerable ancestorKeys = []; + navigationQueryService.Setup(x => x.TryGetAncestorsKeys(It.IsAny(), out ancestorKeys)).Returns(true); + + var publishStatusQueryService = new Mock(); + publishStatusQueryService + .Setup(x => x.IsDocumentPublished(It.IsAny(), It.IsAny())) + .Returns(true); + + return new UrlProvider( + umbracoContextAccessor, + Options.Create(new WebRoutingSettings()), + new UrlProviderCollection(() => new[] { contentUrlProvider.Object }), + new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }), + Mock.Of(), + navigationQueryService.Object, + new Mock().Object); + } }