Merge p22-w1-seo-metadata-component into dev
Phase 22: parameterized SEO metadata component for the public site — SeoHead + typed JSON-LD builders, per-medium release schema, env-gated noindex (beta uncrawled), inline-safe JSON-LD escaping.
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must
|
||||||
|
/// not be crawled, so the <i>default</i> robots directive is environment-gated: <c>index,follow</c> only in
|
||||||
|
/// Production, <c>noindex,nofollow</c> everywhere else. A per-page <see cref="SeoModel.Robots"/> override
|
||||||
|
/// still wins — this only sets the default.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the
|
||||||
|
/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve
|
||||||
|
/// the same flag. The WASM assembly has no <c>IWebHostEnvironment</c> (config comes from the server). This
|
||||||
|
/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from
|
||||||
|
/// <c>IWebHostEnvironment.IsProduction()</c>) and <c>[PersistentState]</c> rounds to the client, so both
|
||||||
|
/// passes resolve the identical value. <c>SeoHead</c> injects this rather than an environment dependency,
|
||||||
|
/// honouring the no-environment-in-the-component constraint.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class SeoEnvironment
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to
|
||||||
|
/// <c>false</c> so the fail-safe is "do not index" — a missing bridge never accidentally opens a
|
||||||
|
/// non-production site to crawlers.
|
||||||
|
/// </summary>
|
||||||
|
[PersistentState]
|
||||||
|
public bool IsProduction { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The environment-gated default robots directive. Explicit page values override this.</summary>
|
||||||
|
public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow";
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed schema.org JSON-LD nodes (Phase 22, OQ5 — the typed-builder option). Each record mirrors one
|
||||||
|
/// schema.org type; <see cref="SeoJsonLd.Serialize"/> renders a node to the <c><script type="application/ld+json"></c>
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// All nodes share <see cref="JsonLdNode"/> so the <c>@context</c>/<c>@type</c> 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).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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.
|
||||||
|
// Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the
|
||||||
|
// body is injected into the <script> element. See Serialize.
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders a node to its compact JSON-LD script body. The host component wraps it in the script tag.
|
||||||
|
/// The body is run through <see cref="InlineSafe"/> so CMS-authored values containing
|
||||||
|
/// <c></script></c> or <c><</c> cannot break out of the inline script element (XSS).
|
||||||
|
/// </summary>
|
||||||
|
public static string Serialize<TNode>(TNode node) where TNode : JsonLdNode =>
|
||||||
|
InlineSafe(JsonSerializer.Serialize(node, node.GetType(), Options));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escapes the three characters that can break out of an inline <c><script type="application/ld+json"></c>
|
||||||
|
/// element. Replacing <c><</c>/<c>></c>/<c>&</c> with their <c>\uXXXX</c> JSON escapes keeps the
|
||||||
|
/// JSON byte-for-byte equivalent on parse (a JSON string treats <c><</c> and <c><</c> identically)
|
||||||
|
/// while making <c></script></c> impossible to emit raw — the documented safe pattern for inline JSON-LD.
|
||||||
|
/// </summary>
|
||||||
|
internal static string InlineSafe(string json) => json
|
||||||
|
.Replace("<", "\\u003C")
|
||||||
|
.Replace(">", "\\u003E")
|
||||||
|
.Replace("&", "\\u0026");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Base for every schema.org node: emits <c>@context</c> and <c>@type</c> first.</summary>
|
||||||
|
public abstract record JsonLdNode
|
||||||
|
{
|
||||||
|
[JsonPropertyName("@context")]
|
||||||
|
[JsonPropertyOrder(-2)]
|
||||||
|
public string Context => "https://schema.org";
|
||||||
|
|
||||||
|
[JsonPropertyName("@type")]
|
||||||
|
[JsonPropertyOrder(-1)]
|
||||||
|
public abstract string Type { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Deep DRFT collective entity — the home/about node.</summary>
|
||||||
|
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<string>? SameAs { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A studio cut or a live session release. <c>AlbumProductionType</c> distinguishes them.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>schema.org <c>MusicAlbumProductionType</c> URI, e.g. <c>StudioAlbum</c> or <c>LiveAlbum</c>.</summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>Ordered list of the album's recordings (cut track list, in TrackNumber order).</summary>
|
||||||
|
[JsonPropertyName("track")] public IReadOnlyList<MusicRecordingNode>? Track { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single recording — a mix release, or one track inside an album's <c>track</c> list.</summary>
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from <c>DurationSeconds</c>.</summary>
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A browse/index surface listing releases (cuts/sessions/mixes/archive).</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A nested <c>byArtist</c> reference — the collective as a MusicGroup, by name.</summary>
|
||||||
|
public sealed record ArtistRef
|
||||||
|
{
|
||||||
|
[JsonPropertyName("@type")] public string Type => "MusicGroup";
|
||||||
|
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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."),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>SeoHead</c> so no page re-declares them. Registered as a singleton in
|
||||||
|
/// <see cref="Startup.ConfigureDomainServices"/>, which runs in <b>both</b> the server prerender and the
|
||||||
|
/// WASM passes, so both passes resolve identical values (the double-render-identity requirement, §5/AC6).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="BaseUrl"/> is the load-bearing field: absolute canonical / <c>og:url</c> / <c>og:image</c>
|
||||||
|
/// origins all come from here, never from a browser API — there is no <c>window.location</c> during
|
||||||
|
/// server prerender, and the request host is unreliable behind the nginx reverse proxy (§5, OQ1).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SeoOptions
|
||||||
|
{
|
||||||
|
/// <summary>Canonical production origin, no trailing slash. Absolute URLs are this + a resolved path (OQ1).</summary>
|
||||||
|
public string BaseUrl { get; init; } = "https://deepdrft.com";
|
||||||
|
|
||||||
|
/// <summary>The brand name used in <c>og:site_name</c>, <c>application-name</c>, and the JSON-LD MusicGroup.</summary>
|
||||||
|
public string SiteName { get; init; } = "Deep DRFT";
|
||||||
|
|
||||||
|
/// <summary>Appended to a page's bare title as <c>"{Title} · {TitleSuffix}"</c>. Resolves the prior suffix inconsistency (OQ4).</summary>
|
||||||
|
public string TitleSuffix { get; init; } = "Deep DRFT";
|
||||||
|
|
||||||
|
/// <summary>Fallback meta/OG description for pages that supply none.</summary>
|
||||||
|
public string DefaultDescription { get; init; } =
|
||||||
|
"Deep DRFT — an electronic music collective from Charleston, South Carolina. Studio cuts, live sessions, and DJ mixes.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string DefaultImageUrl { get; init; } = "/img/og-default.png";
|
||||||
|
|
||||||
|
/// <summary>OG locale. Optional surface tag.</summary>
|
||||||
|
public string Locale { get; init; } = "en_US";
|
||||||
|
|
||||||
|
/// <summary>The collective's primary genre, used in the MusicGroup JSON-LD node.</summary>
|
||||||
|
public string Genre { get; init; } = "Electronic";
|
||||||
|
|
||||||
|
// The default robots directive is NOT a static option — it is environment-gated (Production →
|
||||||
|
// index,follow; non-production → noindex,nofollow) via SeoEnvironment so the beta/staging site is
|
||||||
|
// never crawled. A page's explicit SeoModel.Robots still overrides that default.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public social profile URLs for the MusicGroup <c>sameAs</c> array (OQ3). Instagram only —
|
||||||
|
/// no Twitter/X account exists, so no <c>twitter:site</c>/<c>twitter:creator</c> handle is emitted.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> SameAs { get; init; } = ["https://instagram.com/deepdrft.music"];
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute-URL composition for SEO tags (Phase 22). Canonical / <c>og:url</c> / <c>og:image</c> origins
|
||||||
|
/// all come from <see cref="SeoOptions.BaseUrl"/> (config), never from a browser API — there is no
|
||||||
|
/// <c>window.location</c> during server prerender and the request host is unreliable behind nginx
|
||||||
|
/// (§5, OQ1). Shared by the <c>SeoModel</c> factories (which absolutise JSON-LD <c>url</c>/<c>image</c>)
|
||||||
|
/// and <c>SeoHead</c> (which absolutises the meta/OG tags) so the rule lives in exactly one place.
|
||||||
|
/// </summary>
|
||||||
|
public static class SeoUrls
|
||||||
|
{
|
||||||
|
/// <summary>BaseUrl + a site-relative path. Both sides are trimmed so the join never doubles or drops the slash.</summary>
|
||||||
|
public static string Absolute(SeoOptions options, string path)
|
||||||
|
{
|
||||||
|
var origin = options.BaseUrl.TrimEnd('/');
|
||||||
|
if (string.IsNullOrEmpty(path)) return origin;
|
||||||
|
return $"{origin}/{path.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute URL of a release/track cover from its FileDatabase <c>ImagePath</c>, via the public image
|
||||||
|
/// route (<c>api/image/{escaped}</c>). Returns the configured default share image when no cover exists
|
||||||
|
/// (C6/AC4 — a default guarantees <c>og:image</c> presence).
|
||||||
|
/// </summary>
|
||||||
|
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)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISO-8601 duration (e.g. <c>PT1H2M3S</c>) from a seconds value, for JSON-LD <c>duration</c> and the
|
||||||
|
/// <c>music:duration</c> OG tag. Null / non-finite / non-positive input yields null (omit the tag).
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
@using DeepDrftPublic.Client.Common
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
@inject SeoEnvironment SeoEnv
|
||||||
|
@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 <PageTitle> (the sole title source — pages drop their bare <PageTitle>) plus a
|
||||||
|
<HeadContent> block carrying the full standard/OG/Twitter/JSON-LD surface (§3), projected into the
|
||||||
|
<HeadOutlet> 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.
|
||||||
|
*@
|
||||||
|
|
||||||
|
<PageTitle>@_fullTitle</PageTitle>
|
||||||
|
|
||||||
|
<HeadContent>
|
||||||
|
@* Standard / search *@
|
||||||
|
<meta name="description" content="@_description" />
|
||||||
|
<link rel="canonical" href="@_canonical" />
|
||||||
|
<meta name="robots" content="@_robots" />
|
||||||
|
<meta name="application-name" content="@Seo.SiteName" />
|
||||||
|
|
||||||
|
@* Open Graph *@
|
||||||
|
<meta property="og:title" content="@Model.Title" />
|
||||||
|
<meta property="og:description" content="@_description" />
|
||||||
|
<meta property="og:url" content="@_canonical" />
|
||||||
|
<meta property="og:type" content="@_ogType" />
|
||||||
|
<meta property="og:site_name" content="@Seo.SiteName" />
|
||||||
|
<meta property="og:locale" content="@Seo.Locale" />
|
||||||
|
<meta property="og:image" content="@_image" />
|
||||||
|
@if (_hasCover)
|
||||||
|
{
|
||||||
|
<meta property="og:image:alt" content="@($"{Model.Title} cover art")" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Music-vertical OG (release pages only) *@
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.Artist))
|
||||||
|
{
|
||||||
|
<meta property="music:musician" content="@Model.Artist" />
|
||||||
|
}
|
||||||
|
@if (Model.ReleaseDate is not null)
|
||||||
|
{
|
||||||
|
<meta property="music:release_date" content="@Model.ReleaseDate.Value.ToString("yyyy-MM-dd")" />
|
||||||
|
}
|
||||||
|
@if (_isoDuration is not null)
|
||||||
|
{
|
||||||
|
<meta property="music:duration" content="@_isoDuration" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Twitter Card. No twitter:site / twitter:creator — no X account exists (OQ3). *@
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="@Model.Title" />
|
||||||
|
<meta name="twitter:description" content="@_description" />
|
||||||
|
<meta name="twitter:image" content="@_image" />
|
||||||
|
|
||||||
|
@* JSON-LD structured data *@
|
||||||
|
@if (!string.IsNullOrEmpty(Model.JsonLd))
|
||||||
|
{
|
||||||
|
<script type="application/ld+json">@((MarkupString)Model.JsonLd)</script>
|
||||||
|
}
|
||||||
|
</HeadContent>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The page's resolved SEO input, built via a <see cref="SeoModel"/> factory.</summary>
|
||||||
|
[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;
|
||||||
|
// Default robots is environment-gated (non-production → noindex,nofollow) so beta/staging is never
|
||||||
|
// crawled; an explicit per-page Robots still wins (e.g. the 404's / soft-404's noindex,follow).
|
||||||
|
_robots = string.IsNullOrWhiteSpace(Model.Robots) ? SeoEnv.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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@inject IJSRuntime JsRuntime
|
@inject IJSRuntime JsRuntime
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>The Collective - Deep DRFT</PageTitle>
|
<SeoHead Model="@SeoModel.ForAbout(Seo)" />
|
||||||
|
|
||||||
@* ──────────────────────────────────────────────────────────────────────────────
|
@* ──────────────────────────────────────────────────────────────────────────────
|
||||||
THE LINER NOTES — a numbered three-movement editorial essay.
|
THE LINER NOTES — a numbered three-movement editorial essay.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
@page "/cuts"
|
@page "/cuts"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Cuts</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Cut, "/cuts")" />
|
||||||
|
|
||||||
@* The shared release-card grid; each card routes to /cuts/{entryKey} via the one ReleaseRoutes table.
|
@* 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. *@
|
Cuts show a track count where other media show the artist, supplied via SubtitleResolver. *@
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
@page "/archive"
|
@page "/archive"
|
||||||
@using DeepDrftModels.Enums
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Archive</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, null, "/archive")" />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="archive-view-container">
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
@inherits CutDetailBase
|
@inherits CutDetailBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>
|
|
||||||
|
|
||||||
@if (ViewModel.IsLoading)
|
@if (ViewModel.IsLoading)
|
||||||
{
|
{
|
||||||
@@ -17,6 +16,9 @@
|
|||||||
}
|
}
|
||||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||||
{
|
{
|
||||||
|
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
||||||
|
(mirrors the dedicated /404 NotFound page). *@
|
||||||
|
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||||
<div class="deepdrft-track-detail-container">
|
<div class="deepdrft-track-detail-container">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
<MudText Typo="Typo.h4" Align="Align.Center">Cut not found.</MudText>
|
||||||
@@ -37,6 +39,10 @@ else
|
|||||||
var hasYear = release.ReleaseDate is not null;
|
var hasYear = release.ReleaseDate is not null;
|
||||||
var firstTrack = ViewModel.Tracks.Count > 0 ? ViewModel.Tracks[0] : 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). *@
|
||||||
|
<SeoHead Model="@SeoModel.ForRelease(Seo, release, ViewModel.Tracks)" />
|
||||||
|
|
||||||
@* Full-screen content body (Phase 20 Wave 2 §1): the scaffold has no Class param, so a thin wrapper
|
@* 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
|
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. *@
|
ambient visualizer reads full-screen and the site footer is pushed below the fold. *@
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>
|
<SeoHead Model="@SeoModel.ForHome(Seo)" />
|
||||||
|
|
||||||
@* Hero - split 50/50 *@
|
@* Hero - split 50/50 *@
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
@inherits ReleaseDetailBase
|
@inherits ReleaseDetailBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
<PageTitle>@(ViewModel.Release?.Title ?? "Mix") - DeepDrft</PageTitle>
|
|
||||||
|
|
||||||
@if (ViewModel.IsLoading)
|
@if (ViewModel.IsLoading)
|
||||||
{
|
{
|
||||||
@@ -16,6 +15,9 @@
|
|||||||
}
|
}
|
||||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||||
{
|
{
|
||||||
|
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
||||||
|
(mirrors the dedicated /404 NotFound page). *@
|
||||||
|
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||||
<div class="deepdrft-track-detail-container">
|
<div class="deepdrft-track-detail-container">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
|
<MudText Typo="Typo.h4" Align="Align.Center">Mix not found.</MudText>
|
||||||
@@ -32,6 +34,11 @@ else if (ViewModel.NotFound || ViewModel.Release is null)
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var release = ViewModel.Release;
|
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). *@
|
||||||
|
<SeoHead Model="@SeoModel.ForRelease(Seo, release, mixTracks)" />
|
||||||
|
|
||||||
@* Full-page waveform sits behind the scaffold content. The scaffold's container is positioned
|
@* 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
|
above it via the mix-detail-foreground stacking context. TrackId lets the visualizer couple to
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@page "/mixes"
|
@page "/mixes"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@inherits MediumBrowseBase
|
@inherits MediumBrowseBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Mixes</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Mix, "/mixes")" />
|
||||||
|
|
||||||
<ReleaseGallery Releases="@Releases"
|
<ReleaseGallery Releases="@Releases"
|
||||||
Loading="@Loading"
|
Loading="@Loading"
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
@page "/404"
|
@page "/404"
|
||||||
|
@using DeepDrftPublic.Client.Controls
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
|
@* The 404 must not be indexed (AC8): noindex,follow — no canonical, no JSON-LD. *@
|
||||||
|
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||||
|
|
||||||
<MudText Typo="Typo.h3">
|
<MudText Typo="Typo.h3">
|
||||||
Not Found
|
Not Found
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@using DeepDrftPublic.Client.Services
|
@using DeepDrftPublic.Client.Services
|
||||||
@inherits ReleaseDetailBase
|
@inherits ReleaseDetailBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
<PageTitle>@(ViewModel.Release?.Title ?? "Session") - DeepDrft</PageTitle>
|
|
||||||
|
|
||||||
@if (ViewModel.IsLoading)
|
@if (ViewModel.IsLoading)
|
||||||
{
|
{
|
||||||
@@ -20,6 +19,9 @@
|
|||||||
}
|
}
|
||||||
else if (ViewModel.NotFound || ViewModel.Release is null)
|
else if (ViewModel.NotFound || ViewModel.Release is null)
|
||||||
{
|
{
|
||||||
|
@* Soft-404: a bad key renders a 200 "not found" view, so it must carry noindex so it is not indexed
|
||||||
|
(mirrors the dedicated /404 NotFound page). *@
|
||||||
|
<SeoHead Model="@SeoModel.ForNotFound(Seo)" />
|
||||||
<div class="deepdrft-track-detail-container">
|
<div class="deepdrft-track-detail-container">
|
||||||
<div class="deepdrft-track-detail-masthead">
|
<div class="deepdrft-track-detail-masthead">
|
||||||
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
|
<MudText Typo="Typo.h4" Align="Align.Center">Session not found.</MudText>
|
||||||
@@ -40,6 +42,10 @@ else
|
|||||||
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
|
// Hero image precedence: the session's dedicated hero, then the release cover, then a placeholder.
|
||||||
var heroImage = !string.IsNullOrEmpty(heroKey) ? heroKey : release.ImagePath;
|
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). *@
|
||||||
|
<SeoHead Model="@SeoModel.ForRelease(Seo, release)" />
|
||||||
|
|
||||||
@* Ambient living waveform behind the hero overlay (Phase 12 §3e option b / §3f mode B). Session does
|
@* 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
|
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
|
full-bleed wrapper — the engine is single-source either way, only the mount differs (§3b). The
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@page "/sessions"
|
@page "/sessions"
|
||||||
|
@using DeepDrftModels.Enums
|
||||||
@using DeepDrftPublic.Client.Controls
|
@using DeepDrftPublic.Client.Controls
|
||||||
@inherits MediumBrowseBase
|
@inherits MediumBrowseBase
|
||||||
|
@inject SeoOptions Seo
|
||||||
|
|
||||||
<PageTitle>DeepDrft Sessions</PageTitle>
|
<SeoHead Model="@SeoModel.ForBrowse(Seo, ReleaseMedium.Session, "/sessions")" />
|
||||||
|
|
||||||
<ReleaseGallery Releases="@Releases"
|
<ReleaseGallery Releases="@Releases"
|
||||||
Loading="@Loading"
|
Loading="@Loading"
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ public static class Startup
|
|||||||
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
||||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||||
services.AddScoped<ShareTracker>();
|
services.AddScoped<ShareTracker>();
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
|
||||||
|
// Environment-gated robots bridge. Scoped + [PersistentState] like DarkModeSettings: the server
|
||||||
|
// seeds IsProduction during prerender and it rounds to the WASM pass, so SeoHead resolves the same
|
||||||
|
// default robots in both render passes (non-production → noindex,nofollow, keeping beta uncrawled).
|
||||||
|
services.AddScoped<SeoEnvironment>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@using DeepDrftPublic.Client
|
@using DeepDrftPublic.Client
|
||||||
|
@using DeepDrftPublic.Client.Common
|
||||||
@using DeepDrftPublic.Services
|
@using DeepDrftPublic.Services
|
||||||
@using DeepDrftShared.Client.Components
|
@using DeepDrftShared.Client.Components
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -34,11 +35,16 @@
|
|||||||
@code {
|
@code {
|
||||||
|
|
||||||
[Inject] public required DarkModeService DarkModeService { get; set; }
|
[Inject] public required DarkModeService DarkModeService { get; set; }
|
||||||
|
[Inject] public required SeoEnvironment SeoEnvironment { get; set; }
|
||||||
|
[Inject] public required IWebHostEnvironment HostEnvironment { get; set; }
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
base.OnInitialized();
|
base.OnInitialized();
|
||||||
DarkModeService.CheckDarkMode();
|
DarkModeService.CheckDarkMode();
|
||||||
|
// Seed the environment-gated robots bridge during prerender; [PersistentState] rounds it to WASM
|
||||||
|
// so both render passes resolve the same default robots (Production → index, else noindex).
|
||||||
|
SeoEnvironment.IsProduction = HostEnvironment.IsProduction();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DeepDrftModels.DTOs;
|
||||||
|
using DeepDrftModels.Enums;
|
||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the Phase 22 SEO typed builders (<see cref="SeoModel"/> factories + <see cref="SeoJsonLd"/>
|
||||||
|
/// nodes + <see cref="SeoUrls"/>). 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.
|
||||||
|
/// </summary>
|
||||||
|
[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",
|
||||||
|
string title = "Test Release",
|
||||||
|
string artist = "Aphex Twin") => new()
|
||||||
|
{
|
||||||
|
EntryKey = "abc-key",
|
||||||
|
Title = title,
|
||||||
|
Artist = artist,
|
||||||
|
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<TrackDto>
|
||||||
|
{
|
||||||
|
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<TrackDto> { 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<ReleaseMedium>())
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- byArtist consistency: per-release artist, matching music:musician -------
|
||||||
|
|
||||||
|
[TestCase(ReleaseMedium.Cut)]
|
||||||
|
[TestCase(ReleaseMedium.Session)]
|
||||||
|
[TestCase(ReleaseMedium.Mix)]
|
||||||
|
public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
|
||||||
|
{
|
||||||
|
var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
|
||||||
|
var node = Parse(model.JsonLd);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
// byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
|
||||||
|
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
|
||||||
|
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
|
||||||
|
Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
|
||||||
|
{
|
||||||
|
var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
|
||||||
|
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);
|
||||||
|
|
||||||
|
Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
|
||||||
|
Is.EqualTo("Aphex Twin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
|
||||||
|
{
|
||||||
|
// A CMS-authored title containing </script> / < must not survive raw in the inline script body —
|
||||||
|
// that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
|
||||||
|
var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
|
||||||
|
var body = SeoModel.ForRelease(Options, release).JsonLd;
|
||||||
|
|
||||||
|
Assert.That(body, Is.Not.Null);
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
|
||||||
|
Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
|
||||||
|
Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
|
||||||
|
// The escaped body still parses as JSON and round-trips the original value.
|
||||||
|
var node = Parse(body);
|
||||||
|
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
|
||||||
|
{
|
||||||
|
// The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
|
||||||
|
var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
|
||||||
|
var body = SeoModel.ForRelease(Options, release).JsonLd;
|
||||||
|
|
||||||
|
Assert.That(body, Is.Not.Null);
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
|
||||||
|
Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
|
||||||
|
Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using DeepDrftPublic.Client.Common;
|
||||||
|
|
||||||
|
namespace DeepDrftTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for <see cref="SeoUrls"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user