f976af0f7c
Escape </>& in JSON-LD body to kill script-breakout; byArtist now uses the release artist; detail-page not-found branches emit noindex; default robots gated to Production via a PersistentState SeoEnvironment bridge.
210 lines
9.2 KiB
C#
210 lines
9.2 KiB
C#
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."),
|
|
};
|
|
}
|