From 99d158e218cb2c816e68098b72c29f9c56224f2b Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Sun, 17 May 2026 21:43:26 -0700 Subject: [PATCH] Improve sitemap lastmod coverage and safeguards Add file-backed lastmod support for static routes and root sitemap entry, while preserving chapter page lastmod behavior from SiteMapping data. Harden static route dependency tracking by mapping /home to AnnouncementCatalog, documenting the dependency map contract, and warning when mapped files are missing. Expand sitemap helper tests to cover mapped static route lastmod application alongside existing root and chapter lastmod checks. --- .../SitemapXmlHelpersTests.cs | 82 +++++++++++++++++++ .../Controllers/HomeController.cs | 57 ++++++++++++- .../Helpers/SitemapXmlHelpers.cs | 64 +++++++++++++-- 3 files changed, 193 insertions(+), 10 deletions(-) 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);