mplemented the attached SEO/navigation polish.

This commit is contained in:
Nick Beckley 2026-06-24 10:42:30 +01:00
parent c724f7b178
commit bd5bf07906
6 changed files with 235 additions and 1514 deletions

View File

@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Authorization;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels; using PlotLine.ViewModels;
using System.Net; using System.Net;
using System.Text.Json;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
[Route("help")] [Route("help")]
[AllowAnonymous] [AllowAnonymous]
public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown) : Controller public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown, IConfiguration configuration) : Controller
{ {
[HttpGet("")] [HttpGet("")]
public IActionResult Index(string? q) public IActionResult Index(string? q)
@ -18,7 +19,21 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
return RedirectPermanent(PreserveQuery("/help")); return RedirectPermanent(PreserveQuery("/help"));
} }
ViewData["CanonicalUrl"] = "https://plotdirector.com/help"; var canonicalUrl = BuildAbsoluteUrl("/help");
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
{
JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "CollectionPage",
["name"] = "Help Centre",
["url"] = canonicalUrl,
["description"] = "Find practical guidance for planning, tracking, and shaping your story in PlotDirector."
}),
BuildBreadcrumbJsonLd(
("Help", canonicalUrl))
};
var articles = help.GetAllArticles(); var articles = help.GetAllArticles();
return View(new HelpCentreIndexViewModel return View(new HelpCentreIndexViewModel
{ {
@ -53,7 +68,22 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
} }
var articles = help.GetByCategorySlug(categorySlug); var articles = help.GetByCategorySlug(categorySlug);
ViewData["CanonicalUrl"] = $"https://plotdirector.com{canonicalPath}"; var canonicalUrl = BuildAbsoluteUrl(canonicalPath);
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
{
JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "CollectionPage",
["name"] = $"{category} Help",
["url"] = canonicalUrl,
["description"] = $"Help articles about {category} in PlotDirector."
}),
BuildBreadcrumbJsonLd(
("Help", BuildAbsoluteUrl("/help")),
(category, canonicalUrl))
};
return View(new HelpCategoryArticlesViewModel return View(new HelpCategoryArticlesViewModel
{ {
CategoryName = category, CategoryName = category,
@ -76,7 +106,25 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
return RedirectPermanent(article.Url); return RedirectPermanent(article.Url);
} }
ViewData["CanonicalUrl"] = $"https://plotdirector.com{article.Url}"; var canonicalUrl = BuildAbsoluteUrl(article.Url);
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
{
JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "TechArticle",
["headline"] = article.Title,
["name"] = article.Title,
["url"] = canonicalUrl,
["description"] = string.IsNullOrWhiteSpace(article.QuickSummary) ? null : article.QuickSummary,
["dateModified"] = article.LastModifiedUtc == default ? null : article.LastModifiedUtc.ToString("O")
}),
BuildBreadcrumbJsonLd(
("Help", BuildAbsoluteUrl("/help")),
(article.Category, BuildAbsoluteUrl(article.CategoryUrl)),
(article.Title, canonicalUrl))
};
return View(new HelpCentreArticleViewModel return View(new HelpCentreArticleViewModel
{ {
Article = article, Article = article,
@ -111,4 +159,35 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
string.IsNullOrWhiteSpace(HttpContext.Request.QueryString.Value) string.IsNullOrWhiteSpace(HttpContext.Request.QueryString.Value)
? path ? path
: $"{path}{HttpContext.Request.QueryString.Value}"; : $"{path}{HttpContext.Request.QueryString.Value}";
private string BuildAbsoluteUrl(string path)
{
var baseUrl = configuration["Site:PublicBaseUrl"]?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = "https://plotdirector.com";
}
return $"{baseUrl}{path}";
}
private static string BuildBreadcrumbJsonLd(params (string Name, string Url)[] items) =>
JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "BreadcrumbList",
["itemListElement"] = items.Select((item, index) => new Dictionary<string, object?>
{
["@type"] = "ListItem",
["position"] = index + 1,
["name"] = item.Name,
["item"] = item.Url
})
});
private static string JsonLd(object value) =>
JsonSerializer.Serialize(value, new JsonSerializerOptions
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
});
} }

View File

