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,113 @@
|
||||
@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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user