From ef1aaf8bcea3fb8144c2b82959678fa401b1ea58 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 16 Sep 2025 11:33:40 +0200 Subject: [PATCH] Cherry-pick #20142 for V16 (#20147) * Support querystring and anchor for local links in Delivery API output (#20142) * Support querystring and anchor for local links in Delivery API output * Add default implementation for backwards compat * Add default implementation for backwards compat (also on the interface) * Fix default implementation * Add extra tests proving that querystring/postfix can be handled for local links in both legacy and current format. --- .../Models/DeliveryApi/ApiContentRoute.cs | 2 + .../Models/DeliveryApi/IApiContentRoute.cs | 5 ++ .../DeliveryApi/ApiRichTextMarkupParser.cs | 2 +- .../DeliveryApi/ApiRichTextParserBase.cs | 7 +- .../DeliveryApi/OpenApiContractTest.cs | 4 ++ .../DeliveryApi/RichTextParserTests.cs | 68 ++++++++++++++++++- .../ApiRichTextMarkupParserTests.cs | 39 +++++++++++ 7 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs b/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs index 45fcaaba4b..f5d56fbf2e 100644 --- a/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs +++ b/src/Umbraco.Core/Models/DeliveryApi/ApiContentRoute.cs @@ -10,5 +10,7 @@ public sealed class ApiContentRoute : IApiContentRoute public string Path { get; } + public string? QueryString { get; set; } + public IApiContentStartItem StartItem { get; } } diff --git a/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs b/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs index 1cc2b36b9d..cfc0b2984a 100644 --- a/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs +++ b/src/Umbraco.Core/Models/DeliveryApi/IApiContentRoute.cs @@ -4,5 +4,10 @@ public interface IApiContentRoute { string Path { get; } + public string? QueryString + { + get => null; set { } + } + IApiContentStartItem StartItem { get; } } diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs index 42418146db..3712a4b6c5 100644 --- a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParser.cs @@ -60,7 +60,7 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT link.GetAttributeValue("type", "unknown"), route => { - link.SetAttributeValue("href", route.Path); + link.SetAttributeValue("href", $"{route.Path}{route.QueryString}"); link.SetAttributeValue("data-start-item-path", route.StartItem.Path); link.SetAttributeValue("data-start-item-id", route.StartItem.Id.ToString("D")); link.Attributes["type"]?.Remove(); diff --git a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs index 5ba7b37171..d1cf1b5b3a 100644 --- a/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs +++ b/src/Umbraco.Infrastructure/DeliveryApi/ApiRichTextParserBase.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.DeliveryApi; @@ -58,6 +59,7 @@ internal abstract partial class ApiRichTextParserBase : null; if (route != null) { + route.QueryString = match.Groups["query"].Value.NullOrWhiteSpaceAsNull(); handleContentRoute(route); return ReplaceStatus.Success; } @@ -105,6 +107,7 @@ internal abstract partial class ApiRichTextParserBase : null; if (route != null) { + route.QueryString = match.Groups["query"].Value.NullOrWhiteSpaceAsNull(); handleContentRoute(route); return ReplaceStatus.Success; } @@ -140,10 +143,10 @@ internal abstract partial class ApiRichTextParserBase handleMediaUrl(_apiMediaUrlProvider.GetUrl(media)); } - [GeneratedRegex("{localLink:(?umb:.+)}")] + [GeneratedRegex("{localLink:(?umb:.+)}(?[^\"]*)")] private static partial Regex LegacyLocalLinkRegex(); - [GeneratedRegex("{localLink:(?.+)}")] + [GeneratedRegex("{localLink:(?.+)}(?[^\"]*)")] private static partial Regex LocalLinkRegex(); private enum ReplaceStatus diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs index da1c8aa88e..3514f32d2b 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs @@ -1320,6 +1320,10 @@ internal sealed class OpenApiContractTest : UmbracoTestServerTestBase "path": { "type": "string" }, + "queryString": { + "type": "string", + "nullable": true + }, "startItem": { "oneOf": [ { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs index f113c9a998..2acab6cace 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/RichTextParserTests.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Extensions; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; @@ -127,12 +128,15 @@ public class RichTextParserTests : PropertyValueConverterTests Assert.AreEqual("the original something", span.Attributes.First().Value); } - [Test] - public void ParseElement_CanParseContentLink() + [TestCase(null)] + [TestCase("")] + [TestCase("#some-anchor")] + [TestCase("?something=true")] + public void ParseElement_CanParseContentLink(string? postfix) { var parser = CreateRichTextElementParser(); - var element = parser.Parse($"

", RichTextBlockModel.Empty) as RichTextRootElement; + var element = parser.Parse($"