@ -0,0 +1,101 @@
using System.Text;
using System.Xml;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Models;
using PlotLine.Services;
namespace PlotLine.Controllers;
[AllowAnonymous]
public sealed class SitemapController(IHelpService help, IConfiguration configuration) : Controller
{
[HttpGet("/sitemap.xml")]
public IActionResult Index()
{
var urls = new List<SitemapUrl>
{
new("/", "weekly", "1.0"),
new("/pricing", "weekly", "0.8"),
new("/help", "weekly", "0.7"),
new("/about", "monthly", "0.6"),
new("/contact", "monthly", "0.5"),
new("/privacy", "yearly", "0.3"),
new("/terms", "yearly", "0.3")
};
var articles = help.GetAllArticles();
urls.AddRange(articles
.Where(article => !string.IsNullOrWhiteSpace(article.CategorySlug))
.GroupBy(article => article.CategorySlug, StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => new SitemapUrl(
$"/help/{group.Key}",
"monthly",
"0.6",
group.Max(article => article.LastModifiedUtc))));
urls.AddRange(articles
.Where(article => !string.IsNullOrWhiteSpace(article.CategorySlug) && !string.IsNullOrWhiteSpace(article.Slug))
.OrderBy(article => article.CategorySlug, StringComparer.OrdinalIgnoreCase)
.ThenBy(article => article.Slug, StringComparer.OrdinalIgnoreCase)
.Select(article => new SitemapUrl(
article.Url,
"monthly",
"0.5",
article.LastModifiedUtc)));
return Content(BuildXml(urls), "application/xml", Encoding.UTF8);
}
private string BuildXml(IEnumerable<SitemapUrl> urls)
{
var settings = new XmlWriterSettings
{
Encoding = new UTF8Encoding(false),
Indent = true,
OmitXmlDeclaration = false
};
using var writer = new Utf8StringWriter();
using var xml = XmlWriter.Create(writer, settings);
xml.WriteStartDocument();
xml.WriteStartElement("urlset", "http://www.sitemaps.org/schemas/sitemap/0.9");
foreach (var url in urls)
{
xml.WriteStartElement("url");
xml.WriteElementString("loc", BuildAbsoluteUrl(url.Path));
if (url.LastModifiedUtc is DateTime lastModifiedUtc && lastModifiedUtc != default)
{
xml.WriteElementString("lastmod", lastModifiedUtc.ToString("yyyy-MM-dd"));
}
xml.WriteElementString("changefreq", url.ChangeFrequency);
xml.WriteElementString("priority", url.Priority);
xml.WriteEndElement();
}
xml.WriteEndElement();
xml.WriteEndDocument();
xml.Flush();
return writer.ToString();
}
private string BuildAbsoluteUrl(string path)
{
var baseUrl = configuration["Site:PublicBaseUrl"]?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = "https://plotdirector.com";
}
return $"{baseUrl}{path}";
}
private sealed record SitemapUrl(string Path, string ChangeFrequency, string Priority, DateTime? LastModifiedUtc = null);
private sealed class Utf8StringWriter : StringWriter
{
public override Encoding Encoding => Encoding.UTF8;
}
}

View File

@ -14,6 +14,7 @@ public sealed class HelpArticle
public string Screenshot { get; set; } = string.Empty; public string Screenshot { get; set; } = string.Empty;
public string MarkdownContent { get; set; } = string.Empty; public string MarkdownContent { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty; public string RelativePath { get; set; } = string.Empty;
public DateTime LastModifiedUtc { get; set; }
public IReadOnlyList<string> ValidationIssues { get; set; } = []; public IReadOnlyList<string> ValidationIssues { get; set; } = [];
public string CategoryUrl => $"/help/{CategorySlug}"; public string CategoryUrl => $"/help/{CategorySlug}";
public string Url => $"/help/{CategorySlug}/{Slug}"; public string Url => $"/help/{CategorySlug}/{Slug}";

View File

@ -239,7 +239,8 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
QuickSummary = GetValue(metadata, "quickSummary"), QuickSummary = GetValue(metadata, "quickSummary"),
Screenshot = GetValue(metadata, "screenshot"), Screenshot = GetValue(metadata, "screenshot"),
MarkdownContent = markdownContent, MarkdownContent = markdownContent,
RelativePath = relativePath RelativePath = relativePath,
LastModifiedUtc = File.GetLastWriteTimeUtc(filePath)
}; };
issues.AddRange(Validate(article)); issues.AddRange(Validate(article));

View File

