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); + } +}