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.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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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 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}";
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user