Files
deepdrft/product-notes/phase-22-seo-metadata-component.md
T

31 KiB
Raw Blame History

Phase 22 — SEO Metadata Component (parameterized head/meta injection, public site)

Product spec. Status: design / framing — implementation-ready pending Daniel's open-question calls. Author: product-designer. Date: 2026-06-23. No code has been written by this doc. Surface: public listener site only (DeepDrftPublic ASP.NET Core host + DeepDrftPublic.Client Blazor WASM assembly). The CMS (DeepDrftManager) is an authenticated admin surface and is explicitly out of scope — it must not be touched, and admin pages should if anything carry noindex. No data-model or schema change. No new API endpoint (every value the component needs is already returned by the existing TrackDto / ReleaseDto / HomeStatsDto reads).


1. Goal

Give every public page a single, reusable, parameterized component that emits the full modern-SEO head surface — standard meta, canonical, robots, Open Graph, Twitter Card, and schema.org JSON-LD — so crawlers and social unfurlers see correct, page-specific metadata in the prerendered HTML, with no per-page boilerplate and no double-maintenance.

One-line framing: today each page hand-writes a bare <PageTitle> and nothing else; Phase 22 replaces that with one <SeoHead …> component that a page configures with a handful of parameters (or a typed model), defaulting everything else from a site-wide config, and that renders the complete head surface server-side during prerender where crawlers can read it.

What exists today (the starting point — verified 2026-06-23)

  • App.razor (DeepDrftPublic/Components/App.razor) declares <HeadOutlet @rendermode="InteractiveAuto" /> in <head>, and a static <head> block with charset, viewport, <base href="/">, stylesheet links, <ImportMap />, and a favicon. No <meta name="description">, no canonical, no OG/Twitter tags, no JSON-LD. The only per-page head contribution anywhere is <PageTitle>.
  • Pages set titles ad hoc: Home.razor<PageTitle>Deep DRFT - Electronic Music Collective</PageTitle>; CutDetail.razor<PageTitle>@(ViewModel.Release?.Title ?? "Cut") - DeepDrft</PageTitle>. No shared title-composition convention — the suffix (" - DeepDrft" vs " - Electronic Music Collective") is already inconsistent.
  • <html lang="en"> is set once in App.razor.
  • Detail pages (CutDetail, SessionDetail, MixDetail) inherit base classes (ReleaseDetailBase / CutDetailBase) that load a ReleaseDto into a ViewModel in OnParametersSetAsync (not OnInitialized — the documented same-template-nav reuse rule), and bridge the prerender fetch across the WASM seam with PersistentComponentState. This is the key fact for render-mode correctness (§5): the release data is already resolved during prerender, so the SEO tags it feeds can be too.
  • Canonical URL composition already exists for releases: ReleaseRoutes.DetailHref(entryKey, medium)/cuts/{key} | /sessions/{key} | /mixes/{key}. SharePopover already builds absolute share URLs from this; the SEO canonical tag is the same URL, absolutized.
  • Cover art resolves to api/image/{Uri.EscapeDataString(release.ImagePath)} — the OG image source.

Why now / why it matters

A public music catalogue lives or dies on discoverability and on how its links unfurl in iMessage, Discord, X, and search results. Right now a shared /mixes/{key} link unfurls as a bare title and a URL — no description, no cover image, no rich card. Search engines see a title and an empty description. The share affordance (Phase 16/17) is already a first-class feature; SEO/OG metadata is the missing half of "this link is worth sharing."


