f3b89ca9d7
One presentational SeoHead renders the full OG/Twitter/JSON-LD head surface at prerender via typed schema.org builders. Per-medium release schema, config-sourced canonicals, 404 noindex. Zero CMS change.
45 lines
2.1 KiB
C#
45 lines
2.1 KiB
C#
namespace DeepDrftPublic.Client.Common;
|
|
|
|
/// <summary>
|
|
/// Absolute-URL composition for SEO tags (Phase 22). Canonical / <c>og:url</c> / <c>og:image</c> origins
|
|
/// all come from <see cref="SeoOptions.BaseUrl"/> (config), never from a browser API — there is no
|
|
/// <c>window.location</c> during server prerender and the request host is unreliable behind nginx
|
|
/// (§5, OQ1). Shared by the <c>SeoModel</c> factories (which absolutise JSON-LD <c>url</c>/<c>image</c>)
|
|
/// and <c>SeoHead</c> (which absolutises the meta/OG tags) so the rule lives in exactly one place.
|
|
/// </summary>
|
|
public static class SeoUrls
|
|
{
|
|
/// <summary>BaseUrl + a site-relative path. Both sides are trimmed so the join never doubles or drops the slash.</summary>
|
|
public static string Absolute(SeoOptions options, string path)
|
|
{
|
|
var origin = options.BaseUrl.TrimEnd('/');
|
|
if (string.IsNullOrEmpty(path)) return origin;
|
|
return $"{origin}/{path.TrimStart('/')}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Absolute URL of a release/track cover from its FileDatabase <c>ImagePath</c>, via the public image
|
|
/// route (<c>api/image/{escaped}</c>). Returns the configured default share image when no cover exists
|
|
/// (C6/AC4 — a default guarantees <c>og:image</c> presence).
|
|
/// </summary>
|
|
public static string CoverOrDefault(SeoOptions options, string? imagePath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(imagePath))
|
|
return Absolute(options, options.DefaultImageUrl);
|
|
|
|
return Absolute(options, $"api/image/{Uri.EscapeDataString(imagePath)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from a seconds value, for JSON-LD <c>duration</c> and the
|
|
/// <c>music:duration</c> OG tag. Null / non-finite / non-positive input yields null (omit the tag).
|
|
/// </summary>
|
|
public static string? IsoDuration(double? seconds)
|
|
{
|
|
if (seconds is null || double.IsNaN(seconds.Value) || double.IsInfinity(seconds.Value) || seconds.Value <= 0)
|
|
return null;
|
|
|
|
return System.Xml.XmlConvert.ToString(TimeSpan.FromSeconds(seconds.Value));
|
|
}
|
|
}
|