2026-07-01 21:14:53 +01:00

273 lines
9.5 KiB
C#

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<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
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
public static string WebPageJsonLd(
IConfiguration configuration,
string schemaType,
string name,
string path,
string? description = null) =>
JsonLd(new Dictionary<string, object?>
{
["@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<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "BreadcrumbList",
["itemListElement"] = items.Select((item, index) => new Dictionary<string, object?>
{
["@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(@"!\[(?<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; } = [];
}