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 Microsoft.AspNetCore.Authorization;
using PlotLine.Services; using PlotLine.Services;
using PlotLine.ViewModels; using PlotLine.ViewModels;
using System.Net;
namespace PlotLine.Controllers; namespace PlotLine.Controllers;
[Route("Help")] [Route("help")]
[Authorize] [AllowAnonymous]
public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown) : Controller public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer markdown) : Controller
{ {
[HttpGet("")] [HttpGet("")]
public IActionResult Index(string? q) public IActionResult Index(string? q)
{ {
if (!IsCanonicalPath("/help"))
{
return RedirectPermanent(PreserveQuery("/help"));
}
ViewData["CanonicalUrl"] = "https://plotdirector.com/help";
var articles = help.GetAllArticles(); var articles = help.GetAllArticles();
return View(new HelpCentreIndexViewModel return View(new HelpCentreIndexViewModel
{ {
@ -21,6 +28,7 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
.Select(category => new HelpCategoryViewModel .Select(category => new HelpCategoryViewModel
{ {
CategoryName = category, CategoryName = category,
CategorySlug = HelpService.Slugify(category),
ArticleCount = help.GetByCategory(category).Count ArticleCount = help.GetByCategory(category).Count
}) })
.ToList(), .ToList(),
@ -29,26 +37,46 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
}); });
} }
[HttpGet("Category/{category}")] [HttpGet("{categorySlug}")]
public IActionResult Category(string category) 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 return View(new HelpCategoryArticlesViewModel
{ {
CategoryName = category, CategoryName = category,
CategorySlug = HelpService.Slugify(category),
Articles = articles Articles = articles
}); });
} }
[HttpGet("Article/{key}")] [HttpGet("{categorySlug}/{articleSlug}")]
public IActionResult Article(string key) public IActionResult Article(string categorySlug, string articleSlug)
{ {
var article = help.GetByContextKey(key); var article = help.GetBySlugs(categorySlug, articleSlug);
if (article is null) 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 return View(new HelpCentreArticleViewModel
{ {
Article = article, Article = article,
@ -56,4 +84,31 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
RelatedArticles = help.GetRelatedArticles(article) 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; namespace PlotLine.Controllers;
[Authorize] [AllowAnonymous]
public sealed class HelpDrawerController( public sealed class HelpDrawerController(
IHelpService help, IHelpService help,
IHelpMarkdownRenderer markdown, IHelpMarkdownRenderer markdown,

View File

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

View File

@ -9,7 +9,7 @@ public interface IHelpMarkdownRenderer
string Render(string markdown); string Render(string markdown);
} }
public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer public sealed partial class HelpMarkdownRenderer(IHelpService help) : IHelpMarkdownRenderer
{ {
public string Render(string markdown) public string Render(string markdown)
{ {
@ -130,7 +130,7 @@ public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
private static IReadOnlyList<string> SplitTableCells(string line) => private static IReadOnlyList<string> SplitTableCells(string line) =>
line.Trim('|').Split('|', StringSplitOptions.TrimEntries).ToList(); line.Trim('|').Split('|', StringSplitOptions.TrimEntries).ToList();
private static string RenderInline(string value) private string RenderInline(string value)
{ {
var encoded = WebUtility.HtmlEncode(value); var encoded = WebUtility.HtmlEncode(value);
encoded = ImageRegex().Replace(encoded, match => encoded = ImageRegex().Replace(encoded, match =>
@ -142,7 +142,7 @@ public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
encoded = LinkRegex().Replace(encoded, match => encoded = LinkRegex().Replace(encoded, match =>
{ {
var text = match.Groups["text"].Value; 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>"; return $"<a href=\"{url}\" target=\"_blank\" rel=\"noopener\">{text}</a>";
}); });
encoded = StrongRegex().Replace(encoded, "<strong>$1</strong>"); encoded = StrongRegex().Replace(encoded, "<strong>$1</strong>");
@ -150,6 +150,34 @@ public sealed partial class HelpMarkdownRenderer : IHelpMarkdownRenderer
return encoded; 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>[^)]+)\)")] [GeneratedRegex(@"!\[(?<alt>[^\]]*)\]\((?<url>[^)]+)\)")]
private static partial Regex ImageRegex(); private static partial Regex ImageRegex();

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using PlotLine.Models; using PlotLine.Models;
using System.Globalization;
using System.Text;
namespace PlotLine.Services; namespace PlotLine.Services;
@ -9,7 +11,10 @@ public interface IHelpService
HelpArticle? GetByContextKey(string contextKey); HelpArticle? GetByContextKey(string contextKey);
HelpArticle? GetByRelativePath(string relativePath); HelpArticle? GetByRelativePath(string relativePath);
IReadOnlyList<HelpArticle> GetByCategory(string category); IReadOnlyList<HelpArticle> GetByCategory(string category);
IReadOnlyList<HelpArticle> GetByCategorySlug(string categorySlug);
string? GetCategoryBySlug(string categorySlug);
IReadOnlyList<string> GetCategories(); IReadOnlyList<string> GetCategories();
HelpArticle? GetBySlugs(string categorySlug, string articleSlug);
IReadOnlyList<HelpArticle> Search(string query); IReadOnlyList<HelpArticle> Search(string query);
IReadOnlyList<HelpArticle> GetRelatedArticles(HelpArticle article, int maxItems = 5); IReadOnlyList<HelpArticle> GetRelatedArticles(HelpArticle article, int maxItems = 5);
IReadOnlyList<HelpCoverageItem> GetCoverage(); IReadOnlyList<HelpCoverageItem> GetCoverage();
@ -68,6 +73,31 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
.ToList(); .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() => public IReadOnlyList<string> GetCategories() =>
GetAllArticles() GetAllArticles()
.Where(x => !string.IsNullOrWhiteSpace(x.Category)) .Where(x => !string.IsNullOrWhiteSpace(x.Category))
@ -76,6 +106,18 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
.OrderBy(x => x) .OrderBy(x => x)
.ToList(); .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) public IReadOnlyList<HelpArticle> Search(string query)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
@ -133,11 +175,13 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
return []; return [];
} }
return Directory var articles = Directory
.EnumerateFiles(articleRoot, "*.md", SearchOption.AllDirectories) .EnumerateFiles(articleRoot, "*.md", SearchOption.AllDirectories)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase) .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.Select(path => ParseArticle(articleRoot, path)) .Select(path => ParseArticle(articleRoot, path))
.ToList(); .ToList();
EnsureUniqueArticleSlugs(articles);
return articles;
} }
private static HelpArticle ParseArticle(string articleRoot, string filePath) private static HelpArticle ParseArticle(string articleRoot, string filePath)
@ -186,8 +230,10 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
{ {
Title = GetValue(metadata, "title"), Title = GetValue(metadata, "title"),
Category = GetValue(metadata, "category"), Category = GetValue(metadata, "category"),
CategorySlug = Slugify(GetValue(metadata, "category")),
Tags = SplitTags(GetValue(metadata, "tags")), Tags = SplitTags(GetValue(metadata, "tags")),
ContextKey = GetValue(metadata, "contextKey"), ContextKey = GetValue(metadata, "contextKey"),
Slug = GetArticleSlug(metadata, relativePath),
Micro = GetValue(metadata, "micro"), Micro = GetValue(metadata, "micro"),
QuickTitle = GetValue(metadata, "quickTitle"), QuickTitle = GetValue(metadata, "quickTitle"),
QuickSummary = GetValue(metadata, "quickSummary"), 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.ContextKey)) issues.Add("ContextKey is required.");
if (string.IsNullOrWhiteSpace(article.QuickTitle)) issues.Add("QuickTitle 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.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; return issues;
} }
@ -253,6 +301,95 @@ public sealed class HelpService(IWebHostEnvironment environment) : IHelpService
return score; 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) => private static bool Contains(string? source, string value) =>
!string.IsNullOrWhiteSpace(source) !string.IsNullOrWhiteSpace(source)
&& source.Contains(value, StringComparison.OrdinalIgnoreCase); && 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-key", Key);
output.Attributes.SetAttribute("data-help-title", quickTitle); output.Attributes.SetAttribute("data-help-title", quickTitle);
output.Attributes.SetAttribute("data-help-summary", quickSummary); output.Attributes.SetAttribute("data-help-summary", quickSummary);
if (article is not null)
{
output.Attributes.SetAttribute("data-help-url", article.Url);
}
output.Content.SetHtmlContent($""" output.Content.SetHtmlContent($"""
<button type="button" <button type="button"
class="plotline-help-icon" class="plotline-help-icon"

View File

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

View File

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

View File

@ -4,7 +4,7 @@
} }
<nav class="breadcrumb-trail" aria-label="Breadcrumb"> <nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-action="Index">Help</a> <a href="/help">Help</a>
<span>@Model.CategoryName</span> <span>@Model.CategoryName</span>
</nav> </nav>
@ -14,7 +14,7 @@
<h1>@Model.CategoryName</h1> <h1>@Model.CategoryName</h1>
<p class="lead-text">@Model.Articles.Count article@(Model.Articles.Count == 1 ? "" : "s")</p> <p class="lead-text">@Model.Articles.Count article@(Model.Articles.Count == 1 ? "" : "s")</p>
</div> </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> </div>
<section class="list-section"> <section class="list-section">
@ -28,7 +28,7 @@
@foreach (var article in Model.Articles) @foreach (var article in Model.Articles)
{ {
<article class="plain-card"> <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> <p>@article.QuickSummary</p>
@if (article.Tags.Any()) @if (article.Tags.Any())
{ {

View File

@ -44,7 +44,7 @@
@foreach (var article in Model.SearchResults) @foreach (var article in Model.SearchResults)
{ {
<article class="plain-card"> <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> <p>@article.QuickSummary</p>
<span class="status-pill">Category: @article.Category</span> <span class="status-pill">Category: @article.Category</span>
</article> </article>
@ -72,7 +72,7 @@
@foreach (var category in Model.Categories) @foreach (var category in Model.Categories)
{ {
<article class="plain-card"> <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> <p class="muted">@category.ArticleCount article@(category.ArticleCount == 1 ? "" : "s")</p>
</article> </article>
} }
@ -90,7 +90,7 @@
@foreach (var article in Model.RecentArticles) @foreach (var article in Model.RecentArticles)
{ {
<tr> <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.Category</td>
<td>@article.QuickSummary</td> <td>@article.QuickSummary</td>
</tr> </tr>

View File

@ -21,7 +21,7 @@ else
<p class="lead-text">@Model.Article.QuickSummary</p> <p class="lead-text">@Model.Article.QuickSummary</p>
} }
<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> </p>
<div class="help-markdown-body"> <div class="help-markdown-body">
@Html.Raw(Model.RenderedHtml) @Html.Raw(Model.RenderedHtml)

View File

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

View File

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