273 lines
9.5 KiB
C#
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; } = [];
|
|
}
|