2. Constraints / invariants (the contract that must hold)

  • C1 — Public site only. Zero changes to DeepDrftManager. The component, its config, and its registration all live in the public host + client. If anything, the CMS should later emit noindex (noted as an adjacent concern in §7, not specified here).
  • C2 — Tags must be present at prerender time, not after WASM boot. Crawlers and unfurlers read the server-rendered HTML and (mostly) do not execute the WASM runtime. The component must contribute its head content during the server prerender pass. This is the single most load-bearing correctness requirement and is detailed in §5. It governs where the data comes from (must be resolvable during prerender) and how the component renders (via <HeadContent> into the existing <HeadOutlet>).
  • C3 — One component, parameterized — DRY. A page supplies only its own specifics; everything else defaults from a site-wide config. No page re-declares the boilerplate set of ~15 tags. Adding a new page type means passing a model, not copy-pasting a head block.
  • C4 — SOLID seam. The component renders; it does not fetch. Page-level data (the ReleaseDto, the home stats) is already loaded by the page's ViewModel — the SEO component is presentational, fed by parameters, exactly like ReleaseHeroOverlay / ReleaseDescription / NowShowingPanel. Defaults come from an injected config object, not from a data fetch inside the component.
  • C5 — No new fetch path, no new endpoint, no schema change. Every value is already available: ReleaseDto carries Title, Artist, Genre, ReleaseDate, Description, ImagePath, EntryKey, Medium; HomeStatsDto backs the home page; the About page is static editorial. If a desired tag has no source datum, it is either omitted or filled from config — never a reason to add a column.
  • C6 — Graceful partial data. A release with no Description, no ImagePath, or no Genre must still emit a valid, complete-as-possible head (fall back to config defaults; omit truly optional tags rather than emit empty ones — mirror ReleaseDescription's "null description renders nothing" rule).
  • C7 — Valid, non-duplicated output. Exactly one <title>, one canonical, one OG block, one JSON-LD script per page. <PageTitle> and the SEO component must not both emit a title — the component owns the title (it composes PageTitle internally) so there is one source of truth. (See OQ4.)

3. Metadata surface (what the component emits)

The full modern set, grouped. Each row notes its source (page param / config default / derived) and whether it is always emitted or conditional.

Tag Source Emit
<title> page (composed with config site-name suffix) always
<meta name="description"> page; falls back to config default description always
<link rel="canonical" href> derived: config base URL + current path (releases via ReleaseRoutes) always
<meta name="robots"> config default (index,follow); page may override (e.g. noindex on /404) always
<meta name="author"> / <meta name="application-name"> config (Deep DRFT) optional
Tag Source Emit
og:title page (defaults to <title> sans suffix) always
og:description page (defaults to meta description) always
og:url = canonical always
og:type page (website default; music.album / music.song for releases — see §4) always
og:site_name config (Deep DRFT) always
og:image page (release cover → absolute …/api/image/{path}); falls back to config default OG image always (default guarantees presence)
og:image:alt page (e.g. "{Title} cover art") conditional (when image present)
og:locale config (en_US) optional
Music-vertical OG (music:musician, music:release_date, music:duration) release params conditional (release pages only)

3.3 Twitter Card

Tag Source Emit
twitter:card config (summary_large_image when an image exists, else summary) always
twitter:title / twitter:description mirror OG always
twitter:image mirror og:image always
twitter:site / twitter:creator config (the collective's handle, if any) optional — OQ3

3.4 JSON-LD structured data (schema.org)

One <script type="application/ld+json"> per page, shaped by page type. This is where the music domain gets expressed richly (cuts/sessions/mixes map to schema.org music types):

  • Site-wide / homeMusicGroup (the Deep DRFT collective): name, url, genre, description, logo, optional sameAs (social links — OQ3). Optionally a WebSite node with potentialAction search (only if a public search surface exists — it does not today; defer).
  • Cut detail (/cuts/{key}, a studio release, possibly multi-track) — MusicAlbum: name=Title, byArtistMusicGroup, albumProductionTypeStudioAlbum, datePublished=ReleaseDate, genre, image=cover, url=canonical, and track→ ordered list of MusicRecording (the album's tracks; the page already holds ViewModel.Tracks in TrackNumber order).
  • Session detail (/sessions/{key}, a live release) — MusicAlbum with albumProductionTypeLiveAlbum (schema.org has LiveAlbum), or a MusicEvent/MusicRecording hybrid. Recommend MusicAlbum+LiveAlbum for parity with cuts and because the catalogue treats a session as a release, not a calendar event. (Revisit if a live schedule page lands — see §7.)
  • Mix detail (/mixes/{key}, a single long continuous track) — MusicRecording (one recording, not an album): name, byArtist, duration (ISO-8601 from DurationSeconds), genre, image, url. A mix is the cleanest single-MusicRecording case.
  • About (/about) — AboutPage referencing the MusicGroup, or simply the MusicGroup node again with the editorial bio as description.
  • Browse pages (/cuts, /sessions, /mixes, /archive) — CollectionPage, optionally with an ItemList of the releases shown. Lighter touch; the detail pages carry the rich per-release schema.

JSON-LD is the highest-leverage, music-specific part of this spec and the part most worth getting right. It is also the part with the most modeling latitude — the exact node shapes above are a recommendation; the precise schema.org property set is a refinement staff-engineer can tune against Google's Rich Results test (see §8 AC5). The spec fixes which schema type maps to which medium (the product decision) and leaves property-level polish to implementation.


4. Component design (the contract)

4.1 Shape: one component + a typed model + a config

Three pieces, each a clean SOLID responsibility:

  1. SeoHead.razor — the single reusable presentational component. Lives in DeepDrftPublic.Client (it must render in the WASM-shared component graph so it works in both prerender and interactive passes — see §5). Renders a <PageTitle> and a <HeadContent> block containing all of §3. Owns no data fetch and no business logic. Parameterized over a model (below). It reads the injected SeoOptions for defaults and NavigationManager for the current absolute URL (canonical/og:url).

  2. SeoModel (a record/class in Common/) — the typed per-page input. Rather than ~15 loose [Parameter]s, the page hands SeoHead one model. Suggested surface:

    • Title (string, required) — page title sans site suffix.
    • Description (string?) — falls back to SeoOptions.DefaultDescription.
    • CanonicalPath (string?) — defaults to NavigationManager's current relative path; release pages pass ReleaseRoutes.DetailHref(...) so the canonical is stable regardless of alias routes (/tracks/... redirects, query strings).
    • ImagePath (string?) — relative cover path; component absolutizes to …/api/image/{escaped}; falls back to SeoOptions.DefaultImageUrl.
    • OgType (enum/string, default Website).
    • Robots (string?, default from config index,follow).
    • JsonLd (a RenderFragment or a typed structured-data object) — the page supplies its schema.org node; see 4.3 for the build-vs-pass decision (OQ5).
    • Music-specific optionals used only by release pages: Artist, Genre, ReleaseDate, DurationSeconds, and (for albums) the ordered track list.

    A small set of named factory helpers keeps call sites terse and DRY — e.g. SeoModel.ForRelease(ReleaseDto, tracks?), SeoModel.ForHome(HomeStatsDto), SeoModel.ForAbout(), SeoModel.ForBrowse(medium). Each factory encodes the medium→OgType→JSON-LD mapping from §3.4 in exactly one place (DRY: a page never re-derives "a mix is a MusicRecording"). These factories are pure functions over DTOs the page already holds — unit-testable without rendering.

  3. SeoOptions (config, in Common/) — site-wide defaults: SiteName (Deep DRFT), TitleSuffix, DefaultDescription, BaseUrl (the canonical production origin — OQ1), DefaultImageUrl, TwitterSite/TwitterCreator (OQ3), DefaultRobots, Locale, Genre, social sameAs links (OQ3). Registered in Startup.ConfigureDomainServices (the existing seam that runs in both server and WASM Program.cs, per the project's static-Startup convention). Source values from appsettings.json server-side; the WASM pass either hardcodes the same constants or receives them via the existing config seam. Note: these are non-secret brand constants — they belong in appsettings.json / a constants class, not environment/ secrets.

4.2 How pages supply their specifics (DRY in practice)

  • Home (Home.razor): <SeoHead Model="SeoModel.ForHome(stats)" /> — replaces the current bare <PageTitle>. Description from config or a curated home string; JSON-LD = MusicGroup.
  • About (/about): <SeoHead Model="SeoModel.ForAbout()" /> — static; description = the bio lede; JSON-LD = MusicGroup/AboutPage.
  • Release detail (CutDetail/SessionDetail/MixDetail): <SeoHead Model="@_seo" /> where _seo = SeoModel.ForRelease(ViewModel.Release, ViewModel.Tracks) — set once the release is resolved. The factory reads Medium and picks MusicAlbum (cut/session) vs MusicRecording (mix), the og:type, the canonical via ReleaseRoutes, and the cover image. One call site, all 15+ tags.
  • Browse (AlbumsView/SessionsView/MixesView/ArchiveView): SeoModel.ForBrowse(medium).
  • 404 (NotFound): SeoModel with Robots = "noindex,follow".

Each page touches one line. The boilerplate lives in SeoHead + the factories; the per-page values flow in through the model. That is the DRY mechanism C3 demands.

4.3 Build-vs-pass for JSON-LD (the one genuine design fork — OQ5)

Two ways to produce the <script type="application/ld+json"> body:

  • (a) Typed builder: SeoModel carries strongly-typed structured-data objects (small C# records mirroring the schema.org nodes) that a serializer renders to JSON. Pros: type-safe, unit-testable, DRY (the medium→type mapping is C# in the factories), no hand-written JSON in pages. Cons: a small amount of schema.org-shaped record plumbing to build once.
  • (b) RenderFragment / raw string: the page hands SeoHead a pre-built JSON-LD fragment. Pros: trivial component. Cons: pushes JSON authoring into pages (violates C3/DRY), easy to get invalid, not testable.

Recommend (a) — the typed builder. It is the only option that honors DRY (the medium→schema mapping must live in one place) and is testable (AC5 wants Rich-Results validity; pure builders make that a unit test, not a manual check). The record set is small (a MusicGroup, a MusicAlbum with a track list, a MusicRecording) and confined to Common/. This is the load-bearing implementation choice and is recorded as OQ5 for Daniel to confirm.

4.4 SOLID / road-not-taken rationale

  • SRP: SeoHead renders; SeoModel factories map DTOs→SEO shape; SeoOptions holds defaults; pages fetch (already do). No responsibility crosses a boundary it does not already own — identical to the ReleaseHeroOverlay/ReleaseDescription presentational-component pattern already in the codebase.
  • OCP: a new page type or a fourth medium adds a factory method, not a new tag block. The medium switch lives next to ReleaseRoutes' existing medium switch (same "Cut is the default arm so a gap is build-visible" discipline).
  • DRY: the ~15-tag boilerplate exists once. Pages pass values; the suffix inconsistency observed today (- DeepDrft vs - Electronic Music Collective) is resolved by SeoOptions.TitleSuffix.
  • Road not taken — a server-side middleware/filter that rewrites <head>. Tempting (one place, zero component change) but it cannot see Blazor's per-page render state cleanly, fights the HeadOutlet mechanism Blazor provides for exactly this, and would be a parallel metadata path the team has to reason about separately. Rejected: use the framework's head seam, not an HTTP filter.
  • Road not taken — per-page hand-written <HeadContent> blocks (no shared component). This is just the status quo extended; it is the boilerplate-duplication C3 forbids. Rejected.

5. Render-mode correctness (the load-bearing requirement — C2)

This is the part most likely to be got subtly wrong, so it is spelled out.

The mechanism. Blazor's <HeadContent> projects child content into the <HeadOutlet> declared in App.razor. During the server prerender pass, components render to HTML server-side before WASM boots; their <HeadContent> is written into the <head> of the delivered document. A crawler fetching the page sees the fully-populated <head> in the initial HTML response — exactly what C2 requires — provided the data the head depends on is available during that prerender pass.

Why this works here (the key enabler). The release detail pages already resolve their ReleaseDto during prerender and bridge it across the WASM seam via PersistentComponentState (documented in DeepDrftPublic.Client/CLAUDE.md and the tracksview-persistent-state-seam memory). The SEO data is a projection of that same already-prerendered DTO. So SeoHead, fed by the page's ViewModel, emits correct tags during prerender with no new fetch and no new bridge — it rides the one the page already has. This is why the component must be presentational and parameter-fed (C4): it inherits the page's prerender-readiness for free.

The render-mode flags to get right:

  • <HeadOutlet> in App.razor is currently @rendermode="InteractiveAuto". The prerender of that outlet still happens server-side (Auto prerenders on the server first), so prerendered head content is emitted. Confirm during implementation that prerender is not disabled for these pages — if any SEO page were ever set prerender: false (as BatchUpload in the CMS is), its head would be empty for crawlers. None of the public SEO-target pages disable prerender today; the spec's requirement is that they must not.
  • SeoHead lives in DeepDrftPublic.Client so it participates in both the server-prerender render tree and the WASM interactive render tree (the project's "static Startup called from both Program.cs" convention guarantees identical DI in both passes). Putting it only in the server host would break the interactive pass; putting it only in the client without prerender would break crawlers.

The risk to flag — the InteractiveAuto/WASM boundary and double-render. On an Auto page the head is rendered twice: once server-side (prerender, what crawlers see) and again when the component re-renders client-side after WASM boot. Two cautions:

  1. Idempotent, identical output across passes. The tags the WASM pass produces must match the prerender pass (same canonical, same OG, same JSON-LD), or the client re-render will replace correct tags with different ones. Because the data is bridged via PersistentComponentState (not re-fetched), the two passes see the same ReleaseDto and produce identical head content — as long as the model is built from the bridged state, not a fresh client fetch. Guard the same way detail pages already guard their restore: on id/key equality, to prevent cross-item bleed when prerender and WASM-boot disagree on the current item (the documented OnParametersSetAsync rule).
  2. Canonical/og:url absolutization must not depend on a browser-only API. During server prerender there is no window.location; the absolute base must come from SeoOptions.BaseUrl (config), not from JS interop. NavigationManager is available server-side for the path; the origin comes from config (this is also why BaseUrl is OQ1 — the canonical origin is a product decision, not discoverable at prerender from the request reliably behind the nginx proxy).

Net: the approach emits correct tags server-side at prerender because it projects already-prerendered data through the framework's own head seam. The only genuinely new care points are (1) identical output across the double render, solved by feeding from bridged state, and (2) config-sourced origin for absolute URLs, solved by SeoOptions.BaseUrl.


6. Use cases

  • UC1 — A /mixes/{key} link pasted into Discord/iMessage unfurls richly. Title, description, and cover image appear in the unfurl card (OG tags present at prerender). Today: bare title + URL.
  • UC2 — Google indexes a cut with structured data. The MusicAlbum JSON-LD with its track list is eligible for rich results; canonical points at /cuts/{key} regardless of how the user arrived (alias /tracks/... route, query params).
  • UC3 — The home page presents the collective. MusicGroup JSON-LD + site-level OG so the root URL unfurls and is indexed as the band's entity.
  • UC4 — A release with no cover / no description still has valid metadata. Falls back to the config default OG image and default description; omits og:image:alt; emits valid (if leaner) tags (C6).
  • UC5 — The 404 page is not indexed. NotFound passes Robots = "noindex,follow".
  • UC6 — Twitter/X card renders large-image. summary_large_image when a cover exists, summary otherwise.

7. Open questions for Daniel (product calls, not implementation detail)

  • OQ1 — Canonical production origin (BaseUrl). What is the canonical public origin for absolute canonical/og:url/og:image URLs (e.g. https://deepdrft.com)? This must be a fixed config value — it cannot be reliably derived at prerender behind the nginx reverse proxy, and getting it wrong silently breaks every absolute URL an unfurler resolves. Also: is there a single canonical host, or do www/apex/staging variants need a canonical-host normalization rule? [Daniel decision]
  • OQ2 — Default OG image. What is the fallback share image for pages without a cover (home, about, browse, and cover-less releases)? A branded 1200×630 card is the OG standard. Is there an existing brand asset to use, or does one need to be produced? Until one exists, the component can omit og:image (degrades to a summary Twitter card) — but a default image materially improves every unfurl. [Daniel decision — and an asset to point at]
  • OQ3 — Social handles / sameAs. Does Deep DRFT have public social accounts (X/Twitter handle for twitter:site/creator, and URLs for the MusicGroup.sameAs array)? If yes, supply them for the config; if no, those tags are simply omitted (valid). [Daniel decision]
  • OQ4 — Title suffix + composition. Standardize the title pattern: recommend "{PageTitle} · Deep DRFT" (or " - Deep DRFT"), with the home page as a special case ("Deep DRFT — Electronic Music Collective"). Confirm the suffix string and separator; this resolves the existing inconsistency (- DeepDrft vs - Electronic Music Collective). [Daniel decision — low stakes, pick one]
  • OQ5 — JSON-LD: typed builder vs. passed fragment (§4.3). Recommend the typed builder (option a) for DRY + testability. Confirm, or accept the lighter passed-fragment approach if the record plumbing is judged not worth it. [Daniel decision — recommendation: typed builder]
  • OQ6 — Session schema type. Recommend modeling a Session as MusicAlbum + LiveAlbum albumProductionType (parity with cuts; the catalogue treats a session as a release, not an event). Confirm — or, if a live schedule surface is ever planned, a session might better be a MusicEvent. [Daniel decision — recommendation: MusicAlbum/LiveAlbum for now]

Adjacent but separate concerns (flagged, not specified here)

These are SEO-adjacent and worth their own small follow-ups; they are not in this component's scope:

  • robots.txt — a static file (or a minimal endpoint) at the public host root: allow crawl, point at the sitemap, and disallow the CMS host (DeepDrftManager is a separate app/host — its exclusion is a deployment/robots concern, not a CMS code change). Small, separate task.
  • sitemap.xml — a generated sitemap enumerating home/about/browse + every release detail URL. This does need a small server-side endpoint on the public host that lists releases (reusing the existing paged release read — no new data) and emits XML. A natural Phase 22.x follow-on, but a different unit of work (an endpoint, not a component). Flag for Daniel: want this folded into Phase 22 as a second wave, or tracked as its own phase?
  • CMS noindex — ensuring DeepDrftManager pages are not indexed. Out of scope for this public-site component (C1); noted so it is not forgotten. Cheapest fix is a robots disallow on the CMS host +/- a blanket noindex meta in the CMS layout — a CMS-side change for a later, separately-scoped task.

8. Acceptance criteria

  • AC1 — Tags present in prerendered HTML. curl-ing (no JS execution) any public SEO-target page returns a <head> containing title, description, canonical, the full OG block, the Twitter block, and a JSON-LD script — populated with that page's specifics. (The crawler-visibility guarantee, C2.)
  • AC2 — One component, one line per page. Each target page invokes SeoHead with a single model; no page hand-writes the tag set. Adding a hypothetical new page type requires a new factory method, not a new tag block (C3/OCP).
  • AC3 — Release pages carry correct per-medium schema. A cut → MusicAlbum with an ordered track list; a session → MusicAlbum/LiveAlbum; a mix → MusicRecording with ISO-8601 duration. Title, artist, genre, date, cover, and canonical all match the release.
  • AC4 — Graceful partial data. A release with no description/cover/genre still emits valid head content (config-default description, config-default or omitted image, omitted optional tags) — no empty content="" tags, no broken JSON-LD (C6).
  • AC5 — Structured data validates. The emitted JSON-LD passes Google's Rich Results / Schema Markup Validator for the chosen types (no errors; warnings acceptable). Pure-function builders make this a unit test plus one manual validator pass.
  • AC6 — Identical output across the double render. The head content produced by the WASM interactive pass is byte-identical to the prerender pass for the same page/item (fed from bridged PersistentComponentState, not a fresh fetch) — no client re-render clobbering correct tags (§5).
  • AC7 — Canonical correctness. Canonical and og:url are absolute (config origin + resolved path), point at the dedicated release route (not an alias/redirect path or a query-string variant), and are identical to each other.
  • AC8 — 404 is noindex. The not-found page emits robots: noindex.
  • AC9 — Zero CMS change. No file under DeepDrftManager is touched (C1).

9. Wave decomposition

Dependency shape: 22.1 → 22.2 → 22.3, with 22.4 validating. 22.1 is the cold-start prerequisite.

  • 22.1 — Core component + config + model, on the simplest pages (cold-start). Build SeoHead, SeoModel (+ the standard/OG/Twitter tag rendering), and SeoOptions (registered via the static Startup seam). Wire the static pages first — Home and About — where data is trivially available at prerender and there is no double-render subtlety beyond the baseline. Proves §5's prerender emission end to end (AC1) on the easy case. Depends on OQ1/OQ2/OQ4 (origin, default image, suffix) — these are config values it needs; can stub with placeholders and swap in Daniel's answers.
  • 22.2 — Release detail pages + per-medium JSON-LD (the rich case). Add the MusicGroup / MusicAlbum / MusicRecording builders (OQ5 recommendation: typed) and the ForRelease factory with the medium→type mapping; wire CutDetail/SessionDetail/MixDetail from their already-bridged ReleaseDto. This is where AC3/AC5/AC6/AC7 are exercised. Depends on 22.1 and on OQ5/OQ6.
  • 22.3 — Browse + 404 + remaining pages. CollectionPage for browse surfaces; noindex for 404; any remaining public routes. Depends on 22.1.
  • 22.4 — Validation pass. Exercise AC1AC9: crawler-view via no-JS fetch (AC1), Rich Results validator (AC5), double-render-identity check (AC6), canonical/alias matrix (AC7), partial-data releases (AC4). Largely test/measurement. Depends on 22.122.3.
  • (Adjacent, separate) — robots.txt + sitemap.xml. Per §7, an endpoint-shaped follow-on, not a component wave. Tracked as its own unit pending Daniel's call on folding-in vs. separate phase.

10. Cross-references (read before implementing)

  • DeepDrftPublic/Components/App.razor — the <head> block + <HeadOutlet @rendermode="InteractiveAuto"> this component feeds; the place to confirm prerender is not disabled.
  • DeepDrftPublic.Client/CLAUDE.md — the PersistentComponentState prerender-bridge convention, the OnParametersSetAsync same-template-nav rule, the static-Startup-called-from-both-Program.cs DI convention (where SeoOptions registers), and the presentational-component pattern SeoHead follows.
  • Auto-memory tracksview-persistent-state-seam — why the bridge matters for the double-render identity (AC6); the SEO model must be built from bridged state, not a fresh client fetch.
  • DeepDrftPublic.Client/Common/ReleaseRoutes.cs — the canonical-URL source for release pages; the SEO canonical reuses it (and extends the same "Cut is the default arm" medium-switch discipline).
  • DeepDrftPublic.Client/Pages/{Home,CutDetail,SessionDetail,MixDetail,About}.razor — the current bare <PageTitle> usage these pages replace; the ViewModels (ReleaseDto, HomeStatsDto) that feed the model.
  • DeepDrftPublic.Client/Controls/SharePopover.razor — already builds absolute share URLs from ReleaseRoutes; the canonical/OG URL is the same absolutization, sourced from SeoOptions.BaseUrl.
  • DeepDrftModels (ReleaseDto, TrackDto, HomeStatsDto) — the data the model projects; no new field needed (C5).
  • Google Rich Results / Schema.org MusicGroup/MusicAlbum/MusicRecording/LiveAlbum docs — the validation target (AC5) and the type vocabulary for §3.4.