Help System SEO URL Slugs
This commit is contained in:
parent
6896629ed0
commit
c724f7b178
@ -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}";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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())
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user