Help System SEO URL Slugs

This commit is contained in:
Nick Beckley 2026-06-24 10:09:07 +01:00
parent 6896629ed0
commit c724f7b178
15 changed files with 1715 additions and 32 deletions

View File

@ -2,16 +2,23 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using PlotLine.Services;
using PlotLine.ViewModels;
using System.Net;
namespace PlotLine.Controllers;
[Route("Help")]
[Authorize]
[Route("help")]
[AllowAnonymous]
public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown) : Controller
{
[HttpGet("")]
public IActionResult Index(string? q)
{
if (!IsCanonicalPath("/help"))
{
return RedirectPermanent(PreserveQuery("/help"));
}
ViewData["CanonicalUrl"] = "https://plotdirector.com/help";
var articles = help.GetAllArticles();
return View(new HelpCentreIndexViewModel
{
@ -21,6 +28,7 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
.Select(category => new HelpCategoryViewModel
{
CategoryName = category,
CategorySlug = HelpService.Slugify(category),
ArticleCount = help.GetByCategory(category).Count
})
.ToList(),
@ -29,26 +37,46 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
});
}
[HttpGet("Category/{category}")]
public IActionResult Category(string category)
[HttpGet("{categorySlug}")]
public IActionResult Category(string categorySlug)
{
var articles = help.GetByCategory(category);
var category = help.GetCategoryBySlug(categorySlug);
if (category is null)
{
return NotFound();
}
var canonicalPath = $"/help/{HelpService.Slugify(category)}";
if (!IsCanonicalPath(canonicalPath))
{
return RedirectPermanent(canonicalPath);
}
var articles = help.GetByCategorySlug(categorySlug);
ViewData["CanonicalUrl"] = $"https://plotdirector.com{canonicalPath}";
return View(new HelpCategoryArticlesViewModel
{
CategoryName = category,
CategorySlug = HelpService.Slugify(category),
Articles = articles
});
}
[HttpGet("Article/{key}")]
public IActionResult Article(string key)
[HttpGet("{categorySlug}/{articleSlug}")]
public IActionResult Article(string categorySlug, string articleSlug)
{
var article = help.GetByContextKey(key);
var article = help.GetBySlugs(categorySlug, articleSlug);
if (article is null)
{
return View("ArticleNotFound", key);
return View("ArticleNotFound", $"{categorySlug}/{articleSlug}");
}
if (!IsCanonicalPath(article.Url))
{
return RedirectPermanent(article.Url);
}
ViewData["CanonicalUrl"] = $"https://plotdirector.com{article.Url}";
return View(new HelpCentreArticleViewModel
{
Article = article,
@ -56,4 +84,31 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
RelatedArticles = help.GetRelatedArticles(article)
});
}
[HttpGet("/Help/Category/{category}")]
public IActionResult LegacyCategory(string category)
{
var articles = help.GetByCategory(WebUtility.UrlDecode(category));
var firstArticle = articles.FirstOrDefault();
return firstArticle is null
? NotFound()
: RedirectPermanent(firstArticle.CategoryUrl);
}
[HttpGet("/Help/Article/{key}")]
public IActionResult LegacyArticle(string key)
{
var article = help.GetByContextKey(WebUtility.UrlDecode(key));
return article is null
? View("ArticleNotFound", key)
: RedirectPermanent(article.Url);
}
private bool IsCanonicalPath(string path) =>
string.Equals(HttpContext.Request.Path.Value, path, StringComparison.Ordinal);
private string PreserveQuery(string path) =>
string.IsNullOrWhiteSpace(HttpContext.Request.QueryString.Value)
? path
: $"{path}{HttpContext.Request.QueryString.Value}";
}

View File

