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.ViewModels;
using System.Net;
using System.Text.Json;
namespace PlotLine.Controllers;
[Route("help")]
[AllowAnonymous]
public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown) : Controller
public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown, IConfiguration configuration) : Controller
{
[HttpGet("")]
public IActionResult Index(string? q)
@ -18,7 +19,21 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
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();
return View(new HelpCentreIndexViewModel
{
@ -53,7 +68,22 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
}
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
{
CategoryName = category,
@ -76,7 +106,25 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
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
{
Article = article,
@ -111,4 +159,35 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
string.IsNullOrWhiteSpace(HttpContext.Request.QueryString.Value)
? path
: $"{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 MarkdownContent { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public DateTime LastModifiedUtc { get; set; }
public IReadOnlyList<string> ValidationIssues { get; set; } = [];
public string CategoryUrl => $"/help/{CategorySlug}";
public string Url => $"/help/{CategorySlug}/{Slug}";

View File

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

View File

@ -19,7 +19,7 @@
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
var canonicalUrl = ViewData["CanonicalUrl"] as string;
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";
}
<head>
@ -56,12 +56,12 @@
<meta name="twitter:description" content="@seoDescription" />
}
<meta name="twitter:image" content="@ogImageUrl" />
@foreach (var jsonLd in structuredData)
{
@Html.Raw("<script type=\"application/ld+json\">")
@Html.Raw(jsonLd)
@Html.Raw("</script>")
}
}
@foreach (var jsonLd in structuredData)
{
@Html.Raw("<script type=\"application/ld+json\">")
@Html.Raw(jsonLd)
@Html.Raw("</script>")
}
@if (!string.IsNullOrWhiteSpace(canonicalUrl))
{
@ -112,25 +112,19 @@
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<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)
{
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Projects" asp-action="Index">Projects</a>
</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">
<a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a>
</li>
<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 class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="FeatureRequests" asp-action="Index">
@ -154,25 +148,53 @@
</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>
<div class="d-flex align-items-center gap-2 flex-wrap">
@if (User.Identity?.IsAuthenticated == true)
{
<span class="navbar-text text-dark">Hello @User.Identity.Name</span>
<a class="btn btn-outline-secondary btn-sm" asp-controller="ManageAccount" asp-action="Index">Manage Account</a>
<form asp-controller="Account" asp-action="Logout" method="post" class="m-0">
<button class="btn btn-outline-secondary btn-sm" type="submit">Logout</button>
</form>
<div class="nav-item dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="accountMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
Hello @User.Identity.Name
</button>
<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
{
<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>
}
<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>

File diff suppressed because it is too large Load Diff