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.
128 lines
6.1 KiB
C#
128 lines
6.1 KiB
C#
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;
|
|
}
|