Public SEO pass.
This commit is contained in:
parent
b7865c8e54
commit
18a14bbd01
@ -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.";
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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; } = [];
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user