Public SEO pass.

This commit is contained in:
Nick Beckley 2026-06-25 19:46:23 +01:00
parent b7865c8e54
commit 18a14bbd01
6 changed files with 376 additions and 95 deletions

View File

@ -19,20 +19,27 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
}
var canonicalUrl = PublicSeo.AbsoluteUrl(configuration, "/help");
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
const string description = "Browse PlotDirector help articles covering projects, books, chapters, scenes, timelines, continuity, characters, locations, and writing tools.";
PublicSeo.Apply(ViewData, configuration, new SeoMetadata
{
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "CollectionPage",
["name"] = "Help Centre",
["url"] = canonicalUrl,
["description"] = "Find practical guidance for planning, tracking, and shaping your story in PlotDirector."
}),
PublicSeo.BreadcrumbJsonLd(
("Help", canonicalUrl))
};
Title = "Help Centre",
Description = description,
Keywords = "PlotDirector help, writing software help, story planning help, novel planning guides",
CanonicalPath = "/help",
JsonLd =
[
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "CollectionPage",
["name"] = "Help Centre",
["url"] = canonicalUrl,
["description"] = description
}),
PublicSeo.BreadcrumbJsonLd(
("Help", canonicalUrl))
]
});
var articles = help.GetAllArticles();
return View(new HelpCentreIndexViewModel
{
@ -68,21 +75,28 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
var articles = help.GetByCategorySlug(categorySlug);
var canonicalUrl = PublicSeo.AbsoluteUrl(configuration, canonicalPath);
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
var description = CategoryDescription(category);
PublicSeo.Apply(ViewData, configuration, new SeoMetadata
{
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "CollectionPage",
["name"] = $"{category} Help",
["url"] = canonicalUrl,
["description"] = $"Help articles about {category} in PlotDirector."
}),
PublicSeo.BreadcrumbJsonLd(
("Help", PublicSeo.AbsoluteUrl(configuration, "/help")),
(category, canonicalUrl))
};
Title = $"{category} Help",
Description = description,
Keywords = PublicSeo.Keywords([category, "PlotDirector help", "novel planning guides"], articles.SelectMany(article => article.Tags)),
CanonicalPath = canonicalPath,
JsonLd =
[
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "CollectionPage",
["name"] = $"{category} Help",
["url"] = canonicalUrl,
["description"] = description
}),
PublicSeo.BreadcrumbJsonLd(
("Help", PublicSeo.AbsoluteUrl(configuration, "/help")),
(category, canonicalUrl))
]
});
return View(new HelpCategoryArticlesViewModel
{
CategoryName = category,
@ -106,24 +120,32 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
}
var canonicalUrl = PublicSeo.AbsoluteUrl(configuration, article.Url);
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
var description = PublicSeo.HelpArticleDescription(article, help.GetAllArticles());
PublicSeo.Apply(ViewData, configuration, new SeoMetadata
{
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "TechArticle",
["headline"] = article.Title,
["name"] = article.Title,
["url"] = canonicalUrl,
["description"] = string.IsNullOrWhiteSpace(article.QuickSummary) ? null : article.QuickSummary,
["dateModified"] = article.LastModifiedUtc == default ? null : article.LastModifiedUtc.ToString("O")
}),
PublicSeo.BreadcrumbJsonLd(
("Help", PublicSeo.AbsoluteUrl(configuration, "/help")),
(article.Category, PublicSeo.AbsoluteUrl(configuration, article.CategoryUrl)),
(article.Title, canonicalUrl))
};
Title = article.Title,
Description = description,
Keywords = PublicSeo.Keywords([article.Category, article.Title, "PlotDirector help"], article.Tags),
CanonicalPath = article.Url,
OgType = "article",
JsonLd =
[
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "TechArticle",
["headline"] = article.Title,
["name"] = article.Title,
["url"] = canonicalUrl,
["description"] = description,
["dateModified"] = article.LastModifiedUtc == default ? null : article.LastModifiedUtc.ToString("O")
}),
PublicSeo.BreadcrumbJsonLd(
("Help", PublicSeo.AbsoluteUrl(configuration, "/help")),
(article.Category, PublicSeo.AbsoluteUrl(configuration, article.CategoryUrl)),
(article.Title, canonicalUrl))
]
});
return View(new HelpCentreArticleViewModel
{
Article = article,
@ -159,4 +181,6 @@ public sealed class HelpController(IHelpService help, IHelpMarkdownRenderer mark
? path
: $"{path}{HttpContext.Request.QueryString.Value}";
private static string CategoryDescription(string category) =>
$"Find PlotDirector help articles for {category.ToLowerInvariant()}, including practical guidance for organising story details, reviewing continuity, and keeping your novel on track.";
}

View File

@ -13,23 +13,35 @@ namespace PlotLine.Controllers
public IActionResult Index()
{
const string description = "PlotDirector helps authors organise characters, plot threads, timelines, story assets, relationships, and continuity so they can finish complex novels with confidence.";
var canonicalUrl = PublicSeo.AbsoluteUrl(configuration, "/");
ViewData["SeoTitle"] = "PlotDirector | Novel Planning Software for Authors";
ViewData["SeoDescription"] = description;
ViewData["SeoKeywords"] = "novel planning software, writing software, story planning app, author tools, plot tracking, character tracking, story timeline, novel organisation, fiction writing software, series writing software, continuity tracking, plot threads, story bible";
ViewData["CanonicalUrl"] = canonicalUrl;
ViewData["StructuredData"] = new[]
PublicSeo.Apply(ViewData, configuration, new SeoMetadata
{
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "WebSite",
["name"] = "PlotDirector",
["url"] = PublicSeo.BaseUrl(configuration),
["description"] = "Novel planning and story organisation software for authors."
}),
PublicSeo.WebPageJsonLd(configuration, "WebPage", "PlotDirector", "/", description)
};
Title = "Novel Planning Software for Authors",
Description = description,
Keywords = "novel planning software, writing software, story planning app, author tools, plot tracking, character tracking, story timeline, novel organisation, fiction writing software, series writing software, continuity tracking, plot threads, story bible",
CanonicalPath = "/",
JsonLd =
[
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "WebSite",
["name"] = "PlotDirector",
["url"] = PublicSeo.BaseUrl(configuration),
["description"] = "Novel planning and story organisation software for authors."
}),
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "SoftwareApplication",
["name"] = "PlotDirector",
["applicationCategory"] = "WritingApplication",
["operatingSystem"] = "Web",
["url"] = PublicSeo.BaseUrl(configuration),
["description"] = description
}),
PublicSeo.WebPageJsonLd(configuration, "WebPage", "PlotDirector", "/", description)
]
});
return View();
}

