feature: Phase 22 SEO metadata component for public site
One presentational SeoHead renders the full OG/Twitter/JSON-LD head surface at prerender via typed schema.org builders. Per-medium release schema, config-sourced canonicals, 404 noindex. Zero CMS change.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
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);
|
||||
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<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."),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user