diff --git a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs index b47b49bf..9f9c2bde 100644 --- a/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs +++ b/EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs @@ -246,6 +246,88 @@ public async Task GenerateSitemapXml_DoesNotSetLastModifiedDateWhenSiteMappingDa await Assert.That(siteMappingNode.LastModificationDate).IsNull(); } + [Test] + public async Task GenerateSitemapXml_UsesLastModifiedDateFromStaticRouteLookup() + { + // Arrange + var baseUrl = "https://test.example.com/"; + var homeLastModified = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var staticRouteLastModifiedDates = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["/home"] = homeLastModified + }; + var siteMappings = new List(); + + // Act + var routeConfigurationService = Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + siteMappings, + routeConfigurationService, + baseUrl, + staticRouteLastModifiedDates, + out var nodes); + + // Assert + var homeNode = nodes.First(node => node.Url.EndsWith("/home", StringComparison.OrdinalIgnoreCase)); + await Assert.That(homeNode.LastModificationDate).IsEqualTo(homeLastModified); + } + + [Test] + public async Task GenerateSitemapXml_AppliesLastModifiedDateForAllMappedStaticRoutes() + { + // Arrange + var baseUrl = "https://test.example.com/"; + var staticRouteLastModifiedDates = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["/home"] = new DateTime(2025, 1, 2, 3, 4, 5, DateTimeKind.Utc), + ["/about"] = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc), + ["/guidelines"] = new DateTime(2025, 3, 4, 5, 6, 7, DateTimeKind.Utc) + }; + var siteMappings = new List(); + + // Act + var routeConfigurationService = Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + siteMappings, + routeConfigurationService, + baseUrl, + staticRouteLastModifiedDates, + out var nodes); + + // Assert + foreach ((var route, var expectedLastModified) in staticRouteLastModifiedDates) + { + var routeNode = nodes.First(node => node.Url.EndsWith(route, StringComparison.OrdinalIgnoreCase)); + await Assert.That(routeNode.LastModificationDate).IsEqualTo(expectedLastModified); + } + } + + [Test] + public async Task GenerateSitemapXml_UsesHomeLastModifiedDateForRootNode() + { + // Arrange + var baseUrl = "https://test.example.com/"; + var homeLastModified = new DateTime(2025, 2, 3, 4, 5, 6, DateTimeKind.Utc); + var staticRouteLastModifiedDates = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["/home"] = homeLastModified + }; + var siteMappings = new List(); + + // Act + var routeConfigurationService = Factory.Services.GetRequiredService(); + SitemapXmlHelpers.GenerateSitemapXml( + siteMappings, + routeConfigurationService, + baseUrl, + staticRouteLastModifiedDates, + out var nodes); + + // Assert + var rootNode = nodes.First(node => node.Url == baseUrl); + await Assert.That(rootNode.LastModificationDate).IsEqualTo(homeLastModified); + } + private static SiteMapping CreateSiteMapping( int chapterNumber, int pageNumber, diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index de471129..3e9e7645 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -12,8 +12,18 @@ namespace EssentialCSharp.Web.Controllers; -public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings) : BaseController(routeConfigurationService, httpContextAccessor) +public partial class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions siteSettings) : BaseController(routeConfigurationService, httpContextAccessor) { + // Keep this map in sync with files that materially affect each route's rendered content. + private static readonly IReadOnlyDictionary StaticRouteContentFiles = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["/home"] = ["Views\\Home\\Home.cshtml", "Models\\AnnouncementCatalog.cs"], + ["/about"] = ["Views\\Home\\About.cshtml"], + ["/announcements"] = ["Views\\Home\\Announcements.cshtml", "Models\\AnnouncementCatalog.cs"], + ["/termsofservice"] = ["Views\\Home\\TermsOfService.cshtml"], + ["/guidelines"] = ["Views\\Home\\Guidelines.cshtml", "Guidelines\\guidelines.json"] + }; + [EnableRateLimiting("content")] public IActionResult Index() { @@ -95,10 +105,53 @@ public IActionResult Guidelines() [EnableRateLimiting("content")] public IActionResult SitemapXml() { - SitemapXmlHelpers.GenerateSitemapXml(siteMappingService.SiteMappings, RouteConfigurationService, siteSettings.Value.BaseUrl, out var nodes); + SitemapXmlHelpers.GenerateSitemapXml( + siteMappingService.SiteMappings, + RouteConfigurationService, + siteSettings.Value.BaseUrl, + GetStaticRouteLastModifiedDates(), + out var nodes); return new SitemapProvider().CreateSitemap(new SitemapModel(nodes)); } + private Dictionary GetStaticRouteLastModifiedDates() + { + var routeLastModifiedDates = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((var route, var sourceFiles) in StaticRouteContentFiles) + { + DateTime? maxLastModified = null; + foreach (var sourceFile in sourceFiles) + { + var sourceFilePath = Path.Join(hostingEnvironment.ContentRootPath, sourceFile); + if (!System.IO.File.Exists(sourceFilePath)) + { + LogSitemapMappedFileMissing(logger, route, sourceFilePath); + continue; + } + + var sourceFileLastWriteTime = System.IO.File.GetLastWriteTimeUtc(sourceFilePath); + if (sourceFileLastWriteTime <= DateTime.UnixEpoch) + { + continue; + } + + maxLastModified = maxLastModified is null + ? sourceFileLastWriteTime + : sourceFileLastWriteTime > maxLastModified.Value ? sourceFileLastWriteTime : maxLastModified.Value; + } + + if (maxLastModified is DateTime routeLastModified) + { + routeLastModifiedDates[route] = routeLastModified; + } + } + + return routeLastModifiedDates; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Sitemap mapped file missing for route {Route}: {FilePath}")] + private static partial void LogSitemapMappedFileMissing(ILogger logger, string route, string filePath); + private string FlipPage(int currentChapter, int currentPage, bool next) { if (siteMappingService.SiteMappings.Count == 0) diff --git a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs index 5db73953..a9882b62 100644 --- a/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs +++ b/EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs @@ -18,18 +18,37 @@ public static void EnsureSitemapHealthy(List siteMappings) } } - public static void GenerateSitemapXml(IEnumerable siteMappings, IRouteConfigurationService routeConfigurationService, string baseUrl, out List nodes) + public static void GenerateSitemapXml( + IEnumerable siteMappings, + IRouteConfigurationService routeConfigurationService, + string baseUrl, + out List nodes) => + GenerateSitemapXml(siteMappings, routeConfigurationService, baseUrl, staticRouteLastModifiedDates: null, out nodes); + + public static void GenerateSitemapXml( + IEnumerable siteMappings, + IRouteConfigurationService routeConfigurationService, + string baseUrl, + IReadOnlyDictionary? staticRouteLastModifiedDates, + out List nodes) { // Routes should end up with leading slash baseUrl = baseUrl.TrimEnd('/'); // Start with the root URL — no LastModificationDate: it doesn't change per-request + var rootNode = new SitemapNode($"{baseUrl}/") + { + ChangeFrequency = ChangeFrequency.Daily, + Priority = 1.0M + }; + + if (TryGetRouteLastModified(staticRouteLastModifiedDates, "/home") is DateTime homeLastModified) + { + rootNode.LastModificationDate = homeLastModified; + } + nodes = new() { - new($"{baseUrl}/") - { - ChangeFrequency = ChangeFrequency.Daily, - Priority = 1.0M - } + rootNode }; // Add routes dynamically discovered from controllers @@ -44,11 +63,18 @@ public static void GenerateSitemapXml(IEnumerable siteMappings, IRo foreach (var route in controllerRoutes) { - nodes.Add(new($"{baseUrl}{route}") + var node = new SitemapNode($"{baseUrl}{route}") { ChangeFrequency = GetChangeFrequencyForRoute(route), Priority = GetPriorityForRoute(route) - }); + }; + + if (TryGetRouteLastModified(staticRouteLastModifiedDates, route) is DateTime routeLastModified) + { + node.LastModificationDate = routeLastModified; + } + + nodes.Add(node); } // Add site mappings from content @@ -69,6 +95,28 @@ public static void GenerateSitemapXml(IEnumerable siteMappings, IRo })); } + private static DateTime? TryGetRouteLastModified(IReadOnlyDictionary? staticRouteLastModifiedDates, string route) + { + if (staticRouteLastModifiedDates is null) + { + return null; + } + + var normalizedRoute = NormalizeRoute(route); + return staticRouteLastModifiedDates.TryGetValue(normalizedRoute, out var lastModified) ? lastModified : null; + } + + private static string NormalizeRoute(string route) + { + route = route.Trim(); + if (route == "/") + { + return route; + } + + return $"/{route.TrimStart('/').ToLowerInvariant()}"; + } + private static bool IsSitemapRoute(string route) => route.TrimStart('/').Equals("sitemap.xml", StringComparison.OrdinalIgnoreCase);