diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
index dc0fbf281d..ba5d1a844c 100644
--- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
+++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
@@ -14,11 +14,15 @@ public sealed class HtmlLocalLinkParser
// media
// other page
internal static readonly Regex LocalLinkTagPattern = new(
- @"document|media)['""].*?(?href=[""']/{localLink:(?[a-fA-F0-9-]+)})[""'])|((?href=[""']/{localLink:(?[a-fA-F0-9-]+)})[""'].*?type=(['""])(?document|media)(?:['""])))|(?:(?:type=['""](?document|media)['""])|(?:(?href=[""']/{localLink:[a-fA-F0-9-]+})[""'])))[^>]*>",
+ @"\/?{localLink:(?[a-fA-F0-9-]+)})[^>]*?>",
+ RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline);
+
+ internal static readonly Regex TypePattern = new(
+ """type=['"](?(?:media|document))['"]""",
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
internal static readonly Regex LocalLinkPattern = new(
- @"href=""[/]?(?:\{|\%7B)localLink:([a-zA-Z0-9-://]+)(?:\}|\%7D)",
+ @"href=['""](?\/?(?:\{|\%7B)localLink:(?[a-zA-Z0-9-://]+)(?:\}|\%7D))",
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
private readonly IPublishedUrlProvider _publishedUrlProvider;
@@ -58,24 +62,20 @@ public sealed class HtmlLocalLinkParser
{
if (tagData.Udi is not null)
{
- var newLink = "#";
- if (tagData.Udi?.EntityType == Constants.UdiEntityType.Document)
+ var newLink = tagData.Udi?.EntityType switch
{
- newLink = _publishedUrlProvider.GetUrl(tagData.Udi.Guid);
- }
- else if (tagData.Udi?.EntityType == Constants.UdiEntityType.Media)
- {
- newLink = _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid);
- }
-
+ Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid),
+ Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid),
+ _ => ""
+ };
text = StripTypeAttributeFromTag(text, tagData.Udi!.EntityType);
- text = text.Replace(tagData.TagHref, "href=\"" + newLink);
+ text = text.Replace(tagData.TagHref, newLink);
}
else if (tagData.IntId.HasValue)
{
var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value);
- text = text.Replace(tagData.TagHref, "href=\"" + newLink);
+ text = text.Replace(tagData.TagHref, newLink);
}
}
@@ -83,7 +83,7 @@ public sealed class HtmlLocalLinkParser
}
// under normal circumstances, the type attribute is preceded by a space
- // to cover the rare occasion where it isn't, we first replace with a a space and then without.
+ // to cover the rare occasion where it isn't, we first replace with a space and then without.
private string StripTypeAttributeFromTag(string tag, string type) =>
tag.Replace($" type=\"{type}\"", string.Empty)
.Replace($"type=\"{type}\"", string.Empty);
@@ -93,21 +93,22 @@ public sealed class HtmlLocalLinkParser
MatchCollection localLinkTagMatches = LocalLinkTagPattern.Matches(text);
foreach (Match linkTag in localLinkTagMatches)
{
- if (linkTag.Groups.Count < 1)
+ if (Guid.TryParse(linkTag.Groups["guid"].Value, out Guid guid) is false)
{
continue;
}
- if (Guid.TryParse(linkTag.Groups["guid"].Value, out Guid guid) is false)
+ // Find the type attribute
+ Match typeMatch = TypePattern.Match(linkTag.Value);
+ if (typeMatch.Success is false)
{
continue;
}
yield return new LocalLinkTag(
null,
- new GuidUdi(linkTag.Groups["type"].Value, guid),
- linkTag.Groups["locallink"].Value,
- linkTag.Value);
+ new GuidUdi(typeMatch.Groups["type"].Value, guid),
+ linkTag.Groups["locallink"].Value);
}
// also return legacy results for values that have not been migrated
@@ -124,25 +125,26 @@ public sealed class HtmlLocalLinkParser
MatchCollection tags = LocalLinkPattern.Matches(text);
foreach (Match tag in tags)
{
- if (tag.Groups.Count > 0)
+ if (tag.Groups.Count <= 0)
{
- var id = tag.Groups[1].Value; // .Remove(tag.Groups[1].Value.Length - 1, 1);
+ continue;
+ }
- // The id could be an int or a UDI
- if (UdiParser.TryParse(id, out Udi? udi))
- {
- var guidUdi = udi as GuidUdi;
- if (guidUdi is not null)
- {
- yield return new LocalLinkTag(null, guidUdi, tag.Value, null);
- }
- }
+ var id = tag.Groups["guid"].Value;
- if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId))
+ // The id could be an int or a UDI
+ if (UdiParser.TryParse(id, out Udi? udi))
+ {
+ if (udi is GuidUdi guidUdi)
{
- yield return new LocalLinkTag (intId, null, tag.Value, null);
+ yield return new LocalLinkTag(null, guidUdi, tag.Groups["locallink"].Value);
}
}
+
+ if (int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intId))
+ {
+ yield return new LocalLinkTag (intId, null, tag.Groups["locallink"].Value);
+ }
}
}
@@ -155,20 +157,10 @@ public sealed class HtmlLocalLinkParser
TagHref = tagHref;
}
- public LocalLinkTag(int? intId, GuidUdi? udi, string tagHref, string? fullTag)
- {
- IntId = intId;
- Udi = udi;
- TagHref = tagHref;
- FullTag = fullTag;
- }
-
public int? IntId { get; }
public GuidUdi? Udi { get; }
public string TagHref { get; }
-
- public string? FullTag { get; }
}
}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs
index 298cf5ddc4..5a35a096ee 100644
--- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs
@@ -117,6 +117,34 @@ public class HtmlLocalLinkParserTests
[TestCase(
"world",
"world")]
+ [TestCase(
+ "world
world
",
+ "world
world
")]
+
+ // attributes order should not matter
+ [TestCase(
+ "world",
+ "world")]
+ [TestCase(
+ "world",
+ "world")]
+ [TestCase(
+ "world",
+ "world")]
+
+ // anchors and query strings
+ [TestCase(
+ "world",
+ "world")]
+ [TestCase(
+ "world",
+ "world")]
+
+ // custom type ignored
+ [TestCase(
+ "world",
+ "world")]
+
// legacy
[TestCase(
"hello href=\"{localLink:1234}\" world ",
@@ -127,9 +155,15 @@ public class HtmlLocalLinkParserTests
[TestCase(
"hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ",
"hello href=\"/my-test-url\" world ")]
+ [TestCase(
+ "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}#anchor\" world ",
+ "hello href=\"/my-test-url#anchor\" world ")]
[TestCase(
"hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ",
"hello href=\"/media/1001/my-image.jpg\" world ")]
+ [TestCase(
+ "hello href='{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}' world ",
+ "hello href='/media/1001/my-image.jpg' world ")]
// This one has an invalid char so won't match.
[TestCase(
@@ -137,7 +171,7 @@ public class HtmlLocalLinkParserTests
"hello href=\"{localLink:umb^://document/9931BDE0-AAC3-4BAB-B838-909A7B47570E}\" world ")]
[TestCase(
"hello href=\"{localLink:umb://document-type/9931BDE0-AAC3-4BAB-B838-909A7B47570E}\" world ",
- "hello href=\"#\" world ")]
+ "hello href=\"\" world ")]
public void ParseLocalLinks(string input, string result)
{
// setup a mock URL provider which we'll use for testing