diff --git a/src/Sitecore.AspNetCore.SDK.RenderingEngine/Extensions/SitecoreFieldExtensions.cs b/src/Sitecore.AspNetCore.SDK.RenderingEngine/Extensions/SitecoreFieldExtensions.cs index 710e258..2cc16ff 100644 --- a/src/Sitecore.AspNetCore.SDK.RenderingEngine/Extensions/SitecoreFieldExtensions.cs +++ b/src/Sitecore.AspNetCore.SDK.RenderingEngine/Extensions/SitecoreFieldExtensions.cs @@ -1,4 +1,6 @@ -using System.Text.RegularExpressions; +using System.Collections.Specialized; +using System.Text.RegularExpressions; +using System.Web; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.WebUtilities; using Sitecore.AspNetCore.SDK.LayoutService.Client.Response.Model.Fields; @@ -21,12 +23,85 @@ public static partial class SitecoreFieldExtensions ArgumentNullException.ThrowIfNull(imageField); string? urlStr = imageField.Value.Src; - if (urlStr == null) + string? result = null; + if (urlStr != null) { - return null; + result = GetSitecoreMediaUri(urlStr, imageParams); } - return GetSitecoreMediaUri(urlStr, imageParams); + return result; + } + + /// + /// Gets modified URL string to Sitecore media item for srcSet. + /// This method preserves existing URL parameters and merges them with new ones. + /// + /// The image field. + /// Base image parameters. + /// SrcSet specific parameters that override imageParams. + /// Media item URL. + public static string? GetMediaLinkForSrcSet(this ImageField imageField, object? imageParams, object? srcSetParams) + { + ArgumentNullException.ThrowIfNull(imageField); + string? urlStr = imageField.Value.Src; + + string? result = null; + if (urlStr != null) + { + Dictionary mergedParams = MergeParameters(imageParams, srcSetParams); + result = GetSitecoreMediaUriWithPreservation(urlStr, mergedParams); + } + + return result; + } + + /// + /// Merges base parameters with override parameters. + /// + /// Base image parameters. + /// SrcSet specific parameters that take precedence. + /// Merged parameters as dictionary. + private static Dictionary MergeParameters(object? imageParams, object? srcSetParams) + { + Dictionary result = new(StringComparer.OrdinalIgnoreCase); + + // Add base parameters first + AddParametersToResult(result, imageParams); + + // Override with srcSet parameters + AddParametersToResult(result, srcSetParams); + + return result; + } + + /// + /// Adds parameters from an object to the result dictionary. + /// + /// The result dictionary to add parameters to. + /// The parameters object (can be Dictionary or any object with properties). + /// Whether to skip null values when adding parameters. + private static void AddParametersToResult(Dictionary result, object? parameters, bool skipNullValues = false) + { + switch (parameters) + { + case null: + break; + case Dictionary paramDict: + foreach (KeyValuePair kvp in paramDict.Where(kvp => !skipNullValues || kvp.Value != null)) + { + result[kvp.Key] = kvp.Value; + } + + break; + default: + RouteValueDictionary routeValues = new(parameters); + foreach (KeyValuePair kvp in routeValues.Where(kvp => !skipNullValues || kvp.Value != null)) + { + result[kvp.Key] = kvp.Value; + } + + break; + } } /// @@ -53,6 +128,129 @@ private static string GetSitecoreMediaUri(string url, object? imageParams) } } + return ApplyJssMediaUrlPrefix(url); + } + + /// + /// Gets modified URL string to Sitecore media item with parameter preservation. + /// This method preserves existing URL parameters and merges them with new ones. + /// + /// The URL string. + /// Parameters to merge. + /// Modified URL string. + private static string? GetSitecoreMediaUriWithPreservation(string? urlStr, object? parameters) + { + string? url; + + if (string.IsNullOrEmpty(urlStr)) + { + url = urlStr; + } + else + { + // Parse existing query parameters and build merged parameters dictionary + Dictionary mergedParams = new(StringComparer.OrdinalIgnoreCase); + + if (!Uri.TryCreate(urlStr, UriKind.RelativeOrAbsolute, out Uri? uri)) + { + url = urlStr; + } + else + { + url = ParseUrlParams(uri, mergedParams); + + // Add new parameters (these will override existing ones) + AddParametersToResult(mergedParams, parameters, skipNullValues: true); + + // Add query parameters + foreach (KeyValuePair kvp in mergedParams) + { + if (kvp.Value != null) + { + url = QueryHelpers.AddQueryString(url, kvp.Key, kvp.Value.ToString() ?? string.Empty); + } + } + } + } + + string? result = url == null ? null : ApplyJssMediaUrlPrefix(url); + return result; + } + + /// + /// Parses URL query string parameters and adds them to the provided dictionary. + /// + /// The Uri with potential query parameters. + /// The dictionary to add parsed parameters to. + /// The URL without query parameters. + private static string ParseUrlParams(Uri? uri, Dictionary parameters) + { + string result; + + if (uri == null) + { + result = string.Empty; + } + else if (uri.IsAbsoluteUri) + { + result = ParseAbsoluteUriParams(uri, parameters); + } + else + { + result = ParseRelativeUriParams(uri, parameters); + } + + return result; + } + + private static string ParseAbsoluteUriParams(Uri uri, Dictionary parameters) + { + string url = $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}"; + NameValueCollection queryParams = HttpUtility.ParseQueryString(uri.Query); + foreach (string? param in queryParams.AllKeys) + { + if (!string.IsNullOrEmpty(param)) + { + parameters[param] = queryParams[param]; + } + } + + return url; + } + + private static string ParseRelativeUriParams(Uri uri, Dictionary parameters) + { + // For relative URIs, accessing Uri.Query throws InvalidOperationException, so we use string manipulation + string original = uri.OriginalString; + int queryIndex = original.IndexOf('?'); + string result; + + if (queryIndex >= 0) + { + string query = original[queryIndex..]; + Dictionary parsedQuery = QueryHelpers.ParseQuery(query); + foreach (KeyValuePair kvp in parsedQuery) + { + parameters[kvp.Key] = kvp.Value.Count > 0 ? kvp.Value[0] : null; + } + + result = original[..queryIndex]; + } + else + { + result = original; + } + + return result; + } + + /// + /// Applies JSS media URL prefix replacement to the given URL. + /// + /// The URL to transform. + /// The URL with JSS media prefix applied if applicable. + private static string ApplyJssMediaUrlPrefix(string url) + { // TODO Review hardcoded matching and replacement Match match = MediaUrlPrefixRegex().Match(url); if (match.Success) diff --git a/src/Sitecore.AspNetCore.SDK.RenderingEngine/TagHelpers/Fields/ImageTagHelper.cs b/src/Sitecore.AspNetCore.SDK.RenderingEngine/TagHelpers/Fields/ImageTagHelper.cs index 84adca1..ea517ee 100644 --- a/src/Sitecore.AspNetCore.SDK.RenderingEngine/TagHelpers/Fields/ImageTagHelper.cs +++ b/src/Sitecore.AspNetCore.SDK.RenderingEngine/TagHelpers/Fields/ImageTagHelper.cs @@ -29,6 +29,8 @@ public class ImageTagHelper(IEditableChromeRenderer chromeRenderer) : TagHelper private const string VSpaceAttribute = "vspace"; private const string TitleAttribute = "title"; private const string BorderAttribute = "border"; + private const string SrcSetAttribute = "srcset"; + private const string SizesAttribute = "sizes"; private readonly IEditableChromeRenderer _chromeRenderer = chromeRenderer ?? throw new ArgumentNullException(nameof(chromeRenderer)); /// @@ -53,6 +55,19 @@ public class ImageTagHelper(IEditableChromeRenderer chromeRenderer) : TagHelper /// public object? ImageParams { get; set; } + /// + /// Gets or sets the srcset configurations for responsive images. + /// Supports: object[] (anonymous objects) or Dictionary arrays. + /// Each item should contain width parameters like { mw = 300 }, { w = 100 }. + /// + public object? SrcSet { get; set; } + + /// + /// Gets or sets the sizes attribute for responsive images. + /// Example: "(min-width: 960px) 300px, 100px". + /// + public string? Sizes { get; set; } + /// public override void Process(TagHelperContext context, TagHelperOutput output) { @@ -98,6 +113,20 @@ public override void Process(TagHelperContext context, TagHelperOutput output) { output.Attributes.Add(ScrAttribute, field.GetMediaLink(ImageParams)); + if (SrcSet != null) + { + string srcSetValue = GenerateSrcSetAttribute(field); + if (!string.IsNullOrEmpty(srcSetValue)) + { + output.Attributes.Add(SrcSetAttribute, srcSetValue); + } + } + + if (!string.IsNullOrEmpty(Sizes)) + { + output.Attributes.Add(SizesAttribute, Sizes); + } + if (!string.IsNullOrWhiteSpace(field.Value.Alt)) { output.Attributes.Add(AltAttribute, field.Value.Alt); @@ -141,6 +170,42 @@ public override void Process(TagHelperContext context, TagHelperOutput output) } } + private static string? GetWidthDescriptor(object? parameters) + { + if (parameters == null) + { + return null; + } + + IDictionary dictionary = HtmlHelper.AnonymousObjectToHtmlAttributes(parameters); + + // Priority: w > mw > width > maxWidth (matching Content SDK behavior + legacy support) + string? width = null; + if (dictionary.TryGetValue("w", out object? wValue)) + { + width = wValue.ToString(); + } + else if (dictionary.TryGetValue("mw", out object? mwValue)) + { + width = mwValue.ToString(); + } + else if (dictionary.TryGetValue("width", out object? widthValue)) + { + width = widthValue.ToString(); + } + else if (dictionary.TryGetValue("maxWidth", out object? maxWidthValue)) + { + width = maxWidthValue.ToString(); + } + + if (width != null && int.TryParse(width, out int widthValueInt) && widthValueInt <= 0) + { + return null; + } + + return width != null ? $"{width}w" : null; + } + private TagBuilder GenerateImage(ImageField imageField, TagHelperOutput output) { Image image = imageField.Value; @@ -152,6 +217,20 @@ private TagBuilder GenerateImage(ImageField imageField, TagHelperOutput output) if (!string.IsNullOrWhiteSpace(image.Src)) { tagBuilder.Attributes.Add(ScrAttribute, imageField.GetMediaLink(ImageParams)); + + if (SrcSet != null) + { + string srcSetValue = GenerateSrcSetAttribute(imageField); + if (!string.IsNullOrEmpty(srcSetValue)) + { + tagBuilder.Attributes.Add(SrcSetAttribute, srcSetValue); + } + } + + if (!string.IsNullOrEmpty(Sizes)) + { + tagBuilder.Attributes.Add(SizesAttribute, Sizes); + } } if (!string.IsNullOrWhiteSpace(image.Alt)) @@ -230,8 +309,52 @@ private HtmlString MergeEditableMarkupWithCustomAttributes(string editableMarkUp } imageNode.SetAttributeValue(ScrAttribute, imageField.GetMediaLink(ImageParams)); + + if (SrcSet != null) + { + string srcSetValue = GenerateSrcSetAttribute(imageField); + if (!string.IsNullOrEmpty(srcSetValue)) + { + imageNode.SetAttributeValue(SrcSetAttribute, srcSetValue); + } + } + + if (!string.IsNullOrEmpty(Sizes)) + { + imageNode.SetAttributeValue(SizesAttribute, Sizes); + } } return new HtmlString(doc.DocumentNode.OuterHtml); } + + private string GenerateSrcSetAttribute(ImageField imageField) + { + if (SrcSet is not object[] parsedSrcSet || parsedSrcSet.Length == 0) + { + return string.Empty; + } + + List srcSetEntries = []; + + foreach (object srcSetItem in parsedSrcSet) + { + // Get width descriptor first to check if this entry should be included + string? descriptor = GetWidthDescriptor(srcSetItem); + if (descriptor == null) + { + // Skip entries without valid width parameters (matching Content SDK behavior) + continue; + } + + // Use GetMediaLinkForSrcSet to preserve existing URL parameters (like ttc, tt, hash, quality, format) because in preview context id the images doesn't get loaded with src-set implementation + string? mediaUrl = imageField.GetMediaLinkForSrcSet(ImageParams, srcSetItem); + if (!string.IsNullOrEmpty(mediaUrl)) + { + srcSetEntries.Add($"{mediaUrl} {descriptor}"); + } + } + + return string.Join(", ", srcSetEntries); + } } \ No newline at end of file diff --git a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/ComponentModels/ComponentWithImages.cs b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/ComponentModels/ComponentWithImages.cs index 85d60f3..d0fccc2 100644 --- a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/ComponentModels/ComponentWithImages.cs +++ b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/ComponentModels/ComponentWithImages.cs @@ -9,4 +9,8 @@ public class ComponentWithImages public ImageField? SecondImage { get; set; } public TextField? Heading { get; set; } + + public ImageField? ThirdImage { get; set; } + + public ImageField? FourthImage { get; set; } } \ No newline at end of file diff --git a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Fixtures/TagHelpers/ImageFieldTagHelperFixture.cs b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Fixtures/TagHelpers/ImageFieldTagHelperFixture.cs index 95f9877..b0a00a5 100644 --- a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Fixtures/TagHelpers/ImageFieldTagHelperFixture.cs +++ b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Fixtures/TagHelpers/ImageFieldTagHelperFixture.cs @@ -95,7 +95,7 @@ public async Task ImgTagHelper_GeneratesImageTags() // Assert // check that there is proper number of 'img' tags generated. - sectionNode.ChildNodes.Count(n => n.Name.Equals("img", StringComparison.OrdinalIgnoreCase)).Should().Be(2); + sectionNode.ChildNodes.Count(n => n.Name.Equals("img", StringComparison.OrdinalIgnoreCase)).Should().Be(4); } [Fact] @@ -140,12 +140,13 @@ public async Task ImgTagHelper_GeneratesProperImageUrlIncludingImageParams() HtmlDocument doc = new(); doc.LoadHtml(response); HtmlNode? sectionNode = doc.DocumentNode.ChildNodes.First(n => n.HasClass("component-with-images")); - HtmlNode? lastImage = sectionNode.ChildNodes.Last(n => n.Name.Equals("img", StringComparison.OrdinalIgnoreCase)); + HtmlNode? secondImage = sectionNode.Descendants("img").ElementAtOrDefault(1); // Assert // check that image url contains mw and mh parameters - lastImage.Attributes.Should().Contain(a => a.Name == "src"); - lastImage.Attributes["src"].Value.Should().Contain("mw=100&mh=50"); + secondImage.Should().NotBeNull(); + secondImage?.Attributes.Should().Contain(a => a.Name == "src"); + secondImage?.Attributes["src"].Value.Should().Contain("mw=100&mh=50"); } [Fact] @@ -176,6 +177,44 @@ public async Task ImgTagHelper_GeneratesProperEditableImageMarkupWithCustomPrope sectionNode.InnerHtml.Should().Contain("src=\"/sitecore/shell/-/jssmedia/styleguide/data/media/img/sc_logo.png?mw=100&mh=50\""); } + [Fact] + public async Task ImgTagHelper_GeneratesProperSrcSetAttributeWithCorrectUrlsAndSizes() + { + // Arrange + _mockClientHandler.Responses.Push(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(Serializer.Serialize(CannedResponses.PageWithPreview)) + }); + + HttpClient client = _server.CreateClient(); + + // Act + string response = await client.GetStringAsync(new Uri("/", UriKind.Relative)); + HtmlDocument doc = new HtmlDocument(); + doc.LoadHtml(response); + HtmlNode? sectionNode = doc.DocumentNode.ChildNodes.First(n => n.HasClass("component-with-images")); + + // Assert + // Third image for (index 2) + HtmlNode? thirdImg = sectionNode.Descendants("img").ElementAt(2); + thirdImg.Should().NotBeNull(); + thirdImg.Attributes.Should().Contain(a => a.Name == "srcset"); + thirdImg.Attributes.Should().Contain(a => a.Name == "sizes"); + thirdImg.Attributes["srcset"].Value.Should().Contain("site/third.png?mw=400 400w"); + thirdImg.Attributes["srcset"].Value.Should().Contain("site/third.png?mw=200 200w"); + thirdImg.Attributes["sizes"].Value.Should().Be("(min-width: 400px) 400px, 200px"); + + // Fourth image for (index 3) + HtmlNode? fourthImg = sectionNode.Descendants("img").ElementAt(3); + fourthImg.Should().NotBeNull(); + fourthImg.Attributes.Should().Contain(a => a.Name == "srcset"); + fourthImg.Attributes.Should().Contain(a => a.Name == "sizes"); + fourthImg.Attributes["srcset"].Value.Should().Contain("site/fourth.png?mw=800 800w"); + fourthImg.Attributes["srcset"].Value.Should().Contain("site/fourth.png?mw=400 400w"); + fourthImg.Attributes["sizes"].Value.Should().Be("(min-width: 800px) 800px, 400px"); + } + public void Dispose() { _mockClientHandler.Dispose(); diff --git a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Views/Shared/Components/SitecoreComponent/ComponentWithImages.cshtml b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Views/Shared/Components/SitecoreComponent/ComponentWithImages.cshtml index c6d2dbc..6ef9560 100644 --- a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Views/Shared/Components/SitecoreComponent/ComponentWithImages.cshtml +++ b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Integration.Tests/Views/Shared/Components/SitecoreComponent/ComponentWithImages.cshtml @@ -4,4 +4,6 @@
+ + \ No newline at end of file diff --git a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Tests/TagHelpers/Fields/ImageTagHelperFixture.cs b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Tests/TagHelpers/Fields/ImageTagHelperFixture.cs index e9d9baf..a26a4bb 100644 --- a/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Tests/TagHelpers/Fields/ImageTagHelperFixture.cs +++ b/tests/Sitecore.AspNetCore.SDK.RenderingEngine.Tests/TagHelpers/Fields/ImageTagHelperFixture.cs @@ -739,6 +739,420 @@ public void Process_RenderingChromesAreNotNull_ChromesAreOutput( chromeRenderer.Received().Render(closingChrome); } + [Theory] + [AutoNSubstituteData] + public void Process_ScImgTagWithSrcSet_GeneratesSrcSetAttribute( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = RenderingEngineConstants.SitecoreTagHelpers.ImageHtmlTag; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] { new { w = 400 }, new { w = 200 } }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + string content = tagHelperOutput.Content.GetContent(); + + System.Text.RegularExpressions.Match srcsetMatch = System.Text.RegularExpressions.Regex.Match(content, "srcset=\"([^\"]*)\""); + srcsetMatch.Success.Should().BeTrue("srcset attribute should be present in the HTML"); + + string srcsetValue = srcsetMatch.Groups[1].Value; + + // Verify each entry precisely with preserved query parameters and new width parameters + srcsetValue.Should().Contain("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=400 400w"); + srcsetValue.Should().Contain("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=200 200w"); + + string[] entries = srcsetValue.Split(", "); + entries.Should().HaveCount(2); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithContentSDKBehavior_MatchesExpectedOutput( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.ImageParams = new { h = 1000 }; // Base parameters + sut.SrcSet = new object[] { new { h = 1000, w = 1000 }, new { mh = 250, mw = 250 } }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify exact srcset format + string[] entries = srcSetValue.Split(", "); + entries.Should().HaveCount(2); + entries[0].Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&h=1000&w=1000 1000w"); + entries[1].Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&h=1000&mh=250&mw=250 250w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithoutValidWidth_FiltersEntries( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] { new { h = 1000 }, new { mw = 250 }, new { quality = 80 } }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify exact entry + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&mw=250 250w"); + + string[] entries = srcSetValue.Split(", "); + entries.Should().HaveCount(1); + } + + [Theory] + [AutoNSubstituteData] + public void Process_EditableImageWithSrcSet_MergesSrcSetIntoEditableMarkup( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + ImageField imageField = new(_image) + { + EditableMarkup = "\"Sitecore" + }; + + tagHelperOutput.TagName = RenderingEngineConstants.SitecoreTagHelpers.ImageHtmlTag; + sut.For = GetModelExpression(imageField); + sut.SrcSet = new object[] { new { mw = 600 }, new { mw = 300 } }; + sut.Sizes = "(min-width: 768px) 600px, 300px"; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + string content = tagHelperOutput.Content.GetContent(); + + // Extract srcset and sizes using regex for full string comparison + System.Text.RegularExpressions.Match srcsetMatch = System.Text.RegularExpressions.Regex.Match(content, "srcset=\"([^\"]*)\""); + srcsetMatch.Success.Should().BeTrue(); + string srcsetValue = srcsetMatch.Groups[1].Value; + + System.Text.RegularExpressions.Match sizesMatch = System.Text.RegularExpressions.Regex.Match(content, "sizes=\"([^\"]*)\""); + sizesMatch.Success.Should().BeTrue(); + string sizesValue = sizesMatch.Groups[1].Value; + + // Full string comparison + srcsetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&mw=600 600w, http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&mw=300 300w"); + sizesValue.Should().Be("(min-width: 768px) 600px, 300px"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithMixedParameterTypes_GeneratesSrcSetAttribute( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = RenderingEngineConstants.SitecoreTagHelpers.ImageHtmlTag; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] + { + new { w = 800 }, // Anonymous object with 'w' + new { mw = 400 }, // Anonymous object with 'mw' + }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + string content = tagHelperOutput.Content.GetContent(); + + // Extract srcset using regex for full string comparison + System.Text.RegularExpressions.Match srcsetMatch = System.Text.RegularExpressions.Regex.Match(content, "srcset=\"([^\"]*)\""); + srcsetMatch.Success.Should().BeTrue(); + string srcsetValue = srcsetMatch.Groups[1].Value; + + // Full string comparison + srcsetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=800 800w, http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&mw=400 400w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithImageParamsConflict_SrcSetParametersArePreferred( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.ImageParams = new { w = 1000, quality = 50, format = "jpg" }; + sut.SrcSet = new object[] { new { w = 320, quality = 75 } }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify SrcSet parameters are preferred over ImageParams + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=320&quality=75&format=jpg 320w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithWidthParameterPriority_UsesCorrectPrecedence( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange - Test priority: w > mw > width > maxWidth + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] + { + new { w = 100, mw = 200, width = 300, maxWidth = 400 }, // Should use w=100 + new { mw = 200, width = 300, maxWidth = 400 }, // Should use mw=200 + new { width = 300, maxWidth = 400 }, // Should use width=300 + new { maxWidth = 400 } // Should use maxWidth=400 + }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify parameter priority + string[] entries = srcSetValue.Split(", "); + entries.Should().HaveCount(4); + entries[0].Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=100&mw=200&width=300&maxWidth=400 100w"); + entries[1].Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&mw=200&width=300&maxWidth=400 200w"); + entries[2].Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&width=300&maxWidth=400 300w"); + entries[3].Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&maxWidth=400 400w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithZeroOrNegativeWidths_SkipsInvalidEntries( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] + { + new { w = 0, quality = 75 }, // Should skip + new { w = -100, quality = 80 }, // Should skip + new { w = 320, quality = 75 }, // Should include + new { quality = 90 } // Should skip - no width + }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify only valid entry remains + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=320&quality=75 320w"); + + string[] entries = srcSetValue.Split(", "); + entries.Should().HaveCount(1); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithExistingUrlParameters_PreservesAndMergesParameters( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + Image imageWithParams = new Image + { + Src = "https://edge.sitecorecloud.io/media/image.jpg?h=2001&iar=0&hash=abc123&w=3000", + Alt = "Test Image" + }; + + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(imageWithParams)); + sut.SrcSet = new object[] { new { w = 320, quality = 75 } }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify parameters are preserved and merged + srcSetValue.Should().Be("https://edge.sitecorecloud.io/media/image.jpg?h=2001&iar=0&hash=abc123&w=320&quality=75 320w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithMediaUrlTransformation_TransformsCorrectly( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + Image imageWithMediaUrl = new Image + { + Src = "/~/media/images/test.jpg", + Alt = "Test Image" + }; + + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(imageWithMediaUrl)); + sut.SrcSet = new object[] { new { w = 320, quality = 75 } }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify URL transformation + srcSetValue.Should().Be("/~/jssmedia/images/test.jpg?w=320&quality=75 320w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithNullAndEmptyEntries_HandlesGracefully( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object?[] + { + new { w = 320, quality = 75 }, + null, // Should skip + new { w = 480, quality = 80 }, + new { } // Should skip - no width parameter + }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify only valid entries remain + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=320&quality=75 320w, http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=480&quality=80 480w"); + + string[] entries = srcSetValue.Split(", "); + entries.Should().HaveCount(2); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithComplexParameters_HandlesAllParameters( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] + { + new { w = 320, quality = 75, format = "webp", dpr = 2, fit = "crop" } + }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify all parameters are included + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=320&quality=75&format=webp&dpr=2&fit=crop 320w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithDictionaryParameters_GeneratesSrcSetAttribute( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] + { + new Dictionary { { "w", 320 }, { "quality", 75 } }, + new Dictionary { { "mw", 480 }, { "quality", 80 } } + }; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + + // Full string comparison - verify dictionary parameters + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=320&quality=75 320w, http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&mw=480&quality=80 480w"); + } + + [Theory] + [AutoNSubstituteData] + public void Process_SrcSetWithSizesAttribute_AddsBothAttributes( + ImageTagHelper sut, + TagHelperContext tagHelperContext, + TagHelperOutput tagHelperOutput) + { + // Arrange + tagHelperOutput.TagName = "img"; + sut.For = GetModelExpression(new ImageField(_image)); + sut.SrcSet = new object[] { new { w = 320 }, new { w = 480 } }; + sut.Sizes = "(min-width: 768px) 480px, 320px"; + + // Act + sut.Process(tagHelperContext, tagHelperOutput); + + // Assert + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "srcset"); + tagHelperOutput.Attributes.Should().ContainSingle(a => a.Name == "sizes"); + + string srcSetValue = tagHelperOutput.Attributes["srcset"].Value.ToString()!; + string sizesValue = tagHelperOutput.Attributes["sizes"].Value.ToString()!; + + // Full string comparison + srcSetValue.Should().Be("http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=320 320w, http://styleguide/-/jssmedia/styleguide/data/media/img/sc_logo.png?iar=0&hash=F313AD90AE547CAB09277E42509E289B&w=480 480w"); + sizesValue.Should().Be("(min-width: 768px) 480px, 320px"); + } + private static ModelExpression GetModelExpression(Field model) { DefaultModelMetadata? modelMetadata = Substitute.For( diff --git a/tests/data/Sitecore.AspNetCore.SDK.TestData/CannedResponses.cs b/tests/data/Sitecore.AspNetCore.SDK.TestData/CannedResponses.cs index 4740cb0..ef8a712 100644 --- a/tests/data/Sitecore.AspNetCore.SDK.TestData/CannedResponses.cs +++ b/tests/data/Sitecore.AspNetCore.SDK.TestData/CannedResponses.cs @@ -2988,6 +2988,10 @@ public static class CannedResponses ["FirstImage"] = new ImageField(new Image { Alt = "sample", Src = "sample.png" }), ["SecondImage"] = new ImageField(new Image { Alt = "second", Src = "site/second.png" }), + ["ThirdImage"] = new ImageField(new Image + { Alt = "third", Src = "site/third.png" }), + ["FourthImage"] = new ImageField(new Image + { Alt = "fourth", Src = "site/fourth.png" }), ["Heading"] = new TextField(TestConstants.TestFieldValue), } }, @@ -3894,6 +3898,10 @@ public static class CannedResponses ["FirstImage"] = new ImageField(new Image { Alt = "sample", Src = "sample.png" }), ["SecondImage"] = new ImageField(new Image { Alt = "second", Src = "site/second.png" }), + ["ThirdImage"] = new ImageField(new Image + { Alt = "third", Src = "site/third.png" }), + ["FourthImage"] = new ImageField(new Image + { Alt = "fourth", Src = "site/fourth.png" }), ["Heading"] = new TextField(TestConstants.TestFieldValue), } },