diff --git a/DeepDrftPublic.Client/Common/SeoJsonLd.cs b/DeepDrftPublic.Client/Common/SeoJsonLd.cs
new file mode 100644
index 0000000..ffa5b8c
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoJsonLd.cs
@@ -0,0 +1,110 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
+/// schema.org type; renders a node to the <script type="application/ld+json">
+/// 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.
+///
+///
+/// All nodes share so the @context/@type 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).
+///
+///
+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.
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ WriteIndented = false,
+ };
+
+ /// Renders a node to its compact JSON-LD script body. The host component wraps it in the script tag.
+ public static string Serialize(TNode node) where TNode : JsonLdNode =>
+ JsonSerializer.Serialize(node, node.GetType(), Options);
+}
+
+/// Base for every schema.org node: emits @context and @type first.
+public abstract record JsonLdNode
+{
+ [JsonPropertyName("@context")]
+ [JsonPropertyOrder(-2)]
+ public string Context => "https://schema.org";
+
+ [JsonPropertyName("@type")]
+ [JsonPropertyOrder(-1)]
+ public abstract string Type { get; }
+}
+
+/// The Deep DRFT collective entity — the home/about node.
+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? SameAs { get; init; }
+}
+
+/// A studio cut or a live session release. AlbumProductionType distinguishes them.
+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; }
+
+ /// schema.org MusicAlbumProductionType URI, e.g. StudioAlbum or LiveAlbum.
+ [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; }
+
+ /// Ordered list of the album's recordings (cut track list, in TrackNumber order).
+ [JsonPropertyName("track")] public IReadOnlyList? Track { get; init; }
+}
+
+/// A single recording — a mix release, or one track inside an album's track list.
+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; }
+
+ /// ISO-8601 duration (e.g. PT1H2M3S) from DurationSeconds.
+ [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; }
+}
+
+/// A browse/index surface listing releases (cuts/sessions/mixes/archive).
+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; }
+}
+
+/// A nested byArtist reference — the collective as a MusicGroup, by name.
+public sealed record ArtistRef
+{
+ [JsonPropertyName("@type")] public string Type => "MusicGroup";
+ [JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
+}
diff --git a/DeepDrftPublic.Client/Common/SeoModel.cs b/DeepDrftPublic.Client/Common/SeoModel.cs
new file mode 100644
index 0000000..0af06b2
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoModel.cs
@@ -0,0 +1,207 @@
+using DeepDrftModels.DTOs;
+using DeepDrftModels.Enums;
+
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// The OG og:type for a page. Releases map per medium (§3.4); everything else is a website.
+///
+public enum SeoOgType
+{
+ Website,
+ MusicAlbum,
+ MusicSong,
+}
+
+///
+/// The typed per-page SEO input (Phase 22). A page hands SeoHead 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.
+///
+///
+/// is site-relative; SeoHead absolutises it against
+/// . Release pages pass so the
+/// canonical is the dedicated route regardless of alias/query routes (AC7). A null cover means the model
+/// carries no and SeoHead falls back to the default OG image (C6/AC4).
+///
+///
+public sealed record SeoModel
+{
+ /// Bare page title, no site suffix. SeoHead composes "{Title} · {suffix}".
+ public required string Title { get; init; }
+
+ /// Meta/OG description. Null falls back to .
+ public string? Description { get; init; }
+
+ /// Site-relative canonical path. Null defaults to the current path in SeoHead.
+ public string? CanonicalPath { get; init; }
+
+ /// Relative cover ImagePath. Null → the default OG image.
+ public string? ImagePath { get; init; }
+
+ public SeoOgType OgType { get; init; } = SeoOgType.Website;
+
+ /// Robots directive. Null falls back to .
+ public string? Robots { get; init; }
+
+ /// Pre-serialised JSON-LD script body, or null to emit no structured-data script.
+ 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
+
+ /// Home page: the collective entity (MusicGroup JSON-LD), site-level OG.
+ public static SeoModel ForHome(SeoOptions options) => new()
+ {
+ Title = "Electronic Music Collective",
+ Description = options.DefaultDescription,
+ CanonicalPath = "/",
+ OgType = SeoOgType.Website,
+ JsonLd = SeoJsonLd.Serialize(MusicGroup(options)),
+ };
+
+ /// About page: the collective again, with the bio lede as description.
+ 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.",
+ }),
+ };
+
+ /// A browse surface: CollectionPage JSON-LD, website OG.
+ 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),
+ }),
+ };
+ }
+
+ /// The 404 page: no canonical, noindex,follow, no JSON-LD.
+ public static SeoModel ForNotFound(SeoOptions options) => new()
+ {
+ Title = "Not Found",
+ Description = options.DefaultDescription,
+ Robots = "noindex,follow",
+ OgType = SeoOgType.Website,
+ };
+
+ ///
+ /// 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
+ /// seed the album's ordered track list (cut). One call site, all tags.
+ ///
+ public static SeoModel ForRelease(SeoOptions options, ReleaseDto release, IReadOnlyList? tracks = null)
+ {
+ var canonicalPath = ReleaseRoutes.DetailHref(release.EntryKey, release.Medium);
+ var image = SeoUrls.CoverOrDefault(options, release.ImagePath);
+ var artist = new ArtistRef { Name = options.SiteName };
+ 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? AlbumTracks(
+ SeoOptions options, ArtistRef artist, IReadOnlyList? 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."),
+ };
+}
diff --git a/DeepDrftPublic.Client/Common/SeoOptions.cs b/DeepDrftPublic.Client/Common/SeoOptions.cs
new file mode 100644
index 0000000..515f171
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoOptions.cs
@@ -0,0 +1,51 @@
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// 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
+/// SeoHead so no page re-declares them. Registered as a singleton in
+/// , which runs in both the server prerender and the
+/// WASM passes, so both passes resolve identical values (the double-render-identity requirement, §5/AC6).
+///
+///
+/// is the load-bearing field: absolute canonical / og:url / og:image
+/// origins all come from here, never from a browser API — there is no window.location during
+/// server prerender, and the request host is unreliable behind the nginx reverse proxy (§5, OQ1).
+///
+///
+public sealed record SeoOptions
+{
+ /// Canonical production origin, no trailing slash. Absolute URLs are this + a resolved path (OQ1).
+ public string BaseUrl { get; init; } = "https://deepdrft.com";
+
+ /// The brand name used in og:site_name, application-name, and the JSON-LD MusicGroup.
+ public string SiteName { get; init; } = "Deep DRFT";
+
+ /// Appended to a page's bare title as "{Title} · {TitleSuffix}". Resolves the prior suffix inconsistency (OQ4).
+ public string TitleSuffix { get; init; } = "Deep DRFT";
+
+ /// Fallback meta/OG description for pages that supply none.
+ public string DefaultDescription { get; init; } =
+ "Deep DRFT — an electronic music collective from Charleston, South Carolina. Studio cuts, live sessions, and DJ mixes.";
+
+ ///
+ /// 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.
+ ///
+ public string DefaultImageUrl { get; init; } = "/img/og-default.png";
+
+ /// OG locale. Optional surface tag.
+ public string Locale { get; init; } = "en_US";
+
+ /// The collective's primary genre, used in the MusicGroup JSON-LD node.
+ public string Genre { get; init; } = "Electronic";
+
+ /// Default robots directive; a page may override (e.g. the 404's noindex,follow).
+ public string DefaultRobots { get; init; } = "index,follow";
+
+ ///
+ /// Public social profile URLs for the MusicGroup sameAs array (OQ3). Instagram only —
+ /// no Twitter/X account exists, so no twitter:site/twitter:creator handle is emitted.
+ ///
+ public IReadOnlyList SameAs { get; init; } = ["https://instagram.com/deepdrft.music"];
+}
diff --git a/DeepDrftPublic.Client/Common/SeoUrls.cs b/DeepDrftPublic.Client/Common/SeoUrls.cs
new file mode 100644
index 0000000..3945ee1
--- /dev/null
+++ b/DeepDrftPublic.Client/Common/SeoUrls.cs
@@ -0,0 +1,44 @@
+namespace DeepDrftPublic.Client.Common;
+
+///
+/// Absolute-URL composition for SEO tags (Phase 22). Canonical / og:url / og:image origins
+/// all come from (config), never from a browser API — there is no
+/// window.location during server prerender and the request host is unreliable behind nginx
+/// (§5, OQ1). Shared by the SeoModel factories (which absolutise JSON-LD url/image)
+/// and SeoHead (which absolutises the meta/OG tags) so the rule lives in exactly one place.
+///
+public static class SeoUrls
+{
+ /// BaseUrl + a site-relative path. Both sides are trimmed so the join never doubles or drops the slash.
+ public static string Absolute(SeoOptions options, string path)
+ {
+ var origin = options.BaseUrl.TrimEnd('/');
+ if (string.IsNullOrEmpty(path)) return origin;
+ return $"{origin}/{path.TrimStart('/')}";
+ }
+
+ ///
+ /// Absolute URL of a release/track cover from its FileDatabase ImagePath, via the public image
+ /// route (api/image/{escaped}). Returns the configured default share image when no cover exists
+ /// (C6/AC4 — a default guarantees og:image presence).
+ ///
+ 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)}");
+ }
+
+ ///
+ /// ISO-8601 duration (e.g. PT1H2M3S) from a seconds value, for JSON-LD duration and the
+ /// music:duration OG tag. Null / non-finite / non-positive input yields null (omit the tag).
+ ///
+ 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));
+ }
+}
diff --git a/DeepDrftPublic.Client/Controls/SeoHead.razor b/DeepDrftPublic.Client/Controls/SeoHead.razor
new file mode 100644
index 0000000..597c271
--- /dev/null
+++ b/DeepDrftPublic.Client/Controls/SeoHead.razor
@@ -0,0 +1,113 @@
+@using DeepDrftPublic.Client.Common
+@inject SeoOptions Seo
+@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 (the sole title source — pages drop their bare ) plus a
+ block carrying the full standard/OG/Twitter/JSON-LD surface (§3), projected into the
+ 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.
+*@
+
+@_fullTitle
+
+
+ @* Standard / search *@
+
+
+
+
+
+ @* Open Graph *@
+
+
+
+
+
+
+
+ @if (_hasCover)
+ {
+
+ }
+
+ @* Music-vertical OG (release pages only) *@
+ @if (!string.IsNullOrWhiteSpace(Model.Artist))
+ {
+
+ }
+ @if (Model.ReleaseDate is not null)
+ {
+
+ }
+ @if (_isoDuration is not null)
+ {
+
+ }
+
+ @* Twitter Card. No twitter:site / twitter:creator — no X account exists (OQ3). *@
+
+
+
+
+
+ @* JSON-LD structured data *@
+ @if (!string.IsNullOrEmpty(Model.JsonLd))
+ {
+
+ }
+
+
+@code {
+ /// The page's resolved SEO input, built via a factory.
+ [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;
+ _robots = string.IsNullOrWhiteSpace(Model.Robots) ? Seo.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",
+ };
+}
diff --git a/DeepDrftPublic.Client/Pages/About.razor b/DeepDrftPublic.Client/Pages/About.razor
index 57247ca..5e13d18 100644
--- a/DeepDrftPublic.Client/Pages/About.razor
+++ b/DeepDrftPublic.Client/Pages/About.razor
@@ -2,8 +2,9 @@
@using DeepDrftPublic.Client.Controls
@implements IAsyncDisposable
@inject IJSRuntime JsRuntime
+@inject SeoOptions Seo
-The Collective - Deep DRFT
+
@* ──────────────────────────────────────────────────────────────────────────────
THE LINER NOTES — a numbered three-movement editorial essay.
diff --git a/DeepDrftPublic.Client/Pages/AlbumsView.razor b/DeepDrftPublic.Client/Pages/AlbumsView.razor
index 071ee68..d9d2a1a 100644
--- a/DeepDrftPublic.Client/Pages/AlbumsView.razor
+++ b/DeepDrftPublic.Client/Pages/AlbumsView.razor
@@ -1,7 +1,9 @@
@page "/cuts"
+@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
+@inject SeoOptions Seo
-DeepDrft 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. *@
diff --git a/DeepDrftPublic.Client/Pages/ArchiveView.razor b/DeepDrftPublic.Client/Pages/ArchiveView.razor
index 2a5069e..9b121d4 100644
--- a/DeepDrftPublic.Client/Pages/ArchiveView.razor
+++ b/DeepDrftPublic.Client/Pages/ArchiveView.razor
@@ -1,8 +1,9 @@
@page "/archive"
@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
+@inject SeoOptions Seo
-DeepDrft Archive
+
diff --git a/DeepDrftPublic.Client/Pages/CutDetail.razor b/DeepDrftPublic.Client/Pages/CutDetail.razor
index 8d2809b..2311b93 100644
--- a/DeepDrftPublic.Client/Pages/CutDetail.razor
+++ b/DeepDrftPublic.Client/Pages/CutDetail.razor
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits CutDetailBase
-
-@(ViewModel.Release?.Title ?? "Cut") - DeepDrft
+@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -37,6 +36,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). *@
+
+
@* 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. *@
diff --git a/DeepDrftPublic.Client/Pages/Home.razor b/DeepDrftPublic.Client/Pages/Home.razor
index 4e24e94..65feb10 100644
--- a/DeepDrftPublic.Client/Pages/Home.razor
+++ b/DeepDrftPublic.Client/Pages/Home.razor
@@ -1,8 +1,9 @@
@page "/"
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
+@inject SeoOptions Seo
-Deep DRFT - Electronic Music Collective
+
@* Hero - split 50/50 *@
diff --git a/DeepDrftPublic.Client/Pages/MixDetail.razor b/DeepDrftPublic.Client/Pages/MixDetail.razor
index 1800076..40d080a 100644
--- a/DeepDrftPublic.Client/Pages/MixDetail.razor
+++ b/DeepDrftPublic.Client/Pages/MixDetail.razor
@@ -2,8 +2,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
-
-@(ViewModel.Release?.Title ?? "Mix") - DeepDrft
+@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -32,6 +31,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). *@
+
@* 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
diff --git a/DeepDrftPublic.Client/Pages/MixesView.razor b/DeepDrftPublic.Client/Pages/MixesView.razor
index b4eb946..d5dac4c 100644
--- a/DeepDrftPublic.Client/Pages/MixesView.razor
+++ b/DeepDrftPublic.Client/Pages/MixesView.razor
@@ -1,8 +1,10 @@
@page "/mixes"
+@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
+@inject SeoOptions Seo
-DeepDrft Mixes
+
Not Found
diff --git a/DeepDrftPublic.Client/Pages/SessionDetail.razor b/DeepDrftPublic.Client/Pages/SessionDetail.razor
index dd954ae..bb418cf 100644
--- a/DeepDrftPublic.Client/Pages/SessionDetail.razor
+++ b/DeepDrftPublic.Client/Pages/SessionDetail.razor
@@ -3,8 +3,7 @@
@using DeepDrftPublic.Client.Controls
@using DeepDrftPublic.Client.Services
@inherits ReleaseDetailBase
-
-@(ViewModel.Release?.Title ?? "Session") - DeepDrft
+@inject SeoOptions Seo
@if (ViewModel.IsLoading)
{
@@ -40,6 +39,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). *@
+
+
@* 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
diff --git a/DeepDrftPublic.Client/Pages/SessionsView.razor b/DeepDrftPublic.Client/Pages/SessionsView.razor
index 626497e..d2e9768 100644
--- a/DeepDrftPublic.Client/Pages/SessionsView.razor
+++ b/DeepDrftPublic.Client/Pages/SessionsView.razor
@@ -1,8 +1,10 @@
@page "/sessions"
+@using DeepDrftModels.Enums
@using DeepDrftPublic.Client.Controls
@inherits MediumBrowseBase
+@inject SeoOptions Seo
-DeepDrft Sessions
+();
services.AddScoped();
services.AddScoped();
+
+ // 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());
}
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
diff --git a/DeepDrftTests/SeoModelTests.cs b/DeepDrftTests/SeoModelTests.cs
new file mode 100644
index 0000000..92b07f9
--- /dev/null
+++ b/DeepDrftTests/SeoModelTests.cs
@@ -0,0 +1,236 @@
+using System.Text.Json;
+using DeepDrftModels.DTOs;
+using DeepDrftModels.Enums;
+using DeepDrftPublic.Client.Common;
+
+namespace DeepDrftTests;
+
+///
+/// Unit tests for the Phase 22 SEO typed builders ( factories +
+/// nodes + ). 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.
+///
+[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") => new()
+ {
+ EntryKey = "abc-key",
+ Title = "Test Release",
+ Artist = "Deep DRFT",
+ 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
+ {
+ 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 { 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())
+ {
+ 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);
+ });
+ }
+}
diff --git a/DeepDrftTests/SeoUrlsTests.cs b/DeepDrftTests/SeoUrlsTests.cs
new file mode 100644
index 0000000..fd2ad4c
--- /dev/null
+++ b/DeepDrftTests/SeoUrlsTests.cs
@@ -0,0 +1,68 @@
+using DeepDrftPublic.Client.Common;
+
+namespace DeepDrftTests;
+
+///
+/// Unit tests for — 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.
+///
+[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);
+ }
+}