", RichTextBlockModel.Empty) as RichTextRootElement; Assert.IsNotNull(element); var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; Assert.IsNotNull(link); @@ -142,6 +146,7 @@ public class RichTextParserTests : PropertyValueConverterTests var route = link.Attributes.First().Value as IApiContentRoute; Assert.IsNotNull(route); Assert.AreEqual("/some-content-path", route.Path); + Assert.AreEqual(postfix.NullOrWhiteSpaceAsNull(), route.QueryString); Assert.AreEqual(_contentRootKey, route.StartItem.Id); Assert.AreEqual("the-root-path", route.StartItem.Path); } @@ -176,6 +181,22 @@ public class RichTextParserTests : PropertyValueConverterTests Assert.AreEqual("https://some.where/else/", link.Attributes.First().Value); } + [TestCase("#some-anchor")] + [TestCase("?something=true")] + public void ParseElement_CanHandleNonLocalLink_WithPostfix(string postfix) + { + var parser = CreateRichTextElementParser(); + + var element = parser.Parse($"

", RichTextBlockModel.Empty) as RichTextRootElement; + Assert.IsNotNull(element); + var link = element.Elements.OfType().Single().Elements.Single() as RichTextGenericElement; + Assert.IsNotNull(link); + Assert.AreEqual("a", link.Tag); + Assert.AreEqual(1, link.Attributes.Count); + Assert.AreEqual("href", link.Attributes.First().Key); + Assert.AreEqual($"https://some.where/else/{postfix}", link.Attributes.First().Value); + } + [Test] public void ParseElement_LinkTextIsWrappedInTextElement() { @@ -459,12 +480,51 @@ public class RichTextParserTests : PropertyValueConverterTests { var parser = CreateRichTextMarkupParser(); + var result = parser.Parse($"

"); + Assert.IsTrue(result.Contains("href=\"/some-content-path\"")); + Assert.IsTrue(result.Contains("data-start-item-path=\"the-root-path\"")); + Assert.IsTrue(result.Contains($"data-start-item-id=\"{_contentRootKey:D}\"")); + } + + [Test] + public void ParseMarkup_CanParseLegacyContentLink() + { + var parser = CreateRichTextMarkupParser(); + var result = parser.Parse($"

"); Assert.IsTrue(result.Contains("href=\"/some-content-path\"")); Assert.IsTrue(result.Contains("data-start-item-path=\"the-root-path\"")); Assert.IsTrue(result.Contains($"data-start-item-id=\"{_contentRootKey:D}\"")); } + [TestCase("#some-anchor")] + [TestCase("?something=true")] + [TestCase("#!some-hashbang")] + [TestCase("?something=true#some-anchor")] + public void ParseMarkup_CanParseContentLink_WithPostfix(string postfix) + { + var parser = CreateRichTextMarkupParser(); + + var result = parser.Parse($"

"); + Assert.IsTrue(result.Contains($"href=\"/some-content-path{postfix}\"")); + Assert.IsTrue(result.Contains("data-start-item-path=\"the-root-path\"")); + Assert.IsTrue(result.Contains($"data-start-item-id=\"{_contentRootKey:D}\"")); + } + + [TestCase("#some-anchor")] + [TestCase("?something=true")] + [TestCase("#!some-hashbang")] + [TestCase("?something=true#some-anchor")] + public void ParseMarkup_CanParseLegacyContentLink_WithPostfix(string postfix) + { + var parser = CreateRichTextMarkupParser(); + + var result = parser.Parse($"

"); + Assert.IsTrue(result.Contains($"href=\"/some-content-path{postfix}\"")); + Assert.IsTrue(result.Contains("data-start-item-path=\"the-root-path\"")); + Assert.IsTrue(result.Contains($"data-start-item-id=\"{_contentRootKey:D}\"")); + } + [Test] public void ParseMarkup_CanParseMediaLink() { @@ -485,6 +545,8 @@ public class RichTextParserTests : PropertyValueConverterTests } [TestCase("

")] + [TestCase("

")] + [TestCase("

")] [TestCase("

")] public void ParseMarkup_CanHandleNonLocalReferences(string html) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParserTests.cs index 04c6df0882..0ab8eb48cd 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/DeliveryApi/ApiRichTextMarkupParserTests.cs @@ -79,6 +79,45 @@ public class ApiRichTextMarkupParserTests Assert.AreEqual(expectedOutput, parsedHtml); } + [TestCase("#some-anchor")] + [TestCase("?something=true")] + [TestCase("#!some-hashbang")] + [TestCase("?something=true#some-anchor")] + public void Can_Parse_LocalLinks_With_Postfix(string postfix) + { + var key1 = Guid.Parse("eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f"); + var data1 = new MockData() + .WithKey(key1) + .WithRoutePath($"/self/{postfix}") + .WithRouteStartPath("self"); + + var key2 = Guid.Parse("cc143afe-4cbf-46e5-b399-c9f451384373"); + var data2 = new MockData() + .WithKey(key2) + .WithRoutePath($"/other/{postfix}") + .WithRouteStartPath("other"); + + var mockData = new Dictionary + { + { key1, data1 }, + { key2, data2 }, + }; + + var parser = BuildDefaultSut(mockData); + + var html = + $@"

Rich text outside of the blocks with a link to itself

+

and to the other page

"; + + var expectedOutput = + $@"

Rich text outside of the blocks with a link to itself

+

and to the other page

"; + + var parsedHtml = parser.Parse(html); + + Assert.AreEqual(expectedOutput, parsedHtml); + } + [Test] public void Can_Parse_Inline_LocalImages() {