Files
deepdrft/DeepDrftPublic.Client/Controls/SeoHead.razor
T
daniel-c-harvey f976af0f7c fix(seo): escape inline JSON-LD, per-release byArtist, soft-404 + env-gated noindex
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.
2026-06-23 06:10:03 -04:00

117 lines
4.8 KiB
Plaintext

@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",
};
}