Skip to content

Commit ae4fc9e

Browse files
feat(seo): add per-page descriptions and canonical metadata (#1531)
## Why Every page currently receives the same site-wide description, and `og:url` always points at the homepage. This makes chapter and static-page metadata misleading to search engines and link previews. It also has to be correct on both `essentialcsharp.com` and `dev.essentialcsharp.com`. ## What changed - Give public MVC actions page-specific descriptions. Chapter pages read ParserCli-generated chapter descriptions when present, with a heading-based fallback when the generated file is absent. - Emit exactly one canonical `<link>` and a matching `og:url` on every successful, non-Identity page, excluding query strings. The origin always comes from `SiteSettings:BaseUrl`. - Chapter pages keep the path from the parser-generated canonical (so aliases such as `/hello-world` point to `/introducing-c`), re-based from the parser's hardcoded production origin onto the configured origin. - The parser's own canonical tag is stripped from the injected head. - Share the selected description across HTML, Open Graph, and Twitter metadata, while retaining the layout fallback for pages without an override. - Cover canonical and description output for both production and dev origins with functional tests; document the metadata contract. ## Supersedes #1130 This reconciles #1130 and addresses its review findings: - **Duplicate/conflicting canonical on aliases:** fixed. There is one canonical, and it points to the primary page. - **`X-Forwarded-Host` without an allowlist:** avoided. The request `Host` is not used for canonicals, so `XForwardedHost` is not enabled. That flag would also have let a trusted proxy poison emailed reset/confirmation links. - **`XForwardedHost` is a no-op under `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`:** moot for the same reason. Also addresses this PR's own review comment: metadata tests now pin `SiteSettings:BaseUrl` instead of depending on `appsettings.Development.json`. ## Deployment note Each environment must set `SiteSettings__BaseUrl` to its own origin, for example `https://dev.essentialcsharp.com` for dev. Production already depends on this for the www→apex redirect. ## Validation The full Web test suite passes locally (186 tests), built with `/p:SkipFrontendBuild=true`. The local frontend build is blocked by an unrelated stale `@zxcvbn-ts/core` install.
1 parent a99b2b1 commit ae4fc9e

8 files changed

Lines changed: 211 additions & 4 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using EssentialCSharp.Web.Services;
2+
using Microsoft.AspNetCore.Hosting;
3+
using Moq;
4+
5+
namespace EssentialCSharp.Web.Tests;
6+
7+
public class ChapterDescriptionServiceTests
8+
{
9+
[Test]
10+
public async Task GeneratedDescriptionsOverrideHeadingFallback()
11+
{
12+
string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
13+
Directory.CreateDirectory(Path.Combine(directory, "Chapters"));
14+
try
15+
{
16+
File.WriteAllText(Path.Combine(directory, "Chapters", "sitemap.descriptions.json"),
17+
"""[{"Keys":["example-page","alias"],"Value":"A tailored chapter description."}]""");
18+
Mock<IWebHostEnvironment> environment = new();
19+
environment.SetupGet(e => e.ContentRootPath).Returns(directory);
20+
ChapterDescriptionService service = new(environment.Object);
21+
SiteMapping mapping = CreateMapping("alias", "Examples");
22+
23+
await Assert.That(service.GetDescription(mapping)).IsEqualTo("A tailored chapter description.");
24+
await Assert.That(service.GetDescription(CreateMapping("missing", "Other Examples"))).Contains("Other Examples");
25+
}
26+
finally
27+
{
28+
Directory.Delete(directory, recursive: true);
29+
}
30+
}
31+
32+
[Test]
33+
public async Task WithoutGeneratedDescriptionsUsesPageHeading()
34+
{
35+
Mock<IWebHostEnvironment> environment = new();
36+
environment.SetupGet(e => e.ContentRootPath).Returns(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
37+
ChapterDescriptionService service = new(environment.Object);
38+
39+
await Assert.That(service.GetDescription(CreateMapping("sample", "Sample Heading"))).Contains("Sample Heading");
40+
}
41+
42+
private static SiteMapping CreateMapping(string key, string heading) =>
43+
new([key], key, ["Chapters", "01", "Pages", "01.html"], 1, 1, 1,
44+
"Chapter One", heading, null, 1);
45+
}

‎EssentialCSharp.Web.Tests/FunctionalTests.cs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
using System.Net;
2+
using HtmlAgilityPack;
3+
using EssentialCSharp.Web.Services;
4+
using Microsoft.AspNetCore.Hosting;
5+
using Microsoft.AspNetCore.Mvc.Testing;
6+
using Microsoft.Extensions.DependencyInjection;
27

38
namespace EssentialCSharp.Web.Tests;
49

@@ -42,6 +47,72 @@ public async Task WhenPagesAreAccessed_TheyReturnHtml(string relativeUrl)
4247
await Assert.That(content).Contains("<html", StringComparison.OrdinalIgnoreCase);
4348
}
4449

50+
[Test]
51+
[Arguments("/about?rid=abc", "https://essentialcsharp.com/about", "Learn about Essential C#")]
52+
[Arguments("/home", "https://essentialcsharp.com/home", "Explore Essential C#")]
53+
[Arguments("/cookie-policy", "https://essentialcsharp.com/cookie-policy", "uses cookies")]
54+
[Arguments("/guidelines", "https://essentialcsharp.com/guidelines", "coding guidelines")]
55+
[Arguments("/announcements", "https://essentialcsharp.com/announcements", "announcements")]
56+
[Arguments("/TermsOfService", "https://essentialcsharp.com/TermsOfService", "terms of service")]
57+
[Arguments("/mcp-setup", "https://essentialcsharp.com/mcp-setup", "Model Context Protocol")]
58+
[Arguments("/introducing-c", "https://essentialcsharp.com/introducing-c", "Introducing C#")]
59+
[Arguments("/hello-world", "https://essentialcsharp.com/introducing-c", "Hello")]
60+
public async Task PagesHaveOneCanonicalAndPageSpecificDescriptions(string path, string expectedCanonical, string expectedDescription)
61+
{
62+
await using WebApplicationFactory<Program> factory = CreateFactoryWithBaseUrl("https://essentialcsharp.com");
63+
await AssertPageMetadata(factory, path, expectedCanonical, expectedDescription);
64+
}
65+
66+
[Test]
67+
[Arguments("/about", "https://dev.essentialcsharp.com/about", "Learn about Essential C#")]
68+
[Arguments("/introducing-c", "https://dev.essentialcsharp.com/introducing-c", "Introducing C#")]
69+
[Arguments("/hello-world?rid=abc", "https://dev.essentialcsharp.com/introducing-c", "Hello")]
70+
public async Task CanonicalUrlsUseConfiguredDeploymentOrigin(string path, string expectedCanonical, string expectedDescription)
71+
{
72+
await using WebApplicationFactory<Program> factory = CreateFactoryWithBaseUrl("https://dev.essentialcsharp.com/");
73+
await AssertPageMetadata(factory, path, expectedCanonical, expectedDescription);
74+
}
75+
76+
private WebApplicationFactory<Program> CreateFactoryWithBaseUrl(string baseUrl) =>
77+
Factory.Inner.WithWebHostBuilder(builder => builder.ConfigureServices(services =>
78+
services.PostConfigure<SiteSettings>(settings => settings.BaseUrl = baseUrl)));
79+
80+
private static async Task AssertPageMetadata(WebApplicationFactory<Program> factory, string path, string expectedCanonical, string expectedDescription)
81+
{
82+
using HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
83+
using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, path);
84+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
85+
86+
HtmlDocument document = new();
87+
document.LoadHtml(await response.Content.ReadAsStringAsync());
88+
HtmlNode head = document.DocumentNode.SelectSingleNode("//head") ?? throw new InvalidOperationException("Missing head element");
89+
HtmlNodeCollection canonicals = head.SelectNodes("link[@rel='canonical']") ?? throw new InvalidOperationException("Missing canonical link");
90+
await Assert.That(canonicals.Count).IsEqualTo(1);
91+
await Assert.That(canonicals[0].GetAttributeValue("href", "")).IsEqualTo(expectedCanonical);
92+
await Assert.That((head.SelectSingleNode("meta[@property='og:url']") ?? throw new InvalidOperationException("Missing OG URL")).GetAttributeValue("content", "")).IsEqualTo(expectedCanonical);
93+
94+
string description = (head.SelectSingleNode("meta[@name='description']") ?? throw new InvalidOperationException("Missing description")).GetAttributeValue("content", "");
95+
await Assert.That(description).Contains(expectedDescription);
96+
await Assert.That((head.SelectNodes("meta[@name='description']") ?? throw new InvalidOperationException("Missing description")).Count).IsEqualTo(1);
97+
await Assert.That((head.SelectSingleNode("meta[@property='og:description']") ?? throw new InvalidOperationException("Missing OG description")).GetAttributeValue("content", "")).IsEqualTo(description);
98+
await Assert.That((head.SelectSingleNode("meta[@name='twitter:description']") ?? throw new InvalidOperationException("Missing Twitter description")).GetAttributeValue("content", "")).IsEqualTo(description);
99+
}
100+
101+
[Test]
102+
public async Task NotFoundPageKeepsDescriptionFallbackWithoutCanonical()
103+
{
104+
using HttpClient client = CreateClientWithoutAutoRedirect();
105+
using HttpResponseMessage response = await GetFollowingGetRedirectsAsync(client, "/non-existing-page1234");
106+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
107+
108+
HtmlDocument document = new();
109+
document.LoadHtml(await response.Content.ReadAsStringAsync());
110+
HtmlNode head = document.DocumentNode.SelectSingleNode("//head") ?? throw new InvalidOperationException("Missing head element");
111+
await Assert.That(head.SelectNodes("link[@rel='canonical']")).IsNull();
112+
await Assert.That((head.SelectSingleNode("meta[@name='description']") ?? throw new InvalidOperationException("Missing description")).GetAttributeValue("content", ""))
113+
.Contains("Accelerate your development knowledge");
114+
}
115+
45116
[Test]
46117
public async Task WhenTheApplicationStarts_NonExistingPage_GivesCorrectStatusCode()
47118
{

‎EssentialCSharp.Web/Controllers/HomeController.cs‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
namespace EssentialCSharp.Web.Controllers;
1515

16-
public class HomeController(ILogger<HomeController> logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions<SiteSettings> siteSettings) : BaseController(routeConfigurationService, httpContextAccessor)
16+
public class HomeController(ILogger<HomeController> logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, ChapterDescriptionService chapterDescriptionService, IHttpContextAccessor httpContextAccessor, IRouteConfigurationService routeConfigurationService, IOptions<SiteSettings> siteSettings) : BaseController(routeConfigurationService, httpContextAccessor)
1717
{
1818
[EnableRateLimiting("content")]
1919
public IActionResult Index()
@@ -40,10 +40,29 @@ public IActionResult Index()
4040
styleNode.SetAttributeValue("nonce", nonce);
4141
}
4242
}
43+
// The parser emits a canonical with a hardcoded production origin. Keep its path (so alias routes
44+
// still point at the primary page) but re-base it on this deployment's configured origin, and
45+
// remove it from the injected head so the layout renders exactly one canonical.
46+
string canonicalPath = "/" + siteMapping.PrimaryKey;
47+
foreach (HtmlNode canonicalNode in doc.DocumentNode.SelectNodes("/html/head/link[@rel='canonical']")?.ToList() ?? [])
48+
{
49+
string href = canonicalNode.GetAttributeValue("href", "");
50+
if (Uri.TryCreate(href, UriKind.Absolute, out Uri? absolute))
51+
{
52+
canonicalPath = absolute.AbsolutePath;
53+
}
54+
else if (href.StartsWith('/'))
55+
{
56+
canonicalPath = href.Split('?', '#')[0];
57+
}
58+
canonicalNode.Remove();
59+
}
4360
string headHtml = doc.DocumentNode.Element("html")?.Element("head")?.InnerHtml ?? string.Empty;
4461
string html = doc.DocumentNode.Element("html")?.Element("body")?.InnerHtml ?? string.Empty;
4562

4663
ViewBag.PageTitle = siteMapping.IndentLevel is 0 ? siteMapping.ChapterTitle + " " + siteMapping.RawHeading : siteMapping.RawHeading;
64+
ViewData["Description"] = chapterDescriptionService.GetDescription(siteMapping);
65+
ViewData["CanonicalUrl"] = $"{siteSettings.Value.BaseUrl.TrimEnd('/')}{Request.PathBase}{canonicalPath}";
4766
ViewBag.NextPage = FlipPage(siteMapping!.ChapterNumber, siteMapping.PageNumber, true);
4867
ViewBag.CurrentPageKey = siteMapping.PrimaryKey;
4968
ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false);
@@ -63,40 +82,46 @@ public IActionResult Index()
6382
public IActionResult TermsOfService()
6483
{
6584
ViewBag.PageTitle = "Terms Of Service";
85+
ViewData["Description"] = "Read the terms of service for using Essential C# and its online C# learning resources.";
6686
return View();
6787
}
6888

6989
[Route("/cookie-policy", Name = "CookiePolicy")]
7090
public IActionResult CookiePolicy()
7191
{
7292
ViewBag.PageTitle = "Cookie Policy";
93+
ViewData["Description"] = "Learn how Essential C# uses cookies and manages your privacy preferences.";
7394
return View();
7495
}
7596

