feature: Phase 22 SEO metadata component for public site
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.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user