Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
66 changes: 66 additions & 0 deletions EssentialCSharp.Web.Tests/SitemapXmlHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,72 @@ public async Task GenerateSitemapXml_DoesNotSetLastModifiedDateWhenSiteMappingDa
await Assert.That(siteMappingNode.LastModificationDate).IsNull();
}

[Test]
public async Task GenerateSitemapXml_GuidelinesRoute_HasHighPriority()
{
// Arrange
var siteMappings = new List<SiteMapping>();
var baseUrl = "https://test.example.com/";

// Act
var routeConfigurationService = Factory.Services.GetRequiredService<IRouteConfigurationService>();
SitemapXmlHelpers.GenerateSitemapXml(
siteMappings,
routeConfigurationService,
baseUrl,
out var nodes);

// Assert — /guidelines should have priority 0.9 (higher than default 0.5)
var guidelinesNode = nodes.FirstOrDefault(n => n.Url.Contains("/guidelines", StringComparison.OrdinalIgnoreCase));
await Assert.That(guidelinesNode).IsNotNull();
await Assert.That(guidelinesNode!.Priority).IsEqualTo(0.9M);
await Assert.That(guidelinesNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Monthly);
}

[Test]
public async Task GenerateSitemapXml_TermsOfServiceRoute_HasYearlyFrequencyAndLowPriority()
{
// Arrange
var siteMappings = new List<SiteMapping>();
var baseUrl = "https://test.example.com/";

// Act
var routeConfigurationService = Factory.Services.GetRequiredService<IRouteConfigurationService>();
SitemapXmlHelpers.GenerateSitemapXml(
siteMappings,
routeConfigurationService,
baseUrl,
out var nodes);

// Assert — /termsofservice changes rarely (Yearly) and has low priority (0.2)
var tosNode = nodes.FirstOrDefault(n => n.Url.Contains("/termsofservice", StringComparison.OrdinalIgnoreCase));
await Assert.That(tosNode).IsNotNull();
await Assert.That(tosNode!.Priority).IsEqualTo(0.2M);
await Assert.That(tosNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Yearly);
}

[Test]
public async Task GenerateSitemapXml_UnknownRoute_UsesDefaultSeoValues()
{
// Arrange
var siteMappings = new List<SiteMapping> { CreateSiteMapping(1, 1, true, "test-page-unknown-route") };
var baseUrl = "https://test.example.com/";

// Act
var routeConfigurationService = Factory.Services.GetRequiredService<IRouteConfigurationService>();
SitemapXmlHelpers.GenerateSitemapXml(
siteMappings,
routeConfigurationService,
baseUrl,
out var nodes);

// Assert — content pages without specific config get default SEO values
var contentNode = nodes.FirstOrDefault(n => n.Url.Contains("test-page-unknown-route", StringComparison.OrdinalIgnoreCase));
await Assert.That(contentNode).IsNotNull();
await Assert.That(contentNode!.Priority).IsEqualTo(0.5M);
await Assert.That(contentNode.ChangeFrequency).IsEqualTo(ChangeFrequency.Monthly);
}