@ -5,7 +5,7 @@ using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
[AllowAnonymous]
public sealed class HelpDrawerController(
IHelpService help,
IHelpMarkdownRenderer markdown,

View File

@ -4,8 +4,10 @@ public sealed class HelpArticle
{
public string Title { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string CategorySlug { get; set; } = string.Empty;
public IReadOnlyList<string> Tags { get; set; } = [];
public string ContextKey { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public string Micro { get; set; } = string.Empty;
public string QuickTitle { get; set; } = string.Empty;
public string QuickSummary { get; set; } = string.Empty;
@ -13,6 +15,8 @@ public sealed class HelpArticle
public string MarkdownContent { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public IReadOnlyList<string> ValidationIssues { get; set; } = [];
public string CategoryUrl => $"/help/{CategorySlug}";
public string Url => $"/help/{CategorySlug}/{Slug}";
public bool IsValid => ValidationIssues.Count == 0;
}

View File

@ -9,7 +9,7 @@ public interface IHelpMarkdownRenderer
string Render(string markdown);
}
public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
public sealed partial class HelpMarkdownRenderer(IHelpService help) : IHelpMarkdownRenderer
{
public string Render(string markdown)
{
@ -130,7 +130,7 @@ public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
private static IReadOnlyList<string> SplitTableCells(string line) =>
line.Trim('|').Split('|', StringSplitOptions.TrimEntries).ToList();
private static string RenderInline(string value)
private string RenderInline(string value)
{
var encoded = WebUtility.HtmlEncode(value);
encoded = ImageRegex().Replace(encoded, match =>
@ -142,7 +142,7 @@ public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
encoded = LinkRegex().Replace(encoded, match =>
{
var text = match.Groups["text"].Value;
var url = match.Groups["url"].Value;
var url = RewriteHelpUrl(match.Groups["url"].Value);
return $"<a href=\"{url}\" target=\"_blank\" rel=\"noopener\">{text}</a>";
});
encoded = StrongRegex().Replace(encoded, "<strong>$1</strong>");
@ -150,6 +150,34 @@ public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
return encoded;
}
private string RewriteHelpUrl(string url)
{
var decodedUrl = WebUtility.HtmlDecode(url);
const string articlePrefix = "/Help/Article/";
if (decodedUrl.StartsWith(articlePrefix, StringComparison.OrdinalIgnoreCase))
{
var key = Uri.UnescapeDataString(decodedUrl[articlePrefix.Length..]);
var article = help.GetByContextKey(key);
if (article is not null)
{
return article.Url;
}
}
const string categoryPrefix = "/Help/Category/";
if (decodedUrl.StartsWith(categoryPrefix, StringComparison.OrdinalIgnoreCase))
{
var category = Uri.UnescapeDataString(decodedUrl[categoryPrefix.Length..]);
var categoryName = help.GetByCategory(category).FirstOrDefault()?.Category;
if (!string.IsNullOrWhiteSpace(categoryName))
{
return $"/help/{HelpService.Slugify(categoryName)}";
}
}
return url;
}
[GeneratedRegex(@"!\[(?<alt>[^\]]*)\]\((?<url>[^)]+)\)")]
private static partial Regex ImageRegex();

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Hosting;
using PlotLine.Models;
using System.Globalization;
using System.Text;
namespace PlotLine.Services;
@ -9,7 +11,10 @@ public interface IHelpService
HelpArticle? GetByContextKey(string contextKey);
HelpArticle? GetByRelativePath(string relativePath);
IReadOnlyList<HelpArticle> GetByCategory(string category);
IReadOnlyList<HelpArticle> GetByCategorySlug(string categorySlug);
string? GetCategoryBySlug(string categorySlug);
IReadOnlyList<string> GetCategories();
HelpArticle? GetBySlugs(string categorySlug, string articleSlug);
IReadOnlyList<HelpArticle> Search(string query);
IReadOnlyList<HelpArticle> GetRelatedArticles(HelpArticle article, int maxItems = 5);
IReadOnlyList<HelpCoverageItem> GetCoverage();
@ -68,6 +73,31 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
.ToList();
}
public IReadOnlyList<HelpArticle> GetByCategorySlug(string categorySlug)
{
if (string.IsNullOrWhiteSpace(categorySlug))
{
return [];
}
return GetAllArticles()
.Where(x => string.Equals(x.CategorySlug, categorySlug, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.Title)
.ToList();
}
public string? GetCategoryBySlug(string categorySlug)
{
if (string.IsNullOrWhiteSpace(categorySlug))
{
return null;
}
return GetAllArticles()
.Select(x => x.Category)
.FirstOrDefault(category => string.Equals(Slugify(category), categorySlug, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<string> GetCategories() =>
GetAllArticles()
.Where(x => !string.IsNullOrWhiteSpace(x.Category))
@ -76,6 +106,18 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
.OrderBy(x => x)
.ToList();
public HelpArticle? GetBySlugs(string categorySlug, string articleSlug)
{
if (string.IsNullOrWhiteSpace(categorySlug) || string.IsNullOrWhiteSpace(articleSlug))
{
return null;
}
return GetAllArticles().FirstOrDefault(x =>
string.Equals(x.CategorySlug, categorySlug, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Slug, articleSlug, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<HelpArticle> Search(string query)
{
if (string.IsNullOrWhiteSpace(query))
@ -133,11 +175,13 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
return [];
}
return Directory
var articles = Directory
.EnumerateFiles(articleRoot, "*.md", SearchOption.AllDirectories)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => ParseArticle(articleRoot, path))
.ToList();
EnsureUniqueArticleSlugs(articles);
return articles;
}
private static HelpArticle ParseArticle(string articleRoot, string filePath)
@ -186,8 +230,10 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
{
Title = GetValue(metadata, "title"),
Category = GetValue(metadata, "category"),
CategorySlug = Slugify(GetValue(metadata, "category")),
Tags = SplitTags(GetValue(metadata, "tags")),
ContextKey = GetValue(metadata, "contextKey"),
Slug = GetArticleSlug(metadata, relativePath),
Micro = GetValue(metadata, "micro"),
QuickTitle = GetValue(metadata, "quickTitle"),
QuickSummary = GetValue(metadata, "quickSummary"),
@ -227,6 +273,8 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
if (string.IsNullOrWhiteSpace(article.ContextKey)) issues.Add("ContextKey is required.");
if (string.IsNullOrWhiteSpace(article.QuickTitle)) issues.Add("QuickTitle is required.");
if (string.IsNullOrWhiteSpace(article.QuickSummary)) issues.Add("QuickSummary is required.");
if (string.IsNullOrWhiteSpace(article.CategorySlug)) issues.Add("Category slug could not be generated.");
if (string.IsNullOrWhiteSpace(article.Slug)) issues.Add("Article slug could not be generated.");
return issues;
}
@ -253,6 +301,95 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
return score;
}
private static string GetArticleSlug(IReadOnlyDictionary<string, string> metadata, string relativePath)
{
var explicitSlug = Slugify(GetValue(metadata, "slug"));
if (!string.IsNullOrWhiteSpace(explicitSlug))
{
return explicitSlug;
}
var titleSlug = Slugify(GetValue(metadata, "title"));
if (!string.IsNullOrWhiteSpace(titleSlug))
{
return titleSlug;
}
var contextKey = GetValue(metadata, "contextKey");
var finalContextSegment = contextKey.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).LastOrDefault();
var contextSlug = Slugify(finalContextSegment ?? string.Empty);
if (!string.IsNullOrWhiteSpace(contextSlug))
{
return contextSlug;
}
return Slugify(Path.GetFileNameWithoutExtension(relativePath));
}
private static void EnsureUniqueArticleSlugs(IReadOnlyList<HelpArticle> articles)
{
foreach (var group in articles.GroupBy(x => x.CategorySlug, StringComparer.OrdinalIgnoreCase))
{
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var article in group.OrderBy(x => x.RelativePath, StringComparer.OrdinalIgnoreCase))
{
var baseSlug = string.IsNullOrWhiteSpace(article.Slug) ? "article" : article.Slug;
var slug = baseSlug;
if (used.Contains(slug))
{
var contextSuffix = Slugify(article.ContextKey.Replace('.', ' '));
slug = string.IsNullOrWhiteSpace(contextSuffix) ? baseSlug : $"{baseSlug}-{contextSuffix}";
}
var sequence = 2;
var uniqueSlug = slug;
while (!used.Add(uniqueSlug))
{
uniqueSlug = $"{slug}-{sequence}";
sequence++;
}
article.Slug = uniqueSlug;
}
}
}
public static string Slugify(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalised = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder();
var pendingSeparator = false;
foreach (var character in normalised)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsLetterOrDigit(character))
{
if (pendingSeparator && builder.Length > 0)
{
builder.Append('-');
}
builder.Append(char.ToLowerInvariant(character));
pendingSeparator = false;
continue;
}
pendingSeparator = builder.Length > 0;
}
return builder.ToString();
}
private static bool Contains(string? source, string value) =>
!string.IsNullOrWhiteSpace(source)
&& source.Contains(value, StringComparison.OrdinalIgnoreCase);

View File

@ -33,6 +33,11 @@ public sealed class HelpIconTagHelper(IHelpService help) : TagHelper
output.Attributes.SetAttribute("data-help-key", Key);
output.Attributes.SetAttribute("data-help-title", quickTitle);
output.Attributes.SetAttribute("data-help-summary", quickSummary);
if (article is not null)
{
output.Attributes.SetAttribute("data-help-url", article.Url);
}
output.Content.SetHtmlContent($"""
<button type="button"
class="plotline-help-icon"

View File

@ -38,13 +38,17 @@ public sealed class HelpCentreIndexViewModel
public sealed class HelpCategoryViewModel
{
public string CategoryName { get; set; } = string.Empty;
public string CategorySlug { get; set; } = string.Empty;
public int ArticleCount { get; set; }
public string Url => $"/help/{CategorySlug}";
}
public sealed class HelpCategoryArticlesViewModel
{
public string CategoryName { get; set; } = string.Empty;
public string CategorySlug { get; set; } = string.Empty;
public IReadOnlyList<HelpArticle> Articles { get; set; } = [];
public string Url => $"/help/{CategorySlug}";
}
public sealed class HelpCentreArticleViewModel

View File

@ -4,8 +4,8 @@
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-action="Index">Help</a>
<a asp-action="Category" asp-route-category="@Model.Article.Category">@Model.Article.Category</a>
<a href="/help">Help</a>
<a href="@Model.Article.CategoryUrl">@Model.Article.Category</a>
<span>@Model.Article.Title</span>
</nav>
@ -22,7 +22,7 @@
<p class="muted">Tags: @string.Join(", ", Model.Article.Tags)</p>
}
</div>
<a class="btn btn-outline-secondary" asp-action="Index">Help Centre</a>
<a class="btn btn-outline-secondary" href="/help">Help Centre</a>
</div>
<section class="plain-card">
@ -39,7 +39,7 @@
@foreach (var article in Model.RelatedArticles)
{
<article class="plain-card">
<h3><a asp-action="Article" asp-route-key="@article.ContextKey">@article.Title</a></h3>
<h3><a href="@article.Url">@article.Title</a></h3>
<p>@article.QuickSummary</p>
<span class="status-pill">@article.Category</span>
</article>

View File

@ -4,7 +4,7 @@
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-action="Index">Help</a>
<a href="/help">Help</a>
<span>@Model.CategoryName</span>
</nav>
@ -14,7 +14,7 @@
<h1>@Model.CategoryName</h1>
<p class="lead-text">@Model.Articles.Count article@(Model.Articles.Count == 1 ? "" : "s")</p>
</div>
<a class="btn btn-outline-secondary" asp-action="Index">Help Centre</a>
<a class="btn btn-outline-secondary" href="/help">Help Centre</a>
</div>
<section class="list-section">
@ -28,7 +28,7 @@
@foreach (var article in Model.Articles)
{
<article class="plain-card">
<h2><a asp-action="Article" asp-route-key="@article.ContextKey">@article.Title</a></h2>
<h2><a href="@article.Url">@article.Title</a></h2>
<p>@article.QuickSummary</p>
@if (article.Tags.Any())
{

View File

@ -44,7 +44,7 @@
@foreach (var article in Model.SearchResults)
{
<article class="plain-card">
<h3><a asp-action="Article" asp-route-key="@article.ContextKey">@article.Title</a></h3>
<h3><a href="@article.Url">@article.Title</a></h3>
<p>@article.QuickSummary</p>
<span class="status-pill">Category: @article.Category</span>
</article>
@ -72,7 +72,7 @@
@foreach (var category in Model.Categories)
{
<article class="plain-card">
<h3><a asp-action="Category" asp-route-category="@category.CategoryName">@category.CategoryName</a></h3>
<h3><a href="@category.Url">@category.CategoryName</a></h3>
<p class="muted">@category.ArticleCount article@(category.ArticleCount == 1 ? "" : "s")</p>
</article>
}
@ -90,7 +90,7 @@
@foreach (var article in Model.RecentArticles)
{
<tr>
<td><a asp-action="Article" asp-route-key="@article.ContextKey">@article.Title</a></td>
<td><a href="@article.Url">@article.Title</a></td>
<td>@article.Category</td>
<td>@article.QuickSummary</td>
</tr>

View File

@ -21,7 +21,7 @@ else
<p class="lead-text">@Model.Article.QuickSummary</p>
}
<p>
<a asp-controller="Help" asp-action="Article" asp-route-key="@Model.Article.ContextKey">Open in Help Centre</a>
<a href="@Model.Article.Url">Open in Help Centre</a>
</p>
<div class="help-markdown-body">
@Html.Raw(Model.RenderedHtml)

View File

@ -17,7 +17,7 @@
var defaultTitle = $"{ViewData["Title"]} - PlotDirector";
var seoTitle = isMarketingPage && ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
var seoDescription = isMarketingPage && ViewData["SeoDescription"] is string seoDescriptionValue ? seoDescriptionValue : null;
var canonicalUrl = isMarketingPage && ViewData["CanonicalUrl"] is string canonicalUrlValue ? canonicalUrlValue : 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>();
const string ogImageUrl = "https://plotdirector.com/og-image.png";
@ -36,10 +36,6 @@
{
<meta name="keywords" content="@seoKeywords" />
}
@if (!string.IsNullOrWhiteSpace(canonicalUrl))
{
<link rel="canonical" href="@canonicalUrl" />
}
<meta name="robots" content="index, follow" />
<meta property="og:site_name" content="PlotDirector" />
<meta property="og:title" content="@seoTitle" />
@ -67,6 +63,10 @@
@Html.Raw("</script>")
}
}
@if (!string.IsNullOrWhiteSpace(canonicalUrl))
{
<link rel="canonical" href="@canonicalUrl" />
}
<script>
(() => {
let savedTheme = null;

View File

@ -358,7 +358,7 @@
summaryText.textContent = summary;
const moreLink = document.createElement("a");
moreLink.href = "#";
moreLink.href = icon.dataset.helpUrl || "#";
moreLink.textContent = "More...";
moreLink.className = "plotline-help-more";
moreLink.addEventListener("click", (event) => {

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff