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

440 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**.
### 3.1 Standard / search
| 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 |
### 3.2 Open Graph (link unfurling — Facebook/iMessage/Discord/Slack)
| 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 / home** — `MusicGroup` (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, `byArtist``MusicGroup`, `albumProductionType``StudioAlbum`, `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 `albumProductionType`
`LiveAlbum` (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.