private static SiteMapping CreateSiteMapping(
int chapterNumber,
int pageNumber,
Expand Down
59 changes: 59 additions & 0 deletions EssentialCSharp.Web/Constants/RouteConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Collections.Frozen;
using DotnetSitemapGenerator;

namespace EssentialCSharp.Web.Constants;

/// <summary>
/// Centralized definition of application routes and their metadata.
/// This is the single source of truth for static page route paths.
/// Update these whenever a static page is added, removed, or renamed.
/// </summary>
public static class RouteConstants
{
/// <summary>
/// Static page routes that are not content pages (e.g., informational, utility pages).
/// Content pages are dynamically discovered from sitemap.json.
/// </summary>
public static class StaticPages
{
public const string Home = "/home";
public const string About = "/about";
public const string Guidelines = "/guidelines";
public const string Announcements = "/announcements";
public const string TermsOfService = "/termsofservice";
}

/// <summary>
/// Immutable set of non-content route paths. Use to determine if a requested path
/// is a static page (non-content) rather than a content page (from sitemap).
/// </summary>
public static readonly FrozenSet<string> NonContentRoutes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
StaticPages.Home,
StaticPages.About,
StaticPages.Guidelines,
StaticPages.Announcements,
StaticPages.TermsOfService
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// SEO metadata for static routes used in sitemap.xml generation.
/// </summary>
public static class SeoMetadata
{
/// <summary>
/// Immutable map of route paths to (ChangeFrequency, Priority) tuples.
/// Keys include the leading slash (e.g. "/home") to match how ASP.NET Core
/// exposes routes. Priority is 0.0–1.0; 0.5 is the sitemap default.
/// </summary>
public static readonly FrozenDictionary<string, (ChangeFrequency Frequency, decimal Priority)> RouteConfig =
new Dictionary<string, (ChangeFrequency, decimal)>(StringComparer.OrdinalIgnoreCase)
{
{ StaticPages.Home, (ChangeFrequency.Monthly, 0.5m) },
{ StaticPages.About, (ChangeFrequency.Monthly, 0.5m) },
{ StaticPages.Guidelines, (ChangeFrequency.Monthly, 0.9m) },
{ StaticPages.Announcements, (ChangeFrequency.Monthly, 0.5m) },
{ StaticPages.TermsOfService,(ChangeFrequency.Yearly, 0.2m) }
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
}
11 changes: 6 additions & 5 deletions EssentialCSharp.Web/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using DotnetSitemapGenerator;
using EssentialCSharp.Web.Constants;
using EssentialCSharp.Web.Extensions;
using EssentialCSharp.Web.Helpers;
using EssentialCSharp.Web.Models;
Expand Down Expand Up @@ -48,35 +49,35 @@ public IActionResult Index()
}
}

[Route("/TermsOfService",
[Route(RouteConstants.StaticPages.TermsOfService,
Name = "TermsOfService")]
public IActionResult TermsOfService()
{
ViewBag.PageTitle = "Terms Of Service";
return View();
}

[Route("/Announcements", Name = "Announcements")]
[Route(RouteConstants.StaticPages.Announcements, Name = "Announcements")]
public IActionResult Announcements()
{
ViewBag.PageTitle = "Announcements";
return View();
Comment on lines +60 to 64
}

[Route("/about", Name = "about")]
[Route(RouteConstants.StaticPages.About, Name = "about")]
public IActionResult About()
{
ViewBag.PageTitle = "About";
return View();
}

[Route("/home", Name = "home")]
[Route(RouteConstants.StaticPages.Home, Name = "home")]
public IActionResult Home()
{
return View();
}

[Route("/guidelines", Name = "guidelines")]
[Route(RouteConstants.StaticPages.Guidelines, Name = "guidelines")]
public IActionResult Guidelines()
{
ViewBag.PageTitle = "Coding Guidelines";
Expand Down
25 changes: 11 additions & 14 deletions EssentialCSharp.Web/Helpers/SitemapXmlHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using DotnetSitemapGenerator;
using EssentialCSharp.Web.Constants;
using EssentialCSharp.Web.Services;

namespace EssentialCSharp.Web.Helpers;
Expand Down Expand Up @@ -74,26 +75,22 @@ private static bool IsSitemapRoute(string route) =>

private static ChangeFrequency GetChangeFrequencyForRoute(string route)
{
return route.ToLowerInvariant() switch
if (RouteConstants.SeoMetadata.RouteConfig.TryGetValue(route, out var config))
{
"/termsofservice" => ChangeFrequency.Yearly,
"/announcements" => ChangeFrequency.Monthly,
"/guidelines" => ChangeFrequency.Monthly,
_ => ChangeFrequency.Monthly
};
return config.Frequency;
}

return ChangeFrequency.Monthly;
Comment on lines 76 to +83
}

private static decimal GetPriorityForRoute(string route)
{
return route.ToLowerInvariant() switch
if (RouteConstants.SeoMetadata.RouteConfig.TryGetValue(route, out var config))
{
"/home" => 0.5M,
"/about" => 0.5M,
"/announcements" => 0.5M,
"/guidelines" => 0.9M,
"/termsofservice" => 0.2M,
_ => 0.5M
};
return config.Priority;
}

return 0.5M;
}

}
28 changes: 2 additions & 26 deletions EssentialCSharp.Web/src/components/SidebarPanel.vue
Original file line number Diff line number Diff line change
@@ -1,35 +1,11 @@
<script setup>
import { inject } from "vue";
import TocTree from "./TocTree.vue";
import { NAVIGATION_LINKS } from "../constants/routes.js";

const shell = inject("shell");
const currentPath = window.location.pathname.toLowerCase();
const navLinks = [
{
href: "/home",
iconClass: "fas fa-home me-2",
label: "Home",
activePaths: ["/", "/home"]
},
{
href: "/about",
iconClass: "fas fa-book me-2",
label: "About",
activePaths: ["/about"]
},
{
href: "/guidelines",
iconClass: "fas fa-code me-2",
label: "Guidelines",
activePaths: ["/guidelines"]
},
{
href: "/announcements",
iconClass: "fas fa-bullhorn me-2",
label: "Announcements",
activePaths: ["/announcements"]
}
];
const navLinks = NAVIGATION_LINKS;

function isActivePath(paths) {
return paths.includes(currentPath);
Expand Down
11 changes: 10 additions & 1 deletion EssentialCSharp.Web/src/composables/useSiteShell.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { useWindowSize } from "./useWindowSize.js";
import { isContentPagePath } from "../constants/routes.js";

const SMALL_SCREEN_SIZE = 768;

Expand Down Expand Up @@ -71,7 +72,15 @@ export function useSiteShell() {
const smallScreen = computed(() => (windowWidth.value || 0) < SMALL_SCREEN_SIZE);
const currentPage = findCurrentPage([], tocData) ?? [];
const chapterParentPage = currentPage.find((parent) => parent.level === 0) ?? null;
const isContentPage = computed(() => percentComplete.value !== null);

/**
* Determines if the current page is a content page (from sitemap) or a static page.
* Checks against the NON_CONTENT_ROUTES array via isContentPagePath and falls back
* to percentComplete check for additional verification that content data is loaded.
*/
const isContentPage = computed(() => {
return isContentPagePath(window.location.pathname) && percentComplete.value !== null;
});
Comment on lines +76 to +83
Comment on lines +76 to +83

for (const item of currentPage) {
expandedTocs.add(item.key);
Expand Down
76 changes: 76 additions & 0 deletions EssentialCSharp.Web/src/constants/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Centralized route constants for the frontend.
* Mirrors the backend RouteConstants to ensure consistency across frontend and backend.
* Update these whenever routes are added, removed, or changed.
*/

export const ROUTES = {
HOME: '/home',
ABOUT: '/about',
GUIDELINES: '/guidelines',
ANNOUNCEMENTS: '/announcements',
TERMS_OF_SERVICE: '/termsofservice'
};

/**
* Array of non-content route paths.
* Use with Array.includes() to determine if a path is a static page vs. a content page.
*/
export const NON_CONTENT_ROUTES = [
ROUTES.HOME,
ROUTES.ABOUT,
ROUTES.GUIDELINES,
ROUTES.ANNOUNCEMENTS,
ROUTES.TERMS_OF_SERVICE
];

/**
* Navigation link definitions for the sidebar.
* Each link includes href, label, icon class, and active path matching patterns.
* Note: ROUTES.TERMS_OF_SERVICE is intentionally excluded from this list —
* it is a legal page linked in the footer, not a primary navigation destination.
*/
export const NAVIGATION_LINKS = [
{
href: ROUTES.HOME,
label: 'Home',
iconClass: 'fas fa-home me-2',
activePaths: ['/', ROUTES.HOME],
key: 'home'
},
{
href: ROUTES.ABOUT,
label: 'About',
iconClass: 'fas fa-book me-2',
activePaths: [ROUTES.ABOUT],
key: 'about'
},
{
href: ROUTES.GUIDELINES,
label: 'Guidelines',
iconClass: 'fas fa-code me-2',
activePaths: [ROUTES.GUIDELINES],
key: 'guidelines'
},
{
href: ROUTES.ANNOUNCEMENTS,
label: 'Announcements',
iconClass: 'fas fa-bullhorn me-2',
activePaths: [ROUTES.ANNOUNCEMENTS],
key: 'announcements'
Comment on lines +38 to +60
}
];

/**
* Determines if the given path is a content page (from sitemap)
* versus a static page (non-content).
* @param {string} path - The path to check
* @returns {boolean} - True if path is a content page, false if static page
*/
export function isContentPagePath(path) {
const normalizedPath = path.toLowerCase();
return !NON_CONTENT_ROUTES.some(route =>
normalizedPath === route.toLowerCase() ||
normalizedPath.startsWith(route.toLowerCase() + '/')
);
}
Loading