View File

@ -19,14 +19,45 @@ public sealed class PricingController(
public async Task<IActionResult> Index()
{
const string description = "Compare PlotDirector plans and choose the right subscription for organising your novel, series, characters, plot threads, timelines, and story details.";
ViewData["SeoTitle"] = "Pricing | PlotDirector";
ViewData["SeoDescription"] = description;
ViewData["SeoKeywords"] = "novel planning software, writing software, story planning app, author tools, plot tracking, character tracking, story timeline, novel organisation, fiction writing software, series writing software, continuity tracking, plot threads, story bible";
ViewData["CanonicalUrl"] = PublicSeo.AbsoluteUrl(configuration, "/pricing");
ViewData["StructuredData"] = new[]
PublicSeo.Apply(ViewData, configuration, new SeoMetadata
{
PublicSeo.WebPageJsonLd(configuration, "WebPage", "Pricing", "/pricing", description)
};
Title = "Pricing",
Description = description,
Keywords = "PlotDirector pricing, novel planning software pricing, writing software plans, author tools subscription",
CanonicalPath = "/pricing",
JsonLd =
[
PublicSeo.WebPageJsonLd(configuration, "WebPage", "Pricing", "/pricing", description),
PublicSeo.JsonLd(new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "FAQPage",
["mainEntity"] = new[]
{
new Dictionary<string, object?>
{
["@type"] = "Question",
["name"] = "Can I start with a free trial?",
["acceptedAnswer"] = new Dictionary<string, object?>
{
["@type"] = "Answer",
["text"] = "Yes. Creating an account starts a 30-day Draft Desk trial immediately."
}
},
new Dictionary<string, object?>
{
["@type"] = "Question",
["name"] = "Do I need a credit card for the trial?",
["acceptedAnswer"] = new Dictionary<string, object?>
{
["@type"] = "Answer",
["text"] = "No. Payment details are not required for the free trial."
}
}
}
})
]
});
UserSubscriptionDetail? currentSubscription = null;
if (currentUser.UserId is int userId)

View File

