Merge p22-w1-seo-metadata-component into dev

Phase 22: parameterized SEO metadata component for the public site — SeoHead + typed JSON-LD builders, per-medium release schema, env-gated noindex (beta uncrawled), inline-safe JSON-LD escaping.
This commit is contained in:
daniel-c-harvey
2026-06-23 06:16:31 -04:00
20 changed files with 1018 additions and 12 deletions
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
/// not be crawled, so the <i>default</i> robots directive is environment-gated: <c>index,follow</c> only in
/// Production, <c>noindex,nofollow</c> everywhere else. A per-page <see cref="SeoModel.Robots"/> override
/// still wins — this only sets the default.
///
/// <para>
/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
/// the same flag. The WASM assembly has no <c>IWebHostEnvironment</c> (config comes from the server). This
/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
/// <c>IWebHostEnvironment.IsProduction()</c>) and <c>[PersistentState]</c> rounds to the client, so both
/// passes resolve the identical value. <c>SeoHead</c> injects this rather than an environment dependency,
/// honouring the no-environment-in-the-component constraint.
/// </para>
/// </summary>
public class SeoEnvironment
{
/// <summary>
/// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
/// <c>false</c> so the fail-safe is "do not index" — a missing bridge never accidentally opens a
/// non-production site to crawlers.
/// </summary>
[PersistentState]
public bool IsProduction { get; set; }
/// <summary>The environment-gated default robots directive. Explicit page values override this.</summary>
public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
}
+127
View File
@@ -0,0 +1,127 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
/// schema.org type; <see cref="SeoJsonLd.Serialize"/> renders a node to the <c>&lt;script type="application/ld+json"&gt;</c>
/// body. Keeping the shape in C# (not hand-written JSON in pages) is what makes the medium→type mapping
/// live in one place (DRY, §4.3) and the output unit-testable (AC5) rather than a manual validator pass.
///
/// <para>
/// All nodes share <see cref="JsonLdNode"/> so the <c>@context</c>/<c>@type</c> pair serialises first and
/// once. Null properties are omitted (the serializer ignores nulls) so partial data never emits an empty
/// or broken node (C6/AC4).
/// </para>
/// </summary>
public static class SeoJsonLd
{
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives
// each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc.
// Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
// body is injected into the <script> element. See Serialize.
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false,
};
/// <summary>
/// Renders a node to its compact JSON-LD script body. The host component wraps it in the script tag.
/// The body is run through <see cref="InlineSafe"/> so CMS-authored values containing
/// <c>&lt;/script&gt;</c> or <c>&lt;</c> cannot break out of the inline script element (XSS).
/// </summary>
public static string Serialize<TNode>(TNode node) where TNode : JsonLdNode =>
InlineSafe(JsonSerializer.Serialize(node, node.GetType(), Options));
/// <summary>
/// Escapes the three characters that can break out of an inline <c>&lt;script type="application/ld+json"&gt;</c>
/// element. Replacing <c>&lt;</c>/<c>&gt;</c>/<c>&amp;</c> with their <c>\uXXXX</c> JSON escapes keeps the
/// JSON byte-for-byte equivalent on parse (a JSON string treats <c><</c> and <c>&lt;</c> identically)
/// while making <c>&lt;/script&gt;</c> impossible to emit raw — the documented safe pattern for inline JSON-LD.
/// </summary>
internal static string InlineSafe(string json) => json
.Replace("<", "\\u003C")
.Replace(">", "\\u003E")
.Replace("&", "\\u0026");
}
/// <summary>Base for every schema.org node: emits <c>@context</c> and <c>@type</c> first.</summary>
public abstract record JsonLdNode
{
[JsonPropertyName("@context")]
[JsonPropertyOrder(-2)]
public string Context => "https://schema.org";
[JsonPropertyName("@type")]
[JsonPropertyOrder(-1)]
public abstract string Type { get; }
}
/// <summary>The Deep DRFT collective entity — the home/about node.</summary>
public sealed record MusicGroupNode : JsonLdNode
{
public override string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("url")] public string? Url { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("logo")] public string? Logo { get; init; }
[JsonPropertyName("sameAs")] public IReadOnlyList<string>? SameAs { get; init; }
}
/// <summary>A studio cut or a live session release. <c>AlbumProductionType</c> distinguishes them.</summary>
public sealed record MusicAlbumNode : JsonLdNode
{
public override string Type => "MusicAlbum";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>schema.org <c>MusicAlbumProductionType</c> URI, e.g. <c>StudioAlbum</c> or <c>LiveAlbum</c>.</summary>
[JsonPropertyName("albumProductionType")] public string? AlbumProductionType { get; init; }
[JsonPropertyName("datePublished")] public string? DatePublished { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
/// <summary>Ordered list of the album's recordings (cut track list, in TrackNumber order).</summary>
[JsonPropertyName("track")] public IReadOnlyList<MusicRecordingNode>? Track { get; init; }
}
/// <summary>A single recording — a mix release, or one track inside an album's <c>track</c> list.</summary>
public sealed record MusicRecordingNode : JsonLdNode
{
public override string Type => "MusicRecording";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("byArtist")] public ArtistRef? ByArtist { get; init; }
/// <summary>ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from <c>DurationSeconds</c>.</summary>
[JsonPropertyName("duration")] public string? Duration { get; init; }
[JsonPropertyName("genre")] public string? Genre { get; init; }
[JsonPropertyName("image")] public string? Image { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A browse/index surface listing releases (cuts/sessions/mixes/archive).</summary>
public sealed record CollectionPageNode : JsonLdNode
{
public override string Type => "CollectionPage";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("description")] public string? Description { get; init; }
[JsonPropertyName("url")] public string? Url { get; init; }
}
/// <summary>A nested <c>byArtist</c> reference — the collective as a MusicGroup, by name.</summary>
public sealed record ArtistRef
{
[JsonPropertyName("@type")] public string Type => "MusicGroup";
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
}
+209
View File
@@ -0,0 +1,209 @@
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// The OG <c>og:type</c> for a page. Releases map per medium (§3.4); everything else is a website.
/// </summary>
public enum SeoOgType
{
Website,
MusicAlbum,
MusicSong,
}
/// <summary>
/// The typed per-page SEO input (Phase 22). A page hands <c>SeoHead</c> one model instead of ~15 loose
/// parameters; the named factories below encode the per-page / per-medium mapping (title, description,
/// canonical path, og:type, JSON-LD node) in exactly one place each (DRY, §4.1/§4.2). The factories are
/// pure functions over DTOs the page already holds — unit-testable without rendering.
///
/// <para>
/// <see cref="CanonicalPath"/> is site-relative; <c>SeoHead</c> absolutises it against
/// <see cref="SeoOptions.BaseUrl"/>. Release pages pass <see cref="ReleaseRoutes.DetailHref"/> so the
/// canonical is the dedicated route regardless of alias/query routes (AC7). A null cover means the model
/// carries no <see cref="ImagePath"/> and <c>SeoHead</c> falls back to the default OG image (C6/AC4).
/// </para>
/// </summary>
public sealed record SeoModel
{
/// <summary>Bare page title, no site suffix. <c>SeoHead</c> composes <c>"{Title} · {suffix}"</c>.</summary>
public required string Title { get; init; }
/// <summary>Meta/OG description. Null falls back to <see cref="SeoOptions.DefaultDescription"/>.</summary>
public string? Description { get; init; }
/// <summary>Site-relative canonical path. Null defaults to the current path in <c>SeoHead</c>.</summary>
public string? CanonicalPath { get; init; }
/// <summary>Relative cover <c>ImagePath</c>. Null → the default OG image.</summary>
public string? ImagePath { get; init; }
public SeoOgType OgType { get; init; } = SeoOgType.Website;
/// <summary>Robots directive. Null falls back to <see cref="SeoOptions.DefaultRobots"/>.</summary>
public string? Robots { get; init; }
/// <summary>Pre-serialised JSON-LD script body, or null to emit no structured-data script.</summary>
public string? JsonLd { get; init; }
// --- Music-vertical OG, release pages only (null elsewhere → tags omitted) ---
public string? Artist { get; init; }
public DateOnly? ReleaseDate { get; init; }
public double? DurationSeconds { get; init; }
// ------------------------------------------------------------------ Factories
/// <summary>Home page: the collective entity (MusicGroup JSON-LD), site-level OG.</summary>
public static SeoModel ForHome(SeoOptions options) => new()
{
Title = "Electronic Music Collective",
Description = options.DefaultDescription,
CanonicalPath = "/",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options)),
};
/// <summary>About page: the collective again, with the bio lede as description.</summary>
public static SeoModel ForAbout(SeoOptions options) => new()
{
Title = "The Collective",
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
CanonicalPath = "/about",
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(MusicGroup(options) with
{
Description =
"Two people, many hats. Deep DRFT brings the heart and soul of Midwest deep house to " +
"Charleston — informed by the founders of the style, and promising to push it forward.",
}),
};
/// <summary>A browse surface: <c>CollectionPage</c> JSON-LD, website OG.</summary>
public static SeoModel ForBrowse(SeoOptions options, ReleaseMedium? medium, string path)
{
var (title, description) = BrowseCopy(medium);
return new SeoModel
{
Title = title,
Description = description,
CanonicalPath = path,
OgType = SeoOgType.Website,
JsonLd = SeoJsonLd.Serialize(new CollectionPageNode
{
Name = title,
Description = description,
Url = SeoUrls.Absolute(options, path),
}),
};
}
/// <summary>The 404 page: no canonical, <c>noindex,follow</c>, no JSON-LD.</summary>
public static SeoModel ForNotFound(SeoOptions options) => new()
{
Title = "Not Found",
Description = options.DefaultDescription,
Robots = "noindex,follow",
OgType = SeoOgType.Website,
};
/// <summary>
/// A release detail page. The medium picks the schema (cut/session → MusicAlbum, mix → MusicRecording),
/// the og:type, and the music-vertical OG fields; the canonical is the dedicated route. The optional
/// <paramref name="tracks"/> seed the album's ordered <c>track</c> list (cut). <b>One call site, all tags.</b>
/// </summary>
public static SeoModel ForRelease(SeoOptions options, ReleaseDto release, IReadOnlyList<TrackDto>? tracks = null)
{
var canonicalPath = ReleaseRoutes.DetailHref(release.EntryKey, release.Medium);
var image = SeoUrls.CoverOrDefault(options, release.ImagePath);
// byArtist reflects the release's own artist, consistent with the music:musician OG tag (Daniel's
// call) — not the collective name. Album sub-recordings share it: the tracks are by this artist.
var artist = new ArtistRef { Name = release.Artist };
var description = string.IsNullOrWhiteSpace(release.Description) ? options.DefaultDescription : release.Description;
// A mix is a single recording; its duration comes from the (single) track when present.
var mixDurationSeconds = release.Medium == ReleaseMedium.Mix
? tracks?.FirstOrDefault()?.DurationSeconds
: null;
JsonLdNode node = release.Medium switch
{
ReleaseMedium.Mix => new MusicRecordingNode
{
Name = release.Title,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(mixDurationSeconds),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
},
// Cut and Session are both albums; the production type distinguishes a live session.
_ => new MusicAlbumNode
{
Name = release.Title,
ByArtist = artist,
AlbumProductionType = release.Medium == ReleaseMedium.Session
? "https://schema.org/LiveAlbum"
: "https://schema.org/StudioAlbum",
DatePublished = release.ReleaseDate?.ToString("yyyy-MM-dd"),
Genre = release.Genre,
Image = image,
Url = SeoUrls.Absolute(options, canonicalPath),
Track = AlbumTracks(options, artist, tracks),
},
};
return new SeoModel
{
Title = release.Title,
Description = description,
CanonicalPath = canonicalPath,
ImagePath = release.ImagePath,
OgType = release.Medium == ReleaseMedium.Mix ? SeoOgType.MusicSong : SeoOgType.MusicAlbum,
Artist = release.Artist,
ReleaseDate = release.ReleaseDate,
DurationSeconds = mixDurationSeconds,
JsonLd = SeoJsonLd.Serialize(node),
};
}
// The collective entity, built once from config — the home/about JSON-LD root.
private static MusicGroupNode MusicGroup(SeoOptions options) => new()
{
Name = options.SiteName,
Url = SeoUrls.Absolute(options, "/"),
Genre = options.Genre,
Description = options.DefaultDescription,
Logo = SeoUrls.Absolute(options, options.DefaultImageUrl),
SameAs = options.SameAs.Count > 0 ? options.SameAs : null,
};
// Ordered recording list for an album's `track` property. Null when there are no tracks so the
// property is omitted rather than emitting an empty array (C6).
private static IReadOnlyList<MusicRecordingNode>? AlbumTracks(
SeoOptions options, ArtistRef artist, IReadOnlyList<TrackDto>? tracks)
{
if (tracks is null || tracks.Count == 0) return null;
return tracks
.OrderBy(t => t.TrackNumber)
.Select(t => new MusicRecordingNode
{
Name = t.TrackName,
ByArtist = artist,
Duration = SeoUrls.IsoDuration(t.DurationSeconds),
})
.ToList();
}
private static (string Title, string Description) BrowseCopy(ReleaseMedium? medium) => medium switch
{
ReleaseMedium.Cut => ("Cuts", "Studio cuts from Deep DRFT — composed, layered, and finished."),
ReleaseMedium.Session => ("Sessions", "Live sessions from Deep DRFT — performances caught in the moment, unrepeatable and unedited."),
ReleaseMedium.Mix => ("Mixes", "DJ mixes from Deep DRFT — uninterrupted sets, one track bleeding into the next."),
_ => ("Archive", "The full Deep DRFT catalogue — cuts, sessions, and mixes, indexed and always expanding."),
};
}
@@ -0,0 +1,52 @@
namespace DeepDrftPublic.Client.Common;
/// <summary>
/// Site-wide SEO defaults (Phase 22). These are non-secret brand constants — a single canonical origin,
/// the site name/suffix, the fallback share image, the social links — sourced once and injected into
/// <c>SeoHead</c> so no page re-declares them. Registered as a singleton in
/// <see cref="Startup.ConfigureDomainServices"/>, which runs in <b>both</b> the server prerender and the
/// WASM passes, so both passes resolve identical values (the double-render-identity requirement, §5/AC6).
///
/// <para>
/// <see cref="BaseUrl"/> is the load-bearing field: absolute canonical / <c>og:url</c> / <c>og:image</c>
/// origins all come from here, never from a browser API — there is no <c>window.location</c> during
/// server prerender, and the request host is unreliable behind the nginx reverse proxy (§5, OQ1).
/// </para>
/// </summary>
public sealed record SeoOptions
{
/// <summary>Canonical production origin, no trailing slash. Absolute URLs are this + a resolved path (OQ1).</summary>
public string BaseUrl { get; init; } = "https://deepdrft.com";
/// <summary>The brand name used in <c>og:site_name</c>, <c>application-name</c>, and the JSON-LD MusicGroup.</summary>
public string SiteName { get; init; } = "Deep DRFT";
/// <summary>Appended to a page's bare title as <c>"{Title} · {TitleSuffix}"</c>. Resolves the prior suffix inconsistency (OQ4).</summary>
public string TitleSuffix { get; init; } = "Deep DRFT";
/// <summary>Fallback meta/OG description for pages that supply none.</summary>
public string DefaultDescription { get; init; } =
"Deep DRFT — an electronic music collective from Charleston, South Carolina. Studio cuts, live sessions, and DJ mixes.";
/// <summary>
/// Absolute or root-relative URL of the default 1200×630 share image used when a page has no cover (OQ2).
/// A placeholder path until the real asset is dropped in; swapping it is a one-value change.
/// </summary>
public string DefaultImageUrl { get; init; } = "/img/og-default.png";
/// <summary>OG locale. Optional surface tag.</summary>
public string Locale { get; init; } = "en_US";
/// <summary>The collective's primary genre, used in the MusicGroup JSON-LD node.</summary>
public string Genre { get; init; } = "Electronic";
// The default robots directive is NOT a static option — it is environment-gated (Production →
// index,follow; non-production → noindex,nofollow) via SeoEnvironment so the beta/staging site is
// never crawled. A page's explicit SeoModel.Robots still overrides that default.
/// <summary>
/// Public social profile URLs for the MusicGroup <c>sameAs</c> array (OQ3). Instagram only —
/// no Twitter/X account exists, so no <c>twitter:site</c>/<c>twitter:creator</c> handle is emitted.
/// </summary>
public IReadOnlyList<string> SameAs { get; init; } = ["https://instagram.com/deepdrft.music"];
}
+44
View File
@@ -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));
}
}
@@ -0,0 +1,116 @@
@using DeepDrftPublic.Client.Common
@inject SeoOptions Seo
@inject SeoEnvironment SeoEnv
@inject NavigationManager Nav
@*
The single reusable SEO head surface (Phase 22). Presentational and parameter-fed — owns no fetch and
no business logic (C4); it reads the injected SeoOptions for defaults and NavigationManager for the
current path. Renders <PageTitle> (the sole title source — pages drop their bare <PageTitle>) plus a
<HeadContent> block carrying the full standard/OG/Twitter/JSON-LD surface (§3), projected into the
<HeadOutlet> in App.razor so it is present in the prerendered HTML a crawler sees (C2/AC1).
Identical output across the InteractiveAuto double render (AC6): every value comes from the parameter
Model (built from the page's bridged PersistentComponentState) and config — never a browser API — so
the prerender and WASM passes render byte-identical tags.
Partial data (C6/AC4): a missing value falls back to config or omits its tag; og:image always resolves
(the default guarantees presence) so there is never a content="" attribute or a broken node.
*@
<PageTitle>@_fullTitle</PageTitle>
<HeadContent>
@* Standard / search *@
<meta name="description" content="@_description" />
<link rel="canonical" href="@_canonical" />
<meta name="robots" content="@_robots" />
<meta name="application-name" content="@Seo.SiteName" />
@* Open Graph *@
<meta property="og:title" content="@Model.Title" />
<meta property="og:description" content="@_description" />
<meta property="og:url" content="@_canonical" />
<meta property="og:type" content="@_ogType" />
<meta property="og:site_name" content="@Seo.SiteName" />
<meta property="og:locale" content="@Seo.Locale" />
<meta property="og:image" content="@_image" />
@if (_hasCover)
{
<meta property="og:image:alt" content="@($"{Model.Title} cover art")" />
}
@* Music-vertical OG (release pages only) *@
@if (!string.IsNullOrWhiteSpace(Model.Artist))
{
<meta property="music:musician" content="@Model.Artist" />
}
@if (Model.ReleaseDate is not null)
{
<meta property="music:release_date" content="@Model.ReleaseDate.Value.ToString("yyyy-MM-dd")" />
}
@if (_isoDuration is not null)
{
<meta property="music:duration" content="@_isoDuration" />
}
@* Twitter Card. No twitter:site / twitter:creator — no X account exists (OQ3). *@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@Model.Title" />
<meta name="twitter:description" content="@_description" />
<meta name="twitter:image" content="@_image" />
@* JSON-LD structured data *@
@if (!string.IsNullOrEmpty(Model.JsonLd))
{
<script type="application/ld+json">@((MarkupString)Model.JsonLd)</script>
}
</HeadContent>
@code {
/// <summary>The page's resolved SEO input, built via a <see cref="SeoModel"/> factory.</summary>
[Parameter, EditorRequired] public required SeoModel Model { get; set; }
private string _fullTitle = string.Empty;
private string _description = string.Empty;
private string _canonical = string.Empty;
private string _robots = string.Empty;
private string _ogType = "website";
private string _image = string.Empty;
private bool _hasCover;
private string? _isoDuration;
protected override void OnParametersSet()
{
_fullTitle = $"{Model.Title} · {Seo.TitleSuffix}";
_description = string.IsNullOrWhiteSpace(Model.Description) ? Seo.DefaultDescription : Model.Description;
// Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never
// crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow).
_robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.DefaultRobots : Model.Robots;
_ogType = OgTypeString(Model.OgType);
// Canonical: BaseUrl + the model's path, defaulting to the current relative path. The origin is
// always config (no browser API) so prerender and WASM agree (§5).
var path = Model.CanonicalPath ?? RelativePath();
_canonical = SeoUrls.Absolute(Seo, path);
_hasCover = !string.IsNullOrWhiteSpace(Model.ImagePath);
_image = SeoUrls.CoverOrDefault(Seo, Model.ImagePath);
_isoDuration = SeoUrls.IsoDuration(Model.DurationSeconds);
}
private string RelativePath()
{
var path = Nav.ToBaseRelativePath(Nav.Uri);
var query = path.IndexOf('?');
if (query >= 0) path = path[..query];
return "/" + path;
}
private static string OgTypeString(SeoOgType type) => type switch
{
SeoOgType.MusicAlbum => "music.album",
SeoOgType.MusicSong => "music.song",
_ => "website",
};
}
+2 -1
View File
@@ -2,8 +2,9 @@
@using DeepDrftPublic.Client.Controls
@implements IAsyncDisposable
@inject IJSRuntime JsRuntime
@inject SeoOptions Seo
<PageTitle>The Collective - Deep DRFT</PageTitle>
<SeoHead Model="@SeoModel.ForAbout(Seo)" />
@* ──────────────────────────────────────────────────────────────────────────────
THE LINER NOTES — a numbered three-movement editorial essay.
+3 -1
View File
@@ -1,7 +1,9 @@
@page "/cuts"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
<PageTitle>DeepDrft Cuts</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Cut, "/cuts")" />
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
@@ -1,8 +1,9 @@
@page "/archive"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
<PageTitle>DeepDrft Archive</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
<div>
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
+8 -2
View File
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits CutDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -17,6 +16,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
@@ -37,6 +39,10 @@ else
var hasYear = release.ReleaseDate is not null;
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : null;
@* SEO head — fed from the same bridged release + ordered tracks, so the prerender and WASM passes
render identical tags (AC6). MusicAlbum/StudioAlbum with the ordered track list (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, ViewModel.Tracks)" />
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
carries the min-height. dd-detail-fill keeps the body >= viewport height (below the nav) so the
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
+2 -1
View File
@@ -1,8 +1,9 @@
@page "/"
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inject SeoOptions Seo
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
<SeoHead Model="@SeoModel.ForHome(Seo)" />
@* Hero - split 50/50 *@
<section class="hero">
+9 -2
View File
@@ -2,8 +2,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -16,6 +15,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
else
{
var release = ViewModel.Release;
var mixTracks = ViewModel.Track is not null ? new[] { ViewModel.Track } : null;
@* SEO head — fed from the same bridged release + single track, so prerender and WASM render identical
tags (AC6). MusicRecording with ISO-8601 duration from the track (§3.4). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release, mixTracks)" />
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
+3 -1
View File
@@ -1,8 +1,10 @@
@page "/mixes"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
@inject SeoOptions Seo
<PageTitle>DeepDrft Mixes</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
@@ -1,4 +1,9 @@
@page "/404"
@using DeepDrftPublic.Client.Controls
@inject SeoOptions Seo
@* The 404 must not be indexed (AC8): noindex,follow — no canonical, no JSON-LD. *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<MudText Typo="Typo.h3">
Not Found
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -20,6 +19,9 @@
}
else if (ViewModel.NotFound || ViewModel.Release is null)
{
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
(mirrors the dedicated /404 NotFound page). *@
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
<div class="deepdrft-track-detail-container">
<div class="deepdrft-track-detail-masthead">
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
@@ -40,6 +42,10 @@ else
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
@* SEO head — fed from the same bridged release, so prerender and WASM render identical tags (AC6).
MusicAlbum/LiveAlbum (a session is a live release, §3.4/OQ6). *@
<SeoHead Model="@SeoModel.ForRelease(Seo, release)" />
@* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does
NOT compose ReleaseDetailScaffold, so it mounts the shared engine directly with its own thin
full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The
@@ -1,8 +1,10 @@
@page "/sessions"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
@inject SeoOptions Seo
<PageTitle>DeepDrft Sessions</PageTitle>
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
<ReleaseGallery Releases="@Releases"
Loading="@Loading"
+10
View File
@@ -45,6 +45,16 @@ public static class Startup
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
services.AddScoped<ShareTracker>();
// Phase 22 SEO defaults — non-secret brand constants (canonical origin, site name, default share
// image, social links). Singleton: stateless config, identical in the server-prerender and WASM
// passes (this method runs in both), which is what makes SeoHead's double-render output identical.
services.AddSingleton(new SeoOptions());
// Environment-gated robots bridge. Scoped + [PersistentState] like DarkModeSettings: the server
// seeds IsProduction during prerender and it rounds to the WASM pass, so SeoHead resolves the same
// default robots in both render passes (non-production → noindex,nofollow, keeping beta uncrawled).
services.AddScoped<SeoEnvironment>();
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
+6
View File
@@ -1,4 +1,5 @@
@using DeepDrftPublic.Client
@using DeepDrftPublic.Client.Common
@using DeepDrftPublic.Services
@using DeepDrftShared.Client.Components
<!DOCTYPE html>
@@ -34,11 +35,16 @@
@code {
[Inject] public required DarkModeService DarkModeService { get; set; }
[Inject] public required SeoEnvironment SeoEnvironment { get; set; }
[Inject] public required IWebHostEnvironment HostEnvironment { get; set; }
protected override void OnInitialized()
{
base.OnInitialized();
DarkModeService.CheckDarkMode();
// Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM
// so both render passes resolve the same default robots (Production → index, else noindex).
SeoEnvironment.IsProduction = HostEnvironment.IsProduction();
}
}
+308
View File
@@ -0,0 +1,308 @@
using System.Text.Json;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for the Phase 22 SEO typed builders (<see cref="SeoModel"/> factories + <see cref="SeoJsonLd"/>
/// nodes + <see cref="SeoUrls"/>). These are pure functions over the DTOs a page already holds — the
/// medium→schema mapping (AC3), graceful partial data (AC4), JSON-LD validity (AC5), and canonical
/// correctness (AC7) are all testable here without rendering. The JSON-LD is parsed back to a document so
/// each assertion checks real structure, not a substring.
/// </summary>
[TestFixture]
public class SeoModelTests
{
private static readonly SeoOptions Options = new()
{
BaseUrl = "https://deepdrft.com",
SiteName = "Deep DRFT",
TitleSuffix = "Deep DRFT",
DefaultDescription = "default description",
DefaultImageUrl = "/img/og-default.png",
Genre = "Electronic",
SameAs = ["https://instagram.com/deepdrft.music"],
};
private static ReleaseDto Release(
ReleaseMedium medium,
string? image = "cover.jpg",
string? description = "desc",
string title = "Test Release",
string artist = "Aphex Twin") => new()
{
EntryKey = "abc-key",
Title = title,
Artist = artist,
Genre = "House",
Description = description,
ImagePath = image,
ReleaseDate = new DateOnly(2026, 3, 14),
Medium = medium,
};
private static JsonElement Parse(string? json)
{
Assert.That(json, Is.Not.Null.And.Not.Empty, "expected a JSON-LD body");
return JsonDocument.Parse(json!).RootElement;
}
// --- AC3: per-medium schema -------------------------------------------------
[Test]
public void ForRelease_Cut_IsMusicAlbum_StudioAlbum_WithOrderedTrackList()
{
var tracks = new List<TrackDto>
{
new() { TrackName = "Second", TrackNumber = 2, DurationSeconds = 60 },
new() { TrackName = "First", TrackNumber = 1, DurationSeconds = 30 },
};
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut), tracks);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/StudioAlbum"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
var trackArray = node.GetProperty("track");
Assert.That(trackArray.GetArrayLength(), Is.EqualTo(2));
// Ordered by TrackNumber, not input order.
Assert.That(trackArray[0].GetProperty("name").GetString(), Is.EqualTo("First"));
Assert.That(trackArray[1].GetProperty("name").GetString(), Is.EqualTo("Second"));
Assert.That(trackArray[0].GetProperty("duration").GetString(), Is.EqualTo("PT30S"));
});
}
[Test]
public void ForRelease_Session_IsMusicAlbum_LiveAlbum()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Session));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicAlbum"));
Assert.That(node.GetProperty("albumProductionType").GetString(), Is.EqualTo("https://schema.org/LiveAlbum"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicAlbum));
});
}
[Test]
public void ForRelease_Mix_IsMusicRecording_WithIsoDuration()
{
var tracks = new List<TrackDto> { new() { TrackName = "The Mix", DurationSeconds = 3723 } }; // 1h 2m 3s
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicRecording"));
Assert.That(node.GetProperty("duration").GetString(), Is.EqualTo("PT1H2M3S"));
Assert.That(model.OgType, Is.EqualTo(SeoOgType.MusicSong));
Assert.That(model.DurationSeconds, Is.EqualTo(3723));
// A mix is one recording, not an album — it carries no track list.
Assert.That(node.TryGetProperty("track", out _), Is.False);
});
}
[Test]
public void ForRelease_AllNodes_DeclareSchemaOrgContext()
{
foreach (var medium in Enum.GetValues<ReleaseMedium>())
{
var node = Parse(SeoModel.ForRelease(Options, Release(medium)).JsonLd);
Assert.That(node.GetProperty("@context").GetString(), Is.EqualTo("https://schema.org"),
$"{medium} node must declare the schema.org context");
}
}
// --- AC4: graceful partial data ---------------------------------------------
[Test]
public void ForRelease_NoDescription_FallsBackToDefault()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, description: null));
Assert.That(model.Description, Is.EqualTo(Options.DefaultDescription));
}
[Test]
public void ForRelease_NoCover_OmitsImagePath_SoHeadFallsBackToDefault()
{
var model = SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, image: null));
// The model carries no relative ImagePath; the JSON-LD image is absolutised to the default.
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(model.ImagePath, Is.Null);
Assert.That(node.GetProperty("image").GetString(), Is.EqualTo("https://deepdrft.com/img/og-default.png"));
});
}
[Test]
public void ForRelease_NoGenre_OmitsGenreProperty_NoEmptyValue()
{
var release = Release(ReleaseMedium.Cut);
release.Genre = null;
var node = Parse(SeoModel.ForRelease(Options, release).JsonLd);
Assert.That(node.TryGetProperty("genre", out _), Is.False, "a null genre must be omitted, not emitted empty");
}
[Test]
public void ForRelease_Mix_NoTrack_OmitsDuration()
{
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Mix), tracks: null).JsonLd);
Assert.That(node.TryGetProperty("duration", out _), Is.False, "a mix with no track must omit duration");
}
// --- AC7: canonical correctness ---------------------------------------------
[TestCase(ReleaseMedium.Cut, "https://deepdrft.com/cuts/abc-key")]
[TestCase(ReleaseMedium.Session, "https://deepdrft.com/sessions/abc-key")]
[TestCase(ReleaseMedium.Mix, "https://deepdrft.com/mixes/abc-key")]
public void ForRelease_CanonicalPath_IsDedicatedRoute_AndJsonLdUrlAgrees(ReleaseMedium medium, string expectedAbsolute)
{
var model = SeoModel.ForRelease(Options, Release(medium));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(SeoUrls.Absolute(Options, model.CanonicalPath!), Is.EqualTo(expectedAbsolute));
// The JSON-LD url must be the same absolute canonical (AC7: canonical == og:url == node url).
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo(expectedAbsolute));
});
}
// --- Home / About / Browse / NotFound ---------------------------------------
[Test]
public void ForHome_IsMusicGroup_WithSameAs()
{
var model = SeoModel.ForHome(Options);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Deep DRFT"));
Assert.That(node.GetProperty("sameAs")[0].GetString(), Is.EqualTo("https://instagram.com/deepdrft.music"));
Assert.That(model.CanonicalPath, Is.EqualTo("/"));
});
}
[Test]
public void ForAbout_IsMusicGroup()
{
var node = Parse(SeoModel.ForAbout(Options).JsonLd);
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("MusicGroup"));
}
[TestCase(ReleaseMedium.Cut, "/cuts")]
[TestCase(ReleaseMedium.Session, "/sessions")]
[TestCase(ReleaseMedium.Mix, "/mixes")]
public void ForBrowse_IsCollectionPage_WithAbsoluteUrl(ReleaseMedium medium, string path)
{
var model = SeoModel.ForBrowse(Options, medium, path);
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
Assert.That(node.GetProperty("@type").GetString(), Is.EqualTo("CollectionPage"));
Assert.That(node.GetProperty("url").GetString(), Is.EqualTo($"https://deepdrft.com{path}"));
Assert.That(model.CanonicalPath, Is.EqualTo(path));
});
}
[Test]
public void ForBrowse_NullMedium_IsArchive()
{
var model = SeoModel.ForBrowse(Options, null, "/archive");
Assert.That(model.Title, Is.EqualTo("Archive"));
}
[Test]
public void ForNotFound_IsNoindex_NoCanonical_NoJsonLd()
{
var model = SeoModel.ForNotFound(Options);
Assert.Multiple(() =>
{
Assert.That(model.Robots, Is.EqualTo("noindex,follow"));
Assert.That(model.CanonicalPath, Is.Null);
Assert.That(model.JsonLd, Is.Null);
});
}
// --- byArtist consistency: per-release artist, matching music:musician -------
[TestCase(ReleaseMedium.Cut)]
[TestCase(ReleaseMedium.Session)]
[TestCase(ReleaseMedium.Mix)]
public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
{
var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
// byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
});
}
[Test]
public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
{
var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);
Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
Is.EqualTo("Aphex Twin"));
}
// --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------
[Test]
public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
{
// A CMS-authored title containing </script> / < must not survive raw in the inline script body —
// that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
// The escaped body still parses as JSON and round-trips the original value.
var node = Parse(body);
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
});
}
[Test]
public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
{
// The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
});
}
}
+68
View File
@@ -0,0 +1,68 @@
using DeepDrftPublic.Client.Common;
namespace DeepDrftTests;
/// <summary>
/// Unit tests for <see cref="SeoUrls"/> — the absolute-URL composition shared by the SeoModel factories
/// and SeoHead (Phase 22). Origin always comes from config (never a browser API), so these pin the
/// slash-join, the cover-vs-default fallback (C6/AC4), and the ISO-8601 duration edge cases.
/// </summary>
[TestFixture]
public class SeoUrlsTests
{
private static readonly SeoOptions Options = new()
{
BaseUrl = "https://deepdrft.com",
DefaultImageUrl = "/img/og-default.png",
};
[TestCase("/cuts/key", "https://deepdrft.com/cuts/key")]
[TestCase("cuts/key", "https://deepdrft.com/cuts/key")]
[TestCase("/", "https://deepdrft.com/")]
[TestCase("", "https://deepdrft.com")]
public void Absolute_JoinsOriginAndPath_WithoutDoublingOrDroppingSlash(string path, string expected)
{
Assert.That(SeoUrls.Absolute(Options, path), Is.EqualTo(expected));
}
[Test]
public void Absolute_TrimsTrailingSlashOnBaseUrl()
{
var withSlash = Options with { BaseUrl = "https://deepdrft.com/" };
Assert.That(SeoUrls.Absolute(withSlash, "/cuts"), Is.EqualTo("https://deepdrft.com/cuts"));
}
[Test]
public void CoverOrDefault_WithCover_BuildsEscapedImageRoute()
{
Assert.That(SeoUrls.CoverOrDefault(Options, "my cover.jpg"),
Is.EqualTo("https://deepdrft.com/api/image/my%20cover.jpg"));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void CoverOrDefault_WithoutCover_FallsBackToDefaultImage(string? image)
{
Assert.That(SeoUrls.CoverOrDefault(Options, image),
Is.EqualTo("https://deepdrft.com/img/og-default.png"));
}
[TestCase(30.0, "PT30S")]
[TestCase(90.0, "PT1M30S")]
[TestCase(3723.0, "PT1H2M3S")]
public void IsoDuration_PositiveSeconds_RendersIso8601(double seconds, string expected)
{
Assert.That(SeoUrls.IsoDuration(seconds), Is.EqualTo(expected));
}
[TestCase(null)]
[TestCase(0.0)]
[TestCase(-5.0)]
[TestCase(double.NaN)]
[TestCase(double.PositiveInfinity)]
public void IsoDuration_NonPositiveOrNonFinite_ReturnsNull(double? seconds)
{
Assert.That(SeoUrls.IsoDuration(seconds), Is.Null);
}
}