using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc.ViewFeatures; using PlotLine.Models; namespace PlotLine.Services; public static class PublicRouteNames { public const string Home = "PublicHome"; public const string Features = "PublicFeatures"; public const string Pricing = "PublicPricing"; public const string Help = "PublicHelp"; public const string HelpCategory = "PublicHelpCategory"; public const string HelpArticle = "PublicHelpArticle"; public const string About = "PublicAbout"; public const string Contact = "PublicContact"; public const string Privacy = "PublicPrivacy"; public const string Terms = "PublicTerms"; } 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('/'); 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)}{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 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[] 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 { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); public static string WebPageJsonLd( IConfiguration configuration, string schemaType, string name, string path, string? description = null) => JsonLd(new Dictionary { ["@context"] = "https://schema.org", ["@type"] = schemaType, ["name"] = name, ["url"] = AbsoluteUrl(configuration, path), ["description"] = description }); public static string BreadcrumbJsonLd(params (string Name, string Url)[] items) => JsonLd(new Dictionary { ["@context"] = "https://schema.org", ["@type"] = "BreadcrumbList", ["itemListElement"] = items.Select((item, index) => new Dictionary { ["@type"] = "ListItem", ["position"] = index + 1, ["name"] = item.Name, ["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(@"!\[(?[^\]]*)\]\([^)]+\)")] private static partial Regex MarkdownImageRegex(); [GeneratedRegex(@"\[(?[^\]]+)\]\([^)]+\)")] private static partial Regex MarkdownLinkRegex(); [GeneratedRegex(@"[#>*_`~\-\|]+")] private static partial Regex MarkdownSyntaxRegex(); [GeneratedRegex(@"]*>|@\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 JsonLd { get; init; } = []; }