diff --git a/PlotLine/Controllers/HelpController.cs b/PlotLine/Controllers/HelpController.cs index bf8282c..a376ae4 100644 --- a/PlotLine/Controllers/HelpController.cs +++ b/PlotLine/Controllers/HelpController.cs @@ -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}"; } diff --git a/PlotLine/Controllers/HelpDrawerController.cs b/PlotLine/Controllers/HelpDrawerController.cs index 99d6efb..ef07f66 100644 --- a/PlotLine/Controllers/HelpDrawerController.cs +++ b/PlotLine/Controllers/HelpDrawerController.cs @@ -5,7 +5,7 @@ using PlotLine.ViewModels; namespace PlotLine.Controllers; -[Authorize] +[AllowAnonymous] public sealed class HelpDrawerController( IHelpService help, IHelpMarkdownRenderer markdown, diff --git a/PlotLine/Models/HelpModels.cs b/PlotLine/Models/HelpModels.cs index 3697a0a..c102cd7 100644 --- a/PlotLine/Models/HelpModels.cs +++ b/PlotLine/Models/HelpModels.cs @@ -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 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 ValidationIssues { get; set; } = []; + public string CategoryUrl => $"/help/{CategorySlug}"; + public string Url => $"/help/{CategorySlug}/{Slug}"; public bool IsValid => ValidationIssues.Count == 0; } diff --git a/PlotLine/Services/HelpMarkdownRenderer.cs b/PlotLine/Services/HelpMarkdownRenderer.cs index 372c263..b8c213f 100644 --- a/PlotLine/Services/HelpMarkdownRenderer.cs +++ b/PlotLine/Services/HelpMarkdownRenderer.cs @@ -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 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 $"{text}"; }); encoded = StrongRegex().Replace(encoded, "$1"); @@ -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(@"!\[(?[^\]]*)\]\((?[^)]+)\)")] private static partial Regex ImageRegex(); diff --git a/PlotLine/Services/HelpService.cs b/PlotLine/Services/HelpService.cs index 2df649b..e738f74 100644 --- a/PlotLine/Services/HelpService.cs +++ b/PlotLine/Services/HelpService.cs @@ -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 GetByCategory(string category); + IReadOnlyList GetByCategorySlug(string categorySlug); + string? GetCategoryBySlug(string categorySlug); IReadOnlyList GetCategories(); + HelpArticle? GetBySlugs(string categorySlug, string articleSlug); IReadOnlyList Search(string query); IReadOnlyList GetRelatedArticles(HelpArticle article, int maxItems = 5); IReadOnlyList GetCoverage(); @@ -68,6 +73,31 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService .ToList(); } + public IReadOnlyList 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 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 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 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 articles) + { + foreach (var group in articles.GroupBy(x => x.CategorySlug, StringComparer.OrdinalIgnoreCase)) + { + var used = new HashSet(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); diff --git a/PlotLine/TagHelpers/HelpIconTagHelper.cs b/PlotLine/TagHelpers/HelpIconTagHelper.cs index c0ff3ef..630d4f8 100644 --- a/PlotLine/TagHelpers/HelpIconTagHelper.cs +++ b/PlotLine/TagHelpers/HelpIconTagHelper.cs @@ -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($"""