@ -19,7 +19,7 @@
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null; var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
var canonicalUrl = ViewData["CanonicalUrl"] as string; var canonicalUrl = ViewData["CanonicalUrl"] as string;
var seoKeywords = isMarketingPage && ViewData["SeoKeywords"] is string seoKeywordsValue ? seoKeywordsValue : null; var seoKeywords = isMarketingPage && ViewData["SeoKeywords"] is string seoKeywordsValue ? seoKeywordsValue : null;
var structuredData = isMarketingPage && ViewData["StructuredData"] is IEnumerable<string> structuredDataValues ? structuredDataValues : Array.Empty<string>(); var structuredData = ViewData["StructuredData"] is IEnumerable<string> structuredDataValues ? structuredDataValues : Array.Empty<string>();
const string ogImageUrl = "https://plotdirector.com/og-image.png"; const string ogImageUrl = "https://plotdirector.com/og-image.png";
} }
<head> <head>
@ -56,12 +56,12 @@
<meta name="twitter:description" content="@seoDescription" /> <meta name="twitter:description" content="@seoDescription" />
} }
<meta name="twitter:image" content="@ogImageUrl" /> <meta name="twitter:image" content="@ogImageUrl" />
@foreach (var jsonLd in structuredData) }
{ @foreach (var jsonLd in structuredData)
@Html.Raw("<script type=\"application/ld+json\">") {
@Html.Raw(jsonLd) @Html.Raw("<script type=\"application/ld+json\">")
@Html.Raw("</script>") @Html.Raw(jsonLd)
} @Html.Raw("</script>")
} }
@if (!string.IsNullOrWhiteSpace(canonicalUrl)) @if (!string.IsNullOrWhiteSpace(canonicalUrl))
{ {
@ -112,25 +112,19 @@
</button> </button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between"> <div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1"> <ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Pricing" asp-action="Index">Pricing</a>
</li>
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Projects" asp-action="Index">Projects</a> <a class="nav-link text-dark" asp-area="" asp-controller="Projects" asp-action="Index">Projects</a>
</li> </li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Imports" asp-action="Index">Import</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a> <a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Help" asp-action="Index">Help</a> <a class="nav-link text-dark" asp-area="" asp-controller="Imports" asp-action="Index">Import</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/help">Help</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index"> <a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
@ -154,25 +148,53 @@
</li> </li>
} }
} }
else
{
<li class="nav-item">
<a class="nav-link text-dark" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/pricing">Pricing</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="/help">Help</a>
</li>
}
</ul> </ul>
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2 flex-wrap">
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<span class="navbar-text text-dark">Hello @User.Identity.Name</span> <div class="nav-item dropdown">
<a class="btn btn-outline-secondary btn-sm" asp-controller="ManageAccount" asp-action="Index">Manage Account</a> <button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="accountMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
<form asp-controller="Account" asp-action="Logout" method="post" class="m-0"> Hello @User.Identity.Name
<button class="btn btn-outline-secondary btn-sm" type="submit">Logout</button> </button>
</form> <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="accountMenuButton">
<li><a class="dropdown-item" asp-controller="ManageAccount" asp-action="Index">Manage Account</a></li>
<li><a class="dropdown-item" asp-controller="ManageAccount" asp-action="Subscription">Billing / Subscription</a></li>
<li><hr class="dropdown-divider" /></li>
<li>
<div class="px-3 py-2">
<div class="small text-muted mb-2">Theme</div>
<div class="theme-switcher btn-group btn-group-sm w-100" role="group" aria-label="Theme selection">
<button type="button" class="btn btn-outline-secondary" data-theme-choice="light" aria-pressed="true">Light</button>
<button type="button" class="btn btn-outline-secondary" data-theme-choice="dark" aria-pressed="false">Dark</button>
</div>
</div>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<form asp-controller="Account" asp-action="Logout" method="post" class="m-0">
<button class="dropdown-item" type="submit">Logout</button>
</form>
</li>
</ul>
</div>
} }
else else
{ {
<a class="btn btn-outline-secondary btn-sm" asp-controller="Account" asp-action="Login">Login</a> <a class="btn btn-outline-secondary btn-sm" asp-controller="Account" asp-action="Login">Login</a>
<a class="btn btn-outline-primary btn-sm" asp-controller="Account" asp-action="Register">Register</a> <a class="btn btn-outline-primary btn-sm" asp-controller="Account" asp-action="Register">Register</a>
} }
<div class="theme-switcher btn-group btn-group-sm" role="group" aria-label="Theme selection">
<button type="button" class="btn btn-outline-secondary" data-theme-choice="light" aria-pressed="true">Light</button>
<button type="button" class="btn btn-outline-secondary" data-theme-choice="dark" aria-pressed="false">Dark</button>
</div>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff