Skip to content

Commit c36dba0

Browse files
Adds sitemap generation
Implements sitemap generation to improve SEO. Adds a new service to extract static routes from the application's controllers and actions. These routes are then used, along with content-based routes, to generate a sitemap.xml file in the wwwroot directory during application startup. This helps search engines crawl and index the site more effectively.
1 parent f02d63a commit c36dba0

File tree

7 files changed

+321
-1
lines changed

7 files changed

+321
-1
lines changed

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
4141
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
4242
<PackageVersion Include="Octokit" Version="14.0.0" />
43+
<PackageVersion Include="DotnetSitemapGenerator" Version="1.0.4" />
4344
<PackageVersion Include="xunit" Version="2.9.3" />
4445
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.1" />
4546
</ItemGroup>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using EssentialCSharp.Web.Services;
2+
using Microsoft.AspNetCore.Mvc.Testing;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace EssentialCSharp.Web.Tests;
6+
7+
public class RouteConfigurationServiceTests : IClassFixture<WebApplicationFactory<Program>>
8+
{
9+
private readonly WebApplicationFactory<Program> _Factory;
10+
private readonly IRouteConfigurationService _RouteConfigurationService;
11+
12+
public RouteConfigurationServiceTests(WebApplicationFactory<Program> factory)
13+
{
14+
_Factory = factory;
15+
16+
// Get the service from the DI container to test with real routes
17+
var scope = _Factory.Services.CreateScope();
18+
_RouteConfigurationService = scope.ServiceProvider.GetRequiredService<IRouteConfigurationService>();
19+
}
20+
21+
[Fact]
22+
public void GetStaticRoutes_ShouldReturnExpectedRoutes()
23+
{
24+
// Act
25+
var routes = _RouteConfigurationService.GetStaticRoutes().ToList();
26+
27+
// Assert
28+
Assert.NotEmpty(routes);
29+
30+
// Check for expected routes from the HomeController
31+
Assert.Contains("home", routes);
32+
Assert.Contains("about", routes);
33+
Assert.Contains("guidelines", routes);
34+
Assert.Contains("announcements", routes);
35+
Assert.Contains("termsofservice", routes);
36+
}
37+
38+
[Fact]
39+
public void GetStaticRoutes_ShouldIncludeAllHomeControllerRoutes()
40+
{
41+
// Act
42+
var routes = _RouteConfigurationService.GetStaticRoutes().ToHashSet(StringComparer.OrdinalIgnoreCase);
43+
44+
// Assert - check all expected routes from HomeController
45+
var expectedRoutes = new[] { "home", "about", "guidelines", "announcements", "termsofservice" };
46+
47+
foreach (var expectedRoute in expectedRoutes)
48+
{
49+
Assert.True(routes.Contains(expectedRoute),
50+
$"Expected route '{expectedRoute}' was not found in discovered routes: [{string.Join(", ", routes)}]");
51+
}
52+
}
53+
54+
[Fact]
55+
public void GetStaticRoutes_ShouldNotIncludeIdentityRoutes()
56+
{
57+
// Act
58+
var routes = _RouteConfigurationService.GetStaticRoutes();
59+
60+
// Assert - ensure no Identity area routes are included
61+
Assert.DoesNotContain("identity", routes, StringComparer.OrdinalIgnoreCase);
62+
}
63+
64+
65+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Mvc.Filters;
3+
using EssentialCSharp.Web.Services;
4+
5+
namespace EssentialCSharp.Web.Controllers;
6+
7+
public abstract class BaseController : Controller
8+
{
9+
private readonly IRouteConfigurationService _routeConfigurationService;
10+
11+
protected BaseController(IRouteConfigurationService routeConfigurationService)
12+
{
13+
_routeConfigurationService = routeConfigurationService;
14+
}
15+
16+
public override void OnActionExecuting(ActionExecutingContext context)
17+
{
18+
// Automatically add static routes to all views
19+
ViewBag.StaticRoutes = System.Text.Json.JsonSerializer.Serialize(_routeConfigurationService.GetStaticRoutes());
20+
base.OnActionExecuting(context);
21+
}
22+
}

EssentialCSharp.Web/EssentialCSharp.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<PackageReference Include="Newtonsoft.Json" />
3636
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
3737
<PackageReference Include="Octokit" />
38+
<PackageReference Include="DotnetSitemapGenerator" />
3839
</ItemGroup>
3940
<ItemGroup>
4041
<Content Update="wwwroot\images\00mindmap.svg">
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using DotnetSitemapGenerator;
2+
using DotnetSitemapGenerator.Serialization;
3+
using Microsoft.AspNetCore.Mvc.Infrastructure;
4+
5+
namespace EssentialCSharp.Web.Helpers;
6+
7+
public static class SitemapXmlHelpers
8+
{
9+
private const string RootUrl = "https://essentialcsharp.com/";
10+
11+
public static void EnsureSitemapHealthy(List<SiteMapping> siteMappings)
12+
{
13+
var groups = siteMappings.GroupBy(item => new { item.ChapterNumber, item.PageNumber });
14+
foreach (var group in groups)
15+
{
16+
try
17+
{
18+
SiteMapping result = group.Single(item => item.IncludeInSitemapXml);
19+
}
20+
catch (Exception ex)
21+
{
22+
throw new InvalidOperationException($"Sitemap error: Chapter {group.Key.ChapterNumber}, Page {group.Key.PageNumber} has more than one canonical link, or none: {ex.Message}", ex);
23+
}
24+
}
25+
}
26+
27+
public static void GenerateAndSerializeSitemapXml(DirectoryInfo wwwrootDirectory, List<SiteMapping> siteMappings, ILogger logger, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
28+
{
29+
GenerateSitemapXml(wwwrootDirectory, siteMappings, actionDescriptorCollectionProvider, out string xmlPath, out List<SitemapNode> nodes);
30+
XmlSerializer sitemapProvider = new();
31+
sitemapProvider.Serialize(new SitemapModel(nodes), xmlPath, true);
32+
logger.LogInformation("sitemap.xml successfully written to {XmlPath}", xmlPath);
33+
}
34+
35+
public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List<SiteMapping> siteMappings, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, out string xmlPath, out List<SitemapNode> nodes)
36+
{
37+
xmlPath = Path.Combine(wwwrootDirectory.FullName, "sitemap.xml");
38+
DateTime newDateTime = DateTime.UtcNow;
39+
40+
// Start with the root URL
41+
nodes = new() {
42+
new($"{RootUrl}")
43+
{
44+
LastModificationDate = newDateTime,
45+
ChangeFrequency = ChangeFrequency.Daily,
46+
Priority = 1.0M
47+
}
48+
};
49+
50+
// Add routes dynamically discovered from controllers (excluding Identity routes)
51+
var controllerRoutes = GetControllerRoutes(actionDescriptorCollectionProvider);
52+
foreach (var route in controllerRoutes)
53+
{
54+
nodes.Add(new($"{RootUrl.TrimEnd('/')}{route}")
55+
{
56+
LastModificationDate = newDateTime,
57+
ChangeFrequency = GetChangeFrequencyForRoute(route),
58+
Priority = GetPriorityForRoute(route)
59+
});
60+
}
61+
62+
// Add site mappings from content
63+
nodes.AddRange(siteMappings.Where(item => item.IncludeInSitemapXml).Select<SiteMapping, SitemapNode>(siteMapping => new($"{RootUrl}{siteMapping.Keys.First()}")
64+
{
65+
LastModificationDate = newDateTime,
66+
ChangeFrequency = ChangeFrequency.Daily,
67+
Priority = 0.8M
68+
}));
69+
}
70+
71+
private static List<string> GetControllerRoutes(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
72+
{
73+
var routes = new List<string>();
74+
75+
foreach (var actionDescriptor in actionDescriptorCollectionProvider.ActionDescriptors.Items)
76+
{
77+
// Skip Identity area routes
78+
if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity")
79+
continue;
80+
81+
// Skip the default fallback route (Index action in HomeController)
82+
if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index")
83+
continue;
84+
85+
// Skip Error actions
86+
if (action == "Error")
87+
continue;
88+
89+
// Get the route template or attribute route
90+
if (actionDescriptor.AttributeRouteInfo?.Template is string template)
91+
{
92+
// Clean up the template (remove parameters, etc.)
93+
var cleanRoute = template.TrimStart('/');
94+
if (!string.IsNullOrEmpty(cleanRoute) && !routes.Contains($"/{cleanRoute}"))
95+
{
96+
routes.Add($"/{cleanRoute}");
97+
}
98+
}
99+
}
100+
101+
return routes.Distinct().OrderBy(r => r).ToList();
102+
}
103+
104+
private static ChangeFrequency GetChangeFrequencyForRoute(string route)
105+
{
106+
return route.ToLowerInvariant() switch
107+
{
108+
"/termsofservice" => ChangeFrequency.Yearly,
109+
"/announcements" => ChangeFrequency.Monthly,
110+
"/guidelines" => ChangeFrequency.Monthly,
111+
_ => ChangeFrequency.Monthly
112+
};
113+
}
114+
115+
private static decimal GetPriorityForRoute(string route)
116+
{
117+
return route.ToLowerInvariant() switch
118+
{
119+
"/home" => 0.5M,
120+
"/about" => 0.5M,
121+
"/announcements" => 0.5M,
122+
"/guidelines" => 0.9M,
123+
"/termsofservice" => 0.2M,
124+
_ => 0.5M
125+
};
126+
}
127+
}
128+
129+
public class InvalidItemException : Exception
130+
{
131+
public InvalidItemException(string? message) : base(message)
132+
{
133+
}
134+
public InvalidItemException(string? message, Exception exception) : base(message, exception)
135+
{
136+
}
137+
}

EssentialCSharp.Web/Program.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators;
44
using EssentialCSharp.Web.Data;
55
using EssentialCSharp.Web.Extensions;
6+
using EssentialCSharp.Web.Helpers;
67
using EssentialCSharp.Web.Middleware;
78
using EssentialCSharp.Web.Services;
89
using EssentialCSharp.Web.Services.Referrals;
910
using Mailjet.Client;
1011
using Microsoft.AspNetCore.HttpOverrides;
1112
using Microsoft.AspNetCore.Identity;
1213
using Microsoft.AspNetCore.Identity.UI.Services;
14+
using Microsoft.AspNetCore.Mvc.Infrastructure;
1315
using Microsoft.EntityFrameworkCore;
1416

1517
namespace EssentialCSharp.Web;
@@ -146,6 +148,7 @@ private static void Main(string[] args)
146148
builder.Services.AddRazorPages();
147149
builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender));
148150
builder.Services.AddSingleton<ISiteMappingService, SiteMappingService>();
151+
builder.Services.AddScoped<IRouteConfigurationService, RouteConfigurationService>();
149152
builder.Services.AddHostedService<DatabaseMigrationService>();
150153
builder.Services.AddScoped<IReferralService, ReferralService>();
151154

@@ -182,7 +185,6 @@ private static void Main(string[] args)
182185

183186

184187
WebApplication app = builder.Build();
185-
186188
// Configure the HTTP request pipeline.
187189
if (!app.Environment.IsDevelopment())
188190
{
@@ -215,6 +217,24 @@ private static void Main(string[] args)
215217

216218
app.MapFallbackToController("Index", "Home");
217219

220+
// Generate sitemap.xml at startup
221+
var wwwrootDirectory = new DirectoryInfo(app.Environment.WebRootPath);
222+
var siteMappingService = app.Services.GetRequiredService<ISiteMappingService>();
223+
var actionDescriptorCollectionProvider = app.Services.GetRequiredService<IActionDescriptorCollectionProvider>();
224+
var logger = app.Services.GetRequiredService<ILogger<Program>>();
225+
226+
try
227+
{
228+
SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList());
229+
SitemapXmlHelpers.GenerateAndSerializeSitemapXml(wwwrootDirectory, siteMappingService.SiteMappings.ToList(), logger, actionDescriptorCollectionProvider);
230+
logger.LogInformation("Sitemap.xml generation completed successfully during application startup");
231+
}
232+
catch (Exception ex)
233+
{
234+
logger.LogError(ex, "Failed to generate sitemap.xml during application startup");
235+
// Continue startup even if sitemap generation fails
236+
}
237+
218238
app.Run();
219239
}
220240
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Mvc.Infrastructure;
3+
using System.Reflection;
4+
5+
namespace EssentialCSharp.Web.Services;
6+
7+
public interface IRouteConfigurationService
8+
{
9+
IEnumerable<string> GetStaticRoutes();
10+
}
11+
12+
public class RouteConfigurationService : IRouteConfigurationService
13+
{
14+
private readonly IActionDescriptorCollectionProvider _ActionDescriptorCollectionProvider;
15+
private readonly HashSet<string> _StaticRoutes;
16+
17+
public RouteConfigurationService(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
18+
{
19+
_ActionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
20+
_StaticRoutes = ExtractStaticRoutes();
21+
}
22+
23+
public IEnumerable<string> GetStaticRoutes()
24+
{
25+
return _StaticRoutes;
26+
}
27+
28+
private HashSet<string> ExtractStaticRoutes()
29+
{
30+
var routes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
31+
32+
// Get all action descriptors
33+
var actionDescriptors = _ActionDescriptorCollectionProvider.ActionDescriptors.Items;
34+
35+
foreach (var actionDescriptor in actionDescriptors)
36+
{
37+
// Look for route attributes
38+
if (actionDescriptor.AttributeRouteInfo?.Template != null)
39+
{
40+
string template = actionDescriptor.AttributeRouteInfo.Template;
41+
42+
// Remove leading slash and add to our set
43+
string routePath = template.TrimStart('/').ToLowerInvariant();
44+
routes.Add(routePath);
45+
}
46+
47+
// Skip Identity area routes
48+
if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity")
49+
continue;
50+
51+
// Skip the default fallback route (Index action in HomeController)
52+
if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index")
53+
continue;
54+
55+
// Skip Error actions
56+
if (action == "Error")
57+
continue;
58+
59+
// For actions without attribute routes, use conventional routing
60+
if (actionDescriptor.AttributeRouteInfo?.Template == null &&
61+
actionDescriptor.RouteValues.TryGetValue("action", out var actionName) &&
62+
actionDescriptor.RouteValues.TryGetValue("controller", out var controllerName))
63+
{
64+
if (controllerName?.Equals("Home", StringComparison.OrdinalIgnoreCase) == true && actionName != null)
65+
{
66+
// Use the action name directly as the route
67+
routes.Add(actionName.ToLowerInvariant());
68+
}
69+
}
70+
}
71+
72+
return routes;
73+
}
74+
}

0 commit comments

Comments
 (0)