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 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}";
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[AllowAnonymous]
|
||||
public sealed class HelpDrawerController(
|
||||
IHelpService help,
|
||||
IHelpMarkdownRenderer markdown,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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())
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
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