Skip to content

Commit

Permalink
✨ Added support for sitemap images #7
Browse files Browse the repository at this point in the history
  • Loading branch information
Marthijn van den Heuvel committed Mar 15, 2024
1 parent 89c0d8e commit a0750c6
Show file tree
Hide file tree
Showing 10 changed files with 277 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Sidio.Sitemap.Core.Extensions;

namespace Sidio.Sitemap.Core.Tests.Extensions;

public sealed class SitemapImageLocationTests
{
[Fact]
public void Construct_WithValidArguments_SitemapImageLocationConstructed()
{
// arrange
const string Url = "http://www.example.com";

// act
var sitemapNode = new SitemapImageLocation(Url);

// assert
sitemapNode.Url.Should().Be(Url);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Construct_WithEmptyUrl_ThrowException(string? url)
{
// act
var sitemapNodeAction = () => new SitemapImageLocation(url!);

// assert
sitemapNodeAction.Should().ThrowExactly<ArgumentNullException>();
}
}
73 changes: 73 additions & 0 deletions src/Sidio.Sitemap.Core.Tests/Extensions/SitemapImageNodeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using Sidio.Sitemap.Core.Extensions;

namespace Sidio.Sitemap.Core.Tests.Extensions;

public sealed class SitemapImageNodeTests
{
private readonly Fixture _fixture = new ();

[Fact]
public void Construct_WithValidArguments_SitemapImageLocationConstructed()
{
// arrange
const string Url = "http://www.example.com";
var imageLocation = new SitemapImageLocation(Url);

// act
var sitemapNode = new SitemapImageNode(Url, imageLocation);

// assert
sitemapNode.Url.Should().Be(Url);
sitemapNode.Images.Should().HaveCount(1);
sitemapNode.Images.Should().Contain(imageLocation);
}

[Fact]
public void Construct_WithValidArguments_MultipleImages_SitemapImageLocationConstructed()
{
// arrange
const string Url = "http://www.example.com";
var imageLocations = _fixture.CreateMany<SitemapImageLocation>().ToList();

// act
var sitemapNode = new SitemapImageNode(Url, imageLocations);

// assert
sitemapNode.Url.Should().Be(Url);
sitemapNode.Images.Should().HaveCount(imageLocations.Count);
sitemapNode.Images.Should().Contain(imageLocations);
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void Construct_WithEmptyUrl_ThrowException(string? url)
{
// act
var sitemapNodeAction = () => new SitemapImageNode(url!, new SitemapImageLocation("http://www.example.com"));

// assert
sitemapNodeAction.Should().ThrowExactly<ArgumentNullException>();
}

[Fact]
public void Construct_WithoutImages_ThrowException()
{
// act
var sitemapNodeAction = () => new SitemapImageNode("http://www.example.com", []);

// assert
sitemapNodeAction.Should().ThrowExactly<ArgumentException>();
}

[Fact]
public void Construct_WithoutTooManyImages_ThrowException()
{
// act
var sitemapNodeAction = () => new SitemapImageNode("http://www.example.com", new List<SitemapImageLocation>(1001).ToArray());

// assert
sitemapNodeAction.Should().ThrowExactly<ArgumentException>();
}
}
27 changes: 26 additions & 1 deletion src/Sidio.Sitemap.Core.Tests/Serialization/XmlSerializerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Sidio.Sitemap.Core.Serialization;
using Sidio.Sitemap.Core.Extensions;
using Sidio.Sitemap.Core.Serialization;

namespace Sidio.Sitemap.Core.Tests.Serialization;

Expand All @@ -16,6 +17,7 @@ public void Serialize_WithSitemap_ReturnsXml()
var changeFrequency = _fixture.Create<ChangeFrequency>();
sitemap.Add(new SitemapNode(Url, now, changeFrequency, 0.32m));
var serializer = new XmlSerializer();

var expectedUrl = Url.Replace("&", "&amp;").Replace(">", "&gt;").Replace("<", "&lt;").Replace("'", "&apos;").Replace("\"", "&quot;");

// act
Expand All @@ -27,6 +29,29 @@ public void Serialize_WithSitemap_ReturnsXml()
$"<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"><url><loc>{expectedUrl}</loc><lastmod>{now:yyyy-MM-dd}</lastmod><changefreq>{changeFrequency.ToString().ToLower()}</changefreq><priority>0.3</priority></url></urlset>");
}

[Fact]
public void Serialize_WithSitemapContainsImageNodes_ReturnsXml()
{
// arrange
const string Url = "https://example.com/?id=1&name=example&gt=>&lt=<&quotes=";
var sitemap = new Sitemap();
var now = DateTime.UtcNow;
var changeFrequency = _fixture.Create<ChangeFrequency>();
sitemap.Add(new SitemapNode(Url, now, changeFrequency, 0.32m));
sitemap.Add(new SitemapImageNode(Url, new SitemapImageLocation(Url)));
var serializer = new XmlSerializer();

var expectedUrl = Url.Replace("&", "&amp;").Replace(">", "&gt;").Replace("<", "&lt;").Replace("'", "&apos;").Replace("\"", "&quot;");

// act
var result = serializer.Serialize(sitemap);

// assert
result.Should().NotBeNullOrEmpty();
result.Should().Be(
$"<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"no\"?><urlset xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\" xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"><url><loc>{expectedUrl}</loc><lastmod>{now:yyyy-MM-dd}</lastmod><changefreq>{changeFrequency.ToString().ToLower()}</changefreq><priority>0.3</priority></url><url><loc>{expectedUrl}</loc><image:image><image:loc>{expectedUrl}</image:loc></image:image></url></urlset>");
}

[Fact]
public void Serialize_SitemapTooLarge_ThrowException()
{
Expand Down
26 changes: 26 additions & 0 deletions src/Sidio.Sitemap.Core/Extensions/SitemapImageLocation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Sidio.Sitemap.Core.Extensions;

/// <summary>
/// Represents the location of an image in a sitemap.
/// </summary>
public sealed record SitemapImageLocation
{
/// <summary>
/// Initializes a new instance of the <see cref="SitemapImageLocation"/> class.
/// </summary>
/// <param name="url">The URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters.</param>
public SitemapImageLocation(string url)
{
if (string.IsNullOrWhiteSpace(url))
{
throw new ArgumentNullException(nameof(url));
}

Url = url;
}

/// <summary>
/// Gets the image URL.
/// </summary>
public string Url { get; }
}
55 changes: 55 additions & 0 deletions src/Sidio.Sitemap.Core/Extensions/SitemapImageNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace Sidio.Sitemap.Core.Extensions;

/// <summary>
/// Represents a node in a sitemap with images.
/// </summary>
public sealed record SitemapImageNode : ISitemapNode
{
private const int MaxImages = 1000;

/// <summary>
/// Initializes a new instance of the <see cref="SitemapImageNode"/> class.
/// </summary>
/// <param name="url">The URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters.</param>
/// <param name="imageLocations">One or more image locations.</param>
/// <exception cref="ArgumentNullException">Thrown when a required argument is null or empty.</exception>
/// <exception cref="ArgumentException">Thrown when an argument has an invalid value.</exception>
public SitemapImageNode(string url, IEnumerable<SitemapImageLocation> imageLocations)
{
if (string.IsNullOrWhiteSpace(url))
{
throw new ArgumentNullException(nameof(url));
}

Url = url;
Images = imageLocations.ToList();

switch (Images.Count)
{
case 0:
throw new ArgumentException($"A {nameof(SitemapImageNode)} must contain at least one image location.", nameof(imageLocations));
case > MaxImages:
throw new ArgumentException($"A {nameof(SitemapImageNode)} must contain at most {MaxImages} image locations.", nameof(imageLocations));
}
}

/// <summary>
/// Initializes a new instance of the <see cref="SitemapImageNode"/> class.
/// </summary>
/// <param name="url">The URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters.</param>
/// <param name="imageLocation">An image locations.</param>
/// <exception cref="ArgumentNullException">Thrown when a required argument is null or empty.</exception>
/// <exception cref="ArgumentException">Thrown when an argument has an invalid value.</exception>
public SitemapImageNode(string url, SitemapImageLocation imageLocation)
: this(url, new[] { imageLocation })
{
}

/// <inheritdoc />
public string Url { get; }

/// <summary>
/// Gets the image locations.
/// </summary>
public IReadOnlyCollection<SitemapImageLocation> Images { get; }
}
12 changes: 12 additions & 0 deletions src/Sidio.Sitemap.Core/ISitemapNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Sidio.Sitemap.Core;

/// <summary>
/// Represents a node in a sitemap.
/// </summary>
public interface ISitemapNode
{
/// <summary>
/// Gets the URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters.
/// </summary>
public string Url { get; }
}
8 changes: 8 additions & 0 deletions src/Sidio.Sitemap.Core/Serialization/SitemapExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Sidio.Sitemap.Core.Extensions;

namespace Sidio.Sitemap.Core.Serialization;

internal static class SitemapExtensions
{
public static bool HasImageNodes(this Sitemap sitemap) => sitemap.Nodes.Any(node => node is SitemapImageNode);
}
39 changes: 38 additions & 1 deletion src/Sidio.Sitemap.Core/Serialization/XmlSerializer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Text;
using System.Xml;
using Sidio.Sitemap.Core.Extensions;
using Sidio.Sitemap.Core.Validation;

namespace Sidio.Sitemap.Core.Serialization;
Expand Down Expand Up @@ -76,14 +77,33 @@ public Task<string> SerializeAsync(SitemapIndex sitemapIndex, CancellationToken
Encoding = new UTF8Encoding(true), Indent = false, OmitXmlDeclaration = false, NewLineHandling = NewLineHandling.None,
};

private static void WriteNamespaces(XmlWriter writer, Sitemap sitemap)
{
if (sitemap.HasImageNodes())
{
writer.WriteAttributeString("xmlns", "image", null, "http://www.google.com/schemas/sitemap-image/1.1");
}
}

private void SerializeSitemap(XmlWriter writer, Sitemap sitemap)
{
writer.WriteStartDocument(false);
writer.WriteStartElement(null, "urlset", SitemapNamespace);
WriteNamespaces(writer, sitemap);

foreach (var n in sitemap.Nodes)
{
SerializeNode(writer, n);
switch (n)
{
case SitemapNode regularNode:
SerializeNode(writer, regularNode);
break;
case SitemapImageNode imageNode:
SerializeNode(writer, imageNode);
break;
default:
throw new NotSupportedException($"The node type {n.GetType()} is not supported.");
}
}

writer.WriteEndElement();
Expand Down Expand Up @@ -113,6 +133,23 @@ private void SerializeNode(XmlWriter writer, SitemapNode node)
writer.WriteEndElement();
}

private void SerializeNode(XmlWriter writer, SitemapImageNode node)
{
var url = _urlValidator.Validate(node.Url);
writer.WriteStartElement("url");
writer.WriteElementString("loc", url.ToString());

foreach(var imageLocationNode in node.Images)
{
var imageUrl = _urlValidator.Validate(imageLocationNode.Url);
writer.WriteStartElement("image", "image", null);
writer.WriteElementString("image", "loc", null, imageUrl.ToString());
writer.WriteEndElement();
}

writer.WriteEndElement();
}

private void SerializeSitemapIndex(XmlWriter writer, SitemapIndex sitemapIndex)
{
writer.WriteStartDocument(false);
Expand Down
10 changes: 5 additions & 5 deletions src/Sidio.Sitemap.Core/Sitemap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public sealed class Sitemap
{
internal const int MaxNodes = 50000;

private readonly List<SitemapNode> _nodes = new ();
private readonly List<ISitemapNode> _nodes = new ();

/// <summary>
/// Initializes a new instance of the <see cref="Sitemap"/> class.
Expand All @@ -21,7 +21,7 @@ public Sitemap()
/// </summary>
/// <param name="nodes">The sitemap nodes.</param>
/// <exception cref="InvalidOperationException">Thrown when the number of nodes exceeds the maximum number of nodes.</exception>
public Sitemap(IEnumerable<SitemapNode> nodes)
public Sitemap(IEnumerable<ISitemapNode> nodes)
{
ArgumentNullException.ThrowIfNull(nodes);
_nodes.AddRange(nodes);
Expand All @@ -31,14 +31,14 @@ public Sitemap(IEnumerable<SitemapNode> nodes)
/// <summary>
/// Gets the sitemap nodes.
/// </summary>
public IReadOnlyList<SitemapNode> Nodes => _nodes;
public IReadOnlyList<ISitemapNode> Nodes => _nodes;

/// <summary>
/// Adds the specified nodes to the sitemap.
/// </summary>
/// <param name="nodes">The nodes.</param>
/// <exception cref="InvalidOperationException">Thrown when the number of nodes exceeds the maximum number of nodes.</exception>
public void Add(params SitemapNode[] nodes)
public void Add(params ISitemapNode[] nodes)
{
ArgumentNullException.ThrowIfNull(nodes);
Add(nodes.AsEnumerable());
Expand All @@ -49,7 +49,7 @@ public void Add(params SitemapNode[] nodes)
/// </summary>
/// <param name="nodes">The nodes.</param>
/// <exception cref="InvalidOperationException">Thrown when the number of nodes exceeds the maximum number of nodes.</exception>
public void Add(IEnumerable<SitemapNode> nodes)
public void Add(IEnumerable<ISitemapNode> nodes)
{
ArgumentNullException.ThrowIfNull(nodes);
_nodes.AddRange(nodes);
Expand Down
6 changes: 2 additions & 4 deletions src/Sidio.Sitemap.Core/SitemapNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// <summary>
/// Represents a node in a sitemap.
/// </summary>
public sealed record SitemapNode
public sealed record SitemapNode : ISitemapNode
{
private decimal? _priority;

Expand All @@ -29,9 +29,7 @@ public SitemapNode(string? url, DateTime? lastModified = null, ChangeFrequency?
LastModified = lastModified;
}

/// <summary>
/// Gets the URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters.
/// </summary>
/// <inheritdoc />
public string Url { get; }

/// <summary>
Expand Down

0 comments on commit a0750c6

Please sign in to comment.