f3b89ca9d7
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.
114 lines
4.6 KiB
Plaintext
114 lines
4.6 KiB
Plaintext
@using DeepDrftPublic.Client.Common
|
|
@inject SeoOptions Seo
|
|
@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;
|
|
_robots = string.IsNullOrWhiteSpace(Model.Robots) ? Seo.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",
|
|
};
|
|
}
|