7697
[Route("/Announcements", Name = "Announcements")]
7798
public IActionResult Announcements()
7899
{
79100
ViewBag.PageTitle = "Announcements";
101+
ViewData["Description"] = "Read the latest Essential C# announcements, updates, and news about the online C# guide.";
80102
return View();
81103
}
82104

83105
[Route("/about", Name = "about")]
84106
public IActionResult About()
85107
{
86108
ViewBag.PageTitle = "About";
109+
ViewData["Description"] = "Learn about Essential C#, the free online C# tutorial and reference by Mark Michaelis and Benjamin Michaelis.";
87110
return View();
88111
}
89112

90113
[Route("/home", Name = "home")]
91114
public IActionResult Home()
92115
{
116+
ViewData["Description"] = "Explore Essential C#, a free online C# tutorial and reference with practical examples for developers.";
93117
return View();
94118
}
95119

96120
[Route("/guidelines", Name = "guidelines")]
97121
public IActionResult Guidelines()
98122
{
99123
ViewBag.PageTitle = "Coding Guidelines";
124+
ViewData["Description"] = "Explore Essential C# coding guidelines and best practices for writing clear, maintainable C#.";
100125
FileInfo fileInfo = new(Path.Join(hostingEnvironment.ContentRootPath, "Guidelines", "guidelines.json"));
101126
if (!fileInfo.Exists)
102127
{

‎EssentialCSharp.Web/Controllers/McpSetupController.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public McpSetupController(IRouteConfigurationService routeConfigurationService,
2121
public IActionResult Index()
2222
{
2323
ViewBag.PageTitle = "MCP Setup";
24+
ViewData["Description"] = "Connect AI coding tools to Essential C# with the Model Context Protocol setup guide.";
2425
var toolInfos = _tools
2526
.OrderBy(t => t.ProtocolTool.Name)
2627
.Select(t =>

‎EssentialCSharp.Web/Program.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ private static void Main(string[] args)
267267
builder.Services.AddOutputCache();
268268
builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender));
269269
builder.Services.AddSingleton<ISiteMappingService, SiteMappingService>();
270+
builder.Services.AddSingleton<ChapterDescriptionService>();
270271
builder.Services.AddSingleton<IRouteConfigurationService, RouteConfigurationService>();
271272
builder.Services.AddSingleton<IListingSourceCodeService, ListingSourceCodeService>();
272273
builder.Services.AddSingleton<IBookToolQueryService, BookToolQueryService>();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System.Text.Json;
2+
3+
namespace EssentialCSharp.Web.Services;
4+
5+
public sealed class ChapterDescriptionService
6+
{
7+
private readonly Dictionary<string, string> _Descriptions;
8+
9+
public ChapterDescriptionService(IWebHostEnvironment environment)
10+
{
11+
string path = Path.Combine(environment.ContentRootPath, "Chapters", "sitemap.descriptions.json");
12+
if (!File.Exists(path))
13+
{
14+
_Descriptions = new Dictionary<string, string>();
15+
return;
16+
}
17+
18+
using FileStream stream = File.OpenRead(path);
19+
List<ChapterDescription> entries = JsonSerializer.Deserialize<List<ChapterDescription>>(stream)
20+
?? throw new InvalidDataException($"No chapter descriptions found in {path}");
21+
Dictionary<string, string> descriptions = new(StringComparer.OrdinalIgnoreCase);
22+
foreach (ChapterDescription entry in entries)
23+
{
24+
foreach (string key in entry.Keys)
25+
{
26+
descriptions.Add(key, entry.Value);
27+
}
28+
}
29+
_Descriptions = descriptions;
30+
}
31+
32+
public string GetDescription(SiteMapping mapping)
33+
{
34+
foreach (string key in mapping.Keys)
35+
{
36+
if (_Descriptions.TryGetValue(key, out string? description) && !string.IsNullOrWhiteSpace(description))
37+
{
38+
return description;
39+
}
40+
}
41+
42+
string heading = mapping.IndentLevel == 0 ? mapping.ChapterTitle : mapping.RawHeading;
43+
return $"Learn about {heading} in Essential C# with practical examples and explanations from the online C# guide.";
44+
}
45+
46+
private sealed record ChapterDescription(List<string> Keys, string Value);
47+
}

‎EssentialCSharp.Web/Views/Shared/_Layout.cshtml‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,35 @@
1212
@inject SignInManager<EssentialCSharpWebUser> SignInManager
1313
@inject IConfiguration Configuration
1414
@inject IOptions<CaptchaOptions> _CaptchaOptions
15+
@inject IOptions<SiteSettings> SiteSettings
1516
<!DOCTYPE html>
1617
<html lang="en">
1718
<head>
1819
@{
1920
const string imageUrl = "https://essentialcsharp.com/images/icon.png";
20-
const string description = "Accelerate your development knowledge with C# expert Mark Michaelis and Benjamin Michaelis' free, online comprehensive C# tutorial and reference that is updated through C# 11.0";
21+
const string defaultDescription = "Accelerate your development knowledge with C# expert Mark Michaelis and Benjamin Michaelis' free, online comprehensive C# tutorial and reference that is updated through C# 11.0";
22+
string description = ViewData["Description"] is string pageDescription && !string.IsNullOrWhiteSpace(pageDescription)
23+
? pageDescription
24+
: defaultDescription;
25+
string canonicalUrl = ViewData["CanonicalUrl"] as string
26+
?? $"{SiteSettings.Value.BaseUrl.TrimEnd('/')}{Context.Request.PathBase}{Context.Request.Path}";
2127
string title = $"Essential C#{(string.IsNullOrEmpty(ViewBag.PageTitle) ? string.Empty : $": {ViewBag.PageTitle}")}";
2228
}
2329
<meta charset="utf-8" />
2430
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
2531
<meta property="og:type" content="website" />
26-
<meta property="og:url" content="https://essentialcsharp.com/" />
32+
<meta property="og:url" content="@canonicalUrl" />
33+
@if (Context.Response.StatusCode < 400 && !Context.Request.Path.StartsWithSegments("/Identity"))
34+
{
35+
<link rel="canonical" href="@canonicalUrl" />
36+
}
2737
<meta property="og:image:secure_url" content="@imageUrl" />
2838
<meta property="og:image" content="@imageUrl" />
2939
<meta name="twitter:image" content="@imageUrl" />
3040
<title id="page-title">@title</title>
3141
<meta property="og:image:type" content="image/png" />
32-
<meta name="description" property="og:description" content="@description" />
42+
<meta name="description" content="@description" />
43+
<meta property="og:description" content="@description" />
3344
<meta name="twitter:description" content="@description" />
3445
<meta name="twitter:card" content="summary" />
3546
<meta name="twitter:site" content="@@IntelliTect" />

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ For any bugs, questions, or anything else with specifically the code found insid
1010

1111
For information on setting up your local development environment, please see the [Getting Started Guide](docs/getting-started.md).
1212

13+
## Page metadata
14+
15+
The shared layout uses `ViewData["Description"]` for the HTML, Open Graph, and Twitter descriptions; pages without an override use the site-wide fallback. Public page actions set their own descriptions. Chapter pages use `Chapters/sitemap.descriptions.json` when available (generated by ParserCli), or a description based on the chapter heading when the file is absent. Views can also set `ViewData["Description"]` before the layout renders.
16+
17+
The layout renders exactly one canonical link (and a matching `og:url`) for every successful, non-Identity page. Its origin always comes from `SiteSettings:BaseUrl`, so each deployment must set it to its own origin (for example `SiteSettings__BaseUrl=https://dev.essentialcsharp.com` for the dev environment). The request `Host` header is deliberately not used, to avoid host-header poisoning. For chapter pages, the path from the parser-generated canonical is kept (so alias routes such as `/hello-world` point to the primary page) and re-based onto the configured origin; the parser's own `<link rel="canonical">` is removed from the injected head. Other pages use the request path. Query strings are excluded.
18+
1319
Please use issues or discussions to report issues found.

0 commit comments

Comments
 (0)