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); // 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? 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."), }; }