mplemented the attached SEO/navigation polish.
This commit is contained in:
parent
c724f7b178
commit
bd5bf07906
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
101
PlotLine/Controllers/SitemapController.cs
Normal file
101
PlotLine/Controllers/SitemapController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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}";
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,13 +56,13 @@
|
||||
<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>")
|
||||
}
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(canonicalUrl))
|
||||
{
|
||||
<link rel="canonical" href="@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>
|
||||
<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="btn btn-outline-secondary btn-sm" type="submit">Logout</button>
|
||||
<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
Loading…
x
Reference in New Issue
Block a user