Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Octokit" Version="14.0.0" />
<PackageVersion Include="DotnetSitemapGenerator" Version="1.0.4" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
Expand Down
65 changes: 65 additions & 0 deletions EssentialCSharp.Web.Tests/RouteConfigurationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace EssentialCSharp.Web.Tests;

public class RouteConfigurationServiceTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _Factory;
private readonly IRouteConfigurationService _RouteConfigurationService;

public RouteConfigurationServiceTests(WebApplicationFactory<Program> factory)
{
_Factory = factory;

// Get the service from the DI container to test with real routes
var scope = _Factory.Services.CreateScope();
_RouteConfigurationService = scope.ServiceProvider.GetRequiredService<IRouteConfigurationService>();
}

[Fact]
public void GetStaticRoutes_ShouldReturnExpectedRoutes()
{
// Act
var routes = _RouteConfigurationService.GetStaticRoutes().ToList();

// Assert
Assert.NotEmpty(routes);

// Check for expected routes from the HomeController
Assert.Contains("home", routes);
Assert.Contains("about", routes);
Assert.Contains("guidelines", routes);
Assert.Contains("announcements", routes);
Assert.Contains("termsofservice", routes);
}

[Fact]
public void GetStaticRoutes_ShouldIncludeAllHomeControllerRoutes()
{
// Act
var routes = _RouteConfigurationService.GetStaticRoutes().ToHashSet(StringComparer.OrdinalIgnoreCase);

// Assert - check all expected routes from HomeController
var expectedRoutes = new[] { "home", "about", "guidelines", "announcements", "termsofservice" };

foreach (var expectedRoute in expectedRoutes)
{
Assert.True(routes.Contains(expectedRoute),
$"Expected route '{expectedRoute}' was not found in discovered routes: [{string.Join(", ", routes)}]");
}
}

[Fact]
public void GetStaticRoutes_ShouldNotIncludeIdentityRoutes()
{
// Act
var routes = _RouteConfigurationService.GetStaticRoutes();

// Assert - ensure no Identity area routes are included
Assert.DoesNotContain("identity", routes, StringComparer.OrdinalIgnoreCase);
}


}
22 changes: 22 additions & 0 deletions EssentialCSharp.Web/Controllers/BaseController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using EssentialCSharp.Web.Services;

namespace EssentialCSharp.Web.Controllers;

public abstract class BaseController : Controller
{
private readonly IRouteConfigurationService _routeConfigurationService;

protected BaseController(IRouteConfigurationService routeConfigurationService)
{
_routeConfigurationService = routeConfigurationService;
}

public override void OnActionExecuting(ActionExecutingContext context)
{
// Automatically add static routes to all views
ViewBag.StaticRoutes = System.Text.Json.JsonSerializer.Serialize(_routeConfigurationService.GetStaticRoutes());
base.OnActionExecuting(context);
}
}
1 change: 1 addition & 0 deletions EssentialCSharp.Web/EssentialCSharp.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
<PackageReference Include="Octokit" />
<PackageReference Include="DotnetSitemapGenerator" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\images\00mindmap.svg">
Expand Down
137 changes: 137 additions & 0 deletions EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using DotnetSitemapGenerator;
using DotnetSitemapGenerator.Serialization;
using Microsoft.AspNetCore.Mvc.Infrastructure;

namespace EssentialCSharp.Web.Helpers;

public static class SitemapXmlHelpers
{
private const string RootUrl = "https://essentialcsharp.com/";

public static void EnsureSitemapHealthy(List<SiteMapping> siteMappings)
{
var groups = siteMappings.GroupBy(item => new { item.ChapterNumber, item.PageNumber });
foreach (var group in groups)
{
try
{
SiteMapping result = group.Single(item => item.IncludeInSitemapXml);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Sitemap error: Chapter {group.Key.ChapterNumber}, Page {group.Key.PageNumber} has more than one canonical link, or none: {ex.Message}", ex);
}
}
}

public static void GenerateAndSerializeSitemapXml(DirectoryInfo wwwrootDirectory, List<SiteMapping> siteMappings, ILogger logger, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
{
GenerateSitemapXml(wwwrootDirectory, siteMappings, actionDescriptorCollectionProvider, out string xmlPath, out List<SitemapNode> nodes);
XmlSerializer sitemapProvider = new();
sitemapProvider.Serialize(new SitemapModel(nodes), xmlPath, true);
logger.LogInformation("sitemap.xml successfully written to {XmlPath}", xmlPath);
}

public static void GenerateSitemapXml(DirectoryInfo wwwrootDirectory, List<SiteMapping> siteMappings, IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, out string xmlPath, out List<SitemapNode> nodes)
{
xmlPath = Path.Combine(wwwrootDirectory.FullName, "sitemap.xml");
DateTime newDateTime = DateTime.UtcNow;

// Start with the root URL
nodes = new() {
new($"{RootUrl}")
{
LastModificationDate = newDateTime,
ChangeFrequency = ChangeFrequency.Daily,
Priority = 1.0M
}
};

// Add routes dynamically discovered from controllers (excluding Identity routes)
var controllerRoutes = GetControllerRoutes(actionDescriptorCollectionProvider);
foreach (var route in controllerRoutes)
{
nodes.Add(new($"{RootUrl.TrimEnd('/')}{route}")
{
LastModificationDate = newDateTime,
ChangeFrequency = GetChangeFrequencyForRoute(route),
Priority = GetPriorityForRoute(route)
});
}

// Add site mappings from content
nodes.AddRange(siteMappings.Where(item => item.IncludeInSitemapXml).Select<SiteMapping, SitemapNode>(siteMapping => new($"{RootUrl}{siteMapping.Keys.First()}")
{
LastModificationDate = newDateTime,
ChangeFrequency = ChangeFrequency.Daily,
Priority = 0.8M
}));
}

private static List<string> GetControllerRoutes(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider)
{
var routes = new List<string>();

foreach (var actionDescriptor in actionDescriptorCollectionProvider.ActionDescriptors.Items)
{
// Skip Identity area routes
if (actionDescriptor.RouteValues.TryGetValue("area", out var area) && area == "Identity")
continue;

// Skip the default fallback route (Index action in HomeController)
if (actionDescriptor.RouteValues.TryGetValue("action", out var action) && action == "Index")
continue;

// Skip Error actions
if (action == "Error")
continue;

// Get the route template or attribute route
if (actionDescriptor.AttributeRouteInfo?.Template is string template)
{
// Clean up the template (remove parameters, etc.)
var cleanRoute = template.TrimStart('/');
if (!string.IsNullOrEmpty(cleanRoute) && !routes.Contains($"/{cleanRoute}"))
{
routes.Add($"/{cleanRoute}");
}
}
}

return routes.Distinct().OrderBy(r => r).ToList();
}

private static ChangeFrequency GetChangeFrequencyForRoute(string route)
{
return route.ToLowerInvariant() switch
{
"/termsofservice" => ChangeFrequency.Yearly,
"/announcements" => ChangeFrequency.Monthly,
"/guidelines" => ChangeFrequency.Monthly,
_ => ChangeFrequency.Monthly
};
}

private static decimal GetPriorityForRoute(string route)
{
return route.ToLowerInvariant() switch
{
"/home" => 0.5M,
"/about" => 0.5M,
"/announcements" => 0.5M,
"/guidelines" => 0.9M,
"/termsofservice" => 0.2M,
_ => 0.5M
};
}
}

public class InvalidItemException : Exception
{
public InvalidItemException(string? message) : base(message)
{
}
public InvalidItemException(string? message, Exception exception) : base(message, exception)
{
}
}
36 changes: 28 additions & 8 deletions EssentialCSharp.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators;
using EssentialCSharp.Web.Data;
using EssentialCSharp.Web.Extensions;
using EssentialCSharp.Web.Helpers;
using EssentialCSharp.Web.Middleware;
using EssentialCSharp.Web.Services;
using EssentialCSharp.Web.Services.Referrals;
using Mailjet.Client;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace EssentialCSharp.Web;
Expand Down Expand Up @@ -41,7 +43,7 @@ private static void Main(string[] args)
// Create a temporary logger for startup logging
using var loggerFactory = LoggerFactory.Create(loggingBuilder =>
loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Information));
var logger = loggerFactory.CreateLogger<Program>();
var initialLogger = loggerFactory.CreateLogger<Program>();

if (!builder.Environment.IsDevelopment())
{
Expand All @@ -51,17 +53,17 @@ private static void Main(string[] args)

if (!string.IsNullOrEmpty(appInsightsConnectionString))
{
builder.Services.AddOpenTelemetry().UseAzureMonitor(
options =>
{
options.ConnectionString = appInsightsConnectionString;
});
builder.Services.AddOpenTelemetry().UseAzureMonitor(
options =>
{
options.ConnectionString = appInsightsConnectionString;
});
builder.Services.AddApplicationInsightsTelemetry();
builder.Services.AddServiceProfiler();
}
else
{
logger.LogWarning("Application Insights connection string not found. Telemetry collection will be disabled.");
initialLogger.LogWarning("Application Insights connection string not found. Telemetry collection will be disabled.");
}
}

Expand Down Expand Up @@ -146,6 +148,7 @@ private static void Main(string[] args)
builder.Services.AddRazorPages();
builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender));
builder.Services.AddSingleton<ISiteMappingService, SiteMappingService>();
builder.Services.AddScoped<IRouteConfigurationService, RouteConfigurationService>();
builder.Services.AddHostedService<DatabaseMigrationService>();
builder.Services.AddScoped<IReferralService, ReferralService>();

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


WebApplication app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
Expand Down Expand Up @@ -215,6 +217,24 @@ private static void Main(string[] args)

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

// Generate sitemap.xml at startup
var wwwrootDirectory = new DirectoryInfo(app.Environment.WebRootPath);
var siteMappingService = app.Services.GetRequiredService<ISiteMappingService>();
var actionDescriptorCollectionProvider = app.Services.GetRequiredService<IActionDescriptorCollectionProvider>();
var logger = app.Services.GetRequiredService<ILogger<Program>>();

try
{
SitemapXmlHelpers.EnsureSitemapHealthy(siteMappingService.SiteMappings.ToList());
SitemapXmlHelpers.GenerateAndSerializeSitemapXml(wwwrootDirectory, siteMappingService.SiteMappings.ToList(), logger, actionDescriptorCollectionProvider);
logger.LogInformation("Sitemap.xml generation completed successfully during application startup");
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to generate sitemap.xml during application startup");
// Continue startup even if sitemap generation fails
}

app.Run();
}
}
Loading
Loading