@ -17,7 +17,7 @@ public sealed class PublicController(IConfiguration configuration) : Controller
public IActionResult About()
{
SetSeo(
"About PlotDirector | Built for Novelists",
"About",
"Learn why PlotDirector was created by a novelist to help writers organise complex stories, manage continuity, and finish their books with confidence.",
"/about",
"AboutPage",
@ -29,7 +29,7 @@ public sealed class PublicController(IConfiguration configuration) : Controller
public IActionResult Contact()
{
SetSeo(
"Contact PlotDirector",
"Contact",
"Contact PlotDirector for support, questions, feedback, or feature suggestions about our novel planning and story organisation platform.",
"/contact",
"ContactPage",
@ -41,7 +41,7 @@ public sealed class PublicController(IConfiguration configuration) : Controller
public IActionResult Privacy()
{
SetSeo(
"Privacy Policy | PlotDirector",
"Privacy Policy",
"Read the PlotDirector Privacy Policy to learn how we protect your account information, subscription data, and story content.",
"/privacy",
"PrivacyPolicy",
@ -53,7 +53,7 @@ public sealed class PublicController(IConfiguration configuration) : Controller
public IActionResult Terms()
{
SetSeo(
"Terms of Service | PlotDirector",
"Terms of Service",
"Read the PlotDirector Terms of Service for information about subscriptions, user content, acceptable use, and account responsibilities.",
"/terms",
"WebPage",
@ -63,14 +63,17 @@ public sealed class PublicController(IConfiguration configuration) : Controller
private void SetSeo(string title, string description, string path, string schemaType, string pageName)
{
ViewData["SeoTitle"] = title;
ViewData["SeoDescription"] = description;
ViewData["SeoKeywords"] = Keywords;
ViewData["CanonicalUrl"] = PublicSeo.AbsoluteUrl(configuration, path);
ViewData["StructuredData"] = new[]
PublicSeo.Apply(ViewData, configuration, new SeoMetadata
{
PublicSeo.WebPageJsonLd(configuration, schemaType, pageName, path, description)
};
Title = title,
Description = description,
Keywords = Keywords,
CanonicalPath = path,
JsonLd =
[
PublicSeo.WebPageJsonLd(configuration, schemaType, pageName, path, description)
]
});
}
private static PublicPageViewModel CreateModel() => new()

View File

@ -1,5 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using PlotLine.Models;
namespace PlotLine.Services;
@ -16,18 +19,108 @@ public static class PublicRouteNames
public const string Terms = "PublicTerms";
}
public static class PublicSeo
public static partial class PublicSeo
{
public const string DefaultBaseUrl = "https://plotdirector.com";
public const string DefaultOgImagePath = "/og-image.png";
private const int DescriptionTargetLength = 155;
public static string BaseUrl(IConfiguration configuration)
{
var baseUrl = configuration["Site:PublicBaseUrl"]?.TrimEnd('/');
return string.IsNullOrWhiteSpace(baseUrl) ? DefaultBaseUrl : baseUrl;
if (string.IsNullOrWhiteSpace(baseUrl))
{
return DefaultBaseUrl;
}
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
|| !string.Equals(uri.Host, "plotdirector.com", StringComparison.OrdinalIgnoreCase))
{
return DefaultBaseUrl;
}
return DefaultBaseUrl;
}
public static string AbsoluteUrl(IConfiguration configuration, string path) =>
$"{BaseUrl(configuration)}{path}";
$"{BaseUrl(configuration)}{NormalisePath(path)}";
public static string DisplayTitle(string? title)
{
if (string.IsNullOrWhiteSpace(title))
{
return "PlotDirector";
}
var trimmed = title.Trim();
return string.Equals(trimmed, "PlotDirector", StringComparison.OrdinalIgnoreCase)
|| trimmed.EndsWith("| PlotDirector", StringComparison.OrdinalIgnoreCase)
|| trimmed.EndsWith("- PlotDirector", StringComparison.OrdinalIgnoreCase)
? trimmed
: $"{trimmed} | PlotDirector";
}
public static void Apply(ViewDataDictionary viewData, IConfiguration configuration, SeoMetadata metadata)
{
var canonicalUrl = string.IsNullOrWhiteSpace(metadata.CanonicalUrl)
? AbsoluteUrl(configuration, metadata.CanonicalPath ?? "/")
: CanonicalUrl(configuration, metadata.CanonicalUrl);
var ogImage = string.IsNullOrWhiteSpace(metadata.OgImage)
? AbsoluteUrl(configuration, DefaultOgImagePath)
: CanonicalUrl(configuration, metadata.OgImage);
var fullTitle = DisplayTitle(metadata.Title);
viewData["Seo"] = metadata with
{
CanonicalUrl = canonicalUrl,
OgImage = ogImage
};
viewData["SeoTitle"] = fullTitle;
viewData["SeoDescription"] = metadata.Description;
viewData["SeoKeywords"] = metadata.Keywords;
viewData["CanonicalUrl"] = canonicalUrl;
viewData["OgTitle"] = DisplayTitle(metadata.OgTitle ?? metadata.Title);
viewData["OgDescription"] = metadata.OgDescription ?? metadata.Description;
viewData["OgType"] = string.IsNullOrWhiteSpace(metadata.OgType) ? "website" : metadata.OgType;
viewData["OgImage"] = ogImage;
viewData["TwitterCard"] = string.IsNullOrWhiteSpace(metadata.TwitterCard) ? "summary_large_image" : metadata.TwitterCard;
viewData["StructuredData"] = metadata.JsonLd;
}
public static string HelpArticleDescription(HelpArticle article, IEnumerable<HelpArticle> allArticles)
{
var description = MetaDescription(article.QuickSummary, article.MarkdownContent);
var duplicates = allArticles
.Where(other => !ReferenceEquals(other, article))
.Select(other => MetaDescription(other.QuickSummary, other.MarkdownContent))
.Any(otherDescription => string.Equals(otherDescription, description, StringComparison.OrdinalIgnoreCase));
if (!duplicates)
{
return description;
}
var fallback = $"{article.Title}: {description}";
return TrimDescription(fallback);
}
public static string MetaDescription(string? summary, string? markdown)
{
var source = !string.IsNullOrWhiteSpace(summary)
? summary
: FirstMeaningfulParagraph(markdown);
return TrimDescription(CleanText(source));
}
public static string Keywords(params IEnumerable<string?>[] groups)
{
return string.Join(", ", groups
.SelectMany(group => group)
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value!.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(12));
}
public static string JsonLd(object value) =>
JsonSerializer.Serialize(value, new JsonSerializerOptions
@ -63,4 +156,116 @@ public static class PublicSeo
["item"] = item.Url
})
});
private static string CanonicalUrl(IConfiguration configuration, string value)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return AbsoluteUrl(configuration, value);
}
return $"{BaseUrl(configuration)}{NormalisePath(uri.PathAndQuery)}";
}
private static string NormalisePath(string? path)
{
if (string.IsNullOrWhiteSpace(path) || path == "/")
{
return "/";
}
return path.StartsWith("/", StringComparison.Ordinal) ? path : $"/{path}";
}
private static string FirstMeaningfulParagraph(string? markdown)
{
if (string.IsNullOrWhiteSpace(markdown))
{
return "Find practical guidance for planning, tracking, and shaping your story in PlotDirector.";
}
var withoutCode = CodeFenceRegex().Replace(markdown, " ");
foreach (var paragraph in ParagraphSplitRegex().Split(withoutCode))
{
var cleaned = CleanText(paragraph);
if (cleaned.Length >= 40)
{
return cleaned;
}
}
return CleanText(withoutCode);
}
private static string CleanText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var cleaned = HtmlTagRegex().Replace(value, " ");
cleaned = MarkdownImageRegex().Replace(cleaned, "${alt}");
cleaned = MarkdownLinkRegex().Replace(cleaned, "${text}");
cleaned = MarkdownSyntaxRegex().Replace(cleaned, " ");
cleaned = HelperSyntaxRegex().Replace(cleaned, " ");
cleaned = WhitespaceRegex().Replace(cleaned, " ");
return System.Net.WebUtility.HtmlDecode(cleaned).Trim();
}
private static string TrimDescription(string value)
{
var cleaned = CleanText(value);
if (cleaned.Length <= DescriptionTargetLength)
{
return cleaned;
}
var cutoff = cleaned.LastIndexOf(' ', DescriptionTargetLength);
if (cutoff < 120)
{
cutoff = DescriptionTargetLength;
}
return $"{cleaned[..cutoff].TrimEnd('.', ',', ';', ':', ' ')}.";
}
[GeneratedRegex(@"```[\s\S]*?```")]
private static partial Regex CodeFenceRegex();
[GeneratedRegex(@"\r?\n\s*\r?\n+")]
private static partial Regex ParagraphSplitRegex();
[GeneratedRegex("<[^>]+>")]
private static partial Regex HtmlTagRegex();
[GeneratedRegex(@"!\[(?<alt>[^\]]*)\]\([^)]+\)")]
private static partial Regex MarkdownImageRegex();
[GeneratedRegex(@"\[(?<text>[^\]]+)\]\([^)]+\)")]
private static partial Regex MarkdownLinkRegex();
[GeneratedRegex(@"[#>*_`~\-\|]+")]
private static partial Regex MarkdownSyntaxRegex();
[GeneratedRegex(@"<help-icon[^>]*>|@\w+\([^)]*\)")]
private static partial Regex HelperSyntaxRegex();
[GeneratedRegex(@"\s+")]
private static partial Regex WhitespaceRegex();
}
public sealed record SeoMetadata
{
public string Title { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string? Keywords { get; init; }
public string? CanonicalPath { get; init; }
public string? CanonicalUrl { get; init; }
public string? OgTitle { get; init; }
public string? OgDescription { get; init; }
public string OgType { get; init; } = "website";
public string? OgImage { get; init; }
public string TwitterCard { get; init; } = "summary_large_image";
public IReadOnlyList<string> JsonLd { get; init; } = [];
}

View File

@ -14,19 +14,25 @@
&& adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase));
var featureRequestAttentionCount = userId.HasValue ? await FeatureRequestService.CountAttentionForUserAsync(userId.Value) : 0;
var adminFeatureRequestAttentionCount = isAdmin ? await FeatureRequestService.CountWaitingForAdminAsync() : 0;
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 seoMetadata = ViewData["Seo"] as SeoMetadata;
var defaultTitle = PublicSeo.DisplayTitle(ViewData["Title"] as string);
var seoTitle = ViewData["SeoTitle"] is string seoTitleValue ? seoTitleValue : defaultTitle;
var hasSeoMetadata = seoMetadata is not null || ViewData["SeoDescription"] is string || ViewData["CanonicalUrl"] is string;
var seoDescription = ViewData["SeoDescription"] as string;
var canonicalUrl = ViewData["CanonicalUrl"] as string;
var seoKeywords = isMarketingPage && ViewData["SeoKeywords"] is string seoKeywordsValue ? seoKeywordsValue : null;
var seoKeywords = ViewData["SeoKeywords"] as string;
var structuredData = ViewData["StructuredData"] is IEnumerable<string> structuredDataValues ? structuredDataValues : Array.Empty<string>();
const string ogImageUrl = "https://plotdirector.com/og-image.png";
var ogTitle = ViewData["OgTitle"] as string ?? seoTitle;
var ogDescription = ViewData["OgDescription"] as string ?? seoDescription;
var ogType = ViewData["OgType"] as string ?? "website";
var ogImageUrl = ViewData["OgImage"] as string ?? "https://plotdirector.com/og-image.png";
var twitterCard = ViewData["TwitterCard"] as string ?? "summary_large_image";
}
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@seoTitle</title>
@if (isMarketingPage)
@if (hasSeoMetadata)
{
@if (!string.IsNullOrWhiteSpace(seoDescription))
{
@ -38,22 +44,22 @@
}
<meta name="robots" content="index, follow" />
<meta property="og:site_name" content="PlotDirector" />
<meta property="og:title" content="@seoTitle" />
@if (!string.IsNullOrWhiteSpace(seoDescription))
<meta property="og:title" content="@ogTitle" />
@if (!string.IsNullOrWhiteSpace(ogDescription))
{
<meta property="og:description" content="@seoDescription" />
<meta property="og:description" content="@ogDescription" />
}
<meta property="og:type" content="website" />
<meta property="og:type" content="@ogType" />
@if (!string.IsNullOrWhiteSpace(canonicalUrl))
{
<meta property="og:url" content="@canonicalUrl" />
}
<meta property="og:image" content="@ogImageUrl" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@seoTitle" />
@if (!string.IsNullOrWhiteSpace(seoDescription))
<meta name="twitter:card" content="@twitterCard" />
<meta name="twitter:title" content="@ogTitle" />
@if (!string.IsNullOrWhiteSpace(ogDescription))
{
<meta name="twitter:description" content="@seoDescription" />
<meta name="twitter:description" content="@ogDescription" />
}
<meta name="twitter:image" content="@ogImageUrl" />
}